/* ═══════════════════════════════════════════════════════════════════════════
   case-study.css — long-form case study pages

   Loaded AFTER style.css, by standalone case study pages only (jupiter.html).
   Everything here is layout. Type, colour, radius and the two font families
   all come from style.css tokens — nothing new is invented.

   ── Why a second file ──────────────────────────────────────────────────────
   style.css is already 3.3k lines and owns the landing page plus the two
   .case-* overlays. A 17-section long-form page needs a dozen layout patterns
   that nothing else on the site uses. Bolting them onto the monolith buys
   nothing: no other page loads them. This mirrors the per-component JS split
   already in the project (sticker-field.js, testimonials.js, orbit-reel.js).

   ── .case-* vs .cs-* ───────────────────────────────────────────────────────
   .case-*  = the overlay case studies inside index.html (Wave, Mosaico).
              Short-form: hero, text, metrics, gallery, team.
   .cs-*    = this file. Long-form editorial page, its own URL.
   They share tokens, fonts and the .case-metric-* stat treatment, which this
   file reuses directly rather than re-cutting. They do not share layout.

   ── Mobile-first, deliberately ─────────────────────────────────────────────
   style.css is desktop-first (base rules + max-width: 640px overrides). This
   file goes the other way: base rules are the narrow layout, and min-width
   queries scale up. That is what the brief for this page asked for, and since
   nothing else loads this file the two conventions cannot collide.
═══════════════════════════════════════════════════════════════════════════ */


/* ─── Page shell ──────────────────────────────────────────────────────────
   Cream ground, same as .case-overlay. The landing page's snap scroll,
   Lenis and GSAP are all absent here on purpose — this is a document you
   read, so it uses the browser's own scrolling.                          */

.cs-page {
  background: var(--color-cream);
  color: var(--color-dark);
  /* Crossfade, not a jump — the landing page's own figure for the same
     move. Only the ground travels; foreground tokens stay put, because they
     are flipped per-section by .cs-ground--green rather than page-wide, and
     a page-wide flip would take the cream sections either side of it with
     it. */
  transition: background-color 0.6s ease;

  /* Side margin: 6.4vw matches the .case-overlay mobile inset, 8.33vw the
     desktop one (= --page-margin = --case-margin). Defined on the page rather
     than on .cs because the top bar needs it too. */
  --cs-margin: 6.4vw;

  /* The one width every section's content shares. Matches --meta-pad's own
     card exactly (950px), not a separately-chosen number — the two used to
     be independent, which is what let the info card sit narrower than
     everything around it. Now the card is simply full-width inside a column
     that is already this size; nothing about the card itself needed to
     change. Ground bands (.cs-band) still bleed to the viewport edge for
     their background — that is an existing, separate device for marking a
     real change in what the page is doing, not a column — but their own
     text content is wrapped in .cs-band-copy, which reads this same token,
     so a reader's eye meets the same left edge whether the ground under it
     is cream or dark. */
  --cs-content-max: 950px;

  /* Vertical rhythm. Sections are far apart, blocks inside them are close.
     All multiples of 4. +40px at every breakpoint on instruction (80/120/160
     -> 120/160/200), so the ratio between the three steps is unchanged. */
  --cs-sec-gap: 120px;   /* between numbered sections */
  --cs-blk-gap: 32px;   /* between blocks inside one section */

  --cs-bar-h: 56px;

  /* The article's own top padding, tokenized rather than inlined because
     .cs-hook cancels it — see the quarter-viewport note there. */
  --cs-pad-top: 96px;

  /* Hairline + secondary text.

     #a3a3a3 is the site's de-facto secondary grey — .work-label, .work-date,
     .case-team-label and .work-subtitle all hardcode it — but every one of
     those that reads well lives in #work, on the dark ground, where it
     measures 6.6:1. On this page's cream it is 2.29:1, less than half the
     4.5:1 floor the project sets for body text, and the labels here are real
     content: the spec sheet keys, the benchmark lenses, the persona meta.

     So: dark at 66% over cream instead. Measured 5.1:1. Noticeably darker
     than #a3a3a3, which is the point.

     Worth knowing: .case-team-label in style.css has exactly this problem —
     #a3a3a3 on the cream .case-overlay — and is not fixed here. */
  /* Foreground for everything that sits on the current ground. Cream is the
     page default; a .cs-band--dark redefines this and the three below. */
  --cs-fg:    var(--color-dark);

  /* Cream-on-dark, defined once. Two separate things need these: a dark band,
     and the fixed bar when it is sitting over one. The bar is outside the band
     in the DOM, so it cannot inherit the band's own --cs-label / --cs-rule and
     was carrying its own copies of the numbers. Hoisted here so the bar and
     the band cannot drift to different greys.
     Measured on --color-dark: label 8.4:1, well clear of the 4.5:1 floor. */
  --cs-on-dark-label: rgba(245, 244, 240, 0.72);
  --cs-on-dark-rule:  rgba(245, 244, 240, 0.20);

  --cs-rule:  rgba(30, 30, 32, 0.16);   /* = --color-border in CLAUDE.md */
  --cs-label: rgba(30, 30, 32, 0.66);
  /* A step up from --cs-label for labels that need to out-rank the body they
     sit against — eyebrows and the prologue's annotation keys. Darker, and
     paired with weight 700, so the label reads as a heading of its line rather
     than as quiet metadata. Still short of --cs-fg, which is reserved for real
     headings and running copy. */
  --cs-label-strong: rgba(30, 30, 32, 0.86);
  --cs-tint:  rgba(30, 30, 32, 0.03);   /* placeholder + aside ground */
  --cs-dash:  rgba(30, 30, 32, 0.28);   /* every placeholder outline */

  /* The gap from an eyebrow to the heading it introduces. Deliberately tighter
     than --cs-blk-gap (32-40px): an eyebrow and its heading are one unit, and
     at the block gap they read as two separate things. One value, applied
     wherever an eyebrow sits above a heading. */
  --cs-eyebrow-gap: 20px;

  /* Raised card surface. The §2 info card is the first use; any later section
     that needs a panel lifted off the cream ground pulls these three (via the
     .cs-card class, or by referencing the tokens) so the treatment cannot
     drift from one card to the next. Shadow recipe is the site's own
     resting-card shadow, mixed against --color-dark rather than a bespoke
     rgba so it re-tints if the ink colour ever changes. */
  --cs-card-bg:     var(--color-white);
  --cs-card-radius: var(--radius);
  --cs-card-shadow: 0 8px 24px -8px color-mix(in srgb, var(--color-dark) 14%, transparent);

  /* ── Paired phone screens ────────────────────────────────────────────────
     §3's then/now and §10-20's before/after are the same object: two screens
     of deliberately different size, stood on one baseline over a dot field,
     the later one haloed. These four were §3's private --pro-* values until
     the screen sections adopted the same treatment; they are hoisted here so
     the two compositions cannot drift, which is the whole point of the
     instruction that they match.

     --cs-screen-step  how much larger the second screen is than the first.
                       The size jump IS the argument in both sections.
     --cs-screen-glow  a warm halo marking where the story lands: a coral
                       layer under the neutral card shadow, wide and soft with
                       no offset, so it reads as a glow and not a second
                       shadow.
     --cs-screen-hover how far a hovered screen grows. Felt more than seen.
     --cs-screen-dots  fainter than the landing page's fields (0.32) — this
                       one sits behind artwork, not behind running text. */
  --cs-screen-step:  1.2;
  --cs-screen-glow:  0 2px 44px 4px color-mix(in srgb, var(--hook-highlight) 20%, transparent);
  --cs-screen-hover: 1.08;
  --cs-screen-dots:  0.18;

  /* Screens carry a heavier shadow than the rest of the card system, and the
     reason is the artwork rather than the hierarchy: the Jupiter product
     screens are drawn on the app's own cream ground, measured at
     rgb(245,243,239) against this page's rgb(245,244,240). A delta of one
     level in two channels — the edge is invisible, and the shadow is the only
     thing separating a screen from the page it sits on. --cs-card-shadow, at
     14% with a -8px spread, is not enough on its own for that.

     Three layers, no ring: an ambient with no offset so the TOP edge reads
     too (a purely downward shadow leaves it merged), a tight contact, and the
     same soft depth the cards have. The screens must not gain a border —
     that is a standing instruction — so every edge here is drawn with light,
     not with a stroke. Still mixed against --color-dark, so this is the card
     recipe turned up rather than a bespoke colour. */
  --cs-screen-shadow:
    0 0 4px 0    color-mix(in srgb, var(--color-dark)  9%, transparent),
    0 3px 8px -2px color-mix(in srgb, var(--color-dark) 15%, transparent),
    0 14px 32px -10px color-mix(in srgb, var(--color-dark) 20%, transparent);

  /* The page's phone-frame width — what a full-size screen renders at. One
     token so the 1200px step-up below reaches the real screens, the
     placeholders still waiting on assets, and the stepped-down before screen
     together; they were three separate max-widths and drifted apart once. */
  --cs-screen-w:     280px;

  /* ── Cursor bubble (.cs-bubble) ──────────────────────────────────────────
     A FigJam/Figma multiplayer chat bubble: a pointer at one top corner and a
     filled label hanging off it. Tokens live here, not on the component, so a
     later section can retint one bubble without forking the recipe.

     The ground is spacefire exactly — #FC7A69, the brand value, not a darker
     stand-in.

     The ink is white, on instruction, and that is a knowing trade rather than
     an oversight: white on #FC7A69 measures 2.58:1, which fails WCAG AA and
     fails the 3:1 large-text floor too, so no size or weight setting rescues
     it. Dark ink measures 6.44:1 and was shown side by side against it; white
     was chosen anyway, because the alternative that keeps white legible is a
     darkened ground that stops reading as spacefire. The bubble carries a
     short conclusion that is also stated in the running copy around it, so no
     information is only available here. Do not "fix" this by darkening the
     ground — that has been tried and rejected. Re-measure before changing
     either value. */
  --cs-bubble-bg:     var(--hook-highlight);
  --cs-bubble-fg:     var(--color-white);
  /* Three corners match the cards the bubble sits under, so a callout and a
     card read as the same family of surface. The fourth — the one the pointer
     attaches to — is nearly square, which is what makes the bubble read as
     belonging to that cursor. That 1px is the only literal radius here and is
     specified by name; see CLAUDE.md's radius exceptions. */
  --cs-bubble-radius:      var(--cs-card-radius);
  --cs-bubble-radius-tip:  1px;
  --cs-bubble-pad:    16px;
  --cs-bubble-cursor: 24px;   /* pointer's bounding box, square */
  /* Clear air between the pointer's tail and the bubble's corner. The two are
     one gesture but two objects, and letting them touch reads as a tooltip
     tail rather than as a cursor with a label. */
  --cs-bubble-cursor-gap: 8px;
  /* Shape-accurate, so it wraps the pointer too — see the filter note on the
     component. Two stops: a tight contact shadow plus a wider, softer cast,
     which is what reads as "hovering" rather than "outlined". */
  --cs-bubble-shadow:
    drop-shadow(0 1px 1px color-mix(in srgb, var(--color-dark) 16%, transparent))
    drop-shadow(0 8px 16px color-mix(in srgb, var(--color-dark) 20%, transparent));
}

/* body in style.css has overflow-x hidden on <html>; nothing to add. */

.cs {
  display: flex;
  flex-direction: column;
  gap: var(--cs-sec-gap);
  padding: var(--cs-pad-top) var(--cs-margin) var(--cs-sec-gap);
}

/* The +40px on each side from 641px up pulls the content column in so it stops
   stretching to full width on a large screen: 1200px -> 1120px of content at
   1440. Deliberately NOT applied below 641px — 6.4vw is only 24px on a 375px
   phone, and +40 there would leave 247px of usable width, which is a different
   problem from the one this solves. */
@media (min-width: 641px) {
  .cs-page {
    --cs-margin: calc(6.4vw + 40px);
    --cs-sec-gap: 160px;
    --cs-blk-gap: 40px;
    --cs-bar-h: 64px;
    --cs-pad-top: 120px;
  }
}

@media (min-width: 1025px) {
  .cs-page {
    --cs-margin: calc(8.33vw + 40px);
    --cs-sec-gap: 200px;
    --cs-pad-top: 152px;
  }
}

/* ─── Ground bands ────────────────────────────────────────────────────────
   The page was one continuous cream ground for seventeen sections, which is
   why it read flat however well each section was set: nothing told the eye
   where it had arrived. A band is a section that takes over the full width
   and changes the ground under it.

   Used sparingly and structurally, not decoratively — the impact numbers
   (twice, and they are the same numbers, so the ground is what makes the
   second reading land differently) and the untangle. Three moments across a
   long document, each marking a real change in what the page is doing.

   ── How the full-bleed works ───────────────────────────────────────────────
   .cs pays --cs-margin as padding. A band cancels exactly that with a
   negative inline margin and re-pays it as its own padding, so the ground
   reaches the viewport edge while the content column never moves. Because
   both come from the same token they cannot drift apart at any breakpoint.

   Everything inside a band reads its colour from --cs-fg / --cs-label /
   --cs-rule, which the band redefines. Shared type (.cs-h2, .cs-lede) points
   at --cs-fg rather than --color-dark for exactly this reason: one selector
   set works on either ground.                                              */

.cs-band {
  margin-inline: calc(-1 * var(--cs-margin));
  padding: var(--cs-sec-gap) var(--cs-margin);
  background: var(--cs-band-bg, transparent);
}

.cs-band--dark {
  --cs-band-bg: var(--color-dark);
  --cs-fg:    var(--color-cream);
  /* Cream at 72% over --color-dark measures 8.4:1 — well clear of the 4.5:1
     floor, and the same relationship to its ground that --cs-label has to
     cream (5.1:1) rather than a guess at "looks about right". */
  --cs-label: var(--cs-on-dark-label);
  --cs-label-strong: rgba(245, 244, 240, 0.92);
  --cs-rule:  var(--cs-on-dark-rule);
  --cs-tint:  rgba(245, 244, 240, 0.06);
  --cs-dash:  rgba(245, 244, 240, 0.32);
  color: var(--cs-fg);
}

/* ── A full-viewport ground, the landing page's about fold on this page ─────
   Three things make that fold what it is: it owns the whole screen, the page
   crossfades to its colour as it takes over, and the dot field sits on it.
   All three are reproduced here; the type inside is untouched.

   The section paints its own ground AND .cs-page crossfades to the same
   colour underneath it (case-study.js sets data-cs-ground). Both, not either:
   the crossfade is what makes the change read as the page turning over rather
   than a coloured block scrolling past, but it is script-driven, and a
   script-driven background with script-independent cream type on it would be
   cream-on-cream the moment the script did not run. The section's own
   background is what makes the type safe without it.                       */
.cs-ground {
  /* min-height, not height: .cs-band's --cs-sec-gap padding is inside this
     box, and at the narrow end the copy plus that padding is taller than the
     viewport. A fixed height would clip it. */
  min-height: 100svh;
  justify-content: center;
  position: relative;
  isolation: isolate;
  overflow: hidden;
}

/* Cream on green, matching the landing page's own treatment of this ground.
   Same token-flip shape as .cs-band--dark above, so every shared type rule
   (.cs-h2, .cs-lede, .cs-eyebrow) lands correctly with no per-element
   overrides. */
.cs-ground--green {
  /* Was var(--color-green) (#49a862); now the same near-black the impact
     band and the spectrum tiles use, on instruction -- see --color-charcoal's
     own note in style.css. This incidentally clears a contrast gap the two
     comments below were written around: cream on #49a862 measured 2.70:1,
     under the 3:1 large-text floor; cream on #2b2d33 measures 12.5:1, clear
     of even the 7:1 AAA floor. Left both comments in place rather than
     rewritten, because they still explain why --cs-fg and --cs-label are
     full-opacity cream rather than the page's usual 72% label -- that
     reasoning does not depend on which ground it was measured against, and
     full-opacity cream at 72% flattened over this ground still measures
     7.24:1, so the choice costs nothing here either. */
  --cs-band-bg: var(--color-charcoal);
  /* Cream, on instruction -- reverses the dark-ink swap from two rounds ago.
     That round measured cream at 2.70:1 here, below the 3:1 large-text floor,
     and switched to dark ink (5.60:1) to clear it. Told to keep it light
     regardless: still 2.70:1, still under AA, same number as before. Left as
     a known, named gap rather than silently "fixed" again -- see the
     PORTFOLIO_DIRECTION.md row for the reasoning either way, since this has
     now gone back and forth once. */
  --cs-fg:    var(--color-cream);
  /* Full opacity, on instruction -- was --cs-on-dark-label (cream at 72%,
     the figure every other dark ground on this page uses for its secondary
     text). That 72% was already short of AA here at 2.09:1; full cream only
     brings it up to --cs-fg's own 2.70:1, the same shortfall the heading
     already carries and the same trade-off already named above. Body copy
     and the heading now read as one weight of "still under AA", not two. */
  --cs-label: var(--color-cream);
  --cs-label-strong: rgba(245, 244, 240, 0.92);
  --cs-rule:  var(--cs-on-dark-rule);
  --cs-tint:  rgba(245, 244, 240, 0.06);
  --cs-dash:  rgba(245, 244, 240, 0.32);
  color: var(--cs-fg);
}

/* The field itself. Pattern comes from the one shared rule in style.css, so
   this cannot drift from the landing page's own dots in tile size or weight;
   only the box is this section's. Inset by --cs-margin rather than run to the
   bleed, which is what .dots-layer does with --page-margin on the landing
   page -- the field is a margin-to-margin panel there, not a full-bleed
   texture, and it does not line up with the copy's own column on either page.

   z-index: -1 puts it under the copy but still over the section's own
   background: a stacking context paints its background first and its
   negative-z children second, so the order is green, then dots, then text. */
.cs-ground-dots {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--cs-margin);
  right: var(--cs-margin);
  z-index: -1;
  /* Matches .cs-impact-dots -- both are the same shared field over a dark
     ground now, and this one never got the flip that section's did. Without
     it, the pattern's own light-ground default (--color-dark dots at 0.32)
     was painting dark dots on a near-black ground: technically present,
     practically invisible. Cream at 0.10, not the shared 0.32: that figure
     is tuned for dark dots on a light ground, and cream at the same value on
     a dark one reads as static rather than texture. */
  --dot-ink: var(--color-cream);
  --dot-opacity: 0.10;
}

/* A band that runs to the top of the next one with no cream seam between. */
.cs-band + .cs-band { margin-top: calc(-1 * var(--cs-sec-gap)); }

/* One section. Blocks inside stack at the tighter rhythm. */
.cs-sec {
  display: flex;
  flex-direction: column;
  gap: var(--cs-blk-gap);
}

/* One column, one width, used everywhere. :not(.cs-band) is the only carve-out
   — a band's own element has to stay full-bleed for its background, so ITS
   content is capped by .cs-band-copy one level in instead. Every plain
   section gets this for free with no markup change: the flex stretch above
   already gives each .cs-sec the full padded width, so a max-width narrower
   than that plus an auto inline margin is enough to centre it exactly like a
   block element would. */
.cs-sec:not(.cs-band) {
  /* width:100% is doing the real work; margin-inline:auto is only there to
     centre what max-width then trims off. Tried align-items:stretch on .cs
     first, on the theory that its initial value ("normal") wasn't resolving
     to stretch — it was already resolving correctly, and stretch was never
     the problem. The actual cause: per the flexbox spec, an "auto" cross-axis
     margin on a flex item cancels stretch outright and falls back to the
     item's own shrink-to-fit width, THEN centres that with the margin. So
     any section whose content was naturally narrower than 950px — a lone
     heading and one diagram frame, a short paragraph — sat at its own
     content width instead of the shared one; sections with enough content to
     fill 950px anyway (most of them) looked correct by accident. width:100%
     is an explicit, non-auto value, which keeps stretch out of the decision
     entirely: the box is always min(available width, 950px), full stop. */
  width: 100%;
  max-width: var(--cs-content-max);
  margin-inline: auto;
}

/* Reusable raised panel on the cream ground. Carries only the surface — colour,
   corner, shadow — from the --cs-card-* tokens; a component that uses it owns
   its own padding and internal layout (see .cs-meta). Add it to any future
   section that needs the same lifted card as §2's info card. */
.cs-card {
  background: var(--cs-card-bg);
  border-radius: var(--cs-card-radius);
  box-shadow: var(--cs-card-shadow);
}

/* ─── .cs-bubble — the cursor callout ──────────────────────────────────────
   A multiplayer cursor bubble, borrowed from Figma/FigJam: a pointer sitting
   at one top corner with a filled label hanging off it, so a remark reads as
   someone pointing at the thing above it rather than as more page copy.

   Markup is the class and a direction, nothing else — the pointer is drawn by
   the component:

       <aside class="cs-bubble cs-bubble--left" data-motion="bubble">
         <p class="cs-eyebrow">The takeaway</p>
         <p class="cs-bubble-line">...</p>
       </aside>

   --left puts the pointer at the top-left corner aiming up and out to the
   left; --right mirrors it. Pick whichever aims at the thing being remarked
   on; the bubble then grows away from that corner, so the copy always lands
   on the side with room for it.

   Three things here are load-bearing:

   1. It hugs. width: fit-content, so a three-word bubble is three words wide.
      --cs-bubble-measure is only a ceiling for when the text is long enough
      to need a reading measure; short content never reaches it.

   2. The shadow is a `filter`, not a `box-shadow`. box-shadow follows the
      border box, which would draw a rounded rectangle's shadow and leave the
      pointer flat and pasted-on. drop-shadow works off rendered alpha, and a
      filter on an element applies to its pseudo-elements too, so one
      declaration wraps bubble and pointer as a single silhouette. The white
      text casts nothing, because it sits on an opaque fill and so does not
      change that silhouette.

   3. The corner nearest the pointer is --cs-bubble-radius-tip (1px) and the
      other three are --cs-bubble-radius (4px). Both are literal values rather
      than --radius, at Shriya's explicit direction, and the asymmetry is the
      point: a near-square corner reads as attached to the cursor, while a
      matched 4px would read as a rectangle that happens to have an arrow near
      it. See CLAUDE.md's radius-exception list.

   The pointer is two stacked masked boxes: ::before is the dart in white,
   ::after the same dart inset by 1px in spacefire, which leaves a 1px white
   border running the whole way around the fill, as Figma's own cursors have.
   The inset comes from the masks themselves rather than from scaling the
   box — each mask fills AND strokes the same path with a round linejoin (the
   stroke is also what rounds the dart's three corners, which a bare polygon
   cannot do), and a stroke grows a silhouette by half its width. 2.6 against
   0.93 is a 0.835-unit ring, which at the 1.2x this 20-unit viewBox is scaled
   to inside a 24px box lands on 1px. Change --cs-bubble-cursor and the ring
   scales with it, which is the right behaviour: a keyline that stayed 1px on
   a 48px cursor would read as hairline dirt. */
.cs-bubble {
  --cs-bubble-measure: 34em;
  /* The dart, pointing up and left, at two sizes: the outer silhouette and
     the same path 1px in. `black` rather than a hex so the data URI needs no
     escaped #. */
  --cs-bubble-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2 2 L17 9.2 L9.8 9.8 L9.2 17 Z" fill="black" stroke="black" stroke-width="2.6" stroke-linejoin="round"/></svg>');
  --cs-bubble-mask-inner: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2 2 L17 9.2 L9.8 9.8 L9.2 17 Z" fill="black" stroke="black" stroke-width="0.93" stroke-linejoin="round"/></svg>');

  position: relative;
  width: fit-content;
  max-width: var(--cs-bubble-measure);
  /* Reserves exactly the part of the pointer that overhangs the top edge, so
     the bubble can be dropped under anything without the dart colliding. */
  margin-top: calc(var(--cs-bubble-cursor) / 2 + var(--cs-bubble-cursor-gap));
  padding: var(--cs-bubble-pad);
  border-radius: var(--cs-bubble-radius);
  background: var(--cs-bubble-bg);
  color: var(--cs-bubble-fg);
  filter: var(--cs-bubble-shadow);
}

.cs-bubble::before,
.cs-bubble::after {
  content: '';
  position: absolute;
  width: var(--cs-bubble-cursor);
  height: var(--cs-bubble-cursor);
  /* The dart's notch sits at the centre of its own box, so pulling the box
     back by half the cursor would land that notch exactly on the bubble's
     corner. --cs-bubble-cursor-gap then pulls it further out again, which is
     what opens the air between the tail and the corner. */
  top: calc(var(--cs-bubble-cursor) / -2 - var(--cs-bubble-cursor-gap));
}

.cs-bubble::before {
  background: var(--color-white);
  -webkit-mask: var(--cs-bubble-mask) center / contain no-repeat;
  mask: var(--cs-bubble-mask) center / contain no-repeat;
}

.cs-bubble::after {
  background: var(--cs-bubble-bg);
  -webkit-mask: var(--cs-bubble-mask-inner) center / contain no-repeat;
  mask: var(--cs-bubble-mask-inner) center / contain no-repeat;
}

/* --- Direction ---------------------------------------------------------- */

/* The pointer's position is the only place this geometry is written down; the
   `bubble` motion primitive measures the dart's computed box to find its
   growth anchor rather than keeping a second copy of these numbers. */
.cs-bubble--left { border-top-left-radius: var(--cs-bubble-radius-tip); }

.cs-bubble--right {
  border-top-right-radius: var(--cs-bubble-radius-tip);
  margin-left: auto;
}

.cs-bubble--left::before,
.cs-bubble--left::after { left: calc(var(--cs-bubble-cursor) / -2 - var(--cs-bubble-cursor-gap)); }

.cs-bubble--right::before,
.cs-bubble--right::after {
  right: calc(var(--cs-bubble-cursor) / -2 - var(--cs-bubble-cursor-gap));
  transform: scaleX(-1);
}

/* --- Contents ----------------------------------------------------------- */

/* The eyebrow keeps its size, weight and tracking and only changes ink — the
   grey it carries on cream is under 2:1 on this ground. Solid, not a
   translucent one: at --text-label this is small text and needs the full
   4.5:1, which any alpha below 1 gives up. Hierarchy inside the bubble comes
   from size and case instead, which is what it came from outside too. */
.cs-bubble .cs-eyebrow {
  color: var(--cs-bubble-fg);
  /* 400, not the eyebrow's usual 700: a bubble sets no bold anywhere, and this
     was the last of it. Geist loads 400 and 700 only — there is no 500 to
     split the difference with (see CLAUDE.md) — so it goes all the way down.
     The label still reads as a label on case and tracking alone, which is
     most of what was carrying it. */
  font-weight: 400;
  margin-bottom: 8px;
}

.cs-bubble-line {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-lead);
  line-height: 1.4;
  letter-spacing: -0.01em;
  color: var(--cs-bubble-fg);
  margin: 0;
}

/* .cs-bubble-hi was here — the closing clause at weight 700, carrying the
   emphasis the spacefire accent used to. Removed on instruction: a bubble
   sets no bold. The clause is the end of the sentence and lands on its own. */

/* The text half of a ground band — heading and lede — sized and
   centred the same way as everything else on the page. The band's own
   diagram or number layout is NOT inside this: it stays a direct child of
   the section so it can keep using the section's real full-bleed width for
   whatever visual device it needs (the untangle diagram's own pinned canvas,
   for one). */
.cs-band-copy {
  /* width:100% first, same reasoning as .cs-sec:not(.cs-band) above — an auto
     margin alone would let this shrink to its own content's width instead of
     the shared one. */
  width: 100%;
  max-width: var(--cs-content-max);
  margin-inline: auto;
  display: flex;
  flex-direction: column;
  gap: var(--cs-blk-gap);
}


/* ─── Shared type ─────────────────────────────────────────────────────────
   Three roles, used by every section. Nothing else sets font-family. */

/* Section heading.

   The page used to carry only tiny grey eyebrows, which meant it had no
   typographic hierarchy at all between the hook and the body — every section
   opened at the same whisper, and nothing announced itself while scrolling.

   Sized between the body and the hook so it reads as a chapter without
   competing with the thesis: 24px on a phone, 48px at 1440. None of the
   :root type tokens sits in that gap — --text-body tops out at 36 and
   --text-case starts at 64, which is the hook's own size — so this is a
   local token rather than a hijacked one.

   The 1.5rem floor is set against the hook's, not chosen for its own sake.
   --text-case bottoms out at 2rem, and a 1.75rem floor here put the two
   within 4px of each other on a phone — both clamped, hierarchy gone. At
   1.5rem the heading stays three-quarters of the hook at every width. */
/* Promoted to style.css as --text-case-h2 on 2026-09-29, so the About-me
   overlay's reel headline can be sized to the same scale without index.html
   having to load this stylesheet. The clamp itself is unchanged — the value
   now has one home, and .cs-h2 keeps its local alias so every rule below
   still reads in this file's own terms. */
.cs-page { --cs-h2: var(--text-case-h2); }

.cs-h2 {
  font-family: var(--font-display);
  /* 500, matching the project header (.cs-hook-line, --hook-title-weight: 500)
     — every section heading from the prologue on carries the same weight as
     the title of the piece. Also what CLAUDE.md's type scale already assigns
     to "the case-study page's sub-headings"; this was 700 before, a drift from
     that. .cs-screen-framing (§10-20) matches. */
  font-weight: 500;
  font-size: var(--cs-h2);
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--cs-fg);
  max-width: 18em;
  margin: 0;
}

/* Eyebrow — the small uppercase label that introduces a section heading.
   Weight 700 for emphasis over the body, but the label grey rather than the
   strong ink: on instruction, every eyebrow on the page is now grey. The
   heading right below carries the section; the eyebrow just names it. */
.cs-eyebrow {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 700;
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  margin: 0;
}

/* An eyebrow and the heading it introduces are one unit, so the space between
   them is --cs-eyebrow-gap (20px), tighter than the --cs-blk-gap the section's
   flex layout puts between every other pair of blocks. Expressed as a negative
   top margin that cancels the block gap down to the eyebrow gap, so it stays
   20px at both the 32px and 40px block-gap breakpoints. Applies anywhere an
   eyebrow precedes a heading. */
.cs-eyebrow + .cs-h2,
.cs-eyebrow + .cs-display,
.cs-eyebrow + .cs-results {
  margin-top: calc(var(--cs-eyebrow-gap) - var(--cs-blk-gap));
}

/* Where a subhead follows that heading, it now closes to the same
   --cs-eyebrow-gap rather than the section's normal --cs-blk-gap — on
   instruction, so an eyebrow/heading/subhead group reads as one tight unit
   with even spacing throughout, not 20px then 32-40px. Scoped to headings
   that sit directly after an eyebrow: a heading with no eyebrow above it
   keeps the section's ordinary block rhythm before its own subhead. */
.cs-eyebrow + .cs-h2 + .cs-lede,
.cs-eyebrow + .cs-display + .cs-lede {
  margin-top: calc(var(--cs-eyebrow-gap) - var(--cs-blk-gap));
}

/* The page's largest type. Same recipe as .case-title. */
.cs-display {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--text-case);
  line-height: 1.125;
  letter-spacing: -0.02em;
  color: var(--color-dark);
  margin: 0;
}

/* Section-opening statement — between display and body. */
.cs-lede {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: var(--text-body);
  line-height: 1.333;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  /* 32em, not 24. The source's subheads are real paragraphs of 40-60 words,
     and at 24em they wrapped to six or seven short lines that read as a
     column of fragments. 32em holds roughly 60-65 characters, which is inside
     the comfortable measure and stops the ragging. */
  max-width: 32em;
  margin: 0;
}

/* Running body copy. Same recipe as .case-body p.
   max-width is in em so the measure tracks the fluid type — at any viewport
   it stays ~40 characters per line rather than sprawling at 1920px. */
.cs-prose {
  display: flex;
  flex-direction: column;
  gap: 16px;
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.5;
  color: var(--color-dark);
  max-width: 40em;
  font-feature-settings: 'liga' 0;
}

.cs-prose p { margin: 0; }
.cs-prose strong { font-weight: 700; }


/* ─── Top bar ─────────────────────────────────────────────────────────────
   Carries the one persistent control: back to the portfolio.

   It was a floating corner button first. That does not work on this page: the
   content column starts at --cs-margin (40px at 630px wide, 24px on a phone),
   so any floating control sits on top of the running text — measured, it
   covered the first two characters of a section lede and cut through a
   paragraph in the deep dive. There is no gutter to hide in at any width the
   page actually gets read at.

   A full-width opaque bar has the same footprint but reads as chrome rather
   than as something broken, and it is the pattern the landing page's own nav
   already uses. Content scrolling under it is then expected.               */

/* The bar themes with whatever ground is under it, the way the landing page's
   nav does with body[data-bg]. Without this it stays a cream slab sitting on
   top of a dark band, which reads as a rendering fault rather than as chrome.
   The state is set by case-study.js from scroll position, so it works with or
   without GSAP. */
/* Set by case-study.js when a .cs-ground section holds the middle of the
   viewport. The section is 100svh, so at the moment this flips it already
   fills the screen — which is what keeps the crossfade off the cream
   sections above and below it.

   "green" here is a STATE NAME, not a colour any more -- case-study.js reads
   it off the section's own data-ground="green" attribute, and renaming that
   plumbing was out of scope for a colour swap. What the state paints is
   --color-charcoal now, same as .cs-ground--green itself; the crossfade has
   to match the section it is leading into or the "page turning over" effect
   reads as a colour mismatch instead. */
/* "dark" is §17's state, and unlike "green" it IS the colour's name. Same
   device, same 0.6s on .cs-page above: the page turns over to the section's
   ground while that section holds the middle of the viewport, instead of a
   coloured block sliding past a cream page with a hard edge at each end.

   ── The foreground travels too, and it has to ──────────────────────────────
   .cs-page's own comment above says only the ground moves, "because a
   page-wide flip would take the cream sections either side of it with it".
   Taking them with it is the POINT, and not doing so was a bug.

   The claim this rested on -- that a 100svh ground "already fills the screen
   at the moment this flips, which is what keeps the crossfade off the cream
   sections above and below it" -- is not true of a -50%/-50% root margin.
   That margin is a zero-height line across the middle of the viewport, so the
   state turns on when the section's TOP crosses it: the section covers the
   bottom half and a cream neighbour still holds the top half. Measured on
   §5, the one the comment was written about: 309px of §4's tail is on screen
   when it flips, and 308px of §6's head when it leaves. It went unnoticed
   only because those two strips happen to be near-empty. §16's tail is not --
   it is the "After" caption, and it went dark-grey-on-charcoal.

   So this does what the landing page does: body[data-bg="green"] sets
   background-color AND color, and the whole fold turns over together. Same
   token set .cs-band--dark flips, so a cream section caught mid-fade reads
   as cream type on a darkening ground rather than as dark type disappearing
   into it. .cs-band--dark and .cs-ground--green set these on themselves and
   are more specific inside their own subtrees, so the dark sections are not
   affected by this at all -- it only reaches the transparent ones. */
.cs-page[data-cs-ground="green"],
.cs-page[data-cs-ground="dark"] {
  background: var(--color-charcoal);
  --cs-fg:    var(--color-cream);
  --cs-label: var(--cs-on-dark-label);
  --cs-label-strong: rgba(245, 244, 240, 0.92);
  --cs-rule:  var(--cs-on-dark-rule);
  --cs-tint:  rgba(245, 244, 240, 0.06);
  --cs-dash:  rgba(245, 244, 240, 0.32);
}

/* Custom properties do not transition, so without this the type would SNAP
   to cream while the ground took 0.6s to arrive -- the one thing more
   noticeable than the dark text was. Listed by role rather than applied with
   a wildcard: these are the type classes that can appear in the strip a
   crossfade exposes, and a blanket transition would also catch every
   component that animates colour on hover and slow it to 0.6s. */
.cs-page .cs-eyebrow,
.cs-page .cs-h2,
.cs-page .cs-lede,
.cs-page .cs-sub,
.cs-page .cs-sub strong,
.cs-page .cs-pro-key,
.cs-page .cs-pro-text,
.cs-page .cs-screen-title,
.cs-page .cs-screen-framing {
  transition: color 0.6s ease;
}

/* ── The bar's ground ──────────────────────────────────────────────────────
   One declaration for all three states. --cs-bar-ground is written onto the
   body by case-study.js, read off the band the bar is actually over, so the
   bar cannot sit a shade off the thing behind it; the fallback is only for
   the frame before the first paint and for the no-JS path.

   It also follows the page's own crossfade. While data-cs-ground is set the
   page is charcoal everywhere, including the strip under the bar, so the bar
   has to travel with it — otherwise it stays cream over a charcoal page for
   the length of the fade. Both selectors resolve to the same colour, and the
   transition below is 0.6s to match .cs-page's exactly rather than finishing
   ahead of it. */
.cs-page[data-cs-bar="dark"] .cs-bar,
.cs-page[data-cs-bar="green"] .cs-bar,
.cs-page[data-cs-ground="green"] .cs-bar,
.cs-page[data-cs-ground="dark"] .cs-bar {
  background: var(--cs-bar-ground, var(--color-charcoal));
  border-bottom-color: var(--cs-on-dark-rule);
}

.cs-page[data-cs-bar="dark"] .cs-back,
.cs-page[data-cs-bar="green"] .cs-back,
.cs-page[data-cs-ground="green"] .cs-back,
.cs-page[data-cs-ground="dark"] .cs-back { color: var(--color-cream); }

.cs-page[data-cs-bar="dark"] .cs-progress,
.cs-page[data-cs-bar="green"] .cs-progress,
.cs-page[data-cs-ground="green"] .cs-progress,
.cs-page[data-cs-ground="dark"] .cs-progress { background: var(--color-cream); }

/* The separate "green" bar block that used to sit here is folded into the one
   above. It existed because that state wanted --color-charcoal while the
   generic dark state wanted --color-dark; now the colour comes off the
   section itself for every state, so there is nothing left for it to say.
   Cream on charcoal measures 12.5:1, which is what made that distinction
   safe to drop rather than merely tidy. */

/* Colour is the only thing that changes. 0.6s, not 0.4: it is the same figure
   .cs-page uses for its own crossfade, and while data-cs-ground is set these
   two are painting the same colour over the same moment. At 0.4 the bar
   arrived first and read as a slab that had changed independently of the
   page under it. */
.cs-bar,
.cs-bar .cs-back,
.cs-progress {
  transition: background-color 0.6s ease, color 0.6s ease, border-color 0.6s ease;
}

.cs-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  height: var(--cs-bar-h);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 0 var(--cs-margin);
  background: var(--color-cream);
  border-bottom: 1px solid var(--cs-rule);
}

/* Reading progress. Seventeen sections is ~14,000px at desktop, and without
   this the reader has no idea whether they are two minutes or ten from the
   end — which is exactly the sort of thing that makes someone bail early.

   It rides the bar's existing 1px bottom rule rather than adding a band of its
   own, so the page gains a position cue and no new furniture. 2px so it reads
   at a glance; that is the hairline family (like the 1px rules and the aside's
   4px edge), not a spacing value, so it sits outside the multiple-of-4 grid.

   scaleX on a transform, not width, so it never triggers layout on scroll. */
.cs-progress {
  position: absolute;
  left: 0;
  bottom: -1px;
  height: 2px;
  width: 100%;
  transform: scaleX(0);
  transform-origin: left center;
  background: var(--color-dark);
  will-change: transform;
}

/* Both bar controls carry a 44px-tall touch box. The bar is a fixed 56/64px,
   so the extra height is paid as vertical padding and negative inline margin
   rather than by growing the control's visible box: nothing moves, and the
   hit area stops being the 16px cap-height it was measured at. */
.cs-back {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding-block: 12px;
  margin-block: -12px;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-dark);
  text-decoration: none;
  transition: opacity 0.25s ease;
}

.cs-back:hover { opacity: 0.6; }
.cs-back svg { display: block; }


/* ─── Placeholder ─────────────────────────────────────────────────────────
   Every image on this page is a labelled box until the real asset lands.
   Dashed, tinted, with the label describing exactly what belongs there.

   --ph-ar carries the aspect ratio so each placeholder holds the shape its
   real asset will have. That way dropping in the real file does not reflow
   the page. Set it inline or with one of the modifiers below.            */

.cs-ph {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: var(--ph-ar, 16 / 9);
  padding: 16px;
  border: 1px dashed var(--cs-dash);
  background: var(--cs-tint);
  text-align: center;
}

/* Every placeholder is rounded except the full-bleed ones, whose left and
   right edges sit on the viewport edge where a corner would read as a
   rendering fault. Scoped here rather than cancelled with a literal 0 later —
   see .cs-ph--bleed. */
.cs-ph:not(.cs-ph--bleed) { border-radius: var(--radius); }

.cs-ph span {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

/* Grid and flex children default to min-width: auto, which refuses to shrink
   below their content's minimum width. For anything holding a .cs-ph that
   minimum is circular: the placeholder has an aspect-ratio, its label wraps to
   more lines as the box narrows, the extra height feeds back through the ratio
   as extra width, and the track settles wider than the container it sits in.
   Measured at 375px before this rule: a .cs-ba card resolved to 352.3px inside
   a 327px grid, pushing the document 1px past the viewport.

   min-width: 0 lets these shrink to the track instead of dictating it. */
.cs-ph,
.cs-duo-face, .cs-approach, .cs-spec-step, .cs-sub, .cs-voice, .cs-ba-shot {
  min-width: 0;
}

/* A phone screen. Capped, because 9/19.5 is a punishing ratio to give a full
   column: measured at a 630px viewport, a one-per-row phone placeholder came
   out 654 x 1417 — a phone mock rendered larger than a real phone, and over a
   viewport tall. 280px keeps it near life size wherever it lands, and is under
   every column width the two-up layouts produce, so those are unaffected. */
.cs-ph--phone   { --ph-ar: 9 / 19.5; max-width: 280px; }

/* A real screen capture standing in a .cs-ph--phone's slot. It carries the
   SAME 9/19.5 as the placeholder, so a before shot, an after shot and a slot
   still waiting on its asset are all one size — every screen on this page
   renders identically whatever its source happened to measure.

   Every asset is normalised to that ratio when it is added (artboard frame
   cropped off, rounded corners squared, then resampled), so `cover` has
   nothing to crop today; it is here so a future screen that arrives a few
   pixels off ratio loses a hair off one edge instead of breaking the row.

   The shadow is the card system's recipe turned up (--cs-screen-shadow, mixed
   against the same --color-dark), so these still read as the same family as
   the benchmark previews and the info card — but heavier, because several of
   the captures are drawn on a cream that measures one level off this page's
   own ground and would otherwise have no visible edge at all. */
.cs-shot-img {
  display: block;
  width: 100%;
  max-width: var(--cs-screen-w);
  aspect-ratio: 9 / 19.5;
  height: auto;
  object-fit: cover;
  border-radius: var(--radius);
  box-shadow: var(--cs-screen-shadow);
}

/* An animated shot carries a <picture> so a reduced-motion reader gets a still
   frame instead of the loop — a GIF cannot honour that preference on its own,
   and the page's own rule is that motion never plays against it. Block, so the
   wrapper reports the image's box to .cs-shot's flex gap exactly as a bare
   <img> did. */
.cs-shot picture { display: block; }

/* No prior screen existed — solid rule instead of dashed, so it reads as a
   deliberate absence rather than an asset still to come. */
.cs-ph--none {
  border-style: solid;
  background: none;
}


/* ═══ §1 — Hook ══════════════════════════════════════════════════════════ */

/* Centred, unlike every other section on the page — the opening is a title
   card, and the rest is a document. The switch is deliberate and does not
   propagate: .cs-sec's own children stay left-aligned everywhere else.

   The whitespace above the icon is re-paid from the top of <main>: the article
   pays --cs-pad-top to clear the fixed bar, .cs-hook cancels that with a
   negative margin and pays --hook-space (26svh) instead. The bar then covers
   the top --cs-bar-h of it, so the VISIBLE gap above the icon is --hook-clear
   (--hook-space minus the bar). That visible number is what the gap below the
   subhead is matched to — see .cs-hook's margin-bottom — so the fold no longer
   reads lopsided. svh not vh so a mobile browser's collapsing toolbar cannot
   change where the icon sits mid-scroll.

   ── Every value in this fold is a custom property ──
   Not decoration: it is what makes the fold dialable. case-study-dial.js
   (?dial) overrides these on .cs-page and nothing else, so the panel needs no
   knowledge of these selectors, and an untouched control leaves the CSS
   default — including its clamp() — completely intact. Defaults live here,
   not in the JS, so deleting the dial kit changes nothing on the page.

   ── Dialled and finalised 2026-09-02 ──
   The two font sizes came out of the panel as fixed px (60 and 24), which is
   what a slider can express. Shipping them as fixed px would have put a 60px
   headline on a 375px phone, so both are re-expressed as clamps whose CEILING
   is the dialled value:

       60px -> clamp(2rem, 5vw, 3.75rem)     3.75rem = 60px
       24px -> clamp(1rem, 2vw, 1.5rem)      1.5rem  = 24px

   The vw terms are set so each one reaches its ceiling by ~1200px and stays
   there. That means every desktop width — 1280, 1440, 1512, 1920 — renders
   exactly the dialled number, so it does not matter which viewport the tuning
   happened at, and the type still scales down instead of overflowing a phone.

   --hook-gap came back as 24px, identical to the default, so it is left alone
   rather than restated. --hook-icon, --hook-sub-weight and --hook-sub-lh were
   never touched in the panel and keep their defaults. */
.cs-page {
  --hook-space:         26svh;
  /* The whitespace a reader actually sees above the icon: --hook-space minus
     the fixed bar that covers the top of it. Both the padding above the icon
     and the margin below the subhead reference this, so the fold is
     symmetrical to the eye rather than only on paper. */
  --hook-clear:         calc(var(--hook-space) - var(--cs-bar-h));
  --hook-gap:           24px;
  /* Was clamp(88px, 9vw, 128px). Reduced ~22% across the whole clamp — 128 to
     100 at the desktop ceiling, 88 to 68 at the mobile floor — so the icon
     reads as a subtle interactive detail rather than the loudest thing in the
     fold, per direct instruction. The interactive AppIcon3D fills whatever
     size this token gives it, so nothing else needed to change for the
     reduction to take effect. */
  --hook-icon:          clamp(68px, 7vw, 100px);
  --hook-icon-radius:   20%;

  --hook-title-size:    clamp(2rem, 5vw, 3.75rem);   /* -> 60px from ~1200px up */
  --hook-title-weight:  500;
  --hook-title-lh:      1.12;
  --hook-title-measure: 13.5em;

  --hook-sub-size:      clamp(1rem, 2vw, 1.5rem);    /* -> 24px from ~1200px up */
  --hook-sub-weight:    400;
  --hook-sub-lh:        1.45;
  --hook-sub-measure:   25em;

  /* An opaque grey, not the translucent dark --cs-label the fold's other greys
     use. --cs-label is rgba(30,30,32,0.66), which on this cream renders as
     ~#6A6A6C — this is that same rendered value, set solid. The subhead carries
     the 🇮🇳 flag emoji: a colour with an alpha channel drops a translucency
     veil over everything painted in the element, and the emoji (a full-colour
     glyph, not a monochrome one tinted by `color`) visibly dulls under it. A
     solid grey keeps the type at the same weight on the page and lets the flag
     render at full strength. Contrast on cream: 5.2:1, well over the body floor. */
  --hook-sub-color:     #6A6A6C;

  /* Spacefire. This hex is the brand value, given by name — if a brief says
     "spacefire", it means #FC7A69 and nothing nearby. See the measurement
     note on .cs-hook-highlight for what that costs in contrast. */
  --hook-highlight:     #FC7A69;

  /* The site's only red, and it carries meaning rather than decoration: a
     thing that went wrong. Spacefire is the brand's warm accent and reads as
     positive, so it cannot also be the failure colour — §6 uses both at once
     and needs them to say opposite things.

     #C9302A measures 5.10:1 on cream, and 2.06 against spacefire itself, which
     is what keeps the two legible as different signals rather than as two
     shades of the same one. Do not reach for this for emphasis. */
  --cs-negative:        #C9302A;
}

.cs-hook {
  align-items: center;
  text-align: center;
  gap: var(--hook-gap);
  margin-top: calc(-1 * var(--cs-pad-top));

  /* --hook-space (26svh) from the top of <main>. The fixed bar covers the top
     --cs-bar-h of that, so what the reader actually SEES above the icon is
     --hook-clear (= --hook-space - --cs-bar-h). That visible number is the one
     the gap below has to match. */
  padding-top: var(--hook-space);

  /* Make the gap below the subhead equal the VISIBLE gap above the icon
     (--hook-clear), not the generic --cs-sec-gap every other boundary uses.
     .cs already lays --cs-sec-gap here via flex `gap`; this margin is the
     difference — a small positive top-up at desktop (--hook-clear 170 vs gap
     160), a negative trim where the gap is larger. Formula, so it tracks
     --hook-space, --cs-bar-h and --cs-sec-gap at every breakpoint. The max()
     floor keeps ≥24px of real gap on a pathologically short viewport where
     --hook-clear would otherwise collapse it. */
  margin-bottom: max(
    calc(24px - var(--cs-sec-gap)),
    calc(var(--hook-clear) - var(--cs-sec-gap))
  );
}

/* .cs-hook's own margin-bottom above is tuned to ITS internal rhythm — the
   gap under the subhead matches the visible space above the icon, not the
   page's generic --cs-sec-gap. Good for the hero on its own terms, but it
   meant the meta card directly below inherited that tuning as ITS gap
   above, while its gap below (to whatever follows) is the plain
   --cs-sec-gap from .cs's flex `gap` — measured 190px above the card
   against 160px below it, asymmetric by exactly the hero's own top-up.

   This cancels that top-up rather than touching .cs-hook, which stays
   correct for the hero. Negating the identical expression keeps the two
   in lockstep at every breakpoint — --hook-space is in svh and --cs-bar-h
   and --cs-sec-gap both step at 640/1024, so a fixed pixel here would only
   have cancelled it at one width. */
.cs-hook + .cs-sec {
  margin-top: calc(-1 * max(
    calc(24px - var(--cs-sec-gap)),
    calc(var(--hook-clear) - var(--cs-sec-gap))
  ));
}

/* The visual anchor — the real Jupiter app icon, a full-bleed square with no
   corner baked in, so the squircle is cut here.

   ── A deliberate exception to the --radius rule ──
   CLAUDE.md says every corner on this site is var(--radius) (8px) and that a
   literal border-radius is a bug. This is the fourth documented exception, and
   it is the same species as --hook-icon-radius below: the corner belongs to
   the OBJECT being depicted, not to our UI. (It used to cite .hero-photo's
   clip-path, which went when the homepage's drawn acrylic panel was replaced
   by a photograph of one.) An app icon's radius is roughly 22.5%
   of its own width — at the 128px this renders, --radius would be 6%, which
   reads as a slightly-softened square rather than as an app icon, and would
   misrepresent the asset.

   Percentage, not px, so it holds that proportion at every size the clamp
   produces — and it is a token, so the dial kit can reach it like everything
   else in this fold.

   Animation lands on this <img> directly; a transform-based revolve needs no
   markup change. */
/* The mount carries the icon's responsive size so AppIcon3D can fill it —
   the component measures its host rather than taking a hardcoded pixel size,
   which keeps the icon on the same clamp() it always used. */
.cs-hook-icon {
  width: var(--hook-icon);
  aspect-ratio: 1;
}

.cs-hook-icon img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--hook-icon-radius);
}

/* The hook line itself — the biggest type on the page. Narrower than the old
   left-aligned cap: centred type needs a tighter measure to hold its shape,
   or the ragged edges read as accidental rather than composed.

   Size/weight/leading are restated here rather than inherited from
   .cs-display so the dial can reach them. Equal specificity, later in the
   file, so these win. */
.cs-hook-line {
  font-size: var(--hook-title-size);
  font-weight: var(--hook-title-weight);
  line-height: var(--hook-title-lh);
  max-width: var(--hook-title-measure);
}

/* The one number the reader should carry away from the fold. Highlighted
   rather than the surrounding words: it is the single scannable proof-point
   in the sentence — "onboarding" and "deposits" are context, the figure is
   the claim — and colouring only it keeps the rest of the line doing its job
   as plain, confident type instead of competing for the same attention.

   Colour is derived from the Jupiter icon's own coral, sampled directly from
   img/jupiter-icon.jpg (averaged, white glyph and black excluded: #F77468 —
   HSL H5 L69 S89) rather than picked by eye. That sample measures 2.7:1 on
   this page's cream. Two darker stand-ins were tried here — #f43d2f, then
   #ea5e3e at 3.26:1 — both chosen to buy contrast. Neither is spacefire.
   Shriya named the brand value directly: spacefire is #FC7A69, so that is
   what the token holds now.

   The cost, measured: #FC7A69 is 2.47:1 on cream. That is under the 3:1 floor
   this project holds even for large text, so the $2.4M is now below AA for
   its size where before it was marginally over. It is three display-size
   words inside a sentence that reads identically without colour, and the
   brand hex is not a judgement call this file gets to make — but it IS a
   regression against the old value, so it is written down rather than
   absorbed silently. */
.cs-hook-highlight {
  color: var(--hook-highlight);
}

/* The company line — now the fold's only subhead. Greyed and a step down from
   the title, so the fold reads as one statement plus its context rather than
   two competing ones. */
.cs-hook-sub {
  font-family: var(--font-ui);
  font-size: var(--hook-sub-size);
  font-weight: var(--hook-sub-weight);
  line-height: var(--hook-sub-lh);
  color: var(--hook-sub-color);
  max-width: var(--hook-sub-measure);
  margin: 0;
}

/* Shared label used by the identity strip, the deep-dive sub-blocks and the
   untangle diagrams. Lived here when the hook still had a then/now split;
   kept in place because six sections reference it. */
.cs-split-label {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

/* The one note about the frames being placeholders. Set at label scale in the
   secondary grey so it reads as an aside from the author rather than as page
   copy, and said once — the alternative was the word PLACEHOLDER repeated
   twenty-nine times on the way down, which reads as abandoned rather than
   in progress. */
.cs-aside-note {
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.5;
  color: var(--cs-label);
  max-width: 40em;
  margin: 0;
}


/* ═══ §2 — Orientation ═══════════════════════════════════════════════════
   One horizontal card holding role / timeline / team as three columns, the
   layout the Collectibles study uses for the same job. It replaced a stacked
   four-row spec sheet, and it carries no heading: the column labels already
   say what it is, so a title above it was repeating itself.

   White on the cream ground rather than a tint, so the card lifts off the
   page the way the reference's does. Corner is --radius; the reference's
   larger radius is its own visual language, not part of the layout.

   The company line that used to be the fourth row moved up into the hero —
   also following the reference, which puts its "what is Swiggy" explainer in
   the opening panel rather than in the meta card. That leaves three columns
   whose values are short enough to sit side by side.

   Every value here is a custom property for the same reason fold 1's are:
   case-study-dial.js reaches them by name. Defaults live here, so removing
   the dial kit changes nothing. */
.cs-page {
  --meta-pad:        24px;
  --meta-gap:        24px;   /* between columns */
  --meta-stack:      8px;    /* label -> value, inside one column */
  /* The dial kit reaches this by name, so it stays a --meta-* knob; its
     default is now the shared card corner rather than a second reference to
     --radius. */
  --meta-radius:     var(--cs-card-radius);

  --meta-key-size:   var(--text-label);
  --meta-key-weight: 400;
  --meta-key-track:  0.1em;

  --meta-val-size:   var(--text-body);
  /* Was 700, then 400 (matched to the hook subhead's regular weight, so the
     value read as calm information rather than a second headline). Now 500,
     matched instead to .cs-bubble-line — both are var(--font-display), so this
     is Clash Display's own medium file in both places, not a synthesis. */
  --meta-val-weight: 500;
  --meta-val-lh:     1.35;
}

.cs-meta {
  display: grid;
  gap: var(--meta-gap);
  padding: var(--meta-pad);

  /* Geometrically equal to the gap above (both exactly --cs-sec-gap, via the
     .cs-hook + .cs-sec rule above cancelling the hero's own top-up) still
     read as tighter below on review — the card's flat white edge sits hard
     against cream, then colourful phone screens start immediately after it,
     which carries more visual weight than the plain type above the card and
     makes an identical gap look shorter. Requested explicitly at +24px
     rather than re-balanced to equal again.

     On .cs-meta, not the section wrapping it: a <section> is a flex item of
     .cs and establishes its own block formatting context, so a margin here
     grows this section's own box rather than collapsing through it into the
     flex `gap` — the gap above is untouched. */
  margin-bottom: 24px;

  /* Surface from the shared card tokens (see --cs-card-* and .cs-card). This
     card is the reference use; a later section wanting the same panel adds the
     .cs-card class rather than re-typing these. border-radius stays routed
     through --meta-radius so the dial kit's corner control still bites. */
  background: var(--cs-card-bg);
  border-radius: var(--meta-radius);

  /* No width or centring of its own any more — the section wrapping it is
     already exactly --cs-content-max wide (every section is, now), so the
     card simply fills it. It used to carry its own separate 950px cap; that
     is what let it sit narrower than everything else on the page, since
     every other section was still the wider, uncapped column. One page-wide
     rule now does what two independent ones used to.

     950px is still the number in play, unchanged from when it was tuned: the
     three column values are auto-sized to their own content, and forcing the
     column narrower makes the grid compress all three at once rather than
     wrapping only the longest — at 640px, tested, "My role" broke to three
     ragged lines and "Team" to five. At 950px only Team wraps, cleanly, to
     two even lines; Role and Duration hold their single line. Measured at
     six widths, not picked by eye.

     The border is gone; a soft shadow separates the card from the page
     instead — now --cs-card-shadow, shared with every other card. */
  box-shadow: var(--cs-card-shadow);
}

.cs-meta-item {
  display: flex;
  flex-direction: column;
  gap: var(--meta-stack);
  min-width: 0;
}

.cs-meta-key {
  font-family: var(--font-ui);
  font-size: var(--meta-key-size);
  font-weight: var(--meta-key-weight);
  line-height: 1.4;
  letter-spacing: var(--meta-key-track);
  text-transform: uppercase;
  color: var(--cs-label);
}

/* Bold, matching the reference card: the label is the quiet half of each
   column and the value is the answer, so they differ by weight as well as by
   size and colour. */
.cs-meta-val {
  font-family: var(--font-display);
  font-size: var(--meta-val-size);
  font-weight: var(--meta-val-weight);
  line-height: var(--meta-val-lh);
  color: var(--color-dark);
  margin: 0;
}


@media (min-width: 641px) {
  .cs-page { --meta-gap: 40px; --meta-pad: 40px; }
  /* Three equal columns. An earlier build used repeat(3, auto) + space-between
     so each value took only the width it needed — but with "My role" and
     "Team" both carrying long values and "Duration" carrying "1.5 months", the
     card ended up visibly heavier on the left two thirds. Equal 1fr columns
     was asked for directly: every column is the same width, the two longer
     values wrap within their own third, and the card reads balanced. */
  .cs-meta { grid-template-columns: repeat(3, 1fr); }
}

@media (min-width: 1025px) {
  .cs-page { --meta-gap: 56px; --meta-pad: 48px; }
}

/* The legacy spec sheet that used to live here is gone, and it had to go:
   §17's strategy spectrum took the name .cs-spec, so this dead rule stopped
   being harmlessly unused and started styling a live section from six
   thousand lines away. It was contributing a border-top — the stray divider
   above the tab row — and a max-width: 56em that pulled §17's right edge in
   off the column, which is why that section's right margin did not match
   §7's. Neither was overridden, because neither property appears in §17's
   own .cs-spec rule; there was nothing to notice.

   Its comment said "delete if nothing picks it up". Something picked up the
   name instead, which is the worse case, so: deleted. §2 is .cs-meta.     */


/* ═══ §3 / §14 — Stats ═══════════════════════════════════════════════════
   Numbers and labels reuse .case-metric-number / .case-metric-label from
   style.css verbatim. Only the grid is new here: .case-metrics is a fixed
   760px flex row, which cannot stack.                                    */

.cs-stats {
  display: grid;
  gap: 32px;
  border-top: 1px solid var(--color-dark);
  padding-top: 24px;
}

/* §14 repeats §3's numbers larger. One step up the scale, nothing else. */
.cs-stats--lg .case-metric-number {
  font-size: var(--text-section);
}


/* ═══ §4 — Selected screens (silent strip) ═══════════════════════════════
   Four real screens, no captions — one eyebrow above, and that is the whole
   section. Placeholder boxes used to stand here at a size invented for this
   section alone (168px mobile, a 4-up grid capped at 1040px desktop). Real
   screens replaced them at the size every other "after" screen on the page
   already uses (--pro-card-w * --cs-screen-step, below), which does not fit
   four across inside the page's own 950px column at most desktop widths — so
   this bleeds to the viewport the same way §6's journey and §17's spectrum
   do, rather than being shrunk to fit a column the rest of the page never
   asked these screens to fit either.

   No scroller, on instruction, and that is also what fixed the clipped
   shadow. The row used to bleed to the viewport and pay a --cs-margin-sized
   gutter back in on both sides, which left 950px of usable width for 1204px
   of screens — so it scrolled, and the fourth screen sat 10px past the
   scrollport's own right edge with its shadow shaved off against it.
   Measured before: scrollWidth 1695 against clientWidth 1440, last screen's
   right gap -10px.

   Centring the row instead of gutter-padding it is what buys the width back:
   the bleed stays (four real screens need more than the 950px column can
   ever give them) but nothing is subtracted from it, so 1204px of content
   centres inside 1440px with 118px to spare and the whole row is simply
   visible. No overflow property anywhere in this rule now, which means no
   scrollport, which means nothing to clip a shadow against — the
   padding-block/margin-block pair that used to buy vertical room for it is
   gone with the scroller that made it necessary.

   flex-wrap is the honest answer below ~893px, where four at this size stop
   fitting the viewport itself: they wrap to 2x2, then to one column, rather
   than either scrolling (asked against) or shrinking below the size the rest
   of the page calls "after". */

.cs-strip {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-end;
  gap: var(--pro-stage-gap);
  /* Bleeds to the viewport because the 950px column cannot hold four screens
     at this size. Deliberately NOT paying a gutter back in — see above. */
  margin-inline: calc(50% - 50vw);
  /* The only inset: enough that a wrapped row never touches the screen edge.
     Cannot be --cs-margin, which is 160px at 1440 and would pull the usable
     width back under what one row needs, re-creating the wrap it is meant to
     avoid. A token because the 2-up cap below has to add it back — see
     there. */
  --strip-edge: 24px;
  padding-inline: var(--strip-edge);
}

/* Below ~893px four screens at this size stop fitting the viewport, and left
   to itself flex-wrap breaks them 3 + 1 — a full row and an orphan, which
   reads as a mistake rather than as a grid. Capping the box at two screens
   plus one gap makes the break 2 x 2 instead. Written off the same two tokens
   the items are sized from, so it cannot drift from them.

   The bleed goes with it: at these widths the row is narrower than the column
   anyway, so it has nothing to escape and margin-inline: auto simply centres
   it. Below ~454px even two stop fitting and the same rule degrades to one
   per row on its own, no third breakpoint needed. */
@media (max-width: 892px) {
  .cs-strip {
    /* + the two --strip-edge paddings, because the page's reset puts every
       box on border-box: without them the padding eats into this cap and
       only ONE screen fits the row it is meant to hold two of. Measured at
       745px before adding them: 4 rows of 1, not the 2 x 2 intended. */
    max-width: calc(2 * (var(--pro-card-w) * var(--cs-screen-step))
                    + var(--pro-stage-gap)
                    + 2 * var(--strip-edge));
    margin-inline: auto;
  }
}

.cs-strip > * {
  /* auto, not a pixel value: the item's width comes from .cs-shot-img below,
     which is a genuine variable (--pro-card-w is a clamp()) rather than a
     number this rule would have to keep in step with by hand. */
  flex: 0 0 auto;
}

/* The four screens, sized identically to every §10-20 "after" screen on the
   page. A descendant selector, not .cs-strip > *, because the splash image
   sits inside a <picture> for its reduced-motion still — the box that needs
   this width is the <img> either way. */
.cs-strip .cs-shot-img {
  width: calc(var(--pro-card-w) * var(--cs-screen-step));
  max-width: none;
}

/* Same fix as .cs-shot picture, same reason: a bare <picture> is inline by
   default, which leaves a few px of baseline gap below it that a flex item
   normally would not carry, and misaligns it against its bare-<img> siblings
   on the row's own baseline. */
.cs-strip picture { display: block; }


/* ═══ §5 — Maturity gap ═════════════════════════════════════════════════ */

/* The 2019-vs-now identity strip: two short wide panels, not phones. */
.cs-identity {
  display: grid;
  gap: 16px;
  max-width: 800px;
}


/* .cs-quotes / .cs-quote lived here — a second, conflicting copy of the pivot
   section's quote treatment, shadowed by another set further down the file and
   used by nothing once §4 was rebuilt around .cs-voice cards. Deleted rather
   than left to rot; git has it. */


/* ═══ §7 / §16 — Step markers ═══════════════════════════════════════════
   One pattern, two uses: the mandate sequence (§7) and the since-shipping
   timeline (§16). Same shape, so it is the same component rather than two.

   Square markers, no dots: --radius-round is reserved for the three floating
   circular icon buttons (see CLAUDE.md), and a literal border-radius is not
   allowed, so the marker is simply a square. It suits the type anyway.

   Flex-wrap rather than a media query — the row breaks wherever it runs out
   of width, at any viewport, and the connectors sit between siblings so a
   wrapped line still reads correctly.                                    */

.cs-steps {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px 16px;
  list-style: none;
  padding: 0;
  margin: 0;
}

.cs-step {
  display: flex;
  align-items: center;
  gap: 12px;
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.4;
  color: var(--color-dark);
}

.cs-step::before {
  content: '';
  width: 8px;
  height: 8px;
  flex: none;
  background: var(--color-dark);
}

.cs-step::after {
  content: '';
  width: 24px;
  height: 1px;
  flex: none;
  background: var(--cs-rule);
}

.cs-step:last-child::after { display: none; }


/* ═══ §8 — Problems + principles ════════════════════════════════════════
   Three cards, each carrying one problem and the principle answering it.
   The principle sits below a hairline inside the card so the pairing is
   structural, not just adjacency.                                        */

.cs-pairs {
  display: grid;
  gap: 16px;
}


/* ═══ Card grid ══════════════════════════════════════════════════════════
   One component, four sections. Added after an audit found five sections
   carrying prose with no visual or structural anchor at all — the page was
   telling where it should have been showing, and nothing in those stretches
   could be skimmed.

   The pattern is lifted from the Bikiron Collectibles case study, which chunks
   its own text-only stretches into small bordered cards rather than running
   them as paragraphs. Structure copied, dressing not: those cards are tinted
   sticky-note colours from a different palette, these use our hairline and
   ground. His radius happens to be 8px too, which is our --radius exactly.

   Column count is per-use, so one grid serves a 2-up then/now, a 3-up set of
   beats and a 4-up set of workstreams:
       style="--cs-cols: 4"      on .cs-grid
   Falls to one column on a phone and two at tablet regardless, because four
   bordered cards across a 375px screen is unreadable at any font size.     */

.cs-grid {
  display: grid;
  gap: 16px;
  grid-template-columns: 1fr;
}


/* ═══ §9 — Deep dive ════════════════════════════════════════════════════
   Four sub-artifacts, four shapes. Diagrams run full width; benchmarks are
   a dense label grid (no screenshots); personas are two compact cards.

   §9's own .cs-sub wrapper (display:flex, column, gap 16px) used to live
   here and is gone. Nothing in the markup used it any more, but the SECTION
   SUBHEAD further down this file is also called .cs-sub, so this dead rule
   was silently making all four of those paragraphs flex containers.

   That is invisible while such a paragraph holds nothing but text — one
   anonymous flex item, laid out exactly like a block. It stops being
   invisible the moment one contains an element: §17's subhead bolds a clause,
   and the <strong> blockified into a flex item, breaking the sentence onto
   three lines with the bold phrase alone in the middle. Same failure as the
   legacy .cs-spec rule two thousand lines up — a dead selector that stopped
   being dead when a new component took its name.                          */

/* ═══ §10 — The existing journey, untangled ═════════════════════════════
   A before/after diagram on the dark band: eight scattered steps that
   regroup into four bands. Built by case-study-untangle.js — read the note
   at the top of that file before changing anything here, because the layout
   is computed from the --ut-* tokens below and nothing else.

   The argument is conservation: the same eight nodes are on screen in both
   states. That is why the two states are one node set, not two drawings.

   Everything here is layout and colour. No element is hidden by this
   stylesheet — every hidden state in this section is written by the script,
   onto elements the script itself created.                                */

/* The band's own --cs-sec-gap padding is 160px a side at 1025+, which a
   100svh pin cannot afford. Trimmed on this one section rather than on
   .cs-band, which two other sections use at full depth. */
.cs-ut-band { padding-block: 32px; }

/* Only where the diagram is actually pinned. Below 768px the section is two
   static figures and has no business being a viewport tall. */
@media (min-width: 768px) {
  .cs-ut-band {
    min-height: 100svh;
    justify-content: center;
  }
}

.cs-ut {
  display: flex;
  flex-direction: column;
  gap: 16px;

  /* The diagram grid. Every one of these is a multiple of 4, and every
     grouped-state node position is built from them, so the grouped state
     cannot leave the grid. Read by case-study-untangle.js through
     getComputedStyle — they are the single source for both files. */
  --ut-node-w: 96px;
  --ut-node-h: 64px;
  --ut-gap-x: 12px;
  --ut-gap-y: 32px;
  --ut-head-w: 0px;      /* 0 = the band heading sits above its row */
  --ut-head-h: 32px;
  --ut-head-gap: 8px;
  --ut-pad: 16px;
}

/* 768px is the same figure case-study-untangle.js pins on, deliberately: at
   this width the band heading moves beside its row and the diagram starts
   being animated, and those two decisions must not disagree. Neither 641 nor
   1025 works — a three-node band plus a side heading needs 680px. */
@media (min-width: 768px) {
  .cs-ut {
    --ut-node-w: 144px;
    --ut-gap-x: 24px;
    --ut-gap-y: 24px;
    --ut-head-w: 176px;
    --ut-head-gap: 24px;
    --ut-pad: 24px;
  }
}

@media (min-width: 1200px) {
  .cs-ut {
    --ut-node-w: 200px;
    --ut-gap-x: 32px;
    --ut-head-w: 224px;
    --ut-head-gap: 32px;
  }
}

/* ── The two state labels ───────────────────────────────────────────────
   They carry the count, and the count is the same on both sides. With no
   script they read as a before/after summary above the list; in the static
   build the script moves one into each figure. */
.cs-ut-states {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.cs-ut-state {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 0;
}

.cs-ut-state-name {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-fg);
}

.cs-ut-state-count {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  color: var(--cs-label);
}

/* Pinned build only, and the class is added by the script. The two labels
   stack so the count sits in exactly the same place either side of the
   fade — which is the point, because it does not change. */
.cs-ut-states--cross {
  display: block;
  position: relative;
  min-height: 48px;
}

.cs-ut-states--cross .cs-ut-state {
  position: absolute;
  top: 0;
  left: 0;
}

/* ── Full bleed ─────────────────────────────────────────────────────────
   Cancels the band's --cs-margin exactly, the same technique .cs-ph--bleed
   uses, so the diagram reaches the viewport edge while the column above it
   never moves. The script measures this element to get its stage width, so
   the margin arithmetic lives in one place rather than being re-resolved in
   JavaScript. */
.cs-ut-holder { margin-inline: calc(-1 * var(--cs-margin)); }

.cs-ut-figs {
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.cs-ut-fig {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 0;
}

/* In the static pair each figure carries its own label, inset back to the
   text column even though the diagram beneath it is not. */
.cs-ut-fig > .cs-ut-state { padding-inline: var(--cs-margin); }

/* The sprawl runs past both edges and is clipped here. It never reaches the
   document: an absolutely positioned child of an overflow-hidden box cannot
   widen the page. */
.cs-ut-stage {
  position: relative;
  width: 100%;
  overflow: hidden;
}

.cs-ut-wires {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  fill: none;
  stroke: var(--cs-dash);
  stroke-width: 1;
  stroke-linecap: round;
}

/* The four grounds the nodes settle onto. */
.cs-ut-band-row {
  position: absolute;
  border-radius: var(--radius);
  background: var(--cs-tint);
}

.cs-ut-head {
  position: absolute;
  display: flex;
  align-items: center;
}

/* Opaque on the band ground, so a wire passes behind a node rather than
   through its label. --color-dark is the band's own ground, which is what
   makes an outlined chip of it. */
.cs-ut-node {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  width: var(--ut-node-w);
  height: var(--ut-node-h);
  padding: 8px;
  border: 1px solid var(--cs-dash);
  border-radius: var(--radius);
  background: var(--color-dark);
  text-align: center;
  will-change: transform;
}

.cs-ut-node-name {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.25;
  color: var(--cs-fg);
}

/* The one step the redesign added. Dashed and badged, because the rest of
   the diagram is a conserved set and this is not part of it. */
.cs-ut-node--added { border-style: dashed; }

.cs-ut-node-tag {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.2;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

/* ── The list ───────────────────────────────────────────────────────────
   The section's content with no JavaScript, no GSAP or a thrown error: the
   documented steps in flow order, as plain text. The script hides it only
   once a diagram exists in its place, and hides it from the eye rather than
   from the accessibility tree — every stage is aria-hidden, so this list is
   what a screen reader is given. Same recipe as .alb-live in style.css. */
.cs-ut-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 0;
  padding-left: 24px;
}

.cs-ut-list li {
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.5;
  color: var(--cs-fg);
}

.cs-ut-item-tag { color: var(--cs-label); }

.cs-ut-list--sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* The line that says the map is a reconstruction. It sits with the diagram, in
   plain view, because a reader who takes this for Jupiter's real flow map has
   been misled by the page. */
.cs-ut-note {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.5;
  color: var(--cs-label);
  max-width: 64em;
  margin: 0;
}

/* ═══ §6 — .cs-jn, the journey shown twice ════════════════════════════════
   Eleven steps as pills, switched between a descending staircase (before) and
   five merged stage blocks on one level (after), by a segmented control.

   The same eleven <li> nodes serve both states. They are re-positioned, never
   re-created, which is what makes the conservation claim structural instead of
   rhetorical: there is no code path that could show a different set in one
   view than the other.

   Two layout modes:
   - No JS / not yet measured: the <ol> is a plain readable list. Everything
     below that positions anything is gated behind .is-live, which the script
     adds only once it has measured the canvas.
   - .is-live: pills go absolute and animate left/top/width/border-radius.

   Colour comes from the band's own tokens (--cs-fg, --cs-label, --cs-rule,
   --cs-tint), all of which .cs-band--dark redefines, so the component themes
   itself on the dark ground without a single dark-specific rule. */
.cs-jn-sec { --cs-blk-gap: 28px; }

.cs-jn {
  --jn-pill-h: 28px;
  --jn-pill-w: 176px;
  --jn-label-w: 176px;         /* label wraps inside its own pill's width */

  /* The horizontal step is the value that fixes the overlap, and it is the
     label's width plus clear air — NOT the pill's. A label hangs under its
     pill and is as wide as --jn-label-w, so as soon as the step is narrower
     than that, every label runs under the next pill and the next label. It was
     95px at 1440 (the column width divided by ten) against 190px labels, which
     is why the before view was unreadable.

     Setting it as a fixed step rather than dividing the available width means
     the staircase has an intrinsic width — 2096px for eleven steps — so it
     scrolls instead of compressing, and stays legible at any viewport. */
  --jn-x-step: 192px;
  --jn-row: 48px;              /* vertical drop per staircase step */
  --jn-gap: 4px;               /* divider between merged slices */
  --jn-label-h: 64px;          /* room under the last pill for its label */
  --jn-block-gap: 32px;        /* between the five stage blocks */
  --jn-title-gap: 24px;        /* bar to stage title */
  /* Fallback only. The real value is measured from the rendered titles and
     written back as --jn-title-h by the script: a fixed guess clipped the
     summaries the moment a stage name wrapped to a second line. */
  --jn-title-h: 92px;
  /* The after timeline still reads as "compact" against a 2112px staircase at
     0.92 — the contrast that matters is with the before view, not with the
     frame. At 0.78 in a 950px column the five blocks came out 164px wide and
     every stage name wrapped to three lines, which read as clutter. */
  /* The after view is sized from a target BLOCK width rather than a fraction
     of the frame. With the summaries gone a stage block only has to hold its
     name, so 176px fits all five names in two lines at most and leaves the row
     visibly shorter than the frame — which is what stops it ending hard
     against the right edge. A fraction of the frame could not do that, because
     it grows the blocks to fill whatever room it is given.

     --jn-after-trail is guaranteed air past the last block. Where the frame is
     wide enough it is simply empty space; where it is not, it is the bit of
     scroll that keeps the last stage from sitting flush against the edge. */
  --jn-after-block: 176px;
  --jn-after-trail: 120px;

  /* Two radii, and the change between them is part of the transition: a
     capsule in the before state, a card corner once the pills merge into
     blocks. The 999px is a literal rather than a token because --radius-round
     is reserved for three floating icon buttons and its own rule says not to
     extend it; this is the same call .cs-did li already made, and the spec
     asked for "pill/capsule shapes" by name. Only the two OUTER corners of a
     block keep it; every inner slice edge squares off, which is what makes a
     stage read as one bar cut into parts. */
  --jn-radius-pill: 999px;
  --jn-radius-block: var(--cs-card-radius);

  /* Mixed from the band's own foreground, so this is cream-on-dark inside the
     dark band and ink-on-cream anywhere else, from one declaration. */
  --jn-neutral: color-mix(in srgb, var(--cs-fg) 15%, transparent);
  --jn-positive: var(--hook-highlight);

  /* Same weight as the prologue's field (--cs-screen-dots), which also sits
     behind a visual rather than behind running text. */
  --jn-dot-opacity: 0.18;
  --jn-drop: var(--cs-negative);
  --jn-ease: cubic-bezier(.2, .8, .2, 1);
  --jn-dur: 0.74s;
  --jn-dur-colour: 0.42s;
  display: flex;
  flex-direction: column;
  gap: 24px;
}

/* --- head: the segmented control + legend -------------------------------- */
/* Left, on the column's margin with the heading above it, rather than pushed
   to the far right — it is the first thing the section asks the reader to do,
   so it reads in the same place their eye already is.

   The legend sits in this row now too, beside the toggle, rather than below
   the canvas. The canvas reserves height for the full eleven-step staircase,
   so a legend parked after it sat ~400px below the last visible pill on
   arrival — a footnote to the section, not a key to the graph it was
   describing. Here it is in the same glance as the control that switches the
   view it applies to, and .cs-jn-legend's own fade still hides it once that
   view has nothing to key. */
.cs-jn-head {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 24px;
  flex-wrap: wrap;
}

/* iOS-style segmented control: one track, a moving thumb behind the labels.
   The thumb is the ::after of the track rather than a third element, so the
   markup stays two buttons and a group. */
.cs-jn-toggle {
  position: relative;
  display: inline-flex;
  padding: 3px;
  border-radius: 999px;
  background: var(--cs-tint);
  border: 1px solid var(--cs-rule);
}

.cs-jn-toggle::after {
  content: '';
  position: absolute;
  z-index: 0;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 3px);
  border-radius: 999px;
  background: var(--cs-fg);
  transition: transform var(--jn-dur-colour) var(--jn-ease);
}

.cs-jn[data-jn-state="after"] .cs-jn-toggle::after { transform: translateX(100%); }

.cs-jn-tab {
  position: relative;
  z-index: 1;
  min-width: 92px;
  /* 12px + a 16px line + 12px is 40, and the track's 3px padding either side
     plus its hairline makes the control 48 tall. Measured, because the first
     pass used 9px here and came out at 42 — under the 44px tap floor this
     project holds, and close enough to it to look right while failing. */
  padding: 12px 20px;
  border: 0;
  background: none;
  border-radius: 999px;
  cursor: pointer;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 700;
  color: var(--cs-label);
  transition: color var(--jn-dur-colour) var(--jn-ease);
}

.cs-jn-tab[aria-pressed="true"] { color: var(--cs-band-bg, var(--color-cream)); }
.cs-jn-tab:focus-visible { outline: 2px solid var(--cs-fg); outline-offset: 3px; }

/* --- viewport + scroller ------------------------------------------------- */
/* The frame. Its left edge is the content column's, so the visual lines up with
   the heading; its right edge runs out to the viewport's, which is what stops
   the after view reading as cramped in a 950px column and shows more of the
   staircase per screen.

   50% is half this box (the 950 column), 50vw is half the viewport, so the
   difference is exactly the gutter on the right — no magic number, and it
   tracks every breakpoint on its own. <html> already has overflow-x hidden,
   which covers the scrollbar's width being counted in 50vw. */
.cs-jn-viewport {
  position: relative;
  /* Full bleed BOTH ways now. The right edge always ran out to the viewport's;
     the left one does too, so scrolled content crops against the screen rather
     than against the column. The content still STARTS at the column's left
     edge — that inset moved to the scroller's padding, below — so nothing about
     the unscrolled state changed. */
  margin-inline: calc(50% - 50vw);
  /* On top of .cs-jn's own 24px gap. The control sits above the visual now
     rather than beside it, so it needs real separation from it. */
  margin-top: 32px;
}

.cs-jn-scroller {
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--cs-rule) transparent;
  /* Room for the pointer's own scrollbar so it never sits on the last label. */
  padding-bottom: 8px;

  /* The scroller now spans the whole viewport, so this padding is what holds
     the staircase's start on the column's left edge while the box it scrolls
     inside reaches the screen. Scrolled content passes through it and crops
     against the screen, which is the whole point of the change.

     Written without percentages on purpose: inside a full-bleed box a
     percentage resolves against 100vw, not against the column, so the gutter
     has to be rebuilt from the same two values the page lays itself out with.
     It is the page margin, except once the column has hit its cap and the
     margins are whatever is left over — which is exactly max() of the two.

     .cs-jn-scroller's clientWidth INCLUDES this padding, and the script sizes
     both layouts from it, so case-study-journey.js subtracts it back off. The
     two have to agree: change one and the staircase resizes. */
  --jn-gutter: max(var(--cs-margin),
                   calc((100vw - var(--cs-content-max)) / 2));
  padding-left: var(--jn-gutter);
}

.cs-jn-scroller::-webkit-scrollbar { height: 6px; }
.cs-jn-scroller::-webkit-scrollbar-thumb {
  background: var(--cs-rule);
  border-radius: 999px;
}

/* --- canvas ------------------------------------------------------------- */
.cs-jn-canvas { position: relative; }

.cs-jn.is-live .cs-jn-canvas {
  width: var(--jn-canvas-w, 100%);
  height: var(--jn-canvas-h, 0px);
  transition:
    height var(--jn-dur) var(--jn-ease),
    width var(--jn-dur) var(--jn-ease);
}

/* Decorative ground. The pattern itself comes from the shared rule in
   style.css — this sets only the box, the fade and the weight, the same split
   .cs-pro-dots uses so the two fields cannot drift apart.

   Inset past the canvas on every side so the radial mask has faded the dots to
   nothing well before the box ends: the field reads as light behind the
   staircase rather than as a panel with an edge. */
.cs-jn-dots {
  position: absolute;
  /* Vertical overhang only. An absolutely positioned child still counts toward
     the scroller's scrollWidth, so a horizontal one made the after view 982px
     wide inside a 950px scroller and left it scrollable when it should be
     inert. Nothing is lost: the radial mask has already faded the dots out
     well inside these edges. */
  inset: -24px 0;
  opacity: var(--jn-dot-opacity);

  /* Edge-anchored fade, not the prologue's proportional radial one. This canvas
     changes width dramatically between the two views — 2112px when the
     staircase is scrolling, 950 when it collapses — and a radial ellipse at
     50% put its bright centre a thousand pixels off to the right, leaving the
     START of the staircase, which is where the reader begins, almost bare.
     A fade measured in px from each edge holds at any width.

     Two gradients intersected: horizontal and vertical. mask-composite is the
     standard property; the -webkit- pair is what Safari still reads, and its
     'source-in' is the same operation. */
  --jn-fade-x: 96px;
  --jn-fade-y: 56px;
  -webkit-mask-image:
    linear-gradient(to right, transparent, #000 var(--jn-fade-x), #000 calc(100% - var(--jn-fade-x)), transparent),
    linear-gradient(to bottom, transparent, #000 var(--jn-fade-y), #000 calc(100% - var(--jn-fade-y)), transparent);
          mask-image:
    linear-gradient(to right, transparent, #000 var(--jn-fade-x), #000 calc(100% - var(--jn-fade-x)), transparent),
    linear-gradient(to bottom, transparent, #000 var(--jn-fade-y), #000 calc(100% - var(--jn-fade-y)), transparent);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  z-index: 0;
}

/* Everything real paints above the field. */
.cs-jn-steps > li,
.cs-jn-stages > li { position: relative; z-index: 1; }

/* --- steps -------------------------------------------------------------- */
.cs-jn-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.cs-jn-steps > li {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  color: var(--cs-fg);
}

.cs-jn.is-live .cs-jn-steps { display: block; }

.cs-jn.is-live .cs-jn-steps > li {
  position: absolute;
  top: 0;
  left: 0;
  height: var(--jn-pill-h);
  width: var(--jn-pill-w);
  padding: 0 10px;
  border-radius: var(--jn-radius-pill);
  background: var(--jn-neutral);
  overflow: visible;
  transform: translate3d(var(--jn-x, 0), var(--jn-y, 0), 0);
  transition:
    transform var(--jn-dur) var(--jn-ease),
    width var(--jn-dur) var(--jn-ease),
    border-radius var(--jn-dur) var(--jn-ease),
    background-color var(--jn-dur-colour) var(--jn-ease);
}

/* The warm fill is the before state's only colour, and it is a diagnosis:
   where the funnel bled. It does not carry into the after view, because the
   regrouping is a proposal and colouring it would imply a measured result. */
.cs-jn.is-live[data-jn-state="before"] .cs-jn-steps > li[data-jn-drop] {
  background: linear-gradient(
    100deg,
    color-mix(in srgb, var(--jn-drop) 88%, transparent),
    var(--jn-drop));
}

.cs-jn-emoji { font-size: 15px; line-height: 1; flex: none; }

.cs-jn-label { white-space: nowrap; }

/* Once live the label drops out of the pill and sits under it, which is what
   lets the pill itself shrink to a slice without the text fighting the box. */
.cs-jn.is-live .cs-jn-label {
  position: absolute;
  top: calc(100% + 7px);
  left: 0;
  width: max-content;
  max-width: var(--jn-label-w);
  white-space: normal;
  font-size: var(--text-label);
  line-height: 1.35;
  color: var(--cs-label);
  transition: opacity var(--jn-dur-colour) var(--jn-ease);
}

.cs-jn.is-live[data-jn-state="after"] .cs-jn-label,
.cs-jn.is-live[data-jn-state="after"] .cs-jn-emoji { opacity: 0; pointer-events: none; }

.cs-jn.is-live .cs-jn-label,
.cs-jn.is-live .cs-jn-emoji { opacity: 1; }

/* Slices: square off every inner edge so a stage reads as one bar cut into
   parts rather than as pills parked next to each other. The script sets
   --jn-slice on the members that are not at an end of their stage. */
/* The after view is the resolution, so it carries the brand's warm accent on
   every slice — where the before view carries red on the steps that bled. The
   two states now say opposite things in colour, not just in arrangement. */
.cs-jn.is-live[data-jn-state="after"] .cs-jn-steps > li {
  border-radius: 0;
  padding: 0;
  background: var(--jn-positive);
}
.cs-jn.is-live[data-jn-state="after"] .cs-jn-steps > li[data-jn-edge="start"] {
  border-radius: var(--jn-radius-block) 0 0 var(--jn-radius-block);
}
.cs-jn.is-live[data-jn-state="after"] .cs-jn-steps > li[data-jn-edge="end"] {
  border-radius: 0 var(--jn-radius-block) var(--jn-radius-block) 0;
}
.cs-jn.is-live[data-jn-state="after"] .cs-jn-steps > li[data-jn-edge="only"] {
  border-radius: var(--jn-radius-block);
}

/* First measured paint: positions applied with no travel, so the pills do not
   fly in from the canvas origin the first time the section is laid out. */
.cs-jn.is-settling .cs-jn-steps > li,
.cs-jn.is-settling .cs-jn-stages > li,
.cs-jn.is-settling .cs-jn-canvas { transition: none !important; }

/* --- stage titles ------------------------------------------------------- */
.cs-jn-stages { list-style: none; margin: 0; padding: 0; }

/* Before the script runs these are the plain stage list, which is useful on
   its own; once live they are absolutely placed under their block. */
.cs-jn:not(.is-live) .cs-jn-stages {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-top: 28px;
  padding-top: 20px;
  border-top: 1px solid var(--cs-rule);
}

.cs-jn:not(.is-live) .cs-jn-stages > li { display: flex; flex-direction: column; gap: 2px; }

.cs-jn.is-live .cs-jn-stages > li {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
  /* Capped to the block it labels, on the <li> rather than on the summary
     alone: a long stage name ("Pick a Jupiter experience") is wider than a
     fifth of the timeline and ran straight under the next stage's badge when
     only the summary was constrained. */
  width: var(--jn-sum-w, 200px);
  transform: translate3d(var(--jn-x, 0), var(--jn-y, 0), 0);
  opacity: 0;
  pointer-events: none;
  transition:
    opacity var(--jn-dur-colour) var(--jn-ease),
    transform var(--jn-dur) var(--jn-ease);
}

/* Staggered by stage index so the five titles settle left to right rather than
   all arriving at once. --jn-i is set by the script. */
.cs-jn.is-live[data-jn-state="after"] .cs-jn-stages > li {
  opacity: 1;
  pointer-events: auto;
  transition-delay: calc(var(--jn-i, 0) * 0.06s);
}

.cs-jn-stage-name {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-ui);
  line-height: 1.25;
  color: var(--cs-fg);
}

/* The numeral is a counter, not typed into the markup, so it cannot drift out
   of step with the list order. */
.cs-jn-stages { counter-reset: jn-stage; }
.cs-jn-stages > li { counter-increment: jn-stage; }

.cs-jn-stage-name::before {
  content: counter(jn-stage);
  display: inline-grid;
  place-items: center;
  width: 20px;
  height: 20px;
  margin-right: 8px;
  border-radius: 999px;
  /* Spacefire, matching the slices above it. The numeral is dark ink, not
     cream: white on spacefire measures 2.58:1 and would be unreadable at 11px,
     where ink is 6.44:1. (The bubble's white-on-spacefire is a separate,
     deliberate call about a display-size clause — it does not extend here.) */
  background: var(--jn-positive);
  color: var(--color-dark);
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 700;
  vertical-align: 1px;
}

/* .cs-jn-stage-sum — a one-line summary under each stage name — was removed on
   instruction. The name and the slices under it carry the stage on their own,
   and the summaries were most of what made this view read as dense. */

/* --- legend ------------------------------------------------------------- */
/* Only the before view has drop-off in it, so the key goes with it — and now
   lives inside .cs-jn-head, beside the toggle it keys (see the note above). */
.cs-jn-legend {
  display: flex;
  gap: 24px;
  flex-wrap: wrap;
  margin: 0;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  color: var(--cs-label);
  transition: opacity var(--jn-dur-colour) var(--jn-ease);
}

.cs-jn[data-jn-state="after"] .cs-jn-legend { opacity: 0; pointer-events: none; }

.cs-jn-key { display: inline-flex; align-items: center; gap: 8px; }

.cs-jn-key::before {
  content: '';
  width: 9px;
  height: 9px;
  border-radius: 999px;
  background: var(--jn-neutral);
}

.cs-jn-key--drop::before { background: var(--jn-drop); }

/* --- scroll hint ---------------------------------------------------------- */
/* "Scroll" plus a nudging double-chevron, pushed to the row's far right by its
   own auto margin -- the toggle and legend read left to right on the near
   edge, this sits where the eye lands next, roughly above the edge the
   staircase actually scrolls past.

   Hidden by default and shown only under .is-live[data-jn-state="before"] --
   the two conditions under which the staircase is ACTUALLY a horizontal
   scroller. Below the 641px breakpoint the component has no JS layout at all
   (is-live never gets added; the fallback is a plain vertical list — nothing
   horizontal to scroll), and the after view collapses to fit the frame and
   "goes inert" (see the scroller's own comment below). A hint that shows
   regardless of either would tell a reader to scroll toward content that
   cannot move. Verified: at 640px, .cs-jn lacks .is-live and the scroller's
   own scrollWidth equals its clientWidth — genuinely nothing to scroll,
   which is what this rule is for.

   Modelled on a reference recording of the same pattern rather than guessed:
   the chevron pair holds at rest, eases right, holds, eases back -- and it is
   ONE rigid unit, not two staggered glyphs. Confirmed on the recording by
   tracking each chevron's own pixel centroid: both moved by the same amount on
   every frame and the gap between them never changed. */
/* display:none by default -- the <641px fallback never gets .is-live, so the
   hint never enters layout there at all. This is deliberately a SEPARATE
   toggle from the opacity one below: display can't transition, so the
   before/after cross-fade needs the element to stay display:flex the whole
   time .is-live holds, exactly like .cs-jn-legend, with only .is-live itself
   (added once, on load, never removed) switching it into the layout. */
.cs-jn-hint {
  display: none;
  align-items: center;
  gap: 6px;
  margin: 0 0 0 auto;
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  color: var(--cs-label);
  transition: opacity var(--jn-dur-colour) var(--jn-ease);
}

.cs-jn.is-live .cs-jn-hint { display: flex; }

.cs-jn.is-live[data-jn-state="after"] .cs-jn-hint { opacity: 0; pointer-events: none; }

.cs-jn-hint-icon { width: 18px; height: 18px; flex: none; }

@media (prefers-reduced-motion: no-preference) {
  .cs-jn-hint-icon { animation: cs-hint-nudge 1.2s ease-in-out infinite; }
}

/* Holds at 0-8% and 92-100% (so the loop meets itself with no seam), eases out
   to +8px by 46%, holds at the peak to 54%, eases back by 92% -- a ~1.2s round
   trip with a brief dwell at each end, matching the reference's timing rather
   than a plain linear back-and-forth.

   8px is not the reference's own pixel figure -- that recording was captured
   at an unknown zoom, so the PROPORTION was kept (the swing measured about a
   third of one chevron's own width) and the result rounded to the nearest
   number on the page's 4px grid. */
@keyframes cs-hint-nudge {
  0%, 8%    { transform: translateX(0); }
  46%, 54%  { transform: translateX(8px); }
  92%, 100% { transform: translateX(0); }
}

/* --- reduced motion ----------------------------------------------------- */
/* The switch still has to happen — it is the whole control — so this takes out
   the travel, not the change: positions jump and only opacity is timed. */
@media (prefers-reduced-motion: reduce) {
  .cs-jn { --jn-dur: 0.01s; --jn-dur-colour: 0.18s; }
  .cs-jn.is-live .cs-jn-steps > li,
  .cs-jn.is-live .cs-jn-stages > li,
  .cs-jn.is-live .cs-jn-canvas,
  .cs-jn-toggle::after { transition-duration: 0.01s; }
  .cs-jn.is-live .cs-jn-label,
  .cs-jn.is-live .cs-jn-stages > li { transition: opacity 0.18s linear; }
}

/* The two flow maps break the margin and run to the page edge.

   Copied from how the Collectibles study handles its own system diagrams: it
   renders them 2,590 and 2,787px wide inside a 1,440px viewport, deliberately
   larger than the column, so a map of a sprawling flow reads as sprawling
   rather than as a tidy figure in a text column. Boxed at reading width, a
   twelve-step mess just looks small and orderly, which is the opposite of
   what this section is arguing.

   The label stays inset so the section still hangs off the same left edge as
   every other one.

   No border-radius is cancelled here, because none is applied: .cs-ph scopes
   its corner to :not(.cs-ph--bleed). Writing `border-radius: 0` would be a
   literal, which this project does not allow anywhere — an element that should
   not be rounded gets no declaration at all. */
.cs-ph--bleed {
  margin-left: calc(-1 * var(--cs-margin));
  margin-right: calc(-1 * var(--cs-margin));
  border-left: 0;
  border-right: 0;
}


/* ═══ §11 — Three splash approaches ═════════════════════════════════════
   Side by side, each with its verdict beneath. The shipped one is marked by
   a full-strength border and a small check — no badge, no colour fill.   */

.cs-approaches {
  display: grid;
  gap: 24px;
}

.cs-approach {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 16px;
  border: 1px solid transparent;
  border-radius: var(--radius);
}

.cs-approach-num {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  display: flex;
  align-items: center;
  gap: 8px;
}

.cs-approach-note {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.5;
  color: var(--color-dark);
  margin: 0;
}

/* Shipped. The border does the work; the check is a whisper, not a rosette. */
.cs-approach.is-shipped {
  border-color: var(--color-dark);
}

.cs-approach.is-shipped .cs-approach-num {
  color: var(--color-dark);
}

.cs-approach-check {
  display: none;
  flex: none;
}

.cs-approach.is-shipped .cs-approach-check { display: block; }

@media (min-width: 641px) {
  .cs-approaches { grid-template-columns: repeat(3, 1fr); gap: 16px; }
  .cs-approach   { padding: 20px; }
}

@media (min-width: 1025px) {
  .cs-approaches { gap: 24px; max-width: 1040px; }
}

.cs-ba-shot {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.cs-ba-shot-label {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}


/* ═══ §13 — Iterations: the strategy spectrum ═══════════════════════════════
   Six explorations of one screen laid along the axis they actually differ
   on — how much of the decision the screen keeps for itself. Left to right is
   that spectrum, NOT chronology, which is why iteration 1 sits on the right
   next to 5 and 6.

   Everything here is borrowed rather than invented, on instruction:

     screen size     --pro-card-w x --cs-screen-step, the "after" screen's own
                     width, so these read as the same object as every other
                     screen on the page
     gap             --pro-stage-gap, the same air that sits between a before
                     and an after pair
     hover           --cs-screen-hover, the same 1.08 lift
     the rail        full viewport bleed with the gutter padded back, the
                     construction .cs-jn-viewport uses, so a scrolled screen
                     crops against the screen rather than against the column
     the tabs        §7's research tabs, but as a readout: the rail selects
                     them, they do not gate the rail
     the field       the shared dot pattern in style.css

   The annotations follow the pointer. That needs JS, and it is the only part
   here that does — see case-study-spectrum.js. Without it the bubbles simply
   sit under their screens, which is the no-JS state and is readable.       */

.cs-spec {
  --spec-shot-w: calc(var(--pro-card-w) * var(--cs-screen-step));
  /* The before/after pair's own gap plus 32, on instruction. Still written
     off --pro-stage-gap rather than as a flat 88px: the relationship to that
     pair is the point, and --pro-stage-gap steps down to 32px below 1025,
     which a literal would not follow. */
  --spec-gap: calc(var(--pro-stage-gap) + 32px);
  /* How far a hovered screen grows past its own box. Derived, not typed: it
     is exactly half the height the 1.08 lift adds, which is the shot's width
     times its 9/19.5 ratio times (hover - 1), halved because the transform
     grows from the centre. Every term is a token, so retuning --cs-screen-hover
     or --pro-card-w re-sizes the room the rail leaves for it automatically —
     a literal here would be correct at one breakpoint and wrong at the next,
     since --pro-card-w is a clamp(). */
  --spec-lift: calc(var(--spec-shot-w) * (19.5 / 9)
                    * (var(--cs-screen-hover) - 1) / 2);
  display: flex;
  flex-direction: column;
  /* The same token that sets every other block gap in this section, so the
     air under the tab row matches the air above it exactly rather than
     being a smaller number that happened to look fine on its own. Measured
     before: 40px above the tabs, 24 below. */
  gap: var(--cs-blk-gap);
}

/* ── The dark ground ──────────────────────────────────────────────────────
   Same near-black as §5 and §21, and set the same way .cs-impact sets it:
   scoped here, overriding .cs-band--dark's own --cs-band-bg rather than
   editing that class, which is a generic flip meant for reuse and should not
   inherit one section's specific colour. Everything else -- --cs-fg,
   --cs-label, --cs-label-strong, --cs-rule -- stays .cs-band--dark's own.

   min-height, and it is load-bearing rather than cosmetic. This section
   carries data-ground, so the page crossfades to its colour while it holds
   the middle of the viewport -- and that device only stays off the cream
   sections either side if the section already fills the screen by the time
   the fade starts. .cs-ground states the same requirement as a rule; this
   section is taller than 100svh at every width the page supports, so the
   declaration guarantees an invariant rather than changing any layout. */
#screen-iterations {
  --cs-band-bg: var(--color-charcoal);
  min-height: 100svh;
}

/* --- head: the tabs ------------------------------------------------------ */

/* No pole labels above the row and no gloss line under each name — both went
   on instruction. The axis is named in the subhead now ("a scale ranging from
   user control to system intelligence"), which is the one place it needs to
   be said; repeating it as a rule with a word at each end, plus a third line
   restating each tab in plainer words, was three copies of one idea sitting
   on top of the screens that demonstrate it. */
.cs-spec-tabs {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0;
}

/* Now literally §7's tab: same flex row, same padding, same type. With the
   gloss line gone there is nothing left that differs, so this is a copy of
   .cs-tab's declarations rather than a variation on them — kept as its own
   selector only because the two are wired to different scripts. */
.cs-spec-tab {
  appearance: none;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--cs-rule);
  font: inherit;
  text-align: left;
  cursor: pointer;
  display: flex;
  align-items: baseline;
  gap: 16px;
  padding: 20px 0;
  min-height: 44px;
  transition: color 0.25s ease, border-color 0.25s ease;
}

.cs-spec-tab-num {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  color: var(--cs-label);
}

.cs-spec-tab-name {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: calc(var(--text-body) - 4px);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-label);
}

.cs-spec-tab:hover .cs-spec-tab-name { color: var(--cs-fg); }

.cs-spec-tab[aria-selected="true"] { border-bottom-color: var(--cs-fg); }

.cs-spec-tab[aria-selected="true"] .cs-spec-tab-num,
.cs-spec-tab[aria-selected="true"] .cs-spec-tab-name { color: var(--cs-fg); }

.cs-spec-tab:focus-visible {
  outline: 2px solid var(--cs-fg);
  outline-offset: 2px;
}

/* 32px and the same breakpoint as .cs-tablist, not 24 — the two rows sit six
   sections apart on one page and any difference in their column gap reads as
   a mistake rather than as a distinction. */
@media (min-width: 768px) {
  .cs-spec-tabs { grid-template-columns: repeat(3, 1fr); gap: 32px; }
}

/* --- the field and the rail ---------------------------------------------- */

/* Used to hold a dot field still (position: relative) while the screens
   travelled across it, as a sibling of the scroller rather than a child --
   that dot field is gone on instruction, and nothing else in this section is
   absolutely positioned against this box, so the positioning context went
   with it. What is left, and load-bearing: 24px of vertical air around the
   rail, matching the field's own name from when it did more. */
.cs-spec-field {
  padding-block: 24px;
}

/* The "Scroll >>" cue that used to sit here is gone on instruction. The zone
   dimming below is a better affordance anyway: a row where part of the
   content is deliberately held back reads as having more to it than the
   column shows, without a label saying so. */

/* The rail. Full bleed to the VIEWPORT both ways, then the column's gutter
   padded back in — .cs-jn-viewport's construction, for .cs-jn-viewport's
   reason: a scrolled screen should crop against the screen edge, not against
   the column.

   It used to bleed by --cs-margin alone, and that is what was cropping the
   screens. Past 950px the column stops growing and the real gutter is
   whatever is left over, which is wider than --cs-margin — so the rail's box
   ended short of the viewport on both sides and cut the travelling screens
   off inside the page rather than at its edge. max() of the two rebuilds the
   true gutter at every width, exactly as --jn-gutter does; it cannot be a
   percentage, because inside a full-bleed box a percentage resolves against
   100vw rather than against the column.

   50% is half this box (the column), 50vw half the viewport, so their
   difference is precisely one gutter. <html> already has overflow-x hidden,
   which covers the scrollbar being counted in 50vw. */
.cs-spec-rail {
  --spec-gutter: max(var(--cs-margin),
                     calc((100vw - var(--cs-content-max)) / 2));
  position: relative;
  z-index: 1;
  display: flex;
  gap: var(--spec-gap);
  margin-inline: calc(50% - 50vw);
  padding-inline: var(--spec-gutter);
  list-style: none;
  overflow-x: auto;
  scrollbar-width: none;
  overscroll-behavior-x: contain;
  /* Room for the hover lift to render: a scroller computes overflow-y to auto
     and clips, so a screen growing 8% inside one is shaved top and bottom.
     Padding buys the space; the negative margin gives it back to the layout.
     Same technique, same reason, as .tm-viewport.

     This was a flat 16px, and 16px was not enough — the lift alone is ~22px
     at 1440 and the shadow wants ~22 more below it, so both the screen's own
     edges and its shadow were being cut off by the scroller. It is derived
     now: --spec-lift (see .cs-spec) plus 24px, which is what the deepest of
     --cs-screen-shadow's three layers reaches once it has been scaled with
     the screen.

     Neither figure lands on the 4px grid at every breakpoint, and that is
     correct here: this padding is cancelled to the pixel by the margin below
     it, contributes nothing to layout, and is an overflow allowance measured
     off another token rather than a designed piece of spacing. */
  padding-block: calc(var(--spec-lift) + 24px);
  margin-block: calc(-1 * (var(--spec-lift) + 24px));
}

.cs-spec-rail::-webkit-scrollbar { display: none; }

.cs-spec-step {
  position: relative;
  flex: 0 0 var(--spec-shot-w);
}

.cs-spec-hit {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  text-align: left;
  cursor: pointer;
  font: inherit;
  color: inherit;
}

.cs-spec-hit:focus-visible {
  outline: 2px solid var(--cs-fg);
  outline-offset: 4px;
}

/* object-position: top, deliberately. The six captures come in four native
   ratios and the thing being compared across them is the FRAMING — the
   headline and the first card. Cropping from the bottom keeps every one of
   those in the same place; centring would slide each headline to a different
   height and make the row impossible to read across. */
.cs-spec-shot {
  display: block;
  width: 100%;
  aspect-ratio: 9 / 19.5;
  object-fit: cover;
  object-position: top;
  border-radius: var(--cs-card-radius);
  box-shadow: var(--cs-screen-shadow);
}

/* "Iteration 1", "Iteration 2"… are gone on instruction. They were the one
   label on the row that carried no information: the reader can count, and the
   order they are counting is the spectrum, not the chronology, so a number
   under each screen was actively arguing the wrong reading. The alt text
   still names each iteration, so nothing was lost for a screen reader.

   One label survives, because it says something the row cannot: which of the
   six actually shipped. Marked by a word in spacefire rather than a badge —
   the page has no filled surfaces. */
.cs-spec-ship {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--hook-highlight);
}

/* Pointer devices only, and on the image rather than the step: the step is
   what the annotation is positioned against, and a transform on it would move
   the thing the bubble is measuring. Same split, same reason, as §3's. */
@media (hover: hover) {
  .cs-spec-shot { transition: transform 0.3s ease; }
  .cs-spec-hit:hover .cs-spec-shot { transform: scale(var(--cs-screen-hover)); }
  .cs-spec-step:hover { z-index: 2; }
}

@media (prefers-reduced-motion: reduce) {
  .cs-spec-shot { transition: none; }
  .cs-spec-hit:hover .cs-spec-shot { transform: none; }
}

/* ── Which screens belong to the strategy the tabs are naming ─────────────
   The screens outside the selected zone are held back, so the group the tab
   row is reporting reads as a group. case-study-spectrum.js puts .is-aside
   on them; see the note there for why the decision is made in JS.

   0.34, and it is a judgement about what this section is for. Enough that
   the boundary between two zones is unmistakable mid-scroll, and not so
   little that a held-back screen stops being a screen — the argument here is
   six explorations, and a reader skimming the row should still see six.

   Which is also why hover overrides it. The dimming is a reading aid, not a
   gate: any screen comes back to full strength under the pointer, whichever
   zone it belongs to, so nothing is ever actually withheld. That rule has to
   sit after this one to win, since both are one class deep.

   Gated on .is-live for the usual reason — with no JS nothing ever adds
   .is-aside, so every screen simply stays at full strength. */
.cs-spec.is-live .cs-spec-step {
  transition: opacity 0.35s ease;
}

.cs-spec.is-live .cs-spec-step.is-aside { opacity: 0.34; }

@media (hover: hover) {
  .cs-spec.is-live .cs-spec-step.is-aside:hover,
  .cs-spec.is-live .cs-spec-step.is-aside:focus-within { opacity: 1; }
}

/* Keyboard has no hover to lean on, so focus restores it on every device. */
.cs-spec.is-live .cs-spec-step.is-aside:focus-within { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .cs-spec.is-live .cs-spec-step { transition: none; }
}

/* --- the annotations ----------------------------------------------------- */

/* Default — and the whole no-JS state: the note sits under its screen and is
   simply there. case-study-spectrum.js takes over from here and makes it
   follow the pointer; if it never runs, this is what a reader gets. */
.cs-spec-note {
  --cs-bubble-measure: 20em;
  max-width: 100%;
}

/* With the script running, the bubble leaves the flow and is positioned in
   viewport coordinates against the real cursor. position: fixed, not
   absolute, so it escapes the rail's overflow clipping entirely rather than
   needing room padded into the scroller for it. */
.cs-spec.is-live .cs-spec-note {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 40;
  margin: 0;
  width: max-content;
  max-width: min(22em, 70vw);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
}

.cs-spec.is-live .cs-spec-note.is-open { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .cs-spec.is-live .cs-spec-note { transition: none; }
}


/* The aside. Tinted, indented, on a heavy left rule — three signals so it
   cannot be mistaken for the next paragraph of the main argument. */
.cs-aside {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 24px;
  border-left: 4px solid var(--color-dark);
  border-radius: var(--radius);
  background: var(--cs-tint);
  max-width: 44em;
}


/* ═══ §15 — If I were doing this today ══════════════════════════════════
   Four stages, each an icon, a stage name and the tool. No screenshots.  */

.cs-tools {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 24px;
  max-width: 1040px;
}

/* The slot the confirmed tool names go in. Unused until then: the markup
   carries a .cs-todo chip instead, because the script flags these as needing
   Shriya's sign-off before they can be defended in an interview. Swap the
   chip's class to this one when the names land. */
.cs-tool-name {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}


/* ─── Closing beat ────────────────────────────────────────────────────────
   §17 currently ends on an unresolved TODO, which is a cold last impression
   for someone who just read seventeen sections. A short sign-off and a way
   onward costs two lines and ends the page like a person wrote it.        */

.cs-outro {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 20px;
  padding-top: 40px;
}

/* ── Next project ──
   The primary exit. Someone who read to the bottom is warm; handing them the
   next piece of work is worth more than handing them the index. Built as a
   card rather than a link so it has the visual weight of an offer, with the
   back-to-work link demoted to text beneath it.

   The whole card is the target — no nested link — so there is one hit area
   and one tab stop. */
.cs-next {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  width: 100%;
  padding: 24px;
  border: 1px solid var(--cs-rule);
  border-radius: var(--radius);
  background: var(--color-white);
  text-decoration: none;
  transition: border-color 0.25s ease, transform 0.25s ease;
}

.cs-next:hover {
  border-color: var(--color-dark);
  transform: translateX(4px);
}

.cs-next-text {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.cs-next-label {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

.cs-next-title {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-lead);
  line-height: 1.25;
  letter-spacing: -0.01em;
  color: var(--color-dark);
  margin: 0;
}

.cs-next-arrow {
  flex: none;
  color: var(--color-dark);
  transition: transform 0.25s ease;
}

.cs-next:hover .cs-next-arrow { transform: translateX(4px); }

/* Secondary exit — plain text, deliberately quieter than the card. */
.cs-outro-back {
  /* 44px touch box, paid in padding so the underline stays on the text. */
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  color: var(--cs-label);
  text-decoration: underline;
  text-underline-offset: 4px;
  transition: color 0.25s ease;
}

.cs-outro-back:hover { color: var(--color-dark); }

@media (min-width: 641px) {
  .cs-next { padding: 32px; }
}

@media (prefers-reduced-motion: reduce) {
  .cs-next, .cs-next-arrow { transition: border-color 0.25s ease; }
  .cs-next:hover { transform: none; }
  .cs-next:hover .cs-next-arrow { transform: none; }
}

.cs-outro-line {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: var(--text-body);
  line-height: 1.333;
  letter-spacing: -0.01em;
  color: var(--color-dark);
  margin: 0;
}

/* Same recipe as .cs-back so the two exits read as one control, not two. */
.cs-outro-link {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  border: 1px solid var(--cs-rule);
  border-radius: var(--radius);
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-dark);
  text-decoration: none;
  transition: border-color 0.25s ease, opacity 0.25s ease;
}


/* ─── Entrance ────────────────────────────────────────────────────────────
   Motion on this page is GSAP's, in case-study-motion.js. It used to be a
   hand-written CSS copy of WORD_REVEAL; that copy is gone, along with the
   sync hazard of maintaining the same numbers in two places.

   Almost nothing is needed here as a result: gsap.from() writes its own start
   state inline the moment the tween is built, so there is no hidden state for
   the stylesheet to own — and therefore no way for a failed script to strand
   the page, which is the property that mattered.

   The one thing CSS must supply is the box the transform acts on. A bare
   <span> around a word is inline, and translateY does nothing to an inline
   box. */

.cs-word {
  display: inline-block;
  /* Words are transformed individually and repeatedly; promoting them keeps
     the paint off the main thread on the long headings. */
  will-change: transform;
}

/* Reduced motion never reaches the script — it returns before body.cs-motion
   is added and before any tween exists. This is belt and braces for anything
   that sets a transform some other way. */
@media (prefers-reduced-motion: reduce) {
  .cs-word { transform: none !important; opacity: 1 !important; }
  .cs-progress { transition: none; }
}


/* ─── Open copy ───────────────────────────────────────────────────────────
   Two lines in the script are still unresolved (§15 tool names, §17's "what
   I'd do differently"). Marked rather than invented, and visible so they
   cannot ship by accident.                                              */

.cs-todo {
  display: inline-block;
  /* Shrink to the text. Without this it stretches to the column width in the
     flex-column parents it sits in (.cs-tool). */
  align-self: flex-start;
  padding: 4px 8px;
  border: 1px dashed var(--cs-dash);
  border-radius: var(--radius);
  font-family: var(--font-ui);
  font-size: var(--text-label);
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}


/* ═══ Impact — the promise and the payoff ══════════════════════════════════
   §3 and §14 are the same three numbers, and they are deliberately built as
   one component used twice with the hierarchy inverted.

   §3  numbers loud (--text-section), sentence quiet.   The promise.
   §14 numbers quiet (--text-body),  sentence loud.     The payoff.

   Sharing ground, table structure and left edge is what makes a reader
   recognise the callback 13,000px later before they have read a word. The
   inversion is what stops the second appearance from being a repeat: by then
   the figures are established and the sentence about them is the new thing.

   No borders anywhere in either. The dark ground is the only container — a
   metric card is exactly what this page has too many of already.           */

.cs-impact {
  /* Overrides .cs-band--dark's own --cs-band-bg (var(--color-dark),
     #1e1e20) with this section's own near-black, #2b2d33, on instruction.
     Scoped here rather than edited on .cs-band--dark itself: that class is a
     generic "dark ground" flip meant for reuse, and baking one section's
     specific colour into it would make a future dark band inherit a colour
     it never asked for. --cs-fg/--cs-label/etc. stay .cs-band--dark's own,
     tuned for cream against a near-black ground -- and #2b2d33 is close
     enough to #1e1e20 that nothing there needed re-measuring: cream at 72%
     flattened over it is 7.24:1, still clear of the 4.5:1 floor the original
     8.4:1 was tuned past. */
  --cs-band-bg: var(--color-charcoal);
  /* Taller than a normal band: this is a full stop in the argument, and the
     air around the numbers is what makes them read as a result rather than
     as a row of statistics. */
  padding-block: calc(var(--cs-sec-gap) + 40px);
  /* Positioning context for the dot field, and its own stacking context so
     the field's z-index: -1 stays local -- it has to sit above this band's
     own background but below its copy, and nothing outside should be able
     to land between the two. */
  position: relative;
  isolation: isolate;
  overflow: hidden;
}

/* The same field the landing page and §5 carry, inverted for a dark ground.
   Inset by --cs-margin rather than run to the bleed, which is what every
   other field on the site does -- it is a margin-to-margin panel, not an
   edge-to-edge texture. Cream at 0.10 rather than the shared 0.32: that
   figure is tuned for dark dots on a light ground, and cream on near-black
   at the same value reads as static rather than texture. */
.cs-impact-dots {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--cs-margin);
  right: var(--cs-margin);
  z-index: -1;
  --dot-ink: var(--color-cream);
  --dot-opacity: 0.10;
}

/* §3 holds most of a viewport, so arriving at it is a change of chapter
   rather than another section. svh rather than vh: on iOS vh is the
   address-bar-collapsed height, which overshoots on first paint and leaves
   the band taller than the screen it is meant to fill.

   Centred rather than top-aligned, because with this much air a top-aligned
   block leaves a pool of dead ground beneath it. min-height, not height, so
   the band still grows if the copy ever does. */
@media (min-width: 641px) {
  .cs-impact {
    min-height: 88svh;
    justify-content: center;
  }
}

/* A ledger, not a dashboard. Three entries, each a full-width row: the
   figure on the left at display scale, its reading on the right, a hairline
   above every row and one closing the set. That structure is what lets the
   figures get this big -- across in three columns they were bounded by a
   third of the column (~84px), stacked down the left they left half the
   width empty. A row spends the whole width on one figure and its reading,
   so the number can run to ~150px and the right side is occupied by
   something that belongs there.

   The three stay equal to each other: same size, same weight, same shape.
   Equal hierarchy was the instruction and it still holds -- what changed is
   how much room all three get, not their relationship. */
.cs-results {
  display: flex;
  flex-direction: column;
}

.cs-result {
  position: relative;
  display: grid;
  grid-template-columns: 1fr;
  gap: 20px;
  padding-block: 40px;
}

@media (min-width: 768px) {
  .cs-result {
    /* 3:2 rather than a fixed label column: at 768 a fixed 320px label would
       leave the figure less room than "$2.4M" needs. Fractions hold the
       proportion at every width instead. */
    grid-template-columns: 3fr 2fr;
    gap: 40px;
    /* end first, as the fallback: anything that does not understand "last
       baseline" keeps bottom-aligned boxes rather than dropping to stretch,
       which would pull the reading to full row height. */
    align-items: end;
    /* LAST baseline, not first: the readings run to two lines, and it is the
       line they end on that should sit on the figure's baseline. Bottom-
       aligning the boxes (align-items: end) left the reading's baseline
       ~18px high of the figure's, because the figure's box carries ~26px of
       descent below its baseline at this size and the reading's carries ~8px. */
    align-items: last baseline;
    padding-block: 56px;
  }
}

/* Between the rows only -- nothing above the first or below the last, so the
   three are separated without being boxed in.

   Pseudo-elements rather than borders because they are drawn on arrival and
   a border cannot be scaled. --cs-rule-scale is what the motion layer
   animates; with no motion layer it is absent and the fallback paints them
   full width, which is the static page. */
.cs-result + .cs-result::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 1px;
  background: var(--cs-rule);
  transform: scaleX(var(--cs-rule-scale, 1));
  transform-origin: left center;
}

.cs-result-num {
  font-family: var(--font-display);
  font-weight: 700;
  /* Scoped deliberately rather than reaching for --text-section, which tops
     out at 108px and is shared with the work-section titles: this figure
     wants to be the largest type in the case study and nothing else should
     move with it. The -4px is taken off all three stops rather than just the
     preferred one, so the reduction holds at the floor and the ceiling too
     and the curve keeps its shape: 60px / 9vw-4px / 148px, landing ~126px at
     1440. "$2.4M", the longest figure, sets ~477px there against a 546px
     column. */
  font-size: clamp(3.75rem, calc(9vw - 4px), 9.25rem);
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--cs-fg);
}

/* Up from --text-ui. Against a 130px figure the old 16px read as a caption
   that had wandered in; --text-lead (15-30px) is a reading, which is what
   it is. */
.cs-result-label {
  font-family: var(--font-ui);
  font-size: var(--text-lead);
  line-height: 1.4;
  color: var(--cs-label);
  max-width: 22em;
}

/* ── The odometer ─────────────────────────────────────────────────────────
   Each figure rolls its digits into place on arrival. Symbols hold still --
   $ . % K M never move -- so the value spins and the unit stays put, which
   is the distinction that keeps it from reading as decoration.

   Clash Display's figures are PROPORTIONAL and the face ships no tabular
   set: "1" measures 0.399em against "0" at 0.725em, and font-variant-
   numeric: tabular-nums is a no-op in it (measured: identical 0.326em spread
   either way). A reel of equal-width cells would therefore either clip the
   wide digits or hang slack around every 1. So each cell is sized to the
   digit it will LAND on, and the digits passing through are scaled into that
   width -- a squeeze nobody can read at 60ms a digit, and the landing frame
   is the exact width the plain text was.

   Widths are set in em, so the whole reel survives a resize even though the
   figure's size is fluid. */
.cs-odo {
  display: inline-flex;
  align-items: flex-start;
}

.cs-odo-cell {
  display: block;
  height: 1em;
  overflow: hidden;
}

/* display: block is load-bearing, not tidiness -- a <span> is inline by
   default and transforms do not apply to non-replaced inline elements, so
   the reel silently refused to move while GSAP reported the right value. */
.cs-odo-strip {
  display: block;
  will-change: transform;
}

.cs-odo-strip > span {
  display: block;
  height: 1em;
  line-height: 1;
  text-align: center;
}

/* The real figure, for anything that reads the page rather than looks at it:
   the reel itself is aria-hidden, since its text is 0123456789 over and
   over. */
.cs-odo-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* Only under the motion layer, and only until the reel is built and its
   first frame set -- see the safety note at the top of case-study-motion.js.
   Without that class this never applies and the figures are simply there. */
body.cs-motion .cs-result-num {
  opacity: 0;
}

/* The promise line under §3's numbers. */
.cs-impact-promise {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: var(--text-body);
  line-height: 1.4;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  max-width: 28em;
  margin: 0;
}

/* ── §14, inverted ────────────────────────────────────────────────────────
   The receipt: same table, dropped to body scale and to the label colour, so
   it reads as a reference the reader already holds rather than as news. */
.cs-results--receipt .cs-result-num {
  font-size: var(--text-body);
  color: var(--cs-label);
}

.cs-results--receipt .cs-result-label {
  font-size: var(--text-meta);
}

@media (min-width: 641px) {
  .cs-results--receipt .cs-result-num { min-width: 12ch; }
}

.cs-impact--full { gap: 40px; }


.cs-reframe-sub {
  font-family: var(--font-ui);
  font-size: var(--text-lead);
  line-height: 1.5;
  color: var(--cs-label);
  max-width: 32em;
  margin: 0;
}


/* ═══ §8 — three problems, one root cause ══════════════════════════════════
   The section is the page's keystone and the old layout contradicted it:
   three equal bordered cards assert three separate problems with three
   separate fixes, which is the opposite of what the copy claims.

   A brace makes the claim visually. Three problems on the left, one brace,
   the cause on the right — the reader sees the reduction before reading it.

   Borderless throughout. The three problems are separated by 1px rules, not
   boxed; on a page measured at 63% bordered rectangle, another three cards
   here would be three too many.                                            */

.cs-root {
  display: grid;
  /* Mobile: problems, then a horizontal brace, then the cause. The brace turns
     a quarter turn rather than becoming a different mark. */
  grid-template-columns: 1fr;
  gap: 24px;
  align-items: center;
}

.cs-root-problems {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.cs-root-problem {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 16px;
  row-gap: 4px;
  padding-block: 20px;
  border-top: 1px solid var(--cs-rule);
}

.cs-root-problem:last-child { border-bottom: 1px solid var(--cs-rule); }

.cs-root-num {
  grid-row: 1 / span 2;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  color: var(--cs-label);
  padding-top: 4px;
}

.cs-root-name {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  margin: 0;
}

.cs-root-detail {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.45;
  color: var(--cs-label);
  margin: 0;
  max-width: 26em;
}

/* The brace. Two paths, one per orientation — see the markup note for why a
   rotated single element does not work here. */
.cs-root-brace {
  color: var(--cs-rule);
  justify-self: center;
}

.cs-root-brace--h {
  display: block;
  width: 100%;
  height: 32px;
}

.cs-root-brace--v { display: none; }

.cs-root-cause-key,
.cs-principles-key {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  margin-bottom: 12px;
}

/* The finding. The largest type in the section, because it is the thing the
   whole case study turns on and the page did not previously contain it. */
.cs-root-cause-line {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--cs-h2);
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--cs-fg);
  max-width: 12em;
  margin: 0 0 16px;
}

.cs-root-cause-sub {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.5;
  color: var(--cs-label);
  max-width: 30em;
  margin: 0;
}

/* The principles answer the root cause, not the individual problems, so they
   sit below the whole bracket under their own rule rather than nested inside
   each problem where they used to imply a one-to-one mapping. */
.cs-principles {
  border-top: 1px solid var(--cs-rule);
  padding-top: 24px;
}

.cs-principles-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.cs-principles-list li {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
}

@media (min-width: 768px) {
  .cs-root {
    /* problems | brace | cause */
    grid-template-columns: minmax(0, 1fr) 48px minmax(0, 1fr);
    gap: 40px;
  }
  .cs-root-brace--h { display: none; }
  .cs-root-brace--v {
    display: block;
    width: 48px;
    /* Stretch to the full height of the tallest cell, which is the problem
       list, so the brace always spans exactly the three it is bracketing. */
    align-self: stretch;
    height: auto;
  }
  .cs-principles-list {
    flex-direction: row;
    gap: 56px;
  }
}


/* ═══ §9 — the deep dive ═══════════════════════════════════════════════════
   Four workstreams that are four genuinely different kinds of object, and
   which used to be rendered as four identical card grids. Each now gets the
   shape its content actually has. Nothing in this section is a card.        */

/* ── The funnel strip ──────────────────────────────────────────────────────
   Not a chart, on purpose — the per-step numbers do not exist, and a drawn
   curve would be invented data. See the note in the markup.

   The steps read as a horizontal sequence that scrolls on narrow screens,
   with one step carrying the section's only real figure. */
.cs-funnel {
  list-style: none;
  margin: 0;
  padding: 0 0 8px;
  display: flex;
  gap: 0;
  overflow-x: auto;
  scrollbar-width: none;
  border-top: 1px solid var(--cs-rule);
}

.cs-funnel::-webkit-scrollbar { display: none; }

.cs-funnel-step {
  flex: 1 0 auto;
  min-width: 128px;
  padding: 16px 16px 20px 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  position: relative;
}

/* A tick at the top of each step, on the section rule. The steps are a
   sequence, and a tick is the least a sequence can be marked with. */
.cs-funnel-step::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 1px;
  height: 12px;
  background: var(--cs-rule);
}

.cs-funnel-name {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.3;
  color: var(--cs-label);
}

/* The one step with evidence behind it. Dark type and the figure beneath, so
   the eye lands on the only place in the strip that carries a number. */
.cs-funnel-step.is-spike .cs-funnel-name {
  color: var(--cs-fg);
  font-weight: 700;
}

.cs-funnel-step.is-spike::before {
  height: 24px;
  background: var(--cs-fg);
}

.cs-funnel-figure {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--text-ui);
  letter-spacing: -0.01em;
  color: var(--cs-fg);
}

/* ── Context spectrum ──────────────────────────────────────────────────────
   Five rules, poles labelled at each end. Five lines instead of five boxes,
   and it reads in about three seconds. */
.cs-spectrum {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.cs-spectrum-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: 8px;
}

.cs-spectrum-name {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-ui);
  letter-spacing: -0.01em;
  color: var(--cs-fg);
}

.cs-spectrum-pole {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  color: var(--cs-label);
}

.cs-spectrum-rule {
  display: block;
  height: 1px;
  background: var(--cs-rule);
  min-width: 40px;
}

@media (min-width: 641px) {
  .cs-spectrum-row {
    /* name | left pole | rule | right pole */
    grid-template-columns: 14em auto minmax(56px, 1fr) auto;
    align-items: center;
    gap: 16px;
  }
  .cs-spectrum-pole--end { text-align: right; }
}

/* ── Benchmarking as an index ──────────────────────────────────────────────
   Source left, lens right, a rule between rows. It is an index page and it
   should look like one. The two groups are separated so the cross-industry
   half stops being an eighth of a grid. */
.cs-index-group + .cs-index-group { margin-top: 40px; }

.cs-index-key {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  margin-bottom: 12px;
}

.cs-index {
  list-style: none;
  margin: 0;
  padding: 0;
}

.cs-index li {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px 24px;
  padding: 16px 0;
  border-top: 1px solid var(--cs-rule);
}

.cs-index li:last-child { border-bottom: 1px solid var(--cs-rule); }

/* No "In category" label above the first group any more, so nothing needs a
   rule under it -- every other row's border-top is what draws the line
   between it and the row before, but NiyoX has no row before it now. */
.cs-bench-board .cs-index-group:first-child .cs-index li:first-child {
  border-top: none;
}

.cs-index-name {
  font-family: var(--font-display);
  font-weight: 500;
  /* Matched to .cs-tab-name's size, on instruction: the segmented control
     above names the same three categories this list opens into, so the two
     read as one scale rather than the list stepping up a size. */
  font-size: calc(var(--text-body) - 4px);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
}

.cs-index-lens {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.4;
  color: var(--cs-label);
  text-align: right;
  /* justify-content: space-between only spaces items apart when they share a
     flex line. A long enough lens -- "Intent-based personalisation and
     time-setting" is the current case -- pushes onto a second line alone,
     and a lone item on a wrapped line has nothing to be spaced from -- it
     packs to the start, undoing text-align: right. margin-left: auto claims
     all the line's free space regardless of whether the lens is sharing a
     line with its name or wrapped onto its own. */
  margin-left: auto;
}

/* ── Benchmark previews ────────────────────────────────────────────────────
   Hovering a row shows that source's screen beside it.

   The no-overlap guarantee against the LIST is structural, not a tuned
   offset: the board is a two-track grid, the list owns track 1, and the
   preview is painted into track 2, which holds nothing else. There is no
   width at which an image can reach a row's text, because the text is in the
   other column.

   The no-overlap guarantee against the TAB LABELS above and the next
   section's heading below is not structural, and cannot be: every preview is
   now the same width and its own natural height, on instruction, which means
   Noom and Pediatric render at ~610 and ~560px tall (0.61 and 0.66 aspect)
   against Monzo's ~185px (2.02 aspect) -- a 3.3x spread with no shared
   ceiling to clamp it to any more, now that clamping is what caused the
   width mismatch this change exists to fix. So each row carries its own
   --bench-lift, measured from that row's own image height and its own
   position in the corridor between the tab labels and the next section's
   heading -- see the per-row values further down. A row added later needs
   its own measured value the same way; there is no longer a formula that
   derives one automatically.

   The aspects above moved once already: the source JPEGs used to carry a
   white matte with the card's shadow baked into it, and cropping that off
   (so the CSS shadow is the only one) made every portrait shot taller at a
   fixed width. Re-cropping an image means re-measuring its row's lift --
   Noom's went 296 -> 340px that way, having grown 63px with only 33px of
   slack under it.

   Each <img> still sits inside its own <li>, and the <li> is still the
   positioning context -- so the preview still tracks its own row's Y, just
   with a per-row distance now rather than one shared constant.            */
.cs-bench-board {
  --bench-shot: 380px;
  position: relative;
}

.cs-index li { position: relative; }

.cs-bench-shot {
  position: absolute;
  /* 50% is the row's own middle; --bench-lift (set per-<li> in the markup)
     is how far above that middle the image's top edge sits. Larger lift for
     a taller image and for a row further down the list -- both push the
     image's top edge higher so its greater height still clears the next
     section's heading; smaller lift for a short, early image, which has
     little room above it before the tab labels and does not need much. */
  top: calc(50% - var(--bench-lift, 80px));
  /* 100% is the row's right edge, which is the list track's right edge. */
  left: calc(100% + 40px);
  /* Width is fixed to Festival's own natural width, so every preview reserves
     the same horizontal footprint -- on instruction. Height is auto with no
     cap this time: the earlier 240px cap was what kept a portrait screenshot
     from reaching var(--bench-shot) at all (it hit the height ceiling first
     and came out narrower, letterboxed inside the box) -- exactly the
     "images aren't the same width" complaint this removes. object-fit and
     object-position are gone with it: without a second constraint the image
     fills its box exactly, so there is nothing left to fit or align. */
  width: var(--bench-shot);
  height: auto;
  border-radius: var(--cs-card-radius);
  /* box-shadow again, not drop-shadow: that swap existed only because a
     letterboxed image was casting a shadow around its own empty margin.
     There is no margin now -- the box is the photo -- so the ghost-frame
     problem it solved cannot recur, and the standard card shadow is simpler
     than the hand-tuned approximation it replaced. */
  box-shadow: var(--cs-card-shadow);
  opacity: 0;
  pointer-events: none;
  transform: scale(0.98);
  transition: opacity 0.25s ease, transform 0.25s ease;
}

.cs-index li:hover .cs-bench-shot {
  opacity: 1;
  transform: scale(1);
}

/* The reserved track only exists where there is width for it. Below this the
   list runs full width and the previews are not rendered at all -- there is no
   hover on a touch screen to drive them, and the lens beside each name already
   carries what the row is there to say. */
@media (min-width: 1025px) {
  .cs-bench-board {
    display: grid;
    grid-template-columns: minmax(0, 1fr) var(--bench-shot);
    /* Row gap 0: the groups already carry their own 40px separation, and grid
       auto-placement would otherwise put the second group in the reserved
       column instead of under the first. grid-column pins them both. */
    gap: 0 40px;
    align-items: start;
  }
  .cs-bench-board .cs-index-group { grid-column: 1; }
}

@media (max-width: 1024px) {
  .cs-bench-shot { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .cs-bench-shot { transition: opacity 0.25s ease; transform: none; }
  .cs-index li:hover .cs-bench-shot { transform: none; }
}


/* ── Personas ──────────────────────────────────────────────────────────────
   One portrait at a time, switched by a thumbnail pair in its top corner.

   The monogram treatment this replaces existed because "no stock photography"
   was a locked decision and a persona portrait would have broken it. Shriya
   supplied these two portraits directly, which retires that constraint for
   this component only -- the placeholder rule still stands everywhere else.

   Structure is what makes it degrade well: each persona's whole card lives in
   its own tabpanel and only the thumbnail row is positioned outside, over the
   photo. With no script nothing hides a panel, so the two cards stack and both
   read -- no absolute positioning to untangle and no overlap.             */
.cs-duo {
  --duo-gap:   16px;
  --duo-inset: 16px;
  --duo-thumb: 48px;
  position: relative;
}

.cs-duo-panel {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--duo-gap);
}

/* [hidden] is display:none in the UA sheet and any author display declaration
   outranks it -- the exact trap .cs-panel documents, one level down. */
.cs-duo-panel[hidden] { display: none !important; }
.cs-duo-panel:focus { outline: none; }

/* ── The portrait ───────────────────────────────────────────────────────── */
.cs-duo-figure {
  position: relative;
  margin: 0;
  overflow: hidden;
  min-height: 480px;
  min-width: 0;
  border-radius: var(--cs-card-radius);
  background: var(--cs-rule);
  /* The photo is the absolute layer; the two plates stay in normal flow and
     are pushed to the foot. Anchoring the quote with bottom: calc(... + the
     identity plate's height) would have meant hard-coding a height that
     changes with the column width and with how the money line wraps. */
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 8px;
  padding: var(--duo-inset);
}

.cs-duo-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Focal points, not arbitrary numbers: Suraj's face centres about 34% down his
   frame and Raj's about 39% down theirs. Keyed per photograph because a focal
   point belongs to the image, not to the component. */
#pp-suraj .cs-duo-photo, #pp-tab-suraj img { object-position: 50% 34%; }
#pp-raj   .cs-duo-photo, #pp-tab-raj   img { object-position: 50% 39%; }

/* ONE continuous scrim across the foot of the photo, identity and quote both
   inside it, rather than two separately-blurred plates with a gap between
   them. Two plates each running their own backdrop-filter left the strip of
   photo IN the gap sharp and un-blurred -- a visible seam where the reference
   reads as a single sheet of glass. Merging them into one element is the only
   way backdrop-filter can cover that seam, because it blurs whatever is
   directly behind ITS OWN box; two boxes can never blur the space between
   them.

   Still the same 56% + blur(24px) recipe as before -- only the boxing
   changed, not the glass. */
.cs-duo-scrim {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 0;
  padding: var(--duo-inset);
  border-radius: var(--cs-card-radius);
  background: color-mix(in srgb, var(--color-dark) 56%, transparent);
  backdrop-filter: blur(24px) saturate(140%);
  -webkit-backdrop-filter: blur(24px) saturate(140%);
  color: var(--color-cream);
}

.cs-duo-id {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* The one hairline left: between identity and quote, inside the shared scrim,
   rather than as a border running around two separate boxes. Same token
   (--cs-on-dark-rule) the old borders used. */
.cs-duo-quote {
  margin: 0;
  padding-top: 12px;
  border-top: 1px solid var(--cs-on-dark-rule);
  font-family: var(--font-display);
  /* Upright, not italic. Clash Display ships no italic file, so font-style
     here would be a synthesised oblique -- the same fault the weight rules
     exist to prevent. The quote marks already mark it as speech. */
  font-weight: 500;
  font-size: var(--text-meta);
  line-height: 1.4;
  letter-spacing: -0.01em;
}

.cs-duo-name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--text-body);
  line-height: 1.1;
  letter-spacing: -0.01em;
}

/* Role, list items and the quote all read at one size now -- --text-meta --
   rather than three different sizes across four elements with no hierarchy
   reason to differ. .cs-duo-name stays the one true heading in this plate;
   the card titles are headings too and get the same treatment, below. */
.cs-duo-role,
.cs-duo-meta li {
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.5;
  color: var(--cs-on-dark-label);
}

/* The three facts that used to run together in one middot-joined line, each
   now its own row with its own icon -- money, home, people -- which is what
   the reference does and what makes each fact scannable rather than parsed
   out of a sentence. */
.cs-duo-meta {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.cs-duo-meta li {
  display: flex;
  align-items: center;
  gap: 8px;
}

.cs-duo-meta-icon {
  width: 15px;
  height: 15px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* A touch under full opacity -- these sit on --cs-on-dark-label text
     (already 72% cream), and full-strength strokes read heavier than the
     type they are labelling. */
  opacity: 0.82;
}

/* ── The switch ─────────────────────────────────────────────────────────────
   Two thumbnails in the portrait's top corner. Full-size portraits were the
   control before this and the swap did not read as a swap: two images of the
   same weight look like a layout, not like a choice. At 48px they are
   unmistakably a picker, and the large frame is left to say who is selected. */
.cs-duo-switch {
  position: absolute;
  top: var(--duo-inset);
  left: var(--duo-inset);
  z-index: 2;
  display: flex;
  gap: 8px;
}

.cs-duo-thumb {
  appearance: none;
  padding: 0;
  /* A button does not inherit font-family -- same trap .cs-tab documents. */
  font: inherit;
  cursor: pointer;
  width: var(--duo-thumb);
  height: var(--duo-thumb);
  overflow: hidden;
  border: 1px solid var(--cs-on-dark-rule);
  border-radius: var(--cs-card-radius);
  background: var(--cs-rule);
  opacity: 0.55;
  transition: opacity 0.25s ease, box-shadow 0.25s ease;
}

.cs-duo-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.cs-duo-thumb:hover { opacity: 0.8; }

/* Ring via box-shadow, not outline: outline is the page's focus indicator and
   a selected thumbnail must not be indistinguishable from a focused one. */
.cs-duo-thumb[aria-selected="true"] {
  opacity: 1;
  box-shadow: 0 0 0 2px var(--color-cream);
}

.cs-duo-thumb-name {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* ── The detail cards ───────────────────────────────────────────────────── */
.cs-duo-cards {
  display: flex;
  flex-direction: column;
  gap: var(--duo-gap);
  min-width: 0;
}

/* The feedback that the text changed. Switching portraits swapped three cards
   of similar-looking copy and nothing announced it, so the change could pass
   unnoticed. Re-running on reveal is free: [hidden] takes the panel to
   display:none, and returning from that restarts the animation with no JS and
   no state to track. One movement for the column rather than a per-card
   stagger -- enough to register, not a performance. */
@keyframes cs-duo-swap {
  from { opacity: 0; transform: translateY(8px); }
}

.cs-duo-cards,
.cs-duo-figure { animation: cs-duo-swap 0.35s ease both; }

/* Surface comes from .cs-card; this owns only padding and internal layout.

   --duo-chip / --duo-chip-gap live HERE, not on .cs-duo-head: the head and the
   bullet list are siblings, and a custom property set on one sibling is
   invisible to the other -- it only inherits to descendants. Scoped to their
   shared parent, both the chip's own box and the bullets below can read the
   same two numbers, which is what keeps the title's left edge and the
   bullets' alignment locked together. */
.cs-duo-card {
  --duo-chip: 36px;
  --duo-chip-gap: 12px;
  padding: 24px;
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* --duo-chip / --duo-chip-gap tie the head row to the bullet alignment below:
   both the title's left edge and the bullets' dot-centre and text-start are
   DERIVED from these two numbers, so the three cannot drift out of step with
   each other again. */
.cs-duo-head {
  display: flex;
  align-items: center;
  gap: var(--duo-chip-gap, 12px);
  /* Down from 16px to match .cs-duo-list's own 12px gap -- one rhythm from
     the icon row through every bullet, not a bespoke number for this one
     gap. */
  margin-bottom: 12px;
}

/* The background box is gone -- on instruction, the icons alone carry the
   row now. Box model is UNCHANGED (still 36x36, centring a 20x20 svg) so nothing
   downstream shifts: the title's left edge and the bullets' alignment below
   are both measured off this box's width, not off where a background used to
   be painted. */
.cs-duo-chip {
  width: var(--duo-chip, 36px);
  height: var(--duo-chip, 36px);
  flex: none;
  display: grid;
  place-items: center;
}

.cs-duo-chip svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: var(--cs-fg);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.cs-duo-card-title {
  margin: 0;
  font-family: var(--font-display);
  /* Matched to .cs-duo-quote's weight on instruction -- was 400. Both are
     var(--font-display), so this is Clash Display's real medium file in both
     places, not a synthesis. */
  font-weight: 500;
  font-size: var(--text-lead);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
}

.cs-duo-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* Text starts at chip-width + chip-gap -- exactly where .cs-duo-card-title's
   text starts -- so a bullet reads flush with the heading above it instead of
   at its own, narrower indent. This was the actual bug: the dot's old
   left:0/padding-left:16 had no relationship to the head row's 36px chip and
   12px gap, so the two sat 32px apart. */
.cs-duo-list li {
  position: relative;
  padding-left: calc(var(--duo-chip, 36px) + var(--duo-chip-gap, 12px));
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.45;
  color: var(--cs-label);
}

/* The dot's centre sits under the chip's centre (half the chip width),
   rather than at the card's raw left edge -- so the marker reads as
   belonging to the icon above it, not to the card border. */
.cs-duo-list li::before {
  content: "";
  position: absolute;
  left: calc(var(--duo-chip, 36px) / 2 - 2px);
  top: 8px;
  width: 4px;
  height: 4px;
  border-radius: var(--radius-round);
  background: var(--cs-rule);
}

.cs-duo-list strong {
  font-weight: 700;
  color: var(--cs-fg);
}

.cs-persona-point {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: var(--text-body);
  line-height: 1.35;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  max-width: 26em;
  margin: 24px 0 0;
}

/* Side by side only from the page's desktop breakpoint. The card's own source
   stacked below 860px, and 43% of a tablet column is narrower still -- at
   800px it left the portrait 260px wide, which wrapped the money line to three
   lines and grew the plate over most of the frame. */
@media (min-width: 1025px) {
  .cs-duo-panel {
    grid-template-columns: minmax(0, 43fr) minmax(0, 57fr);
    align-items: stretch;
  }
}

@media (max-width: 640px) {
  .cs-duo { --duo-thumb: 44px; }
  .cs-duo-figure { min-height: 560px; }
  .cs-duo-card { padding: 20px; }
}

@media (prefers-reduced-motion: reduce) {
  .cs-duo-cards,
  .cs-duo-figure { animation: none; }
  .cs-duo-thumb { transition: none; }
}


/* ═══ §12 — one fix at full weight, five as a table ════════════════════════
   The six fixes are not six equal things. PAN carries the section's only
   number and is the case study's central pattern recurring at screen scale;
   the other five are described changes with no result attached. One big and
   five small is both the better rhythm and the more honest hierarchy.     */

.cs-fix {
  display: grid;
  grid-template-columns: 1fr;
  gap: 32px;
}

.cs-fix-shots {
  display: flex;
  gap: 16px;
}

.cs-fix-shots > * { flex: 1 1 0; min-width: 0; }

.cs-fix-story {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.cs-fix-title {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  margin: 0;
}

/* Three beats, each a label over its value on a rule. The sequence is the
   argument: what they said, what it was, what changed. */
.cs-fix-beats {
  margin: 0;
  display: flex;
  flex-direction: column;
}

.cs-fix-beat {
  padding: 16px 0;
  border-top: 1px solid var(--cs-rule);
}

.cs-fix-beat:last-child { border-bottom: 1px solid var(--cs-rule); }

.cs-fix-beat dt {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  margin-bottom: 4px;
}

.cs-fix-beat dd {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.45;
  color: var(--cs-fg);
  margin: 0;
  max-width: 28em;
}

/* The one number in the section, at the scale that says so. */
.cs-fix-figure {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--text-case);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--cs-fg);
  margin: 0;
}

/* The other five. Rows, not cards. */
.cs-fixes {
  list-style: none;
  margin: 0;
  padding: 0;
}

.cs-fixes-row {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 32px;
  padding: 20px 0;
  border-top: 1px solid var(--cs-rule);
}

.cs-fixes-row:last-child { border-bottom: 1px solid var(--cs-rule); }

.cs-fixes-name {
  flex: 0 0 auto;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
}

.cs-fixes-change {
  flex: 1 1 20em;
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.45;
  color: var(--cs-label);
  align-self: center;
}

.cs-fixes-tag {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 400;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  margin-left: 8px;
}

@media (min-width: 768px) {
  .cs-fix {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 56px;
    align-items: start;
  }
  .cs-fixes-name { flex: 0 0 14em; }
}


/* ═══ §13 — the objection ══════════════════════════════════════════════════
   The iteration captions that used to live here went with the markup they
   styled: §13's six screens carry their reasoning in a .cs-bubble each now,
   not in a caption under a column. See .cs-spec. */

/* ── The objection ────────────────────────────────────────────────────────
   A defence addressed to a reader about to object, so it gets its own ground
   rather than a tint. Deliberately short — it is an interruption, not a
   section, and it should feel like a page from a different document. */
.cs-objection {
  padding-block: calc(var(--cs-sec-gap) - 16px);
  gap: 24px;
}

.cs-objection-q {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: var(--cs-h2);
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--cs-fg);
  max-width: 14em;
  margin: 0;
}

.cs-objection-a {
  font-family: var(--font-ui);
  font-size: var(--text-lead);
  line-height: 1.55;
  color: var(--cs-label);
  max-width: 34em;
  margin: 0;
}


/* ═══ §6 — the finding ═════════════════════════════════════════════════════
   The section previously ended on three empty quote shells and then §7 opened
   with "That evidence went to the CEO" — the hunch was stated and tested and
   the result was never given. This is the result, at the weight a result
   deserves.                                                                */

.cs-finding {
  border-top: 1px solid var(--cs-fg);
  padding-top: 24px;
}

.cs-finding-key {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  margin-bottom: 12px;
}

.cs-finding-line {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: var(--cs-h2);
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--cs-fg);
  max-width: 18em;
  margin: 0;
}


/* ═══ What did I do? ═══════════════════════════════════════════════════════
   A real heading, deliberately bigger than every other section title on the
   page — .cs-h2 sizes this fold's headings up to 56px; this one runs up to
   108px, reusing --text-section (the scale the landing page's own big work
   titles use) rather than inventing a new number. On direct instruction: a
   four-second skim item earns a heading this size only because it was asked
   for, not as a judgement call made here.

   Previously larger than the other headers on instruction; now inherits .cs-h2 entirely. */
.cs-did-heading { /* inherits .cs-h2 size, like every other case-study header */ }

/* Hidden for now, at Shriya's request — not deleted, since she wants it back.
   ?did in the URL brings it back, matching the ?notes / ?dial pattern
   case-study.js already uses for exactly this kind of "on, but not by
   default" content. Hidden in CSS rather than only in JS, so it stays hidden
   even if the script never runs — the safe default for something someone
   asked to have turned off is off, not "off unless something fails". */
.cs-did-sec { display: none; }
body.cs-show-did .cs-did-sec { display: flex; }

/* Pills, not the bordered tags an earlier build of this section used (see the
   markup comment for that history). Genuinely pill-shaped: border-radius is
   a literal 999px here, not var(--radius-round) — that token is reserved by
   CLAUDE.md for three specific floating circular icon buttons and its own
   rule says not to extend it, so a fourth, unrelated use gets its own literal
   value instead of quietly breaking that rule. Documented as the fifth
   exception to the two-radius system, in CLAUDE.md, at Shriya's explicit
   request.

   .cs-pills shares this declaration rather than repeating it: §16's three
   scope tags (Scale for 3-6 products / Dynamic state-handling / First fold
   optimisation) want the exact same pill — border, padding, radius, type —
   just under a name that does not carry "what did I do?"'s meaning. One
   recipe, two call sites, so a future change to the shape only has one place
   to happen. */
.cs-did,
.cs-pills {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.cs-did li,
.cs-pills li {
  padding: 12px 24px;
  border: 1px solid var(--cs-rule);
  border-radius: 999px;
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.2;
  color: var(--cs-fg);
}


/* ═══ §6 — the turn ════════════════════════════════════════════════════════
   The pivot the case study turns on: what the company asked for, against what
   she thought was actually wrong. Three equal cards gave the ask and the
   hunch identical weight, which is the exact claim the section argues against.

   So the layout disagrees with itself. The ask is set plainly at UI scale;
   the hunch answers it at heading scale. Both hang off the same rule, and the
   difference in size is the argument.                                      */

.cs-turn {
  display: grid;
  grid-template-columns: 1fr;
  gap: 32px;
  border-top: 1px solid var(--cs-fg);
  padding-top: 24px;
}

.cs-turn-half {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.cs-turn-key {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

.cs-turn-ask {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.5;
  color: var(--cs-label);
  max-width: 22em;
  margin: 0;
}

.cs-turn-hunch {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: var(--text-body);
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  max-width: 22em;
  margin: 0;
}

/* One line on its own, because three days is the whole move. */
.cs-turn-test {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.5;
  color: var(--cs-label);
  margin: 0;
}

@media (min-width: 768px) {
  .cs-turn {
    /* The hunch gets the wider column — it is the half that matters. */
    grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
    gap: 56px;
  }
}


/* ─── Pinned bands keep their full bleed ───────────────────────────────────
   ScrollTrigger's pin does two things that break .cs-band's full-bleed:

     1. it moves the element into a .pin-spacer and transfers the negative
        inline margins onto that spacer, and
     2. it writes an inline px width taken from the element's content box
        *before* pinning, then sets position: fixed.

   Measured at 1440: the band came out 1121px wide with margin 0, leaving a
   319px cream gutter down the right of a section that is supposed to reach
   both edges. It read as a rendering fault rather than as a band.

   The override wins because a stylesheet !important outranks GSAP's inline
   width. 100% rather than 100vw deliberately: for a fixed element it resolves
   against the viewport, and for the relative state at either end of the pin it
   resolves against the spacer — which already carries the negative margins and
   is therefore already full-bleed. One rule, correct in both states, and no
   scrollbar-width error of the kind 100vw introduces.

   Scoped to .pin-spacer so an unpinned band is untouched.                  */
.pin-spacer > .cs-band {
  width: 100% !important;
  /* GSAP writes max-width alongside width, so overriding width alone is not
     enough — measured: width resolved to 1440 and was then clamped straight
     back to 1121 by the inline max-width. Both have to go. */
  max-width: none !important;
  margin-inline: 0 !important;
}


/* ─── Focus ───────────────────────────────────────────────────────────────
   The page's four focusable things were falling back to Chrome's default
   focus ring — a 1px auto outline in rgb(0, 95, 204). It is visible, so this
   is not a compliance fix, but it is browser blue on a page with a four
   colour palette, and on the bar's dark state it is the lowest-contrast
   thing on screen.

   style.css defines a house ring for the landing nav (2px solid, 4px offset)
   and this page inherits none of it, because those rules are scoped to
   .nav-link and .nav-logo. Same recipe, restated against this page's own
   foreground token so it flips with the ground the way everything else here
   does — dark on cream, cream on a dark band.

   :focus-visible, not :focus, so a mouse click on the notes toggle does not
   leave a ring behind it. */
.cs-page :focus-visible {
  outline: 2px solid var(--cs-fg);
  outline-offset: 4px;
  border-radius: var(--radius);
}

/* The bar sits outside any band, so it takes the on-dark foreground from the
   same token pair the rest of its dark state uses. */
.cs-page[data-cs-bar="dark"] .cs-bar :focus-visible,
.cs-page[data-cs-bar="green"] .cs-bar :focus-visible {
  outline-color: var(--color-cream);
}


/* ─── Skip link ───────────────────────────────────────────────────────────
   Off-screen until focused, then it lands in the bar's own left slot rather
   than floating over the hero. Clipped rather than display:none so it stays
   in the tab order — a skip link nobody can reach is worse than none.       */
.cs-skip {
  position: fixed;
  top: 8px;
  left: 8px;
  z-index: 200;
  transform: translateY(-200%);
  /* 44px touch box like the other controls; padding alone came to 40. */
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 12px 16px;
  background: var(--color-dark);
  color: var(--color-cream);
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  text-decoration: none;
  border-radius: var(--radius);
  transition: transform 0.2s ease;
}

.cs-skip:focus-visible { transform: translateY(0); }

@media (prefers-reduced-motion: reduce) {
  .cs-skip { transition: none; }
}

/* The exit card is an <a>, so without an explicit colour any text node added
   straight to it would render in the UA default link blue. Every descendant
   sets its own colour today, so nothing paints wrong — this is here so the
   next edit to the card cannot introduce browser blue by accident. */
.cs-next { color: var(--cs-fg); }


/* ═══ §4 — The ask, the signals, the evidence, the takeaway ════════════════

   ── The scale this section is held to ──────────────────────────────────────
   Every text element here resolves to one of FOUR sizes and THREE weights, and
   nothing in this section may add a fifth or a fourth:

     --text-label  700  eyebrows ("The ask", "What people actually said",
                        "The takeaway") and the method tag
                   400  the "20 conversations" figure
     --text-ui     400  the subhead / the attribution
     --text-lead   400  the verbatim  /  500  the takeaway line
     --cs-h2       500  the section heading

   Four, not five, because the fifth was fake: --text-meta's clamp is within
   0.02px of --text-ui's below ~1620px. Two tokens, one rendered size, no step.

   Anything doing an eyebrow's job takes the .cs-eyebrow CLASS, not a lookalike
   recipe — that is what let the evidence label drift to 0.1em tracking against
   the eyebrow's 0.08em. The method tag repeats the eyebrow's numbers; only ink
   differs.

   ── Spacing ────────────────────────────────────────────────────────────────
   Only 4 / 8 / 12 / 16 / 24 / 48 appear below.

   ── What was cut ───────────────────────────────────────────────────────────
   The full-width divider and its two meta figures, the coral mark and accent
   on the takeaway, and — this pass — the two-signal stats row that sat between
   the subhead and the evidence. That takes the section from four accent uses
   to one (the green method tag): heading -> subhead -> evidence -> conclusion,
   nothing competing across it.                                             */

.cs-page {
  /* The method tag. Green, as the reference has it, but derived rather than
     picked: the ink is --color-green pulled 45% toward --color-dark so it
     clears the contrast floor (5.3:1 on the chip), which the raw accent never
     would — CLAUDE.md records it at 2.7:1 and bars it as type. */
  --ask-chip-fg: color-mix(in srgb, var(--color-green) 55%, var(--color-dark));
  --ask-chip-bg: color-mix(in srgb, var(--color-green) 12%, var(--color-white));

  /* One measure for the heading, the subhead AND the takeaway line — all
     three end at the same right edge, on instruction. 54rem (864px) is the
     prologue heading's own measure at desktop (.cs-h2's 18em resolves to
     ~863px there), so §4's opening reads at the same width as §3's rather than
     the tighter column it had. A rem value, not each element's own em: an em
     measure resolves against font-size, so the same "18em" on the h2 and on
     the Geist subhead would be two different widths — which is the bug this
     token exists to close. */
  --ask-measure: min(100%, 54rem);
}

/* The takeaway is no longer in this list: it is a .cs-bubble now, and a bubble
   hugs its own text rather than ruling off at a shared measure. */
.cs-ask .cs-h2,
.cs-ask .cs-sub { max-width: var(--ask-measure); }

/* The takeaway bubble ends exactly where the third card ends, on instruction —
   which, now that the evidence block no longer bleeds, is simply the section's
   right edge. The cards grid fills the column, so its last card and the bubble
   land on the same number with nothing to keep in step.

   The third card is rotated 1.5deg, so its *visual* corner reaches a couple of
   px further; the alignment is to the card's layout edge, which is the one the
   eye reads as the column's edge.

   Placement, not a component property, so it lives here rather than on
   .cs-bubble. */
.cs-ask .cs-bubble { margin-left: auto; }

/* A section subhead set in body copy rather than the display face — the same
   Geist / --text-ui / label-grey as the running prose elsewhere on the page.
   Used where a section opens on a heading and a supporting paragraph, not a
   display-weight lede (§4, §5 and §16 so far). Sits on .cs-lede in the
   markup, so it inherits that element's margin and is overridden only where
   it needs to differ; being later in the file, it wins the type properties.

   §4 additionally caps this at --ask-measure via the selector above — a
   one-off, so its opening matches §3's. Everywhere else it takes the 40em
   below, a normal paragraph measure at this size. */
.cs-sub {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  font-weight: 400;
  line-height: 1.55;
  /* .cs-lede sets -0.01em for the display face it normally wears; this is
     Geist, which tracks normal like the rest of the body copy. */
  letter-spacing: normal;
  color: var(--cs-label);
  max-width: 40em;
}

/* The bolded clause inside one. Weight and ink, no colour of its own — the
   emphasis is on a phrase inside running copy, not a label, so it takes the
   same treatment .cs-meta-key does: 700 at --cs-label-strong, which is
   defined for the light ground and redefined by every dark one. */
.cs-sub strong {
  font-weight: 700;
  color: var(--cs-label-strong);
}

/* A subhead that wraps on the same right edge as the .cs-h2 above it rather
   than on its own 40em measure. 40em of 14px body copy is ~560px against the
   heading's 18em of --cs-h2 (863px at 1440), so by default the two ragged
   edges sit 300px apart and read as a mistake in a section that opens on
   both.

   Written as 18 x the HEADING's font size, not as a figure in this element's
   own ems — that is what makes it the same measure rather than a number that
   happens to match at one width. --cs-h2 is a clamp(), so this tracks its
   whole fluid curve on its own. */
.cs-sub--wide { max-width: calc(18 * var(--cs-h2)); }

/* ── The evidence ────────────────────────────────────────────────────────
   No container ground any more — removed on instruction. The cards sit
   straight on the page, tilted, so the head row and the grid both align to
   the section's own column and there is nothing to keep them apart. */
.cs-voices {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

/* The "What people actually said / 20 conversations" head is gone on
   instruction — the three badged cards introduce themselves, so the section
   runs subhead straight into evidence with nothing announcing it. */

.cs-voices-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 16px;
}

/* One card per method, on the shared .cs-card surface (markup) — same white /
   --radius / soft-shadow as §2's info card and §3's screens. */
.cs-voice {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 24px;
  min-width: 0;
  position: relative;
}

/* The method tag.

   Its corner is HALF the card's, which is what makes the two read as the same
   corner treatment. They were both a literal 8px and still looked mismatched,
   and the measurement says why: 8px is 32% of this tag's 25px height but only
   4% of the card's 227px. Identical numbers, two different-looking corners.
   Scaling the radius with the element is what matches them to the eye — and it
   stays tied to the card's own token, so the pair cannot drift.

   Type is the eyebrow recipe exactly (--text-label / 700 / 0.08em); only the
   ink and the ground differ. It hugs its text: align-self covers the flex
   layout below 1024, justify-self the subgrid layout at and above it, where a
   grid item would otherwise stretch the full column. */
.cs-voice-method {
  align-self: flex-start;
  justify-self: start;
  padding: 4px 12px;
  border-radius: calc(var(--cs-card-radius) / 2);
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 700;
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ask-chip-fg);
  background: var(--ask-chip-bg);
}

/* The verbatim — the only human voice in the case study, set in the body face
   rather than the display one: it is someone being quoted mid-sentence, not a
   heading, and italic is how running prose marks that everywhere else. Real
   italic (Geist's own 400i, loaded in jupiter.html), not a synthesised slant —
   compared side by side against the browser's faux-oblique and kept the real
   face. --text-lead, the section's statement size, shared with the takeaway
   line. Straight ASCII quote marks, per CLAUDE.md. */
.cs-voice-quote {
  font-family: var(--font-ui);
  font-style: italic;
  font-weight: 400;
  font-size: var(--text-lead);
  line-height: 1.35;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  margin: 0;
  /* Pushes the attribution to the card's bottom edge in the flex fallback. */
  flex: 1;
}

/* Who said it, on a hairline. The rule sits at the top of the card's third
   subgrid row, so it lands level across all three cards whatever the quote
   above it did.

   Small and quiet at --text-label, but sentence case, on instruction — and
   with tracking back to normal, because 0.08em is a device for uppercase
   labels and only makes running words look loose. */
.cs-voice-who {
  border-top: 1px solid var(--cs-rule);
  padding-top: 16px;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 400;
  line-height: 1.5;
  letter-spacing: normal;
  color: var(--cs-label);
}

/* Three up from 1024. At 768 three columns came out 159px wide (measured) —
   too narrow for a quote in the display face — so below this they stack full
   width.

   The evidence block used to hang --ask-bleed (up to 48px, 36px at 1440) past
   the section column on each side so the cards gained width. Removed: it made
   §4 the only plain section on the page whose edges did not line up with the
   rest, which reads as a mistake rather than as emphasis. At the plain column
   the three cards are ~301px each, well clear of the 159px that forced the
   stack below this breakpoint, so the width the bleed was buying was never
   the thing keeping them readable. */
@media (min-width: 1024px) {

  /* Three separated columns. The cards were briefly overlapped into a dealt
     stack; reverted on instruction, so they sit apart on a plain 24px gap
     again and the tilt alone carries the scattered read. */
  .cs-voices-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }

  /* Tilt, so the set reads as a pile of notes rather than a filed row.

     A symmetric fan — one out, one straight, one out the other way — not the
     three unrelated angles this had before (-1.4 / 1.1 / -0.7), which read as
     jitter rather than as a composition. The centre card being true is what
     makes the outer two look placed. */
  .cs-voice:nth-child(1) { transform: rotate(-1.5deg); }
  .cs-voice:nth-child(3) { transform: rotate(1.5deg); }

  @media (hover: hover) {
    .cs-voice { transition: transform 0.28s ease; }
    .cs-voice:hover {
      transform: rotate(0) scale(1.02);
      z-index: 1;
    }
  }
}

/* The tilt is a static state and stays, but the straighten-on-hover loses its
   transition when the reader has asked for less movement. */
@media (prefers-reduced-motion: reduce) {
  .cs-voice { transition: none; }
}

/* Line the three cards up row by row — badge, quote, attribution — not just at
   their outer edges. The middle byline wraps to two lines where the others
   take one, and flex alone left its rule 24px high (measured). Subgrid hands
   every card the same three parent rows. Guarded, and only where the cards are
   side by side; without subgrid the flex layout above stands, uneven rules and
   all. */
@supports (grid-template-rows: subgrid) {
  @media (min-width: 1024px) {
    .cs-voices-grid { grid-template-rows: auto 1fr auto; }

    .cs-voice {
      display: grid;
      grid-row: span 3;
      grid-template-rows: subgrid;
      gap: 16px;
    }
  }
}

/* ── The takeaway ─────────────────────────────────────────────────────────
   The conclusion the three verbatims add up to.

   .cs-takeaway / -line / -hi lived here — an eyebrow over a plain-ink line
   ruled off at --ask-measure, with its closing clause in spacefire. It is a
   .cs-bubble now (see the component near .cs-card), which says the same thing
   structurally that the copy says: the conclusion came out of the cards, so it
   hangs off a cursor pointing at them. The type recipe moved to
   .cs-bubble-line unchanged; only the ink and the measure differ. */

/* .cs-widen — the three-step "conversion optimisation -> wider experience ->
   company-level priority" progression set in growing type — was removed on
   instruction. §5's subhead now carries that arc in prose. */

/* ═══ §3 — The prologue's annotated then/now ══════════════════════════════
   Two real product shots on a dot field, with four annotations hung at the
   corners and a leader line running from each toward the stage.

   ── No frame around the visual ─────────────────────────────────────────────
   Deliberate, per direct instruction. Both PNGs are full opaque phone screens,
   so a container with a tinted ground and a radius would be drawing a second
   card around a card. The corner and shadow come from the --cs-card-* tokens
   on the <img> itself; the dot field is the only ground.

   ── The two screens are deliberately different sizes ───────────────────────
   The "now" screen renders --cs-screen-step (1.2x) larger than the "then" one,
   on instruction: the size jump is the argument, a bigger, denser product
   grown out of a small one. --pro-card-w is the "then" width; the "now" rule
   multiplies it. The stage bottom-aligns them so they stand on one baseline
   and the "now" screen visibly reaches higher.                              */

.cs-page {
  /* --pro-card-w is the "then" (smaller) screen's width. The "now" screen is
     --cs-screen-step times this. Sized so that even at 1.2x on the widest
     screen the four annotation columns still hold their copy in three lines
     inside the bled-wide figure. Measured at 1024 / 1440 / 1880. */
  --pro-card-w:    clamp(156px, 15vw, 224px);
  /* Base 32 is the vertical gap in the < 640 stacked layout and the horizontal
     gap from 640-1024. The corner block bumps it to 56 (+24 on instruction) for
     the main desktop view, where the extra air between the two screens reads as
     the distance the product travelled. */
  --pro-stage-gap: 32px;
  --pro-lead-w:    32px;
  --pro-lead-gap:  12px;

  /* The two screens are bottom-aligned and different heights, so the shorter
     "then" screen's TOP edge sits this far below the taller "now" screen's.
     The top-left annotation is dropped by the same amount (corner layout only)
     so it runs alongside its screen instead of floating in the gap above it.
     = height difference = (now - then) width delta x the 728:1576 aspect. */
  --pro-then-offset: calc(
    (var(--cs-screen-step) - 1) * var(--pro-card-w) * 1576 / 728
  );

  /* The halo, the hover step and the field weight are shared with §10-20's
     before/after pairs — see --cs-screen-* in the token block at the top. */
}

/* Base is the narrow layout: the stage on top, the four annotations stacked
   under it in reading order. The corner composition only switches on when
   there is room for it — see the min-width block below. */
.cs-pro-fig {
  position: relative;
  display: grid;
  gap: 32px;
  padding-block: 24px;
}

/* Decorative ground. Pattern comes from the shared rule in style.css; only the
   box, the fade and the weight are set here. Inset a small fixed amount past
   the figure on every side — enough for the radial mask to have faded the dots
   to nothing before this box ends, so nothing visible is lost, but small
   enough that the bled-wide figure never pushes this past the viewport edge.
   (It was -6% horizontally, which at the widest bleed put it ~60px beyond the
   figure and off-screen at 1024.) */
.cs-pro-dots {
  position: absolute;
  inset: -24px;
  opacity: var(--cs-screen-dots);
  /* The fade the brief asks for: solid through the middle, gone by the outer
     edge, so the field has no border of its own and the shots stop looking
     like they are hanging in empty space. Prefixed copy first for older
     WebKit, which still needs it for mask-image. */
  -webkit-mask-image: radial-gradient(ellipse 62% 58% at 50% 50%, #000 35%, transparent 78%);
          mask-image: radial-gradient(ellipse 62% 58% at 50% 50%, #000 35%, transparent 78%);
  z-index: 0;
}

/* Everything real paints above the field. */
.cs-pro-annot,
.cs-pro-stage { position: relative; z-index: 1; }

.cs-pro-stage {
  display: flex;
  /* Column below 640: side by side, the two screens (156 + 187 + gap) are
     wider than a 375px phone's content box and would force a horizontal
     scrollbar. Stacked, each gets full width. Switches to a row at 640, where
     the annotation grid also pairs up and there is room. */
  flex-direction: column;
  align-items: center;
  gap: var(--pro-stage-gap);
}

@media (min-width: 640px) {
  .cs-pro-stage {
    flex-direction: row;
    /* end, not center: the "now" screen is 1.2x the "then" one, so bottom-
       aligning them stands both on one baseline and lets the taller screen
       reach up — the size difference reads as growth rather than as two things
       floated at different heights. */
    align-items: end;
    justify-content: center;
  }
}

.cs-pro-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  margin: 0;
  /* So the hover z-index bump below has something to apply to. */
  position: relative;
  /* The slide-apart lands here. Naming the property lets the compositor keep
     both cards on their own layer for the one transform that matters, and
     costs nothing when the motion never runs. */
  will-change: transform;
}

/* The screens themselves. These are opaque full-bleed phone renders, so the
   corner and the shadow have to come from us — without them a 728x1576
   rectangle just sits on the page as a rectangle, and its edges would read
   softly against the cream ground.

   Surface comes from the shared --cs-card-* tokens rather than a bespoke
   recipe, so these read as the same family of object as §2's info card. This
   is that token set's second consumer, which is what it was hoisted for.

   Width is set on the two modifiers below, not here — the "then" and "now"
   screens are deliberately different sizes. Both stay at full opacity.

   DIRECT CHILD, not any descendant. The prologue's own cards hold exactly one
   <img> each, so `> img` selects precisely what `img` did here — but §10-20's
   cards, which reuse these classes, hold interactive screens built from a
   stack of layered <img>s (Error handling alone has ten). As a descendant
   selector this forced every one of those layers to the full card width,
   height:auto, a radius, a card shadow and transform-origin: bottom center,
   and the hover rule below then scaled them against the motion layer's own
   transforms. The screen-level surface for those sections comes from
   .cs-shot-img and the .cs-pro-card--then/--now rules further down instead. */
.cs-pro-card > img,
.cs-pro-card > video {
  display: block;
  height: auto;
  border-radius: var(--cs-card-radius);
  /* Same family as §2's info card, but the screen-weight shadow — these are
     phone captures on a near-page-coloured ground, like every other screen on
     this page, and "then" would otherwise sit flatter than the "now" beside
     it. */
  box-shadow: var(--cs-screen-shadow);
  /* The hover lift lives on the image, NOT on .cs-pro-card. The card element
     is where the entrance timeline writes its own transform, and an inline
     GSAP transform outranks anything a stylesheet says — put both on one
     element and the hover silently stops working the moment the animation has
     run. Two elements, two independent transform stacks, no conflict. */
  transform-origin: bottom center;
}

/* "Then" is the smaller, earlier screen; "now" is 1.2x it. */
.cs-pro-card--then > img,
.cs-pro-card--then > video { width: var(--pro-card-w); }

/* "Now" is larger, and carries the warm halo — the neutral card shadow plus a
   coral-tinted glow layer, so the screen the story lands on is quietly
   celebrated without a fill or any motion. */
.cs-pro-card--now > img,
.cs-pro-card--now > video {
  width: calc(var(--pro-card-w) * var(--cs-screen-step));
  box-shadow: var(--cs-screen-shadow), var(--cs-screen-glow);
}

/* Pointer devices only: on touch there is no hover state to leave, so a screen
   would grow on tap and stay grown.

   Hover alone, with no :focus-within twin. Nothing inside a card is focusable —
   they are images, not controls, and giving them a tabindex to earn a keyboard
   equivalent would put a stop on the tab order that leads nowhere. The scale
   carries no information the composition does not already carry, so a keyboard
   reader loses nothing by not receiving it. */
@media (hover: hover) {
  .cs-pro-card > img, .cs-pro-card > video { transition: transform 0.3s ease; }

  .cs-pro-card:hover > img, .cs-pro-card:hover > video { transform: scale(var(--cs-screen-hover)); }

  /* A growing screen has to sit above its neighbour, or the extra 10% slides
     under it at the inner edge. */
  .cs-pro-card:hover { z-index: 2; }
}

/* Decoration, not affordance: it says nothing the layout does not already say,
   so it goes when the reader asks for less movement. Last in the file on
   purpose — it has to outrank the (hover: hover) block above when both match. */
@media (prefers-reduced-motion: reduce) {
  .cs-pro-card > img, .cs-pro-card > video { transition: none; }
  .cs-pro-card:hover > img, .cs-pro-card:hover > video { transform: none; }
}

.cs-pro-annot {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Earlier / Now / Audience. Same family, size and tracking as the §2
   meta-card keys, but weight 700 and --cs-label-strong — on instruction they
   now out-rank the annotation body they sit above, the same treatment the
   eyebrows carry. */
.cs-pro-key {
  font-family: var(--font-ui);
  font-size: var(--meta-key-size);
  font-weight: 700;
  line-height: 1.4;
  letter-spacing: var(--meta-key-track);
  text-transform: uppercase;
  color: var(--cs-label-strong);
}

/* Same size and colour as .cs-aside-note, the page's other small annotation
   voice, so the whole document has one scale for "text about the work". */
.cs-pro-text {
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.55;
  color: var(--cs-label);
  margin: 0;
  max-width: 28em;
}

/* The leader lines are hidden in the stacked layout: a line pointing sideways
   at nothing is noise, and there is no spatial relationship for it to describe
   until the corner composition switches on. */
.cs-pro-lead { display: none; }

@media (min-width: 640px) {
  /* Halfway house — still no corner composition, but the four annotations pair
     up so then/now read as two columns rather than one long list. */
  .cs-pro-fig { grid-template-columns: 1fr 1fr; }
  .cs-pro-stage { grid-column: 1 / -1; }
}

@media (min-width: 1024px) {
  /* --pro-lead-w stays at its base 32 here — an earlier 40 cost the annotation
     columns 16px they need at the 1024 end for the three-line wrap.
     --pro-stage-gap is 56 (was 32; +24 on instruction). */
  .cs-page { --pro-stage-gap: 56px; }

  /* The composition proper: annotations at the four corners, stage in the
     middle spanning both rows. Placed by area, so the DOM can stay in reading
     order (then / then-audience / stage / now / now-audience) while the
     picture puts the stage between them. */
  .cs-pro-fig {
    grid-template-columns: 1fr auto 1fr;
    grid-template-rows: auto auto;
    grid-template-areas:
      "then     stage now"
      "then-aud stage now-aud";
    align-items: start;
    /* The column gap has to hold the whole leader line plus clear air at both
       ends, or the line runs out of the gap and lands on top of the card it is
       pointing at — measured, it overshot by exactly --pro-lead-gap before this
       was tied to the same two tokens the line itself uses. */
    gap: 48px calc(var(--pro-lead-w) + var(--pro-lead-gap) * 2);
    padding-block: 40px;

    /* The one place on the page allowed past the section's 950px column, on
       instruction: the bigger screens plus four annotation columns that each
       hold their copy in three lines do not fit inside it. Symmetric negative
       margins widen the grid without moving its centre.

       Sized off --cs-margin (the .cs side padding) rather than a raw vw, so it
       tracks the real room outside the section at every width and always stops
       40px short of the .cs content edge — 24px of which is the dot field's
       own overhang, leaving a true 16px gutter before the viewport. Capped at
       200 so it does not run away on a very wide screen. Verified no
       horizontal scroll 1024 → 1920. */
    --pro-bleed: min(calc(var(--cs-margin) - 40px), 200px);
    margin-inline: calc(-1 * var(--pro-bleed));
  }

  .cs-pro-annot--then     { grid-area: then; }
  .cs-pro-annot--then-aud { grid-area: then-aud; align-self: end; }
  .cs-pro-stage           { grid-area: stage; align-self: center; }
  .cs-pro-annot--now      { grid-area: now; }
  .cs-pro-annot--now-aud  { grid-area: now-aud; align-self: end; }

  /* Drop the top-left annotation down to meet its screen. The "then" screen is
     shorter and bottom-aligned, so its top edge is --pro-then-offset below the
     "now" screen's; without this the "Earlier" block floats in the empty band
     above it. translateY, not margin, so the grid row does not grow and push
     the stage off its own centre. GSAP's entrance adds a relative -12px rise on
     top of this (y: '+=12'), so the offset survives the animation. */
  .cs-pro-annot--then { transform: translateY(var(--pro-then-offset)); }

  /* Left pair reads inward: text ranged right, so its ragged edge faces away
     from the stage and its hard edge faces the leader line. */
  .cs-pro-annot--then,
  .cs-pro-annot--then-aud {
    align-items: flex-end;
    text-align: right;
  }

  /* Let the grid column set the measure — with the figure bled wider, each 1fr
     column is now broad enough to hold the longest of the four texts in three
     lines. A cap here would just re-introduce the four-line wrap the bleed was
     added to fix. Verified by counting rendered lines at 1024 / 1440 / 1880. */
  .cs-pro-text { max-width: none; }

  /* The leader line. Absolutely positioned just outside the annotation's inner
     edge and sitting on the key's own centre line, so it reads as running from
     the key to the stage. A hairline, so its 1px is in the rule family rather
     than the multiple-of-4 spacing grid. */
  .cs-pro-lead {
    display: block;
    position: absolute;
    top: 0.7em;
    width: var(--pro-lead-w);
    height: 1px;
    background: var(--cs-rule);
  }

  /* Origins point away from the stage, so the draw-in grows toward the cards
     rather than retreating from them. */
  .cs-pro-annot--then .cs-pro-lead,
  .cs-pro-annot--then-aud .cs-pro-lead {
    left: 100%;
    margin-left: var(--pro-lead-gap);
    transform-origin: left center;
  }

  .cs-pro-annot--now .cs-pro-lead,
  .cs-pro-annot--now-aud .cs-pro-lead {
    right: 100%;
    margin-right: var(--pro-lead-gap);
    transform-origin: right center;
  }
}


/* ═══ §10-20's screens, reusing the prologue's own layout ═══════════════════
   All nine before/after (and one solo) screen sections now sit inside a
   .cs-pro-fig exactly like the prologue's — same dot field, same
   corner-annotation grid at 1024, same leader lines. Nothing below is a new
   component: it is either the prologue's own selectors reaching new markup,
   or a width formula copied from the prologue's so a screen and the
   prologue's own cards resolve to the identical pixel width at every
   viewport, not merely the same ratio. .cs-pro-fig, .cs-pro-dots,
   .cs-pro-stage, .cs-pro-annot, .cs-pro-key, .cs-pro-text and .cs-pro-lead
   are all untouched above -- this block only adds what the prologue itself
   never needed: sizing its cards off --pro-card-w for content that is not a
   bare <img> (the interactive .cs-shot-live screens, and a still-missing
   .cs-ph--phone placeholder), and a --solo variant (Process Map) for the one
   section with a single screen and no "before" to build a 3-area corner
   composition from.

   The old --cs-screen-w-based sizing on .cs-shot--before/--after is left
   alone above rather than deleted: none of these nine sections reads it any
   more (none carries .cs-shot/.cs-shot--before/.cs-shot--after at all), but
   Iterations' .cs-ph--phone and the shared .cs-shot-img base rule still do —
   and Process Map, solo though it is, opts back INTO --cs-screen-w below
   rather than the prologue's --pro-card-w, on instruction: it was never a
   before/after pair, so nothing here needs to match a partner screen's
   width. */

/* Width only -- .cs-shot-img already carries the aspect-ratio, radius and
   base shadow (identical tokens to the prologue's own cards: --radius ==
   --cs-card-radius, --cs-screen-shadow either way), so re-declaring those
   here would just repeat them. The selector reaches three different things
   with one declaration: a plain <img>, the .cs-shot-live interactive div
   (Mobile number, Permissions, Error handling), and .cs-ph--phone (Loaders'
   still-missing "after" asset) -- all three already carry .cs-shot-img or
   .cs-ph--phone today. */
.cs-pro-card--then .cs-shot-img,
.cs-pro-card--then .cs-ph--phone {
  width: var(--pro-card-w);
  max-width: none;
}

.cs-pro-card--now .cs-shot-img,
.cs-pro-card--now .cs-ph--phone {
  width: calc(var(--pro-card-w) * var(--cs-screen-step));
  max-width: none;
}

/* The halo, same as the prologue's "now" and the old .cs-shot--after -- a
   real screen only, never the dashed placeholder standing in for one. */
.cs-pro-card--now .cs-shot-img {
  box-shadow: var(--cs-screen-shadow), var(--cs-screen-glow);
}

/* Process Map: one screen, so its annotation sits beside it in a 2-area
   "stage now" grid rather than the usual 3-area "then stage now" — there is
   no "before" screen to make the third area from. .cs-pro-annot--now and
   .cs-pro-lead need nothing added to work here: both already position
   entirely off their own .cs-pro-annot box (position:relative, set on it and
   .cs-pro-stage together, above) rather than off the 3-column
   grid-template-areas a "then" side would need, so all a solo case has to
   supply is its own area list naming the same "now" area they already read. */
#screen-process-map .cs-pro-card--now .cs-shot-img {
  /* Its own --cs-screen-w, not --pro-card-w -- see the block comment above. */
  width: var(--cs-screen-w);
  max-width: none;
}

@media (min-width: 640px) and (max-width: 1023.98px) {
  /* Stay single-column through the halfway breakpoint the before/after
     sections use to pair their four annotations into two — there is only one
     annotation here, and .cs-pro-fig's own 1fr/1fr split would strand it
     under the screen's left half instead of the full-width stack narrower
     viewports already give it. */
  .cs-pro-fig--solo { grid-template-columns: 1fr; }
  .cs-pro-fig--solo .cs-pro-stage { grid-column: auto; }
}

@media (min-width: 1024px) {
  .cs-pro-fig--solo {
    /* The pair layout's own three columns, with the "then" cell simply left
       empty. The screen has to stay on the section's true centre line, so an
       equal fr column either side of it is the whole trick — a two-column
       "stage now" grid centres the PAIR instead, which pushes the screen left
       of centre by half the annotation's width.

       minmax(0, 1fr), not a bare 1fr: 1fr floors at min-content, so a long
       enough word in the annotation could grow the right column past its
       share and take the screen off centre again. */
    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
    grid-template-areas: ". stage now";
    /* No bleed: one screen and one annotation column sit inside the section's
       own 950px comfortably, unlike a full then/now pair plus two annotation
       columns. */
    margin-inline: 0;
  }
}

/* Product selection's "after" used to be the whole scrollable frame at its
   own 748x1788 (see git history), which needed a --pro-then-offset override
   here to keep the "before" annotation's leader line meeting its screen
   instead of pointing at empty page above it. The current capture is one
   viewport at the standard 9/19.5, same aspect as "before", so the default
   --pro-then-offset in the prologue block above (width-step-derived) already
   gets this right and no override belongs here. */


/* ═══ 07 — Research tabs ═══════════════════════════════════════════════════
   The one interactive thing on the page. It earns that because this section
   is an expandable behind-the-scenes layer: a reader can skim past it, or
   open one, and a presenter can do either live.

   Progressive enhancement. With no JS every panel is simply visible and the
   tabs read as headings — the content is never behind an interaction that
   failed to load. case-study-tabs.js adds [hidden] to all but the first.   */

/* No rule above the row, on instruction — each tab carries its own underline,
   and a line across the top as well boxed them in. */
.cs-tablist {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0;
}

.cs-tab {
  appearance: none;
  background: none;
  border: 0;
  /* A button does not inherit font-family. Measured before this: the tab
     resolved to Arial 13.33px. Its children set their own faces so nothing
     visible was wrong, but the button's own metrics were the UA default. */
  font: inherit;
  border-bottom: 1px solid var(--cs-rule);
  text-align: left;
  cursor: pointer;
  display: flex;
  align-items: baseline;
  gap: 16px;
  padding: 20px 0;
  /* 44px touch box comes free from the padding at this type size. */
  min-height: 44px;
  transition: color 0.25s ease;
}

.cs-tab-num {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  color: var(--cs-label);
}

.cs-tab-name {
  font-family: var(--font-display);
  /* 500, never 600 — every 500 on this site is Clash Display's own file. */
  font-weight: 500;
  /* calc() rather than a smaller token: a token swap would change the fluid
     curve's own slope, not just its value, and the ask was 4px off the
     existing size at every width, not a different scale step. */
  font-size: calc(var(--text-body) - 4px);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-label);
  transition: color 0.25s ease;
}

.cs-tab:hover .cs-tab-name { color: var(--cs-fg); }

/* The selected tab is marked by colour and its underline, not by a filled pill
   — the page has no filled surfaces anywhere else.

   Ink for the selected tab, --cs-label grey for the rest: back to the page's
   own two-value scheme after a round through spacefire. That route is closed
   for a reason worth keeping — raw spacefire here is 2.35:1 and made the
   SELECTED tab the least readable text in its own row, and darkening it enough
   to pass stopped reading as spacefire at all. Ink at ~15:1 against grey at
   5.27:1 puts the contrast where the selection is, which is the way round it
   should have been. No .cs-users override any more; this is the base rule. */
.cs-tab[aria-selected="true"] {
  border-bottom-color: var(--cs-fg);
}

.cs-tab[aria-selected="true"] .cs-tab-name,
.cs-tab[aria-selected="true"] .cs-tab-num {
  color: var(--cs-fg);
}

.cs-panel {
  padding-top: 32px;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

/* [hidden] is display:none in the UA stylesheet, and any display declaration
   in an author stylesheet outranks it — so the flex above silently defeated
   the attribute and all three panels rendered at once. Measured: hidden=true
   on two panels, computed display flex on all three, every one of them
   visible on the page.

   This is the standard trap with [hidden] plus a display rule, and the fix has
   to be !important because .cs-panel's own display is what it is fighting. */
.cs-panel[hidden] { display: none !important; }

/* The panel is focusable so keyboard users land somewhere after choosing a
   tab; it should not draw a ring when reached by mouse. */
.cs-panel:focus { outline: none; }

.cs-panel-lede {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.5;
  color: var(--cs-label);
  max-width: 34em;
  margin: 0;
}

@media (min-width: 768px) {
  .cs-tablist { grid-template-columns: repeat(3, 1fr); gap: 32px; }
  .cs-tab { padding: 20px 0; }
}


/* ── Spectrums and situations ───────────────────────────────────────────────
   Two 3x3 fields side by side inside §7's first panel. The source artefact is
   a dark board with tinted tiles; here the tiles are a wash of the page's own
   ink so the field reads as quiet texture on cream rather than as eighteen
   competing cards, and the column divider is the page's existing dash. */
.cs-sps {
  position: relative;
  display: grid;
  grid-template-columns: 1fr;
  gap: 48px;
}

/* Decorative ground, the same split the page's other fields use: the pattern
   comes from the shared rule in style.css, only the box, fade and weight are
   set here. Edge-anchored rather than radial so it holds at any column count. */
.cs-sps-dots {
  position: absolute;
  inset: -20px -12px;
  opacity: var(--sps-dot-opacity, 0.18);
  --sps-fade: 80px;
  -webkit-mask-image:
    linear-gradient(to right, transparent, #000 var(--sps-fade), #000 calc(100% - var(--sps-fade)), transparent),
    linear-gradient(to bottom, transparent, #000 var(--sps-fade), #000 calc(100% - var(--sps-fade)), transparent);
          mask-image:
    linear-gradient(to right, transparent, #000 var(--sps-fade), #000 calc(100% - var(--sps-fade)), transparent),
    linear-gradient(to bottom, transparent, #000 var(--sps-fade), #000 calc(100% - var(--sps-fade)), transparent);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  z-index: 0;
}

/* Everything real paints above the field. */
.cs-sps-col { position: relative; z-index: 1; min-width: 0; }

/* One ground for all eighteen. The two columns are told apart by their
   headings and the dash between them, not by hue — two tints would imply the
   spectrums and the situations are different KINDS of thing, and they are two
   readings of the same map.

   Was ink (--color-dark), after an earlier round through a warm tint and
   then a deep green that both got reverted — that history is worth knowing
   before reading this as a settled choice. Now --color-charcoal, the same
   near-black the impact band and the evidence ground use, on instruction --
   close enough to the original ink (#2b2d33 against #1e1e20) that nothing
   here needed re-measuring past a sanity check: white on it is 13.76:1 (was
   16.64:1) and it separates from the cream page by 12.5:1 (was 15.12:1).
   Both numbers still pass by a wide margin. */
.cs-sps { --sps-tile-bg: var(--color-charcoal); }

.cs-sps-head {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  font-weight: 400;
  line-height: 1.5;
  letter-spacing: normal;
  color: var(--cs-label);
  margin: 0 0 32px;
  max-width: 30em;
}

.cs-sps-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px;
}

.cs-sps-grid > li {
  display: grid;
  place-items: center;
  text-align: center;
  min-height: 104px;
  padding: 20px 16px;
  border-radius: var(--cs-card-radius);
  /* A warm spacefire ground rather than the ink-at-5% wash this had, which
     separated from the cream page by 1.03:1 — invisible as a surface, so
     eighteen tiles read as grey text floating in grey boxes.

     60% spacefire on white separates by 1.69:1, which is a tile you can
     actually see, and still takes ink at 9.41:1. Measured both ways: a
     stronger tint stops being a surface and becomes a block of colour, a
     weaker one goes back to invisible. */
  background: var(--sps-tile-bg);
  font-family: var(--font-ui);
  /* Back on --text-label exactly. It was briefly +2px, which pushed the longer
     labels onto a fourth line and broke the tile grid's even rows. */
  font-size: var(--text-label);
  /* Medium, not regular and not bold. Geist's 500 is a real weight here —
     measured strictly between its 400 and 700 — and is requested explicitly in
     jupiter.html's font link. */
  font-weight: 500;
  line-height: 1.4;
  color: var(--color-white);
  /* break-word, NOT anywhere. `anywhere` also lets the track shrink below the
     longest word, which is how these ended up splitting mid-character; this
     only breaks a word that genuinely cannot fit on its own line. */
  overflow-wrap: break-word;
}

@media (min-width: 640px) {
  .cs-sps-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* Side by side, with the artefact's divider between them. Dashed rather than
   solid: it separates two readings of one map, not two unrelated blocks.

   The divider is drawn in the gap rather than as a border on the second
   column. As a border it needed padding to stand off the text, and that
   padding came out of the column's own 1fr track — so the right column's
   tiles were 115px against the left's 137px, and only the right column broke
   its labels mid-word ("Environmen / t &"). The tracks are equal, so 50% of
   this box is the centre of the gap; no magic number, and the columns stay
   identical at every width. */
/* 1100, not 900. Side by side, each column carries three tiles, so a column is
   roughly a sixth of the content width — and below about 1100 that puts the
   tile's content box under the longest label in the set ("Technological",
   "Comprehension", both ~84px at this size), which breaks them mid-word.
   Stacked, the same tiles are more than twice as wide. Measured rather than
   guessed: at 1024 the tiles came out 114px with an 82px content box. */
@media (min-width: 1100px) {
  .cs-sps { grid-template-columns: 1fr 1fr; column-gap: 64px; }
}


/* ═══ 08 — Problem to principle ════════════════════════════════════════════
   Each problem connects to the principle that answers it. The connector is a
   real drawn line rather than an arrow glyph, so it can span the gap at any
   column width and disappear cleanly when the row stacks.                  */

.cs-pp {
  list-style: none;
  margin: 0;
  padding: 0;
}

.cs-pp-row {
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-areas:
    "num problem"
    ".   link"
    ".   principle";
  gap: 12px 16px;
  padding: 32px 0;
  border-top: 1px solid var(--cs-rule);
  align-items: start;
}

/* No rule above the first row or below the last, on instruction -- only the
   two rules between rows survive, separating the three without boxing the
   whole set in. */
.cs-pp-row:first-child { border-top: none; }

.cs-pp-num {
  grid-area: num;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  color: var(--cs-label);
  padding-top: 4px;
}

.cs-pp-problem   { grid-area: problem; }
.cs-pp-principle { grid-area: principle; }
.cs-pp-link      { grid-area: link; }

.cs-pp-key {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  margin-bottom: 8px;
}

.cs-pp-name {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  margin: 0 0 4px;
}

.cs-pp-detail {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.45;
  color: var(--cs-label);
  margin: 0;
  max-width: 28em;
}

/* Matched to .cs-pp-name's size, on instruction: the problem and its answer
   read as a pair, not as a small question under a big headline. Was
   var(--cs-h2) -- section-heading scale, sized to "carry the weight of the
   answer" -- but that made a three-word principle dwarf the problem row
   sitting right next to it. */
.cs-pp-answer {
  font-family: var(--font-display);
  /* Matched to .cs-pp-name's weight (500), on instruction -- was 400. Both
     var(--font-display), so this is Clash Display's real medium file in both
     places, not a synthesis. */
  font-weight: 500;
  /* +8px over .cs-pp-name, on instruction: the pair no longer reads at one
     size, on purpose this time, rather than by the mismatch this row had
     before .cs-pp-name and .cs-pp-answer were unified to one size earlier
     today. */
  font-size: calc(var(--text-body) + 8px);
  line-height: 1.2;
  letter-spacing: -0.01em;
  /* Spacefire, on instruction. Flagging the number per this file's own rule
     on adding a third low-contrast pairing (CLAUDE.md): spacefire on cream
     measures 2.35:1, below even the 3:1 large-text floor. At the smaller end
     of this token the size increase above still lands under 24px, so it
     stays short of "large text" by the WCAG definition and still fails AA
     outright -- same number as the hero's $2.4M, which reads as a short
     accent inside a sentence stated identically in ink nearby. A three-word
     principle standing alone as its row's only answer is not that same case;
     there is no ink-coloured restatement next to it. */
  color: var(--hook-highlight);
  margin: 0;
}

/* The connector. Vertical while the row is stacked, horizontal once the two
   halves sit side by side. */
.cs-pp-link {
  display: block;
  width: 1px;
  height: 24px;
  margin-left: 8px;
  background: var(--cs-rule);
}

@media (min-width: 900px) {
  .cs-pp-row {
    grid-template-columns: auto minmax(0, 1fr) 56px minmax(0, 1fr);
    grid-template-areas: "num problem link principle";
    /* start, not center: .cs-pp-principle is shorter than .cs-pp-problem (no
       third line), and centering each column in the row's full height put
       PROBLEM/PRINCIPLE and the two headers at different Y positions instead
       of level with each other. Both columns open with the same eyebrow
       styling and the header sizes now match too, so aligning to the row's
       top edge is what makes them land level -- .cs-pp-link keeps its own
       align-self: center below so the connector still sits at the visual
       middle regardless. */
    align-items: start;
    gap: 24px;
  }
  .cs-pp-link {
    width: 100%;
    height: 1px;
    margin-left: 0;
    align-self: center;
  }
}

/* ═══ The redesigned onboarding, screen by screen ══════════════════════════
   Eleven sections sharing one annotation grammar, so the reader learns it
   once: red marks the problem the research found, green marks the design
   response. Compositions vary; the grammar, the phone scale and the negative
   space do not.

   ── On the two colours ─────────────────────────────────────────────────────
   Green measures 2.7:1 as type on cream and red is little better, so neither
   is ever used as a text colour here. Both appear only as a 2px edge on the
   note they mark, with the note itself in the page's normal foreground. The
   colour is a marker, not the message — and the words "before"/"after" on the
   frames carry the same distinction, so nothing is conveyed by colour alone. */

/* Scoped to Iterations by id now, not by .cs-screen generally: the other nine
   .cs-screen sections were moved onto the prologue's own layout (.cs-pro-fig
   etc., see below), which gets its block rhythm for free from .cs-sec's
   ordinary flex gap (--cs-blk-gap, 32/40) the same way the prologue does.
   Iterations keeps its own flat 32px unchanged either way. */
#screen-iterations {
  gap: 32px;
}

/* No number on any of the eleven screens (Splash included -- it kept a
   "10" for one round after §11-20 dropped theirs, but that was a one-off
   holdout, not a rule, and is gone now). With no number to reserve a first
   column for, the head is a single flush-left column: title and framing
   both start at the section's own left margin, the same edge
   --cs-eyebrow-gap uses for an eyebrow-then-heading pair anywhere else on
   the page.

   Only Process Map still uses .cs-screen-head/-title/-framing — every other
   screen section, Iterations included as of this round, was moved onto
   .cs-eyebrow + .cs-h2 + .cs-sub directly, the same trio §16 uses. The rule
   stays for that one section rather than being retired. */
.cs-screen-head {
  display: flex;
  flex-direction: column;
  gap: var(--cs-eyebrow-gap);
}

.cs-screen-title {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
  margin: 0;
}

/* The framing line is the heading that matters — it names the design decision
   rather than the screen — so it takes the section scale and the full column,
   below the small label pair. */
.cs-screen-framing {
  font-family: var(--font-display);
  /* 500, same as .cs-h2 and the project header — the §10-20 screen headings
     sit on the same weight as every other heading on the page. */
  font-weight: 500;
  font-size: var(--cs-h2);
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--cs-fg);
  max-width: 18em;
  margin: 0;
}

/* Pointer devices only: on touch there is no hover to leave, so a screen
   would grow on tap and stay grown. Hover with no :focus-within twin, for
   the same reason as §3 — these are images, not controls, and the scale
   carries nothing the composition does not already carry. Origin is the
   bottom edge so a growing screen stays on the shared baseline instead of
   drifting off it. Still live for every .cs-shot-img on the page — the
   figure wrapper that used to bump a growing screen's z-index is gone, but
   .cs-pro-card:hover (the prologue's own rule) covers that for all of
   them now. */
@media (hover: hover) {
  .cs-shot-img { transition: transform 0.3s ease; transform-origin: bottom center; }
  .cs-shot-img:hover { transform: scale(var(--cs-screen-hover)); }
}

/* Decoration, not affordance — same call as §3's cards: it says nothing the
   composition does not already say, so it goes when the reader asks for less
   movement. After the (hover: hover) block so it outranks it when both match. */
@media (prefers-reduced-motion: reduce) {
  .cs-shot-img { transition: none; }
  .cs-shot-img:hover { transform: none; }
}


/* ═══ A screen that plays ═════════════════════════════════════════════════
   §11's after screen is the real capture taken apart into layers so the
   interaction it describes can actually run: the rewards graph draws, then
   the sheet takes focus and the keypad comes up and types.

   The pixels are still the design's own. Only three things are rebuilt, and
   only because they have to move independently: the bars (the base image has
   them erased, so these are drawn from their measured geometry and sampled
   gradients), the keypad (it does not exist in the capture — the screenshot
   is the resting state), and the typed digits.

   EVERY DEFAULT HERE IS THE RESTING STATE — bars full height, sheet down,
   keypad parked below the frame, placeholder showing. Composited with no
   motion layer at all, this is the original screenshot, which is the same
   contract the rest of the page keeps: the stylesheet hides nothing that
   carries meaning, and case-study-motion.js writes the start state itself.

   .cs-shot-live also carries .cs-shot-img, so width, ratio, corner, shadow,
   the after-screen glow and the hover step all come from there unchanged —
   this reads as the same object as every other screen on the page.        */

.cs-shot-live {
  position: relative;
  overflow: hidden;
  /* Lets the keypad and the typed digits size themselves in cqw/cqh against
     the phone frame, so the whole screen scales as one piece at any width
     instead of needing a font size per breakpoint. */
  container-type: size;
  background: var(--color-white);
}

.cs-live-base {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* All geometry is a percentage of the frame, measured off the 738x1600
   capture, so every layer tracks the screen's rendered size exactly. */
.cs-shot-live > :not(.cs-live-base) { position: absolute; }

/* One bar. --bx/--bt/--bh are its measured column and height; --c1/--c2 the
   gradient sampled from the capture's own pixels. It grows from its base,
   which sits under the sheet's top edge — the same place the real bars are
   cut off, so they rise out from behind the sheet exactly as a chart drawn
   behind it would. */
.cs-live-bar {
  left: var(--bx);
  top: var(--bt);
  width: 1.626%;
  height: var(--bh);
  background: linear-gradient(var(--c1), var(--c2));
  border-radius: 0.45% 0.45% 0 0;
  transform-origin: bottom center;
}

/* The typed number, over the placeholder it replaces. Opaque, so showing it
   covers the placeholder without needing a second element to blank it. */
.cs-live-entry {
  left: 6.7073%;
  top: 47.375%;
  width: 86.4499%;
  height: 6.4375%;
  display: flex;
  align-items: center;
  gap: 0.4cqw;
  padding-left: 3.93%;
  background: var(--color-white);
  font-family: var(--font-ui);
  font-size: 2.6cqh;
  letter-spacing: 0.02em;
  color: #202124;
  opacity: 0;
}

/* The field while it is being filled: the capture's own resting border is a
   pale grey, so the active state darkens it. Drawn on the entry, which sits
   exactly on the input's box, rather than as a second layer. Class-gated
   because it belongs to the filling state — left unconditional it would ring
   an untouched field in the resting screenshot.
   Radius is measured off the capture's own corner (pixel-scanned: ~5px at
   the 738px capture width, both top corners), not guessed — 1.1cqw (~8px)
   used to overshoot it, so the darker border stepped outside the grey box's
   actual curve at every corner instead of tracing it. */
.cs-shot-live.is-focused .cs-live-entry {
  border: 0.4cqw solid #6f7377;
  border-radius: 0.678cqw;
}

.cs-live-caret {
  width: 0.35cqh;
  height: 3.1cqh;
  background: var(--hook-highlight);
  opacity: 0;
}

/* ── §12's permission dialogs ─────────────────────────────────────────────
   The stack of system dialogs in the illustration panel, rebuilt as elements
   so each permission can be agreed to in turn. The base image has the stack
   erased; everything else in the panel is the capture's own pixels.

   Rebuilt rather than sliced because THE STACK DEALS ITSELF DOWN — three real
   dialogs, each agreed to in turn, which no amount of moving a cropped image
   can do. Geometry is measured off the 750x1625 capture, and sizes are in cqw
   so the whole dialog scales with the frame as one piece.

   The resting state is the full three-card stack with SMS on top, which is
   what the capture shows — so with no motion layer this composites back to
   the screenshot. */
.cs-perm-stack {
  left: 28.667%;
  top: 12.8%;
  width: 42.667%;
  height: 12.615%;
}

/* One rule places every card, because a card's position is just which step of
   the stack it is on. --slot 0 is the front; each step back is down-left by
   --perm-step. The motion layer tweens --slot rather than GSAP's own x/y so
   the offsets have ONE definition: a CSS resting transform plus a GSAP x/y on
   the same element is the additive trap that broke the §14 sheens.
   Offsets in cqw on BOTH axes so the step stays square — a cqh vertical would
   shear it, as the frame's ratio is not 1.
   The cards are opaque, and so are the capture's: what separates them is the
   shadow each one drops on the one behind, not a fade. */
.cs-perm-card {
  position: absolute;
  inset: 0;
  --slot: 0;
  --perm-step: 3.13cqw;
  transform: translate(calc(var(--slot) * var(--perm-step) * -1),
                       calc(var(--slot) * var(--perm-step)));
  background: var(--color-white);
  border-radius: 0.5cqw;
  box-shadow: 0 0.3cqw 1cqw -0.35cqw rgba(32, 38, 56, 0.20);
}

.cs-perm-text {
  position: absolute;
  left: 3.73cqw;
  right: 3.07cqw;
  top: 5.25cqw;
  font-family: var(--font-ui);
  /* Sized to the capture's LINE BREAK, not to its glyph height. The dialog is
     Roboto; Roboto is not on this machine and must not be depended on for a
     visitor's either — measured against a deliberately nonexistent family, a
     Roboto stack returned the identical width, i.e. it was falling back the
     whole time (the trap CLAUDE.md documents). Geist is ~13% wider than the
     capture's face, so matching the type size would push "Allow Jupiter to
     send and" onto a third line and the card would stop reading as the
     dialog it is. Matching the wrap costs ~3px of glyph height at frame
     scale, which is about 1px on screen. */
  font-size: 2.9cqw;
  line-height: 1.14;
  color: #6b6b6b;
}

.cs-perm-text b { font-weight: 700; color: #000; }

.cs-perm-actions {
  position: absolute;
  right: 3.07cqw;
  bottom: 3.6cqw;
  display: flex;
  gap: 3.5cqw;
  font-family: var(--font-ui);
  font-size: 1.98cqw;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: #299cf7;
}

/* The tap that dismisses the card. Transition, not a tween: the motion layer
   only toggles a class. */
.cs-perm-agree {
  padding: 0.55cqw 0.85cqw;
  margin: -0.55cqw -0.85cqw;
  border-radius: 0.4cqw;
  transition: background-color 0.16s ease;
}

.cs-perm-agree.is-hit { background: rgba(41, 156, 247, 0.18); }

@media (prefers-reduced-motion: reduce) {
  .cs-perm-agree { transition: none; }
}


/* ── §14's process map ────────────────────────────────────────────────────
   One screen, not a pair — there is no before for this; the map is the new
   thing. Two captured states of it, layered so the change between them can be
   played as one move: the card icon becomes a tick, the highlight travels from
   the first step to the second, and the hexagon lights up.

   Both captures have the highlight band's ground repainted back to the page
   ground, and the band is a translucent white rectangle over them instead —
   0.065 alpha, which is exactly the lift from rgb(43,45,51) to rgb(56,59,66).
   A rectangle can slide; a band baked into two images can only cut. It sits
   ABOVE the captures and BELOW the icons, so the icons stay their own colour
   the way they do in the capture.

   Resting state is the FIRST capture, whole: the second state, the tick and
   the hexagon are the only things the stylesheet holds back, and a reader
   with no motion layer still sees a complete, correct screen. */
/* ── §14, the process map ──────────────────────────────────────────────────
   Four real captures, taken apart into the pieces that actually move.

   The screen is rebuilt rather than cross-faded, because the four states are
   the SAME four rows in different states, and a dissolve between two flat
   images makes a row that merely slid up look like a cut. So:

     map-base.png        status bar, page ground and the CTA plate — the parts
                         identical in all four captures, and the only thing on
                         screen that never moves
     map-node-<n>-<st>   ONE STEP'S ICON, alone. Split out from its row so the
                         node can carry its own state change — the icon is
                         what the progress line arrives AT
     map-part-<n>-<st>   and, for four of them, ONE PIECE of that icon lifted
                         out of it: the sparkle, the J, the head, the star
                         badge. The base has those pixels erased, so base plus
                         part composites back to the capture exactly, and the
                         piece can move or light up on its own. That is what
                         lets each icon's microanimation mean something
                         particular instead of being one more pulse
     map-copy-<n>-<st>   that step's pill, title and subtitle
     map-head-<n>        the headline
     map-cta-next.png    "Get started" becomes "Next" after the first step
     band / rails        drawn here, because they are the two things whose
                         geometry is continuous rather than pictorial

   Every sprite is alpha-keyed off its capture's ground, so it composites
   correctly over the page OR over the highlight band, and every crop carries
   two or three pixels of clearance on every side. That clearance matters: the
   first cut measured each icon's extent with the rail column masked out, and
   a circle's topmost and bottommost pixels sit exactly ON that centreline, so
   the green ticks came out visibly flattened top and bottom.

   Reconstruction is checked against the captures pixel for pixel: composite
   every layer at a state's measured positions and the result matches that
   capture, bar the 2px of horizontal jitter between the exports themselves.

   Positions that CHANGE between states are NOT set here. Each layer is parked
   at one resting top and the motion layer moves it with a transform, so a
   travelling row or a travelling highlight never touches layout. */

/* The highlight band. Opaque, not a white wash: the base plate has the row
   zone flattened to page ground, so this is the real captured colour. One
   object that travels down the map — it is translated and scaled from its top
   edge, never re-laid-out, which is what stopped the slide juddering. */
.cs-map-band {
  left: 0;
  right: 0;
  top: 23.737%;
  height: 12.941%;
  background: rgb(56, 59, 66);
  transform-origin: 50% 0%;
  will-change: transform;
}

/* The two columns a row is made of. Each is a box parked at its own resting
   top; the image inside gives it its height, so the motion layer positions a
   layer by transform alone and never has to know how tall it is. */
.cs-map-node,
.cs-map-copy {
  display: block;
  opacity: 0;
  transform-origin: 50% 50%;
  will-change: transform, opacity;
}

.cs-map-node { left:  8.396%; width: 19.190%; }
.cs-map-copy { left: 29.985%; width: 68.366%; }

.cs-map-art {
  display: block;
  width: 100%;
  height: auto;
}

/* A piece lifted out of its icon, laid back exactly where it came from. Top
   and left only: pinning the bottom too would stretch it to the box instead
   of letting its own aspect place it, and a pixel of rounding there would show
   as the piece sitting a hair off its own hole. */
.cs-map-part {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
}

/* And a highlight travelling through an icon, for the two icons that have no
   separable piece to animate. Masked to the icon's own artwork, so the light
   runs along the strokes rather than over a rectangle. */
.cs-map-sheen,
.cs-err-sheen {
  position: absolute;
  inset: 0;
  overflow: hidden;
  opacity: 0;
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
}

.cs-map-sheen i,
.cs-err-sheen i {
  position: absolute;
  top: -25%;
  bottom: -25%;
  left: 0;
  width: 55%;
  background: linear-gradient(90deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.7) 50%,
    rgba(255, 255, 255, 0) 100%);
  /* No resting transform. GSAP reads a CSS translate as a PIXEL offset and
     then ADDS xPercent to it, so parking the bar off-screen here made every
     sweep run from -380% to 0% — off the left, and stopping dead at the left
     edge instead of leaving on the right. The bar's resting position does not
     need to be hidden anyway: its parent is opacity 0 until a sweep runs. */
}

.cs-map-sheen[data-sweep="up"] i {
  top: 0;
  bottom: auto;
  left: -25%;
  right: -25%;
  width: auto;
  height: 55%;
  background: linear-gradient(180deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.7) 50%,
    rgba(255, 255, 255, 0) 100%);
  /* Same reason as the horizontal bar above: no resting transform. */
}

.cs-map-node[data-row="2"][data-face="idle"] .cs-map-part { transform-origin: 44.53% 25.0%; }
.cs-map-node[data-row="2"][data-face="act"] .cs-map-part { transform-origin: 45.31% 52.08%; }
.cs-map-node[data-row="3"][data-face="idle"] .cs-map-part { transform-origin: 44.53% 17.68%; }
.cs-map-node[data-row="3"][data-face="act"] .cs-map-part { transform-origin: 44.92% 18.29%; }
.cs-map-node[data-row="4"][data-face="idle"] .cs-map-part { transform-origin: 63.28% 21.34%; }

.cs-map-node[data-row="1"][data-face="act"] .cs-map-sheen {
  -webkit-mask-image: url(img/screens/map-node-1-act.png);
          mask-image: url(img/screens/map-node-1-act.png);
}
.cs-map-node[data-row="4"][data-face="act"] .cs-map-sheen {
  -webkit-mask-image: url(img/screens/map-node-4-act.png);
          mask-image: url(img/screens/map-node-4-act.png);
}

/* The resting state is the FIRST capture, composited — not a blank plate.
   These four faces at these tops, the first headline, and the band and rails
   above are exactly MAP Screen 1, so with GSAP blocked or reduced motion on,
   §14 is a finished screenshot and nothing meaningful is hidden. The motion
   layer writes every other start state itself. */
.cs-map-node[data-row="1"][data-face="act"] { top: 27.820%; opacity: 1; }
.cs-map-copy[data-row="1"][data-face="act"] { top: 25.813%; opacity: 1; }
.cs-map-node[data-row="2"][data-face="idle"] { top: 42.630%; opacity: 1; }
.cs-map-copy[data-row="2"][data-face="idle"] { top: 43.114%; opacity: 1; }
.cs-map-node[data-row="3"][data-face="idle"] { top: 56.332%; opacity: 1; }
.cs-map-copy[data-row="3"][data-face="idle"] { top: 58.270%; opacity: 1; }
.cs-map-node[data-row="4"][data-face="idle"] { top: 69.619%; opacity: 1; }
.cs-map-copy[data-row="4"][data-face="idle"] { top: 70.311%; opacity: 1; }

/* The faces state 1 does not use, parked where they first appear. Every one
   of them is reached by a transform from here, never by moving its top. */
.cs-map-node[data-row="1"][data-face="done"] { top: 25.606%; }
.cs-map-copy[data-row="1"][data-face="done"] { top: 27.612%; }
.cs-map-node[data-row="2"][data-face="act"] { top: 40.554%; }
.cs-map-copy[data-row="2"][data-face="act"] { top: 36.747%; }
.cs-map-node[data-row="2"][data-face="done"] { top: 37.370%; }
.cs-map-copy[data-row="2"][data-face="done"] { top: 37.785%; }
.cs-map-node[data-row="3"][data-face="act"] { top: 53.356%; }
.cs-map-copy[data-row="3"][data-face="act"] { top: 49.343%; }
.cs-map-node[data-row="3"][data-face="done"] { top: 49.135%; }
.cs-map-copy[data-row="3"][data-face="done"] { top: 51.073%; }
.cs-map-node[data-row="4"][data-face="act"] { top: 64.706%; }
.cs-map-copy[data-row="4"][data-face="act"] { top: 61.038%; }

/* The rail between two consecutive icons: the dotted track the progress line
   travels along. The dash period is in cqw so it stays a 5-on/3-off dash at
   any rendered width, exactly as in the capture — which is also why a rail's
   length is animated rather than scaled: scaling would stretch the dashes. */
.cs-map-rail {
  left: 17.091%;
  width: 0.600%;
  background: repeating-linear-gradient(
    to bottom,
    rgb(198, 198, 200) 0 0.749cqw,
    transparent 0.749cqw 1.199cqw);
}

.cs-map-rail[data-rail="1"] { top: 32.388%; height: 10.450%; }
.cs-map-rail[data-rail="2"] { top: 47.336%; height:  9.481%; }
.cs-map-rail[data-rail="3"] { top: 61.730%; height:  8.097%; }

/* And the progress itself: the same rail in green, GROWN down the track from
   its top edge rather than faded in. scaleY from a top origin is the whole
   point — it is the one thing on this screen that has to read as travelling,
   and an opacity fade reads as a segment popping into existence. At rest it
   is scaled to nothing, which is state 1: no progress yet. */
.cs-map-rail-on {
  position: absolute;
  inset: 0;
  background: rgb(53, 112, 101);
  transform: scaleY(0);
  transform-origin: 50% 0%;
  will-change: transform;
}

/* The headline. All four sit at the same top edge in the captures, so the
   swap between them is a straight lift — no rotation, no per-word staging:
   the heading is a consequence of the progress, not a title card. */
.cs-map-head {
  display: block;
  left: 3.598%;
  top: 10.035%;
  width: 95.352%;
  opacity: 0;
  will-change: transform, opacity;
}

.cs-map-head[data-head="1"] { opacity: 1; }

/* The CTA label changes once, at the first step, and never again. It is two
   layers, not a crossfade: a crossfade between two labels on the same white
   plate shows both at once, and "GetNexted" is what that looks like. So the
   wipe covers the old label first — flat white, which is exactly the plate
   under it — and the new label, keyed off that same white, arrives after. */
.cs-map-cta-wipe {
  left: 8.996%;
  top: 92.734%;
  width: 82.459%;
  height: 2.907%;
  background: var(--color-white);
  opacity: 0;
}

.cs-map-cta {
  display: block;
  left: 43.478%;
  top: 93.149%;
  width: 13.193%;
  opacity: 0;
}


/* ═══ §20's after screen — an error that becomes an offer ═══════════════════
   Two captures of one screen, and the whole point of the section is that the
   second is a REORGANISATION of the first rather than a different screen. So
   the three things that exist in both — the error mark, the headline, and the
   ground they sit on — are lifted out as their own layers and travel, and only
   the sheet is genuinely new.

     err-base      ground, status bar and the Help link. Identical in both
                   captures, and the one thing that never moves
     err-icon      state 1's error mark. ONE sprite for both states: it is the
                   same artwork at 0.559 scale, verified by resampling it and
                   diffing against the second capture — the residual is the
                   octagon's antialiased rim and nothing else
     err-head1/2   the headline is NOT a pure scale between states. It is set
                   smaller AND recoloured, from the heading's near-black
                   rgb(49,51,57) to the subhead's grey rgb(98,97,96) — it is
                   demoted to a caption once the offer arrives. No transform
                   reaches that, so the two renderings cross-fade over the last
                   fifth of the travel, by which point they are within four
                   capture pixels of each other and the swap cannot be seen.
                   Spatial continuity is carried by the movement either way:
                   one object, one path, one landing
     err-sub       state 1's explanatory line. It has no place in state 2, so
                   it leaves with the movement rather than being cut
     err-sheet     the offer's surface, with its heading and its CTA already on
                   it, because a sheet whose contents assemble after it lands
                   reads as two events instead of one arrival
     err-pots/
     ben1/ben2/
     social        and these four are what the sheet reveals once it is still

   Reconstruction is checked against both captures pixel for pixel: composite
   every layer at its measured position and state 1 comes back with a maximum
   channel difference of 16 and state 2 of 1.

   THE RESTING STATE HERE IS THE SECOND CAPTURE, not the first. Everywhere else
   on this page the stylesheet parks a screen on its opening frame, but this
   section's own annotation promises "a smart cross-sell based on eligibility"
   — so a reader with reduced motion has to be given the finished screen, which
   is the only one that shows the offer. The cost is state 1's explanatory line,
   which exists nowhere in state 2's layout; it is carried in the base image's
   alt text so it is not lost to a screen reader. */

.cs-err-icon,
.cs-err-head,
.cs-err-sub {
  display: block;
  transform-origin: 50% 50%;
  will-change: transform, opacity;
}

/* State 1 positions. The icon and the headline are parked HERE and reach their
   second-state places by transform, which is what makes them one object in two
   states rather than two elements. */
.cs-err-icon { top: 28.185%; left: 40.533%; width: 18.667%; }
.cs-err-sub  { top: 52.246%; left: 16.533%; width: 67.067%; opacity: 0; }

.cs-err-head[data-head="1"] { top: 43.262%; left: 17.467%; width: 65.200%; opacity: 0; }

/* State 2's headline sits at its own place, at rest, visible. */
.cs-err-head[data-head="2"] { top: 17.046%; left: 25.467%; width: 49.333%; }

/* At rest the mark is already where the second capture has it: 0.559 of its
   size, 328 capture pixels higher. Written as a percentage of the sprite's own
   height so it holds at any rendered width, and the motion layer starts the run
   by clearing it. */
.cs-err-icon { transform: translateY(-235.971%) scale(0.559); }

/* The sheet. Its own height comes from the artwork inside it, so the four
   revealed pieces can be positioned against it and ride up with it. */
.cs-err-sheet {
  top: 26.338%;
  left: 0;
  width: 100%;
  will-change: transform;
}

.cs-err-sheet > img { display: block; width: 100%; height: auto; }

.cs-err-sheet > :not(.cs-err-art) {
  position: absolute;
  will-change: transform, opacity;
}

.cs-err-pots   { top: 22.556%; left:  0;       width: 100%;     }

/* The shimmer that closes the sequence, masked to the headline's own glyphs so
   the light runs along the type rather than over a band. Shares the sheen
   mechanism with §14's icons — one implementation, two users. */
.cs-err-sheen {
  top: 6.266%;
  left: 0;
  width: 100%;
  height: 10.109%;
  -webkit-mask-image: url(img/screens/err-headline-mask.png);
          mask-image: url(img/screens/err-headline-mask.png);
}

.cs-err-ben    { top: 66.834%; }
.cs-err-ben[data-ben="1"] { left:  0;       width: 50.133%; }
.cs-err-ben[data-ben="2"] { left: 50.133%; width: 49.867%; }
.cs-err-social { top: 81.454%; left:  0;       width: 100%;     }


/* Only once it has played: the screen becomes clickable to run it again. The
   class is added by the motion layer on completion, so without motion there is
   no pointer inviting a click that would do nothing. No keyboard twin, for the
   same reason the hover step has none — this replays decoration and withholds
   nothing from a reader who never triggers it. */
.cs-shot-live.is-replayable { cursor: pointer; }


@media (min-width: 768px) {
  /* Side by side, and now the pair owns the two rows both figures sit in:
     row-gap is the screen-to-annotation distance (16px), column-gap the
     distance between the screens (--pro-stage-gap's 32, then 56 at 1024).

     Columns are sized to the screens and the pair is centred, exactly as
     .cs-pro-stage centres its two. On 1fr 1fr the column-gap was not the gap
     you saw: the smaller before screen left ~85px of its own column empty, so
     the screens sat ~141px apart while the prologue's sat 56px apart. Sizing
     each column to its screen makes the declared gap the real one. */
  .cs-screen-pair {
    grid-template-columns: auto auto;
    grid-template-rows: 1fr auto;
    justify-content: center;
    column-gap: 32px;
    row-gap: 16px;
  }

  /* Each figure is as wide as the screen it holds, which is what lets the auto
     columns above resolve to the screens rather than to the annotations. */
  .cs-shot--before { width: calc(var(--cs-screen-w) / var(--cs-screen-step)); }
  .cs-shot--after  { width: var(--cs-screen-w); }

  @supports (grid-template-rows: subgrid) {
    .cs-shot { grid-row: 1 / -1; grid-template-rows: subgrid; }
  }

}

@media (min-width: 1024px) {
  /* Both numbers are .cs-pro-fig's, so a screen pair and the prologue put the
     same air around their screens and the same distance between them
     (--pro-stage-gap is 56 here for exactly this reason). */
  .cs-screen-pair { column-gap: 56px; padding-block: 40px; }
}

@media (min-width: 1200px) {
  /* The frames stop growing with the column past this width; a phone render
     two hands wide stops reading as a phone. Changing the token is the whole
     step-up: it reaches the real screens, the placeholders still waiting on
     assets and the stepped-down before screen at once, with no second list of
     selectors to keep in sync. */
  .cs-page { --cs-screen-w: 320px; }
}


/* The grouped-state band labels. Same treatment as every other small label on
   the page; they used to be .cs-todo chips. */
.cs-ut-head-label {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}


/* ═══ 22 — The AI stack ════════════════════════════════════════════════════
   Four stages in sequence, each with the tool named. A row per stage rather
   than four icons, because the note under each is the part that makes the
   claim defensible in an interview — a tool name on its own is a name-drop.

   Rules sit BETWEEN steps only — the section heading above and the next
   section's heading below are already dividers, so a rule at either end of
   the list would double one up. border-top on every step but the first
   draws exactly the three joins.

   The framing statement that used to open this section as a .cs-lede now
   closes it as a .cs-bubble instead (markup below the list) — right-aligned
   the same way §4's takeaway is, so it ends exactly where the stack's own
   right edge does. */
.cs-ai-stack .cs-bubble { margin-left: auto; }

.cs-stack {
  list-style: none;
  margin: 0;
  padding: 0;
}

.cs-stack-step {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 16px;
  grid-template-areas:
    "num  stage"
    ".    note";
  /* Matched to .cs-pp-row's 32px, on instruction — the two are the same
     shape of component (a numbered row split into a label pair either side
     of a rule) and read as inconsistent sitting at two different paddings. */
  padding: 32px 0;
}

.cs-stack-step + .cs-stack-step { border-top: 1px solid var(--cs-rule); }

.cs-stack-num {
  grid-area: num;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  color: var(--cs-label);
  padding-top: 4px;
}

/* .cs-stack-stage sits directly in the "stage" area for §22's "What came
   next" reuse (stage, no tool line — see that section's own comment). Where
   there IS a tool line (§22's own four steps), .cs-stack-head wraps both and
   takes the area instead — .cs-stack-stage inside it is then a grandchild,
   not a grid item, so this rule is inert there and never fights the wrapper.
   grid-area is safe to repeat on both: only whichever one is an actual grid
   item of .cs-stack-step reads it. */
.cs-stack-stage,
.cs-stack-head {
  grid-area: stage;
}

.cs-stack-stage {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-body);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
}

/* The stage/tool pair's OWN rhythm — a fixed 4px, regardless of how many
   lines the note beside it wraps to.

   This used to be two more rows of the outer grid, with .cs-stack-note
   spanning across both to sit beside them. A grid track that a spanning item
   overflows grows to fit it, and — measured — that growth was split across
   BOTH spanned rows, not absorbed only by the last one: a 4-line note (this
   section's longest) pushed .cs-stack-tool down by 24.6px, a 3-line note by
   14.4px, where every step's own designed gap is 4px. The note's length was
   dictating the gap between the stage and its tool line, on a completely
   unrelated column. Wrapping the pair so the outer grid places ONE "stage"
   item removes the coupling outright: this flex column now sets its own
   internal 4px gap, unreachable by anything .cs-stack-note does. */
.cs-stack-head {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.cs-stack-tool {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

.cs-stack-note {
  grid-area: note;
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.45;
  color: var(--cs-label);
  margin: 8px 0 0;
  max-width: 30em;
}

@media (min-width: 768px) {
  .cs-stack-step {
    /* 12em plus 76px (36px, then another 40px on instruction), not a bigger
       em figure — the stage/tool column was reading crowded against
       "Explore + Prototype", and a flat px add is one clearly-scoped number
       rather than a change to the em base that would also nudge every other
       measurement riding on it. */
    grid-template-columns: auto calc(12em + 76px) 1fr;
    /* One row, not two: see .cs-stack-head above for why the old two-row
       split (with .cs-stack-note spanning both) is gone. Both "stage" and
       "note" are now single-row items sized purely by their own content, so
       neither's height can push the other. */
    grid-template-areas: "num stage note";
    align-items: start;
    column-gap: 32px;
  }
  .cs-stack-note { margin-top: 0; }
}


/* ═══ The close ════════════════════════════════════════════════════════════
   Composition borrowed from the supplied reference — small mark, large line,
   pill, quiet secondary link — and nothing else. No orange, no new typeface,
   no change of identity. Centred, which nothing else on this page is, so the
   page visibly stops rather than trailing off.                             */

.cs-close {
  align-items: center;
  text-align: center;
  gap: 32px;
  padding-block: var(--cs-sec-gap);
}

/* Square, so the mounted 3D icon reads its own width and gets a matching
   height before app-icon-3d.js ever measures it — the same contract
   .cs-hook-icon keeps for the hero's copy of this component. */
.cs-close-mark { width: 88px; aspect-ratio: 1; }

/* Same size and weight as every section heading (.cs-h2) on the page, on
   instruction — this line is a closing statement, not a bigger crescendo
   than the sixteen headings before it. Was --text-headline/400, a page-wide
   token this component was the only user of on this page. */
.cs-close-line {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--cs-h2);
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--cs-fg);
  margin: 0;
}

/* A solid black pill, on instruction — matching the site's other two CTAs
   (the hero's "more about me" and the custom cursor label): same 999px
   radius, same var(--color-dark) fill / var(--color-cream) text, and the
   arrow reads off currentColor rather than sitting in its own filled disc,
   so all three read as one family. Literal color-dark/cream rather than the
   context-flipping --cs-fg/--cs-label: this section is always cream ground
   (never a dark band), and the pill must stay black regardless. */
.cs-close-cta {
  display: inline-flex;
  align-items: center;
  gap: 16px;
  min-height: 44px;
  padding: 16px 24px;
  border-radius: 999px;
  background: var(--color-dark);
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  color: var(--color-cream);
  text-decoration: none;
  transition: opacity 0.25s ease;
}

.cs-close-cta:hover {
  opacity: 0.85;
}

.cs-close-cta-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: currentColor;
}

.cs-close-back {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  color: var(--cs-label);
  text-decoration: none;
  transition: color 0.25s ease;
}

.cs-close-back:hover { color: var(--cs-fg); }


/* ═══ §13 Loaders — the "after" screen scans the card it is verifying ═══════
   The capture underneath is the whole screen and it already contains a
   finished PAN card. This overlay redraws that one card in vector, at the
   exact box it occupies in the capture (351x208 at 184,708 of 720x1560), and
   covers it — so the beam and the badge have something live to play against
   while status bar, header, headline and the social-proof strip stay real
   pixels.

   Everything here is scoped to body.cs-motion, which case-study-motion.js
   adds only when GSAP actually loaded AND the reader has not asked for
   reduced motion. Without it the overlay never displays and the section
   renders as the screenshot — a finished still of the same screen, not a
   blank. That is the page's standing rule (nothing is hidden by the
   stylesheet that the stylesheet cannot also restore) held to here.       */
.cs-loader-live {
  /* Geometry is the capture's own: x/width against 720, y/height against
     1560, so the drawing tracks the screen at every rendered width. The
     viewBox is the card box grown to hold the beam's overrun and the badge,
     which is why these are not simply the card's own numbers. */
  left: 22.7778%;
  top: 43.0769%;
  width: 54.3056%;
  height: 17.0513%;
  display: none;
  overflow: visible;
}

body.cs-motion .cs-loader-live { display: block; }

/* The capture's own PAN string, in the site's UI face. textLength in the
   markup holds it to the width it measures there, so the substitution cannot
   shift the card's balance. */
.cs-loader-pan {
  font-family: var(--font-ui);
  /* Sized to the cap height the capture measures (12 units), not to a round
     number — textLength already pins the width, so this is the only axis
     left that can give the substituted face away. */
  font-size: 15px;
  fill: #3F3E45;
}

/* Start states. JS writes over all of these the moment it wires the section;
   they exist so the first painted frame is the resting card, never a beam
   parked mid-screen or a badge already earned. */
.cs-loader-beam,
.cs-loader-tail,
.cs-loader-badge { opacity: 0; }


/* ═══ §19 Selfie check — the camera opens, she frames herself, it confirms ══
   Same construction as §13's loader: the capture underneath is the whole
   screen and already contains the finished illustration in its circle; this
   overlay redraws that one circle live, at the exact box it occupies in the
   capture (474 across, centred 374.5,840.5 of 750x1625).

   Scoped to body.cs-motion, which case-study-motion.js adds only when GSAP
   loaded AND the reader has not asked for reduced motion. Without it nothing
   here displays and the section is the screenshot it always was.          */
.cs-selfie-live {
  /* Geometry is the capture's own: x/width against 750, y/height against
     1625. The box is square (0.64 x 750 == 0.2954 x 1625 at this frame's
     9/19.5), so the circle stays a circle at every rendered width. */
  left: 17.9333%;
  top: 36.9538%;
  width: 64%;
  height: 29.5385%;
  display: none;
  overflow: visible;
}

body.cs-motion .cs-selfie-live { display: block; }

/* Start states. JS overwrites all of these as it wires the section; they are
   here so the first painted frame is a closed shutter rather than a figure
   parked mid-slide or a rim light already green. */
.cs-selfie-figure { opacity: 0; }


/* ═══════════════════════════════════════════════════════════════════════════
   myspace.html
   ───────────────────────────────────────────────────────────────────────────
   What that page needs and this stylesheet did not already have: one centred
   screen, a numbered walkthrough, and two small additions to §4's voice card
   so it can carry a user segment instead of a verbatim. Everything else on
   it is reused as-is — the hook, the meta card, the ask section and its
   tilted card row, the green and dark bands, the bubble, the tabs, the
   results row, the close.

   Scoped by class rather than by a page selector, because nothing here is
   MySpace-specific in principle — a later case study wanting one centred
   screen, a numbered walkthrough or a segment card should use these rather
   than write a second variant.
═══════════════════════════════════════════════════════════════════════════ */


/* ── One measure for every heading and its subhead ─────────────────────────
   A section's heading and the subhead under it end on the same right edge,
   page-wide. The two elements default to measures in their OWN ems — .cs-h2
   at 18em of --cs-h2, .cs-sub at 40em of --text-ui — so at 1440 they resolve
   to roughly 863px and 560px and the two ragged edges sit 300px apart. That
   reads as a mistake in a section that opens on both.

   Written as 18 x the HEADING's font size, exactly as .cs-sub--wide already
   does for the one section that had been fixed by hand. --cs-h2 is a clamp(),
   so one figure tracks the whole fluid curve and the pair cannot drift at any
   width. This generalises that fix to the page and supersedes .cs-ask's own
   --ask-measure here, which is a fixed 54rem and therefore only agrees with
   the heading at one viewport.

   Scoped to .cs-ms rather than applied to the base components because
   jupiter.html's measures are tuned and must not reflow. */
.cs-ms .cs-h2,
.cs-ms .cs-sub { max-width: calc(18 * var(--cs-h2)); }

/* Running copy under a subhead takes the subhead's ink, not the page's full
   strength. .cs-prose defaults to --color-dark, which put §5-7's supporting
   detail DARKER than the subhead introducing it — the hierarchy upside down,
   since the same size in stronger ink reads as the more important of the two.

   The measure stays .cs-prose's own 40em and is deliberately NOT pulled up to
   the shared heading measure above: that rule is for a heading and its
   subhead, and 18 x --cs-h2 is ~863px at 1440, which is past 100 characters
   a line for body copy. A subhead is a wide single statement; a paragraph is
   not. So a section reads heading (ink, display) -> subhead (grey, wide) ->
   body (grey, narrower), descending on both counts.

   The min() is what keeps that true at every width and not just at desktop.
   --cs-h2 is a clamp() that bottoms out at 1.5rem, while .cs-prose's 40em
   tracks --text-ui and does not bottom out with it — so below roughly 1100px
   the subhead's measure falls under the prose's and the body starts rendering
   WIDER than the subhead introducing it. Measured at the ~780px case: subhead
   468px against prose 560px. Capping the prose to whichever is smaller keeps
   the descent intact all the way down; at 1440 the min() is inert and 40em
   (640px) still wins.

   jupiter.html does not use .cs-prose at all, so this is scoped to .cs-ms
   only out of the same caution as the measure rule. */
.cs-ms .cs-prose {
  color: var(--cs-label);
  max-width: min(40em, calc(18 * var(--cs-h2)));
}


/* ── No halo on this page's "after" screen ─────────────────────────────────
   .cs-pro-card--now adds --cs-screen-glow, a coral halo, over the card
   shadow. On jupiter.html that marks the screen the story lands on. §5 here
   borrows the same component for the opposite purpose: its "after" is the
   idea that got dropped, and the copy under it explains why. A halo would
   celebrate the thing the section goes on to reject.

   Both of the base rules that set it are re-declared with the shadow alone
   rather than overridden with `none`, so the screens keep the card shadow
   every other screen on the site carries. The base reaches the image two
   ways — `> img` and `.cs-shot-img` — so both are answered. */
.cs-ms .cs-pro-card--now > img,
.cs-ms .cs-pro-card--now > video,
.cs-ms .cs-pro-card--now .cs-shot-img { box-shadow: var(--cs-screen-shadow); }


/* ── Bubbles align to the section's own right edge, page-wide ──────────────
   .cs-bubble is width: fit-content with no inline margin, so left in the
   markup is where it renders. jupiter.html's §4 corrects this with a scoped
   rule (`.cs-ask .cs-bubble { margin-left: auto }`) so its takeaway ends flush
   with the card row's own right edge rather than hanging off the left margin.

   §5 here is a plain .cs-sec, not .cs-ask, so that rule never reached it — its
   bubble rendered flush left instead, the one section on the page whose
   right-edge rule was different from the rest. Generalised here rather than
   duplicated: every bubble on a .cs-ms page now closes to its section's right
   edge, matching the convention the rest of the page already keeps for
   headings and subheads. */
.cs-ms .cs-bubble { margin-left: auto; }


/* ── .cs-voice, adapted — a card that is a segment, not a verbatim ─────────
   myspace.html's §4 is built on jupiter.html's §4, but a card there holds a
   user segment rather than something somebody said, so two things change.

   The big line is upright display type, not Geist italic. Italic is reserved:
   it is how this site marks a quotation, and .cs-voice-quote's own note says
   its italic exists because "it is someone being quoted mid-sentence".
   Reusing it would have claimed these three lines were overheard.

   And the card is laid out as an editorial block rather than a stacked list —
   see .cs-voices-grid--step below. */
.cs-voice-line {
  font-family: var(--font-display);
  /* 500 is Clash Display's own file, as every other 500 on this site is. */
  font-weight: 500;
  font-size: var(--text-lead);
  line-height: 1.25;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
  margin: 0;
}

/* The segment name. The eyebrow recipe exactly — --text-label / 700 / 0.08em
   uppercase — so it reads as the same class of label as every section eyebrow
   on the page.

   This replaced .cs-voice-method's filled green chip, which read as dated
   against a page that marks structure with hairlines and carries no filled
   surfaces outside the meta card. Ink rather than that chip's green: with no
   coloured ground behind it, green type on white would be the one
   low-contrast label on the page for no reason, and the icon now carries the
   colour the chip used to — attached to something that means one thing.

   .cs-voice-method itself is deliberately untouched: jupiter.html still uses
   it, and that page's chip is not the one that needed changing.

   Colour is --cs-label, not --cs-fg: on instruction, this is now the
   .cs-eyebrow recipe exactly, not just its size and tracking — a section
   eyebrow names the section without competing with its heading, and a card
   tag does the same job for the card underneath it. Matching its ink as well
   as its shape is what makes that reading explicit rather than incidental. */
.cs-voice-tag {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 700;
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

/* Fixed 48px rather than an em size. These are raster illustrations with
   their own internal line weights; scaling them off the type would thin or
   thicken those against the card's other rules at different breakpoints.
   Served at 144px (3x) so they stay sharp on a retina screen, with width and
   height repeated on the <img> so the box is reserved before the file lands.

   Pinned to the card's top RIGHT, opposite the text at the bottom left. That
   diagonal, and the air the card keeps between the two, is the whole layout —
   see the grid below. */
.cs-voice-icon {
  align-self: flex-end;
  display: block;
  width: 48px;
  height: 48px;
}

.cs-voice-text {
  /* Pushes the pair to the card's bottom edge, which is what opens the gap
     under the icon. The cards are grid items and stretch to a common height,
     so all three text blocks land on one baseline however long the copy. */
  margin-top: auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}


/* ── .cs-voices-grid--step — the cards as an ascending run ─────────────────
   An editorial layout rather than three identical tiles in a line: each card
   sits higher than the one before it, the icon anchors the top right, and the
   name and description sit at the bottom left with real space between.

   The rise is not decoration. The three segments are a progression in how far
   into their money a person has got — tracking spends, then working rewards,
   then investing — so the row climbs left to right, and the third card's own
   illustration is an ascending bar chart. The composition says what the copy
   says.

   It replaces the base component's tilt. Both exist to stop the set reading
   as a filed row, and running both would be two devices doing one job.

   Scoped to 1024+ for the same reason the tilt is: below that the cards are
   stacked one per row, where a stagger has no row to step across. */
@media (min-width: 1024px) {
  .cs-voices-grid--step .cs-voice {
    --step-y: 0px;
    /* Not rotate(): this overrides the base tilt on children 1 and 3. */
    transform: translateY(var(--step-y));
    /* Room for the icon, the gap, and two lines of text with air between.
       Without it the shortest card collapses to its content and the diagonal
       reads as three different-sized boxes rather than one stepped run. */
    min-height: 216px;
  }

  .cs-voices-grid--step .cs-voice:nth-child(1) { --step-y: 48px; }
  .cs-voices-grid--step .cs-voice:nth-child(2) { --step-y: 24px; }
  .cs-voices-grid--step .cs-voice:nth-child(3) { --step-y: 0px; }

  /* A transform does not affect layout, so the first card hangs 48px below
     the grid's own box and would close the gap to the takeaway bubble.
     Padding the grid by the largest offset gives that overhang real space to
     live in and keeps the section's rhythm. */
  .cs-voices-grid--step { padding-bottom: 48px; }

  /* The step is the resting state, so hover keeps it and adds the lift on
     top. Reading --step-y back means the two cannot fall out of sync. */
  @media (hover: hover) {
    .cs-voices-grid--step .cs-voice:hover {
      transform: translateY(var(--step-y)) scale(1.02);
    }
  }
}

/* Below the three-up, a compact row instead: icon left, text right, centred.
   The diagonal above needs a tall narrow card to work — pinning the icon to
   the top right and the text to the bottom left of a card that is the full
   column wide and only ~156px tall leaves a hole through the middle and reads
   as a mistake rather than as composition. Same two elements, arranged for
   the shape the card actually is at this width. */
@media (max-width: 1023px) {
  .cs-voices-grid--step .cs-voice {
    flex-direction: row;
    align-items: center;
    gap: 20px;
  }

  .cs-voices-grid--step .cs-voice-icon { align-self: center; }

  /* Cancels the push to the bottom edge, which is what created the hole. */
  .cs-voices-grid--step .cs-voice-text { margin-top: 0; }
}

/* The base component puts its cards on a three-row subgrid — tag, quote,
   attribution — to line those rows up across the set. This layout does not
   want that: it is two blocks pinned to opposite ends of a flex column, and
   subgrid would override the display and strand margin-top: auto.

   Same specificity as the rules being corrected on the grid (one class), so
   it depends on sitting after them; the .cs-voice override is a class deeper
   and wins outright. */
@supports (grid-template-rows: subgrid) {
  @media (min-width: 1024px) {
    .cs-voices-grid--step { grid-template-rows: none; }
    .cs-voices-grid--step .cs-voice {
      display: flex;
      flex-direction: column;
      grid-row: auto;
    }
  }
}


/* ── .cs-solo — one screen, centred, with a caption ────────────────────────
   .cs-strip could not do this job. It is a bleeding four-up row that caps
   itself at two cards below 893px, so a lone child inherits sizing written
   for a set and sits at the same small width four screens share.

   This one is the page's single hero artefact, so it is deliberately larger
   than a walkthrough step: --cs-solo-w tops out at 320px against the shared
   screen size's ~269px. Both bounds are multiples of 4.

   No scroller anywhere near it, so --cs-screen-shadow has room to render in
   full (CLAUDE.md gotcha 11). */
.cs-page { --cs-solo-w: clamp(240px, 28vw, 320px); }

.cs-solo {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.cs-solo .cs-shot-img {
  width: var(--cs-solo-w);
  /* The shared rule caps every screen at --cs-screen-w (280px). This one is
     allowed past it, so the cap is lifted here rather than raised there. */
  max-width: none;
}

/* ── .cs-solo--demo — the same component, sized as the page's hero visual ──
   myspace.html's §3 (the "show, don't tell" walkthrough) reuses .cs-solo
   rather than becoming a fourth component, but wants more room than a
   screenshot standing next to running copy: it is the whole section, with no
   heading and no competing text, so it can afford to be the biggest single
   asset on the page. 280 / 400 are both multiples of 4, per the page's
   spacing rule — this is a dimension, not typography, so it answers to that
   scale rather than to --text-*. */
.cs-solo--demo { --cs-solo-w: clamp(212px, 27vw, 300px); }

.cs-solo--demo .cs-ph { max-width: var(--cs-solo-w); }


/* ── .cs-navswap — the nav bar, before and after, side by side ─────────────
   myspace.html §6. Two bottom-nav captures with a curved arrow drawing
   between them.

   Sizing is the whole point of this layout. Stacked full-width, a 720x176
   capture renders about 193px deep, which reads as a dark slab rather than as
   a strip of chrome — the thing it actually is. Side by side, each lands near
   40% of the column, roughly 100px deep, and the proportion is believable
   again.

   The two are NOT equal: before takes 1fr of the free space and after 1.2fr,
   the same step --cs-screen-step applies to every then/now pair on this site,
   so the right-hand bar is visibly the one being arrived at.               */
.cs-navswap {
  margin: 0;
  display: flex;
  /* flex-start, not center: the two items are different heights, so centring
     them pushed their key rows out of line with each other by half the
     difference — about 8px, which is small enough to read as a mistake rather
     than as a decision. Hanging both from the same top line puts the two keys
     on one baseline and lets the size difference show where it should, at the
     bottom edge. */
  align-items: flex-start;
  justify-content: center;
  gap: 16px;
}

.cs-navswap-item {
  display: flex;
  flex-direction: column;
  gap: 12px;
  /* flex-basis 0 so the 1 / 1.2 ratio governs the widths outright rather than
     being applied to whatever the images happen to measure. */
  min-width: 0;
}

.cs-navswap-item--before { flex: 1 1 0; }
.cs-navswap-item--after  { flex: var(--cs-screen-step) 1 0; }

/* The tilt lives here, one level inside the element the motion layer
   animates. wirePop writes an inline transform and does not clearProps, so a
   rotate() on the animated element itself would be wiped the instant the
   entrance played. Keeping them on separate elements lets the entrance, the
   tilt and the hover all own a transform without collision. */
.cs-navswap-bar {
  display: block;
  border-radius: var(--radius);
  overflow: hidden;
  box-shadow: var(--cs-card-shadow);
  transform: rotate(var(--nav-tilt, 0deg));
  transition: transform 0.3s ease;
}

.cs-navswap-item--before .cs-navswap-bar { --nav-tilt: -2deg; }
.cs-navswap-item--after  .cs-navswap-bar { --nav-tilt: 1.5deg; }

.cs-navswap-bar img {
  display: block;
  width: 100%;
  height: auto;
}

/* Straighten and lift on hover, the same gesture .cs-voice uses for its
   tilted cards. Pointer devices only: on touch there is no hover to leave, so
   a bar would straighten on tap and stay straight. */
@media (hover: hover) {
  .cs-navswap-bar:hover { transform: rotate(0deg) scale(1.03); }
}

@media (prefers-reduced-motion: reduce) {
  .cs-navswap-bar { transition: none; }
}

.cs-navswap-key {
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 700;
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

/* The arrow's own column. Fixed rather than flexible so the two bars keep
   their 1:1.2 relationship to each other instead of to the gap. */
.cs-navswap-link {
  flex: 0 0 auto;
  width: clamp(64px, 10vw, 120px);
  /* Centred against the taller of the two items, which puts it between the
     two bars rather than level with either — the row is flex-start now, so
     this is the one thing that still wants the middle. */
  align-self: center;
}

.cs-navswap-arrow {
  display: block;
  width: 100%;
  height: auto;
  stroke: var(--cs-label);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  fill: none;
}

/* Below the three-up widths the pair stacks, and the arrow turns to point
   down the page instead of across it. Rotating the existing glyph rather than
   swapping in a second one keeps there being one arrow to maintain. */
@media (max-width: 767px) {
  .cs-navswap { flex-direction: column; gap: 8px; }
  .cs-navswap-item--before,
  .cs-navswap-item--after { flex: 0 0 auto; width: 100%; }
  .cs-navswap-link { width: clamp(48px, 16vw, 72px); transform: rotate(90deg); }
}


/* ── .cs-blocks — §7's widget grid ─────────────────────────────────────────
   Four wells, 2 x 2, spanning the section's own column. Each well holds one
   widget, and each widget is its own carousel of that widget's states —
   the interaction the system was modelled on, so the section demonstrates
   the argument its heading makes rather than only asserting it.

   ── Not looking retro is a set of small decisions, not one ─────────────────
   A tinted grid of boxes is exactly the thing that dates, so each of these is
   deliberate:

     - No border on the well. A hairline around a box that is ALREADY
       distinguished by its fill states the same boundary twice, which is the
       table look.
     - No shadow on the well. These are recessed wells, not cards lifted off
       the page, and a drop shadow on a tinted box is the 2010s skeuomorphic
       tell. The WIDGET inside does cast one — that is the whole point of the
       arrangement: a recessed well with something floating in it, rather
       than two stacked cards.
     - No dashed placeholder inside. The cell is the frame.
     - Real internal padding, so the widget floats in its well rather than
       filling it corner to corner.

   The fill is DERIVED, not picked: --color-dark mixed 6% into the page's own
   cream. That keeps it the same family as the ground at every future change
   to either token, and keeps it warm — a beige a few steps down from the page
   rather than a grey laid over it. 6% lands around #EAE9E5, which reads as a
   distinct surface without becoming a panel.                                */
.cs-page {
  --bb-ground:  color-mix(in srgb, var(--color-dark) 6%, var(--color-cream));
  --bb-gap:     16px;
  --bb-pad:     24px;

  /* Tuned to the real captures rather than guessed. The widest state is
     640x428 and the tallest is the 312x440 compact; the cell has to seat
     both without either being cropped or the well changing size between
     states. Derived at the 1440 breakpoint, where a cell is 467 wide:
     419 inner width, minus the dot row and the slide's shadow gutters,
     leaves the 640x428 card able to run to full width. */
  --bb-cell-ar: 6 / 5;

  /* Reserved in CSS, not created by the dots. widget-carousel.js injects the
     pagination row, so if it never runs the layout must already be the shape
     it will be once it does — otherwise the grid reflows on hydration. */
  --bb-dot-row: 24px;

  /* The scroll port clips on the cross axis (overflow-x: auto forces
     overflow-y to a non-visible value), so the cast shadow has to fit INSIDE
     the track or it gets sheared off at the edge. At --bb-scale the widget
     leaves roughly 70px of slack above and below it, which is well past the
     ~30px the lower shadow stop reaches, so this padding is no longer what
     saves the shadow — it is what pins the height bound, and it is symmetric
     so the widget sits on the exact centre line. */
  --bb-slide-pad: 16px;

  /* Every widget renders at this fraction of the space its slide gives it.
     Applied to BOTH bounds below, so each capture lands at exactly this
     fraction of the size it had before — the compact portrait shrinks by the
     same amount as the wide cards, and nothing is cropped to get there. */
  --bb-scale: 0.6;

  /* Clear air between one widget and the next. Only ever seen in passing now
     that a slide fills the port: it is the beat of empty ground that crosses
     the frame mid-swipe, which is what stops two states reading as one
     continuous strip. */
  --bb-peek-gap: 40px;

  /* drop-shadow, not box-shadow — the captures are rounded cards on
     transparent ground, and box-shadow follows the border box, which would
     cast a hard rectangle around the transparent corners. Same reason and
     same two-stop recipe as --cs-bubble-shadow: a tight contact stop so the
     card sits ON the well, and a wide soft stop so it reads as floating
     above it. Mixed from --color-dark, so it stays in the page's family. */
  --bb-shadow:
    drop-shadow(0 1px 2px  color-mix(in srgb, var(--color-dark) 14%, transparent))
    drop-shadow(0 10px 20px color-mix(in srgb, var(--color-dark) 18%, transparent));
}

.cs-blocks {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* One column below 640: two cells across a phone leaves ~172px each, which
     is under any width a widget capture reads at. */
  grid-template-columns: 1fr;
  gap: var(--bb-gap);
}

@media (min-width: 640px) {
  .cs-blocks { grid-template-columns: 1fr 1fr; }
}

/* One column makes the cell proportionally much wider than it is tall, and
   6/5 then leaves the widget height-bound: it was rendering 246px wide
   inside 279px of available width on a 375 phone, i.e. small for no reason.
   A shallower cell lets the same capture run to the full measure. Derived
   the same way as --bb-cell-ar above, against the 279px inner width. */
@media (max-width: 639px) {
  .cs-page { --bb-cell-ar: 10 / 9; }
}

.cs-block {
  display: flex;
  aspect-ratio: var(--bb-cell-ar);
  padding: var(--bb-pad);
  background: var(--bb-ground);
  border-radius: var(--radius);
  /* Grid children default to min-width auto and refuse to shrink below their
     content; without this a wide capture sets the track rather than the track
     sizing the capture. Same guard .cs-ph carries. */
  min-width: 0;
}

/* ── The carousel ──────────────────────────────────────────────────────────
   A real scroll container, not a transform rig. That choice buys the whole
   touch story for nothing: swipe, momentum and snap are the platform's, so
   they match the reader's OS instead of approximating it — which matters
   here, where the thing being demonstrated IS an OS widget picker. It also
   means the component degrades to something honest: with no JS the states
   are all still present and still reachable by swipe or trackpad.          */
.cs-wdg {
  position: relative;
  flex: 1 1 auto;
  align-self: stretch;
  min-width: 0;
  display: flex;
  flex-direction: column;
  /* The dot row's space, held open by the box itself rather than by the dots.
     They are injected by script, so reserving it here is what makes the
     no-JS rendering the same shape as the enhanced one — and what stops the
     widget resizing between first paint and hydration. */
  padding-bottom: var(--bb-dot-row);
}

.cs-wdg-track {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  gap: var(--bb-peek-gap);
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  /* Four carousels sit in one page. Without this, flicking past the end of
     one hands the gesture to the page and the reader loses their place. */
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}

/* The dots ARE the affordance, and a scrollbar under each of four cells is
   four pieces of chrome saying what the dots already say. Scrolling itself
   is untouched. */
.cs-wdg-track::-webkit-scrollbar { width: 0; height: 0; }

/* One slide per port, so exactly one widget is ever on screen at rest and no
   neighbour is sliced by the container edge. The widget itself is --bb-scale
   of that port, which leaves the rest of the slide as clear air around it —
   and that air is what the drop shadow casts into, so nothing has to be
   clipped to make room. */
.cs-wdg-slide {
  flex: 0 0 100%;
  scroll-snap-align: center;
  /* Without this a fast flick crosses several states and lands somewhere the
     reader did not choose; the iOS picker it is imitating advances one. */
  scroll-snap-stop: always;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  margin: 0;
  /* Symmetric, unlike the version that peeked. That one carried more padding
     below than above to buy the shadow its room on the side it actually
     falls; centring matters more here, and there is now enough slack on
     every side that the shadow needs no help. Keeping a block padding at all
     is what holds the height bound where it was, so the two portrait
     captures do not grow when the layout around them changes. */
  padding: var(--bb-slide-pad) 0;
}

/* Fits the widget inside the slide on whichever axis runs out first, so the
   640-wide cards and the 312-wide portrait one both seat themselves without
   being told apart — and neither is cropped or stretched. No border-radius:
   the captures carry their own corners on transparent ground, so rounding
   the box would only nick them.

   Both bounds carry the scale, which is what makes the reduction exact
   rather than approximate. The slide is the full port again, so unlike the
   peeking version the WIDTH bound has to apply --bb-scale itself rather than
   inheriting it from a narrowed slide; the height bound applies it to the
   slide's content box. Whichever axis was binding before is still binding,
   and the aspect ratio is untouched either way. */
.cs-wdg-slide img {
  display: block;
  max-width: calc(var(--bb-scale) * 100%);
  max-height: calc(var(--bb-scale) * 100%);
  width: auto;
  height: auto;
  filter: var(--bb-shadow);
}

/* ── Chrome injected by widget-carousel.js ─────────────────────────────── */

/* Absolute, into the band .cs-wdg holds open above. In flow it would only
   occupy space that already exists, and the track would have been the one
   giving it up. */
.cs-wdg-dots {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--bb-dot-row);
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* 24px square, which is the WCAG 2.2 minimum target, with an 8px dot drawn
   inside it. Sizing the button to the dot would give a 8px tap target. */
.cs-wdg-dot {
  appearance: none;
  -webkit-appearance: none;
  width: 24px;
  height: 24px;
  padding: 0;
  margin: 0;
  border: 0;
  background: none;
  display: grid;
  place-items: center;
  cursor: pointer;
}

.cs-wdg-dot::before {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--color-dark) 22%, transparent);
  transition: background-color 0.2s ease;
}

.cs-wdg-dot[aria-current='true']::before {
  background: color-mix(in srgb, var(--color-dark) 64%, transparent);
}

.cs-wdg-dot:focus-visible {
  outline: 2px solid var(--color-dark);
  outline-offset: -2px;
  border-radius: 50%;
}

.cs-wdg-arrow {
  position: absolute;
  /* Centred on the IMAGE, not on the component: the dot row lives at the
     bottom, so the component's midpoint sits below the widget's. */
  top: calc((100% - var(--bb-dot-row)) / 2);
  transform: translateY(-50%);
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--color-cream) 90%, transparent);
  color: var(--color-dark);
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.18s ease, background-color 0.18s ease;
}

.cs-wdg-arrow--prev { left: 0; }
.cs-wdg-arrow--next { right: 0; }

.cs-wdg-arrow svg { width: 16px; height: 16px; display: block; }

.cs-wdg-arrow:hover { background: var(--color-cream); }

/* Revealed by intent — pointing at the cell, or tabbing into it. Four cells
   showing eight arrows at rest would be more chrome than widget. */
.cs-wdg:hover .cs-wdg-arrow,
.cs-wdg:focus-within .cs-wdg-arrow { opacity: 1; }

/* Touch has the swipe, which is the gesture this is imitating in the first
   place; the arrows would only cover the widget. */
@media (hover: none) {
  .cs-wdg-arrow { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .cs-wdg-track { scroll-behavior: auto; }
  .cs-wdg-dot::before,
  .cs-wdg-arrow { transition: none; }
}

/* ── .cs-flow — §8's onboarding rail ───────────────────────────────────────
   Four screens in a row, one centred and lit, the rest dimmed, moved by an
   ordinary horizontal scroll.

   ── The construction ───────────────────────────────────────────────────────
   The rail is a plain scroll container that breaks out of the section column
   and runs the full width of the viewport, so a screen leaving the frame is
   cut by the edge of the window rather than by an invisible column boundary.
   Only the heading above it stays columned.

   Centring falls out of the padding rather than out of any measurement:
   padding-inline is half a viewport less half a screen, so at scroll zero
   screen 01 sits on the centre line, and the last screen can reach that same
   line instead of stopping short against the end of the content.

   scroll-snap-align: center on each step is what makes every screen settle in
   the middle. scroll-snap-stop keeps a fast flick to one screen at a time, so
   the row advances in order rather than skidding to the end.

   ── Why the dimming is a class and not a stylesheet default ────────────────
   Everything renders at full opacity until case-study-flow.js adds
   .cs-flow--live. Without that, the rail is still a working snapping scroller
   and all four screens are legible — nothing is hidden by the stylesheet,
   which is the same contract the motion layer and the prologue keep.        */
.cs-page {
  /* vw and not a percentage, and that is load-bearing. This token is read in
     two places whose percentages resolve against DIFFERENT boxes: padding-
     inline resolves against the rail's containing block, while the steps'
     flex-basis resolves against the rail's own CONTENT box — which is that
     containing block minus the very padding being computed from it. A `66%`
     here came out as 280px in one and 185px in the other, and the first
     screen sat 47px left of centre. A viewport-relative length resolves
     identically in both, so the padding really is the slack either side of
     one step. */
  --flow-shot-w: min(var(--cs-screen-w), 60vw);
  --flow-gap:    80px;

  /* The captures in this rail are 9/20, not the 9/19.5 every other screen on
     the page uses, so the placeholders match THEM rather than the other way
     round. Retune if the remaining three land at a different ratio — but do
     not reach for .cs-shot-img's cover here: cropping would move the shot's
     pixels relative to its box, and the pulse overlay below is positioned in
     percentages measured off the uncropped file. */
  --flow-ar: 9 / 20;

  /* Measured off the assets, not estimated. The My Space glyph in
     onboarding-1-home.png centres at (359.5, 1415.5) in its 720x1600 frame,
     and myspace-pulse.png was drawn 58px wide for that same frame — so its
     resting size is written as that ratio rather than as a number, which
     says where it came from and survives any re-export at another density.
     Percentages throughout, so the overlay tracks the shot at any width. */
  --flow-pulse-x:    49.93%;
  --flow-pulse-y:    88.47%;
  --flow-pulse-size: calc(58 / 720 * 100%);
  --flow-dim:    0.32;

  /* Overflow allowance, not designed spacing. A scroller computes overflow-y
     to auto and clips, so the screens' shadow would be shaved off top and
     bottom once real captures replace the placeholders. --cs-screen-shadow's
     deepest layer is 0 14px 32px -10px, which reaches 14 + 16 - 10 = 20px
     below the box; 24 clears it and stays on the 4px grid. Cancelled to the
     pixel by a negative margin, so it costs the layout nothing. */
  --flow-bleed:  24px;
}

/* Heading row and rail in one column, at the section's own block rhythm —
   the wrapper replaces .cs-sec's gap for these two children rather than
   adding a second, different one. */
.cs-flow {
  display: flex;
  flex-direction: column;
  gap: var(--cs-blk-gap);
}

/* The heading on the column's left edge, the hint on its right, sharing one
   baseline. baseline and not centre: the hint is a line of text set beside a
   line of text, and centring it against the heading's much larger box would
   float it off the line the heading is written on. Both stay columned while
   the rail below them bleeds. */
.cs-flow-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 24px;
}

/* flex:none so a long heading never squeezes it, and the label comes before
   the chevrons so the baseline the row aligns to is the word's, not the
   icon's. */
.cs-flow-hint {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  flex: none;
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  color: var(--cs-label);
  transition: opacity 0.3s ease;
}

.cs-flow-hint-icon {
  width: 18px;
  height: 18px;
  flex: none;
  /* Nudged along by the shared keyframe below, which the journey's own hint
     on the sibling page also runs — one definition, so the two cannot drift
     into telling the reader the same thing at two different speeds. */
}

@media (prefers-reduced-motion: no-preference) {
  .cs-flow-hint-icon { animation: cs-hint-nudge 1.2s ease-in-out infinite; }
}

/* Once the last screen is centred there is nothing left to scroll to, and a
   hint still pointing right would be telling the reader something untrue.
   Driven by case-study-flow.js, so it only ever appears on a rail the script
   is actually tracking. */
.cs-flow--live.is-end .cs-flow-hint {
  opacity: 0;
}

.cs-flow-rail {
  display: flex;
  gap: var(--flow-gap);
  margin: 0;
  /* Escape the column out to the viewport, the same construction the sibling
     page's Iterations rail uses. Without it the scrollport ends at the
     column edge and screens are sliced by a boundary the reader cannot see,
     which reads as a rendering fault rather than as more content. Bled, they
     crop against the SCREEN, which is what says "this keeps going". */
  margin-inline: calc(50% - 50vw);
  /* Half a viewport less half a screen: the slack that puts the first screen
     on the viewport's centre line at scroll zero, and lets the last one reach
     that same line at the end of the scroll. Because the rail is now
     viewport-wide, this is measured off 50vw rather than off the column. */
  padding-inline: calc(50vw - var(--flow-shot-w) / 2);
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  /* Four rails could live on one page one day; without this, flicking past
     the end of one hands the gesture to the document. */
  overscroll-behavior-x: contain;
  padding-block: var(--flow-bleed);
  margin-block: calc(-1 * var(--flow-bleed));
}

.cs-flow-rail::-webkit-scrollbar { width: 0; height: 0; }

.cs-flow-step {
  flex: 0 0 var(--flow-shot-w);
  scroll-snap-align: center;
  /* One screen per gesture, so the flow is read in order. */
  scroll-snap-stop: always;
  min-width: 0;
}

/* Placeholder and real capture share one ratio, so a slot still waiting on
   its asset is exactly the height of one that has it and the row cannot jog
   as the remaining screens arrive. */
.cs-flow-step > .cs-ph {
  width: 100%;
  max-width: none;
  --ph-ar: var(--flow-ar);
}

.cs-flow-shot {
  position: relative;
  margin: 0;
  width: 100%;
}

/* height:auto, no object-fit: the shot is shown whole. A cover crop would
   shave the status bar and the very nav bar this section is about, and would
   break the pulse's percentage positioning at the same time. */
.cs-flow-shot img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: var(--flow-ar);
  border-radius: var(--radius);
  box-shadow: var(--cs-screen-shadow);
}

/* ── The My Space pulse ────────────────────────────────────────────────────
   Shriya's own circle asset, laid over the tab and swelled three times. It is
   the supplied PNG and nothing else — no gradient is drawn here, because the
   asset already carries its own two-tone fill and 70% alpha, and a second
   glow underneath it would read as a halo around a halo.

   Decorative: pointer-events:none so it never eats a drag on the rail, and
   aria-hidden in the markup because it carries no information a reader
   needs. It starts at opacity 0 and is only ever animated ON, so with no
   script the shot is simply a still screenshot — nothing the page depends on
   is hidden by the stylesheet.                                              */
.cs-flow-pulse {
  position: absolute;
  left: var(--flow-pulse-x);
  top: var(--flow-pulse-y);
  width: var(--flow-pulse-size);
  aspect-ratio: 1;
  translate: -50% -50%;
  background: url('img/screens/myspace/myspace-pulse.png') center / contain no-repeat;
  opacity: 0;
  pointer-events: none;
}

/* Exactly three, by iteration count — not a script counting frames. The final
   keyframe leaves it at opacity 0, so when the run ends there is nothing on
   screen and nothing to clean up. */
@media (prefers-reduced-motion: no-preference) {
  .cs-flow-shot.is-pulsing .cs-flow-pulse {
    animation: cs-flow-pulse 1600ms ease-out 3;
  }
}

/* Opens just under the asset's drawn size, comes up fast and trails away as
   it expands, so it reads as a breath rather than as a ring travelling out.
   2.2 at the top takes the 58px circle to about 128px on the shot's own
   scale — wide enough to catch the eye, short of covering its neighbours. */
@keyframes cs-flow-pulse {
  0%   { transform: scale(0.9); opacity: 0; }
  15%  { opacity: 1; }
  100% { transform: scale(2.2); opacity: 0; }
}

/* ── The CVP customisation loop ──────────────────────────────────────────
   Built by case-study-flow.js over the capture's top panel only — the grid
   area from y=160 to y=668 of the 720x1600 frame. Everything under it (the
   heading, subhead, two options, banner and CTA) stays the capture's own
   pixels, so none of that copy can drift.

   The stage is authored at the plates' native 720x508 and scaled to the
   shot's rendered width by the script, so every position in the timeline is
   a pixel measured off Shriya's recording rather than a percentage guessed
   at. overflow:hidden is what crops the rising phone at the panel's bottom
   edge, exactly where the recording crops it. */
.cs-cvp {
  position: absolute;
  left: 0;
  top: 10%;               /* 160 / 1600 */
  width: 100%;
  height: 31.75%;         /* 508 / 1600 */
  overflow: hidden;
  pointer-events: none;
}

.cs-cvp-stage {
  position: absolute;
  left: 0;
  top: 0;
  width: 720px;
  height: 508px;
  transform-origin: 0 0;
}

/* Every plate sits at its measured top-left and is never resized by CSS —
   the images render at their own pixel size inside the 720x508 stage. */
.cs-cvp-stage > * {
  position: absolute;
  display: block;
  max-width: none;
}

.cs-cvp-phone > * {
  position: absolute;
  display: block;
  max-width: none;
}

/* .cs-flow-shot img stretches the capture to the shot's width, rounds it and
   shadows it. None of that may reach the plates: each renders at exactly the
   size the script gives it, flat, or the measured positions stop meaning
   anything. */
.cs-flow-shot .cs-cvp img {
  aspect-ratio: auto;
  border-radius: 0;
  box-shadow: none;
}

/* Slices of the phone and cluster PNGs, cut with background-position so the
   cards can move independently without anyone re-exporting them. */
.cs-cvp-slice {
  background-repeat: no-repeat;
}

/* ── My Space, scrollable ────────────────────────────────────────────────
   Screen 03 is the only one in the rail the reader can operate. The capture
   is one 720x1600 export cut into three strips — status bar (0..64), body
   (64..1356) and nav bar (1456..1600) — and the Lifetime rewards widget is
   the SAME file the widget gallery in section 07 uses, so the two cannot
   drift apart. The body scrolls between the two strips, which is what puts
   rewards, cut off in the capture, within reach.

   Cut rather than rebuilt in markup: the screen is Shriya's artwork, and
   typing it out in HTML would be a redrawing of it that would then have to
   be kept in step with the Figma file by hand.

   Every offset is measured off that export: cards run x 42..678, the
   rewards card's top edge lands at y 1358, and the widget PNG carries a 2px
   transparent margin, so it is placed at x 40 and follows the body strip
   with no gap. Sizes are ratios of the 720px frame, so the whole screen
   scales with the rail. */
.cs-space {
  /* Sampled from the capture's own canvas, so the strips sit on exactly the
     colour they were cut from and no seam shows while scrolling. */
  --space-canvas: #f5f3ef;

  position: relative;
  margin: 0;
  width: 100%;
  aspect-ratio: var(--flow-ar);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--space-canvas);
  box-shadow: var(--cs-screen-shadow);
}

/* The scrollport. Focusable and named, so the arrow keys drive it and it is
   not an unlabelled scrollable region to a screen reader. Scroll chaining is
   left alone: at the bottom of the phone the page takes over, rather than
   trapping the reader inside a phone-sized box. */
.cs-space-scroll {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.cs-space-scroll::-webkit-scrollbar { width: 0; height: 0; }

.cs-space-page {
  display: flex;
  flex-direction: column;
}

/* .cs-flow-shot img stretches its image to the 9/20 frame, rounds it and
   shadows it. None of that may reach the strips: each renders at its own
   natural height inside the scrollport, flat, and .cs-space above already
   carries the radius and the shadow for all of them. */
.cs-flow-shot.cs-space img {
  display: block;
  width: 100%;
  height: auto;
  flex: none;
  aspect-ratio: auto;
  border-radius: 0;
  box-shadow: none;
}

/* Scoped as tightly as the reset above it, or that reset's width:100% wins
   on specificity and the card spills the full width of the frame. */
.cs-flow-shot.cs-space .cs-space-rewards {
  width: calc(640 / 720 * 100%);
  margin-left: calc(40 / 720 * 100%);
}

/* Air, sized off the frame's width so it scales with everything else: 64
   under the status bar, and 170 at the end — 24 of the stack's own gap plus
   the 148 the nav bar covers, less the widget PNG's 2px bottom margin — so
   the last card clears the bar instead of ending behind it. */
.cs-space-gap {
  display: block;
  flex: none;
  width: 100%;
  aspect-ratio: 720 / var(--space-gap-h);
}

/* The OS chrome, over the scrolling body rather than in it. */
.cs-space-status,
.cs-space-nav {
  position: absolute;
  left: 0;
  width: 100%;
  pointer-events: none;
}

.cs-space-status { top: 0; }
.cs-space-nav { bottom: 0; }


/* ── My Space customisation (screen 04) ───────────────────────────────────
   The one screen in the rail that works rather than depicts. Its chrome
   (status bar, back arrow, and the two-weight title with its subhead) and
   its six widget icons are slices of Shriya's export; the rows, switches and
   button are real controls, because they have to toggle, reorder, drag and
   save. See space-customise.js for the behaviour.

   Authored at the export's own 360x800 and scaled to the rail's width by
   that script, so every number below is one measured off the file: cards run
   x 21..338 on a 68px pitch from y 185, the grip is 20x12 at x 38, the icon
   12x12 at x 78, the switch 48x28 at x 276, and the footer band starts at
   y 685 under a 1px rule. The colours are the app's, sampled from the same
   file, which is why they are local rather than site tokens. */
.cs-cust {
  --cust-canvas: #f5f3ef;
  --cust-on:     #357065;
  --cust-off:    #626160;
  --cust-coral:  #e36e64;
  --cust-rule:   #e6e6e7;

  position: relative;
  margin: 0;
  width: 100%;
  aspect-ratio: var(--flow-ar);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--cust-canvas);
  box-shadow: var(--cs-screen-shadow);
}

/* .cs-flow-shot img would stretch the fallback to the 9/20 frame and round
   it; inside here the only image is that fallback, shown whole. */
.cs-flow-shot.cs-cust img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: auto;
  border-radius: 0;
  box-shadow: none;
}

.cs-cust-stage {
  position: absolute;
  left: 0;
  top: 0;
  width: 360px;
  height: 800px;
  transform-origin: 0 0;
  /* Jupiter's own face, not the site's. Everything live on this screen is
     app chrome, so setting it in Geist would print the product in the
     portfolio's voice. Scoped to the stage; see the note in myspace.html. */
  font-family: 'Manrope', var(--font-ui);
  color: var(--color-dark);
}

/* Her artwork for everything above the list, so the two-weight heading is
   her rendering of it rather than an approximation in two web faces. */
.cs-cust-chrome {
  position: absolute;
  left: 0;
  top: 0;
  width: 360px;
  height: 180px;
  background: url('img/screens/myspace/space/cust-chrome.png?v=3') 0 0 / 360px 180px no-repeat;
}

/* The title is set live, because its wording changed and her rendering of
   the old wording cannot be re-cut into it. These four values are her spec
   off the Figma panel — Manrope Medium, 32/40, no tracking — not inferred:
   at them the string measures 313.4px against the 311 she drew, and caps at
   23.5 against her 23. An earlier pass guessed Geist and got the descenders
   visibly short, which is what a 9% narrower face with a different descent
   does at this size. */
.cs-cust-title {
  position: absolute;
  left: 21px;
  /* 80.5, not the 89 her cap sits at: Manrope's line box at 32/40 puts
     8.5px above the cap, computed from the face's own ascent and the half
     leading rather than nudged by eye. */
  top: 80.5px;
  margin: 0;
  width: 318px;
  font-weight: 500;
  font-size: 32px;
  line-height: 40px;
  letter-spacing: 0;
  color: var(--cust-on);
}

/* The subhead is pixels in the chrome image, so it is repeated here for
   assistive tech only — that visible copy stays hers. */
.cs-cust-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cs-cust-list {
  position: absolute;
  left: 21px;
  top: 185px;
  width: 318px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.cs-cust-row {
  position: relative;
  display: flex;
  align-items: center;
  height: 54px;
  margin-bottom: 14px;          /* 68 pitch less the 54 row */
  background: #fff;
  border-radius: 12px;
  /* transform is what both the FLIP pass and the drag animate, so the
     transition lives here and neither of them has to set one. */
  transition: transform 260ms cubic-bezier(0.2, 0, 0, 1);
  /* The grip owns vertical gestures inside this row; without this the phone
     would scroll the page instead of dragging the widget. */
  touch-action: none;
}

/* Lifted under the pointer: 70% and 80%, per the brief. No transition while
   dragging — the row tracks the finger rather than easing after it. */
.cs-cust-row.is-dragging {
  transition: none;
  opacity: 0.8;
  z-index: 2;
  box-shadow: 0 8px 20px rgb(30 30 32 / 18%);
}

.cs-cust-grip {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  width: 20px;
  height: 12px;
  margin-left: 17px;
  flex: none;
  padding: 0;
  border: 0;
  background: none;
  cursor: grab;
  touch-action: none;
}

.cs-cust-grip i {
  display: block;
  height: 2px;
  border-radius: 1px;
  background: var(--cust-off);
}

.cs-cust-row.is-dragging .cs-cust-grip { cursor: grabbing; }

.cs-cust-icon {
  width: 14px;
  height: 14px;
  /* The slice carries 1px of padding around her 12px glyph, so the box
     starts at 77 for the ink itself to land on her 78. */
  margin-left: 19px;
  flex: none;
  background: center / contain no-repeat;
}

.cs-cust-icon[data-icon='spends']      { background-image: url('img/screens/myspace/space/cust-icons/spends.png'); }
.cs-cust-icon[data-icon='balances']    { background-image: url('img/screens/myspace/space/cust-icons/balances.png'); }
.cs-cust-icon[data-icon='networth']    { background-image: url('img/screens/myspace/space/cust-icons/networth.png'); }
.cs-cust-icon[data-icon='rewards']     { background-image: url('img/screens/myspace/space/cust-icons/rewards.png'); }
.cs-cust-icon[data-icon='credit']      { background-image: url('img/screens/myspace/space/cust-icons/credit.png'); }
.cs-cust-icon[data-icon='investments'] { background-image: url('img/screens/myspace/space/cust-icons/investments.png'); }

.cs-cust-label {
  margin-left: 9px;             /* label ink starts at x 100 */
  /* Sized off her ink, not guessed: "Spends" measures 49px wide with an
     11px cap in her export, and Manrope sets it at 49.5 at this size. */
  font-size: 14px;
  letter-spacing: 0;
}

/* A real switch: role and aria-checked come from the script, and only the
   knob moves, so the track stays put underneath it. */
.cs-cust-switch {
  position: absolute;
  right: 15px;
  top: 13px;
  width: 48px;
  height: 28px;
  flex: none;
  padding: 0;
  border: 0;
  border-radius: 14px;
  background: var(--cust-off);
  cursor: pointer;
  transition: background 200ms ease;
}

.cs-cust-row.is-on .cs-cust-switch { background: var(--cust-on); }

.cs-cust-knob {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: #fff;
  transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
}

.cs-cust-row.is-on .cs-cust-knob { transform: translateX(20px); }

.cs-cust-foot {
  position: absolute;
  left: 0;
  /* 684, where her 1px rule sits, not 685 where the white starts: the rule
     is this element's border, and with border-box the band below it then
     lands on 685 and the button and home bar measure where she drew them. */
  top: 684px;
  width: 360px;
  height: 116px;
  background: #fff;
  border-top: 1px solid var(--cust-rule);
}

.cs-cust-save {
  position: absolute;
  left: 20px;
  top: 23px;                    /* 708 in the frame, less the band's 685 */
  width: 320px;
  height: 48px;
  border: 0;
  border-radius: 12px;
  background: var(--cust-coral);
  color: #fff;
  font-family: inherit;
  /* "Save changes" is 106px of ink in her export; Manrope Bold sets it at
     107.4 here. */
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
  transition: opacity 200ms ease;
}

/* Disabled is the resting state: there is nothing to save until something
   differs from the last committed configuration. */
.cs-cust-save[disabled] {
  opacity: 0.45;
  cursor: default;
}

.cs-cust-bar {
  position: absolute;
  left: 113px;
  top: 102px;                   /* 787 in the frame */
  width: 134px;
  height: 5px;
  border-radius: 3px;
  background: var(--color-dark);
}

/* Sits in the band's own air above the button, so confirming a save moves
   nothing on the screen. */
.cs-cust-toast {
  position: absolute;
  left: 0;
  top: 667px;
  width: 360px;
  margin: 0;
  text-align: center;
  font-size: 11px;
  color: var(--cust-on);
  opacity: 0;
  transition: opacity 240ms ease;
}

.cs-cust-toast:not(:empty) { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .cs-cust-row,
  .cs-cust-knob,
  .cs-cust-switch,
  .cs-cust-save,
  .cs-cust-toast { transition: none; }
}

/* The dim. Only ever applied once the script is running — see the note above.
   opacity alone, with no scale: a transform here would need its own overflow
   allowance on top of the shadow's, and the point is to mark which screen is
   live, not to resize the row. */
.cs-flow--live .cs-flow-step {
  transition: opacity 0.3s ease;
}

.cs-flow--live .cs-flow-step:not(.is-focused) {
  opacity: var(--flow-dim);
}

/* "Animations paused on everything but the focused screen." Nothing in the
   placeholders animates yet; this is the hook the real captures land into, so
   a looping screen off to the side is not competing with the one being read. */
.cs-flow--live .cs-flow-step:not(.is-focused) * {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  .cs-flow--live .cs-flow-step { transition: none; }
}


/* ═══ shipping.html — page scope (.cs-sc) ════════════════════════════════════
   The third case study. It also carries .cs-ms, so the shared heading/subhead
   measure, prose ink, halo-less "now" screen and right-aligned bubbles all
   apply unchanged. Everything below is either a small adaptation of an
   existing component (two-up voices, landscape screens, a narrower bench
   preview) or one of the three pieces this story needed that the other pages
   had no component for: the competitor trees, the flowchart and the video
   pair. All sizes on the 4px grid, all type from tokens. */

/* The accent. Bikayi's own violet — the button colour in its merchant
   dashboard, sampled from the walkthrough (#6811CA) — replaces spacefire on
   this page only, on instruction. Chosen from four options, all of which
   pass: white bubble text on it measures 8.33:1 and the hook highlight on
   cream 7.57:1, so unlike spacefire (2.58 / 2.35) neither is an exception.
   Setting --hook-highlight is enough: --cs-bubble-bg and the cursor dart
   both read it, and a same-specificity rule declared later than .cs-page's
   wins on the shared <body>. */
.cs-sc { --hook-highlight: #6811CA; }

/* §1 — the hook subhead. At the shared 25em measure this page's longer
   sentence ran to four lines with "million" alone on the last. Swept at 1440:
   25em -> 4 lines, 28-40em -> 3 lines but with a ragged last line (72-490px
   of 720). 30em plus balance evens the three (641 / 720 on the last). Em, not
   px, so it keeps pace with --hook-sub-size down the clamp. */
.cs-sc .cs-hook-sub { max-width: calc(13.5 * var(--hook-title-size)); text-wrap: pretty; }   /* = the title's --hook-title-measure, in the title's size */

/* Every section subhead on this page: balance the lines so the last one is
   never a stray word (§10's ran "...never factored / in"). The measure is
   unchanged, only how the words are spread across it. */
/* Same measure as the heading above it (18 x the heading's size), so both end on
   one right edge. `pretty`, not `balance`: balance squeezes every line to the
   narrowest width the line count allows, which is what left the gap on the right. */
.cs-sc .cs-sub { max-width: calc(18 * var(--cs-h2)); text-wrap: pretty; }

/* §3 — the "What did I do?" row is hidden by default on jupiter.html (see
   .cs-did-sec above). Approved for this page in the brief, so shown here. */
.cs-sc .cs-did-sec { display: flex; }

/* §6 — two merchant cards, not three. Tilt only the first; the base rotates
   :nth-child(3), which does not exist here, so the pair would otherwise read
   as one tilted card beside a straight one by accident rather than design. */
@media (min-width: 1024px) {
  .cs-sc .cs-voices-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .cs-sc .cs-voice:nth-child(2) { transform: rotate(1.5deg); }
}

.cs-sc-params {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* §7 — landscape dashboard captures in the .cs-pro-fig grammar. The base
   component is built for two phones side by side with annotations at four
   corners; these are ~1.5:1 screens, so they stack, and each annotation sits
   level with its own screen: first screen left, second screen right. */
.cs-pro-fig--dash {
  --dash-w: clamp(280px, 40vw, 600px);
}
.cs-pro-fig--dash .cs-pro-stage {
  flex-direction: column;
  align-items: center;
}
/* .cs-shot-img is a phone frame by default (9 / 19.5, object-fit: cover),
   which crops a landscape capture to a sliver. Both are reset to the image's
   own shape. */
.cs-pro-fig--dash .cs-pro-card .cs-shot-img {
  width: min(100%, var(--dash-w));
  max-width: none;
  height: auto;
  aspect-ratio: auto;
  object-fit: fill;
  border-radius: var(--cs-card-radius);
  box-shadow: var(--cs-screen-shadow);
}
.cs-pro-fig--dash .cs-pro-card { width: 100%; }

@media (min-width: 1024px) {
  /* display: contents lifts the two cards into the figure's own grid, so each
     can share a row with its annotation instead of both hanging off one
     stage that spans two rows of unknown height. */
  .cs-pro-fig--dash {
    grid-template-areas:
      "then shot-a ."
      ".    shot-b now";
    row-gap: var(--pro-stage-gap);
  }
  .cs-pro-fig--dash .cs-pro-stage { display: contents; }
  .cs-pro-fig--dash .cs-pro-card { position: relative; z-index: 1; width: var(--dash-w); }
  .cs-pro-fig--dash .cs-pro-card .cs-shot-img { width: 100%; }
  .cs-pro-fig--dash .cs-pro-card--then { grid-area: shot-a; }
  .cs-pro-fig--dash .cs-pro-card--now  { grid-area: shot-b; }
  .cs-pro-fig--dash .cs-pro-annot--then,
  .cs-pro-fig--dash .cs-pro-annot--now {
    align-self: center;
    transform: none;
  }
}

/* §8 — competitor shipping models as three cards of nested boxes. */
.cs-sc-comp {
  display: grid;
  gap: 16px;
}
@media (min-width: 1024px) {
  .cs-sc-comp { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
}
.cs-sc-comp-card {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 24px;
}
/* Logo + name. The three tiles are normalised to one square (192px source,
   48px here, so 4x for retina) and share one corner: --hook-icon-radius, the
   app-icon corner the Bikayi mark already uses — the depicted-object
   exception in CLAUDE.md, a percentage of the tile's own size. Wix's own
   artwork is rounded slightly more (22% against 20%); the <1px difference at
   48px is invisible. Shopify's tile is white, so the hairline is what makes
   it read as a tile on a white card; on the two purple tiles it is
   invisible, so all three carry it and none is a special case. An outline
   rather than an inset shadow: an <img>'s inset shadow paints UNDER its
   pixels, so an opaque white tile would hide it. Outlines follow the radius
   and paint on top. */
.cs-sc-comp-head {
  display: flex;
  align-items: center;
  gap: 12px;
}
.cs-sc-logo {
  display: block;
  width: 48px;
  height: 48px;
  flex: none;
  border-radius: var(--hook-icon-radius);
  outline: 1px solid var(--cs-rule);
  outline-offset: -1px;
}

/* Hover: the home page's own project-card lift (a.pro-card in style.css) —
   same 4px rise, same --card-shadow-hover, same curve — so the two read as
   one family of card. Fine pointers only, and off under reduced motion. These
   cards sit in a plain grid with no scroller above them, so the deeper
   shadow has nothing to clip against. */
@media (hover: hover) and (pointer: fine) {
  .cs-sc-comp-card {
    transition: box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
                transform  0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
  }
  .cs-sc-comp-card:hover {
    transform: translateY(-4px);
    box-shadow: var(--card-shadow-hover);
  }
}
@media (prefers-reduced-motion: reduce) {
  .cs-sc-comp-card { transition: none; }
  .cs-sc-comp-card:hover { transform: none; }
}

/* The levels. Each is a 4% ink tint box; nested inside the one above, the
   tints stack, so a deeper level is a shade darker without a new colour
   token. Chips sit white on the tint. The "Also" footnote holds the side
   features that are not on the path to a rate, below a rule, pinned to the
   card's foot so the three cards' footers line up. */
.cs-sc-box {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 12px 16px;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--color-dark) 4%, transparent);
}
.cs-sc-box .cs-sc-box { margin-top: 8px; }
.cs-sc-box-name {
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  font-weight: 700;
  line-height: 1.4;
  color: var(--cs-fg);
}
.cs-sc-box-note {
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.45;
  color: var(--cs-label);
}
.cs-sc-chips {
  list-style: none;
  margin: 8px 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
/* The pill shape is .cs-did's literal 999px (CLAUDE.md, radius exception),
   repeated rather than borrowed because that rule also sets the larger
   padding and type of the "What did I do?" row. */
.cs-sc-chips li {
  padding: 4px 12px;
  border: 1px solid var(--cs-rule);
  border-radius: 999px;
  background: var(--color-white);
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.4;
  white-space: nowrap;
  color: var(--cs-fg);
}
.cs-sc-also {
  margin: auto 0 0;
  padding-top: 16px;
  border-top: 1px solid var(--cs-rule);
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.45;
  color: var(--cs-label);
}
.cs-sc-also b { color: var(--cs-fg); margin-right: 4px; }

/* §9 — delivery apps as a vertical tab set: rows left, the selected app's
   screen right. See the markup comment for why this is not the bench board.

   Rows: number, then name over a two-line description in one left-aligned
   column, then an arrow. At rest a row is flat on the page; hover tints it;
   selected, it becomes a white card (.cs-card's own surface and shadow) and
   its arrow turns the accent — the one visual tie between a row and the
   screen beside it. The stage is sized to the captures (~392:847), and the
   list is centred against it so neither column hangs. */
.cs-sc-apps {
  display: grid;
  gap: 40px;
  justify-items: center;
}
@media (min-width: 1025px) {
  .cs-sc-apps {
    /* The list is capped so a row card does not run a long empty stretch
       between its copy and its arrow; space-between puts the screen on the
       column's right edge. */
    grid-template-columns: minmax(0, 480px) 260px;
    justify-content: space-between;
    gap: 64px;
    align-items: center;
    justify-items: stretch;
  }
}

.cs-sc-apps-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 100%;
}

.cs-sc-app {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 24px;
  width: 100%;
  padding: 24px;
  border: 0;
  border-radius: var(--cs-card-radius);
  background: transparent;
  color: var(--cs-fg);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background-color 0.25s ease, box-shadow 0.25s ease;
}
.cs-sc-app:hover { background: color-mix(in srgb, var(--color-dark) 4%, transparent); }
.cs-sc-app[aria-selected="true"] {
  background: var(--cs-card-bg);
  box-shadow: var(--cs-card-shadow);
}
.cs-sc-app:focus-visible {
  outline: 2px solid var(--hook-highlight);
  outline-offset: 2px;
}

.cs-sc-app-num {
  align-self: start;
  padding-top: 4px;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  /* Tabular figures: proportional "01" is narrower than "02", which set the
     first row's name 3px left of the other two. */
  font-variant-numeric: tabular-nums;
  color: var(--cs-label);
}
.cs-sc-app-text {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.cs-sc-app-name {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-lead);
  line-height: 1.2;
  letter-spacing: -0.01em;
}
/* 22em puts every description on exactly two lines (measured: the
   shortest, Dunzo's, needs 23.8em to fit on one; the longest, Porter's,
   still fits two). */
.cs-sc-app-lens {
  max-width: 22em;
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.5;
  color: var(--cs-label);
}

/* The arrow is faint at rest, darker on hover, the accent when selected, and
   nudges toward the screen — the same 4px shift as the home page card CTA. */
.cs-sc-app-arrow {
  color: color-mix(in srgb, var(--color-dark) 32%, transparent);
  transition: color 0.25s ease, transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.cs-sc-app:hover .cs-sc-app-arrow { color: var(--cs-fg); }
.cs-sc-app[aria-selected="true"] .cs-sc-app-arrow {
  color: var(--hook-highlight);
  transform: translateX(4px);
}

.cs-sc-apps-stage {
  width: min(260px, 70vw);
}
.cs-sc-app-shot {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--cs-card-radius);
  box-shadow: var(--cs-screen-shadow);
  animation: cs-duo-swap 0.35s ease both;
}
.cs-sc-app-shot[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  .cs-sc-app,
  .cs-sc-app-arrow { transition: none; }
  .cs-sc-app[aria-selected="true"] .cs-sc-app-arrow { transform: none; }
  .cs-sc-app-shot { animation: none; }
}

/* §11 — the two scope cards side by side. */
.cs-sc-scope {
  display: grid;
  gap: 16px;
}
@media (min-width: 768px) {
  .cs-sc-scope { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
}
.cs-sc-scope-copy {
  margin: 0;
  padding-left: calc(var(--duo-chip, 36px) + var(--duo-chip-gap, 12px));
  font-family: var(--font-ui);
  font-size: var(--text-ui);
  line-height: 1.5;
  color: var(--cs-label);
}
/* The shared methods row under the two cards. */
.cs-sc-methods {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-top: 32px;
}
/* "Product based" is not built yet: dashed and muted, the same pill with its
   border and ink pulled back. */
.cs-pills li.cs-sc-pill-next {
  border-style: dashed;
  color: var(--cs-label);
}

.cs-sc-scope-sub {
  margin: 0 0 12px;
  padding-left: calc(var(--duo-chip, 36px) + var(--duo-chip-gap, 12px));
  font-family: var(--font-ui);
  font-size: var(--text-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--cs-label);
}

/* §13 — the flowchart. Every connector is a 1px hairline drawn by a node's
   own ::before (the stem into it) or by the split's ::before/::after (the
   bar across the two branches), so nothing is positioned against anything
   but its own box. --fc-stem is the gap between nodes and the stem length. */
.cs-sc-flow {
  --fc-stem: 32px;
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--fc-stem);
}
.cs-sc-flow ol,
.cs-sc-flow ul { list-style: none; margin: 0; padding: 0; }

.cs-sc-flow-node {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 4px;
  width: min(100%, 360px);
  padding: 20px 24px;
}
/* Stems in: every node that has something above it. Stem out: the node the
   flow splits from, which reaches down to the split's top bar. */
.cs-sc-flow > .cs-sc-flow-node + .cs-sc-flow-node::before,
.cs-sc-flow > .cs-sc-flow-split + .cs-sc-flow-node::before,
.cs-sc-flow-branch > .cs-sc-flow-node::before,
.cs-sc-flow > .cs-sc-flow-node:has(+ .cs-sc-flow-split)::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: 100%;
  width: 1px;
  height: var(--fc-stem);
  background: var(--cs-fg);
}
.cs-sc-flow > .cs-sc-flow-node:has(+ .cs-sc-flow-split)::after {
  bottom: auto;
  top: 100%;
}
.cs-sc-flow-title {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--text-lead);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--cs-fg);
}
.cs-sc-flow-sub,
.cs-sc-flow-list li {
  font-family: var(--font-ui);
  font-size: var(--text-meta);
  line-height: 1.45;
  color: var(--cs-label);
}
.cs-sc-flow-list { display: flex; flex-direction: column; gap: 8px; }

/* The split: two branches under one bar. The branch's own first node draws
   the stem down from that bar; the split's ::before is the bar itself. */
.cs-sc-flow-split {
  position: relative;
  display: grid;
  gap: var(--fc-stem);
  width: 100%;
}
.cs-sc-flow-branch {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--fc-stem);
}
/* Stacked (under 768) the two branches are alternatives, not steps, so the
   stem that would join them is replaced by an "or". */
@media (max-width: 767px) {
  .cs-sc-flow-branch + .cs-sc-flow-branch > .cs-sc-flow-node:first-child::before { content: none; }
  .cs-sc-flow-branch + .cs-sc-flow-branch::before {
    content: 'or';
    margin-bottom: calc(-1 * var(--fc-stem) + 8px);
    font-family: var(--font-ui);
    font-size: var(--text-label);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--cs-label);
  }
}

@media (min-width: 768px) {
  .cs-sc-flow-split {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 24px;
    /* Room for the stems between each bar and the nodes it joins. */
    padding-block: var(--fc-stem);
  }
  /* Bar across the top (from the split point) and across the bottom (into
     the save step), each running between the two branch centres. */
  .cs-sc-flow-split::before,
  .cs-sc-flow-split::after {
    content: '';
    position: absolute;
    left: 25%;
    right: 25%;
    height: 1px;
    background: var(--cs-fg);
  }
  .cs-sc-flow-split::before { top: 0; }
  .cs-sc-flow-split::after  { bottom: 0; }
  /* Each branch's last node stretches (flex: 1, and the grid stretches both
     branches to one height), so the two bottoms are level and one stem of
     --fc-stem reaches the bottom bar from either. */
  .cs-sc-flow-branch > .cs-sc-flow-node:last-child { flex: 1; }
  .cs-sc-flow-branch > .cs-sc-flow-node:last-child::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 100%;
    width: 1px;
    height: var(--fc-stem);
    background: var(--cs-fg);
  }
}

/* §14 — the two walkthroughs. The frame is the videos' own 380:240, so the
   player never letterboxes; overflow + the card radius round the iframe. */
.cs-sc-videos {
  display: flex;
  flex-direction: column;
  gap: 56px;
}
.cs-sc-video {
  display: flex;
  flex-direction: column;
  gap: 16px;
  margin: 0;
}
.cs-sc-video-frame {
  aspect-ratio: 380 / 240;
  overflow: hidden;
}
.cs-sc-video-frame iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}


/* ── .cs-iter — a numbered row of iterations ───────────────────────────────
   myspace.html §5, "The starting point": four Home screens in the order they
   were tried, each with its number and what it taught. Sized and laid out
   like .cs-strip (the same four-up row at every screen's "after" size,
   bleeding past the 950px column, centred, wrapping 2 x 2 below ~893px and
   one per row on a phone), with a caption under each screen held to the
   screen's own width so the four read as columns. Top-aligned, so the
   numbers and captions sit on one line across the row. The dot ground is
   the prologue's own .cs-pro-dots. */
.cs-iter {
  position: relative;
  margin-inline: calc(50% - 50vw);
  padding: 24px var(--strip-edge, 24px);
}
.cs-iter-row {
  position: relative;
  z-index: 1;
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-start;
  gap: 48px var(--pro-stage-gap);
}
.cs-iter-item {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: calc(var(--pro-card-w) * var(--cs-screen-step));
}
.cs-iter-item .cs-shot-img {
  width: 100%;
  max-width: none;
}
.cs-iter-num {
  margin: 8px 0 0;
  font-family: var(--font-ui);
  font-size: var(--text-label);
  font-weight: 600;
  letter-spacing: 0.08em;
  color: var(--cs-label);
}
.cs-iter-item .cs-pro-text { max-width: none; }
/* The row already runs edge to edge, so the ground stays inside it rather
   than reaching past it (its usual -24px would widen the page). */
.cs-iter .cs-pro-dots { inset: 0; }

@media (max-width: 892px) {
  .cs-iter-row {
    max-width: calc(2 * (var(--pro-card-w) * var(--cs-screen-step)) + var(--pro-stage-gap));
    margin-inline: auto;
  }
}


/* myspace §3's walkthrough video: whole 720 x 1600 frames (status bar to
   home indicator), so its own ratio rather than the shared 9 / 19.5 crop. */
.cs-solo .cs-walkthrough {            /* .cs-solo, to outrank .cs-solo .cs-shot-img's width */
  aspect-ratio: 720 / 1600;
  /* Never taller than the screen less room for the nav, so the whole phone
     is in view at once: its width follows from that height. */
  width: min(var(--cs-solo-w), calc((100svh - 160px) * 720 / 1600));
}

/* ═══ provenance.html (.cs-pv) ════════════════════════════════════════════
   The highlight is Provenance's own blue, sampled from "Starve the slop" on
   its landing page (#0A5AF5, ~5.3:1 on cream). The subhead takes the title's
   measure, as on the Bikayi page. The CTA is the close section's pill,
   centred by the hook's own flex column; the video fills the content column
   so it keeps the page's left and right margins. */
.cs-pv { --hook-highlight: #0A5AF5; }
.cs-pv .cs-hook-sub { max-width: calc(13.5 * var(--hook-title-size)); text-wrap: pretty; }
.cs-pv-cta { align-self: center; }

/* The stage carries the 1.4x width the video used to; the dot field and the
   video both live in it, so the field is sized off the video's own box. */
.cs-pv-stage {
  position: relative;
  align-self: center;
  width: min(140%, calc(100vw - 2 * var(--cs-margin, 24px)));
}
/* Same field as the other projects' screens (.cs-pro-dots), bled well past the
   video so a ring of it shows around the frame, and faded to nothing at its own
   edge (a closest-side ellipse: solid until just past the video's edge). */
.cs-pv-dots {
  inset: -96px -96px;
  -webkit-mask-image: radial-gradient(ellipse closest-side at 50% 50%, #000 76%, transparent 100%);
          mask-image: radial-gradient(ellipse closest-side at 50% 50%, #000 76%, transparent 100%);
}
.cs-pv-video { position: relative; z-index: 1; }
.cs-pv-video {
  display: block;
  /* 1.4x the content column, centred on it; capped so it never runs closer
     to the viewport edge than the page's own side margin (--cs-margin). */
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  border-radius: var(--cs-card-radius);
  /* Scaled up from the screens' shadow, since the surface is ~4x larger:
     a hairline, a close contact shadow and a long soft one. */
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--color-dark) 6%, transparent),
    0 4px 12px -2px color-mix(in srgb, var(--color-dark) 12%, transparent),
    0 24px 56px -16px color-mix(in srgb, var(--color-dark) 28%, transparent);
  background: var(--color-dark);
}
