/* ─────────────────────────────────────────────────────────────
   Direction — the deck you spin

   The landing surface is a vertical deck of plans fenn could run
   for you. One card sits at the centre of the screen, expanded;
   its neighbours peek above and below, minimised. Scroll and the
   cards morph between the two as they cross the middle, the CTA
   scaling in and out with them. The scroll is magnetic: every rest
   is a card at the centre.

   Behind it the sky moves at its own speeds — six cloud layers and
   fenn himself, floating past as a hot air balloon, each on its own
   parallax factor so the depth is felt rather than drawn.

   And when nothing on screen appeals, the bottom bar offers two ways
   out: ask fenn for something that is not on a card, or let the
   sparkles spin the deck like a slot machine — fast, blurred, stretched
   by its own velocity, then a long smooth landing on a plan you didn't
   pick.

   The plans climb from casual to absurd the further you go. That
   ladder IS the narrative — there is no intro to sit through and no
   gate in front of the door.

   Built on the V02 sky (Figma rUeKvnDiKvUmt5maQU9cxW, section
   Sky_Prototype 2435:43609). The card's numbers are its two
   variants, 2435:43611 expanded and 2435:43610 minimised, read off
   the node rather than eyeballed. */

:root {
  --sky-light: linear-gradient(-4.68deg,
      rgba(242, 242, 242, 0.4) 47.7%,
      rgba(0, 180, 255, 0.4) 109.9%,
      rgba(0, 55, 255, 0.4) 133.1%);
  /* The Figma pills' layered shadow, trimmed to its visible layers. */
  --pill-shadow: 0 5px 12px rgba(173, 179, 189, 0.15),
                 0 21px 21px rgba(173, 179, 189, 0.13);
  /* The card carries the same ramp plus one wider layer — it is the
     biggest raised surface on the screen. */
  --card-shadow: 0 5px 12px rgba(173, 179, 189, 0.15),
                 0 21px 21px rgba(173, 179, 189, 0.13),
                 0 47px 28px rgba(173, 179, 189, 0.07);
}

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

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  font-family: var(--font-sans);
  background: var(--sky-light) fixed, var(--surface-primary);
  color: var(--text-primary);
  letter-spacing: var(--tracking);
  -webkit-font-smoothing: antialiased;
  /* The deck is the only scroller on the page. */
  overflow: hidden;
}

/* ── The sky, at six speeds ──────────────────────────────────
   The V02 frames' decorative layer (plans/assets/sky-*.svg) plus
   fenn as a balloon, exported from the prototype's own background
   (node 2426:42744). Fixed behind the deck, never catching a tap.

   The deck writes one number per frame — --p, its scroll as 0..1 —
   and each layer multiplies it by its own --tv, the total distance
   that layer travels across the whole deck. Different distances for
   the same gesture is the parallax; a *total* rather than a rate is
   what keeps every layer in frame from the first card to the last.
   (A plain scroll x factor sends them off the top: the deck is
   4000px long, so even 0.2 throws a cloud 800px away.)
   Transform only, so none of it costs a layout. */
.sky {
  position: fixed; inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
/* The three fenns live in #mascots now, above the scenes rather than inside the
   sky they used to share (D49). Same rule, same --tv, same --bx: only the
   parent moved, so nothing about their travel or their anchors changes. */
.sky img, #mascots img {
  position: absolute;
  display: block;
  transform: translate3d(var(--bx, 0px), calc(var(--tv, 0px) * var(--p, 0) * -1), 0)
             rotate(var(--rot, 0deg));
}
.sky .c-a { width: 320px; right: -160px; top: 62%; --tv: 150px; }
.sky .c-b { width: 300px; left: -170px; top: 38%; --rot: 15deg; --tv: 150px; }
.sky .c-c { width: 78px; left: 14px; top: 66%; --tv: 100px; }
.sky .c-d { width: 40px; right: 40px; top: 26%; --tv: 50px; }
.sky .puff { width: 22px; left: 10px; top: 58%; --tv: 40px; }
.sky .bird { width: 20px; right: 54px; top: 84%; --tv: 70px; }
/* Nearest of the background things, so it travels furthest and reads
   as the layer just behind the cards: fenn rising past you as you dig
   deeper into the deck. */
/* Tucked behind card zero at rest, and carried clear above it by his own 150px
   rise as you dig into the deck. Passing *through* the card's top edge on the
   way is the effect, not a fault — what is a fault is a few pixels of him
   showing at rest, which is what 26% did (and then 30%, at 430x932).

   So he is anchored to the card like the plane and the astronaut below, not to
   the viewport: 170px above centre puts him 8px inside the top edge of the
   tallest card at every height, and his full rise still lands him 8px below the
   wordmark rather than behind it. */
#mascots .balloon { width: 62px; right: 12%; top: calc(50% - 170px); --tv: 150px; }

/* Two more of him, from the mascot sheet (Figma 2454:44901) — fenn drawn as the
   object of a plan. Twenty exist; these are the two that can honestly be in a
   sky, which is why they were picked over a wine glass or a slice of pizza
   floating past. Seventeen more are unused — see D30.

   Not the fenn drawn as the northern lights, though he belongs in a sky as much
   as these do: he is a four pointed star, which is the jackpot button's own
   glyph, and the two cannot share a screen without the background looking like
   a control.

   The sky is position: fixed, so nothing here ever arrives with the scroll —
   every prop is on screen the whole time, and placement is a packing problem
   rather than a timing one. A card leaves two bands clear, above it and between
   it and the bar, and each of these sits in one of them.

   Anchored to the card, not to the viewport. A percentage top cannot work: the
   card is a fixed pixel height centred in the viewport, so a taller viewport
   slides the card down past a fixed percentage and a shorter one lets the card
   eat the band whole — either way the mascot ends up half behind it. That is
   not hypothetical, it is what the balloon does at 430x932 above. calc(50% ± …)
   tracks the card's own edges instead, offset by the *tallest* card's half
   height (178px, card zero) so the clearance holds on every card.

   Travel is 30px, not the balloon's 150. That is a ceiling, not a taste: a prop
   has to stay inside its band across the whole sweep, and the upper band is only
   ~196px tall on a 390x844 phone — a 70px plane rising 120px does not fit in it,
   however good it looks at rest.

   Nothing here may be *partly* behind a card: fully tucked or fully clear. The
   plane began at right: -8%, and a cartoon sliced through its fuselage reads as
   a broken image rather than as a plane leaving frame. */
#mascots .plane { width: 88px; right: 4%; top: calc(50% - 256px); --rot: -8deg; --tv: 30px; }
#mascots .astro { width: 62px; left: 7%; top: calc(50% + 216px); --tv: 30px; }

/* Short viewports have no sky to spare, and a mascot that has to overlap a card
   to exist is worse than no mascot.

   737 is measured, not rounded. Two constraints, and the larger wins. The
   astronaut needs cardBottom + 8 clearance + 30 of rise + his own 49px to land
   8px above the bar, which needs 734px. The balloon's 150px rise has to clear
   the wordmark by 8px from 170px above centre, which needs 738px. So below 738
   the sky carries clouds only. An iPhone SE (667) is well under it; an 8 Plus
   (736) misses by two. */
@media (max-height: 737px) {
  #mascots .plane, #mascots .astro, #mascots .balloon { display: none; }
}

/* ── The ten scenes ──────────────────────────────────────────
   The client's own scene frames (Figma rUeKvnDiKvUmt5maQU9cxW,
   Backgrounds / Components 2480:2825), one per triple of plans.
   Each is a CSS gradient with flat vectors placed over it, and
   each frame composes over #f2f2f2 opaque — which is what lets
   the crossfade be one-sided: the incoming scene fades up over
   the outgoing one, and the outgoing never has to fade out.

   Fixed siblings of .deck, above .sky and below the cards. Never
   slide children: a slide carries content-visibility: auto and
   would stop painting its own background off screen (D43).

   The deck writes two numbers per live scene — --mix, how far in
   it is, and --sp, where the band has got to, 0..1 — and CSS does
   the rest. Same doctrine as the sky's --p (D16): one number,
   many layers, totals rather than rates. */
#scenes, #mascots {
  position: fixed; inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
.scene {
  position: absolute; inset: 0;
  opacity: var(--mix, 0);
  /* The sunburst's rays blend soft-light. Without this they would
     reach through the scene and light the sky behind it. */
  isolation: isolate;
}
/* Two layers live at most: the one you are on, and the one arriving.
   The rest are display: none, so 122 elements cost nothing. */
.scene.off { display: none; }
.scene.in  { z-index: 2; }
.scene.out { z-index: 1; }

/* Read off each frame's own gradient fill rather than transcribed: Figma states
   a gradient as a vector across the frame with offsets along it, CSS as an angle
   with percentages along its own line, and the two lines are not the same one.
   A Figma offset of 0.16 lands at -41% in CSS, and the negative percentages
   below are load-bearing — drop the sign and the stops re-pair with the wrong
   colours, which turns Twilight Flock's grey top into its bottom. Each frame's
   fill-opacity is baked into the stops, so every scene composes opaque over
   #f2f2f2 exactly as the frame does — which is what lets §4's crossfade only
   ever fade one side in.

   Twilight's transparent-navy last stop and Crystal's zig-zag are as designed. */
[data-scene="clear-sky"] {
  background: linear-gradient(0deg, rgba(242,242,242,0.4) 27.916%, rgba(150,224,255,0.4) 55.994%, rgba(0,180,255,0.4) 107.684%),
              var(--surface-primary);
}
[data-scene="cotton-candy"] {
  background: linear-gradient(-0.971deg, rgba(255,81,186,0.8) -4.807%, rgba(0,196,255,0.8) 59.054%, rgba(221,238,246,0.8) 97.208%),
              var(--surface-primary);
}
[data-scene="misty-forest"] {
  background: linear-gradient(0deg, rgba(242,242,242,0.4) 27.916%, rgba(129,193,127,0.4) 55.994%, rgba(0,213,255,0.4) 107.684%),
              var(--surface-primary);
}
[data-scene="sunburst"] {
  background: linear-gradient(-0.153deg, rgba(255,255,255,0.8) -9.828%, rgba(187,158,255,0.8) 51.898%, rgba(0,180,255,0.8) 97.218%),
              var(--surface-primary);
}
[data-scene="desert-sunset"] {
  background: linear-gradient(0deg, rgba(255,241,223,0.4) 27.916%, rgba(255,224,150,0.4) 55.994%, rgba(255,0,89,0.4) 107.684%),
              var(--surface-primary);
}
/* The three inner stops are not in the frame: they trace the ramp Figma draws
   between the last two. Figma interpolates a gradient stop's colour and its
   alpha separately; CSS interpolates them premultiplied, which is the same
   curve until an alpha moves, and then it is not. Both scenes here end on a
   transparent navy, so CSS held the blue all the way down where Figma walks it
   to navy first — measured at 18/255 too blue on the bottom half against the
   frame's own render. Every other segment in the file holds one alpha, where
   the two agree exactly. Four sub-segments put the residual at 1/255, which is
   the rounding the rest of the file already carries (D49). */
[data-scene="twilight-flock"] {
  background: linear-gradient(5.313deg, rgba(242,242,242,0.7) -41.443%, rgba(0,180,255,0.7) -29.994%, rgba(0,72,255,0.7) -17.953%, rgba(0,56,230,0.525) -0.239%, rgba(0,41,204,0.35) 17.476%, rgba(0,26,178,0.175) 35.19%, rgba(0,10,153,0) 52.904%),
              var(--surface-primary);
}
[data-scene="crystal-glow"] {
  background: linear-gradient(-13.657deg, rgba(242,242,242,0.4) -33.503%, rgba(0,180,255,0.4) -24.526%, rgba(0,55,255,0.4) 42.377%),
              var(--surface-primary);
}
[data-scene="starry-night"] {
  background: linear-gradient(-0.971deg, rgba(54,141,255,0.8) -4.807%, rgba(0,43,255,0.8) 31.102%, rgba(0,0,238,0.8) 97.208%),
              var(--surface-primary);
}
[data-scene="dawn-meadow"] {
  background: linear-gradient(0deg, rgba(255,241,223,0.4) 27.916%, rgba(255,224,150,0.4) 55.994%, rgba(0,60,255,0.4) 107.684%),
              var(--surface-primary);
}
/* Same three inner stops on the same last segment, for the same reason. */
[data-scene="deep-ocean"] {
  background: linear-gradient(-8.417deg, rgba(242,242,242,0.7) -23.136%, rgba(0,180,255,0.7) -6.501%, rgba(0,55,255,0.7) 66.078%, rgba(0,44,230,0.525) 78.928%, rgba(0,32,204,0.35) 91.779%, rgba(0,21,178,0.175) 104.629%, rgba(0,10,153,0) 117.479%),
              var(--surface-primary);
}

/* Wider than a phone, the gradient still spans the viewport but the
   elements keep the column they were composed in — a hill drawn for
   420px does not want to be 1200 wide. */
/* Centred with auto margins rather than a translate, and that is not a style
   preference: a transform makes a stacking context, a stacking context isolates
   blending, and the sunburst's rays would have soft-lit against this column's
   own transparent backdrop instead of the scene's gradient — which composites
   identically to no blend at all. Measured: the rays came out 55 points of red
   too light before this. */
.s-col {
  position: absolute;
  top: 0; bottom: 0; left: 0; right: 0;
  margin-inline: auto;
  width: min(100vw, 480px);
}

/* Placement is the frame's own 420x912 as percentages, so a resize
   costs nothing and measure() never sees these. Width only, because
   every asset's viewBox is its own artwork exactly — so the height
   follows, and --ew is the size the frame draws it at. The rotations
   are the frame's own, on the ocean's fish and bubbles; the rest of
   these vectors are drawn already turned. */
#scenes .el {
  position: absolute;
  left:   calc(var(--ex) / 420 * 100%);
  width:  calc(var(--ew) / 420 * 100%);
  height: auto;
  opacity: var(--eo, 1);
  /* --sp .5 is the rest pose, so a scene sitting at its own middle
     shows the elements exactly where they were drawn. --d is the
     depth, 0 far to 1 near, and --s-range the whole travel a near
     element gets across a band: totals, not rates (D16). */
  transform: translate3d(0, calc((0.5 - var(--sp, 0.5)) * var(--d, 0.4) * var(--s-range, 148px)), 0)
             rotate(var(--er, 0deg));
}
#scenes .el:not([data-a="b"]) { top: calc(var(--ey) / 912 * 100%); }
/* Anything low enough to read as ground pins to the bottom edge instead,
   or a taller phone would leave it floating up the screen. */
#scenes .el[data-a="b"] { bottom: calc(var(--eyb) / 912 * 100%); }
:root { --s-range: 148px; }

/* Only Clear Sky and Misty Forest ship the bottom protect. The designer hid
   it on the dark scenes deliberately and the audit agreed, so it stays hidden
   there rather than being made scene-tinted. The two tokens the frames name
   (--surface/gradient/bg-protect-bot-*) are not in tokens.css, so the stops
   are written here — that file mirrors the client's library and is read-only
   (D10).

   The height has to be said out loud, and under #scenes so it outranks the
   `height: auto` there — every other element is an <img> and takes its own ratio
   from that, but this one is an empty <i>, so auto measured zero and the layer
   shipped nothing at all for as long as it has existed. 109 of the frame's 912,
   the same way --ey and --ew carry the frame's own numbers. It changes nothing
   you can see, and that is not an argument against saying it: both gradients are
   already flat #f2f2f2 across the bottom 27.9% — measured, 242,242,242 at y=800
   and y=850 on both, with the layer and without it. */
#scenes .el-fade {
  height: calc(109 / 912 * 100%);
  background: linear-gradient(to bottom, rgba(242, 242, 242, 0), #f2f2f2 30.77%);
}

/* The rays are one 1226px square centred on the frame's own centre, blending
   soft-light so they lift the gradient rather than paint over it. The slow
   turn is scroll-linked through --sp, not a keyframe loop: nothing on this
   page loops for decoration (D8). */
[data-scene="sunburst"] .el {
  mix-blend-mode: soft-light;
  --er: calc((var(--sp, 0.5) - 0.5) * 16deg);
}

/* Below 738px the sky already drops its mascots (above). The scenes drop their
   accent tier on the same measurement: the birds, stars, sparkles, fish and
   bubbles that sit in the middle depths and carry no structure. The gradient,
   the far layers and the ground stay, so the scene still reads as itself. */
@media (max-height: 737px) {
  #scenes .el[data-accent] { display: none; }
}

button, input, textarea { font: inherit; letter-spacing: inherit; color: inherit; }
button { cursor: pointer; border: 0; background: none; }
[hidden] { display: none !important; }
.hidden { display: none !important; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation: none !important; transition: none !important; }
}

/* iOS puts fixed elements against the *layout* viewport and then draws the
   browser toolbar on top of it, so `bottom: 0` lands roughly 79px below what
   you can actually see. --vv-bottom comes from visualViewport instead,
   refreshed as the chrome shows and hides; the deck itself is sized in svh,
   which is the *small* viewport and therefore never moves when the toolbar
   collapses — a deck whose slide heights changed mid-scroll would fight the
   snap it just settled into. */
:root {
  /* Not in tokens.css and not going in it: that file mirrors the client's Figma
     library and is read-only (D10). */
  --spark-lit: #fffa63;
  --vvh: 100svh;      /* visible height */
  --vv-bottom: 0px;   /* height of the chrome covering the bottom */
  /* A second easing curve, for one motion: the floating share row rising in
     (D51). --ease-out is cubic-bezier(0.22, 1, 0.36, 1) — quintic, and right for
     the short arrivals it was chosen for. Over that row's 88px it put half of
     them away inside three frames, which is a pop, not a rise. This is the cubic
     version: the same shape, far less front-loaded. The toast borrows it to move
     in step with the row it makes room for. */
  --ease-rise: cubic-bezier(0.33, 1, 0.68, 1);
}

/* ── The masthead ───────────────────────────────────────────
   A bar over the deck rather than a column beside it: the deck is a
   full-screen scroller, so the header floats and only the header. */
.wrap {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 6;
  max-width: 480px; margin: 0 auto;
  /* The inset is 0 in a normal Safari tab — the strip up there is browser
     chrome, not layout, and theme-color is what answers for it (D54). It is
     real in a standalone context and in landscape, and the protect scrim
     already grows with it, so the row it sits under moves down too. */
  padding: calc(18px + env(safe-area-inset-top)) var(--page-pad-x) 0;
  pointer-events: none;
}
.wrap > .top { pointer-events: auto; }
/* The deck's header and the receiver's are the same row, cloned rather than
   redrawn (D20). No back chevron on either: the deck is where you start, and
   a friend arriving on a link has nothing behind them. The opened plan draws
   its own header, which needs a chevron and a QR button in one row. */
.top { display: flex; align-items: center; gap: 0; }
.markmark { width: 21px; aspect-ratio: 61.5 / 66.5; height: auto; display: block; overflow: visible; }
.markmark .body-fill { fill: var(--surface-raised); }
.markmark .body-line { fill: none; stroke: var(--brand-black); }
.markmark .pupil { fill: var(--brand-black); }
.top span { font-size: var(--h6-size); font-weight: var(--h6-weight); }


.brand { display: inline-flex; align-items: center; gap: 5px; padding: 0; }

/* Starry Night's top band is #0000ee at 0.8, where --text-primary's #333 reads
   1.7:1 and the wordmark disappears. The ink cannot turn over to fix that,
   because the sky is not the only thing behind the word: every position the
   snap parks at puts the card above the centred one there, and white on its
   #f2f2f2 reads 1.04:1. Two grounds, and no one colour clears 4.5:1 on both
   (D49).

   So the ground moves rather than the ink. This is the masthead's top protect,
   the same treatment the scenes' bottom edge already gets — above the scenes
   and above the deck, under the masthead's own content, and never in the way of
   a tap. --protect is how much of it the sky asks for, 0..1, crossfading with
   the sky itself; nine of the ten ask for nothing, so on nine scenes this layer
   is opacity 0 and paints nothing at all. A heavy wash over a pale sky would be
   worse than the bug it fixes.

   The stops are here for the same reason the bottom protect's are: the two
   tokens the frames name for this gradient (--surface/gradient/bg-protect-top-*)
   are not in tokens.css, and that file mirrors the client's library and is
   read-only (D10). The ramp is in pixels rather than percentages so the safe
   area moves the whole thing down instead of stretching it: full strength past
   the word, out by 96.

   The mark itself needs nothing: it is a white face with black features, so its
   features are measured against their own white and it reads on either ground.
   Only the word is on the sky. */
.wrap::before {
  content: '';
  position: fixed;
  top: 0; left: 0; right: 0;
  height: calc(96px + env(safe-area-inset-top));
  z-index: -1;
  pointer-events: none;
  opacity: var(--protect, 0);
  background: linear-gradient(to bottom,
      rgba(242, 242, 242, 0.62) calc(44px + env(safe-area-inset-top)),
      rgba(242, 242, 242, 0) calc(96px + env(safe-area-inset-top)));
}

.foot { margin: 12px 0 0; font-size: var(--body-xs-size); line-height: 16px; color: var(--ink-support); text-align: center; }

.spin {
  width: 15px; height: 15px;
  border: 2px solid rgba(255,255,255,0.35);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 640ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── The deck ────────────────────────────────────────────────
   Geometry, from the prototype frame (912 tall): the centred card
   sits dead centre, its neighbours' edges 41px into view. Rather
   than hardcode that, --slide-h is derived so the peek holds at any
   height: peek = dh/2 - slide_h + 64, so slide_h = dh/2 + 24 puts it
   at 40 on every screen. Capped at 520 so a tall desktop window
   spreads rather than stretches.

   Slides are a CONSTANT height and the card morphs inside them. That
   is the load-bearing decision: scroll-snap targets are measured off
   the slides, so a morphing card can never move the thing it is
   snapping to, and scrollHeight stays put while --t animates. */
:root {
  --dh: 100vh;
  --dh: 100svh;
  --slide-h: min(calc(var(--dh) / 2 + 24px), 520px);
}

.deck {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: var(--dh);
  z-index: 1;
  overflow-y: auto;
  overscroll-behavior: contain;
  scroll-snap-type: y mandatory;
  -webkit-overflow-scrolling: touch;
  /* A scrollbar would draw a ruler down a screen whose whole idea is that
     the deck floats in the sky. */
  scrollbar-width: none;
}
.deck::-webkit-scrollbar { display: none; }
.deck-col { max-width: 480px; margin: 0 auto; }
/* Lets the first and last card reach the centre. */
.deck-pad { height: calc((var(--dh) - var(--slide-h)) / 2); }

.slide {
  /* Sized rather than uncontained, the same way D43 rescued the intro card:
     content-visibility: auto brings paint containment with it, and the fan of
     photographs hangs above the card, so below a viewport height of 711px it was
     being cut. --slide-h is 100svh/2 + 24, so on short phones it falls under the
     card plus its overhang: measured 10.75px of fan clipped at 375x667 (iPhone
     SE 2/3 and 8, the width this project is told to check) and 35.5px at
     320x568. The linear fit crosses zero at 711px and reproduces D35's recorded
     −33.25px of clearance at 390x844 to within a quarter of a pixel. 390 is one
     card (230) plus twice the 72px the fan reaches above it, rounded up for the
     same reason the intro reserve is 560 and not 530 (D56).
     This grows the adjacent-card peek's floor below ~732px, which is a look, not
     just a fix — before/after shots went to Paul with the veto. */
  --plan-slide-h: max(var(--slide-h), 390px);
  height: var(--plan-slide-h);
  scroll-snap-align: center;
  display: grid;
  place-items: center;
  /* Fences the card's height animation: a morph reflows its own slide
     and nothing above it. Layout and style only — `paint` would clip
     the photo stack's overhang. */
  contain: layout style;
  /* Slides are a constant height, so the intrinsic size is exact and both the
     snap geometry and measure()'s offsetTops survive the skip. The overhang is
     safe because it only exists near --t 1, which only happens at the centre
     of the viewport, where a slide is never skipped (D35). */
  content-visibility: auto;
  contain-intrinsic-size: auto var(--plan-slide-h);
}
/* The intro card is 356px expanded where a plan card is 230, its avatar hangs
   72px above it and --card-shadow reaches ~61px below. --slide-h is derived for
   a plan card, so all three were cut: fenn's head flat at the top, the shadow
   flat at the bottom. content-visibility: auto brings paint containment with it,
   which is what does the cutting — the comment above only ever ruled out writing
   `contain: paint` by hand (D43).

   Sized rather than uncontained, so the skip optimisation survives on all
   thirty-one slides. On a 375x667 phone --slide-h is 319px, which the 356px card
   overflowed on its own before any of the overhang. */
.slide--intro {
  /* The card is centred in its slide, so the reserve is the card plus **twice**
     the largest overhang, not the card plus one of them: 356 + 2 x 131 of fenn
     standing above it = 618, rounded to 620. Sizing it as card + overhang left him
     cut by exactly the difference, 29px (D45). The shadow below needs ~61 and gets
     the same overhang for free out of that symmetry.

     Fenn now stands 87px above the card, so the arithmetic asks for 356 + 174 =
     530 (D46). 560 rather than 530, for the reason 640 was not 618: a reserve that
     lands within a pixel of the drawing is a rounding error waiting for a device
     with a different pixel ratio. This leaves about 15px either side. */
  --intro-slide-h: max(var(--slide-h), 560px);
  height: var(--intro-slide-h);
  contain-intrinsic-size: auto var(--intro-slide-h);
}

/* ── The card, morphing on --t ───────────────────────────────
   --t is 0 minimised, 1 expanded, written per frame by the deck's
   scroll handler. Every rule below is a straight interpolation
   between the two Figma variants, so the two states are the same
   element and there is no swap to hide. */
.pcard {
  position: relative;
  width: min(357px, calc(100% - 64px));
  height: calc(128px + 102px * var(--t, 0));
  border-radius: 40px;
  background: linear-gradient(180deg, var(--surface-raised), var(--surface-primary));
  border: 1px solid var(--surface-primary);
  box-shadow: var(--card-shadow);
}

/* The stack is built once at expanded scale and scaled down for the
   minimised pose, so the three photos never re-lay-out. Expanded it
   overhangs the card's top edge by 72; minimised it tucks inline. */
.stack {
  position: absolute;
  width: 151.3px; height: 120px;
  transform-origin: top left;
  left: calc(32px - 14px * var(--t, 0));
  top: calc(32px - 104px * var(--t, 0));
  transform: scale(calc(0.533 + 0.467 * var(--t, 0)));
}
/* Two poses per print, interpolated on the same --t the card morphs on:
   --px/--py/--pr is the pile (minimised), --fx/--fy/--fr the fan (expanded).
   Minimised they sit as a tidy stack of three photographs, squared up with a
   few degrees between them; expanding deals them out, each one travelling and
   turning on its own path. The group handles size and station, the prints
   handle the deal.

   left/top stay at 0 and position lives in the transform, so nothing here
   costs a layout — the whole deal is composited. (The node's minimised
   variant just shrinks the fan; the pile is Paul's call, 08-17.) */
.ph {
  position: absolute;
  left: 0; top: 0;
  border: 5.5px solid var(--brand-white);
  border-radius: 9.6px;
  object-fit: cover;
  background: var(--surface-primary);
  /* Cool-cast, thrown down-right, as drawn on the node. */
  box-shadow: 0.7px 1.4px 3.4px rgba(34, 120, 160, 0.10),
              3.4px 4.8px 5.5px rgba(34, 120, 160, 0.09),
              8.2px 10.3px 7.5px rgba(34, 120, 160, 0.05);
  transform:
    translate(calc(var(--px) + (var(--fx) - var(--px)) * var(--t, 0)),
              calc(var(--py) + (var(--fy) - var(--py)) * var(--t, 0)))
    rotate(calc(var(--pr) + (var(--fr) - var(--pr)) * var(--t, 0)));
}
.ph-back {
  width: 71.4px; height: 71.4px;
  --px: 40px;   --py: 19px;   --pr: -3deg;
  --fx: 5px;    --fy: 27px;   --fr: -19deg;
}
.ph-mid {
  width: 84px; height: 84px;
  --px: 33px;   --py: 12px;  --pr: 2deg;
  --fx: 37px;   --fy: 0px;   --fr: -5deg;
}
.ph-front {
  width: 84px; height: 84px;
  --px: 35px;   --py: 14px;   --pr: 0deg;
  --fx: 70px;   --fy: 16px;   --fr: 2.5deg;
}
/* ── The folder ─────────────────────────────────────────────
   Figma's Folder component (2443:43730) is three layers, not one: a back panel
   the full height of the group, the three photographs, then a front pocket over
   their lower edge. Only the pocket was built — the back panel was missing
   entirely, which is why the folder read as loose prints rather than prints in
   something (D42).

   Both panels carry Figma's GLASS effect, which is a refractive material CSS has
   no equivalent for. What is here is the closest available: a backdrop-filter
   frost so the photographs really blur through, a bright rim and a highlight
   sweep for the polish, and an inset shade at the foot so the edge reads as
   thickness rather than as a border. The fills are the component's own — 0.2 on
   the pocket, 0.5 on the back panel — and the gradients are its handles
   converted.

   The prints overhang both panels, and that is deliberate: D21 opened the fan to
   -19deg/-5deg/2.5deg where the component has them nearly upright, and D21 is the
   later decision. The folder is a backdrop for the fan, not a frame around it. */
.plate-back,
.plate {
  position: absolute;
  left: 15.6px;
  width: 130.84px;
  border-radius: 17.1px;
}
/* The pocket keeps its box-shadow: it is a plain rounded rectangle, so nothing
   clips it. The back panel's equivalents are filters, below. */
.plate {
  box-shadow: 0 15.5px 31px rgba(0, 0, 0, 0.1),
              inset 0 1px 0 rgba(255, 255, 255, 0.55),
              inset 0 -6px 12px -6px rgba(34, 120, 160, 0.18);
}
.plate-back {
  top: 0; height: 120px;
  /* A folder, not a rectangle (D44). The component's back panel is a vector with
     a tab: the top edge runs level for the first 53px and then steps down 8px for
     the rest of the width, which is what makes the shape read as something the
     photographs are in rather than a card behind them. Straight off the node's
     own fillGeometry, rounded to 2dp, in the element's own 130.84x120 box.

     box-shadow cannot come with it: an outer shadow is painted from the border
     box and clip-path cuts it away. drop-shadow() follows the clipped silhouette
     instead, so the shadow and the hairline are filters here. The tight one at
     0.6px blur is the hairline — the panel is 0.5 white on a white card and
     without an edge there is nothing to see (D42). */
  clip-path: path('M0 25.04C0 17.68 0 14 1.11 11.07C2.85 6.48 6.48 2.85 11.07 1.11C14 0 17.68 0 25.04 0C27.01 0 27.99 0 28.96 0.11C30.46 0.28 31.94 0.65 33.35 1.21C34.25 1.57 35.12 2.03 36.85 2.96L40.38 4.85C42.64 6.05 43.77 6.66 44.96 7.08C46.02 7.46 47.11 7.74 48.22 7.9C49.47 8.09 50.75 8.09 53.31 8.09L103.42 8.09C113.02 8.09 117.81 8.09 121.48 9.95C124.71 11.6 127.33 14.22 128.97 17.45C130.84 21.11 130.84 25.91 130.84 35.51L130.84 92.58C130.84 102.17 130.84 106.97 128.97 110.64C127.33 113.87 124.71 116.49 121.48 118.13C117.81 120 113.02 120 103.42 120L27.42 120C17.82 120 13.03 120 9.36 118.13C6.13 116.49 3.51 113.87 1.87 110.64C0 106.97 0 102.17 0 92.58L0 25.04Z');
  filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.10))
          drop-shadow(0 0 0.6px rgba(34, 120, 160, 0.45));
}
.plate      { top: 72.8px; height: 47.3px; }

/* Two background layers on each: the component's gradient, and a sweep across
   the top-left corner that stands in for the way glass catches a light. */
.plate-back {
  background:
    linear-gradient(105deg, rgba(255, 255, 255, 0.38) 0 14%, rgba(255, 255, 255, 0) 46%),
    linear-gradient(180deg, rgba(255, 255, 255, 0.5) -17%, rgba(242, 242, 242, 0.5) 100%);
}
.plate {
  background:
    linear-gradient(105deg, rgba(255, 255, 255, 0.4) 0 16%, rgba(255, 255, 255, 0) 44%),
    linear-gradient(0deg, rgba(255, 255, 255, 0.2) 71%, rgba(242, 242, 242, 0.2) 156%);
}

/* Minimised, the text sits to the right of the inline stack; expanded,
   it drops to the card's own left margin under the overhang. */
.ptext {
  position: absolute;
  top: calc(35px + 27px * var(--t, 0));
  /* The prints deal rightward while the text slides left, so the two cross.
     Holding the text where it is until the stack is half risen keeps the
     crossing to a corner: multiplier stays 1 until --t 0.5, then runs to 0. */
  left: calc(32px + 104.7px * min(1, max(0, (1 - var(--t, 0)) * 2)));
  right: 32px;
}
.ptitle {
  margin: 0;
  /* 31px is the node's size against its 420-wide frame, where the card is
     357 and the text box 293. A real 390 phone gives the card 326 and the
     box 262, and the longest title measures 262 dead on — so the size
     tracks the viewport instead of the mock, and lands at 28px there. */
  font-size: clamp(22px, 7.1vw, 31px);
  line-height: 1.19;
  font-weight: var(--h4-heavy-weight);
  letter-spacing: -0.035em;
  color: var(--text-primary);
  /* One line by design (the minimised variant clips in Figma too).
     Titles are written to fit; the ellipsis is only a safety net. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.psub {
  margin: 6px 0 0;
  font-size: var(--body-md-size);
  line-height: 18px;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Scales up out of the card's bottom-left corner as the card expands,
   and back down as it leaves. Its taps are gated in JS, not here, so
   a half-morphed card can never take a press. */
.check {
  position: absolute;
  left: 32px; bottom: 32px;
  min-width: 191px;
  width: max-content;
  max-width: calc(100% - 64px);
  padding: 16px 24px;
  border-radius: var(--radius-button-md);
  background: linear-gradient(180deg, var(--accent), var(--accent-alt));
  border: 1px solid var(--surface-primary);
  box-shadow: var(--pill-shadow);
  color: var(--text-inverted);
  font-size: var(--body-md-size);
  line-height: 19px;
  font-weight: var(--body-weight);
  display: flex; align-items: center; justify-content: center; gap: 9px;
  white-space: nowrap;
  transform: scale(var(--t, 0));
  transform-origin: left bottom;
  opacity: calc(var(--t, 0) * 1.4 - 0.4);
  pointer-events: none;
}
.check:active { filter: brightness(0.94); }

/* ── Card zero ───────────────────────────────────────────────
   The introduction. Same shell as every plan — radius, gradient, border,
   shadow, and the morph on --t — because it has to belong to the deck. It is
   taller than a plan card because it carries three lines of actions where they
   carry a title and a door: 128 minimised, 294 expanded rather than 230.

   Taller INSIDE a standard slide, deliberately. Slide heights are read from
   slides[0] and the neighbours' peek derives from that, so a taller slide here
   would resize the window every other card morphs in (D15, D29). At 294 centred
   in a 446 slide it clears its neighbours with room to spare. */
.pcard--intro { height: calc(128px + 228px * var(--t, 0)); }

/* Fenn where the prints would be, rising out of the card's top edge on exactly
   the same path the photo stack takes. The character rather than the balloon:
   the balloon is the sky's motif on this branch, and "this is who is doing the
   work" wants his face. */
/* Bigger, and standing on a shadow (D45). The asset is the design's own Fenn node
   (2472:44129): fenn's body is 120 of its 147 units and the ellipse is the last 9,
   sitting 18 units below his feet, so the shadow scales with him and cannot drift.
   fenn-watch-you.svg stays where it is — other branches reference it.

   100.5px, which is 134 less a quarter (D46). The lift comes down with it and is
   not a free number: the shadow sits 27 of the node's units below his feet, so a
   shorter fenn puts it higher, and at the old 164px ramp it would have floated just
   above the card's top edge instead of resting on the card. 119px is what keeps the
   ellipse where it was reviewed, 43px down the card with 30px still clear of the
   title. .slide--intro's reserve comes down with both (D45). */
.icard-fenn {
  position: absolute;
  left: 50%;
  top: calc(32px - 119px * var(--t, 0));
  transform: translateX(-50%) scale(calc(0.62 + 0.38 * var(--t, 0)));
  transform-origin: top center;
  width: 100.5px;
  /* He belongs to the expanded card and goes as it minimises (D47). Not a taste:
     the plan cards put their photographs on the left at --t 0 and slide the text
     out of the way, but this card's text is centred and full width, so there is
     nowhere for fenn to stand — at --t 0 his ramp lands him at top: 32px, which is
     on top of the title.

     0.8 is where his shadow reaches the title's box, and both are straight ramps
     on --t so it can be worked out rather than guessed: the ellipse sits at
     (32 - 119t) + (0.62 + 0.38t) x 130.19 and the title's top at 35 + 27t, and they
     meet at t = 0.80. So he is gone by exactly the point where he would start
     covering a word. Opacity clamps itself, so the ramp needs no min or max, and it
     rides --t per frame — the scroll handler already writes it, so there is no
     scroll listener here. */
  opacity: calc((var(--t, 0) - 0.8) * 5);
}
.icard-fenn img { width: 100%; height: auto; display: block; }

.icard-text {
  position: absolute;
  left: 32px; right: 32px;
  top: calc(46px + 26px * var(--t, 0));
  text-align: center;
}
.icard-title {
  margin: 0;
  /* A notch under .ptitle's clamp, because this is the longest headline in the
     deck and it must hold one line: two lines overflow the 128px minimised card,
     and an ellipsis on the landing frame is worse than a smaller headline. At
     7.1vw it cleared 375 by 6px, which SF Pro's heavy weight collapsing to a
     wider fallback on Android (docs/brand.md) would eat. */
  font-size: clamp(21px, 6.6vw, 29px);
  line-height: 1.19;
  font-weight: var(--h4-heavy-weight);
  letter-spacing: -0.035em;
  color: var(--brand-black);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.icard-sub {
  margin: 6px 0 0;
  font-size: var(--body-md-size);
  line-height: 18px;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The three actions arrive with the card. Minimised, all that shows is fenn and
   the title, which is the right amount for a card sliding past. */
.icard-steps {
  list-style: none;
  margin: 14px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
  text-align: left;
  opacity: calc(var(--t, 0) * 1.6 - 0.6);
}
.icard-steps li {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: var(--body-sm-size);
  line-height: 20px;
  color: var(--ink-support);
  white-space: nowrap;
}
.icard-emoji {
  flex: 0 0 28px;
  width: 28px; height: 28px;
  /* Narrow phones give the row 209px for text, which a 15px line of thirty
     characters does not fit. The chip and the gap give way before the words do. */
  border-radius: 50%;
  background: var(--surface-primary);
  display: grid; place-items: center;
  font-size: 15px;
  line-height: 1;
}

/* A 375 card is 311 wide, so the action rows lose the slack the title has. */
@media (max-width: 389px) {
  .icard-steps li { font-size: 14px; gap: 8px; }
  .icard-emoji { flex: 0 0 24px; width: 24px; height: 24px; font-size: 13px; }
}

/* ── The spin ────────────────────────────────────────────────
   Velocity arrives as --v (0..1) on the deck and the cards wear it:
   blurred along the direction of travel, stretched vertically, as a
   reel would be. Snap is off for the duration so the drive is the
   only thing moving the scroller. */
.deck.spinning { scroll-snap-type: none; }
.deck.spinning .check { pointer-events: none !important; }
/* .near is written by paint() for the slides within a viewport of the centre.
   Blur is the most expensive filter there is, and this was running it on all
   twenty-one cards every frame of a spin while eighteen were off screen —
   will-change moves with the selector, so the layer promotion is scoped too
   (D35). */
.deck.spinning .slide.near .pcard {
  filter: blur(calc(var(--v, 0) * 7px));
  transform: scaleY(calc(1 + var(--v, 0) * 0.1)) scaleX(calc(1 - var(--v, 0) * 0.02));
  will-change: filter, transform;
}

/* ── The bottom bar ─────────────────────────────────────────
   Figma node 2443:43660. Clear of the card peeking at the bottom edge, as
   the node has it. No scrim under it: the sky has faded to near the ground
   colour by this height, and the pill is a raised white surface anyway. */
.bar {
  position: fixed;
  left: 50%;
  /* 46 keeps the row clear of the card peeking at the bottom edge, the same
     clearance the ghost button was given. */
  bottom: calc(46px + env(safe-area-inset-bottom) + var(--vv-bottom));
  transform: translateX(-50%);
  z-index: 5;
  width: min(432px, calc(100vw - 48px));
  display: flex;
  align-items: flex-end;
  gap: 12px;
  transition: opacity var(--dur-base) linear, transform var(--dur-base) var(--ease-out);
}

/* The pill: fenn's mark, the prompt, and dictation where it exists. The 3px
   vertical padding is the node's — the inner row carries the rest, which is
   what keeps the mic's tap target full height. */
.askpill {
  flex: 1 1 auto;
  min-width: 0;
  height: 48px;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 4px 3px 10px;
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  /* The node's drop shadow, plus its inner top highlight — the thing that
     makes it read as raised rather than pasted on. */
  box-shadow: 0 7.5px 7.5px rgba(0, 0, 0, 0.05),
              inset 0 1px 0 rgba(255, 255, 255, 0.7);
  text-align: left;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition: transform var(--dur-fast) var(--ease-out);
}
.askpill:active { transform: scale(0.985); }
.askpill .markmark { width: 24px; flex: 0 0 24px; }
.ask-label {
  flex: 1 1 auto;
  min-width: 0;
  font-size: var(--body-md-size);
  line-height: 19px;
  font-weight: var(--body-weight);
  letter-spacing: -0.02em;
  color: var(--text-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ask-mic {
  flex: 0 0 auto;
  display: grid; place-items: center;
  width: 44px; height: 42px;
  border-radius: var(--radius-button-sm);
  cursor: pointer;
}
.ask-mic img { width: 20px; height: 20px; display: block; }
/* Listening: the mic is the only thing on screen doing anything, so it says
   so rather than relying on a toast. */
.ask-mic.hearing { background: color-mix(in srgb, var(--accent) 14%, transparent); }
.ask-mic.hearing img { animation: micPulse 1.1s var(--ease-out) infinite; }
@keyframes micPulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%      { opacity: 0.55; transform: scale(0.88); }
}

/* Same 48px square and radius as the pill's height, so the row reads as one
   object with a button parked beside it. */
.spinbtn {
  flex: 0 0 48px;
  width: 48px; height: 48px;
  display: grid; place-items: center;
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  box-shadow: 0 7.5px 7.5px rgba(0, 0, 0, 0.05),
              inset 0 1px 0 rgba(255, 255, 255, 0.7);
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition: transform var(--dur-fast) var(--ease-out);
}
.spinbtn .sparks { width: 26px; height: 26px; display: block; overflow: visible; }
.spinbtn:active { transform: scale(0.94); }

/* ── The sparkles ───────────────────────────────────────────
   The icon used to be an <img>, so the only thing it could do was turn as one
   block, and it did: 360° and back on every tap. That is a spinner's gesture,
   and this button is not loading anything — it is throwing a reel. Inlined
   (E16), the two stars are two objects, and what they do instead is trade
   places: the whole gesture in one idea, at three tempos.

   Each star pivots on its own centre rather than on the icon's, and the origin
   is written out per spark rather than asked for with `transform-box: fill-box;
   transform-origin: center`. Same pivot, measured rather than assumed:
   rotate(90deg) moves each spark's rendered centre by 0.0000px either way.

   The reason to write the numbers is the target platform. fill-box on a <g> has
   a bug history in WebKit, and this button ships to iOS, where a wrong reference
   box swings each star around the icon's centre instead of its own — the one
   thing the whole gesture depends on.

   **These are the path's own coordinates, not the icon's viewBox coordinates.**
   transform-origin resolves in the spark's own user space, which sits before the
   wrapper's translate(-12.34,-11.34), so the numbers are the raw getBBox()
   centres: big (29.105, 20.7383), small (18.8765, 30.9658). The spec quotes the
   same two centres in viewBox space as ~(16.8, 9.4) and ~(6.5, 19.6); using
   those here drifts the pivot by 23.5px, which is the wrapper's translate coming
   back as a bug. Re-measure with getBBox() if the path data ever changes, and
   measure in the element's own space.

   Non-scaling stroke, because a star scaled to the other one's size would
   otherwise arrive wearing the other one's line weight divided by the scale —
   which is the difference between the two sparks swapping and one of them
   turning thin. */
.spark--big   { transform-origin: 29.105px 20.7383px; }
.spark--small { transform-origin: 18.8765px 30.9658px; }
/* Lit while they move, back to the button's ground when they settle (D44). Only
   the three interaction states: the idle twinkle never ends, so lighting up for it
   would be lighting up for good. The transition is what brings them back. */
.spinbtn.firing .spark path,
.spinbtn.shuffling .spark path,
.spinbtn.landing .spark path { fill: var(--spark-lit); }
.spark path {
  transition: fill 220ms var(--ease-out);
  /* Filled with the button's own ground rather than left hollow. Hollow, the two
     stars showed each other's strokes through themselves every time they crossed
     during the swap, which read as a rendering fault rather than as two objects
     passing (D43). The fill is the surface behind them, so nothing about the
     resting icon changes. */
  fill: var(--surface-raised);
  stroke: var(--brand-black);
  stroke-width: 2;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* The swap, measured off the artwork. In the icon's 26.2-unit space the two
   centres sit 10.24 apart on one axis and 10.23 on the other, and the sizes are
   0.638 apart, so each star lands exactly where the other stood at exactly the
   other's size. Each bows ~2 units off the straight line, in opposite
   directions, so they visibly pass each other rather than sliding through; each
   turns 90°, and a four pointed star turned 90° is the star it was.

   That symmetry is what makes this one-way: the landing pose is the resting
   icon, so there is nothing to return from and no fill to hold (E17). Measured
   in Chrome, each star lands within 0.02px of where the other stood, at the
   other's size to within 0.02px. What it does not land on is the other's
   *outline* — the two stars are not similar shapes, the big one's arms being
   proportionally the longer — so the frame the animation ends on differs from
   rest by about half a pixel of stroke. At 26px, mid-spring, that is under the
   antialiasing. If it ever reads as a flick on device, the fix is to make the
   620ms an out-and-back rather than to hold the swap. */
@keyframes sparkSwapBig {
  from { transform: translate(0, 0) rotate(0deg) scale(1); }
  50%  { transform: translate(-3.1px, 7.1px) rotate(45deg) scale(0.86);
         animation-timing-function: var(--ease-spring); }
  to   { transform: translate(-10.24px, 10.23px) rotate(90deg) scale(0.638); }
}
@keyframes sparkSwapSmall {
  from { transform: translate(0, 0) rotate(0deg) scale(1); }
  50%  { transform: translate(3.1px, -7.1px) rotate(-45deg) scale(1.2);
         animation-timing-function: var(--ease-spring); }
  to   { transform: translate(10.24px, -10.23px) rotate(-90deg) scale(1.568); }
}

/* The same trade at the reel's tempo: a flatter arc, no spring, and no end —
   each pass resets to a pose identical to the one it just left, so the loop has
   no seam to hide. Linear on purpose: a pass that decelerated into place and
   restarted would read as a stutter rather than as agitation (E18). */
@keyframes sparkTradeBig {
  from { transform: translate(0, 0) rotate(0deg) scale(1); }
  50%  { transform: translate(-4.1px, 6.1px) rotate(45deg) scale(0.86); }
  to   { transform: translate(-10.24px, 10.23px) rotate(90deg) scale(0.638); }
}
@keyframes sparkTradeSmall {
  from { transform: translate(0, 0) rotate(0deg) scale(1); }
  50%  { transform: translate(4.1px, -6.1px) rotate(-45deg) scale(1.2); }
  to   { transform: translate(10.24px, -10.23px) rotate(-90deg) scale(1.568); }
}

/* The reel lands and the button lands with it — one pop, both stars, on the
   frame the deck stops. Press, agitate, land: one causal chain rather than a
   button that finished its own animation a second and a half ago (E18). */
@keyframes sparkSettle {
  from { transform: scale(1); }
  55%  { transform: scale(1.15); }
  to   { transform: scale(1); }
}

/* At rest it twinkles: one quick blink every seven seconds, the two stars
   alternating. The dead time is inside the keyframes — 480ms of motion in the
   last 6.9% of a 7s cycle — so this costs no timer and no JS, and the big spark
   rides the same cycle half a turn behind on a negative delay (E19). */
@keyframes sparkTwinkle {
  0%, 93.1% { transform: rotate(0deg) scale(1); }
  96.5%     { transform: rotate(45deg) scale(0.6); }
  100%      { transform: rotate(90deg) scale(1); }
}
.spark--small { animation: sparkTwinkle 7s var(--ease-out) infinite; }
.spark--big   { animation: sparkTwinkle 7s var(--ease-out) -3.5s infinite; }

/* Four states, one property: each of these replaces the animation outright, so
   the twinkle is suppressed while anything louder is running and comes back on
   its own when the class goes. Order is the whole cascade here — the shuffle
   loses to the tap that started it, and both lose to the landing. */
.spinbtn.shuffling .spark--big   { animation: sparkTradeBig   900ms linear infinite; }
.spinbtn.shuffling .spark--small { animation: sparkTradeSmall 900ms linear infinite; }
.spinbtn.firing .spark--big   { animation: sparkSwapBig   620ms var(--ease-out); }
.spinbtn.firing .spark--small { animation: sparkSwapSmall 620ms var(--ease-out); }
.spinbtn.landing .spark { animation: sparkSettle 260ms var(--ease-spring); }

/* ── The composer ───────────────────────────────────────────
   Takes the bar's place rather than sitting above it: one thing at the
   bottom of the screen at a time. */
.composer {
  position: fixed;
  left: 50%;
  bottom: calc(24px + env(safe-area-inset-bottom) + var(--vv-bottom));
  transform: translateX(-50%);
  z-index: 6;
  width: min(432px, calc(100vw - 48px));
  animation: compIn var(--dur-base) var(--ease-out);
}
@keyframes compIn {
  from { opacity: 0; transform: translate(-50%, 12px); }
  to   { opacity: 1; transform: translateX(-50%); }
}
/* The keyboard eats the bottom of the screen, so the composer sits tight to
   it and the chips lose their breathing room first. */
:root.kb .composer { bottom: calc(10px + var(--vv-bottom)); }

/* The deck's own plans, offered as a row you can scroll. Tapping one flies
   the deck to that card rather than creating anything — the card, with its
   photographs and its pitch, is the better answer to "what have you got". */
.comp-chips {
  display: flex;
  gap: 8px;
  /* Full bleed. The composer is min(432px, 100vw - 48px), so a row scrolling
     inside it was cut 24px in from each edge and a chip sat half-visible in the
     margin. These two cancel out: the row spans the viewport, and the padding
     puts the first and last chip back on the composer's own edge so the ends
     still line up with the field (D43). */
  margin-inline: calc(50% - 50vw);
  padding-inline: calc(50vw - 50%);
  padding-top: 2px;
  /* Room for the chips' own shadow inside the scroller (D48). overflow-x: auto
     forces overflow-y to auto as well — one axis cannot be scrollable while the
     other stays visible — so the box clips vertically too, and --pill-shadow's
     deeper layer is 0 21px 21px, which wants about 32px below a chip and had 6.
     The negative margin gives the layout back what the padding took: 34 - 18 is
     the 16px that used to sit between the chips and the field. */
  padding-bottom: 34px;
  margin-bottom: -18px;
  scroll-padding-inline: calc(50vw - 50%);
  overflow-x: auto;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.comp-chips::-webkit-scrollbar { display: none; }
.comp-chip {
  flex: 0 0 auto;
  padding: 13px 16px;
  border-radius: var(--radius-button-md);
  background: linear-gradient(to bottom, var(--surface-raised), var(--surface-primary));
  border: 1px solid var(--surface-primary);
  box-shadow: var(--pill-shadow);
  font-size: var(--body-md-size);
  line-height: 17px;
  font-weight: var(--body-weight);
  color: var(--text-primary);
  white-space: nowrap;
  transition: transform var(--dur-fast) var(--ease-out);
}
.comp-chip:active { transform: scale(0.94); }

/* foundation's .field and .send, which is the app's own composer bar — typing
   your own plan should feel like typing into fenn. */
.comp-row .field { box-shadow: 0 7.5px 7.5px rgba(0, 0, 0, 0.05); }

/* While the composer is open the deck goes soft behind it: still the thing
   you were looking at, no longer the thing you are reading. Static blur, and
   the deck cannot scroll under it, so it costs one rasterisation. */
body.asking .deck {
  filter: blur(7px);
  overflow: hidden;
  transition: filter var(--dur-base) linear;
}
/* The sky softens with it. Blurring the cards while the clouds behind them
   stay sharp inverts the depth: the far layer cannot be the crisp one. All
   three background layers, since D49 — the scene is the dominant one now, and
   the mascots were inside .sky when this rule was written. */
body.asking .sky,
body.asking #scenes,
body.asking #mascots {
  filter: blur(4px);
  transition: filter var(--dur-base) linear;
}
body.asking .bar { opacity: 0; transform: translate(-50%, 8px); pointer-events: none; }

/* ── Clouds on the receiver ──────────────────────────────────
   It is a fixed overlay painting its own ground, so the page's sky layer is
   hidden behind it and it carries two clouds of its own.

   z-index explicitly, both of them: position:fixed makes these paint above
   every static sibling whatever the source order, and without the pair the
   clouds sat in front of the whole screen. */
.clip-cloud {
  position: fixed;
  z-index: 0;
  pointer-events: none;
}
.clip-cloud--a { width: 300px; right: -150px; top: 60%; }
/* High, in the empty sky — behind the headline it ghosted the first words. */
.clip-cloud--b { width: 260px; left: -150px; top: 4%; transform: rotate(15deg); }
/* The author's sky travels on scroll; this one has no scroll to travel on, so
   it drifts instead. Minutes long and in opposite directions, which is enough
   to stop the page reading as a screenshot without asking to be watched. Each
   keyframe carries its cloud's own resting rotation (D35). */
@keyframes cloudDriftA {
  from { transform: translateX(0); }
  to   { transform: translateX(-26px); }
}
@keyframes cloudDriftB {
  from { transform: rotate(15deg) translateX(0); }
  to   { transform: rotate(15deg) translateX(24px); }
}
.clip-cloud--a { animation: cloudDriftA 62s ease-in-out infinite alternate; }
.clip-cloud--b { animation: cloudDriftB 74s ease-in-out infinite alternate; }
@keyframes sparklePop {
  from { opacity: 0; transform: scale(0.2) rotate(-24deg); }
  to   { opacity: 1; transform: none; }
}

/* ── QR overlay ──────────────────────────────────────────────
   The QR is a second-screen move — the phone you plan on isn't
   always the phone you send from — so it waits behind its own
   button instead of holding a block of the share screen. Over
   everything when it comes, and tap anywhere to put it away. */
.qrveil {
  position: fixed; inset: 0; z-index: 88;
  display: grid; place-items: center;
  padding: var(--page-pad-x);
  background: rgba(0, 0, 0, 0.62);
  animation: veilIn var(--dur-base) linear;
}
@keyframes veilIn { from { opacity: 0; } to { opacity: 1; } }
.qrveil-card {
  width: min(300px, 100%);
  padding: 20px;
  border-radius: var(--radius-xl);
  background: var(--brand-white);
  text-align: center;
  animation: veilCard var(--dur-slow) var(--ease-out);
}
@keyframes veilCard {
  from { opacity: 0; transform: scale(0.92); }
  to   { opacity: 1; transform: none; }
}
/* The SVG carries a square viewBox, so its own intrinsic ratio sizes the box.
   aspect-ratio + a 100% height child measured against the padded box in
   WebKit, and the card came out a padding's worth taller than wide. */
.qrveil .qr { width: 100%; }
.qrveil .qr svg { display: block; width: 100%; height: auto; }
.qrveil .qr-note {
  margin: 15px 0 0;
  font-size: var(--body-sm-size); line-height: 19px;
  color: var(--ink-support);
}

/* The share step is one row of three (D37): the code, the link, the app. The
   printed link and its inline copy button both came off with the named chats —
   a URL nobody can tap is a thing to read, not a thing to use, and the button
   that copies it is now the row's own. */
.channels { display: flex; gap: 9px; margin: 16px 0 0; }
.channel {
  flex: 1;
  padding: 13px 8px;
  border-radius: var(--radius-button-md);
  background: linear-gradient(to bottom, var(--surface-raised), var(--surface-primary));
  border: 1px solid var(--surface-primary);
  box-shadow: var(--pill-shadow);
  color: var(--text-primary);
  font-size: var(--body-sm-size);
  font-weight: var(--body-weight-bold);
  text-align: center;
  text-decoration: none;
  transition: transform var(--dur-fast) var(--ease-out);
}
.channel:active { transform: scale(0.96); }

/* Open Fenn wears the accent and holds the middle (D39), which reverses where
   D37 put the emphasis. Copy is a quiet icon square on the right now, and it
   keeps only the green flash: the accent moved, the confirmation did not. */
.channel--open {
  background: var(--gradient-primary);
  border-color: rgba(255, 255, 255, 0.2);
  box-shadow: 0 5px 20px rgba(80, 177, 249, 0.3);
  color: var(--text-inverted);
}
.channel--copy {
  transition: transform var(--dur-fast) var(--ease-out), background var(--dur-fast) linear;
}
.channel--copy.copied {
  background: var(--positive);
  box-shadow: var(--pill-shadow);
}
/* The tick is the background, so the glyph has to survive landing on it. */
.channel--copy.copied img { filter: brightness(0) invert(1); }

/* Both icons keep their square: a glyph is not a word, and giving either a third
   of the row would have it shouting as loudly as the label between them. Two
   46px squares flanking one flexible button is also what puts Open Fenn in the
   actual middle rather than merely third (D39). */
.channel--icon {
  flex: 0 0 auto;
  width: 46px; min-height: 46px;
  padding: 0;
  display: grid; place-items: center;
}
.channel--icon img { width: 22px; height: 22px; display: block; }

/* ── The share row, floating ────────────────────────────────
   D20 put a second row at the foot of the story so a reader who got to the end
   did not have to scroll back. This is that argument answered better: the row
   comes to them instead (D43). It fades in once the row by the title has left the
   screen and goes again when it returns, so there is exactly one of it visible at
   any moment and the end of the card is free of a duplicate.

   Same three controls and the same markup, filled by the same sharebarHtml(),
   because everything that wires the row up reaches every .sharebar in #detail. */
.sharebar--float {
  position: fixed;
  left: 50%;
  bottom: calc(18px + env(safe-area-inset-bottom) + var(--vv-bottom));
  z-index: 8;
  width: min(357px, calc(100% - 40px));
  padding: 8px 10px 10px;
  border-radius: 28px;
  background: rgba(255, 255, 255, 0.72);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
  backdrop-filter: blur(14px) saturate(1.4);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12),
              inset 0 1px 0 rgba(255, 255, 255, 0.7);
  /* Off the bottom of the screen until it is wanted, and inert while it is out
     of the way (D44). 100% is its own height, so the distance is right whatever
     the row measures, and the rest covers the offset it sits at. */
  opacity: 0;
  transform: translate(-50%, calc(100% + 24px + env(safe-area-inset-bottom)));
  pointer-events: none;
  /* Both states declare their own timing on purpose: a transition belongs to the
     state being entered, so the arrival can take its time while the departure
     stays brisk. Leaving is a dismissal and wants to be over; arriving is the
     thing that has to be seen (D51). */
  transition: opacity 180ms var(--ease-out),
              transform 260ms var(--ease-out);
}
.sharebar--float.up {
  opacity: 1;
  transform: translate(-50%, 0);
  pointer-events: auto;
  /* Opacity finishes first so there is a solid object to watch cover the last
     two thirds of the distance, rather than a shape still fading in. */
  transition: opacity 200ms var(--ease-out),
              transform 440ms var(--ease-rise);
}
/* The row's own top margin belongs to the one in the card, which has copy above
   it. In here the padding is the spacing. */
.sharebar--float .channels { margin: 0; }
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .sharebar--float { background: rgba(255, 255, 255, 0.94); }
}

/* The card opens before the backend has answered (D35), so the row spends a
   moment with nothing behind it. All three doors dim and go inert together —
   there is no printed link left to carry a "getting your link…" placeholder,
   and three visibly disabled buttons say the same thing for the ~600ms it
   lasts. aria-busy on the row is what a screen reader hears (D37). */
.sharebar[aria-busy='true'] .channel {
  pointer-events: none;
  opacity: 0.5;
}

/* ── Receiver ────────────────────────────────────────────────── */

/* The friend's page is the sender's opened card, worn by someone else (D33):
   the same 40px shell, the same header, the same fan of prints at the same
   232px. The thing they were sent and the thing the sender was looking at are
   one object, and looking like it is the cheapest confidence this page can
   buy. What comes off is what a receiver has no use for — the close and the
   QR — which D34 had already reduced to the wordmark alone. The gift box
   stays for a plan we cannot place (D27), in the fan's own footprint so the
   composition does not jump. */
.receiver {
  position: fixed; inset: 0; z-index: 90;
  display: none; overflow-y: auto;
  background: var(--surface-primary);
  color: var(--text-primary);
}
/* `background-attachment: fixed` on the element that is itself the scroller is
   broken on iOS Safari — it janks, or scrolls with the content it is supposed
   to sit behind. The gradient moves onto a fixed pseudo-element instead, which
   is exactly what .detail::before already does. .receiver is position: fixed
   and so its own stacking context, which keeps z-index: -1 inside it, behind
   the clouds at 0 rather than behind the page (D35). */
.receiver::before {
  content: '';
  position: fixed; inset: 0;
  z-index: -1;
  background: var(--sky-light), var(--surface-primary);
}
/* The sender's own scene, at rest, for a plan we can place (D49). Same child
   layer for the same reason: this element is the scroller. Static — nothing
   here drifts, because nothing on this page loops for decoration (D8), and the
   clouds that do drift belong to the unknown-plan sky and come off with it. */
#r-scene {
  position: fixed; inset: 0;
  z-index: -1;
  overflow: hidden;
}
/* A plan we can place brings its own sky, so the generic clouds go. */
.receiver.has-scene .clip-cloud { display: none; }
/* A column, so the card takes the screen. The prototype banner that used to
   own the first row is gone (D37) — and it was carrying this screen's only
   safe-area inset, so the padding moves here or the card slides under the
   status bar on a notched phone. */
.receiver.active { display: flex; flex-direction: column; }
.receiver { padding-top: env(safe-area-inset-top); }

/* The author's card on the receiver's terms. It is a column so the door lands
   at the foot of the screen instead of wherever the copy happens to stop, and
   its own min-height goes: .dcard measures that against the whole viewport,
   which overflows here by the inset above it.

   Descendant selector on purpose. .dcard is defined further down the file, so
   a bare .dcard--r ties on specificity and loses on order — the foot line went
   off the bottom of the screen. */
.receiver .dcard {
  position: relative; z-index: 1;
  flex: 1 0 auto;
  min-height: 0;
  padding-bottom: calc(30px + env(safe-area-inset-bottom) + var(--vv-bottom));
  display: flex; flex-direction: column;
}
.receiver .top { justify-content: center; }

/* Same footprint as .d-stack, so a plan we can place and one we cannot put
   the kicker on the same line of the screen. */
.r-gift {
  width: 232px; height: 174px;
  margin: 26px auto 20px;
  display: grid; place-items: center;
}
.r-gift img { width: 118px; display: block; }

.r-kicker {
  font-size: var(--body-md-size); line-height: 20px;
  color: var(--ink-support);
  text-align: center;
  margin: 0 0 10px;
}
.r-kicker b { font-weight: var(--body-weight-bold); color: var(--text-primary); }

.r-fill { flex: 1 1 auto; min-height: 0; }

/* What tapping does, said before the button that does it. Three short facts,
   not a paragraph: this page is read in two seconds by someone who did not
   ask for it. */
.r-assure {
  list-style: none;
  margin: 0 0 20px;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.r-assure li {
  position: relative;
  padding-left: 24px;
  font-size: var(--body-sm-size);
  line-height: 20px;
  color: var(--ink-support);
}
/* A tick drawn in CSS: one more asset for three list rows is not worth it. */
.r-assure li::before {
  content: '';
  position: absolute;
  left: 6px; top: 6px;
  width: 5px; height: 9px;
  border: solid var(--accent);
  border-width: 0 2px 2px 0;
  transform: rotate(40deg);
}

.r-open {
  display: flex; align-items: center; justify-content: center;
  width: 100%;
  padding: 16px 18px;
  border-radius: var(--radius-button-md);
  background: var(--gradient-primary);
  border: 1px solid rgba(255, 255, 255, 0.2);
  box-shadow: 0 5px 20px rgba(80, 177, 249, 0.3);
  color: var(--text-inverted);
  font-size: var(--h6-size);
  font-weight: var(--h4-heavy-weight);
  text-decoration: none;
  transition: transform var(--dur-fast) var(--ease-out);
}
.r-open:active { transform: scale(0.975); }

.r-sub {
  font-size: var(--body-sm-size); line-height: 19px;
  color: var(--ink-support);
  text-align: center;
  margin: 12px 0 0;
}
/* The scan note sits between the QR and the assurances; off iPhone the card
   runs taller than the window, the flex spacer collapses, and the two touched. */
#r-qr-note {
  margin: -8px 0 28px;
}


/* ── The receiver arrives ────────────────────────────────────
   It was the only screen in the funnel with no entrance at all: display went
   from none to flex and the page was simply there. This gives it the opened
   card's choreography rather than one of its own — the shell rises and fades,
   the prints deal out of a tucked pose the way the deck card deals them, then
   the copy in reading order.

   All of it is CSS, because there is no moment to hook into here: the page
   does not open, it loads. Every deal ends exactly on its print's resting
   rotation, so the animation hands back to the stylesheet without a step. The
   reduced-motion switch at the top of this file turns the lot off (D35). */
@keyframes rRise {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: none; }
}
.receiver.active .dcard { animation: rRise 420ms var(--ease-out) both; }

/* Back, then middle, then front — the order a hand deals them in. */
@keyframes rDealBack {
  from { opacity: 0; transform: translate(36px, 14px) rotate(-3deg) scale(0.86); }
  to   { opacity: 1; transform: rotate(-20deg); }
}
@keyframes rDealMid {
  from { opacity: 0; transform: translate(8px, 12px) rotate(-1deg) scale(0.88); }
  to   { opacity: 1; transform: rotate(-6.5deg); }
}
@keyframes rDealFront {
  from { opacity: 0; transform: translate(-30px, 10px) rotate(1deg) scale(0.9); }
  to   { opacity: 1; transform: rotate(5.5deg); }
}
.receiver.active .dph-back  { animation: rDealBack  460ms var(--ease-out) 180ms backwards; }
.receiver.active .dph-mid   { animation: rDealMid   460ms var(--ease-out) 240ms backwards; }
.receiver.active .dph-front { animation: rDealFront 460ms var(--ease-out) 300ms backwards; }

/* A plan we cannot place has no fan to deal, so the box settles instead. */
@keyframes rGift {
  from { opacity: 0; transform: scale(0.82); }
  62%  { opacity: 1; transform: scale(1.04); }
  to   { opacity: 1; transform: none; }
}
.receiver.active .r-gift { animation: rGift 520ms var(--ease-out) 180ms backwards; }

/* Who sent it, what it is, its line, what tapping does, then the door. */
.receiver.active .r-kicker { animation: rRise 380ms var(--ease-out) 300ms backwards; }
.receiver.active .d-title  { animation: rRise 380ms var(--ease-out) 340ms backwards; }
.receiver.active .d-sub    { animation: rRise 380ms var(--ease-out) 380ms backwards; }
.receiver.active .r-assure li { animation: rRise 340ms var(--ease-out) backwards; }
.receiver.active .r-assure li:nth-child(1) { animation-delay: 420ms; }
.receiver.active .r-assure li:nth-child(2) { animation-delay: 450ms; }
.receiver.active .r-assure li:nth-child(3) { animation-delay: 480ms; }
.receiver.active .r-open { animation: rRise 380ms var(--ease-out) 520ms backwards; }
.receiver.active .r-sub  { animation: rRise 380ms var(--ease-out) 560ms backwards; }

/* ── Toast ───────────────────────────────────────────────────── */

.toast {
  /* Anchored like the bar: 26px off the ground was 26px off the *layout*
     viewport, which on iOS is under the home indicator and the toolbar. */
  position: fixed; left: 50%;
  /* Above the floating share row rather than across it (D44). --toast-lift is
     the row's measured height plus a gap while it is up and 0px when it is not,
     so this holds at any width instead of at one. */
  bottom: calc(26px + env(safe-area-inset-bottom) + var(--vv-bottom) + var(--toast-lift, 0px));
  transform: translate(-50%, 12px);
  z-index: 95; max-width: 320px;
  padding: 12px 16px;
  border-radius: var(--radius-button-sm);
  background: var(--surface-inverted);
  color: var(--text-inverted);
  font-size: var(--body-sm-size); line-height: 18px;
  opacity: 0; pointer-events: none;
  /* bottom is in here for --toast-lift's sake: the lift is written the instant
     the row is asked for, and the row now takes 440ms to get there, so a toast
     already on screen used to make room in one frame and then wait with 110px of
     nothing under it. Transitioned on the row's own curve it rises with the row
     instead — measured 32px of gap at the worst instead of 110 (D51). */
  transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out),
              bottom 440ms var(--ease-rise);
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }

@media (max-width: 359px) {
  .clip-title { font-size: 23px; line-height: 27px; }
}

/* ── Desktop: the deck is the window ────────────────────────
   No frame, no backdrop. The column stays phone-width and centred, the
   slides spread (--slide-h caps at 520), and the masthead holds the
   viewport edges as it does on every other screen. */
@media (min-width: 800px) and (min-height: 700px) {
  .wrap { max-width: none; }
  .top {
    position: fixed;
    top: 18px; left: var(--page-pad-x); right: var(--page-pad-x);
    z-index: 40;
  }
}

/* ── The plan, opened ────────────────────────────────────────
   The card you tapped becomes this screen (D19). It is the same
   object with the same radius, shadow and gradient, grown to hold
   a story: header, the prints dealt wide, the title, the link, then
   what fenn will actually do. The morph is a FLIP in JS — this only
   describes where the card comes to rest. */
.detail {
  position: fixed; inset: 0;
  z-index: 70;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.detail::-webkit-scrollbar { display: none; }
/* The sky again, fading in under the flying card: the deck stays where it
   is behind this, and re-painting the ground is what hides it. */
.detail::before {
  content: '';
  position: fixed; inset: 0;
  z-index: -1;
  background: var(--sky-light) fixed, var(--surface-primary);
  opacity: 0;
  transition: opacity 260ms linear;
}
.detail.active::before { opacity: 1; }
/* The deck's scene, cloned and frozen, over that default sky and under the
   card. Same z-index as ::before and later in tree order, so it paints on top
   of it — which is what makes the pseudo-element the fallback rather than dead
   weight: a plan fenn invented has no scene, and the sky it flies over is the
   one the deck was already showing (D49). */
#d-scene {
  position: fixed; inset: 0;
  z-index: -1;
  overflow: hidden;
  opacity: 0;
  transition: opacity 260ms linear;
}
.detail.active #d-scene { opacity: 1; }
/* The clone carries the --mix and --sp the deck left it at, and there is no
   scroll behind this screen to move them again. */

.dcard {
  width: calc(100% - 16px);
  max-width: 480px;
  margin: 10px auto;
  min-height: calc(var(--dh) - 20px);
  border-radius: 40px;
  background: linear-gradient(180deg, var(--surface-raised), var(--surface-primary));
  border: 1px solid var(--surface-primary);
  box-shadow: var(--card-shadow);
  padding: 14px 24px calc(30px + env(safe-area-inset-bottom));
  /* The FLIP writes translate+scale against this corner. */
  transform-origin: top left;
}
/* The floating row sits over the foot of this card, so the card has to end
   further down than it reads: --float-h is the row's measured height, 18px is the
   offset it sits at, 16px is the gap. Reserved whether the row is showing or not,
   or the last paragraph would shift the moment it appeared (D44). The receiver has
   no floating row, so this is the sender's card only. */
#detail .dcard {
  padding-bottom: calc(30px + env(safe-area-inset-bottom) + var(--float-h, 64px) + 34px);
}

/* Its own header, and nothing in it but the wordmark: the chevron and the QR
   both came out (D34). It keeps the 44px the buttons had, so the fixed close
   button has a row of its own to sit in rather than one over the prints. */
.d-top {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
}

/* The one control on the screen, and it stays put while ~2000px of story runs
   under it (D34). Right-aligned to the card's own edge rather than the
   viewport's, so on a wide window it sits on the card and not out in the sky.
   It crosses photographs, a kicker and body copy on the way down, so it
   carries its own ground instead of trusting whatever it lands on. */
.d-x {
  position: fixed;
  top: 24px;
  /* The card's own 24px padding, so it stands where the QR button stood. */
  right: calc(max(8px, (100% - 480px) / 2) + 24px);
  z-index: 3;
  width: 44px; height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  display: grid; place-items: center;
  background: rgba(255, 255, 255, 0.72);
  -webkit-backdrop-filter: blur(12px) saturate(1.5);
  backdrop-filter: blur(12px) saturate(1.5);
  box-shadow: inset 0 0 0 1px var(--border-secondary),
              0 2px 10px rgba(34, 120, 160, 0.12);
  color: var(--text-primary);
  transition: transform var(--dur-fast) var(--ease-out);
}
.d-x svg { width: 17px; height: 17px; display: block; }
.d-x:active { transform: scale(0.9); }
/* No blur to hide behind: paint the ground opaque instead of translucent. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .d-x { background: var(--surface-raised); }
}
.d-brand {
  display: inline-flex; align-items: center; justify-content: center; gap: 5px;
}
.d-brand span { font-size: var(--h6-size); font-weight: var(--h6-weight); }

/* Dealt wider than the deck ever fans them: on the card this was a corner
   ornament, here it is the photograph at the top of a story. */
.d-stack {
  position: relative;
  width: 232px; height: 174px;
  margin: 26px auto 20px;
}
.dph {
  position: absolute;
  border: 8px solid var(--brand-white);
  border-radius: 14px;
  object-fit: cover;
  background: var(--surface-primary);
  box-shadow: 1px 2px 5px rgba(34, 120, 160, 0.10),
              5px 7px 8px rgba(34, 120, 160, 0.09),
              12px 15px 11px rgba(34, 120, 160, 0.05);
}
.dph-back  { width: 104px; height: 104px; left: -4px;  top: 40px; transform: rotate(-20deg); }
.dph-mid   { width: 122px; height: 122px; left: 47px;  top: -2px; transform: rotate(-6.5deg); }
.dph-front { width: 122px; height: 122px; left: 104px; top: 28px; transform: rotate(5.5deg); }

/* No folder here, and it is not an omission (D43). The folder belongs to the
   deck card: the images come out of it as the card opens and it stays behind, so
   there is nothing on this stack for it to become. D42 did put one here and the
   comment it replaced turned out to be right about why not — at this size the
   pocket is a band across real photographs, glass or not. */

/* ── The frost, and when it is not on ───────────────────────
   The glass really samples what is behind it, which is the whole effect and also
   the risk: this lives inside .stack, which carries a scale() at all times, and
   inside .dcard, which is scaled AND clip-path'd for the length of a flight.
   backdrop-filter under a transformed, clipped ancestor is where WebKit has a
   history, and iOS is the target.

   Only the deck card carries glass now (D43), and the deck card is hidden for the
   length of a flight, so the flight cannot see it and needs no exception. It does
   come off while the reel spins, where the deck is blurred and compositing a
   frost per card is spent for nothing. */
.plate-back, .plate {
  -webkit-backdrop-filter: blur(9px) saturate(1.5);
  backdrop-filter: blur(9px) saturate(1.5);
}
.deck.spinning .plate-back, .deck.spinning .plate {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  /* Without the frost the fills are too thin to read as a panel, so they carry
     their own ground for as long as the filter is off. */
  background-color: rgba(255, 255, 255, 0.55);
}
/* No backdrop-filter at all: the panels keep their gradients and pick up the
   same ground, which is the flat wash the pocket had before any of this. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .plate-back, .plate {
    background-color: rgba(255, 255, 255, 0.55);
  }
}
/* The link is ready, and fenn says so once. */
.d-sparkle {
  position: absolute;
  top: -14px; right: -10px;
  width: 52px;
  display: block;
  pointer-events: none;
}
.detail.active .d-sparkle { animation: sparklePop 480ms var(--ease-out) 250ms both; }

/* Fenn standing in for the prints a plan we invented does not have (D28).
   Same footprint as .d-stack so the composition does not jump. */
.d-fenn {
  width: 220px; height: 172px;
  margin: 26px auto 20px;
  display: grid; place-items: center;
}
.d-fenn img { width: 116px; display: block; }

.d-title {
  margin: 0 0 18px;
  font-size: clamp(27px, 7.6vw, 34px);
  line-height: 1.16;
  font-weight: var(--h4-heavy-weight);
  /* The deck card's -0.035em, not the -0.03em this had: the two titles are
     the same words morphing between two sizes, and a tracking that does not
     match makes the longest of them drift ~3px wide of itself mid-flight.
     The difference is 0.15px a character at rest (D34). */
  letter-spacing: -0.035em;
  color: var(--brand-black);
  text-align: center;
  text-wrap: balance;
}

/* The card's own line, given somewhere to land: title and sub travel together
   out of the deck or the pair comes apart mid-flight (D34). The negative top
   collapses against the title's 18px into a 6px gap, so a plan fenn invented —
   which has no sub — keeps the title's spacing untouched. */
.d-sub {
  margin: -12px 0 18px;
  font-size: var(--body-md-size);
  line-height: 22px;
  color: var(--ink-support);
  text-align: center;
  text-wrap: balance;
}

.d-trust {
  margin: 10px 0 0;
  font-size: var(--body-sm-size); line-height: 18px;
  color: var(--ink-support);
  text-align: center;
}
.d-foot {
  margin: 12px 0 0;
  font-size: var(--body-sm-size); line-height: 18px;
  color: var(--ink-support);
  text-align: center;
}

/* ── What fenn will do ──────────────────────────────────────── */
.d-body { margin: 30px 0 4px; }
.d-para {
  margin: 0 0 16px;
  font-size: var(--body-md-size);
  line-height: 25px;
  color: var(--text-primary);
}
.d-close { margin-bottom: 0; }
.d-photo {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border: 6px solid var(--brand-white);
  border-radius: 14px;
  background: var(--surface-primary);
  box-shadow: 1px 2px 5px rgba(34, 120, 160, 0.10),
              5px 7px 8px rgba(34, 120, 160, 0.09),
              12px 15px 11px rgba(34, 120, 160, 0.05);
  margin: 26px 0;
}
/* Tilted like the prints they came from, in opposite directions so the page
   does not lean. */
.d-photo--a { transform: rotate(-1.6deg); }
.d-photo--b { transform: rotate(1.4deg); }
.d-kicker {
  margin: 30px 0 18px;
  font-size: 20px; line-height: 24px;
  font-weight: var(--h4-heavy-weight);
  letter-spacing: -0.02em;
  color: var(--brand-black);
}
.d-steps { display: flex; flex-direction: column; gap: 20px; }
.d-step { display: flex; gap: 14px; align-items: flex-start; }
.d-emoji {
  flex: 0 0 38px;
  width: 38px; height: 38px;
  border-radius: 50%;
  background: var(--surface-primary);
  display: grid; place-items: center;
  font-size: 19px;
  line-height: 1;
}
.d-step-head {
  margin: 6px 0 4px;
  font-size: 17px; line-height: 21px;
  font-weight: var(--body-weight-bold);
  color: var(--text-primary);
}
.d-step-sub {
  margin: 0;
  font-size: var(--body-sm-size); line-height: 21px;
  color: var(--ink-support);
}

/* The share row appears twice inside the card: at the top, where the link
   already exists, and at the end, where the reading has done its work. */
.sharebar { margin-top: 4px; }

/* ── "Make it happen" CTA ───────────────────────────────────── */
.ch-make {
  flex: 1;
  background: var(--gradient-primary);
  border-color: rgba(255, 255, 255, 0.2);
  box-shadow: 0 5px 20px rgba(80, 177, 249, 0.3);
  color: var(--text-inverted);
}
.ch-make .spin {
  display: inline-block;
  width: 16px; height: 16px;
  margin-right: 8px;
  border: 2px solid rgba(255, 255, 255, 0.3);
  border-top-color: #fff;
  border-radius: 50%;
  vertical-align: middle;
  animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── Created state ──────────────────────────────────────────── */
.d-created {
  margin: 24px 0 0;
  text-align: center;
}
.d-created-qr {
  width: min(220px, 60%);
  margin: 0 auto 24px;
  padding: 16px;
  border-radius: var(--radius-xl);
  background: var(--brand-white);
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
/* Square from the SVG's own viewBox — see .qrveil .qr. */
.d-created-qr svg { display: block; width: 100%; height: auto; }
.d-created-qr[hidden] { display: none; }
/* Who's in: the invite page's avatar row and members line, on the creator's
   side of the link. Hidden until planPreview has answered once. */
.d-created-who {
  margin: -8px auto 20px;
}
.d-created-avatars {
  display: flex;
  justify-content: center;
}
.d-created-avatars:empty { display: none; }
.d-created-avatars .av {
  position: relative;
  overflow: hidden;
  width: 36px;
  height: 36px;
  margin: 0 -5px;
  border: 2px solid var(--brand-white);
  border-radius: 50%;
  background: var(--ink-support);
  color: var(--brand-white);
  font-size: 15px;
  font-weight: 600;
  line-height: 32px;
  text-align: center;
}
.d-created-avatars .av img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.d-created-avatars .av.more {
  background: var(--brand-white);
  color: var(--ink-support);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.d-created-line {
  margin: 10px 0 0;
  font-size: var(--body-sm-size);
  line-height: 19px;
  color: var(--ink-support);
}
.d-created-actions {
  display: flex;
  gap: 9px;
  justify-content: center;
}
.d-created-open {
  display: block;
  margin: 12px 0 0;
  text-decoration: none;
}
.d-created-note {
  margin: 16px 0 0;
  font-size: var(--body-sm-size);
  line-height: 19px;
  color: var(--ink-support);
}

/* While the plan is open the deck must not scroll underneath it. */
body.detail-open .deck { overflow: hidden; }
