/* ============================================================================
   Jaya Birthday Experience — design layer
   MOBILE FIRST. Phone portrait is the real product (she opens this from a QR
   code). Base styles = phone. The min-width:721px block at the bottom is a
   courtesy for laptops, not a deliverable.

   Fully offline: no webfonts, no CDN, no network calls. The only binary assets
   are the per-scene artwork files in /assets/images.

   PER-SCENE RECIPE — each scene is 5 layers on a 3D stage:
     backdrop (art + scrim) -> stars/comets -> lanterns -> content -> bokeh
   Bind a scene's artwork with:
     .scene--<name> .backdrop { background-image:url('../assets/images/<file>'); }
   ============================================================================ */

/* ---------------------------------------------------------------- tokens --- */
:root{
  --ink:            #07030d;
  --blush:          #e2739b;
  --petal:          #ffc3d6;
  --gold:           #ffdaa8;
  --cream:          #fffaf2;

  --text:           rgba(255,250,242,.97);
  --text-soft:      rgba(255,247,238,.92);
  --text-dim:       rgba(255,244,234,.70);

  --glass-line:     rgba(255,255,255,.20);
  --glass-line-hi:  rgba(255,255,255,.46);

  /* Every piece of copy carries this. The artwork behind it changes per scene,
     so a dark halo is the only reliable guarantee of legibility. */
  --halo:           0 1px 2px rgba(10,2,10,.9), 0 2px 12px rgba(10,2,10,.75);

  --f-script: 'Edwardian Script ITC','Palace Script MT','Vivaldi','Segoe Script',
              'Lucida Handwriting','Brush Script MT','Gabriola',cursive;
  --f-serif:  'Cormorant Garamond','Palatino Linotype','Book Antiqua',Palatino,
              Georgia,'Times New Roman',serif;
  --f-sans:   'Segoe UI Variable Display','Segoe UI',system-ui,-apple-system,
              'Helvetica Neue',Arial,sans-serif;

  --e-out:    cubic-bezier(.16,1,.3,1);
  --e-inout:  cubic-bezier(.65,0,.35,1);

  /* ONE height for every button that floats near the foot of a screen — the
     balloons CTA, the candle buttons, the sky CTA, the gallery CTA, the bow
     button and the consent pair all read off this. Deliberately well clear of the
     bottom edge: a phone's home-swipe area and browser chrome both live down
     there, and a button pinned at 8% was within a thumb's width of both. */
  --cta-floor: 14%;

  --cam-x: 0deg;
  --cam-y: 0deg;
  --cam-px: 0px;
  --cam-py: 0px;
}

/* ------------------------------------------------------------------ reset --- */
*,*::before,*::after{ box-sizing:border-box; }

html,body{ height:100%; margin:0; }
html{ background:var(--ink); }

body{
  font-family:var(--f-sans);
  color:var(--text);
  background:var(--ink);
  overflow:hidden;
  -webkit-font-smoothing:antialiased;
  text-rendering:optimizeLegibility;
  cursor:default;
  -webkit-tap-highlight-color:transparent;
}

button{ font:inherit; color:inherit; }
::selection{ background:rgba(226,115,155,.4); color:var(--cream); }

/* =========================================================== scene stack === */
.scene-stack{ position:fixed; inset:0; overflow:hidden; }

/* perspective lives on the scene so .stage can be the preserve-3d camera rig */
.scene{
  position:absolute;
  inset:0;
  perspective:1000px;
  perspective-origin:50% 44%;
  opacity:0;
  visibility:hidden;
  pointer-events:none;
  transition:opacity .9s var(--e-inout), visibility .9s;
}
.scene.is-active{ opacity:1; visibility:visible; pointer-events:auto; }

.scene.is-leaving{ animation:scene-out 1s var(--e-inout) forwards; }
@keyframes scene-out{
  to{ opacity:0; filter:blur(14px); transform:scale(1.12); }
}
.scene.is-entering{ animation:scene-in 1.15s var(--e-out) forwards; }
@keyframes scene-in{
  from{ opacity:0; filter:blur(18px); transform:scale(.9); }
  to  { opacity:1; filter:blur(0);    transform:scale(1);  }
}

/* ================================================================= stage === */
.stage{
  position:absolute;
  inset:0;
  transform-style:preserve-3d;
  will-change:transform;
  transform:
    translate3d(var(--cam-px),var(--cam-py),0)
    rotateX(var(--cam-x))
    rotateY(var(--cam-y));
  /* no CSS transition — the rAF loop already damps the motion */
}

/* inset:0 so percentages inside a layer are exactly viewport-relative.
   JS applies translateZ + a compensating scale, so depth never shrinks a layer. */
.layer{
  position:absolute;
  inset:0;
  transform-style:preserve-3d;
  will-change:transform;
  pointer-events:none;
}
/* No layer captures pointer events — only actual controls do (see .btn and
   .balloon). A full-viewport layer with pointer-events:auto sitting at z=0 will
   silently swallow taps meant for anything behind it on the Z axis. */

/* ============================================================== backdrop === */
/* The artwork. Oversized and Ken-Burns-scaled so neither the slow drift nor the
   camera parallax can ever expose an edge. */
.backdrop{
  position:absolute;
  inset:-5%;
  /* ### The fallback, and it is doing real work. ###
     If a backdrop never arrives — bad signal, a 404, a file renamed — this is what
     she sees instead, and it has to be the palette rather than nothing. Every scene
     is a night scene, so plain deep indigo under the scrim reads as "dark garden,
     lights not up yet" instead of a white hole where a painting should be. It is the
     difference between the experience degrading and the experience looking broken.
     Assets never treats a failed image as fatal for exactly this reason. */
  background-color:#150a20;
  background-position:center center;
  background-size:cover;
  background-repeat:no-repeat;
  transform-origin:50% 58%;      /* pivot low, toward the bench */
  animation:kenburns 34s var(--e-inout) infinite alternate;
  will-change:transform;
}
@keyframes kenburns{
  from{ transform:scale(1.05) translate3d(0,0,0);        }
  to  { transform:scale(1.14) translate3d(-1.2%,-1.6%,0); }
}

/* scene artwork */
.scene--countdown .backdrop{
  background-image:url('../assets/images/bg-countdown.webp');
}
.scene--login .backdrop{
  background-image:url('../assets/images/bg-login.webp');
}
/* the door open, the two of them walking through */
.scene--login .backdrop--through{
  background-image:url('../assets/images/bg-login-2.webp');
  opacity:0;
  /* a slow push toward the doorway instead of the usual drift, so once she is
     through, the frame is very gently still moving inward */
  animation:door-approach 40s var(--e-inout) infinite alternate;
}
@keyframes door-approach{
  from{ transform:scale(1.06) translate3d(0,0,0);     }
  to  { transform:scale(1.17) translate3d(0,-1.2%,0); }
}

/* ---------------------------------------------------------------- doorglow --- */
/* Sits over the archway. Blooms outward when she taps Open, so the light behind
   the door reads as the door giving way. */
.doorglow{
  position:absolute;
  left:50%; top:48%;
  width:78%; height:58%;
  transform:translate(-50%,-50%) scale(.7);
  border-radius:50% 50% 46% 46% / 58% 58% 42% 42%;
  background:radial-gradient(closest-side,
    rgba(255,236,206,.95) 0%,
    rgba(255,190,120,.55) 34%,
    rgba(255,160,90,.16)  62%,
    rgba(255,160,90,0)    100%);
  opacity:0;
  mix-blend-mode:screen;
  pointer-events:none;
}
/* ================================================== opening the door ===
   The one transition that has to feel expensive. Choreography:

     0ms     tap. Door glow starts swelling behind the card.
     0-700   card recedes INTO the doorway (pushed back on Z, blurred).
     0-800   full-screen flash ramps up, fast-in.
     820     artwork swapped underneath the flash at its peak — the crossfade
             itself is never visible, only its result.
     970+    flash falls away slowly, so the light lingers rather than snapping.
     1320    the ask stages in over the couple.

   Everything animates opacity and transform only. The one filter is on the card,
   which is small enough not to cost a frame.
   ======================================================================= */

/* the glow behind the door, before the flash takes over */
.scene--login.is-opening .doorglow{
  animation:door-open 1.4s cubic-bezier(.3,0,.5,1) forwards;
}
@keyframes door-open{
  0%  { opacity:0; transform:translate(-50%,-50%) scale(.6);  }
  40% { opacity:1; }
  100%{ opacity:1; transform:translate(-50%,-50%) scale(2.1); }
}

/* the scrim gets out of the way so the door's own light can carry the moment */
.scene--login.is-opening .backdrop__scrim{
  opacity:0;
  transition:opacity 1.1s var(--e-out);
}

/* the card recedes into the doorway rather than merely fading */
.scene--login.is-opening .card{
  opacity:0;
  transform:translate3d(0,-8px,-90px) scale(.92);
  filter:blur(10px);
  transition:
    opacity .72s cubic-bezier(.4,0,.7,1),
    transform .9s cubic-bezier(.32,0,.5,1),
    filter .72s ease-out;
}
/* the surrounding copy leaves slightly sooner and faster, so the card is clearly
   the thing being drawn through the door */
.scene--login.is-opening .content--login .eyebrow,
.scene--login.is-opening .content--login .greeting,
.scene--login.is-opening .content--login .lede,
.scene--login.is-opening .content--login .hint{
  opacity:0;
  transform:translate3d(0,-6px,-24px);
  transition:opacity .5s ease-out, transform .7s var(--e-out);
}
.content--login[hidden]{ display:none; }

/* ---- the whole-screen blink ---- */
/* Warm, not white — it is the light from behind the door, not a camera flash. */
.finish__flash{
  position:absolute; inset:0;
  opacity:0;
  will-change:opacity;
  background:radial-gradient(76% 56% at 50% 52%,
    rgba(255,248,232,1)   0%,
    rgba(255,226,178,.97) 30%,
    rgba(255,199,138,.9)  56%,
    rgba(255,174,108,.78) 78%,
    rgba(252,158,96,.66)  100%);
}
.scene--login.is-opening .finish__flash{
  animation:door-flash 2.15s forwards;
}
/* Deliberately asymmetric: light arrives fast and leaves slowly. A symmetric
   fade reads as a crossfade; this reads as light. */
@keyframes door-flash{
  0%   { opacity:0;    animation-timing-function:cubic-bezier(.4,0,.6,1); }
  37%  { opacity:.97;  animation-timing-function:linear; }
  46%  { opacity:.97;  animation-timing-function:cubic-bezier(.2,0,.35,1); }
  100% { opacity:0; }
}

/* ---- through the door ---- */
.scene--login.is-through .backdrop--through{
  opacity:1;
  transition:opacity .5s linear;      /* hidden under the flash's peak */
}
.scene--login.is-through .backdrop__scrim--through{
  opacity:1;
  transition:opacity 1.5s var(--e-out);
}

/* Legibility scrim. Strongest across the middle where the copy sits, and fully
   transparent by 86% so the lamp, bench and sunset at the bottom stay untouched
   — that part of the painting is the best part of it. */
/* Bleeds past the viewport exactly like .backdrop does. A scrim with inset:0 has
   a hard rectangular edge, so any mismatch in the layer's compensating scale
   shows up as a visible translucent box floating on the artwork. The bleed makes
   it immune to that and to camera drift. */
.backdrop__scrim{
  position:absolute; inset:-5%;
  background:linear-gradient(180deg,
    rgba(8,3,14,.44)  0%,
    rgba(8,3,14,.18)  12%,
    rgba(10,4,18,.44) 22%,
    rgba(10,4,18,.58) 34%,
    rgba(10,4,18,.60) 47%,
    rgba(10,4,18,.52) 60%,
    rgba(10,4,18,.28) 70%,
    rgba(10,4,18,.09) 79%,
    rgba(10,4,18,0)   87%);
}

/* ------------------------------------------------------- canvas fx layers --- */
.fx{ position:absolute; inset:0; width:100%; height:100%; display:block; }
.fx--bokeh{ filter:blur(7px); opacity:.5; }
/* petals should read as petals, so less blur and much more presence than the
   out-of-focus hearts on scene 1 */
.scene--login .fx--bokeh{ filter:blur(3px); opacity:.92; }

/* -------------------------------------------------------------- lanterns --- */
/* Only a handful, deliberately soft. The painting already has lanterns; these
   are here to make the frame feel alive, not to add new objects. They rise from
   the horizon up through the empty middle band and fade before the top clutter. */
.lantern{
  position:absolute;
  bottom:24%;
  width:var(--w,11px);
  transform-style:preserve-3d;
  animation:lantern-rise var(--dur,30s) linear var(--delay,0s) infinite;
  will-change:transform,opacity;
}
.lantern__body{
  position:relative;
  width:100%;
  aspect-ratio:.78;
  border-radius:44% 44% 38% 38% / 52% 52% 46% 46%;
  background:radial-gradient(60% 50% at 50% 78%,
    #fff3d0 0%, #ffc06a 40%, #ff8a4a 72%, #e0552f 100%);
  box-shadow:
    0 0 10px rgba(255,170,90,.9),
    0 0 28px rgba(255,140,70,.5);
  animation:lantern-sway var(--sway,5s) var(--e-inout) infinite alternate;
}
.lantern__glow{
  position:absolute; inset:-240%;
  border-radius:50%;
  background:radial-gradient(circle,rgba(255,168,92,.28) 0%,rgba(255,168,92,0) 62%);
}
@keyframes lantern-rise{
  0%   { transform:translate3d(0,0,0)              scale(.72); opacity:0;  }
  12%  { opacity:.85; }
  60%  { transform:translate3d(2.5vw,-34vh,50px)   scale(1);   opacity:.7; }
  100% { transform:translate3d(-1vw,-62vh,-30px)   scale(.6);  opacity:0;  }
}
@keyframes lantern-sway{
  from{ transform:rotate(-5deg) translateX(-3px); }
  to  { transform:rotate(5deg)  translateX(3px);  }
}

/* ============================================================== content === */
.content{
  position:absolute;
  left:50%; top:41%;
  transform:translate(-50%,-50%);
  width:min(430px,92vw);
  text-align:center;
  transform-style:preserve-3d;
}

/* Staged entrance: JS adds .is-in to [data-seq].
   Each item's resting Z depth is declared via --lift so the entrance transform
   and the depth placement never fight over the `transform` property.
   The end state uses `filter:none` (not blur(0)) so the element stops being a
   containing block and its 3D children keep their depth. */
[data-seq-item]{
  --lift:0px;
  opacity:0;
  transform:translate3d(0,24px,calc(var(--lift) - 36px));
  filter:blur(8px);
  transition:
    opacity 1.15s var(--e-out),
    transform 1.15s var(--e-out),
    filter 1.15s var(--e-out);
}
[data-seq].is-in [data-seq-item]{
  opacity:1;
  transform:translate3d(0,0,var(--lift));
  filter:none;
}

/* ---------------------------------------------------------------- eyebrow --- */
.eyebrow{
  margin:0 0 .5em;
  font-family:var(--f-sans);
  font-size:10px;
  font-weight:600;
  letter-spacing:.36em;
  text-indent:.36em;         /* balances the trailing letter-space */
  text-transform:uppercase;
  color:rgba(255,222,196,.80);
  text-shadow:var(--halo),0 0 18px rgba(255,180,140,.5);
  --lift:8px;
}

/* --------------------------------------------------------------- wordmark --- */
.wordmark{
  position:relative;
  margin:0 0 .04em;
  font-family:var(--f-script);
  font-weight:400;
  font-size:clamp(72px,23vw,120px);
  line-height:1.02;
  letter-spacing:.01em;
  --lift:44px;
}

/* The gradient text-fill sits on the inner span, NOT on the <h1>, because the
   <h1> is a [data-seq-item] and owns `filter` for the entrance choreography.
   Stops start at 26% and finish by 96%: a script face leaves large empty
   ascender/descender space, so a gradient spread evenly over the full line box
   made the glyphs sample only dull mid-tones. The range is weighted so
   white/cream lands on the letter bodies. */
.wordmark__text{
  position:relative;
  display:inline-block;
  color:transparent;
  background:linear-gradient(176deg,
    #ffffff 26%,
    #fff6ea 37%,
    #ffe6f1 47%,
    #ffbdd5 58%,
    #f194b9 68%,
    #dd77a4 78%,
    #c0648f 88%,
    #a5557e 96%);
  -webkit-background-clip:text;
  background-clip:text;
}

/* blurred duplicate behind the name = volumetric bloom */
.wordmark__glow{
  position:absolute;
  left:0; right:0; top:0;
  z-index:-2;
  color:rgba(255,178,210,.7);
  filter:blur(22px);
  animation:name-pulse 6.5s var(--e-inout) infinite;
  pointer-events:none;
  user-select:none;
}
/* a tighter, hotter second bloom for the core light */
.wordmark::before{
  content:'';
  position:absolute;
  left:8%; right:8%; top:24%; bottom:22%;
  z-index:-3;
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,190,220,.28) 0%,
    rgba(226,115,155,.14) 46%,
    rgba(226,115,155,0)   100%);
  filter:blur(22px);
  pointer-events:none;
}
@keyframes name-pulse{
  0%,100%{ opacity:.44; transform:scale(1);     }
  50%    { opacity:.82; transform:scale(1.035); }
}

/* a specular highlight that sweeps across the letters — the "premium" tell */
.wordmark__sheen{
  position:absolute;
  left:0; right:0; top:0;
  color:transparent;
  background:linear-gradient(102deg,
    rgba(255,255,255,0)   38%,
    rgba(255,255,255,.55) 47%,
    rgba(255,255,255,.95) 50%,
    rgba(255,255,255,.55) 53%,
    rgba(255,255,255,0)   62%);
  background-size:240% 100%;
  background-repeat:no-repeat;
  -webkit-background-clip:text;
  background-clip:text;
  animation:name-sheen 8s var(--e-inout) infinite;
  pointer-events:none;
  user-select:none;
}
@keyframes name-sheen{
  0%,14%  { background-position:190% 0; }
  56%,100%{ background-position:-90% 0; }
}

/* ------------------------------------------------------------------- lede --- */
.lede{
  margin:.24em auto 0;
  max-width:19em;
  font-family:var(--f-serif);
  font-size:clamp(15px,4.4vw,19px);
  font-weight:500;
  font-style:italic;
  line-height:1.54;
  letter-spacing:.014em;
  color:var(--text-soft);
  text-shadow:var(--halo),0 0 30px rgba(226,115,155,.26);
  --lift:24px;
}
.lede[hidden]{ display:none; }
.lede--soft{
  margin-top:.34em;
  color:var(--text-dim);
  font-size:clamp(13px,3.7vw,16px);
  font-weight:400;
  --lift:20px;
}

.heart{
  display:inline-block;
  color:var(--blush);
  text-shadow:0 0 12px rgba(226,115,155,.95),0 0 28px rgba(226,115,155,.5);
  animation:heartbeat 2.1s var(--e-inout) infinite;
}
@keyframes heartbeat{
  0%,100%{ transform:scale(1);    }
  14%    { transform:scale(1.24); }
  28%    { transform:scale(1);    }
  42%    { transform:scale(1.14); }
}

.spark{
  display:inline-block;
  color:var(--gold);
  text-shadow:0 0 12px rgba(255,218,168,.9);
  animation:sparkle 3.4s var(--e-inout) infinite;
}
@keyframes sparkle{
  0%,100%{ opacity:.5; transform:scale(.86) rotate(0deg);   }
  50%    { opacity:1;  transform:scale(1.1) rotate(180deg); }
}

/* ============================================================== the clock === */
/* 2x2 on a phone. Four across is far too cramped at 375px. */
.clock{
  display:grid;
  grid-template-columns:repeat(2,minmax(0,1fr));
  gap:11px;
  max-width:290px;
  margin:24px auto 20px;
  transform-style:preserve-3d;
  perspective:620px;
  --lift:0px;
}
.clock[hidden]{ display:none; }

.clock__tile{
  position:relative;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:.44em;
  padding:14px 6px 11px;
  border-radius:18px;
  transform-style:preserve-3d;
  transition:transform .6s var(--e-out);
  isolation:isolate;
}
/* fanned on both axes so the 2x2 block reads as a curved 3D panel */
.clock__tile:nth-child(1){ transform:translateZ(22px) rotateY( 7deg) rotateX( 3deg); }
.clock__tile:nth-child(2){ transform:translateZ(22px) rotateY(-7deg) rotateX( 3deg); }
.clock__tile:nth-child(3){ transform:translateZ(22px) rotateY( 7deg) rotateX(-3deg); }
.clock__tile:nth-child(4){ transform:translateZ(22px) rotateY(-7deg) rotateX(-3deg); }

/* the glass panel, kept as its own element so backdrop-filter never blurs text */
.clock__glass{
  position:absolute; inset:0;
  z-index:-1;
  border-radius:inherit;
  background:linear-gradient(158deg,
    rgba(255,255,255,.17) 0%,
    rgba(255,255,255,.06) 40%,
    rgba(255,255,255,.02) 100%);
  border:1px solid var(--glass-line);
  backdrop-filter:blur(18px) saturate(160%);
  -webkit-backdrop-filter:blur(18px) saturate(160%);
  box-shadow:
    inset 0 1px 0 var(--glass-line-hi),
    inset 0 -20px 34px rgba(138,32,73,.26),
    0 18px 40px rgba(0,0,0,.5),
    0 0 0 1px rgba(0,0,0,.16);
  overflow:hidden;
}
/* slow specular sweep across the glass */
.clock__glass::after{
  content:'';
  position:absolute;
  top:-60%; left:-140%;
  width:70%; height:260%;
  background:linear-gradient(100deg,
    rgba(255,255,255,0) 0%,
    rgba(255,255,255,.24) 48%,
    rgba(255,255,255,0) 100%);
  transform:rotate(14deg);
  animation:glass-sweep 9s var(--e-inout) infinite;
}
@keyframes glass-sweep{
  0%,64%{ left:-140%; }
  100%  { left:190%;  }
}
.clock__tile:nth-child(2) .clock__glass::after{ animation-delay:1.1s; }
.clock__tile:nth-child(3) .clock__glass::after{ animation-delay:2.2s; }
.clock__tile:nth-child(4) .clock__glass::after{ animation-delay:3.3s; }

.clock__value{
  font-family:var(--f-serif);
  font-size:clamp(30px,9vw,40px);
  font-weight:600;
  line-height:1;
  font-variant-numeric:tabular-nums;
  color:#fffdf8;
  text-shadow:
    0 1px 1px rgba(30,6,22,.6),
    0 0 16px rgba(255,232,208,.6),
    0 0 42px rgba(255,196,150,.42),
    0 0 68px rgba(226,115,155,.36);
  transform:translateZ(14px);
}

.clock__label{
  font-family:var(--f-sans);
  font-size:8px;
  font-weight:700;
  letter-spacing:.2em;
  text-indent:.2em;
  text-transform:uppercase;
  color:rgba(255,226,204,.66);
  text-shadow:0 0 12px rgba(255,180,140,.4);
  transform:translateZ(10px);
}

/* digit change: the tile rolls on X and the glow flares */
.clock__value.is-rolling{ animation:digit-roll .58s var(--e-out); }
@keyframes digit-roll{
  0%  { transform:translateZ(14px) translateY(0)    rotateX(0deg);   opacity:1; filter:blur(0);   }
  38% { transform:translateZ(14px) translateY(-46%) rotateX(78deg);  opacity:0; filter:blur(5px); }
  39% { transform:translateZ(14px) translateY(46%)  rotateX(-78deg); opacity:0; filter:blur(5px); }
  100%{ transform:translateZ(14px) translateY(0)    rotateX(0deg);   opacity:1; filter:blur(0);   }
}
.clock__tile.is-ticking .clock__glass{
  box-shadow:
    inset 0 1px 0 var(--glass-line-hi),
    inset 0 -20px 38px rgba(226,115,155,.4),
    0 18px 44px rgba(0,0,0,.5),
    0 0 30px rgba(226,115,155,.4);
  transition:box-shadow .35s var(--e-out);
}

/* --------------------------------------------------------------- datestamp --- */
.datestamp{
  display:flex;
  align-items:center;
  justify-content:center;
  gap:12px;
  margin:0 0 14px;
  --lift:20px;
}
.datestamp__text{
  font-family:var(--f-serif);
  font-size:15px;
  font-weight:600;
  font-style:italic;
  letter-spacing:.14em;
  text-indent:.14em;
  color:var(--gold);
  text-shadow:var(--halo),0 0 20px rgba(255,218,168,.55);
  white-space:nowrap;
}
.datestamp[hidden]{ display:none; }
.datestamp__text sup{ font-size:.56em; letter-spacing:0; }
.datestamp__rule{
  height:1px;
  width:34px;
  background:linear-gradient(90deg,rgba(255,218,168,0),rgba(255,218,168,.7));
}
.datestamp__rule:last-child{
  background:linear-gradient(90deg,rgba(255,218,168,.7),rgba(255,218,168,0));
}

/* -------------------------------------------------------------------- hint --- */
.hint{
  margin:0 auto;
  max-width:24em;
  font-family:var(--f-sans);
  font-size:11px;
  font-weight:400;
  letter-spacing:.1em;
  text-indent:.1em;
  color:rgba(255,240,228,.62);
  /* heaviest halo of any copy — it sits lowest, closest to the warm sunset */
  text-shadow:
    0 1px 2px rgba(10,2,10,.95),
    0 2px 10px rgba(10,2,10,.85),
    0 0 24px rgba(10,2,10,.7);
  --lift:12px;
}
.hint[hidden]{ display:none; }
/* the breathe only starts once the entrance has settled, so it cannot fight
   the entrance opacity transition */
[data-seq].is-in .hint{
  animation:hint-breathe 5s var(--e-inout) 1.9s infinite;
}
@keyframes hint-breathe{
  0%,100%{ opacity:.78; }
  50%    { opacity:1;   }
}

/* ---------------------------------------------------------- clock caption --- */
/* Only shown during her birthday, when the clock is measuring the day itself
   rather than counting down to it. */
.clocknote{
  margin:-8px 0 14px;
  font-family:var(--f-sans);
  font-size:9px;
  font-weight:600;
  letter-spacing:.24em;
  text-indent:.24em;
  text-transform:uppercase;
  color:rgba(255,226,204,.56);
  text-shadow:var(--halo);
}
.clocknote[hidden]{ display:none; }

/* ====================================================== SCENE 2 — LOGIN === */

/* the copy block is taller here, so it sits a little higher in the doorway */
.content--login{ top:47%; width:min(360px,88vw); }

/* ### The footer line was unreadable on this screen. ###
   `.hint`'s default is an 11px cream at 62% opacity, tuned for the countdown,
   where it sits over deep indigo. On the door it sits over the brightest part of
   the whole painting — warm light spilling out past her — and pale low-opacity
   text over pale bright light is invisible no matter how heavy the halo is.
   Two fixes, and it needs both: raise the ink itself, AND put a feathered dark
   plate behind it. The plate is the reliable half — it does not care what the
   artwork is doing underneath.
   The plate is a soft radial with no hard edge anywhere. A rectangle with an
   `inset` would read as a grey box lying on the painting, which is the trap
   `inset:-5%` exists for on the bigger layers. */
.scene--login .hint{
  position:relative;
  font-size:12px;
  font-weight:500;
  color:rgba(255,248,238,.96);
  text-shadow:
    0 1px 2px rgba(12,3,12,1),
    0 2px 8px rgba(12,3,12,.95),
    0 0 22px rgba(12,3,12,.85);
}
.scene--login .hint::before{
  content:'';
  position:absolute;
  left:-8%; right:-8%; top:-70%; bottom:-70%;
  z-index:-1;
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(10,3,14,.62) 0%,
    rgba(10,3,14,.34) 52%,
    rgba(10,3,14,0)  100%);
  filter:blur(7px);
  pointer-events:none;
}

/* --------------------------------------------------------------- greeting --- */
.greeting{
  margin:0 0 .1em;
  font-family:var(--f-serif);
  font-weight:500;
  font-size:clamp(26px,7.6vw,34px);
  font-style:italic;
  line-height:1.12;
  color:var(--text);
  text-shadow:var(--halo);
  --lift:34px;
}
.greeting__hello{
  display:block;
  font-size:.62em;
  font-style:normal;
  font-weight:400;
  letter-spacing:.05em;
  color:var(--text-dim);
}
/* ### HER NAME, TREATMENT 2 OF 4 — GOLD LEAF. ###
   Every screen that says "Jaya" says it differently on purpose, so the name
   never becomes furniture:
     1 countdown — pearl-white into rose, a wide bloom, a slow specular sweep
     2 login     — GOLD LEAF, lit from behind the door                (this one)
     3 balloons  — struck rose-gold foil with a hard highlight band
     4 candles   — incandescent: no gradient at all, it emits light
   Gold here because this is the doorway scene and the whole frame is warm amber
   light pouring past her — a rose name would have sat in it unnoticed. */
.greeting__name{
  position:relative;
  display:inline-block;
  font-family:var(--f-script);
  font-style:normal;
  font-size:2.15em;
  line-height:1.06;
}
/* a soft warm core behind the letters, so the gold has something to sit in */
.greeting__name::before{
  content:'';
  position:absolute;
  left:2%; right:2%; top:20%; bottom:18%;
  z-index:-2;
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,214,150,.30) 0%,
    rgba(255,168,110,.15) 48%,
    rgba(255,150,90,0)   100%);
  filter:blur(20px);
  pointer-events:none;
}
.greeting__text{
  position:relative;
  color:transparent;
  /* Weighted so the cream and champagne land on the letter BODIES — a script
     face leaves huge empty ascender and descender space, and a gradient spread
     evenly over the full line box samples only the dull middle. */
  background:linear-gradient(174deg,
    #fffdf4 24%,
    #fff3d2 37%,
    #ffe2a8 48%,
    #f8ca79 60%,
    #e5ac59 72%,
    #cd9040 85%,
    #b0762f 96%);
  -webkit-background-clip:text;
  background-clip:text;

  /* ### THIS ONE LINE IS THE FIX FOR THE BLACK NAME. ###
     `.greeting` sets text-shadow:var(--halo) and this span inherits it. On text
     that is color:transparent with background-clip:text, the glyphs paint no ink
     of their own — so the ONLY thing rendering was a near-black halo in the exact
     shape of her name. That is why it looked black on the door, and it is why the
     countdown wordmark never did: .wordmark sets no text-shadow at all.
     Rule for anywhere else in this project: gradient-clipped text must never
     inherit a dark halo. The blurred duplicate behind it is what carries
     legibility instead. */
  text-shadow:none;
}
.greeting__glow{
  position:absolute;
  left:0; right:0; top:0;
  z-index:-1;
  /* amber rather than pink — this is lamplight through a doorway */
  color:rgba(255,198,128,.7);
  filter:blur(18px);
  animation:name-pulse 6.5s var(--e-inout) infinite;
  pointer-events:none;
  user-select:none;
}

/* ------------------------------------------------------------------- card --- */
.card{
  position:relative;
  margin:20px 0 18px;
  padding:20px 18px 18px;
  border-radius:22px;
  text-align:left;
  isolation:isolate;
  --lift:28px;
}
/* glass on its own element so backdrop-filter never blurs the text */
.card__glass{
  position:absolute; inset:0;
  z-index:-1;
  border-radius:inherit;
  background:linear-gradient(158deg,
    rgba(255,255,255,.15) 0%,
    rgba(255,255,255,.05) 42%,
    rgba(255,255,255,.02) 100%);
  border:1px solid var(--glass-line);
  backdrop-filter:blur(20px) saturate(150%);
  -webkit-backdrop-filter:blur(20px) saturate(150%);
  box-shadow:
    inset 0 1px 0 var(--glass-line-hi),
    inset 0 -26px 44px rgba(138,32,73,.22),
    0 22px 50px rgba(0,0,0,.55);
  overflow:hidden;
}
/* the same slow specular sweep the clock tiles use, so they read as one family */
.card__glass::after{
  content:'';
  position:absolute;
  top:-60%; left:-140%;
  width:56%; height:260%;
  background:linear-gradient(100deg,
    rgba(255,255,255,0) 0%,
    rgba(255,255,255,.18) 48%,
    rgba(255,255,255,0) 100%);
  transform:rotate(14deg);
  animation:glass-sweep 11s var(--e-inout) 2s infinite;
}

/* ------------------------------------------------------------------ field --- */
/* Real <input> elements since 15 Sept 2026 — she types her name and the secret
   word. These were spans for months precisely because a phone keyboard resizes the
   visual viewport and would collapse a fixed full-screen 3D stage. That risk has
   not gone away; it is paid for by `interactive-widget=resizes-visual` on the
   viewport meta in <head>, which tells the browser to shrink only the visual
   viewport and scroll the field into view. If the login ever starts jumping or
   re-laying-out when the keyboard opens, that meta tag is the first thing to check. */
.field{ display:block; margin-bottom:14px; }

/* ### pointer-events:auto is what makes typing possible at all. ###
   `.layer` is pointer-events:none everywhere in this project, so that a full-viewport
   canvas cannot swallow taps meant for something behind it on the Z axis — and every
   interactive thing has to opt back IN. `.btn`, `.balloon`, `.print` and
   `.letter__scroll` all do.

   These fields did not, and could not have needed to before: they were spans until
   15 Sept 2026, and a span has nothing to receive. As real inputs they were
   completely unreachable — no tap could land on them, so they could never take focus,
   so nothing could be typed into either one. The symptom is "the login form does not
   work", and nothing about it looks broken.

   It goes on `.field__box` rather than on the input, because pointer-events is
   INHERITED: one declaration covers the input, the icon and the show/hide heart. The
   label needs its own, so that tapping it still focuses its field the way a
   <label for> should. */
.field__box{ pointer-events:auto; }
.field__label{ pointer-events:auto; }

.field__label{
  display:block;
  margin-bottom:6px;
  font-family:var(--f-sans);
  font-size:10px;
  font-weight:600;
  letter-spacing:.14em;
  text-indent:.14em;
  text-transform:uppercase;
  color:rgba(255,226,204,.62);
  text-shadow:var(--halo);
}
.field__box{
  display:flex;
  align-items:center;
  gap:10px;
  padding:11px 13px;
  border-radius:13px;
  background:rgba(10,4,16,.34);
  border:1px solid rgba(255,255,255,.14);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.09),
    inset 0 6px 14px rgba(0,0,0,.3);
}
.field__icon{
  flex:0 0 auto;
  width:15px; height:15px;
  fill:none;
  stroke:rgba(255,206,180,.62);
  stroke-width:1.7;
  stroke-linecap:round;
}
/* What she types. Styled to look like the old .field__value it replaced, so the
   card reads exactly as it did — the same italic serif in the same cream.
   font-size MUST stay at 16px or above: iOS Safari zooms the whole page in on
   focus for anything smaller, and on a fixed full-screen 3D scene that is not
   something the page recovers from gracefully. */
.field__input{
  flex:1 1 auto;
  min-width:0;
  padding:0;
  border:0;
  background:none;
  appearance:none;
  -webkit-appearance:none;
  font-family:var(--f-serif);
  font-size:16px;
  font-style:italic;
  letter-spacing:.01em;
  color:var(--cream);
  text-shadow:0 0 16px rgba(255,214,180,.35);
  caret-color:var(--blush);
}
.field__input::placeholder{
  color:rgba(255,236,220,.34);
  font-style:italic;
}
.field__input:focus{ outline:none; }
/* the box lights up instead, so focus reads as warmth rather than a browser ring */
.field__box:focus-within{
  border-color:rgba(255,206,168,.44);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.12),
    inset 0 6px 14px rgba(0,0,0,.3),
    0 0 0 3px rgba(255,180,140,.13);
}
/* a shake, once, when the word is wrong — the only "error" styling on the site */
.card.is-wrong{ animation:card-shake 460ms var(--e-inout); }
@keyframes card-shake{
  0%,100%{ transform:translateX(0);    }
  18%    { transform:translateX(-7px); }
  38%    { transform:translateX(6px);  }
  58%    { transform:translateX(-4px); }
  78%    { transform:translateX(2px);  }
}

/* ---- show / hide, as a heart ----
   Whole while the word is still a secret; BROKEN once it is out in the open. Which
   is both the nicer joke and the clearer signal — a crossed-out eye says nothing
   about what is at stake, and a heart coming apart says "now everyone can see it".

   The broken version is two half-paths that share the identical zigzag inner edge,
   so together they tile the whole heart exactly. That means the crack is a real
   seam between two shapes rather than a line drawn on top of one — which is what
   lets it sit on any background without needing to match a colour. The halves then
   lean apart, so it reads as breaking rather than as a heart with a scratch. */
.field__eye{
  position:relative;
  flex:0 0 auto;
  display:flex;
  align-items:center;
  justify-content:center;
  width:26px; height:26px;
  margin:-4px -3px -4px 0;
  padding:0;
  border:0;
  border-radius:50%;
  background:none;
  appearance:none;
  -webkit-appearance:none;
  cursor:pointer;
  touch-action:manipulation;
  transition:transform .3s var(--e-out);
}
/* a proper thumb target around a 26px icon */
.field__eye::after{
  content:'';
  position:absolute;
  inset:-10px;
  border-radius:50%;
}
.field__eye:active{ transform:scale(.9); }
.field__eye:focus-visible{ outline:2px solid rgba(255,214,180,.9); outline-offset:2px; }

.field__eyeWhole,
.field__eyeBroken{
  position:absolute;
  width:17px; height:17px;
  fill:rgba(255,150,180,.82);
  filter:drop-shadow(0 0 5px rgba(255,90,130,.5));
  transition:opacity .28s var(--e-out), transform .34s var(--e-out);
}
/* hidden word -> the heart is whole and closed */
.field__eyeBroken{ opacity:0; transform:scale(.72); }
/* revealed word -> it comes apart */
.field__eye.is-shown .field__eyeWhole{ opacity:0; transform:scale(.72); }
.field__eye.is-shown .field__eyeBroken{ opacity:1; transform:scale(1); }

.field__eyeHalf{ transition:transform .34s var(--e-out); }
.field__eye.is-shown .field__eyeHalf--l{ transform:translate(-.9px,.35px) rotate(-9deg); }
.field__eye.is-shown .field__eyeHalf--r{ transform:translate(.9px,.35px) rotate(9deg); }

/* ---- what it says when she gets it wrong ---- */
.card__error{
  margin:12px 0 0;
  font-family:var(--f-sans);
  font-size:11px;
  font-weight:500;
  letter-spacing:.06em;
  line-height:1.5;
  text-align:center;
  color:rgba(255,186,196,.94);
  text-shadow:var(--halo);
  opacity:0;
  transition:opacity .4s var(--e-out);
}
.card__error[hidden]{ display:none; }
.card__error.is-in{ opacity:1; }

/* the nudge, once she has missed a couple of times. Outside the card, below it. */
.card__hint{
  margin:12px auto 0;
  max-width:22em;
  font-family:var(--f-sans);
  font-size:11px;
  font-style:italic;
  line-height:1.6;
  text-align:center;
  color:rgba(255,232,206,.8);
  text-shadow:var(--halo);
  opacity:0;
  transition:opacity .6s var(--e-out);
}
.card__hint[hidden]{ display:none; }
.card__hint.is-in{ opacity:1; }

/* Centred pill, not a full-width block. It used to stretch edge to edge inside
   the card, which made it the one button on the site with a different shape from
   all the others — and the card is the first button she ever taps, so that is
   precisely the wrong place to set a different expectation. Size comes from .btn. */
/* ### The selector needs TWO classes. Do not simplify it back to `.card__go`. ###
   .btn is inline-flex, and auto margins do nothing on an inline-level box, so this
   has to force block-level for `margin:auto` to centre it — the card is
   text-align:left and there is nothing else that would.

   But `.btn{ display:inline-flex }` lives at line ~3221 and `.card__go` was here at
   ~1095: equal specificity, .btn 2000 lines later, so .btn won and this button sat
   hard against the left edge of the card from 14 Sept (when .btn was unified) until
   15 Sept. It is the same specificity trap as the .content--* overrides in §7 of
   Technical_UI_LAYER.md, in a new disguise — and the fix here is SPECIFICITY rather
   than moving the rule further down the file, because file order is exactly what
   made it fragile the first time. `.card .card__go` is (0,2,0) and cannot lose to
   `.btn` wherever either one ends up. */
.card .card__go{
  display:flex;
  width:max-content;
  margin:18px auto 2px;
}

/* ================================================== SCENE 3 — BALLOONS === */

.scene--balloons .backdrop{
  background-image:url('../assets/images/bg-balloons.webp');
}
/* The painting's own middle is already deep and dark, so the scrim barely needs
   to touch it. Its real job here is settling the bright stone floor so the
   continue button reads and the eye stays up in the courtyard. */
.scene--balloons .backdrop__scrim{
  background:linear-gradient(180deg,
    rgba(8,3,14,.34)  0%,
    rgba(10,4,18,.20) 16%,
    rgba(10,4,18,.16) 40%,
    rgba(10,4,18,.22) 58%,
    rgba(10,4,18,.44) 74%,
    rgba(8,3,14,.66)  88%,
    rgba(8,3,14,.74)  100%);
}
/* fireflies carry their own glow, so almost no blur — blurring them just makes
   them muddy rather than luminous */
.scene--balloons .fx--bokeh{ filter:blur(.6px); opacity:1; }

/* --------------------------------------------------------------- balloons --- */
.layer--balloons{ pointer-events:none; }

.balloon{
  position:absolute;
  left:var(--bx);
  top:var(--by);
  width:clamp(56px,15.5vw,74px);
  padding:0;
  border:0;
  background:none;
  cursor:pointer;
  pointer-events:auto;
  transform:translate(-50%,-50%);
  /* deliberately NOT preserve-3d: it has no 3D children, and a 3D context nested
     inside the stage's preserve-3d chain makes hit testing unreliable in Chrome */
  -webkit-tap-highlight-color:transparent;
  animation:balloon-bob var(--bob,16s) ease-in-out var(--bobd,0s) infinite;
  will-change:transform;
}
/* generous invisible hit area — a 60px balloon is a small target for a thumb */
.balloon::after{
  content:'';
  position:absolute;
  inset:-16% -22% -10%;
}

.balloon__body{
  position:relative;
  display:block;
  width:100%;
  aspect-ratio:.86;
  /* the classic balloon silhouette: rounder at the top, tapering to the knot */
  border-radius:50% 50% 48% 48% / 42% 42% 58% 58%;
  background:
    radial-gradient(38% 30% at 32% 24%,
      rgba(255,255,255,.92) 0%, rgba(255,255,255,.25) 34%, rgba(255,255,255,0) 60%),
    radial-gradient(120% 110% at 34% 26%,
      var(--b1) 0%, var(--b2) 46%, var(--b3) 100%);
  box-shadow:
    inset -8px -12px 26px rgba(40,0,20,.42),
    inset 4px 6px 14px rgba(255,255,255,.18),
    0 14px 34px rgba(0,0,0,.42),
    0 0 34px var(--bglow);
}
/* a second tight specular, so it reads as latex rather than a flat oval */
.balloon__shine{
  position:absolute;
  left:22%; top:14%;
  width:20%; height:15%;
  border-radius:50%;
  background:radial-gradient(circle,rgba(255,255,255,.95) 0%,rgba(255,255,255,0) 70%);
  filter:blur(1px);
}
.balloon__knot{
  position:absolute;
  left:50%; bottom:-5%;
  width:14%; aspect-ratio:1;
  transform:translateX(-50%) rotate(45deg);
  border-radius:22% 40% 22% 40%;
  background:var(--b3);
  box-shadow:0 2px 5px rgba(0,0,0,.4);
}
.balloon__string{
  position:absolute;
  left:50%; top:100%;
  width:34%;
  height:auto;
  transform:translateX(-50%);
  overflow:visible;
}
.balloon__string path{
  fill:none;
  stroke:rgba(255,232,210,.5);
  stroke-width:1.4;
  stroke-linecap:round;
  filter:drop-shadow(0 0 3px rgba(255,200,150,.5));
}

/* the four colourways */
.balloon[data-hue="rose"]  { --b1:#ff9dc0; --b2:#e2588c; --b3:#a02864; --bglow:rgba(226,88,140,.5); }
.balloon[data-hue="amber"] { --b1:#ffd79b; --b2:#ffa854; --b3:#d9702c; --bglow:rgba(255,168,84,.5); }
.balloon[data-hue="violet"]{ --b1:#d6b2f8; --b2:#a670e0; --b3:#6a3ca6; --bglow:rgba(166,112,224,.5); }
.balloon[data-hue="blush"] { --b1:#fff0f5; --b2:#ffa8c4; --b3:#e0789e; --bglow:rgba(255,168,192,.5); }

/* A wandering drift rather than a metronome bob. Six stops over 15–20s, run
   `infinite` (not `alternate`) so it never retraces its own path — that is what
   makes it read as floating on air instead of bouncing on a spring. */
@keyframes balloon-bob{
  0%   { transform:translate(-50%,-50%) translate(0,0)          rotate(-3deg);   }
  18%  { transform:translate(-50%,-50%) translate(7px,-11px)    rotate(2.5deg);  }
  36%  { transform:translate(-50%,-50%) translate(-3px,-19px)   rotate(4.5deg);  }
  54%  { transform:translate(-50%,-50%) translate(-10px,-9px)   rotate(-1.5deg); }
  72%  { transform:translate(-50%,-50%) translate(-5px,6px)     rotate(-4.5deg); }
  88%  { transform:translate(-50%,-50%) translate(5px,4px)      rotate(-1deg);   }
  100% { transform:translate(-50%,-50%) translate(0,0)          rotate(-3deg);   }
}

/* hover/press: it strains against the string before it goes */
@media (hover:hover){
  .balloon:hover .balloon__body{ filter:brightness(1.12); }
}
.balloon:active .balloon__body{ transform:scale(.94); }
.balloon:focus-visible{ outline:2px solid rgba(255,214,180,.9); outline-offset:6px; border-radius:50%; }

/* ---- the pop ----
   Squash and stretch first, THEN blow out. Popping straight from rest reads as
   a disappearance; the anticipation is what makes it read as a burst. */
/* The bob deliberately keeps running. Killing it here would snap the balloon
   back to its rest position for one frame before it bursts. */
.balloon.is-popped{ pointer-events:none; }
.balloon.is-popped .balloon__body{
  animation:balloon-pop .42s cubic-bezier(.3,0,.6,1) forwards;
}
@keyframes balloon-pop{
  0%  { transform:scale(1)             ; opacity:1; filter:brightness(1);   }
  26% { transform:scale(1.22,.86)      ; opacity:1; filter:brightness(1.5); }
  44% { transform:scale(.92,1.2)       ; opacity:1; filter:brightness(1.9); }
  100%{ transform:scale(1.75)          ; opacity:0; filter:brightness(2.6) blur(9px); }
}
.balloon.is-popped .balloon__knot,
.balloon.is-popped .balloon__shine{
  opacity:0;
  transition:opacity .18s linear;
}
/* the string goes slack and falls away */
.balloon.is-popped .balloon__string{
  animation:string-fall 1.1s cubic-bezier(.4,0,.7,1) .1s forwards;
}
@keyframes string-fall{
  0%  { opacity:.6; transform:translateX(-50%) translateY(0)    rotate(0deg);  }
  100%{ opacity:0;  transform:translateX(-50%) translateY(64px) rotate(11deg); }
}

/* ============================================================== the wish ===
   The main wish of the whole thing, so it gets the fullest typographic
   treatment in the project: gold-foil "Happy Birthday" with its own sheen, the
   script name with a double bloom and a second sheen, and starlight around the
   finished block. Four parts, revealed one per pop, always in reading order. */
.wish{
  position:absolute;
  left:50%; top:40%;
  transform:translate(-50%,-50%);
  width:min(420px,92vw);
  text-align:center;
  pointer-events:none;
}

/* each part waits its turn, then rises out of the dark */
.wish [data-part]{
  opacity:0;
  transform:translate3d(0,18px,-46px) scale(.9);
  filter:blur(11px);
  transition:
    opacity 1s var(--e-out),
    transform 1.15s cubic-bezier(.16,1.24,.3,1),
    filter 1s var(--e-out);
}
.wish [data-part].is-shown{
  opacity:1;
  transform:translate3d(0,0,var(--lift,18px)) scale(1);
  filter:none;
}

/* ---- 1. the date, in gold, between hairlines ---- */
.wish__date{
  display:flex;
  align-items:center;
  justify-content:center;
  gap:11px;
  margin:0 0 1.15em;
  --lift:10px;
}
.wish__dateText{
  font-family:var(--f-sans);
  font-size:9px;
  font-weight:600;
  letter-spacing:.34em;
  text-indent:.34em;
  text-transform:uppercase;
  white-space:nowrap;
  color:rgba(255,226,178,.9);
  text-shadow:var(--halo),0 0 18px rgba(255,196,120,.55);
}
.wish__rule{
  flex:1 1 auto;
  max-width:46px;
  height:1px;
  background:linear-gradient(90deg,rgba(255,214,150,0),rgba(255,214,150,.75));
}
.wish__rule:last-child{
  background:linear-gradient(90deg,rgba(255,214,150,.75),rgba(255,214,150,0));
}

/* ---- 2. "Happy Birthday" in gold foil ---- */
.wish__hb{
  position:relative;
  margin:0;
  font-family:var(--f-serif);
  font-size:clamp(20px,5.9vw,26px);
  font-weight:600;
  letter-spacing:.2em;
  text-indent:.2em;
  text-transform:uppercase;
  line-height:1.2;
  --lift:22px;
}
.wish__hbText{
  color:transparent;
  background:linear-gradient(178deg,
    #fffdf6 6%, #fff3d4 28%, #ffe1a6 48%, #f6c877 68%, #dfa953 86%, #c98f3f 100%);
  -webkit-background-clip:text;
  background-clip:text;
  text-shadow:none;
}
/* a light sweep across the foil, offset from the name's so the two never flash
   at the same moment */
.wish__hbSheen{
  position:absolute;
  left:0; right:0; top:0;
  color:transparent;
  background:linear-gradient(104deg,
    rgba(255,255,255,0) 40%,
    rgba(255,255,255,.9) 50%,
    rgba(255,255,255,0) 60%);
  background-size:250% 100%;
  background-repeat:no-repeat;
  -webkit-background-clip:text;
  background-clip:text;
  animation:name-sheen 9s var(--e-inout) 1.2s infinite;
  user-select:none;
}

/* ---- 3. her name ---- */
.wish__name{
  position:relative;
  margin:.04em 0 0;
  font-family:var(--f-script);
  font-weight:400;
  font-size:clamp(76px,24vw,126px);
  line-height:1.02;
  letter-spacing:.01em;
  --lift:48px;
}
/* ### HER NAME, TREATMENT 3 OF 4 — STRUCK ROSE-GOLD FOIL. ###
   This is the one she assembles herself, so it should read as the most physical
   of the four: a name pressed into foil rather than printed in light.

   The trick is the ramp. A single light-to-dark gradient reads as PAPER. Metal
   needs the light to come back: bright, then a dark strike line, then bright
   again, then dark. Eyes read that double reversal as a curved reflective
   surface, and it is the whole difference between this and the countdown's
   wordmark, which is deliberately the smooth pearl-to-rose version.
   The stops stay weighted toward the middle of the box for the same reason as
   everywhere else — a script face wastes most of its line box on ascenders. */
.wish__nameText{
  position:relative;
  display:inline-block;
  color:transparent;
  background:linear-gradient(173deg,
    #ffffff  8%,
    #ffe7f2 21%,
    #ffabcc 33%,
    #ee5c8f 44%,   /* the strike */
    #ffdae8 55%,   /* light again — this is the metallic tell */
    #ff9dc1 67%,
    #da4b7d 82%,
    #a63a63 96%);
  -webkit-background-clip:text;
  background-clip:text;
}
.wish__nameGlow{
  position:absolute;
  left:0; right:0; top:0;
  z-index:-1;
  color:rgba(255,158,196,.82);
  filter:blur(30px);
  animation:name-pulse 6.5s var(--e-inout) infinite;
  user-select:none;
}
/* Tighter and faster than the countdown's sweep — foil flashes, it does not
   shimmer. The narrow bright band with hard shoulders is what sells the strike. */
.wish__nameSheen{
  position:absolute;
  left:0; right:0; top:0;
  color:transparent;
  background:linear-gradient(100deg,
    rgba(255,255,255,0)    42%,
    rgba(255,255,255,.72)  48%,
    rgba(255,255,255,1)    50%,
    rgba(255,255,255,.72)  52%,
    rgba(255,255,255,0)    58%);
  background-size:240% 100%;
  background-repeat:no-repeat;
  -webkit-background-clip:text;
  background-clip:text;
  animation:name-sheen 6.5s var(--e-inout) 3.2s infinite;
  user-select:none;
}

/* ---- 4. the wish itself ---- */
.wish__line{
  margin:.5em auto 0;
  max-width:19em;
  font-family:var(--f-serif);
  font-size:clamp(13.5px,3.9vw,16px);
  font-style:italic;
  font-weight:400;
  line-height:1.74;
  letter-spacing:.02em;
  color:rgba(255,244,232,.9);
  text-shadow:var(--halo),0 0 26px rgba(255,196,140,.24);
  --lift:16px;
}
.wish__line .heart{ margin-left:.15em; }

/* ---- starlight, once the wish is complete ---- */
.wish__sparks{
  position:absolute;
  inset:-10% -8%;
  opacity:0;
  transition:opacity 1.6s var(--e-out);
  pointer-events:none;
}
.wish.is-complete .wish__sparks{ opacity:1; }
.wish__sparks i{
  position:absolute;
  left:var(--sx); top:var(--sy);
  width:3px; height:3px;
  border-radius:50%;
  background:#fff8e6;
  box-shadow:0 0 6px 1px rgba(255,226,172,.9), 0 0 16px rgba(255,198,128,.55);
  animation:spark-twinkle 3.2s var(--e-inout) var(--sd,0s) infinite;
}
@keyframes spark-twinkle{
  0%,100%{ opacity:0;  transform:scale(.4); }
  50%    { opacity:1;  transform:scale(1.4); }
}

/* once all four are gone, the whole wish swells once */
.scene--balloons.is-complete .wish{
  animation:wish-swell 1.6s var(--e-out);
}
@keyframes wish-swell{
  0%  { transform:translate(-50%,-50%) scale(1);     }
  30% { transform:translate(-50%,-50%) scale(1.055); }
  100%{ transform:translate(-50%,-50%) scale(1);     }
}

/* -------------------------------------------------------------- tap prompt --- */
.tapnote{
  position:absolute;
  left:50%; bottom:19%;
  transform:translateX(-50%);
  margin:0;
  font-family:var(--f-sans);
  font-size:11px;
  font-weight:500;
  letter-spacing:.2em;
  text-indent:.2em;
  text-transform:uppercase;
  color:rgba(255,240,228,.62);
  text-shadow:var(--halo);
  white-space:nowrap;
  pointer-events:none;
  animation:hint-breathe 3.4s var(--e-inout) infinite;
  transition:opacity .6s var(--e-out), transform .6s var(--e-out);
}
/* it has done its job after the first pop */
.tapnote.is-gone{
  opacity:0;
  transform:translateX(-50%) translateY(8px);
  animation:none;
}

/* The floating CTA slot — balloons, sky, gallery and the bow all use this, so all
   four land in exactly the same place on the screen.
   --cta-floor rather than a literal: it is shared with .candleAction and
   .through__bottom, and one token is what keeps six screens honest.
   Both states must restate the -50% centring, or .cta's own transform wins and
   the button jumps to the right edge. */
.cta--floor{
  position:absolute;
  left:50%; bottom:var(--cta-floor);
  margin:0;
  width:max-content;
  transform:translate3d(-50%,14px,6px);
}
.cta--floor.is-in{ transform:translate3d(-50%,0,20px); }

/* ------------------------------------------------------ the ask (state B) --- */
/* Scrim for the walk-through frame: darkens the sky and the steps for the copy,
   and stays completely clear across the middle, so the couple and the light
   pouring out of the doorway are never veiled. */
.backdrop__scrim--through{
  opacity:0;
  background:linear-gradient(180deg,
    rgba(8,3,14,.86)  0%,
    rgba(8,3,14,.78)  22%,
    rgba(10,4,18,.42) 34%,
    rgba(10,4,18,.06) 44%,
    rgba(10,4,18,0)   54%,
    rgba(10,4,18,.10) 66%,
    rgba(10,4,18,.52) 78%,
    rgba(8,3,14,.86)  92%,
    rgba(8,3,14,.92)  100%);
}

.through{ position:absolute; inset:0; }
.through[hidden]{ display:none; }

/* copy above the couple, the choice below them */
.through__top{
  position:absolute;
  left:50%; top:4.5%;
  transform:translateX(-50%);
  width:min(370px,92vw);
  text-align:center;
}
/* same floor as every other screen's button, and well clear of the bottom edge */
.through__bottom{
  position:absolute;
  left:50%; bottom:var(--cta-floor);
  transform:translateX(-50%);
  width:min(340px,90vw);
  text-align:center;
}

.through__title{
  margin:0 0 .6em;
  font-family:var(--f-serif);
  font-size:clamp(23px,6.6vw,29px);
  font-weight:500;
  font-style:italic;
  line-height:1.2;
  color:var(--cream);
  text-shadow:var(--halo),0 0 30px rgba(255,214,180,.4);
  --lift:26px;
}
.through__body{
  margin:0 auto;
  max-width:23em;
  font-family:var(--f-sans);
  font-size:13px;
  line-height:1.68;
  color:var(--text-dim);
  text-shadow:var(--halo);
  --lift:18px;
}

/* One button now, so it sets no size of its own — .btn owns all geometry (§6b).
   The yes/no PAIR that used to live here moved to .gate__btn below, along with the
   whole "one of these is clearly the answer" hierarchy, when the camera ask moved to
   the start of the visit on 15 Sept 2026. Nothing here has a sibling to balance
   against any more, and giving a lone CTA bespoke dimensions is exactly what the
   one-button rule exists to prevent. */
.through__btn{
  display:flex;
  width:max-content;
  margin:0 auto;
  --lift:22px;
}

/* the quieter half of the choice — never make "no" feel like the wrong answer */
.btn--ghost{
  color:var(--text-soft);
  background:linear-gradient(158deg,
    rgba(255,255,255,.10) 0%,
    rgba(255,255,255,.04) 50%,
    rgba(255,255,255,.02) 100%);
  border:1px solid rgba(255,255,255,.2);
  backdrop-filter:blur(14px) saturate(140%);
  -webkit-backdrop-filter:blur(14px) saturate(140%);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.24),
    0 10px 24px rgba(0,0,0,.4);
  text-shadow:var(--halo);
}

/* =================================================== SCENE 4 — CANDLES === */

.scene--candles .backdrop{
  background-image:url('../assets/images/bg-candles.webp');
  /* ### KEN BURNS IS DELIBERATELY OFF ON THIS SCENE ONLY. ###
     The five candles are painted INTO this image, and the live flames sit on
     their wicks. Ken Burns slowly scales and drifts the painting; the flames are
     positioned against the painting's static geometry, so the drift would walk
     the wicks out from underneath them over 34 seconds.
     The growing candlelight, the flicker, the smoke, the lanterns and the
     fireflies supply the motion instead — and holding the frame still is the
     right choice anyway while she is aiming a thumb at something small.
     If you ever re-enable this, .candleField must be animated identically. */
  animation:none;
}

/* Heavy at the top, where all the copy sits over empty sky. Completely clear
   from ~50% down: the cake, the roses and the lantern-lit garden are the whole
   point of this painting and nothing may be laid over them. Just a touch at the
   very bottom so the continue button reads against the bright stone table. */
.scene--candles .backdrop__scrim{
  background:linear-gradient(180deg,
    rgba(8,3,14,.80)  0%,
    rgba(8,3,14,.74)  14%,
    rgba(10,4,18,.62) 26%,
    rgba(10,4,18,.36) 36%,
    rgba(10,4,18,.13) 43%,
    rgba(10,4,18,0)   50%,
    rgba(10,4,18,0)   78%,
    rgba(8,3,14,.22)  89%,
    rgba(8,3,14,.44)  100%);
}

/* fireflies read as the garden's own fairy lights, so barely any blur */
.scene--candles .fx--bokeh{ filter:blur(.6px); opacity:.9; }
/* just enough to take the edge off the sprite silhouettes; any more and the
   irregular shapes that make it read as smoke get averaged back into blobs */
.scene--candles .fx--smoke{ filter:blur(1px); opacity:1; }

/* ---- the whole garden warms up as each candle catches ----
   --litn is 0..1 (candles lit / 5) and is set on the scene by JS. */
.candleWash{
  position:absolute; inset:-5%;
  background:linear-gradient(180deg,
    rgba(255,150,80,0)   26%,
    rgba(255,152,82,.07) 44%,
    rgba(255,146,74,.15) 62%,
    rgba(255,140,68,.13) 82%,
    rgba(255,134,64,.07) 100%);
  opacity:var(--litn,0);
  mix-blend-mode:soft-light;
  transition:opacity 1.2s var(--e-out);
}

/* ---- the painting-locked field ----
   Width and height are set in px by Candles.layout(), replicating exactly the
   box background-size:cover gives the artwork. That is what lets every flame
   below be placed in IMAGE percentages and stay on its wick at any viewport. */
.candleField{
  position:absolute;
  left:50%; top:50%;
  transform:translate(-50%,-50%);
  pointer-events:none;
}

/* the pooled candlelight on the cake. Inside the field, so it is anchored to
   the artwork rather than to the viewport. */
.candleGlow{
  position:absolute;
  left:50.1%; top:47%;
  width:150%;
  aspect-ratio:1;
  transform:translate(-50%,-42%) scale(calc(.6 + var(--litn,0) * .46));
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,216,158,.52) 0%,
    rgba(255,184,114,.30) 30%,
    rgba(255,152,82,.12)  58%,
    rgba(255,140,70,0)    100%);
  opacity:calc(var(--litn,0) * .95);
  filter:blur(12px);
  transition:opacity 1.1s var(--e-out), transform 1.3s var(--e-out);
  /* No mix-blend-mode:screen here, deliberately. Both this element's own filter
     and .candleField's transform isolate blending, so `screen` would silently
     resolve against transparency and do nothing at all. Over a night-time
     painting a warm gradient on normal blending reads as light anyway, so the
     alphas above are tuned for that instead of relying on a blend that cannot
     reach the artwork. Same reasoning on .flame::before. */
}

/* ------------------------------------------------------------------ flames --- */
/* --cx/--cy are IMAGE percentages. translate(-50%,-100%) puts the flame's BASE
   on the wick tip, and transform-origin bottom-centre means it grows upward out
   of the wick rather than out of its own middle.
   Width is a % of .candleField, i.e. of the painting — so the flame scales with
   the artwork instead of with the viewport, and stays the right size for the
   candle it is standing on. */
.flame{
  position:absolute;
  left:var(--cx); top:var(--cy);
  width:3.3%;
  aspect-ratio:.5;
  transform:translate(-50%,-100%);
  transform-origin:50% 100%;
  opacity:0;
  pointer-events:none;
}

/* The light it throws into the air — much larger than the flame itself.
   Sized for FIVE of these overlapping. On normal blending five halos pile up
   additively, and at the single-candle value (900%) the middle of the row
   saturated to near-white and swallowed the cake. The broad pool of light is
   .candleGlow's job; this is just each flame's own close halo. */
.flame::before{
  content:'';
  position:absolute;
  left:50%; top:52%;
  width:620%;
  aspect-ratio:1;
  transform:translate(-50%,-50%);
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,208,138,.54) 0%,
    rgba(255,178,100,.24) 34%,
    rgba(255,152,74,.08)  62%,
    rgba(255,150,70,0)    100%);
  filter:blur(4px);
  animation:flame-breathe var(--fdur,2.1s) var(--e-inout) var(--fd,0s) infinite;
}
@keyframes flame-breathe{
  0%,100%{ opacity:.82; transform:translate(-50%,-50%) scale(1);    }
  30%    { opacity:1;   transform:translate(-50%,-50%) scale(1.12); }
  62%    { opacity:.72; transform:translate(-50%,-50%) scale(.94);  }
}

/* the flame body: teardrop, hottest and whitest low down where the wick is */
.flame::after{
  content:'';
  position:absolute;
  inset:0;
  border-radius:50% 50% 50% 50% / 72% 72% 28% 28%;
  transform-origin:50% 100%;
  background:
    radial-gradient(46% 30% at 50% 76%,
      rgba(255,255,246,.98) 0%,
      rgba(255,244,190,.90) 34%,
      rgba(255,206,110,.50) 62%,
      rgba(255,180,80,0)    100%),
    linear-gradient(180deg,
      rgba(255,226,150,.90) 0%,
      rgba(255,178,72,.96)  42%,
      rgba(255,132,44,.94)  74%,
      rgba(232,96,32,.90)   100%);
  box-shadow:0 0 8px rgba(255,178,80,.95), 0 0 20px rgba(255,150,60,.7);
  animation:flame-flicker var(--fdur,2.1s) var(--e-inout) var(--fd,0s) infinite;
  will-change:transform;
}
/* Every flame carries its own --fdur and --fd, so no two ever flicker in step.
   Five synchronised flames read as one animated graphic; five out-of-step ones
   read as fire. The body and its halo share the same pair of values, so each
   flame's glow breathes with its own movement. */
@keyframes flame-flicker{
  0%,100%{ transform:scale(1,1)      skewX(0deg)    translateY(0);    }
  18%    { transform:scale(.94,1.08) skewX(-2.2deg) translateY(-3%);  }
  36%    { transform:scale(1.06,.95) skewX(1.8deg)  translateY(1%);   }
  54%    { transform:scale(.97,1.05) skewX(2.6deg)  translateY(-2%);  }
  72%    { transform:scale(1.03,.98) skewX(-1.4deg) translateY(0);    }
}

/* catching light: a fast overshoot, because a flame does not fade in */
.flame.is-lit{
  opacity:1;
  animation:flame-ignite .5s var(--e-out) both;
}
@keyframes flame-ignite{
  0%  { transform:translate(-50%,-100%) scale(.10,.05); }
  40% { transform:translate(-50%,-100%) scale(1.30,1.18); }
  70% { transform:translate(-50%,-100%) scale(.92,.94);  }
  100%{ transform:translate(-50%,-100%) scale(1,1);      }
}

/* going out: it is pushed flat by the breath first, then gone. Declared after
   .is-lit and with both classes, so it wins the `animation` shorthand. */
.flame.is-lit.is-out{
  animation:flame-snuff .34s cubic-bezier(.4,0,.8,1) forwards;
}
@keyframes flame-snuff{
  0%  { opacity:1; transform:translate(-50%,-100%) scale(1,1);       }
  30% { opacity:1; transform:translate(-50%,-100%) scale(1.16,.62);  }
  100%{ opacity:0; transform:translate(-50%,-100%) scale(.50,.12);   }
}
/* the light in the air dies with it */
.flame.is-out::before{
  animation:none;
  opacity:0;
  transition:opacity .3s ease-out;
}

/* ------------------------------------------------------------ candles copy --- */
/* Anchored to the TOP, not centred: the painting's upper half is empty sky and
   the cake owns everything from ~45% down.
   Dropped from 4.5% to 11% on Sai's note (14 Sept 2026) — at 4.5% all three beats
   ("Make a wish", "Now close your eyes", "Happy Birthday Jaya") sat jammed
   against the very top of the frame, which reads as a page header rather than as
   part of the scene. There is plenty of empty sky to spend: the tallest of the
   three blocks is the greeting at roughly 180px, so from 11% it still finishes
   near 34% and stays well clear of the cake. */
.content--candles{
  top:11%;
  transform:translate(-50%,0);
  width:min(360px,90vw);
}
.content--candles[hidden]{ display:none; }

/* The headline of a beat. Shared from Scene 4 onward — every screen from here
   states its beat the same way, and continuity across screens matters more than
   giving each one its own voice. */
.beat-title{
  position:relative;
  margin:.12em 0 0;
  font-family:var(--f-serif);
  font-size:clamp(29px,8.2vw,37px);
  font-weight:500;
  font-style:italic;
  line-height:1.14;
  color:var(--cream);
  text-shadow:var(--halo),0 0 30px rgba(255,206,150,.34);
  --lift:30px;
}
/* The "Happy Birthday Jaya" beat borrows the balloons scene's .wish__hb /
   .wish__name classes, which carry no positioning of their own — only the foil
   and the bloom — so they drop straight in.
   The gold "Happy Birthday" above it stays identical on both screens. Her NAME
   does not: see below. */

/* ### HER NAME, TREATMENT 4 OF 4 — INCANDESCENT. ###
   Sai's call, 14 Sept 2026, and it supersedes the earlier decision that this
   greeting should look exactly like the balloons one. Four screens say her name
   and each one should feel like its own moment.

   This is the only one of the four with NO gradient. The other three are
   reflective — pearl, gold leaf, struck foil — surfaces that are lit by something
   else. This one EMITS: a warm white core with amber bleeding out of it in four
   layered shadows, breathing very slightly.

   That is the whole reason it belongs here and nowhere else. She has just blown
   out five candles and the garden went dark; the only light left in the frame
   should be her name. A reflective treatment has nothing to reflect at that
   moment, which is exactly why the foil version looked flat on this screen. */
.scene--candles .wish__nameText{
  /* stand the gradient down — there is a background-clip:text on the base rule
     and an opaque colour needs it out of the way */
  background:none;
  -webkit-background-clip:border-box;
  background-clip:border-box;
  color:#fff7e6;
  text-shadow:
    0 0 7px   rgba(255,240,206,.95),
    0 0 20px  rgba(255,208,142,.82),
    0 0 44px  rgba(255,170,88,.6),
    0 0 88px  rgba(255,140,58,.38),
    0 2px 3px rgba(52,16,8,.5);
  animation:name-candle 5.5s var(--e-inout) infinite;
}
/* A specular sweep is a reflection, and this name is a light source — the two
   ideas fight, so the foil's sheen is switched off here. */
.scene--candles .wish__nameSheen{ display:none; }
/* and the bloom behind it warms from rose to candlelight */
.scene--candles .wish__nameGlow{
  color:rgba(255,196,120,.88);
  filter:blur(34px);
}
/* the faintest flicker — a flame's worth, not a strobe */
@keyframes name-candle{
  0%,100%{ opacity:.95; }
  38%    { opacity:1;   }
  64%    { opacity:.93; }
}

/* ---- the two buttons ----
   Both sit in the SAME single-cell grid, so "Blow them out" appears exactly where
   "Light the candles" was. Her eye never has to go looking for it. */
.candleAction{
  position:absolute;
  left:50%; bottom:var(--cta-floor);
  transform:translateX(-50%);
  display:grid;
  grid-template-areas:'a';
  place-items:center;
  min-height:54px;
}
.candleAction > *{ grid-area:a; }

.candleAction__btn{
  opacity:0;
  /* the full list is restated because .btn already owns `transition`, and a bare
     `transition:opacity` here would replace it and kill the hover lift */
  transition:opacity .45s var(--e-out), transform .4s var(--e-out),
             box-shadow .4s var(--e-out), filter .4s var(--e-out);
}
.candleAction__btn.is-in{ opacity:1; }
.candleAction__btn[hidden]{ display:none; }

/* ---- the smoke ----
   NOTHING is dimmed or veiled while it rises. An earlier version darkened the
   garden away behind a full-frame scrim so the greeting sat in fog; it made the
   smoke read as a white wash and threw away the painting for several seconds.
   The artwork stays exactly as it is, the lanterns and the fireflies keep going,
   and the smoke is just smoke over the cake.

   The canvas is in its own layer (z-60) so it draws in front of the painting but
   behind the copy — the greeting is never veiled by its own smoke. */

/* ---- ?cal=1 wick calibration ----
   Dev only. Press 1-5 to select a flame, tap to place it, arrows to nudge. */
.scene--candles.is-cal .candleField{ outline:1px dashed rgba(120,255,200,.45); }
.scene--candles.is-cal .flame{ opacity:1; }
.scene--candles.is-cal .flame::after{ outline:1px solid rgba(120,255,200,.85); }
/* hold the flame still and dim its haze — a flickering, glowing target is
   impossible to line up against a painted wick */
.scene--candles.is-cal .flame::after{ animation:none; }
.scene--candles.is-cal .flame::before{ animation:none; opacity:.3; }
.scene--candles.is-cal .flame.is-sel::after{
  outline:2px solid rgba(120,255,200,1);
  box-shadow:0 0 0 6px rgba(120,255,200,.25);
}
/* ?dev=1 then press A — the asset readout. See Dev.buildAssets in app.js for what
   the marks mean. Right-hand side so it never sits over the scene-jump bar. */
.devassets{
  position:fixed;
  right:6px; top:6px;
  z-index:101;
  max-height:88vh;
  max-width:62vw;
  overflow:auto;
  padding:8px 10px;
  border-radius:8px;
  background:rgba(4,6,12,.92);
  border:1px solid rgba(140,210,255,.28);
  color:#cfe6ff;
  font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  white-space:pre;
  pointer-events:none;
}
.devassets[hidden]{ display:none; }

.calreadout{
  position:fixed;
  left:6px; bottom:52px;
  z-index:101;
  max-width:94vw;
  padding:7px 9px;
  border-radius:8px;
  background:rgba(4,10,8,.92);
  border:1px solid rgba(120,255,200,.34);
  color:rgba(190,255,228,.95);
  font-family:ui-monospace,'Cascadia Mono',Consolas,monospace;
  font-size:9px;
  line-height:1.5;
  white-space:pre;
  pointer-events:none;
}
.calreadout[hidden]{ display:none; }

/* ======================================================= SCENE 5 — SKY === */

.scene--sky .backdrop{
  background-image:url('../assets/images/bg-sky.webp');
  /* Ken Burns stays ON here. Nothing is locked to this painting's geometry —
     unlike the candles, the bursts are placed by her thumb in canvas space, so
     the artwork is free to drift. A slow push on a galaxy is worth having. */
}

/* Almost nothing, and that is the point. This painting has TWO heroes — the
   Milky Way across the top and the two of them silhouetted at the bottom — and
   the scrim must not touch either. It only steadies the empty middle band the
   copy sits in, which is already close to black in the artwork. */
.scene--sky .backdrop__scrim{
  background:linear-gradient(180deg,
    rgba(8,3,14,0)    0%,
    rgba(8,3,14,0)    26%,
    rgba(10,4,18,.20) 37%,
    rgba(10,4,18,.34) 47%,
    rgba(10,4,18,.30) 57%,
    rgba(10,4,18,.10) 66%,
    rgba(10,4,18,0)   74%,
    /* the faintest settle over the wet terrace, purely so the floor button
       reads against the lantern reflections. Not enough to touch the couple. */
    rgba(10,4,18,.06) 88%,
    rgba(8,3,14,.22)  100%);
}

/* embers rather than fireflies here, so a touch more presence and less blur */
.scene--sky .fx--bokeh{ filter:blur(1px); opacity:.72; }

/* The fireworks canvas must NOT be blurred or dimmed — the sparks are 1-2px and
   any softening turns a burst into a smudge. It carries its own glow. */
.fx--fw{ filter:none; opacity:1; }

/* ---- a breath of light on the two of them ----
   They are painted into the artwork, so there is nothing to light — this is a soft
   warm glow laid over where they stand at the balustrade, roughly image x 51%,
   y 84%.

   ### Why it is a SIBLING of .backdrop and not a child. ###
   Ken Burns lives on .backdrop and slowly scales and drifts it. A child would ride
   that drift and stay glued to them, which sounds right — but it would also be
   scaled 1.05→1.14, and a glow that grows 9% over 34 seconds pumps visibly. As a
   sibling inside the same layer it shares the layer's parallax (so it never
   separates from the scene) while the painting drifts ~1.5% underneath it. At this
   size and softness that is invisible; if anything it reads as the light shifting.

   Kept very low — Sai's note was "just a little bit". It is a suggestion of
   lamplight catching them, not a spotlight. Anything above about .2 alpha starts
   to look like they are lit from the front, which the painting is not. */
.skyCouple{
  position:absolute;
  left:51%; top:84%;
  width:46%;
  aspect-ratio:1.05;
  transform:translate(-50%,-50%);
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,206,150,.17) 0%,
    rgba(255,176,120,.09) 46%,
    rgba(255,160,100,0)  100%);
  filter:blur(16px);
  pointer-events:none;
  animation:tree-breathe 9s var(--e-inout) infinite;
}

/* The copy lives in the empty band between the galaxy and the couple. Anchored
   from the top like the candles' blocks, not centred, because what is above and
   below it is fixed by the painting. */
.content--sky{
  top:37%;
  transform:translate(-50%,0);
  width:min(360px,90vw);
}
.content--sky[hidden]{ display:none; }

/* why twenty-five — quieter than the lede above it, since it is an aside */
.skynote{
  margin:.9em auto 0;
  max-width:20em;
  font-family:var(--f-sans);
  font-size:10.5px;
  font-weight:500;
  letter-spacing:.14em;
  text-indent:.14em;
  text-transform:uppercase;
  color:rgba(255,226,186,.72);
  text-shadow:var(--halo),0 0 18px rgba(255,196,120,.45);
  --lift:10px;
}

/* ---- the running count ----
   Once she starts tapping, the intro copy steps aside and this is the only thing
   left telling her where she is in the twenty-five. Gold, small, and sitting just
   above the button's slot so the two never overlap when the offer arrives.
   Deliberately NOT a progress bar: a bar makes it a task with a finish line, and
   nothing on this site is allowed to hurry her. A count of lit shells reads as a
   tally of something nice that happened. */
/* ### Moved up off the couple, 15 Sept 2026. ###
   It was at --cta-floor + 62px, which on a 360x780 phone put it at about y 600 —
   landing squarely across their heads and his raised arm. The couple occupy roughly
   image y 71%..93%, which is screen y 570..760 there, so anything below about 28%
   from the floor is on top of them. 32% clears them with room to spare and still
   sits under the copy block above. */
.skycount{
  position:absolute;
  left:50%; bottom:32%;
  transform:translateX(-50%);
  margin:0;
  font-family:var(--f-sans);
  font-size:11px;
  font-weight:700;
  letter-spacing:.26em;
  text-indent:.26em;
  text-transform:uppercase;
  white-space:nowrap;
  color:rgba(255,232,190,.9);
  text-shadow:var(--halo),0 0 20px rgba(255,198,120,.6);
  opacity:0;
  transition:opacity .7s var(--e-out);
  pointer-events:none;
}
.skycount[hidden]{ display:none; }
.skycount.is-in{ opacity:1; }
/* struck once per shell. The keyframes restate translateX(-50%), because a
   keyframe animating transform REPLACES the base transform outright — the trap
   documented for .flame and .wish__name. */
.skycount.is-tick{ animation:skycount-tick 420ms var(--e-out); }
@keyframes skycount-tick{
  0%  { transform:translateX(-50%) scale(1);    }
  28% { transform:translateX(-50%) scale(1.13); }
  100%{ transform:translateX(-50%) scale(1);    }
}

/* ---- this screen's button is the ONE exception to the centred rule ----
   Every other screen puts its button in the middle at --cta-floor, and that is
   still the rule. This painting is the reason for the exception: the two of them
   are standing at the bottom centre of the frame, so the shared position lands the
   button directly on top of the hero of the artwork.

   Small, and tucked to the right of them. Two reasons beyond not covering them:
     - There is nowhere else for it. Raised into the empty band it would sit in the
       middle of the sky she is tapping, and Sky.onTap ignores taps that land on a
       .btn — so a big centred button up there would eat her fireworks and
       occasionally navigate her onward by accident.
     - It should not compete. She is meant to keep playing; this only waits.

   The couple reach to about image x 60%, which is roughly 63% of the viewport on a
   360-wide phone. The button starts past that. If it ever crowds them, the two
   numbers to move are `right` and `min-width` — and the label has to stay short,
   which is why offerCta is now the same "There's more" the balloons and the gallery
   use rather than a full sentence.

   Both transform states are restated without the -50%: .cta--floor centres itself
   with translate3d(-50%,...) and leaving that in place would shift this half its
   own width back over her. */
.scene--sky .cta--floor{
  left:auto;
  right:3.5%;
  bottom:13%;
  transform:translate3d(0,14px,6px);
}
.scene--sky .cta--floor.is-in{ transform:translate3d(0,0,20px); }

.scene--sky .cta--floor .btn{
  min-width:0;                  /* the shared 12.5em floor is too wide to fit here */
  min-height:40px;              /* ::after still gives it a ~60px touch target */
  padding:.62em 1em;
  font-size:11px;
  letter-spacing:.07em;
  gap:.4em;
}

/* =================================================== SCENE 6 — GALLERY === */

.scene--gallery .backdrop{
  background-image:url('../assets/images/bg-gallery.webp');
  /* Ken Burns stays on. Nothing here is locked to the painting — the wires run
     off both edges of the frame and the prints hang from them, so the artwork is
     free to drift underneath. */
}

/* The pillars, the lanterns and the wet flagstones are all worth seeing, and the
   copy only needs help at the very top. So: clear over the galaxy's brightest
   part is impossible here (the copy sits there), but everything below 30% is left
   alone — the prints are opaque and do not need a darkened background the way
   text does. */
.scene--gallery .backdrop__scrim{
  background:linear-gradient(180deg,
    rgba(8,3,14,.52)  0%,
    rgba(8,3,14,.56)  12%,
    rgba(10,4,18,.44) 22%,
    rgba(10,4,18,.18) 30%,
    rgba(10,4,18,0)   38%,
    rgba(10,4,18,0)   82%,
    rgba(8,3,14,.16)  92%,
    rgba(8,3,14,.34)  100%);
}

/* petals read as petals, so little blur and real presence */
.scene--gallery .fx--bokeh{ filter:blur(2px); opacity:.85; }

.content--gallery{
  top:5%;
  transform:translate(-50%,0);
  width:min(360px,90vw);
}
.content--gallery[hidden]{ display:none; }
/* this one is a long line, so it needs to sit tighter than the default */
.content--gallery .beat-title{ font-size:clamp(25px,7vw,32px); line-height:1.18; }

/* --------------------------------------------------------------- the wire --- */
/* Runs off BOTH edges of the frame, so it never needs a painted anchor point to
   hang from. vector-effect keeps the stroke an even weight despite the very
   non-uniform scale that preserveAspectRatio="none" applies — without it the
   line goes hairline-thin, which is the same trap the notes warn about for SVG
   scenery. */
.wire{
  position:absolute;
  left:-6%;
  top:var(--wy);
  width:112%;
  height:22px;
  overflow:visible;
  pointer-events:none;
}
.wire path{
  fill:none;
  stroke:rgba(255,226,190,.34);
  stroke-width:2.4;
  stroke-linecap:round;
  vector-effect:non-scaling-stroke;
  filter:drop-shadow(0 1px 2px rgba(0,0,0,.6));
}

/* ------------------------------------------------------------- the prints --- */
.layer--prints{ pointer-events:none; }

/* Tappable since 15 Sept 2026 — she lifts one out to look at it. */
.print{
  position:absolute;
  left:var(--px);
  top:calc(var(--py) + var(--drop,0%));
  width:var(--pw,26%);
  transform:translateX(-50%);
}

/* the peg stays clipped to the wire while the paper swings from it */
.print__peg{
  position:absolute;
  left:50%; top:-9px;
  z-index:2;
  width:9px; height:19px;
  transform:translateX(-50%);
  border-radius:3px;
  background:linear-gradient(180deg,#e8c79a 0%,#c99f68 55%,#a97f4c 100%);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.5),
    0 2px 4px rgba(0,0,0,.55);
}
.print__peg::after{
  content:'';
  position:absolute;
  left:50%; top:6px;
  width:1.5px; height:8px;
  transform:translateX(-50%);
  border-radius:1px;
  background:rgba(90,60,34,.7);
}

/* a real print: paper border, not a bare image. The bottom border is a touch
   deeper, the way a photographic print actually is. */
.print__paper{
  display:block;
  padding:5px 5px 8px;
  border-radius:2px;
  background:linear-gradient(158deg,#fffaf2 0%,#f6ece0 62%,#e9dccd 100%);
  box-shadow:
    0 1px 0 rgba(255,255,255,.6) inset,
    0 10px 22px rgba(0,0,0,.55),
    0 2px 6px rgba(0,0,0,.4);
  /* swings from the peg above it, not from its own middle */
  transform-origin:50% -9px;
  animation:print-sway var(--sway,5s) var(--e-inout) var(--swayd,0s) infinite alternate;
  will-change:transform;
}
/* The base tilt has to live inside the keyframes: it is the same `transform`
   property the sway animates, so declaring it separately would be overwritten. */
@keyframes print-sway{
  from{ transform:rotate(calc(var(--tilt,0deg) - 1.4deg)); }
  to  { transform:rotate(calc(var(--tilt,0deg) + 1.4deg)); }
}

.print__photo{
  display:block;
  width:100%;
  aspect-ratio:3 / 4;
  /* cover, so any photo straight off her phone fills the frame at any aspect
     ratio and Sai never has to crop anything */
  object-fit:cover;
  object-position:center 30%;   /* bias up: faces sit high in a portrait */
  background:#1a1020;
  border-radius:1px;
}

/* A print is a real <button> now that she can tap it, so it needs the UA reset.
   ### pointer-events:auto is load-bearing. ###
   .layer--prints is pointer-events:none, like every layer. That is not just about
   the browser's own hit testing: document.elementsFromPoint SKIPS elements that are
   not hit-testable, so the delegated handler in Gallery.init would never find a
   print and tapping one would do nothing at all. Opting back in here is what makes
   both paths work. */
.print{
  pointer-events:auto;
  border:0;
  padding:0;
  background:none;
  appearance:none;
  -webkit-appearance:none;
  cursor:pointer;
  touch-action:manipulation;
}
/* A slightly generous hit area, the balloons' trick — but tighter horizontally than
   anywhere else on purpose. A row of three prints leaves only about 12px between
   them, so 6px per side is the whole gap; anything more and a tap in the seam opens
   the neighbouring photo. There is plenty of room above and below. */
.print::after{
  content:'';
  position:absolute;
  inset:-10px -4px;
  border-radius:6px;
}
.print:focus-visible{ outline:2px solid rgba(255,214,180,.9); outline-offset:6px; }
/* the one she is currently looking at fades out under the lightbox, so it does
   not sit on the wire as a duplicate of the photo being shown */
.print.is-open{ opacity:0; transition:opacity .3s var(--e-out); }

/* ------------------------------------------------------------ the lightbox ---
   Tap a print, it comes forward; tap the X, it goes back.

   Rebuilt 15 Sept 2026 on Sai's ask. A version of this existed and was cut on 13
   Sept because "the quiet was better than the feature" — that call is reversed:
   being able to actually look at a photograph beats keeping the screen pure.

   ### It opens on CLICK, never on pointerdown. ###
   This overlay becomes pointer-events:auto the moment it opens. Open it on
   pointerdown and it is already live when the SAME tap's `click` is dispatched on
   pointerup — so that click lands on the freshly revealed scrim and closes it
   again. The symptom is "tapping a photo does nothing", and it is one of the traps
   already in Technical_UI_LAYER §7. The dismiss carries a 400ms guard on top.

   ### And it lives OUTSIDE the 3D stage. ###
   The prints sit in .layer--prints at z=-120, where hit testing is unreliable
   enough that they need elementsFromPoint to be tappable at all. A close button in
   there would inherit that problem, on the one control whose entire job is getting
   her back out. Out here, after .finish, it is plain 2D and always works.

   The photo animates FROM the print's own position rather than fading up in the
   middle — measured in JS, applied as a transform. That is what makes it read as
   the print she touched being lifted off the wire instead of a modal opening. */
.lightbox{
  position:absolute;
  inset:0;
  z-index:5;
  pointer-events:none;
}
.lightbox[hidden]{ display:none; }

.lightbox__scrim{
  position:absolute;
  inset:0;
  background:radial-gradient(ellipse at 50% 44%,
    rgba(6,2,10,.66) 0%,
    rgba(5,1,9,.92) 100%);
  opacity:0;
  transition:opacity .5s var(--e-out);
  /* tapping the darkness closes it too, which is what everyone expects */
  pointer-events:auto;
}
.lightbox.is-open .lightbox__scrim{ opacity:1; }

.lightbox__frame{
  position:absolute;
  left:50%; top:45%;
  width:min(82vw,330px);
  margin:0;
  transform:translate(-50%,-50%);
  pointer-events:none;
}
.lightbox__photo{
  display:block;
  width:100%;
  aspect-ratio:3 / 4;
  object-fit:cover;
  object-position:center 30%;
  background:#1b1017;
  /* the same warm paper border the prints on the wire have, so this reads as the
     very same object brought forward rather than a different, cleaner one */
  border:6px solid #fdf6e8;
  border-radius:3px;
  box-shadow:
    0 30px 70px rgba(0,0,0,.72),
    0 8px 20px rgba(0,0,0,.5);
  opacity:0;
  transform-origin:50% 50%;
  will-change:transform,opacity;
}
.lightbox.is-open .lightbox__photo{ opacity:1; }

/* Not a .btn: an X in a circle is a different affordance from a pill, and forcing
   it into one would be consistency for its own sake. It still gets the parts that
   matter — 44px, the same glass, and its own hit expander. */
.lightbox__close{
  position:absolute;
  top:4.5%; right:5%;
  display:flex;
  align-items:center;
  justify-content:center;
  width:44px; height:44px;
  padding:0;
  border:1px solid rgba(255,255,255,.3);
  border-radius:50%;
  background:rgba(255,255,255,.11);
  backdrop-filter:blur(12px) saturate(140%);
  -webkit-backdrop-filter:blur(12px) saturate(140%);
  box-shadow:0 8px 22px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.34);
  color:var(--cream);
  cursor:pointer;
  touch-action:manipulation;
  pointer-events:auto;
  opacity:0;
  transform:scale(.82);
  transition:opacity .38s var(--e-out) .2s, transform .38s var(--e-out) .2s;
}
.lightbox.is-open .lightbox__close{ opacity:1; transform:scale(1); }
.lightbox__close svg{
  width:17px; height:17px;
  fill:none;
  stroke:currentColor;
  stroke-width:2;
  stroke-linecap:round;
}
.lightbox__close::after{
  content:'';
  position:absolute;
  inset:-10px;
  border-radius:50%;
}

/* ====================================================== SCENE 7 — TREE === */

/* NOTE the filename: bg-bow-arrow.webp, not bg-tree.webp. Sai named the artwork
   and it is bound explicitly here rather than renamed. */
.scene--tree .backdrop{
  background-image:url('../assets/images/bg-bow-arrow.webp');
}

/* The galaxy at the top and the lantern-lit grass at the bottom are both worth
   keeping, so this only steadies the band the copy sits in and settles the very
   top where the copy crosses the Milky Way. */
.scene--tree .backdrop__scrim{
  background:linear-gradient(180deg,
    rgba(8,3,14,.50)  0%,
    rgba(8,3,14,.54)  10%,
    rgba(10,4,18,.42) 20%,
    rgba(10,4,18,.16) 28%,
    rgba(10,4,18,0)   36%,
    rgba(10,4,18,0)   84%,
    rgba(8,3,14,.14)  94%,
    rgba(8,3,14,.28)  100%);
}
.scene--tree .fx--bokeh{ filter:blur(2px); opacity:.8; }

.content--tree{
  top:5%;
  transform:translate(-50%,0);
  width:min(360px,90vw);
}
.content--tree[hidden]{ display:none; }

/* No cursor override any more: the drag is gone and the only thing to click is a
   real button, which brings its own pointer. */

/* ------------------------------------------------------------------- bow --- */
/* Held horizontally, seen side on: the limb arcs UP away from the shooter, the
   string spans below it, and the arrow points up. Pulling the string DOWN is the
   one archery gesture that needs no explaining on a phone, and it keeps the whole
   shot on the centre line so she cannot miss. */
.bow{
  position:absolute;
  left:50%; top:78%;
  width:58%;
  padding:0;
  border:0;
  background:none;
  /* Not interactive. It is animated by the button in the content layer. */
  pointer-events:none;
  transform:translate(-50%,-50%);
  transition:opacity .7s var(--e-out), transform .7s var(--e-out);
}
.bow[hidden]{ display:none; }

.bow__svg{ display:block; width:100%; height:auto; overflow:visible; }

/* A FILLED outline, never a stroke — that is what lets the limbs be thick at the
   riser and taper to nothing at the tips. The first version was a 7px stroke,
   which is uniform by definition and read as a bent stick. */
.bow__limb{
  fill:url(#bowGold);
  filter:drop-shadow(0 3px 6px rgba(0,0,0,.75));
}
.bow__tip{ fill:#c08b3e; }
/* polished metal needs a highlight running along its length */
.bow__sheen{ fill:url(#bowSheen); opacity:.5; }

.bow__string{
  fill:none;
  stroke:rgba(255,246,228,.9);
  stroke-width:1.6;
  stroke-linejoin:round;
  filter:drop-shadow(0 0 2px rgba(255,240,210,.6));
}
.bow__grip{
  fill:#4a3117;
  stroke:rgba(255,226,170,.5);
  stroke-width:1.2;
}
.bow__bind{
  fill:none;
  stroke:rgba(255,226,170,.45);
  stroke-width:1;
}
.bow__shaft{
  stroke:#e8d6b8;
  stroke-width:2.6;
  stroke-linecap:round;
}
.bow__head{ fill:#fff8ea; filter:drop-shadow(0 0 3px rgba(255,240,200,.9)); }
.bow__fletch{ fill:var(--blush); }

/* once fired, the whole rig leaves */
.bow.is-spent{ opacity:0; transform:translate(-50%,-50%) translateY(16px) scale(.94); }

/* -------------------------------------------------------------- the target --- */
.target{
  position:absolute;
  left:50%; top:33%;
  width:21%;
  transform:translate(-50%,-50%);
  transition:opacity .7s var(--e-out), transform .5s var(--e-out);
}
.target[hidden]{ display:none; }
.target__svg{ display:block; width:100%; height:auto; overflow:visible; }
/* light leaking out around the silhouette */
.target__halo{
  fill:rgba(255,60,136,.55);
  filter:blur(4px);
}
/* the gem itself. A RADIAL fill is the whole difference between a solid object
   and a flat pink shape — the old version was one colour with a drop-shadow. */
.target__body{
  fill:url(#heartFill);
  filter:drop-shadow(0 0 6px rgba(255,64,140,.85));
}
/* the rim where light wraps the edge */
.target__rim{
  fill:none;
  stroke:rgba(255,214,236,.92);
  stroke-width:.55;
  filter:drop-shadow(0 0 3px rgba(255,190,222,.95));
}
/* two speculars: one big and soft, one tight and hot. Eyes read that pairing as
   glass; a single highlight reads as a smudge. */
.target__spec{  fill:url(#heartSpec); opacity:.85; }
.target__spec2{ fill:#ffffff; opacity:.92; filter:blur(.3px); }

/* it floats, so it is an object hanging in the air rather than a decal */
.target__svg{ animation:target-float 5.2s var(--e-inout) infinite; }
@keyframes target-float{
  0%,100%{ transform:translateY(0)      rotate(-1.4deg); }
  50%    { transform:translateY(-3.5%)  rotate(1.4deg);  }
}

/* sparkles in the air around it */
.target__sparks{ position:absolute; inset:-26%; pointer-events:none; }
.target__sparks i{
  position:absolute;
  left:var(--sx); top:var(--sy);
  width:3px; height:3px;
  border-radius:50%;
  background:#fff4fa;
  box-shadow:0 0 6px 1px rgba(255,170,210,.95), 0 0 14px rgba(255,90,160,.6);
  animation:spark-twinkle 2.8s var(--e-inout) var(--sd,0s) infinite;
}
/* the volumetric bloom behind it, breathing so it reads as alive and waiting */
.target__glow{
  position:absolute;
  left:50%; top:48%;
  width:280%; aspect-ratio:1;
  transform:translate(-50%,-50%);
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,120,170,.42) 0%,
    rgba(226,88,140,.20) 38%,
    rgba(226,88,140,0)  100%);
  filter:blur(6px);
  animation:target-beat 2.2s var(--e-inout) infinite;
}
@keyframes target-beat{
  0%,100%{ opacity:.6; transform:translate(-50%,-50%) scale(1);    }
  14%    { opacity:1;  transform:translate(-50%,-50%) scale(1.16); }
  30%    { opacity:.7; transform:translate(-50%,-50%) scale(1.02); }
  46%    { opacity:.9; transform:translate(-50%,-50%) scale(1.10); }
}

/* struck: it flares white and blows out */
.target.is-hit{ animation:target-hit .46s cubic-bezier(.3,0,.6,1) forwards; }
@keyframes target-hit{
  0%  { transform:translate(-50%,-50%) scale(1);    filter:brightness(1);              }
  22% { transform:translate(-50%,-50%) scale(1.34); filter:brightness(2.4);            }
  100%{ transform:translate(-50%,-50%) scale(1.9);  filter:brightness(3) blur(10px); opacity:0; }
}

/* -------------------------------------------------------- the arrow in flight --- */
/* Width is set so the flying arrow comes out the same LENGTH as the one nocked
   in the bow — the two are different SVGs at different scales, and an arrow that
   grows the instant it is released is a tell. */
.shot{
  position:absolute;
  left:50%;
  width:3.6%;
  transform:translate(-50%,0);
  opacity:0;
}
.shot[hidden]{ display:none; }
.shot__svg{ display:block; width:100%; height:auto; overflow:visible; }
.shot__shaft{ stroke:#e8d6b8; stroke-width:3; stroke-linecap:round; }
.shot__head{ fill:#fff8ea; }
.shot__fletch{ fill:var(--blush); }
/* a streak behind it, so it reads as speed rather than a sliding object */
.shot::after{
  content:'';
  position:absolute;
  left:50%; top:60%;
  width:2px; height:80px;
  transform:translateX(-50%);
  background:linear-gradient(180deg,
    rgba(255,236,200,.55) 0%, rgba(255,236,200,0) 100%);
  filter:blur(1.5px);
}
.shot.is-flying{ opacity:1; }

/* ------------------------------------------------------------------ tree --- */
/* The tree itself is drawn on .fx--canopy (see Canopy() in app.js). These two
   elements are what sit behind it — and the bloom in particular is the single
   biggest reason the reference artwork looks magical and the first version did
   not. A dense pink crown with no light behind it just looks like confetti. */
.treeBloom{
  position:absolute;
  /* JS writes these from the crown's measured extent */
  left:var(--bx,20%); top:var(--by,26%);
  width:var(--bw,60%); height:var(--bh,34%);
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,92,152,.34) 0%,
    rgba(226,60,124,.20) 40%,
    rgba(180,30,96,.08)  68%,
    rgba(160,20,86,0)    100%);
  filter:blur(16px);
  opacity:0;
  transition:opacity 2.6s var(--e-out);
  pointer-events:none;
}
.scene--tree.is-bloom .treeBloom{
  opacity:1;
  animation:tree-breathe 6.5s var(--e-inout) 2.6s infinite;
}
@keyframes tree-breathe{
  0%,100%{ transform:scale(1);     }
  50%    { transform:scale(1.045); }
}

/* the tree has to sit ON the grass, not float over it */
.treeShade{
  position:absolute;
  left:50%; top:89.5%;
  width:34%; height:5%;
  transform:translate(-50%,-50%);
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(4,2,10,.62) 0%, rgba(4,2,10,.28) 52%, rgba(4,2,10,0) 100%);
  filter:blur(5px);
  opacity:0;
  transition:opacity 1.6s var(--e-out);
  pointer-events:none;
}
.scene--tree.is-bloom .treeShade{ opacity:1; }

/* -------------------------------------------------------------- the letter --- */
.mail{
  position:absolute;
  /* Small and low, pinned to the trunk below the crown. It was 23% wide and sat
     in the middle of the canopy, where it competed with the tree for the frame. */
  left:50%; top:71%;
  width:15%;
  padding:0;
  border:0;
  background:none;
  cursor:pointer;
  pointer-events:auto;
  transform:translate(-50%,-50%);
  -webkit-tap-highlight-color:transparent;
  opacity:0;
  transition:opacity 1.1s var(--e-out), transform .4s var(--e-out);
}
.mail[hidden]{ display:none; }
.mail.is-in{ opacity:1; }
.mail:active{ transform:translate(-50%,-50%) scale(.95); }
.mail:focus-visible{ outline:2px solid rgba(255,214,180,.9); outline-offset:8px; border-radius:8px; }
/* a real thumb target whatever size it is drawn at */
.mail::after{ content:''; position:absolute; inset:-30%; }

.mail__svg{ display:block; position:relative; width:100%; height:auto; }
.mail__body{

  fill:#fff6e6;
  stroke:#d9b98a;
  stroke-width:1.4;
}
.mail__flap{
  fill:none;
  stroke:#c9a271;
  stroke-width:1.6;
  stroke-linejoin:round;
}
/* it is a letter waiting in a dark garden, so it has to look lit from inside */
.mail__glow{
  position:absolute;
  left:50%; top:50%;
  width:320%; aspect-ratio:1;
  transform:translate(-50%,-50%);
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,226,170,.55) 0%,
    rgba(255,190,110,.24) 36%,
    rgba(255,180,100,0)  100%);
  filter:blur(8px);
  animation:mail-breathe 3.4s var(--e-inout) infinite;
}
@keyframes mail-breathe{
  0%,100%{ opacity:.55; transform:translate(-50%,-50%) scale(1);    }
  50%    { opacity:1;   transform:translate(-50%,-50%) scale(1.14); }
}

.mail__badge{
  position:absolute;
  top:-14%; right:-12%;
  display:grid;
  place-items:center;
  min-width:22px; height:22px;
  padding:0 5px;
  border-radius:999px;
  background:linear-gradient(158deg,#f0568c 0%,#b52a5e 100%);
  border:1.5px solid rgba(255,232,240,.95);
  color:#fff;
  font-family:var(--f-sans);
  font-size:11px;
  font-weight:700;
  line-height:1;
  box-shadow:0 3px 10px rgba(0,0,0,.55), 0 0 16px rgba(240,86,140,.7);
  animation:badge-pop .5s var(--e-out) .5s backwards;
}
@keyframes badge-pop{
  0%  { transform:scale(0);    }
  70% { transform:scale(1.25); }
  100%{ transform:scale(1);    }
}

.mail__note{
  position:absolute;
  left:50%; top:79%;
  transform:translateX(-50%);
  margin:0;
  font-family:var(--f-sans);
  font-size:10px;
  font-weight:600;
  letter-spacing:.26em;
  text-indent:.26em;
  text-transform:uppercase;
  white-space:nowrap;
  color:rgba(255,236,206,.78);
  text-shadow:var(--halo);
  opacity:0;
  transition:opacity 1.1s var(--e-out);
  pointer-events:none;
}
.mail__note[hidden]{ display:none; }
.mail__note.is-in{ opacity:1; animation:hint-breathe 4s var(--e-inout) 1.4s infinite; }

/* ==================================================== SCENE 8 — LETTER === */

.scene--letter .backdrop{
  background-image:url('../assets/images/bg-letter.webp');
  /* ### KEN BURNS IS DELIBERATELY OFF ON THIS SCENE. ###
     The sheet of paper is PAINTED INTO this artwork and the letter's text is a
     real element sitting on it, so a 34s scale-and-drift would slowly walk the
     page out from under the words. Exactly the same reason it is off on
     .scene--candles, and the same consequence: if this is ever re-enabled,
     .letterField has to be animated identically or the ink comes adrift.
     The lamplight coming up, the twinkle in the sky behind and the petals
     falling in front carry the motion instead. */
  animation:none;
}

/* The sheet, the roses, the lanterns and the lake town behind are ALL the hero
   in this painting, and every word now sits ON the painted paper in dark ink
   rather than over the artwork — so this scrim has almost nothing left to do.
   A whisper at the two extreme edges seats the frame for the vignette, and
   nothing at all touches the middle. This is the lightest scrim in the project,
   and that is correct: the more the artwork does, the less this should. */
.scene--letter .backdrop__scrim{
  background:linear-gradient(180deg,
    rgba(8,3,14,.26) 0%,
    rgba(8,3,14,.07) 9%,
    rgba(10,4,18,0)  20%,
    rgba(10,4,18,0)  84%,
    rgba(8,3,14,.10) 93%,
    rgba(8,3,14,.30) 100%);
}

/* Petals drift in FRONT of the paper here, so they are dimmer and softer than
   anywhere else on the site — at full strength they cross the text and cost her
   a word here and there, which on this screen of all screens is not a trade
   worth making. */
.scene--letter .fx--bokeh{ filter:blur(2.6px); opacity:.62; }

/* ---- the lamp coming up ----
   The paper is painted, so it cannot unfold. THIS is the entrance instead: the
   lantern light swells across the sheet, and the ink arrives on it line by line.
   Centred on the page rather than low like the old one, and it keeps breathing
   afterwards — with the camera locked on this scene, the light and the canvas FX
   are the only things left moving, so this is carrying more than it used to. */
.letterGlow{
  position:absolute;
  left:50%; top:44%;
  width:118%; aspect-ratio:1.06;
  transform:translate(-50%,-50%);
  border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(255,214,158,.26) 0%,
    rgba(255,178,124,.13) 42%,
    rgba(255,150,100,0)  100%);
  filter:blur(22px);
  opacity:0;
  transition:opacity 2.4s var(--e-out);
}
.scene--letter.is-open .letterGlow{
  opacity:1;
  animation:tree-breathe 9s var(--e-inout) 2.4s infinite;
}

/* ------------------------------------------------- the text area on the page ---
   There is no `.letter__paper` any more, and no unfold. The paper is painted
   into bg-letter.webp — hand-torn edges, gold filigree in the corners, lit by two
   lanterns — so building a cream card in CSS and tipping it open would be
   putting a fake page on top of a real one.

   What is left is the ink. .letterField is the text column ON that page, and JS
   sets its left/top/width/height in PIXELS to replicate exactly the box
   background-size:cover gives the artwork — see Letter.layout().

   ### Why px from JS and not percentages. ###
   `cover` crops a different amount on every viewport shape, so a
   viewport-relative box drifts off the sheet the moment the aspect ratio
   changes. The candles scene solved this first for the wicks; this is the same
   problem with more at stake, because a wick that is 4px out looks fine and a
   paragraph that is 4% out is lying across a rose.

   Note it is a child of .layer--content (z=0), NOT of .layer--backdrop like the
   candle flames. The flames only had to be looked at; this has to be SCROLLED,
   and hit testing inside a deep 3D layer is unreliable — the trap that has
   bitten this project twice. What keeps it married to the painting instead is
   data-camera="lock" on the section: see the note in Camera.tick(). */
.letterField{
  position:absolute;
  /* placeholders — Letter.layout() overwrites all four every time */
  left:0; top:0; width:0; height:0;
  pointer-events:none;
}

/* ---- the scrolling ink ----
   pointer-events and touch-action have to be opted back in: .layer is
   pointer-events:none, and without pan-y the browser will not let her drag the
   text on a touch screen. overscroll-behavior stops the drag chaining out to the
   page once she reaches the end.

   `transform-style:flat` deliberately ENDS the 3D chain here. Everything above
   this is preserve-3d for the camera rig, and a scroll container full of
   3D-transformed children inside a preserve-3d stage is exactly the arrangement
   that made the old scroll area unreliable. Flattening costs nothing — the text
   has no depth of its own — and it makes the one gesture on this screen behave
   like an ordinary scroll.

   ### The softening at the top and bottom is a MASK, not two gradient strips. ###
   The old version laid cream-coloured gradients over the edges, which worked
   when the paper was a CSS element whose exact colour we chose. It cannot work
   now: the painted sheet is warmer at the bottom-left where the lantern is,
   cooler toward the top-right, and carries rose shadows across its top — so any
   flat-coloured strip would sit on it as a visible pale band. A mask fades the
   TEXT's alpha instead, and whatever the painting is doing behind it comes
   through untouched.

   The top fade only exists once she has actually scrolled (.is-scrolled), so the
   very first line is never delivered already half-faded.

   ### --fade-bot spans about THREE LINES, and the ramp is graduated. ###
   Sai's call, 14 Sept 2026, and it is the better read: rather than slicing one
   line in half at a hard-ish edge, the last three lines sink into the paper at
   roughly 72% / 34% / 12% alpha. She does not just see that something is cut off —
   she can see several more lines of writing actually down there, receding. That
   says "a letter continues below this" far more plainly than a clipped edge does.

   The stops are weighted rather than linear so the first of the three barely
   dims and the last almost vanishes; a straight ramp greys all three evenly and
   reads as a smudge on the page instead of depth.

   ### COUPLED TO .letter__inner's padding-bottom. ###
   That padding must always be LARGER than --fade-bot. It is what guarantees the
   signature is never caught in the ramp: at the very end of the scroll the faded
   band contains nothing but empty paper, so "Always yours, Sai" always lands at
   full strength. It is also what stops a SHORT letter — one that does not overflow
   at all — from having its closing lines mysteriously dimmed.
   Change one, change the other. */
.letter__scroll{
  position:absolute;
  inset:0;
  overflow-y:auto;
  overflow-x:hidden;
  pointer-events:auto;
  touch-action:pan-y;
  -webkit-overflow-scrolling:touch;
  overscroll-behavior:contain;
  scrollbar-width:none;
  transform-style:flat;

  --fade-top:0px;
  /* ~3 lines at the body's 14px/1.76. Must stay under .letter__inner's
     padding-bottom — see the note above. */
  --fade-bot:72px;
  -webkit-mask-image:linear-gradient(180deg,
    transparent 0,
    #000 var(--fade-top),
    #000              calc(100% - var(--fade-bot)),
    rgba(0,0,0,.72)   calc(100% - var(--fade-bot) * .66),
    rgba(0,0,0,.34)   calc(100% - var(--fade-bot) * .34),
    rgba(0,0,0,.12)   calc(100% - var(--fade-bot) * .14),
    transparent 100%);
          mask-image:linear-gradient(180deg,
    transparent 0,
    #000 var(--fade-top),
    #000              calc(100% - var(--fade-bot)),
    rgba(0,0,0,.72)   calc(100% - var(--fade-bot) * .66),
    rgba(0,0,0,.34)   calc(100% - var(--fade-bot) * .34),
    rgba(0,0,0,.12)   calc(100% - var(--fade-bot) * .14),
    transparent 100%);
}
.letter__scroll.is-scrolled{ --fade-top:24px; }
.letter__scroll::-webkit-scrollbar{ display:none; }

/* The field IS the text column, so there is barely any padding to add — only a
   little breathing room at the sides so no glyph touches the torn edge of the
   paper.
   The foot is the exception and it is load-bearing: 84px against a 72px
   --fade-bot. That margin is what keeps the signature out of the fade ramp at the
   end of the scroll, and what stops a letter too short to overflow from having its
   last lines dimmed for no reason. Never let it drop below --fade-bot. */
.letter__inner{ padding:2px 6px 84px; }

/* ---- the ink ----
   Everything here is dark on warm cream, which is the exact inverse of every
   other screen in the project — so none of the usual tokens apply and all four
   colours are local on purpose.

   The palette is taken off the painting rather than invented: the deep rose of
   the garden roses for the script, a warm sepia for the body, and the same gold
   as the filigree already printed in the sheet's corners for the rule and the
   seal. */
.letter__hi{
  margin:0 0 .04em;
  font-family:var(--f-script);
  font-weight:400;
  font-size:clamp(30px,8.4vw,40px);
  line-height:1.04;
  color:#8d2f4e;
  --lift:0px;
}
.letter__rule{
  display:block;
  width:46%;
  height:1px;
  margin:.3em 0 1.05em;
  /* gold, answering the filigree the artwork already has */
  background:linear-gradient(90deg,
    rgba(176,132,58,.72) 0%, rgba(176,132,58,.26) 62%, rgba(176,132,58,0) 100%);
  --lift:0px;
}

.letter__p{
  margin:0 0 1em;
  font-family:var(--f-serif);
  font-size:clamp(14px,3.85vw,16px);
  font-weight:500;
  line-height:1.76;
  letter-spacing:.006em;
  /* Warm sepia, never black and never grey. Black on cream reads as a laser
     printout, and this sheet is lit by two amber lanterns — the ink has to sit in
     the same light as the paper it is on. */
  color:#4a3228;
  --lift:0px;
}

.letter__sign{
  margin:1.05em 0 0;
  text-align:right;
  font-family:var(--f-script);
  font-weight:400;
  font-size:clamp(27px,7.4vw,34px);
  line-height:1.1;
  color:#8d2f4e;
  --lift:0px;
}

/* a small gold heart pressed in under the signature */
.letter__seal{
  display:block;
  width:15px;
  margin:.55em 2px 0 auto;
  fill:#b0843a;
  opacity:.5;
  --lift:0px;
}

/* --halo is a dark glow, and it exists for light text sitting on artwork. On
   cream paper it reads as a smudge under every letter, so everything on the sheet
   drops it. This is the only place in the project that does. */
.letter__hi, .letter__p, .letter__sign{ text-shadow:none; }

/* ---- the gilt rail ----
   A scroll indicator, and deliberately NOT a native scrollbar. Two reasons, and
   the first one is the one that actually decides it:

     1. On iOS and Android a native scrollbar is a TRANSIENT OVERLAY — it appears
        only while she is already scrolling. That makes it useless for the one job
        being done here, which is telling her the page moves BEFORE she touches
        it.
     2. It is grey browser chrome, ~6px wide, taken out of a column that is only
        about 230px across, lying on a hand-torn painted sheet in a rose arbour.

   So this is a hairline in the paper's right MARGIN — outside the text column, so
   it costs no width — in the same gold as the filigree the artwork already has
   printed in its corners. It is always visible while there is more to read, and
   unlike a line of copy it also says HOW MUCH more, which matters when the letter
   runs to several screens.

   ### Indicator only, never a control. ###
   pointer-events stay off. A 2px thumb is not a touch target on a phone, and
   making it draggable inside a 3D stage would walk straight into the hit-testing
   trap that has already bitten this project twice.

   JS sets the thumb's height once per size change and its translateY on scroll —
   never height on scroll, which would be animating a layout property.

   NOTE if you widen the column with ?paper=1: the rail sits 5px outside the
   field's right edge, and the artwork's own printed gold rule is at about x 0.80
   of the image. Leave PAPER.x2 a little short of that or the two golds overlap. */
.letterRail{
  position:absolute;
  left:calc(100% + 5px);
  top:0;
  width:2px;
  height:100%;
  border-radius:2px;
  background:rgba(176,132,58,.20);
  opacity:0;
  transition:opacity 1s var(--e-out);
  pointer-events:none;
}
.letterRail[hidden]{ display:none; }
.letterRail.is-in{ opacity:1; }
/* once she has reached the end it has nothing left to say, so it steps back
   rather than sitting there insisting */
.letterRail.is-in.is-done{ opacity:.28; }

.letterRail__thumb{
  display:block;
  width:100%;
  border-radius:2px;
  /* lit from above like everything else in this frame */
  background:linear-gradient(180deg,
    rgba(216,172,94,.88) 0%,
    rgba(176,132,58,.72) 55%,
    rgba(148,108,44,.62) 100%);
  box-shadow:0 0 4px rgba(176,132,58,.4);
  /* height (px, per size) and transform (per scroll) both come from JS */
}

/* Shown only when the letter really is taller than the sheet — measured in
   Letter.checkOverflow(), never assumed, because whoever edits LETTER_COPY
   decides how long it is.
   It is INK too, not cream with a halo: it sits on the paper, just under the text
   column and above the filigree in the bottom corners. */
.letter__more{
  position:absolute;
  left:50%; top:100%;
  transform:translate(-50%,5px);
  margin:0;
  font-family:var(--f-sans);
  font-size:8.5px;
  font-weight:700;
  letter-spacing:.24em;
  text-indent:.24em;
  text-transform:uppercase;
  white-space:nowrap;
  color:rgba(122,84,44,.8);
  text-shadow:none;
  opacity:0;
  transition:opacity .8s var(--e-out);
  pointer-events:none;
}
.letter__more[hidden]{ display:none; }
.letter__more.is-in{ opacity:1; }
.letter__chev{
  display:block;
  width:7px; height:7px;
  margin:4px auto 0;
  border-right:1.6px solid rgba(122,84,44,.72);
  border-bottom:1.6px solid rgba(122,84,44,.72);
  transform:rotate(45deg);
  animation:chev-bob 1.9s var(--e-inout) infinite;
}

/* ?paper=1 — nudging the text column onto the sheet. Only needed if
   bg-letter.webp is ever regenerated. */
.scene--letter.is-paper .letterField{ outline:1px dashed rgba(120,255,200,.7); }
.scene--letter.is-paper .letter__scroll{
  -webkit-mask-image:none; mask-image:none;
  background:rgba(120,255,200,.08);
}
@keyframes chev-bob{
  0%,100%{ opacity:.45; transform:rotate(45deg) translate(0,0);       }
  50%    { opacity:1;   transform:rotate(45deg) translate(2px,2px);   }
}

/* ------------------------------------------------------------- the way in --- */
/* Appears the moment her birthday begins, under the clock. Small on purpose —
   an invitation, not a call to action. */
.cta{
  margin-top:4px;
  opacity:0;
  transform:translate3d(0,14px,6px);
  filter:blur(6px);
  transition:
    opacity 1.1s var(--e-out),
    transform 1.1s var(--e-out),
    filter 1.1s var(--e-out);
}
.cta.is-in{
  opacity:1;
  transform:translate3d(0,0,20px);
  filter:none;
}
.cta[hidden]{ display:none; }

/* ============================================================== buttons ===
   ### ONE BUTTON, EVERY SCREEN. ###
   Rebuilt 14 Sept 2026. Before this the site had three different button sizes and
   two different shapes: a 14px pill on the door, a 12px pill under the clock, and
   full-width blocks inside the card and the consent panel. Nothing was wrong with
   any one of them; the problem was that she meets six of them in a row and they
   should feel like the same object each time.

   So GEOMETRY lives here and nowhere else — one height, one radius, one minimum
   width, one letter-spacing, label always centred. The variants below change only
   the FILL. If you add a button, give it `.btn` plus a variant and set no size of
   its own.

   min-width is what actually makes them look consistent: "There's more" and
   "Whenever you're ready" are very different lengths, and without a floor they
   arrive as two obviously different objects on consecutive screens.

   ### The whole button is the target. ###
   `::after` is an invisible expander reaching 10px past every edge. A <button>
   already forwards clicks from its own children, so the label and the heart never
   needed pointer-events of their own — what this adds is forgiveness at the
   edges, which is the difference between a button that always fires and one that
   occasionally does not on a 360px screen. Nothing else in the project may put a
   background on .btn::after. */
.btn{
  position:relative;
  display:inline-flex;
  align-items:center;
  justify-content:center;
  gap:.55em;
  min-height:48px;              /* comfortable thumb target at any label length */
  min-width:12.5em;
  padding:.9em 1.7em;
  border:0;
  border-radius:999px;
  font-family:var(--f-sans);
  font-size:13px;
  font-weight:600;
  letter-spacing:.1em;
  text-align:center;
  white-space:nowrap;
  cursor:pointer;
  isolation:isolate;
  pointer-events:auto;          /* layers are none; controls opt back in */
  /* Kills the ~300ms tap delay and the double-tap-to-zoom gesture. Without this a
     phone waits to see whether the tap is the first half of a zoom, which reads as
     the button being slow or having missed. */
  touch-action:manipulation;
  transform:translateZ(0);
  transition:transform .4s var(--e-out), box-shadow .4s var(--e-out), filter .4s var(--e-out);
}
/* the forgiving hit area — invisible, and deliberately has no background */
.btn::after{
  content:'';
  position:absolute;
  inset:-10px;
  border-radius:999px;
}

/* the quiet one, and the default nearly everywhere */
.btn--small{
  color:var(--cream);
  background:linear-gradient(158deg,
    rgba(255,255,255,.15) 0%,
    rgba(255,255,255,.05) 48%,
    rgba(255,255,255,.02) 100%);
  border:1px solid rgba(255,255,255,.24);
  backdrop-filter:blur(14px) saturate(150%);
  -webkit-backdrop-filter:blur(14px) saturate(150%);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.34),
    inset 0 -10px 20px rgba(138,32,73,.22),
    0 10px 26px rgba(0,0,0,.42);
  text-shadow:var(--halo);
}
.btn--small::before{
  content:'';
  position:absolute; inset:-30%;
  z-index:-1;
  border-radius:999px;
  background:radial-gradient(circle,rgba(226,115,155,.3) 0%,rgba(226,115,155,0) 68%);
  opacity:0;
  transition:opacity .5s var(--e-out);
}
/* a slow breath so it reads as waiting for her, without demanding attention */
.cta.is-in .btn--small::before{
  animation:cta-breathe 4.5s var(--e-inout) 1.2s infinite;
}
@keyframes cta-breathe{
  0%,100%{ opacity:.35; }
  50%    { opacity:1;   }
}

/* The two warm moments: opening the door, and saying yes. Same pill, same size —
   it simply glows more.
   Retuned off the solid magenta gradient it used to be. That purple-pink appears
   in none of the eight paintings, and set against them it was the one element on
   the site that looked like a web component rather than part of the world. This is
   the same glass as .btn--small with warm light behind it, which keeps the whole
   set reading as one family while still marking these two taps as the big ones. */
.btn--primary{
  color:#fff9f0;
  background:linear-gradient(158deg,
    rgba(255,204,150,.30) 0%,
    rgba(232,132,164,.22) 46%,
    rgba(168,64,120,.18) 100%);
  border:1px solid rgba(255,212,174,.52);
  backdrop-filter:blur(14px) saturate(150%);
  -webkit-backdrop-filter:blur(14px) saturate(150%);
  box-shadow:
    inset 0 1px 0 rgba(255,238,216,.58),
    inset 0 -10px 22px rgba(138,32,73,.28),
    0 14px 34px rgba(0,0,0,.44),
    0 0 42px rgba(255,172,128,.3);
  text-shadow:0 1px 2px rgba(52,10,34,.75);
}
.btn--primary::before{
  content:'';
  position:absolute; inset:-38%;
  z-index:-1;
  border-radius:999px;
  background:radial-gradient(circle,rgba(255,166,132,.4) 0%,rgba(255,166,132,0) 66%);
  opacity:.75;
  transition:opacity .4s var(--e-out);
}
.btn:active{ transform:translateZ(10px) scale(.97); filter:brightness(1.08); }
.btn:focus-visible{ outline:2px solid rgba(255,214,180,.9); outline-offset:4px; }

@media (hover:hover){
  .btn:hover{
    transform:translateZ(24px) translateY(-3px) scale(1.028);
    filter:brightness(1.07);
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,.55),
      inset 0 -10px 22px rgba(88,20,64,.36),
      0 22px 52px rgba(168,64,154,.5),
      0 0 70px rgba(226,115,155,.45);
  }
  .btn:hover::before{ opacity:1; }
}

/* ========================================================= film finish === */
.finish{ position:fixed; inset:0; pointer-events:none; z-index:40; }

.finish__vignette{
  position:absolute; inset:0;
  background:radial-gradient(112% 92% at 50% 44%,
    rgba(0,0,0,0)   34%,
    rgba(0,0,0,.14) 56%,
    rgba(0,0,0,.40) 78%,
    rgba(0,0,0,.72) 100%);
}
/* self-contained grain via inline SVG turbulence — no external asset */
.finish__grain{
  position:absolute; inset:-50%;
  opacity:.04;
  mix-blend-mode:overlay;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
  animation:grain 1.1s steps(4) infinite;
}
@keyframes grain{
  0%  { transform:translate3d(0,0,0);     }
  25% { transform:translate3d(-3%,2%,0);  }
  50% { transform:translate3d(2%,-3%,0);  }
  75% { transform:translate3d(-2%,-2%,0); }
  100%{ transform:translate3d(3%,1%,0);   }
}

/* =============================================================== curtain === */
.curtain{
  position:fixed; inset:0;
  z-index:90;
  background:radial-gradient(120% 100% at 50% 60%,#150a1e 0%,#040209 100%);
  opacity:1;
  pointer-events:none;
  transition:opacity 1.5s var(--e-inout);
}
body.is-ready .curtain{ opacity:0; }

/* ================================================================ devbar === */
.devbar{
  position:fixed;
  left:50%; bottom:10px;
  transform:translateX(-50%);
  z-index:100;
  display:flex;
  flex-wrap:wrap;
  justify-content:center;
  gap:4px;
  max-width:96vw;
  padding:6px 8px;
  border-radius:11px;
  background:rgba(8,4,14,.9);
  border:1px solid rgba(255,255,255,.12);
  backdrop-filter:blur(10px);
  -webkit-backdrop-filter:blur(10px);
  box-shadow:0 10px 30px rgba(0,0,0,.6);
  font-family:ui-monospace,'Cascadia Mono',Consolas,monospace;
  font-size:9px;
}
.devbar[hidden]{ display:none; }
.devbar__btn{
  padding:5px 8px;
  border-radius:6px;
  border:1px solid rgba(255,255,255,.14);
  background:rgba(255,255,255,.05);
  color:rgba(255,250,242,.72);
  cursor:pointer;
  transition:background .2s,color .2s;
}
.devbar__btn.is-current{
  background:linear-gradient(112deg,#a8409a,#e0679a);
  color:#fff;
  border-color:transparent;
}
.devbar__tag{
  align-self:center;
  padding:0 5px;
  color:rgba(255,250,242,.34);
  letter-spacing:.14em;
  text-transform:uppercase;
}

/* ================================================= short phones (SE class) === */
/* 375x667 lands here. Copy lifts so the hint clears the painted sunset. */
@media (max-height:700px){
  /* One token moves every floating button on every screen. On a 667-tall frame
     14% is only ~93px and the copy above needs the room, so it comes in a little. */
  :root{ --cta-floor:11%; }

  .content{ top:39%; width:min(400px,94vw); }
  .wordmark{ font-size:clamp(62px,20vw,96px); }
  .lede{ font-size:15px; }
  .lede--soft{ font-size:13px; }
  .clock{ margin:18px auto 14px; max-width:266px; gap:9px; }
  .clock__tile{ padding:12px 6px 10px; }
  .clock__value{ font-size:31px; }
  .datestamp{ margin-bottom:12px; }

  /* Must be restated: .content--login has the same specificity as .content, and
     the rule above comes later in the file, so it would otherwise win. */
  .content--login{ top:46%; width:min(340px,90vw); }
  .greeting{ font-size:25px; }
  .card{ margin:16px 0 14px; padding:17px 15px 15px; border-radius:20px; }
  .field{ margin-bottom:11px; }
  .field__box{ padding:10px 12px; border-radius:12px; }
  /* .field__input's font-size is NOT reduced here, and must not be. iOS Safari
     zooms the entire page in when a focused input is under 16px, and on a fixed
     full-screen 3D scene that is not something the page comes back from. The old
     .field__value rule that lived here dropped to 15px, which was safe only because
     it was a span. Take the height out of the box padding instead if it is ever
     needed. */
  .field__box{ padding:9px 12px; }
  /* Two classes, to match the base rule — which is now `.card .card__go` so it can
     beat `.btn`. A plain `.card__go` here would be (0,1,0) and would LOSE its
     margin-top to the base rule's margin shorthand, silently putting 18px back. */
  .card .card__go{ margin-top:13px; padding:.94em 1.3em; font-size:13px; }

  /* Restated for the same specificity reason as .content--login above.
     8% rather than the base 11%: a 667-tall frame has less sky to spend, but it
     still needs to be off the very top edge — see the base rule. */
  .content--candles{ top:8%; width:min(340px,92vw); }
  .beat-title{ font-size:29px; }
  /* a 667-tall frame crops the 9:16 art less, so the empty band sits a little
     higher relative to the viewport */
  .content--sky{ top:35%; width:min(340px,92vw); }
  .content--gallery{ top:3.5%; width:min(340px,92vw); }
  .content--gallery .beat-title{ font-size:25px; }

  /* ### Leaving this out was a real bug. ###
     Without it the generic .content{ top:39% } above wins — equal specificity,
     later in the file — so the tree copy landed straight across the canopy,
     unreadable, on every 375x667 screen. Third time this trap has bitten:
     EVERY per-scene .content--* override must be restated in EVERY media
     block, after the generic rule. */
  .content--tree{ top:3.5%; width:min(340px,92vw); }
  .content--tree .beat-title{ font-size:26px; }

  /* The letter's text box is NOT tuned here any more — it comes from
     Letter.layout(), measured off the painted sheet, so it is already correct at
     every viewport shape and a media query would only fight it. A 667-tall screen
     crops the 9:16 artwork less, so the page is if anything roomier. Only the
     type needs easing in, to fit a line or two more on the visible sheet. */
  .letter__hi{ font-size:29px; }
  .letter__p{ font-size:13.6px; line-height:1.7; }
  .letter__sign{ font-size:25px; }
}

/* ============================================== laptop (courtesy, not spec) === */
/* Mobile is the deliverable. This block only stops the page looking broken on a
   desktop browser — do not invest effort here. */
@media (min-width:721px){
  .scene{ perspective:1400px; }

  .content{ top:44%; width:min(760px,86vw); }
  /* restated for the same specificity reason as in the short-phone block */
  .content--login{ top:48%; width:min(420px,60vw); }
  .greeting{ font-size:38px; }

  .eyebrow{ font-size:12px; letter-spacing:.46em; text-indent:.46em; }
  .wordmark{ font-size:clamp(96px,9vw,168px); }
  .wordmark__glow{ filter:blur(30px); }
  .lede{ font-size:clamp(19px,1.6vw,26px); max-width:22em; }
  .lede--soft{ font-size:clamp(15px,1.3vw,20px); }

  /* room for a single row again */
  .clock{
    display:flex;
    justify-content:center;
    gap:18px;
    max-width:none;
    margin:34px auto 24px;
    perspective:800px;
  }
  .clock__tile{ width:clamp(88px,8vw,116px); padding:20px 8px 15px; border-radius:20px; }
  .clock__tile:nth-child(1){ transform:translateZ(18px) rotateY( 9deg); }
  .clock__tile:nth-child(2){ transform:translateZ(30px) rotateY( 3deg); }
  .clock__tile:nth-child(3){ transform:translateZ(30px) rotateY(-3deg); }
  .clock__tile:nth-child(4){ transform:translateZ(18px) rotateY(-9deg); }
  .clock__value{ font-size:clamp(38px,3.4vw,52px); }
  .clock__label{ font-size:10px; letter-spacing:.26em; text-indent:.26em; }

  .datestamp{ gap:22px; margin-bottom:22px; }
  .datestamp__text{ font-size:21px; letter-spacing:.18em; text-indent:.18em; }
  .datestamp__rule{ width:clamp(40px,7vw,96px); }
  .hint{ font-size:13px; letter-spacing:.14em; text-indent:.14em; max-width:none; }

  /* a 9:16 painting on a wide screen crops to a narrow strip, so ease the scrim
     and let the artwork breathe */
  .backdrop__scrim{
    inset:-5%;
    background:linear-gradient(180deg,
      rgba(8,3,14,.42)  0%,
      rgba(10,4,18,.28) 16%,
      rgba(10,4,18,.50) 38%,
      rgba(10,4,18,.50) 56%,
      rgba(10,4,18,.32) 70%,
      rgba(10,4,18,.10) 82%,
      rgba(10,4,18,0)   90%);
  }
  .fx--bokeh{ filter:blur(9px); opacity:.6; }

  /* restated for the same specificity reason as .content--login above */
  .content--candles{ top:9%; width:min(480px,60vw); }
  .content--sky{ top:38%; width:min(520px,60vw); }
  .content--gallery{ top:6%; width:min(560px,60vw); }
  /* restated for the same reason as in the short-phone block */
  .content--tree{ top:6%; width:min(520px,60vw); }
  /* On a wide screen `cover` crops a 9:16 painting top and bottom, so the painted
     sheet loses its head and its feet and the text column shrinks with it. That is
     inherent to the artwork and it is not worth fixing here — this block exists so
     a laptop does not look broken, and mobile is the deliverable. The type is
     eased up so what IS visible reads properly. */
  .letter__p{ font-size:16px; }
  .letter__hi{ font-size:40px; }
  .letter__sign{ font-size:34px; }
  .beat-title{ font-size:42px; }
}

/* ================================================================= sound ===
   The music control. Small, in the top-left corner, fixed outside the 3D stage.

   TOP-LEFT and not top-right: the gallery's lightbox close button lives top-right,
   and two round glass buttons in the same corner on the same screen is exactly the
   kind of collision that only shows up on the one screen you did not check.

   ### It breathes while it is off. ###
   That is doing a job, not decorating. Before her birthday there is nothing on the
   countdown she has to touch, so there may be no gesture for a long time — and
   without a gesture there can be no music. A control that gently pulses is an
   invitation she will notice and understand without a word of instruction. It stops
   pulsing the moment it is on, because then it has nothing left to ask for. */
.sound{
  position:fixed;
  top:calc(env(safe-area-inset-top,0px) + 14px);
  left:calc(env(safe-area-inset-left,0px) + 14px);
  z-index:92;
  display:flex;
  align-items:flex-end;
  justify-content:center;
  gap:2px;
  width:38px; height:38px;
  padding:0;
  border:1px solid rgba(255,255,255,.2);
  border-radius:50%;
  background:rgba(255,255,255,.07);
  backdrop-filter:blur(10px) saturate(140%);
  -webkit-backdrop-filter:blur(10px) saturate(140%);
  box-shadow:0 6px 18px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.24);
  cursor:pointer;
  touch-action:manipulation;
  appearance:none;
  -webkit-appearance:none;
  transition:opacity .5s var(--e-out), transform .3s var(--e-out),
             border-color .4s var(--e-out);
  animation:sound-invite 3.6s var(--e-inout) 2s infinite;
}
.sound[hidden]{ display:none; }
.sound:active{ transform:scale(.92); }
.sound:focus-visible{ outline:2px solid rgba(255,214,180,.9); outline-offset:3px; }
/* the same forgiving edge every other control gets */
.sound::after{
  content:'';
  position:absolute;
  inset:-9px;
  border-radius:50%;
}
@keyframes sound-invite{
  0%,100%{ box-shadow:0 6px 18px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.24),
                      0 0 0 0 rgba(255,196,140,0); }
  50%    { box-shadow:0 6px 18px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.24),
                      0 0 16px 2px rgba(255,196,140,.3); }
}

/* four little bars. Flat and dim when off; dancing when on. Clearer than a speaker
   icon at 38px, and it belongs to a scene rather than to an interface. */
.sound__bars{
  display:flex;
  align-items:flex-end;
  gap:2px;
  height:14px;
}
.sound__bars i{
  display:block;
  width:2.5px;
  height:3px;
  border-radius:2px;
  background:rgba(255,232,206,.55);
  transition:height .4s var(--e-out), background .4s var(--e-out);
}
.sound.is-on{
  border-color:rgba(255,206,168,.44);
  animation:none;                    /* it has nothing left to ask for */
}
.sound.is-on .sound__bars i{
  background:rgba(255,222,180,.95);
  box-shadow:0 0 6px rgba(255,190,130,.7);
  animation:sound-bar 1.15s var(--e-inout) infinite;
}
.sound.is-on .sound__bars i:nth-child(1){ animation-delay:0s;    }
.sound.is-on .sound__bars i:nth-child(2){ animation-delay:.16s;  }
.sound.is-on .sound__bars i:nth-child(3){ animation-delay:.32s;  }
.sound.is-on .sound__bars i:nth-child(4){ animation-delay:.48s;  }
@keyframes sound-bar{
  0%,100%{ height:3px;  }
  50%    { height:13px; }
}

/* ============================================================ preparation ===
   The world getting ready, on the landing screen only.

   Everything about this is chosen so it does not read as a loading bar: one pixel
   tall, pinned to the extreme bottom edge, dim gold fading in from nothing on the
   left, no track behind it, no number, no text. At that size and in that position it
   reads as a horizon line or the edge of a film frame — something in the scene
   rather than something on top of it.

   It is `hidden` by default and only unhidden by Prep if there is genuinely still a
   wait after 1.4s, so on every visit after the first it never exists. */
.prep{
  position:absolute;
  left:0; right:0; bottom:0;
  height:1px;
  z-index:6;
  opacity:0;
  transition:opacity 1.3s var(--e-out);
  pointer-events:none;
}
.prep[hidden]{ display:none; }
.prep.is-in{ opacity:1; }
.prep i{
  display:block;
  width:0%;
  height:100%;
  /* fades in from nothing on the left so it has no visible start point */
  background:linear-gradient(90deg,
    rgba(255,214,150,0)   0%,
    rgba(255,214,150,.35) 45%,
    rgba(255,228,180,.8)  100%);
  box-shadow:0 0 7px rgba(255,196,120,.5);
  /* slow, because the numbers arrive in steps and it should glide between them
     rather than jump nine times */
  transition:width 1s var(--e-out);
}

/* ======================================================== touch feedback ===
   Two or three small hearts wherever she touches, anywhere on the site.

   ### Why this is worth having at all. ###
   Almost everything on these eight screens reacts on a delay — a balloon bursts,
   a shell climbs for most of a second, a candle catches a beat later, a scene
   crossfades over a second. On a phone, with no cursor and no hover, that leaves
   a real gap where she cannot tell whether her tap registered. Three hearts at the
   contact point close that gap instantly and cost nothing.

   ### Why it lives outside the 3D stage. ###
   .taps is a direct child of <body>, positioned fixed, in raw viewport
   coordinates. Inside a scene it would inherit the stage's perspective and the
   layer's parallax, so a heart would appear offset from the finger that made it —
   and the deeper the layer, the worse the miss.

   The heart is an SVG path, the same one used for the letter's seal and the bow's
   target, NOT the ❤ character: U+2764 renders as a full-colour emoji on most
   phones, which ignores `fill` and would drop a glossy Android glyph into the
   middle of a hand-painted scene. */
/* ==================================================== THE GATE — the ask ===
   The camera consent, asked once at the start of the visit, over the countdown.
   Moved here from Scene 2 on 15 Sept 2026 — see the long note in index.html.

   A dimming veil plus a centred glass panel. Deliberately NOT the .through
   layout it replaced: that one is pinned top-and-bottom to keep the couple in
   bg-login-2 uncovered, and there is no such subject to protect here. The
   countdown behind it is meant to be veiled — the moment she answers, the veil
   lifts and the scene comes up to full, which is a nicer reveal than a dialog
   simply vanishing.

   z-index 95: above the scenes and above the sound button (92), below the dev
   bar (100). It is only ever shown after the curtain has gone. */
.gate{
  position:fixed;
  inset:0;
  z-index:95;
  display:flex;
  align-items:center;
  justify-content:center;
  padding:24px;
  /* The panel opts back in below. The veil must take taps too, so nothing behind
     it can be pressed while a question is on screen. */
  pointer-events:auto;
  opacity:0;
  transition:opacity .7s var(--e-out);
}
.gate[hidden]{ display:none; }
.gate.is-in{ opacity:1; }

/* Warm rather than neutral grey — it is lamplight being held back, not a modal
   scrim. Radial so the centre of the frame stays the brightest part. */
.gate__veil{
  position:absolute;
  inset:0;
  background:
    radial-gradient(120% 80% at 50% 46%, rgba(10,3,16,.62) 0%, rgba(8,2,14,.86) 68%, rgba(6,1,11,.94) 100%);
}

.gate__panel{
  position:relative;
  isolation:isolate;
  width:min(360px,92vw);
  padding:26px 22px 20px;
  border-radius:24px;
  text-align:center;
}
/* glass on its own element so backdrop-filter never blurs the text — the same
   split the login card uses, for the same reason */
.gate__glass{
  position:absolute; inset:0;
  z-index:-1;
  border-radius:inherit;
  background:linear-gradient(158deg,
    rgba(255,255,255,.16) 0%,
    rgba(255,255,255,.05) 44%,
    rgba(255,255,255,.02) 100%);
  border:1px solid var(--glass-line);
  backdrop-filter:blur(18px) saturate(150%);
  -webkit-backdrop-filter:blur(18px) saturate(150%);
  box-shadow:
    inset 0 1px 0 var(--glass-line-hi),
    inset 0 -26px 44px rgba(138,32,73,.20),
    0 24px 54px rgba(0,0,0,.58);
}

.gate__title{
  margin:0 0 .55em;
  font-family:var(--f-serif);
  font-weight:500;
  font-size:clamp(23px,6.6vw,29px);
  line-height:1.18;
  color:var(--cream);
  text-shadow:var(--halo);
}
.gate__body{
  margin:0 0 .8em;
  font-family:var(--f-serif);
  font-size:clamp(14px,3.9vw,15px);
  line-height:1.68;
  color:rgba(255,240,224,.86);
  text-shadow:var(--halo);
}

/* "Your phone will ask you next — please tap Allow." A small warm plate behind it,
   because this is the one instruction in the whole site and it has to survive being
   skim-read over a bright part of the artwork. Not a rectangle: rounded and tinted
   just enough to read as a note pinned to the panel rather than a warning box. */
.gate__ask{
  margin:0 0 .9em;
  padding:.7em .9em;
  border-radius:12px;
  background:rgba(255,186,132,.10);
  border:1px solid rgba(255,196,150,.22);
  font-family:var(--f-sans);
  font-size:clamp(11.5px,3.1vw,12.5px);
  line-height:1.55;
  letter-spacing:.01em;
  color:rgba(255,232,206,.92);
  text-shadow:var(--halo);
}
.gate__ask em{
  font-style:normal;
  font-weight:700;
  color:rgba(255,214,170,1);
}

/* ---- the how-to-unblock line ----
   Hidden unless .gate.is-blocked, which only happens when the browser has already
   recorded a Block. Rose rather than amber so it reads as a different KIND of
   sentence from .gate__ask above it — that one is "something is about to happen",
   this one is "something needs your hand". Still not styled as an error: no red, no
   icon, no border-left bar. She has not done anything wrong.

   display:none rather than opacity, so it takes no vertical space in the normal case
   and the panel does not have a mysterious gap in it. */
.gate__blocked{ display:none; }
.gate.is-blocked .gate__blocked{
  display:block;
  margin:0 0 .9em;
  padding:.7em .9em;
  border-radius:12px;
  background:rgba(198,84,120,.14);
  border:1px solid rgba(226,130,160,.26);
  font-family:var(--f-sans);
  font-size:clamp(11.5px,3.1vw,12.5px);
  line-height:1.55;
  color:rgba(255,226,224,.94);
  text-shadow:var(--halo);
}
.gate__blocked em{
  font-style:normal;
  font-weight:700;
  color:rgba(255,206,214,1);
}

/* With the extra line present the panel gets tall on a short phone, so let it
   scroll rather than push the buttons off the bottom of the screen. Only when
   blocked — the normal panel always fits. */
.gate.is-blocked .gate__panel{
  max-height:86vh;
  overflow-y:auto;
  overscroll-behavior:contain;
}

/* "Say no and everything still happens." Deliberately the brightest line in the
   panel and set apart from the body — if she reads one sentence here it should be
   this one, because it is the one that makes "no" a genuinely free choice. */
.gate__note{
  margin:0 0 1.3em;
  font-family:var(--f-serif);
  font-style:italic;
  font-size:clamp(13.5px,3.7vw,14.5px);
  line-height:1.6;
  color:rgba(255,232,204,.96);
  text-shadow:var(--halo);
}

/* Stacked and centred. The gap matters: .btn::after is an invisible 10px hit
   expander on each, so anything tighter would let the two targets overlap and "no"
   could catch a tap meant for "yes" — on this question of all questions. 14px plus
   the shorter ghost's own height keeps them comfortably apart. See §6b. */
.gate__choice{
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:14px;
}

/* ### One of these two is clearly the answer. ###
   Sai's call, 15 Sept 2026; moved here from .through__btn when the ask moved to the
   start of the visit. They used to be a primary and a ghost of identical size, which
   read as a genuine either/or — and it is not one. It is an ask with a polite way to
   decline.

   So "yes" grows: wider, taller, larger type, a brighter rim and a slow breathing
   glow, so it is the thing her eye lands on. And "no" shrinks to a quiet line of text
   in a pill — still a real 40px-plus target with its own hit expander, still perfectly
   easy to choose, just no longer competing for the decision.

   The one thing not to do here is make "no" look like a mistake. It keeps its border,
   it keeps its legibility, and CONSENT_COPY.foot still says the choice is respected.
   Quieter is not the same as discouraged. This is the one sanctioned exception to the
   one-button rule besides the sky button, and it exists because a consent dialog with
   two equal buttons is a worse thing than an inconsistent pill. */
.gate__btn.btn--primary{
  min-width:15em;
  min-height:56px;
  font-size:14px;
  letter-spacing:.11em;
  border-color:rgba(255,222,190,.66);
  box-shadow:
    inset 0 1px 0 rgba(255,244,226,.66),
    inset 0 -12px 26px rgba(138,32,73,.3),
    0 18px 40px rgba(0,0,0,.48),
    0 0 54px rgba(255,176,132,.4);
}
.gate__btn.btn--primary::before{ animation:cta-breathe 4.5s var(--e-inout) 1s infinite; }

.gate__btn.btn--ghost{
  min-width:9.5em;
  min-height:40px;
  padding:.55em 1.2em;
  font-size:11px;
  font-weight:500;
  letter-spacing:.13em;
  color:rgba(255,244,232,.62);
  border-color:rgba(255,255,255,.14);
  box-shadow:none;
  backdrop-filter:blur(8px);
  -webkit-backdrop-filter:blur(8px);
}

.gate__foot{
  margin:1.15em 0 0;
  font-family:var(--f-sans);
  font-size:10px;
  letter-spacing:.13em;
  text-transform:uppercase;
  color:rgba(255,236,214,.52);
  text-shadow:var(--halo);
}

@media (max-height:700px){
  .gate__panel{ padding:20px 18px 16px; border-radius:20px; }
  .gate__title{ font-size:clamp(21px,6.2vw,26px); }
  .gate__body{ margin-bottom:1.1em; line-height:1.58; }
}

.taps{
  position:fixed;
  inset:0;
  z-index:90;                 /* over the scenes, under the curtain and dev bar */
  overflow:hidden;
  pointer-events:none;
}
.tap{
  position:absolute;
  width:var(--sz,12px);
  height:var(--sz,12px);
  /* JS writes left/top as the exact contact point; this centres the heart on it
     WITHOUT a transform, because the keyframes below own transform outright and
     any base transform there would have to be restated in every single frame. */
  margin-left:calc(var(--sz,12px) / -2);
  margin-top:calc(var(--sz,12px) / -2);
  will-change:transform,opacity;
  /* 1500, up from 900 — they were popping and vanishing before she could register
     them as hearts. Slower also means gentler: the same rise spread over two thirds
     more time reads as drifting up rather than being flicked away. */
  animation:tap-heart 1500ms var(--e-out) var(--d,0ms) forwards;
}
.tap svg{
  display:block;
  width:100%;
  height:100%;
  fill:var(--c,#ff3b6b);
  /* the glow is what lets one small red heart read on BOTH a near-black garden
     and the cream paper of the letter, without changing colour per scene */
  filter:drop-shadow(0 0 5px rgba(255,54,104,.75))
         drop-shadow(0 1px 1px rgba(60,0,16,.5));
}
/* The pop is still quick — it has to land under her finger immediately or it is not
   feedback — but it now holds at full strength through the middle before drifting
   off, instead of starting to fade the moment it arrives. */
@keyframes tap-heart{
  0%  { opacity:0; transform:translate(0,0) scale(.35) rotate(0deg); }
  12% { opacity:1;
        transform:translate(calc(var(--dx) * .18),calc(var(--rise) * .12))
                  scale(1.16) rotate(calc(var(--rot) * .2)); }
  55% { opacity:.92;
        transform:translate(calc(var(--dx) * .62),calc(var(--rise) * .58))
                  scale(1) rotate(calc(var(--rot) * .66)); }
  100%{ opacity:0;
        transform:translate(var(--dx),var(--rise)) scale(.72) rotate(var(--rot)); }
}

/* ============================================================== a11y/misc === */
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{
    animation-duration:.001ms !important;
    animation-iteration-count:1 !important;
    transition-duration:.001ms !important;
  }
  .stage{ transform:none !important; }
  .backdrop{ transform:scale(1.05) !important; }

  /* ### The letter cannot take that forced scale. ###
     Letter.layout() places the text column from the artwork's own geometry, so a
     5% enlargement of the painting here would leave every word 5% off the painted
     sheet. inset:-5% already guarantees no edge can show without it, and there is
     no drift on this scene to hide in the first place.
     NOTE: .scene--candles has the same exposure — .candleField is not scaled, so
     under reduced motion the five flames would sit ~5% off their wicks. Left
     alone because that screen is approved and this is not the change to make in
     it, but it is the same one-line fix if it ever shows up. */
  .scene--letter .backdrop{ transform:none !important; }

  [data-seq-item]{ opacity:1; transform:none; filter:none; }
}
