/* ============================================================
   Scene D — Case study: "Modernizing a Legacy Enterprise UI" (Splunk).
   Rebuilt 2026-08-03 from the Figma frame "Jacky_02 — modification"
   (page 💻 Case study, node 928:49913).

   The composition: a full-screen hero, then ONE quiet measure.
     · 760px stream, centred in the viewport (at 1440 that's x 340–1100)
     · 600px text column centred inside it (the 80px inset on each side)
     · figures run the full 760 — they breathe past the prose
     · two margin asides (.cs-note) break out into the 220px side lanes
       at ≥1440 and fold back inline below that
     · ≥2560 the WHOLE measure scales ×1.25 with the type (stream 950,
       col 750, bleed 1375, lanes 275 — the --cs-* vars) so the page keeps
       the 1440 proportions instead of becoming a ribbon
   The v4 side rails are resolved (2026-08-06): PROJECT DETAILS became an
   inline meta block at the top of section 01 (.cs-details); the CONTENTS
   toc is retired for good — the scrollspy wiring in case.js is dormant.

   Same grammar as the rest of the site: homepage tokens, Hyperlegible,
   transparent scene over the shared #bgCanvas dot grid.
   All six figures are real (2026-08-08): 01 legacy still, 02 audit-board
   video, 03 flow breakout, 04 wizard, 05 stepper, 06 prototype video.
   aspect-ratio is locked per figure via --ar.
   Everything scoped under #sD.
   ============================================================ */

#sD { font-family: 'Hyperlegible Sans', system-ui, sans-serif; }
#sD.scene { z-index: 2; background: transparent; color: var(--fg); }

/* ---------------- scroll container ----------------
   Mobile: the scene is in normal flow, body scrolls (like About).
   Desktop: the scene is viewport-locked (.scene{position:fixed}), so the
   main becomes the scroller — same pattern as #sB .ab-main. */
@media (min-width: 1100px) {
  #sD .cs-main {
    position: absolute; inset: 0;
    overflow-y: auto; overflow-x: hidden;
    scroll-behavior: smooth;
    /* PW-3 (Jacky's catch, 2026-08-27): the gate modal's scroll lock flips
       this to overflow:hidden — on classic (non-overlay) scrollbars the
       bar vanished and the centred stream reflowed ~7px sideways, a
       visible background shift under the modal. A stable gutter keeps
       the bar's lane reserved either way; overlay-scrollbar systems are
       untouched (gutter width 0). */
    scrollbar-gutter: stable;
  }
}

/* ── Entry — mirrors About's grammar: the scene fades in (--t-reveal), then the
   hero composition cascades with the 1.6s brand rise. Everything below the
   full-screen hero is handled by the scroll reveals, so the entrance only
   choreographs what's actually on screen at open. ───────────────────────── */
#sD {
  --cs-rise-dur: 1.6s;
  --cs-exit-dur: 1s;     /* CS-16: the exit's own, faster clock (see the Exit note) */
  /* Stream metrics — the scene's three widths, declared once so the
     super-wide tier can scale them together (see the ≥2560 block). */
  --cs-stream: 760px;   /* figures + the measure everything centres in */
  --cs-col: 600px;      /* text column inside the stream */
  --cs-bleed: 1100px;   /* breakout figures (.cs-fig--bleed) */

  /* CS-4 rhythm + type beats — the ?space=1 panel drives these live;
     bake locked values HERE (defaults mirror the shipped tokens).
     CS-9 r1 (Jacky 2026-08-22, tuned on the spacing-map artifact): the
     whole rhythm compacts one-to-two token steps — the old beats read
     loose against the type. --csp-scale multiplies every beat: 1 at
     desktop, 1.25 at ≥2560 where the measure and type grow ×1.25, so the
     spacing/type ratio finally holds at super-wide too (it never did —
     the beats used to stay fixed while the type grew). */
  --csp-scale: 1;
  --csp-section-gap: calc(var(--space-3xl) * var(--csp-scale));  /* section ↔ section */
  --csp-sec-gap: calc(var(--space-lg) * var(--csp-scale));       /* inside a section: head/body/stats/fig (was xl) */
  --csp-head-gap: calc(var(--space-xs) * var(--csp-scale));      /* eyebrow → title (was md) */
  --csp-para-gap: calc(var(--space-sm) * var(--csp-scale));      /* paragraph → paragraph (was md) */
  --csp-items-gap: calc(var(--space-sm) * var(--csp-scale));     /* sub-item ↔ sub-item (was lg) */
  --csp-item-gap: calc(var(--space-2xs) * var(--csp-scale));     /* sub-item title → body (was xs) */
  --csp-work-gap: calc(var(--space-md) * var(--csp-scale));      /* deliverable text → figure (was lg) */
  --csp-worktext-gap: calc(var(--space-xs) * var(--csp-scale)); /* deliverable title → prose — r5: 2xs read too tight under the 30px title */
  --csp-cap-gap: calc(var(--space-2xs) * var(--csp-scale));      /* figure → caption (was md) */
  --csp-statlabel-gap: calc(var(--space-2xs) * var(--csp-scale));/* stat number → label (was xs) */
  /* CS-9: works 01↔02 are their own group (Jacky's call on the map) —
     a beat independent of --csp-sec-gap, applied via the sibling margin
     below (flex gap + margin compose; equal to sec-gap today). */
  --csp-works-gap: calc(var(--space-2xl) * var(--csp-scale));  /* CS-9 r3: lg → 2xl, Jacky's second pass */
  --csp-prose-lh: 1.5;
  --csp-fz-section: var(--fz-section-head);
  --csp-fz-subhead: var(--fz-sub-head);
  --csp-fz-stat: var(--fz-section-head);

  /* The MID DISPLAY TIER — --fz-mid, lifted off the body floor on mobile.
     Everything riding it is a heading: deliverable titles
     (.cs-work-text h3), second-level secheads (.cs-sec-title--sm), and the
     password-gate title.

     --fz-mid bottoms out at 16 on mobile, identical to --fz-body. CS-9 r4
     retired weight as a hierarchy signal on purpose ("hierarchy by SIZE"),
     so at 16/400 those headings were typographically INDISTINGUISHABLE
     from the paragraph under them — only --fg vs --fg-muted was left.
     Below 737 the tier borrows --fz-section-head's 20: the mobile ladder
     has exactly one step between body and hero, so 20 is the only choice
     that isn't a free px.

     Cost, accepted knowingly: on a phone this sits at the same 20 as
     --csp-fz-section, so section titles and these no longer differ by
     size. They stay apart structurally — a section title carries an eyebrow
     and a 144px section gap above it, and runs line-height 1 against their
     1.2. A heading that merely shares a size with a bigger heading beats
     one that reads as body copy.

     Tablet and desktop are untouched: 30 / 20 / 16 and 40 / 30 / 16.
     Ladder for this tier: 20 / 20 / 30. */
  --csp-fz-midtier: var(--fz-section-head);
  --csp-fz-work: var(--csp-fz-midtier);   /* CS-9 r4: steps up to 30 at desktop */
  --csp-fz-gate: var(--csp-fz-midtier);   /* PW-1b */
}
@media (min-width: 737px) {
  #sD { --csp-fz-midtier: var(--fz-mid); }
}
#sD.scene.active { animation: cs-enter var(--t-reveal) cubic-bezier(0.22, 1, 0.36, 1) both; }
@keyframes cs-enter { from { opacity: 0; } to { opacity: 1; } }
@keyframes cs-rise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
@keyframes cs-fade { from { opacity: 0; } to { opacity: 1; } }

#sD.scene.active .cs-back {
  animation: cs-rise var(--cs-rise-dur) cubic-bezier(0.22, 1, 0.36, 1) both;
}
#sD.scene.active .cs-hero-img {
  animation: cs-fade var(--cs-rise-dur) cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Header beat: the cover settles first, chrome follows. */
#sD.scene.active .cs-hero-img { animation-delay: 0ms; }
#sD.scene.active .cs-back     { animation-delay: 320ms; }

/* ── Exit — mirrors the entrance's ORDER (reverse, rise→sink) but on its own
   FASTER clock. CS-16 (Jacky: case→Work 「幾慢」): the exit used the full
   1.6s entrance duration + a 2450ms handoff — with the front-loaded house
   easing the screen sat visually empty from ~1.3s to 2.45s before the Work
   grid even started blooming. An exit is a step BACK — it reads best quicker
   than the arrival (same asymmetry the industry container-transform guidance
   uses). --cs-exit-dur 1s + hero delay 200ms ends the cascade ~1.2s; the JS
   handoff (index.html runCaseExit) fires at 1250ms. Entrance untouched. ── */
@keyframes cs-rise-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(18px); } }
/* CS-15 r3 (Jacky's tablet catch, 2026-08-27): NO explicit `from` — the
   fade starts from each element's own computed opacity. The old
   from{opacity:1} forced the CLOSED contents dropdown (.cs-toc <1440,
   natural opacity 0) fully visible at exit start, which read as the
   grid tap "auto-clicking" the contents icon. Everything this animation
   serves that should fade (hero, footer, chrome row) rests at 1 anyway. */
@keyframes cs-fade-out { to { opacity: 0; } }

#sD.cs-exiting.scene.active .cs-back {
  animation: cs-rise-out var(--cs-exit-dur) cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* CS-16 r2 (Jacky's catch): fade the hero CONTAINER, not just the img.
   .cs-hero carries background: var(--neutral) (the image-loading
   placeholder) — fading only the img peeled the photo off and left a
   flat neutral slab standing until the scene swap, the LAST thing to
   vanish. The container fade takes photo + placeholder down together,
   so the exit dissolves straight to the page background. (The entrance
   keeps animating the img alone — the placeholder is deliberate cover
   while the photo loads.) */
#sD.cs-exiting.scene.active .cs-hero {
  animation: cs-fade-out var(--cs-exit-dur) cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Exact mirror of the entrance delays (last in → first out). */
#sD.cs-exiting.scene.active .cs-back  { animation-delay: 0ms; }
#sD.cs-exiting.scene.active .cs-hero  { animation-delay: 200ms; }   /* CS-16: compressed with the faster clock */
/* Everything the scroll reveals brought in sinks back out together at the head
   of the cascade; figures fade (no transform), mirroring how they arrived. */
#sD.cs-anim.cs-exiting .cs-reveal.is-in {
  animation: cs-rise-out var(--cs-exit-dur) cubic-bezier(0.22, 1, 0.36, 1) both;
}
#sD.cs-anim.cs-exiting .cs-fig.cs-reveal.is-in,
#sD.cs-exiting .cs-footer {
  animation: cs-fade-out var(--cs-exit-dur) cubic-bezier(0.22, 1, 0.36, 1) both;
}

/* ---------------- breadcrumb back link ----------------
   Sits directly under the global wordmark (top 32 + 28px row + small gap),
   floating over the hero. The top-right corner stays free for the global
   socials (desktop) / burger (mobile). */
#sD .cs-back {
  position: fixed;
  top: calc(32px + 28px + 6px);
  left: var(--gutter);
  z-index: 35;
  font-size: var(--fz-microcopy);
  font-weight: 500;
  letter-spacing: var(--nav-tracking);
  text-transform: uppercase;
  text-decoration: none;
  color: var(--fg-muted);
  transition: color var(--t-base);
}
@media (hover: hover) { #sD .cs-back:hover { color: var(--fg); } }
#sD .cs-back:focus-visible { color: var(--fg); }   /* BR-10: global hairline ring applies */
/* Mobile: the body scrolls, and the figures below are full-bleed light images —
   a fixed back link ends up sitting on top of them. Pin it to the hero instead
   so it scrolls away with the cover. (The global wordmark stays fixed; that's
   site chrome shared with every other scene.) */
@media (max-width: 1099px) {
  #sD .cs-main { position: relative; }
  #sD .cs-back { position: absolute; }
  /* CS-5 mobile tail (2026-08-14): the link floats on the dark hero in both
     themes, so light mode borrows the dark theme's muted (matching the
     wordmark/burger treatment in index.html — chrome over the hero reads
     the same in either theme). Absolute → it scrolls away with the cover,
     so a static swap covers its whole visible life. */
  body.light-mode #sD .cs-back { color: #8b8e94; }
  @media (hover: hover) { body.light-mode #sD .cs-back:hover { color: #e5e5e5; } }
  body.light-mode #sD .cs-back:focus-visible { color: #e5e5e5; }
}

/* ---------------- CS-7 A — desktop floating chrome (Jacky 2026-08-21) ----
   The CS-6 frosted BAR is retired: the translucent blur band read
   old-school, and it was the site's only boxed surface — against the
   "no visible borders anywhere" brand language. The chrome (wordmark ·
   theme · burger, order unchanged) now floats directly on the content,
   like every other scene. The .cs-topbar element stays but becomes a top
   FADE MASK — a --bg→transparent gradient so content dissolves as it
   approaches the chrome: no edge, no box.
   Three states, driven by the same body classes as before:
     · over the hero (body.cs-hero-zone): a DARK→transparent scrim — r2
       (Jacky 2026-08-21): the Splunkbase hero is BRIGHT at the top, so
       the light chrome drowned with nothing under it; the classic photo
       treatment (black fading to transparent) puts it back. Ink-black
       (#01050E), not --bg — it sits on artwork, dark in both themes,
       matching the chrome's dark-ink borrow (index.html).
     · reading, scrolling up: the --bg fade mask — content dissolves
       into the page ground as it approaches the chrome
     · reading, scrolling down (body.cs-bar-away): everything retreats
   The two gradients live on ::before/::after and CROSSFADE on the zone
   flip (a background swap would jump-cut at the hero edge). */
/* MOBILE AUDIT r2 (Jacky 2026-08-27): the fade mask was desktop-only, so on
   a phone the icons floated bare on the running text and collided with it
   ("desktop 有個透透地嘅 shadow 令 icon 唔會同內文重疊，手機冇咗"). The whole
   treatment now runs at every width — same two crossfading layers, same
   three states, one height. 120 is not arbitrary on the phone either: the
   icons' hit boxes end at y=68, and this gradient is ~70% opaque there —
   the same dissolve desktop already has under its chrome. */
#sD .cs-topbar {
  display: block;
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 120px;
  z-index: 34;                      /* under the global chrome riding it */
  pointer-events: none;
  transition: opacity var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1);
}
#sD .cs-topbar::before,
#sD .cs-topbar::after {
  content: ''; position: absolute; inset: 0;
  transition: opacity var(--t-base) ease;
}
#sD .cs-topbar::before {          /* hero scrim: ink-black → transparent */
  background: linear-gradient(to bottom,
    rgba(1, 5, 14, 0.55) 0%,
    rgba(1, 5, 14, 0.26) 55%,
    transparent 100%);
  opacity: 0;
}
#sD .cs-topbar::after {           /* page fade mask: --bg → transparent */
  background: linear-gradient(to bottom,
    var(--bg) 0%,
    color-mix(in srgb, var(--bg) 72%, transparent) 55%,
    transparent 100%);
  opacity: 1;
}
body.cs-hero-zone #sD .cs-topbar::before { opacity: 1; }
body.cs-hero-zone #sD .cs-topbar::after  { opacity: 0; }
/* CS-6 r2 behaviour kept: scroll down = retreat (with the chrome, via
   index.html); scroll up = return. case.js drives cs-bar-away from
   .cs-main's scroll direction on desktop and from the body scroller on
   phones (MOBILE AUDIT). */
body.cs-bar-away #sD .cs-topbar { opacity: 0; }
@media (min-width: 1100px) {
  /* CS-6 r2 (Jacky 2026-08-13): the BACK TO WORK link stays out — desktop
     navigation is the burger (full menu) + wordmark (home); the mobile
     hero-pinned link below 1100 is untouched. Markup stays for mobile. */
  #sD .cs-back { display: none; }
}

/* ---------------- CS-7 r3 — case chrome: project name · TOC · close ----
   From Jacky's old portfolio's case nav (2026-08-21): left = wordmark,
   hairline divider, project name; right = section links (click → smooth
   scroll, scrollspy highlights the current one) · theme · X back to Work.
   The X replaces the burger inside the case (his old site's pattern —
   wordmark goes home, X goes back). Desktop-only; mobile handling is an
   open question (tracked in BACKLOG), the mobile chrome is untouched. */
#sD .cs-projname { display: none; }

/* ── CS-11 (Jacky 2026-08-26): contents on narrow screens ──────
   Wherever the TOC row has no room, a contents icon opens the SAME
   .cs-toc nav as a small dropdown panel (body.cs-toc-open, toggled in
   case.js) — scrollspy highlight and the smooth anchor scroll are the
   one binding, reused. r2: the row needs ~1440px to clear the lockup
   (Jacky's iPad-landscape overlap), so the DROPDOWN serves everything
   below 1440, and the row is a >=1440 luxury. */
#sD .cs-toc-btn {
  position: fixed; z-index: 45;
  /* CS-11 r3: the mobile case bar — grid (0), theme (+46), contents
     (+92); the burger retires in scene-d at every width (index.html).
     BR-9 44px-box + 2px-seam pitch. */
  top: 24px; right: calc(var(--gutter) - 8px + 92px);
  width: 44px; height: 44px; padding: 0;                /* BR-9 hit area */
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; cursor: pointer;
  /* r4 (Jacky): ONE rail colour system across all three bar icons —
     rest muted, hover/focus full ink (the theme toggle already did). */
  color: var(--fg-muted);
  transition:
    color var(--t-base),
    transform var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1),
    opacity   var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1);
}
/* r2: the grid icon joins the mobile bar too — the old "← Back to
   work" text link retires, one back-affordance across every width.
   r3 (Jacky): on MOBILE grid takes the RIGHTMOST slot; theme sits
   second, contents third. (CS-15 flipped the DESKTOP order — see the
   ≥1100 block: theme rightmost there, matching the homepage corner.) */
#sD .cs-close {
  position: fixed; z-index: 45;
  top: 24px; right: calc(var(--gutter) - 8px);
  width: 44px; height: 44px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; cursor: pointer;
  color: var(--fg-muted);
  transition:
    color var(--t-base),
    transform var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1),
    opacity   var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1);
}
@media (hover: hover) {
  #sD .cs-toc-btn:hover, #sD .cs-close:hover { color: var(--fg); }
}
#sD .cs-toc-btn:focus-visible, #sD .cs-close:focus-visible { color: var(--fg); }
/* The contents icon reads as ACTIVE while its panel is open. */
body.cs-toc-open #sD .cs-toc-btn { color: var(--fg); }
#sD .cs-back { display: none; }   /* CS-11 r2: retired for the grid icon */
@media (min-width: 1100px) {
  /* CS-7 r4 (Jacky 2026-08-22): the lockup reads as a SUB-BRAND, not a
     caption — the name matches the wordmark's type exactly (same 20px
     size, same case, same tracking; weight one step lighter, 500 vs 600,
     for hierarchy), the divider gets real presence (1.5px at a 35% ink
     mix, full 24px line-box height — the old 1px --line hairline read
     underweight next to the 600 wordmark), and the spacing compacts to
     --space-xs each side (was ~22px + 24px). Left offset = wordmark's
     measured 90.4px end + 12px. */
  #sD .cs-projname {
    display: flex; align-items: center;
    position: fixed; z-index: 35;
    top: 32px; height: 24px;                /* the wordmark's exact line box */
    left: calc(var(--gutter) + 102px);      /* wordmark end (90.4px) + --space-xs */
    padding-left: var(--space-xs);
    border-left: 1.5px solid color-mix(in srgb, var(--fg) 35%, var(--bg));
    /* r7 trial (Jacky 2026-08-22): name at Regular — the 500 still read
       bold next to the muted colour; hierarchy now rests on colour +
       weight both (600 wordmark / 400 name). */
    font-size: var(--fz-sub-head); font-weight: 400;
    letter-spacing: .02em;                  /* mirror .site-wordmark */
    color: var(--fg-muted);
  }
  #sD .cs-toc {
    display: flex; align-items: center; gap: var(--space-lg);
    position: fixed; z-index: 35;
    top: 32px; height: 28px;
    /* CS-9 r10: gutter + 117 puts the last label's end a full 36px (the
       TOC's own item gap) before the moon icon — one rhythm across the
       whole bar (Jacky: 側邊 gap 跟 item spacing 一樣闊). */
    right: calc(var(--gutter) + 117px);
  }
  #sD .cs-toc a {
    /* CS-9 r10 (Jacky 2026-08-22): sentence case, not caps — easier to
       read; the caps tracking goes with it. */
    font-size: var(--fz-microcopy); font-weight: 500; line-height: 1;
    text-decoration: none; color: var(--fg-muted);
    transition: color var(--t-base);
  }
  #sD .cs-toc a:hover { color: var(--fg); }
  /* Rail language: the current section lights up + bolds. */
  #sD .cs-toc a.is-active { color: var(--fg); font-weight: 700; }
  #sD .cs-close {
    display: flex; align-items: center; justify-content: center;
    position: fixed; z-index: 40;
    /* CS-10 (Jacky 2026-08-25): the X retires — this button is now the
       back-to-Work GRID icon. CS-15 (Jacky 2026-08-26, revising CS-11
       r3 on desktop only): THEME reclaims the desktop rightmost slot
       (matching the homepage corner); grid sits second (+56), contents
       third (+112). Mobile keeps grid rightmost. */
    top: 24px; right: calc(var(--gutter) - 8px + 56px);
    width: 44px; height: 44px; padding: 0;            /* BR-9 hit area */
    background: none; border: 0; cursor: pointer;
    color: var(--fg-muted);
    transition: color var(--t-base);
  }
  #sD .cs-close:hover { color: var(--fg); }
  /* CS-9 r10: the r6 icon-drift retires — both side gaps now match the
     TOC's 36px item rhythm via BOX positions (theme slot +10px, TOC
     offset re-derived), icons back at their box centres. */
  /* Sections land clear of the floating chrome on anchor jumps. */
  /* CS-9 L-sweep: 96 = the chrome-inset composition (wordmark top 32 +
     28px row + lg breathing) — same formula the mobile flow inset uses,
     so it tracks the chrome instead of being a stray literal. */
  #sD .cs-sec { scroll-margin-top: calc(32px + 28px + var(--space-lg)); }
  /* Over the hero: bright ink in both themes (the CS-7 r2 lesson — muted
     grey drowns on the artwork's bright corners even with the scrim).
     CS-9 r11 (Jacky 2026-08-22): the flat all-#e5e5e5 killed the
     rest/hover/active ladder — no hover feedback over the hero. The
     ladder returns SHIFTED UP into hero ink: rest a softer bright grey
     (still well clear of --fg-muted, which was what drowned), hover
     #e5e5e5, current #fff — the same interaction grammar as in-page. */
  body.cs-hero-zone:not(.menu-open) #sD .cs-projname { color: #e5e5e5; }
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc a,
  body.cs-hero-zone:not(.menu-open) #sD .cs-close,
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc-btn { color: #b3b8c0; }
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc a:hover,
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc a:focus-visible,
  body.cs-hero-zone:not(.menu-open) #sD .cs-close:hover,
  body.cs-hero-zone:not(.menu-open) #sD .cs-close:focus-visible,
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc-btn:hover,
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc-btn:focus-visible,
  body.cs-hero-zone.cs-toc-open:not(.menu-open) #sD .cs-toc-btn { color: #e5e5e5; }
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc a.is-active { color: #ffffff; }
  body.cs-hero-zone #sD .cs-projname { border-left-color: rgba(229, 229, 229, .45); }
  /* Retreat with the rest of the chrome while reading (CS-6 r2 behaviour). */
  #sD .cs-projname, #sD .cs-toc, #sD .cs-close {
    transition:
      color var(--t-base),
      transform var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1),
      opacity   var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1);
  }
  body.cs-bar-away:not(.menu-open) #sD .cs-projname,
  body.cs-bar-away:not(.menu-open) #sD .cs-toc,
  body.cs-bar-away:not(.menu-open) #sD .cs-close {
    transform: translateY(-88px);
    opacity: 0;
    pointer-events: none;
  }
}
/* ── CS-11 r2: the dropdown panel serves EVERYTHING below 1440 ──
   Placed after the >=1100 chrome block on purpose: in 1100–1439 the
   row styles above still match, and these later equal-specificity
   rules re-shape the same nav into the panel. */
@media (max-width: 1439px) {
  #sD .cs-toc {
    display: flex; flex-direction: column; align-items: stretch;
    gap: var(--space-sm);
    position: fixed; z-index: 44;
    top: 76px; right: calc(var(--gutter) - 8px);
    height: auto; min-width: 200px;
    padding: var(--space-md);
    background: var(--bg);
    border-radius: 2px;
    box-shadow: 0 16px 40px -14px rgba(1, 5, 14, .4);   /* WK-8 soft shadow */
    opacity: 0; transform: translateY(-8px); pointer-events: none;
    transition:
      opacity var(--t-base) cubic-bezier(0.22, 1, 0.36, 1),
      transform var(--t-base) cubic-bezier(0.22, 1, 0.36, 1);
  }
  body.cs-toc-open #sD .cs-toc { opacity: 1; transform: none; pointer-events: auto; }
  #sD .cs-toc a {
    font-size: var(--fz-body); font-weight: 400; line-height: 1.2;
    text-decoration: none; color: var(--fg-muted);
    transition: color var(--t-base);
  }
  #sD .cs-toc a.is-active { color: var(--fg); font-weight: 700; }
  /* The panel is a --bg surface — the hero-zone ink borrow and the
     bar-away retreat are ROW concerns; the panel keeps page ink and
     simply closes on scroll (case.js). */
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc a { color: var(--fg-muted); }
  body.cs-hero-zone:not(.menu-open) #sD .cs-toc a.is-active { color: var(--fg); }
  body.cs-bar-away:not(.menu-open) #sD .cs-toc { transform: translateY(-8px); opacity: 0; pointer-events: none; }
  /* Retreat with the reading chrome. */
  body.cs-bar-away:not(.menu-open) #sD .cs-toc-btn {
    transform: translateY(-88px); opacity: 0; pointer-events: none;
  }
}
/* MOBILE AUDIT (2026-08-26): below 1100 the grid icon had no retreat rule —
   the ≥1100 block above owns that, alongside the projname and the TOC row —
   so once the phone scroll started driving cs-bar-away (case.js) the contents
   icon would have slid away while the grid icon stayed behind, alone. It
   leaves with the rest of the bar. */
@media (max-width: 1099px) {
  body.cs-bar-away:not(.menu-open) #sD .cs-close {
    transform: translateY(-88px); opacity: 0; pointer-events: none;
  }
}
@media (min-width: 1100px) and (max-width: 1439px) {
  /* Desktop chrome, no room for the row: the contents icon takes the
     THIRD desktop slot (theme 0, grid +56, contents +112 — 56 pitch). */
  #sD .cs-toc-btn { right: calc(var(--gutter) - 8px + 112px); }
}
@media (min-width: 1440px) {
  #sD .cs-toc-btn { display: none; }
}
/* Exit: the case's TEXT chrome fades with the rest of the scene's
   cascade (the bar ICONS take the fast swap below — CS-15 r4). */
#sD.cs-exiting .cs-projname,
#sD.cs-exiting .cs-toc {
  animation: cs-fade-out var(--cs-exit-dur) cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* r6 (Jacky 2026-08-27): at ≥1440 the TOC row leaves WITH the grid icon
   on the fast clock — measured: grid was gone in 200ms while the row
   trailed on the 2450ms cascade. Same duration + easing as the icons'
   swap-out, so the two curves match; the projname stays on the slow
   cascade (it's the case's identity, it dissolves with the content). */
@media (min-width: 1440px) {
  #sD.cs-exiting .cs-toc {
    animation: cs-fade-out var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1) both;
  }
}

/* ── CS-15 (Jacky 2026-08-26): the bar icons SWAP, not fade ──────────
   r4 (Jacky's reference: transitions.dev icon-swap): a scale+blur
   crossfade — the outgoing icon shrinks and blurs away, the incoming
   one grows and sharpens in a .12s beat later. Both legs run AT THE
   interaction, not on the 2450ms content cascade — the old lonely
   fade-then-empty-slot read was Jacky's complaint.

   Entry: grid + contents swap in when the scene mounts (the renderer
   rebuilds the buttons per case, so case→case replays too); on mobile
   the burger's swap-out (index.html) crosses them in the shared slot;
   on desktop the theme icon joins via the same keyframes (index.html).
   From-only keyframes + backwards fill: the animation releases to
   natural style when done, so it never pins transform/opacity/filter
   over the transitions the buttons rely on (hero ladder, bar retreat,
   gate dim).

   Exit (r2 lesson kept): one rule covers BOTH icons at every width, at
   the specificity that beats the entry rule while .active is still on.
   `both` fill holds them gone for the rest of the cascade; the burger
   returns meanwhile via body.cs-leaving (index.html). */
/* r6 (Jacky 2026-08-27): entry swap-in runs BELOW 1440 only. At ≥1440
   the TOC is the visible row, which fades in purely with the scene —
   measured curves showed the icons' own delay+swap multiplying under
   the scene fade and LAGGING the row (grid 0.00 vs row 0.34 at 200ms).
   ≥1440 the icons now ride the scene fade too: one curve, perfect
   sync. Below 1440 the TOC is the contents ICON — it and the grid
   share this rule, so they were already sample-identical there, and
   the burger crossfade keeps its snap. */
@media (max-width: 1439px) {
  #sD.scene.active .cs-close,
  #sD.scene.active .cs-toc-btn {
    animation: cs-icon-swap-in var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1) .12s backwards;
  }
}
#sD.cs-exiting.scene.active .cs-close,
#sD.cs-exiting.scene.active .cs-toc-btn {
  animation: cs-icon-swap-out var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes cs-icon-swap-in {
  from { opacity: 0; transform: scale(.55); filter: blur(4px); }
}
/* r3 lesson: no explicit `from` — start from each element's own state. */
@keyframes cs-icon-swap-out {
  to { opacity: 0; transform: scale(.55); filter: blur(4px); }
}

/* ---------------- hero — full-screen cover, chrome floats above ------- */
#sD .cs-hero {
  position: relative;
  height: 100svh;
  min-height: 480px;
  background: var(--neutral);
  overflow: hidden;
}
/* Mobile hero is a width-fit band, not a full-viewport cover (Jacky,
   2026-08-08); desktop keeps the 100svh stage. MUST sit after the base
   rule: equal specificity, source order decides.
   MOBILE AUDIT r4 (2026-08-27): the band was hard-coded 16/9, which fit the
   two Splunk covers and cropped every other hero (SOLO's 2.22:1 video lost
   ~20% of its width). It now takes --cs-hero-ar, the art's own ratio, set
   per case by case-render.js; 16/9 stays the fallback for a hero with no
   dimensions. */
@media (max-width: 1099px) {
  #sD .cs-hero {
    height: auto;
    aspect-ratio: var(--cs-hero-ar, 16 / 9);
    min-height: 0;
  }
}
#sD .cs-hero-img {
  /* The Figma cover is a single 16:9 fill on the 1440×900 frame, centred and
     cropped — so a plain centred cover matches it exactly. The art is dark at
     the bottom-left, which is where the title sits. */
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}
/* The hero carries no type (the Figma cover is image-only), so the page's H1
   is visually hidden — the document still has exactly one heading for
   assistive tech and for search. */
#sD .cs-sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; clip-path: inset(50%);
  white-space: nowrap;
}

/* ---------------- shared text roles ---------------- */
#sD .cs-eyebrow {
  font-size: var(--fz-microcopy);
  font-weight: 500;
  letter-spacing: var(--nav-tracking);
  text-transform: uppercase;
  color: var(--fg-muted);
}
#sD .cs-eyebrow--strong { color: var(--fg); }
/* CS-9 r8 (Jacky 2026-08-22): section eyebrows recede to the About-page
   label grey (--fg-faint, same as PROJECT DETAILS) — the white read as
   competing with the title; hierarchy wants the label quiet. */
#sD .cs-sec-head .cs-eyebrow { color: var(--fg-faint); }
#sD p { font-size: var(--fz-body); line-height: var(--csp-prose-lh); color: var(--fg-muted); }
/* Super-wide tier (≥2560) — body copy grows 16→20px to stay proportional,
   the same scene-scoped bump About uses (about.css) and the homepage does for
   .sa-greet-sub. It matters more here than anywhere: the stream is a fixed
   760px, so on a 2560/3440 display the article is already a narrow ribbon.
   The micro tier rides along, 14→16 (decided 2026-08-05): at this width 14px
   captions / stat labels / aside body read too small next to the 20px body —
   16 keeps the contrast ratio between the two tiers. Scene-scoped var
   override ONLY — the global nav rail shares --fz-microcopy and must not
   move (see BACKLOG RES-1). */
@media (min-width: 2560px) {
  #sD {
    --fz-body: 20px; --fz-microcopy: 16px;
    /* The container scales WITH the type — everything ×1.25 (= 20/16), so
       the measure lands exactly where 1440 put it: 750px ÷ 20px ≡ 600px ÷
       16px chars-per-line, figures grow 25%, and the page stops feeling
       like a narrow ribbon in a sea of margin (Jacky, 2026-08-05). */
    --cs-stream: 950px;
    --cs-col: 750px;
    --cs-bleed: 1375px;
    /* CS-9: the rhythm rides the same ×1.25 — spacing/type ratio holds. */
    --csp-scale: 1.25;
  }
}
#sD p strong, #sD p .cs-k { font-weight: 500; color: var(--fg); }

/* ---------------- project details ----------------
   Inline meta block at the top of section 01 (Figma 1164:3729, 2026-08-06) —
   the v4 right-hand rail retired in its favour, the CONTENTS toc retired
   outright. Label column 120px, caps faint; values body on --fg. Sits in the
   text column, so it scales with --cs-col at ≥2560 like everything else. */
#sD .cs-details {
  width: 100%; max-width: var(--cs-col); margin-inline: auto;
  /* CS-9 r9 (Jacky 2026-08-22): the meta block reads as its OWN section —
     the sec-gap to Background below was too close. The margin tops the
     section flex's --csp-sec-gap up to the mobile stream's section beat
     (2xl 72); desktop overrides below to the full S1 section-gap. */
  margin-bottom: calc(var(--space-2xl) - var(--csp-sec-gap));
}
@media (min-width: 1100px) {
  #sD .cs-details { margin-bottom: calc(var(--csp-section-gap) - var(--csp-sec-gap)); }
}
#sD .cs-details-head,
#sD .cs-details dt {
  font-size: var(--fz-microcopy);
  letter-spacing: var(--nav-tracking);
  text-transform: uppercase;
  color: var(--fg-faint);
}
#sD .cs-details dl {
  display: flex; flex-direction: column; gap: var(--space-2xs);   /* CS-9: was sm */
  margin: var(--space-sm) 0 0;
}
#sD .cs-detail { display: flex; align-items: baseline; gap: var(--space-2xs); }
#sD .cs-detail dt { width: 120px; flex: none; }
/* MOBILE AUDIT (2026-08-26): 120px is 38% of the 318px phone measure, and it
   pushed TEAM to four lines. 96 still holds the longest label (DURATION) on
   one line and hands the values 24px back. */
@media (max-width: 736px) { #sD .cs-detail dt { width: 96px; } }
#sD .cs-detail dd {
  margin: 0;
  font-size: var(--fz-body);
  line-height: 1.5;
  color: var(--fg);
}

/* The 600px text column, centred inside the 760px stream. */
#sD .cs-sec-head,
#sD .cs-prose,
#sD .cs-item,
#sD .cs-step,
#sD .cs-pair,
#sD .cs-chips,
#sD .cs-bullets,
#sD .cs-work-text,
#sD .cs-stats { width: 100%; max-width: var(--cs-col); margin-inline: auto; }

/* ---------------- the stream ----------------
   Mobile: gutter-padded flow. Desktop: a 760px measure centred in the
   viewport, sections on the --space-3xl (144px) rhythm. */
#sD .cs-body {
  display: flex; flex-direction: column;
  gap: var(--space-2xl);
  /* Side padding on phones = THE SITE GUTTER. History: 36px (--space-lg, a
     rung above the gutter) from 2026-08-08, when the prose wanted more air
     off the screen edge; Jacky 2026-08-27, reading on the phone, called it
     too wide — the figures were paying for it (SOLO's process shots sat at
     153px). He tried 8, then 16 (not on the ladder), then compared 18
     against About and picked ABOUT's: "about page 好啲, 24". So the case
     stops having a gutter of its own and joins the other scenes on
     var(--gutter) — one vertical line down the whole site on a phone.
     390 - 48 = 342px of content: figures gain 7.5% on the old 36, and the
     prose measure sits at ~43 characters, mid-band. */
  padding: var(--space-2xl) var(--gutter) 0;
}
@media (min-width: 1100px) {
  #sD .cs-body {
    width: var(--cs-stream);
    max-width: calc(100% - 2 * var(--gutter));
    margin-inline: auto;
    gap: var(--csp-section-gap);
    padding: var(--csp-section-gap) 0 0;
  }
}

/* Sections. */
#sD .cs-sec { position: relative; display: flex; flex-direction: column; gap: var(--csp-sec-gap); }
#sD .cs-sec-head { display: flex; flex-direction: column; gap: var(--csp-head-gap); }
#sD .cs-sec-title {
  font-size: var(--csp-fz-section);
  /* CS-9 r4 (Jacky 2026-08-22): Bold retired — the case's display type
     follows the About grammar, Regular with hierarchy carried by SIZE
     (About statement / pull / work titles are all Regular already). */
  font-weight: 400;
  line-height: 1;
  color: var(--fg);
}
/* CS-9 r14 (Jacky 2026-08-25): second-level heading — a sechead with
   size:'sm' labels a passage inside a section (e.g. the interview quotes),
   so it steps down to the deliverable-title tier instead of the full 40. */
#sD .cs-sec-title--sm { font-size: var(--csp-fz-work); }
/* CS-9 r15 (Jacky 2026-08-25): a full-size sechead that arrives mid-section
   is a NEW MOVEMENT ("Our solution", "Key insights", "Prioritization"…) —
   it must breathe like a section start, echoing the beat before "The
   challenge", not sit one ordinary sec-gap under the previous figure.
   The margin tops the section flex gap up to the full section beat.
   Passage-level (--sm) heads are exempt — they stay close to their text. */
#sD .cs-sec > :not(.cs-details) + .cs-sec-head:not(.cs-sec-head--sm) {
  margin-top: calc(var(--csp-section-gap) - var(--csp-sec-gap));
}
/* CS-20 r2 (Jacky): a HEADED pull is a movement of its own — folding the
   sechead into the quote (CS-20) lost the 144px movement beat the head
   carried, and the vision unit glued itself to its neighbours. The beat
   returns on BOTH sides of the unit. */
#sD .cs-sec > * + .cs-pull--headed,
#sD .cs-sec > .cs-pull--headed + * {
  /* (1,2,0) on the sibling: the later #sD .cs-fig { margin: 0 } (1,1,0)
     was zeroing the below-beat at the tie-break. */
  margin-top: calc(var(--csp-section-gap) - var(--csp-sec-gap));
}

/* All running prose is --fg-muted (inherited from the #sD p rule above); --fg is
   reserved for headings and other "active" type. The Figma frame binds section
   01's body to --fg, but following that left the section bodies brighter than
   the deliverable prose on the SAME page — Jacky's call (2026-08-03) is that
   body copy is body copy. */
#sD .cs-prose > p + p { margin-top: var(--csp-para-gap); }

/* Sub-items (the three challenges, the two reflections). */
#sD .cs-items { display: flex; flex-direction: column; gap: var(--csp-items-gap); }
#sD .cs-item h3 {
  font-size: var(--csp-fz-subhead);
  font-weight: 500;
  line-height: 1.2;
  color: var(--fg);
  margin-bottom: var(--csp-item-gap);
}

/* Numbered process list (items variant:'steps') — number chip in a left
   column, title + copy aligned beside it. Chip ground is --neutral; the
   digit uses the .wk-tile-idx recipe (65% fg toward the surface it sits
   on) so it clears AA on --neutral in both themes (BR-3 rule: never
   faint/muted on neutral). */
#sD .cs-items--steps { gap: var(--space-sm); }  /* tighter than items — was md (CS-9), lg before that */
#sD .cs-step {
  display: grid;
  grid-template-columns: 36px 1fr;
  column-gap: var(--space-md);
  align-items: start;
}
#sD .cs-step-num {
  width: 36px; height: 36px;
  display: flex; align-items: center; justify-content: center;
  background: var(--neutral);
  border-radius: 6px;   /* Jacky 2026-08-15: softer than the site's 2px */
  outline: 1px solid var(--line);
  outline-offset: -1px;
  font-size: var(--fz-body);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--fg) 65%, var(--neutral));
}
/* CS-9 r27 (Jacky 2026-08-26): image ↔ point pairs. Same skeleton as the
   step row above (fixed left column, text beside it), but the left column
   holds a picture instead of a number chip — for lists where each point
   has its own image and a 4-up strip above the copy makes the reader
   guess the pairing. The media keeps the figure ring via .cs-fig-media. */
#sD .cs-pairs { display: flex; flex-direction: column; gap: var(--csp-items-gap); }
#sD .cs-pair {
  display: grid;
  grid-template-columns: 96px 1fr;
  column-gap: var(--space-md);
  /* Centred, not top-aligned (Jacky 2026-08-26): the text block is much
     shorter than the square, so top-alignment left each row hanging off the
     picture's top edge. The step rows keep align-items:start — there the
     left column is a 36px chip, not a 160px image. */
  align-items: center;
}
#sD .cs-pair .cs-fig-media { border-radius: 6px; }   /* matches the step chip */
#sD .cs-pair-text h3 {
  font-size: var(--csp-fz-subhead);
  font-weight: 500;
  line-height: 1.2;
  color: var(--fg);
  margin-bottom: var(--csp-item-gap);
}
@media (min-width: 1100px) {
  #sD .cs-pair { grid-template-columns: 160px 1fr; column-gap: var(--space-lg); }
}

/* CS-9 r26 (Jacky 2026-08-26): method chips — a research-activity list is
   a SET of labels, not a sentence, so it leaves the prose voice and becomes
   a row of chips. Same recipe as the step number chip above (--neutral
   ground, 6px radius, hairline ring, 65%-mixed ink per the BR-3 rule that
   nothing faint/muted sits on --neutral) at label size, so the two read as
   one family. */
#sD .cs-chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs);
  list-style: none;
  /* margin-BLOCK only — the 600px column rule above owns margin-inline
     (auto), and a blanket `margin: 0` here silently killed the centring,
     parking the row on the 760 stream edge instead of the text column. */
  margin-block: 0;
  padding: 0;
}
#sD .cs-chip {
  padding: var(--space-2xs) var(--space-xs);
  background: var(--neutral);
  border-radius: 6px;
  outline: 1px solid var(--line);
  outline-offset: -1px;
  font-size: var(--fz-microcopy);
  font-weight: 500;
  line-height: 1.2;
  color: color-mix(in srgb, var(--fg) 65%, var(--neutral));
}

#sD .cs-step-text h3 {
  font-size: var(--csp-fz-subhead);
  font-weight: 500;
  line-height: 1.2;
  color: var(--fg);
  /* tighter than the step rhythm (Jacky 2026-08-18); tokenised in the
     CS-9 L-sweep — was a stray 6px, 2xs is the same intent on the scale */
  margin-bottom: var(--space-2xs);
  /* top-aligned with the chip, per Jacky's Figma ref */
}

/* Deliverable blocks (01–04): title + prose, then their figures. */
#sD .cs-work { position: relative; display: flex; flex-direction: column; gap: var(--csp-work-gap); }
/* CS-9: work ↔ work composes ON TOP of the section flex's sec-gap —
   margin = works-gap − sec-gap, so the total between deliverables is
   exactly --csp-works-gap whatever either knob does. */
#sD .cs-work + .cs-work { margin-top: calc(var(--csp-works-gap) - var(--csp-sec-gap)); }
/* CS-9 r12 (Jacky 2026-08-22, internship pass): a work that FOLLOWS a
   figure gets the same beat — the section-intro figure sat one sec-gap
   above the first deliverable and read cramped. */
#sD .cs-fig + .cs-work { margin-top: calc(var(--csp-works-gap) - var(--csp-sec-gap)); }
#sD .cs-work-text { display: flex; flex-direction: column; gap: var(--csp-worktext-gap); }
/* CS-9 r4 (Jacky 2026-08-22, option A): deliverable titles read too small
   at sub-head 20 — they step up to --fz-mid 30 at REGULAR, the About-page
   grammar (hierarchy by size, not weight): 40 section → 30 work → 16 body. */
#sD .cs-work-text h3 {
  font-size: var(--csp-fz-work);
  font-weight: 400;
  line-height: 1.2;
  color: var(--fg);
}

/* At-a-glance metrics — 2×2 on mobile, 4-up from tablet, left-aligned. */
#sD .cs-stats {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-md);
}
@media (min-width: 737px) {
  #sD .cs-stats { grid-template-columns: repeat(4, 1fr); }
  /* A stats row is usually four items wide. SOLO's Key insights has only
     two, and in a fixed four-column grid those sat in the left half with
     their labels crushed into five-line columns while the right half stood
     empty. Give a two-item row half-width columns instead. Cases with
     three or four stats don't match this and are untouched. */
  #sD .cs-stats:has(li:nth-child(2):last-child) { grid-template-columns: repeat(2, 1fr); }
}
#sD .cs-stat-num {
  display: block;
  font-size: var(--csp-fz-stat);
  font-weight: 700;
  line-height: 1;
  color: var(--fg);
}
#sD .cs-stat-label {
  display: block;
  font-size: var(--fz-microcopy);
  color: var(--fg-faint);
  margin-top: var(--csp-statlabel-gap);
}
/* CS-9 r28 (Jacky 2026-08-26): variant:'card' — each metric gets its own
   container so the number and its label read as one element. r2: the
   ring-only first cut read too faintly against the page ("透明底就顯得好
   noticeable"), so the card takes the --neutral ground the chips and the
   step chip already use. That ground costs a re-ink — BR-3 bars --fg-faint
   from --neutral — so the label switches to the shipped 65%-toward-the-
   surface mix (the .wk-tile-idx recipe, 6.07:1 dark / 5.49:1 light). The
   number is full --fg, unaffected. Ring + 6px radius stay, same as the
   figure box and the chips. */
#sD .cs-stats--card { gap: var(--space-sm); }
/* One card per row on phones — at 150px wide the labels wrapped to six
   ragged lines, which is exactly the looseness the card was added to fix. */
@media (max-width: 736px) {
  #sD .cs-stats--card { grid-template-columns: 1fr; }
}
#sD .cs-stats--card li {
  padding: var(--space-md);
  background: var(--neutral);
  border-radius: 6px;
  outline: 1px solid var(--line);
  outline-offset: -1px;
}

/* CS-9 r28: bullets — short parallel findings that were running as
   standalone paragraphs. Dot marker in the faint ink, hanging indent so the
   text block still lines up with the column edge. */
#sD .cs-stats--card .cs-stat-label {
  color: color-mix(in srgb, var(--fg) 65%, var(--neutral));
}

#sD .cs-bullets {
  list-style: none;
  display: flex; flex-direction: column;
  gap: var(--csp-para-gap);
  margin-block: 0;
  padding: 0;
}
#sD .cs-bullets li {
  position: relative;
  padding-left: var(--space-md);
  font-size: var(--fz-body);
  line-height: 1.5;
  color: var(--fg-muted);
}
#sD .cs-bullets li::before {
  content: "";
  position: absolute;
  left: 2px;
  top: calc(0.75em - 2px);
  width: 4px; height: 4px;
  border-radius: 50%;
  background: var(--fg-faint);
}

/* ---------------- pull quote (the standalone beat) ----------------
   Figma sets it in an ExtraLight cut we don't have — Hyperlegible ships
   Regular/Medium/Bold — so Regular carries the "light display" read, with the
   design's negative tracking kept as an em value so it scales with the step.
   --fz-mid is 16px on small mobile, too small to read as display type, so it's
   floored at 20px (same deliberate deviation as the Figma mobile frame). */
#sD .cs-pull {
  margin: 0;
  display: flex; flex-direction: column; align-items: center;
  gap: var(--space-sm);
}
#sD .cs-pull p {
  max-width: 720px;
  text-align: center;
  font-size: max(20px, var(--fz-mid));
  font-weight: 400;
  line-height: 1.25;
  letter-spacing: -0.05em;
  color: var(--fg);
}
/* CS-20: the pull's own label — eyebrow grammar, centred, and the quote
   tucks up under it (the flex gap stays for the dividers). Restated at
   (1,2,0): the generic `#sD .cs-pull p` quote rule (1,1,1) would
   otherwise hand the label the 30px quote size and tight tracking. */
#sD .cs-pull .cs-pull-label {
  text-align: center; color: var(--fg-faint);
  font-size: var(--fz-microcopy);
  font-weight: 500;
  letter-spacing: var(--nav-tracking);
}
#sD .cs-pull-label + p { margin-top: calc(-1 * var(--space-2xs)); }
/* Short centred rule (Figma: 60×1). */
#sD .cs-divider {
  width: 60px; height: 1px;
  border: 0; margin: 0;
  background: var(--line);
}

/* Quoted voice inside a deliverable (the engineer's line).
   CS-9 r2 (Jacky 2026-08-22): the old bold left-set block retired — the
   renderer now emits it with the .cs-pull grammar above (centred light
   display between the short rules), so the two quote voices read as one
   family. .cs-quote remains on the element purely as a scoping hook.
   r6: same BREATHING as the standalone pull too — a pulled-out beat
   deserves section-level air wherever it sits. The work flex already
   contributes --csp-work-gap, so the margin tops it up to exactly
   --csp-section-gap on both sides (the works-gap composition trick). */
#sD .cs-quote {
  margin-block: calc(var(--csp-section-gap) - var(--csp-work-gap));
}
/* CS-9 r14 (Jacky 2026-08-25): ADJACENT quotes are one group — the
   section-level breathing belongs around the group, not between its
   members. The second quote pulls back both quotes' facing margins
   (its own top + the first one's bottom), netting the pair to just the
   section's ordinary flex gap; its top rule hides so the pair shares
   the first quote's bottom divider instead of stacking two. */
#sD .cs-quote + .cs-quote {
  margin-top: calc(var(--csp-work-gap) - var(--csp-section-gap));
}
#sD .cs-quote + .cs-quote .cs-divider:first-child { display: none; }

/* ---------------- margin asides ----------------
   Inline callout by default (thin rule + small type). At ≥1440 there's room for
   the 220px side lane plus its 48px gap plus the page gutter, so the note breaks
   out of the measure and sits in the margin beside the passage it belongs to. */
#sD .cs-note {
  width: 100%; max-width: var(--cs-col); margin-inline: auto;
  border-top: 1px solid var(--line);
  padding-top: var(--space-md);
  display: flex; flex-direction: column; gap: var(--space-2xs);   /* CS-9: was xs */
}
#sD .cs-note h3 {
  font-size: var(--fz-sub-head);
  font-weight: 500;
  line-height: 1.2;
  color: var(--fg);
}
#sD .cs-note p { font-size: var(--fz-microcopy); color: var(--fg-muted); }
@media (min-width: 1440px) {
  #sD .cs-note {
    position: absolute; top: 0;
    width: 220px; max-width: none; margin-inline: 0;
    border-top: 0; padding-top: 0;
  }
  #sD .cs-note--right { left: calc(100% + var(--space-xl)); }
  #sD .cs-note--left  { right: calc(100% + var(--space-xl)); }
}
/* Super-wide: the lanes ride the micro type (14→16 up top). 220px was measured
   for 14px body — at 16px it drops to a four-word line. 260 ≈ 220 × 16/14, so
   the measure (chars per line) lands back where the 1440 design put it.
   (Must sit AFTER the ≥1440 rule — same specificity, source order decides.) */
@media (min-width: 2560px) {
  #sD .cs-note { width: 275px; }   /* 220 × 1.25 — same scale as the stream */
}

/* ---------------- figures ----------------
   Full 760 stream width; placeholders keep their aspect via --ar. */
#sD .cs-fig { position: relative; margin: 0; width: 100%; }
#sD .cs-fig-media {
  position: relative; width: 100%;
  aspect-ratio: var(--ar, 16 / 9);
  background: var(--neutral);
  border-radius: 2px;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
  /* CS-4 F5: a hairline edge so figures keep their boundary when the art is
     near the page ground (dark Figma frames on the navy bg; white shots on
     paper in light mode). Outline, not border: paints OVER the image edge,
     zero layout shift, and it follows the radius. --line is theme-aware. */
  outline: 1px solid var(--line);
  outline-offset: -1px;
}
#sD .cs-fig-media img, #sD .cs-fig-media video, #sD .cs-fig-media iframe {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0;
}
/* Breakout figure — art whose full frame must show uncropped, wider than the
   stream (first: Fig 04, a 2.37:1 flow map). The media box carries the image's
   TRUE aspect via --ar and breaks out of the 760 measure to 1100px, centred on
   the stream (which is itself viewport-centred), capped by the page gutters.
   Below 1100 figures are already full column width and the true --ar means
   nothing crops — no extra rule needed. Caption stays at stream width. */
@media (min-width: 1100px) {
  #sD .cs-fig--bleed .cs-fig-media {
    --bleed-w: min(var(--cs-bleed), calc(100vw - 2 * var(--gutter)));
    width: var(--bleed-w);
    margin-left: calc((100% - var(--bleed-w)) / 2);
  }
}

/* Comparison row (CS-9 r16, Jacky 2026-08-25): two full figures side by
   side on desktop — old vs new, each keeping its own caption. Stacks in
   the mobile flow (a plain sec-gap column). --bleed variant widens the
   ROW to the breakout measure, same math as .cs-fig--bleed, so each half
   still reads at a useful size. */
#sD .cs-figrow {
  display: grid;
  gap: var(--csp-sec-gap);
  width: 100%;
}
@media (min-width: 1100px) {
  #sD .cs-figrow {
    grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
    gap: var(--space-sm);
    align-items: start;
  }
  #sD .cs-figrow--bleed {
    --bleed-w: min(var(--cs-bleed), calc(100vw - 2 * var(--gutter)));
    width: var(--bleed-w);
    margin-left: calc((100% - var(--bleed-w)) / 2);
  }
}

/* Phone row (CS-17) — a flat row of phone screens, one centred short note
   under each (the GM workflow analysis). The screens are transparent device
   art: NO skeleton ground, NO hairline — the neutral slab showing through
   the transparent corners was the bug this replaces (.cs-fig--art logic).
   Desktop: four across on the breakout measure; below 1100 they stack,
   each capped to a hand-size width (mobile cut pending Jacky's review). */
#sD .cs-phones {
  display: grid;
  gap: var(--csp-items-gap, var(--space-lg));
  width: 100%;
}
#sD .cs-phone { margin: 0; min-width: 0; }
#sD .cs-phone-media {
  position: relative; width: 100%;
  aspect-ratio: var(--ar, 9 / 19.5);
  background: transparent;
}
#sD .cs-phone-media img {
  width: 100%; height: 100%; object-fit: contain; display: block;
}
#sD .cs-phone-cap {
  margin-top: var(--space-sm);
  text-align: center;
}
#sD .cs-phone-cap h3 {
  font-size: var(--fz-body);
  font-weight: 500;
  line-height: 1.2;
  color: var(--fg);
  margin: 0 0 var(--space-2xs);
}
#sD .cs-phone-cap p {
  font-size: var(--fz-microcopy);
  color: var(--fg-muted);
  margin: 0;
  max-width: 36ch;
  margin-inline: auto;
}
@media (max-width: 1099px) {
  #sD .cs-phone-media { max-width: 320px; margin-inline: auto; }
  /* CS-17 r2 (Jacky 2026-08-30): stacked, each screen+note must read as
     its OWN beat —「一幅圖字係一個 section,隔多少少先係第二個」. The
     base gap resolves to --csp-items-gap (~16px, sub-item scale), which
     glued the four into one strip. Section-scale air between units. */
  #sD .cs-phones { gap: var(--space-2xl); }
}
@media (min-width: 1100px) {
  #sD .cs-phones {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: var(--space-md);
    align-items: start;   /* slight per-screen AR differences — tops align */
    --bleed-w: min(var(--cs-bleed), calc(100vw - 2 * var(--gutter)));
    width: var(--bleed-w);
    margin-left: calc((100% - var(--bleed-w)) / 2);
  }
}

/* Scroll strip (CS-9 r20, Jacky 2026-08-25): a figure whose image keeps a
   READABLE height and runs wider than the page — the original site's
   journey-map treatment. The media box becomes a horizontal scroller
   spanning gutter to gutter; the frame cut off at the right edge is the
   cue (Instagram-style) that there is more to pan through. The image
   escapes the --ar box model entirely: natural aspect at a fixed height. */
#sD .cs-fig--scroll .cs-fig-media {
  aspect-ratio: auto;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  display: block;
  background: none;
  /* r22: the native scrollbar is chrome the composition never asked
     for — the strip's affordances are the cut-off frame, the grab
     cursor + drag (case.js), and the edge arrows. */
  scrollbar-width: none;
  cursor: grab;
  /* r23 (Jacky): the CS-4 F5 figure hairline goes too — a panning
     surface isn't a framed figure; nothing should draw its box. */
  outline: none;
  border-radius: 0;
}
#sD .cs-fig--scroll .cs-fig-media::-webkit-scrollbar { display: none; }
#sD .cs-fig--scroll .cs-fig-media.is-drag { cursor: grabbing; user-select: none; }
/* Slider-style edge arrows (r22): quiet round scrims over the strip's
   viewport edges, vertically centred; .is-off fades one out at its end
   of the run. Desktop-only — touch swipes the strip natively. */
#sD .cs-strip-btn {
  display: none;
  position: absolute; top: 50%;
  z-index: 3;
  width: 44px; height: 44px;
  border: 0; border-radius: 50%;
  align-items: center; justify-content: center;
  color: var(--fg);
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  cursor: pointer;
  transition: opacity var(--t-base) cubic-bezier(0.22, 1, 0.36, 1);
}
#sD .cs-strip-btn.is-off { opacity: 0; pointer-events: none; }
#sD .cs-fig--scroll .cs-fig-media img {
  /* Back into flow — the base .cs-fig-media img is absolutely positioned
     for the aspect-box model, which would leave this scroller 0-tall. */
  position: static; inset: auto;
  height: var(--cs-strip-h, 320px);
  width: auto;
  max-width: none;
  object-fit: unset;
  display: block;
}
/* MOBILE AUDIT (2026-08-26): a DENSE figure — a slide or a multi-screen
   walkthrough whose own body text has to be read — is illegible once it is
   squeezed into the 318px phone measure (GM's four-phone workflow put each
   screen at 65px wide). On phones only it borrows the scroll strip's model:
   readable fixed height, pan sideways, the cut-off frame as the cue. Desktop
   is untouched — there the figure has the full stream and reads fine. */
@media (max-width: 736px) {
  #sD .cs-fig--dense .cs-fig-media {
    aspect-ratio: auto;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    -webkit-overflow-scrolling: touch;
    display: block;
    background: none;
    scrollbar-width: none;
    outline: none;
    border-radius: 0;
  }
  #sD .cs-fig--dense .cs-fig-media::-webkit-scrollbar { display: none; }
  #sD .cs-fig--dense .cs-fig-media img {
    position: static; inset: auto;
    height: var(--cs-strip-h, 320px);
    width: auto; max-width: none;
    object-fit: unset;
    display: block;
  }
}
@media (min-width: 1100px) {
  #sD .cs-fig--scroll .cs-fig-media {
    --strip-w: calc(100vw - 2 * var(--gutter));
    width: var(--strip-w);
    margin-left: calc((100% - var(--strip-w)) / 2);
    /* CS-9 r21 (Jacky): like the original site, the strip's content
       STARTS at the stream's edge — the scroller still spans gutter to
       gutter, but the image leads in (and runs out) at the column, so
       the beginning reads composed and the overflow reads scrollable.
       (Padding %s resolve against the fig's stream-width box.) */
    padding-inline: calc((var(--strip-w) - 100%) / 2);
  }
  #sD .cs-fig--scroll .cs-fig-media img { height: var(--cs-strip-h-desk, 480px); }
  /* Arrows sit at the SCROLLER's viewport edges, not the stream's —
     the fig declares the same breakout width so the offsets resolve. */
  #sD .cs-fig--scroll { --strip-w: calc(100vw - 2 * var(--gutter)); }
  #sD .cs-strip-btn { display: flex; transform: translateY(-50%); }
  #sD .cs-strip-btn--prev { left: calc((100% - var(--strip-w)) / 2 + var(--space-sm)); }
  #sD .cs-strip-btn--next { right: calc((100% - var(--strip-w)) / 2 + var(--space-sm)); }
}
/* ── CS-13 (Jacky 2026-08-26): floating art ─────────────────────
   Transparent collages (FigJam boards, sticky-note clusters, system
   sheets) are ART ON THE PAGE, not framed shots — the fig-media's
   --neutral skeleton ground and the CS-4 F5 hairline showed through
   their transparent areas as a phantom panel. The `art` flag clears
   both; the collage floats straight on the page ground. */
#sD .cs-fig--art .cs-fig-media { background: none; outline: none; }
/* Carousel slides paint their own --neutral ground — inside an art
   figure (the GM before/after boards) they clear it too. */
/* CS-19 r3 (Jacky 2026-08-30): the outline goes too — CS-13 dropped the
   slide's neutral ground for art decks but left the hairline, so
   transparent slides sat in an EMPTY RING(「有個框,有少少奇怪」). Art
   floats frameless, like every other art figure; opaque decks keep
   their ring. */
#sD .cs-fig--art .cs-slide { background: none; outline: none; }
/* (CS-12 r1's ink-plate idea lived here for one round — Jacky's verdict
   was that a backing plate reads alien; the fix shipped instead as
   per-theme image variants (`light` twin on the img data, swapped in
   case.js), keeping the figures transparent in both themes.) */
/* (The r20 "pairs" grid lived here for one round — Jacky's actual ask was
   the margin-note grammar, so figrow + per-fig .cs-note carries it and
   the pairs vocabulary was retired before ever shipping.) */

/* Video scrub — a quiet progress line over the bottom of a figure video.
   Hidden until hover (always faintly there on touch, where hover doesn't
   exist); drag or click to seek, arrow keys when focused. White line over a
   faint player-style scrim, fixed colours NOT --fg: footage varies from
   white product UI to near-black Figma canvas, so the bar must carry its
   own contrast in both themes. */
#sD .cs-vid-scrub {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 34px;
  /* player-style scrim so the line reads on any footage — Fig 02's bottom
     edge is near-black Figma canvas, where an ink bar simply vanishes */
  background: linear-gradient(to top, rgba(1, 5, 14, .40), transparent);
  opacity: 0; transition: opacity var(--t-base);
  cursor: pointer;
  /* MOBILE AUDIT r4 (2026-08-27): was touch-action:none, which swallowed
     vertical swipes that happened to start on this 34px strip — the page
     simply refused to scroll there. pan-y gives the page back its vertical
     gesture; horizontal ones still reach case.js, which starts scrubbing
     only once the finger has moved sideways. */
  touch-action: pan-y;
}
#sD .cs-fig-media:hover .cs-vid-scrub,
#sD .cs-vid-scrub:focus-visible,
#sD .cs-vid-scrub.is-scrubbing { opacity: 1; }
@media (hover: none) { #sD .cs-vid-scrub { opacity: .6; } }
/* BR-10: scrub keeps the global hairline focus ring */
#sD .cs-vid-track {
  position: absolute; left: 12px; right: 12px; bottom: 10px;
  height: 3px; border-radius: 2px;
  background: rgba(255, 255, 255, .30);
}
#sD .cs-vid-fill {
  position: absolute; top: 0; bottom: 0; left: 0;
  width: 0%; border-radius: 2px;
  background: rgba(255, 255, 255, .95);
}

#sD .cs-fig-tag {
  font-size: var(--fz-microcopy);
  color: var(--fg-faint);
  text-align: center;
  padding: 0 var(--space-md);
}

/* Slide carousel (deck figures) — the media wrapper loses its aspect box;
   each slide carries the deck aspect + the hairline ring. 86% slides leave
   the next one peeking as the swipe affordance. */
#sD .cs-fig-media--carousel {
  aspect-ratio: auto;
  background: none;
  outline: none;
  overflow: visible;
  display: block;
}
#sD .cs-carousel {
  display: flex;
  gap: var(--space-xs);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  cursor: grab;
}
#sD .cs-carousel::-webkit-scrollbar { display: none; }
#sD .cs-carousel.is-drag { cursor: grabbing; scroll-snap-type: none; }
#sD .cs-slide {
  flex: 0 0 86%;
  aspect-ratio: var(--ar, 16 / 9);
  background: var(--neutral);
  border-radius: 2px;
  outline: 1px solid var(--line);
  outline-offset: -1px;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
  scroll-snap-align: start;
}
/* Slides carry their own img — undo the media box's absolute stacking
   (that rule targets single-image figures; here each slide is the frame). */
#sD .cs-slide img {
  position: static; inset: auto;
  width: 100%; height: 100%;
  object-fit: cover; display: block;
}
/* figgrid — rows of media cells sharing one caption. Row template comes
   from the data (--cols); single-cell rows span full width. */
#sD .cs-figgrid { display: grid; gap: var(--space-xs); }
#sD .cs-figgrid-row {
  display: grid;
  grid-template-columns: var(--cols, 1fr);
  gap: var(--space-xs);
}
/* MOBILE AUDIT (2026-08-26): --cols is authored for the 760 stream, so a
   3-up row landed at 98px cells and a 4-up at 71px on a 390 phone —
   thumbnails too small to read as photographs. The rows dissolve
   (display: contents) and every cell joins ONE 2-column grid, which also
   avoids the ragged holes a per-row collapse would leave (SOLO's 3+3
   portraits become 3 clean rows). A row holding a single surface is a
   full-width plate (the sketch page, each deliverable slide) and keeps
   the whole measure. */
@media (max-width: 736px) {
  #sD .cs-figgrid { grid-template-columns: repeat(2, 1fr); }
  #sD .cs-figgrid-row { display: contents; }
  #sD .cs-figgrid-row > :only-child { grid-column: 1 / -1; }
}

/* Carousel controls — counter + arrows, right-aligned under the strip.
   Arrows are desktop-only (touch swipes); quiet hairline buttons, muted
   glyph that lights to --fg on hover, dimmed when parked at an end. */
#sD .cs-slide-ctrl {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: var(--space-xs);
  margin-top: var(--space-xs);
}
#sD .cs-slide-ctrl .cs-slide-count { margin-top: 0; }
#sD .cs-slide-btn {
  display: none;
  width: 36px; height: 36px;
  align-items: center; justify-content: center;
  background: none;
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--fg-muted);
  cursor: pointer;
  transition: color var(--t-fast), border-color var(--t-fast), opacity var(--t-fast);
}
@media (min-width: 1100px) {
  #sD .cs-slide-btn { display: inline-flex; }
}
#sD .cs-slide-btn:hover:not(:disabled) { color: var(--fg); border-color: var(--fg); }
#sD .cs-slide-btn:disabled { opacity: 0.4; cursor: default; }

/* Figure lightbox — click a case image to view it near-viewport-size.
   Scrim leans on --bg so both themes read as the site's own ground. */
#sD .cs-fig-media img, #sD .cs-slide img, #sD .cs-phone-media img { cursor: zoom-in; }
.cs-lightbox {
  position: fixed; inset: 0; z-index: 300;
  display: flex; align-items: center; justify-content: center;
  background: color-mix(in srgb, var(--bg) 94%, transparent);
  opacity: 0; visibility: hidden;
  transition: opacity var(--t-base) cubic-bezier(0.22, 1, 0.36, 1), visibility 0s linear var(--t-base);
  cursor: zoom-out;
}
.cs-lightbox.is-open {
  opacity: 1; visibility: visible;
  transition: opacity var(--t-base) cubic-bezier(0.22, 1, 0.36, 1);
}
.cs-lightbox-img {
  max-width: 92vw; max-height: 92vh;
  border-radius: 2px;
  outline: 1px solid var(--line);
  outline-offset: -1px;
  transform: scale(0.985);
  transition: transform var(--t-base) cubic-bezier(0.22, 1, 0.36, 1);
  cursor: zoom-in;   /* CS-18: the overview invites a second stage */
}
.cs-lightbox.is-open .cs-lightbox-img { transform: scale(1); }
/* CS-18 r2: transparent art enlarges FRAMELESS — the hairline drew an
   empty ring round see-through figures. case.js sets .is-art from the
   source figure (.cs-fig--art / .cs-phone-media); opaque photos keep
   the ring (a dark shot on the dark scrim needs the edge). */
.cs-lightbox.is-art .cs-lightbox-img { outline: none; border-radius: 0; }
/* CS-18 — ZOOM stage: the caps come off (the image renders at the largest
   asset's natural size) and the overlay becomes the scroller. flex +
   margin:auto keeps a smaller-than-viewport axis centred while leaving
   every overflowing edge reachable (grid/flex centring alone clips the
   top-left of overflow). Pan = drag / wheel / touch (case.js). */
.cs-lightbox.is-zoom { overflow: auto; }
.cs-lightbox.is-zoom .cs-lightbox-img {
  max-width: none; max-height: none;
  margin: auto;
  cursor: zoom-out;
}
@media (prefers-reduced-motion: reduce) {
  .cs-lightbox, .cs-lightbox-img { transition: none; transform: none; }
}
.cs-lightbox-close {
  /* fixed, not absolute — it must hold its corner while the zoom stage scrolls */
  position: fixed; top: var(--space-md); right: var(--space-md);
  width: 36px; height: 36px;
  display: flex; align-items: center; justify-content: center;
  background: none;
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--fg-muted);
  cursor: pointer;
  transition: color var(--t-fast), border-color var(--t-fast);
}
.cs-lightbox-close:hover { color: var(--fg); border-color: var(--fg); }

/* Edge click-zones — invisible strips over the carousel's sides. Click
   pages the deck; the cursor is a directional arrow (dark disc + white
   chevron, readable over any slide). Desktop only; hidden at the ends. */
#sD .cs-slide-zone {
  position: absolute; top: 0; bottom: 0;
  width: 16%;
  display: none;
  z-index: 2;
}
#sD .cs-slide-zone--prev { left: 0; cursor: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2232%22%20height%3D%2232%22%3E%3Ccircle%20cx%3D%2216%22%20cy%3D%2216%22%20r%3D%2213%22%20fill%3D%22rgba%281%2C5%2C14%2C0.55%29%22%2F%3E%3Cpath%20d%3D%22M18.5%2010%2012.5%2016l6%206%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E") 16 16, w-resize; }
#sD .cs-slide-zone--next { right: 0; cursor: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2232%22%20height%3D%2232%22%3E%3Ccircle%20cx%3D%2216%22%20cy%3D%2216%22%20r%3D%2213%22%20fill%3D%22rgba%281%2C5%2C14%2C0.55%29%22%2F%3E%3Cpath%20d%3D%22m13.5%2010%206%206-6%206%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E") 16 16, e-resize; }
@media (min-width: 1100px) {
  #sD .cs-slide-zone { display: block; }
  #sD .cs-slide-zone[hidden] { display: none; }
}

#sD .cs-slide-count {
  font-size: var(--fz-microcopy);
  color: var(--fg-faint);
  font-variant-numeric: tabular-nums;
  text-align: right;
  margin-top: var(--space-xs);
}
#sD .cs-fig-cap {
  font-size: var(--fz-microcopy);
  color: var(--fg-faint);
  text-align: center;
  margin-top: var(--csp-cap-gap);
}

/* ---------------- other projects + footer ----------------
   Replaced NEXT PROJECT (2026-08-30, Jacky): instead of one blind hero
   name, the case ends on a scrollable row of the OTHER Work tiles —
   thumbnail + the tile's own caption grammar (16px Medium name, 14px
   muted meta), so the reader picks by sight. Cards are sized to a
   FRACTIONAL fit — (track − 3 gaps) / 3.5 on desktop, 82% on phones —
   so a half card always peeks at the edge whatever the viewport: the
   peek IS the scroll affordance (same reasoning as the About carousel),
   which is why the scrollbar is hidden. Width rides the bleed measure
   like the footer — the strip is end-of-page chrome, not a paragraph. */
#sD .cs-others {
  width: 100%;
  /* r2 (Jacky): the label sat right on the pictures — give the lockup the
     case's own section beat instead of the item gap. */
  display: flex; flex-direction: column; gap: var(--csp-sec-gap);
}
@media (min-width: 1100px) {
  /* Break out of the stream to the bleed line, the same widening-off-own-
     box move the footer and .cs-fig--bleed use. */
  #sD .cs-others {
    --bleed-w: min(var(--cs-bleed), calc(100vw - 2 * var(--gutter)));
    width: var(--bleed-w);
    margin-left: calc((100% - var(--bleed-w)) / 2);
  }
}
/* r3 (Jacky): full ink, not the faint eyebrow grey — this label is the
   end-of-page invitation, not another section marker. r5: and Sentence
   case at sub-head size, dropping the caps/tracked eyebrow grammar
   entirely — 20px Medium, the same tier as the desktop greeting label. */
#sD .cs-others-title {
  margin: 0;
  font-size: var(--fz-sub-head);
  font-weight: 500;
  color: var(--fg);
  text-align: center;
}
#sD .cs-others-view { position: relative; }   /* anchors the .cs-slide-zone overlays */
#sD .cs-others-track {
  display: flex; gap: var(--space-md);
  overflow-x: auto;
  scroll-snap-type: x proximity;
  scrollbar-width: none;                    /* peek = the affordance */
  -webkit-overflow-scrolling: touch;
}
#sD .cs-others-track::-webkit-scrollbar { display: none; }
#sD .cs-other {
  flex: 0 0 auto;
  width: 82%;                               /* phones: one card + peek */
  scroll-snap-align: start;
  display: flex; flex-direction: column; gap: var(--space-2xs);
  text-decoration: none; color: var(--fg);
}
@media (min-width: 737px) {
  /* r2 (Jacky): bigger cards — 2 full + half peek instead of 3.5-up.
     Keep the divisor's fractional part near .5: it is what leaves a HALF
     card at the edge (a near-whole card reads as a layout bug, a sliver
     disappears). */
  #sD .cs-other { width: calc((100% - 3 * var(--space-md)) / 2.5); }
}
#sD .cs-other-media {
  display: block; width: 100%;
  aspect-ratio: 4 / 3;                      /* one ratio for the row — the
                                               grid's mixed --wk-ar would
                                               make a ragged strip */
  border-radius: var(--radius-sm);          /* small card, small corner
                                               (WK-9's own rule) */
  overflow: hidden;
  background: var(--neutral);
}
#sD .cs-other-media img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  transition: opacity var(--t-base);
}
#sD .cs-other-name { font-size: var(--fz-body); font-weight: 500; margin-top: var(--space-2xs); }
#sD .cs-other-meta { font-size: var(--fz-microcopy); color: var(--fg-muted); }
@media (hover: hover) { #sD .cs-other:hover .cs-other-media img { opacity: .8; } }
/* ---------------- footer ----------------
   CS-8 r2. The complaint the spike was answering was never really
   alignment — the old footer was two orphan lines (name + email) left
   floating under a full-bleed NEXT PROJECT block, with no structure and
   no boundary. So this gives it both, and otherwise stays quiet:

   · ONE hairline top rule, the case's existing editorial hairline (the
     same language as .cs-divider). No surface, no box — CS-7 retired the
     site's last boxed surface on purpose.
   · Width: the BREAKOUT line (--cs-bleed), the widest measure the case
     already owns — it is where .cs-fig--bleed figures sit. r2 first tried
     the text column and Jacky read it as too narrow, and he was right:
     the footer is the end of the PAGE, not another paragraph, so it has
     no reason to sit on the reading measure. Widening it is not a loss of
     alignment either — the case carries three alignment lines already
     (text 750, stream 950 for figures + NEXT PROJECT, bleed 1375) and
     this picks the outermost. Same min() as the bleed figures, so it
     never outgrows the page gutters.
   · TWO groups, not three: where you can find him, and how to reach him.
     'Last updated' is gone — see the renderer note.
   · Rhythm comes from the CS-9 --csp-* beats, so it scales with the rest
     of the case at >=2560 instead of holding raw space tokens.
   · Colour: the email is the ONLY celadon thing here. BR-11 made colour
     the affordance back when the footer held exactly one link; five
     celadon links would make the accent decorative again. Socials take
     the rail grammar instead — muted at rest, --fg on hover — and keep
     BR-2's draw-in underline, which is the hover feedback either way. */
#sD .cs-footer {
  width: 100%; max-width: var(--cs-col); margin-inline: auto;   /* mobile: the flow measure */
  border-top: 1px solid var(--line);
  /* NO margin-top: .cs-body is a flex column already spaced on
     --csp-section-gap, so adding the beat again doubled it to 360px and
     left a hole under NEXT PROJECT. The rule sits on the section beat; the
     padding is the band's own inner air. */
  padding-top: var(--csp-sec-gap);
  display: flex; flex-direction: column; gap: var(--csp-sec-gap);
  /* Clears the fixed bottom-left theme toggle. */
  padding-bottom: calc(var(--space-3xl) + var(--space-md));
}
#sD .cs-footer-group { display: flex; flex-direction: column; gap: var(--csp-item-gap); }
#sD .cs-footer-links {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--csp-item-gap);
}
#sD .cs-footer a { align-self: start; font-size: var(--fz-body); text-decoration: none; }
/* MOBILE AUDIT (2026-08-26): the footer links were 19.5px-tall targets 8px
   apart — under WCAG 2.5.8's 24px minimum, and a 24px circle centred on one
   overlapped its neighbour, so the spacing exception did not save it either.
   Two moves, phones only. (1) The list opens to --space-md, which alone
   clears the spacing exception (centres 43.5px apart). (2) A transparent
   ::after gives each link a 44px touch box — done with a pseudo, NOT
   padding, because BR-2's draw-in underline paints at the bottom of the
   PADDING box and any padding-bottom would push the underline off the
   text. The boxes now abut instead of overlapping, so a tap can no longer
   land on the wrong link. */
@media (max-width: 736px) {
  #sD .cs-footer-links { gap: var(--space-md); }
  #sD .cs-footer-links a,
  #sD .cs-footer-mail { position: relative; }
  #sD .cs-footer-links a::after,
  #sD .cs-footer-mail::after {
    content: "";
    position: absolute; left: 0; right: 0; top: 50%;
    height: 44px; transform: translateY(-50%);
  }
}
/* The footer label follows the section eyebrows CS-9 r8 sent to faint —
   it is a quiet signpost, not a heading. */
#sD .cs-footer .cs-eyebrow { color: var(--fg-faint); }
#sD .cs-footer-tag { font-size: var(--fz-body); font-weight: 500; color: var(--fg); }
/* Socials are navigation, not the action — the nav rail's rest/hover pair.
   Specificity has to clear BR-11's '#sD .cs-footer a', which lives in the
   inline <style> and therefore wins on source order at equal weight. */
#sD .cs-footer .cs-footer-links a { color: var(--fg-muted); }
@media (hover: hover) {
  #sD .cs-footer .cs-footer-links a:hover { color: var(--fg); }
}
@media (min-width: 1100px) {
  #sD .cs-footer {
    /* Break out to the widest line the case owns — see the note above.
       The stream is only 950 and .cs-body centres it, so the band is
       widened symmetrically off its own box rather than repositioned. */
    max-width: none;
    width: min(var(--cs-bleed), calc(100vw - 2 * var(--gutter)));
    margin-left: calc((100% - min(var(--cs-bleed), calc(100vw - 2 * var(--gutter)))) / 2);
    margin-right: 0;
    display: grid; grid-template-columns: 1fr auto; align-items: start;
    column-gap: var(--space-2xl);
  }
  #sD .cs-footer-cta { justify-self: end; text-align: right; }
  #sD .cs-footer-cta a { align-self: end; }
}

/* ---------------- reveal-on-scroll (case.js adds .cs-anim / .is-in) ----------------
   Hidden state scoped to #sD.cs-anim so content stays visible if JS never runs.
   The reveal uses the SAME cs-rise animation as the hero — NOT a transition —
   because a block revealed in the same frame it becomes visible would snap with
   a class-toggled transition, but an animation always plays (same fix as About).
   Figures just fade — they're big boxes; travel makes them feel heavy. */
#sD.cs-anim .cs-reveal { opacity: 0; }
#sD.cs-anim .cs-reveal.is-in {
  animation: cs-rise var(--cs-rise-dur) cubic-bezier(0.22, 1, 0.36, 1) both;
}
#sD.cs-anim .cs-fig.cs-reveal.is-in {
  animation-name: cs-fade;
}

/* ---------------- password gate (PW-1, teaser model) ----------------
   An INLINE section mounted after the public teaser (background + stats)
   while the encrypted remainder is locked (case-gate.js) — the visitor
   reads the opening freely and meets the key where the protected story
   starts. On unlock the full case re-renders in place and this section
   is simply gone. PLACEHOLDER design — structure + copy carried over
   from the pw-gate-demo spike, restated in the case vocabulary; Jacky
   reskins it in the PW-1 design pass. The card rides the text column
   (--cs-col) like every other prose block; the top hairline marks the
   cut, and generous bottom padding lets the page end quietly. */
#sD .cs-gate-card {
  width: 100%; max-width: var(--cs-col); margin-inline: auto;
  padding: var(--space-xl) 0 var(--space-2xl);
  border-top: 1px solid var(--line);
}
/* The lock sits on the same left edge as the type and hands off to the
   title on the head rhythm the case already uses for eyebrow -> title. */
#sD .cs-gate-lock {
  display: block;
  width: 40px; height: 40px;          /* Jacky sized it up in Figma, 24 -> 40 */
  color: var(--fg-faint);
}
#sD .cs-modal .cs-gate-title { margin-top: var(--csp-head-gap); }
#sD .cs-gate-title {
  margin-top: var(--space-sm);
  /* CS-9 r4 retired Bold across the case's display type; this title was the
     last 700 left in #sD — a leftover from the pw-gate spike. */
  font-size: var(--csp-fz-gate); font-weight: 400; line-height: 1.15;
  color: var(--fg);
}
#sD .cs-gate-lede {
  margin-top: var(--space-xs);        /* Figma 'text' gap — was --space-sm */
  font-size: var(--fz-body); line-height: 1.5; color: var(--fg-muted);
}
#sD .cs-gate-row {
  display: flex; margin-top: var(--space-md);
  border-bottom: 1px solid var(--fg-muted);
  transition: border-color var(--t-base);
}
#sD .cs-gate-row:focus-within { border-color: var(--fg); }
#sD .cs-gate input:focus-visible { outline: none; }   /* the row border IS the focus signal */
#sD .cs-gate input {
  flex: 1; min-width: 0;
  font: inherit; font-size: var(--fz-body); color: var(--fg);
  background: none; border: 0; outline: none;
  padding: var(--space-xs) 0; letter-spacing: 0.2em;   /* CS-9 L-sweep: was 10px */
}
#sD .cs-gate input::placeholder { color: var(--fg-faint); letter-spacing: normal; }
/* The ENTER affordance. Scoped to [type=submit]: as a bare `button` this
   also caught the close X — at (1,1,1) it outranked .cs-gate-x and pushed
   its 18px padding-left onto it, shoving the glyph off-centre inside the
   button box. */
#sD .cs-gate button[type="submit"] {
  font: inherit; font-size: var(--fz-microcopy); font-weight: 500;
  letter-spacing: var(--nav-tracking); text-transform: uppercase;
  color: var(--fg-muted); background: none; border: 0; cursor: pointer;
  padding: var(--space-xs) 0 var(--space-xs) var(--space-sm);   /* CS-9 L-sweep: was 10px */
  transition: color var(--t-base);
}
@media (hover: hover) { #sD .cs-gate button[type="submit"]:hover { color: var(--fg); } }
#sD .cs-gate button[type="submit"]:focus-visible { color: var(--fg); }
/* Keeps its line even when empty so an error never shifts the layout under
   the reader's cursor — but the reserved line is the message's OWN height,
   not extra air. PW-1 polish: 12->8 above, 48->24 below; together those two
   used to open an 82px hole between the input and the request line. */
#sD .cs-gate-msg {
  min-height: 22px; margin-top: var(--space-2xs);
  font-size: var(--fz-microcopy); color: var(--fg-faint);
}
#sD .cs-gate-foot {
  margin-top: var(--space-md);
  font-size: var(--fz-microcopy); color: var(--fg-faint); line-height: 1.6;
}
#sD .cs-gate-req,
#sD .cs-gate-foot a {
  color: var(--fg-muted); text-decoration: none;
  border-bottom: 1px solid var(--line);
  transition: color var(--t-base);
}
@media (hover: hover) { #sD .cs-gate-foot a:hover { color: var(--fg); } }
#sD .cs-gate-card.err .cs-gate-row {
  border-color: var(--fg);
  animation: cs-gate-nudge 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes cs-gate-nudge { 25% { transform: translateX(-6px); } 65% { transform: translateX(4px); } }

/* ---------------- PW-2 r2 — the gate as a dismissible modal ----------
   (Jacky 2026-08-24: "scroll down and a modal pops; you can exit, but you
   can never scroll past".) The form is a centred dialog that opens when the
   reader reaches the cut. The cut itself is an INVISIBLE sentinel —
   .cs-gatecut used to carry a hairline and a "Protected from here" label,
   but Jacky cut those 2026-08-25: the modal arriving already says it, and
   saying it in the page too was noise. The node stays because it is what
   the observer watches, and while locked it carries the scroll runway.

   It IS modal now, unlike the r1 bottom sheet: the scrim takes pointer
   events, the scene's scroller is locked while it is open, Tab is trapped
   and Esc closes. Closing is real — the reader gets the teaser back — but
   there is nothing below the cut to reach, so 'exit' buys a re-read, not
   a way through. case-gate.js re-arms only after the marker leaves the
   viewport, otherwise closing would re-fire the observer on the spot.

   No border: a bordered panel would be the site's only boxed surface
   again (CS-7 retired the last one on purpose). The separation is --bg
   plus a shadow, and a shadow is absence of light, so it is ink-black in
   both themes rather than --fg-tinted, which in dark mode would glow. */
/* Surface (r4). r3 inverted the palette — paper card on the dark site —
   and Jacky's read was that it shouts: "too prominent, probably not the
   right direction". So the card goes back into its own theme and takes
   --white, the site's elevated-surface token (#ffffff on paper, #0d1526 on
   midnight). That alone was the r2 problem — too close to --bg to read as
   a layer — but it is no longer doing the job alone: the scrim now BLURS
   what is behind it, and blur separates far more convincingly than
   contrast does. The card can stay quiet because the ground gives way. */
#sD .cs-modal {
  position: fixed; z-index: 60;
  top: 50%; left: 50%;
  width: min(540px, calc(100vw - 2 * var(--gutter)));
  /* OPTICAL LIFT (Jacky 2026-08-25: "it reads as having sunk a little").
     Geometric centring puts the card's BOX in the middle, but the card is
     top-light — the header band is 68px carrying only a small faint X, so
     the content that actually reads (lock down to the input hairline) sits
     low inside it. Measured at 1440: content centre 198 against a box
     centre of 185, i.e. 13px low. --space-xs (12) is the token nearest that,
     so the card rides 12 up and the type lands centred to the eye.
     Same move CLAUDE.md records for the hero-vs-nav edge: big shapes
     top-aligned by geometry read as sitting low; raise them.

     Enters on the site's own reveal — a rise on the house easing, the same
     move .cs-reveal / cs-rise make everywhere else (scale() was a POP, a
     different idiom, and Jacky read it as 'suddenly there').
     r9: 18px over --t-slow still felt rushed to him — "not peaceful". The
     travel doubles to --space-lg (36) and the clock goes to --t-reveal
     (1.2s), the site's slowest. On this easing that reads as a float up
     rather than a move: most of the distance is covered early, then it
     takes its time settling. Still short of the 1.6s scene entry, which
     is a whole page arriving rather than one card. */
  --cs-modal-lift: var(--space-xs);
  --cs-modal-rise: var(--space-lg);
  transform: translate(-50%, calc(-50% - var(--cs-modal-lift) + var(--cs-modal-rise)));
  opacity: 0; pointer-events: none;
  background: var(--white);
  /* Same corner as the Work gallery tiles (Jacky 2026-08-24) — one radius
     for the site's surfaces, not a dialog dialect. */
  border-radius: var(--radius-md);
  overflow: hidden;                   /* the bands clip to the radius */
  box-shadow: 0 24px 80px rgba(1, 5, 14, 0.18);
  transition: opacity var(--t-reveal) cubic-bezier(0.22, 1, 0.36, 1),
              transform var(--t-reveal) cubic-bezier(0.22, 1, 0.36, 1);
}
#sD .cs-modal.is-up {
  opacity: 1; pointer-events: auto;
  transform: translate(-50%, calc(-50% - var(--cs-modal-lift)));
}
/* The card sits on --white, one step off --bg, so the derived greys the
   page already computed still read correctly on it — no re-declaring needed
   now that the palette is not inverted. A shadow is absence of light, so it
   is ink-black in both themes; --fg-tinted would glow in dark. */
body:not(.light-mode) #sD .cs-modal { box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); }
/* Header band — owns the close button and its own padding. Its height is
   what sets the content's distance from the top of the card, so that
   distance stays a sum of tokens rather than one hand-summed number. */
#sD .cs-gate-head {
  display: flex; justify-content: flex-end;
  /* Re-tuned for the 44px button (Jacky 2026-08-25). His Figma drew a 30px
     box at 18/24, which put the glyph's centre 33 from the top and 39 from
     the right. Growing the box to 44 without touching these would have
     pushed the band to 80 and moved the glyph; --space-xs block /
     --space-sm inline lands it at 34 / 40 — within a pixel on both axes —
     and the band only goes 66 -> 68. Both are still tokens. */
  padding: var(--space-xs) var(--space-sm);
}
#sD .cs-modal .cs-gate-card {
  border-top: 0;                      /* the modal is its own container */
  max-width: none;
  padding: var(--space-xs) var(--space-xl) var(--space-md);
}
/* Close — a real 44x44 button (BR-9's minimum tap target), not a 30px box
   with an invisible hit area bolted on. The 14px glyph Jacky drew is
   centred by the flex box, so the button needs no padding of its own; the
   header band's padding above is what keeps the glyph where he put it. */
#sD .cs-gate-x {
  display: flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; padding: 0;
  background: none; border: 0; cursor: pointer;
  color: var(--fg-faint);
  transition: color var(--t-base);
}
@media (hover: hover) { #sD .cs-gate-x:hover { color: var(--fg); } }
#sD .cs-gate-x:focus-visible { color: var(--fg); }
/* PW-3 r2 (Jacky 2026-08-27): the modal's spacing was drawn for the
   540px desktop card and rode down to phones untouched — 48px side
   padding and a 68px near-empty header band read oversized on a 342px
   card ("太闊,唔適合手機版"). Phone tier (≤736, the site's mobile type
   step): the X tucks toward the corner, the sides drop to --space-md,
   the band hands straight into the lock (card top 0), and the input
   row pulls in a step. Tablet's 540px card keeps the desktop rhythm. */
@media (max-width: 736px) {
  #sD .cs-gate-head { padding: var(--space-2xs) var(--space-2xs); }
  #sD .cs-modal .cs-gate-card { padding: 0 var(--space-md) var(--space-md); }
  #sD .cs-modal .cs-gate-row { margin-top: var(--space-sm); }
}
/* A locked case does not render the site footer: it landed BETWEEN the cut
   and the gate, and its 'let's craft the experience together' + email
   repeated the gate's own request-access route. */
#sD.cs-locked .cs-footer,
#sD.cs-locked .cs-others { display: none; }   /* end-of-page chrome hides with the footer on a locked teaser */
/* ...but the footer was silently providing the SCROLL RUNWAY the gate
   depends on, and removing it made a locked case UNENTERABLE. case-gate.js
   opens the modal when the cut marker clears the viewport's 85% line
   (rootMargin -15%); the footer used to be the scrollable height below the
   marker that let it get there. Without it the marker is the last element,
   tops out below the line, and the observer never fires. A viewport-relative
   pad restores that at any size. */
#sD.cs-locked .cs-gatecut { padding-bottom: 28vh; }

/* While the modal holds the page, the scene's scroller stands still. On
   desktop that is .cs-main; below 1100 the document itself scrolls. */
body.cs-gate-up #sD .cs-main { overflow: hidden; }
@media (max-width: 1099px) {
  body.cs-gate-up { overflow: hidden; }
  /* PW-3: same reflow guard for the document scroller — phones use
     overlay scrollbars (no-op), but a narrow desktop window with
     classic scrollbars would shift when the lock hides the bar. */
  html { scrollbar-gutter: stable; }
}
#sD .cs-dim {
  position: fixed; inset: 0; z-index: 55;
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  opacity: 0; pointer-events: none;
  /* Same clock as the card. The blur rides this fade — a backdrop-filter at
     opacity 0 shows nothing, so the page cross-fades sharp -> soft instead
     of snapping. */
  transition: opacity var(--t-reveal) cubic-bezier(0.22, 1, 0.36, 1);
}
/* r2: the scrim takes pointer events — it is what makes this modal, and
   clicking it is one of the ways out.
   r4: it also BLURS the page behind it (Jacky 2026-08-24). This is what
   lets the card stay on a quiet --white ground instead of shouting: the
   teaser going soft is a stronger 'that layer is behind now' signal than
   any amount of contrast on the card. Declared only on .is-up so the
   compositor is not holding a blur layer for a modal that is closed. */
#sD .cs-dim.is-up {
  opacity: 1; pointer-events: auto; cursor: pointer;
  backdrop-filter: blur(10px) saturate(0.9);
  -webkit-backdrop-filter: blur(10px) saturate(0.9);
}
/* CS-7 r6 bug fix (Jacky 2026-08-22): the .cs-dim wash lives inside #sD's
   stacking context (.scene z-index 2), so it can NEVER cover the body-level
   chrome — while the gate sheet was up, the wordmark/theme sat full-bright
   over the washed page and read as "active". case-gate.js now drives
   body.cs-gate-up with the sheet; the chrome washes to match the dim
   (hover/focus lifts it back — the gate is non-modal, everything stays
   usable). Desktop only; mobile chrome handling is still the CS-7 open. */
@media (min-width: 1100px) {
  body.scene-d.cs-gate-up:not(.menu-open) .site-wordmark,
  body.scene-d.cs-gate-up:not(.menu-open) .theme-btn,
  body.scene-d.cs-gate-up:not(.menu-open) #sD .cs-projname,
  body.scene-d.cs-gate-up:not(.menu-open) #sD .cs-close { opacity: .35; }
  body.scene-d.cs-gate-up:not(.menu-open) .site-wordmark:hover,
  body.scene-d.cs-gate-up:not(.menu-open) .site-wordmark:focus-visible,
  body.scene-d.cs-gate-up:not(.menu-open) .theme-btn:hover,
  body.scene-d.cs-gate-up:not(.menu-open) .theme-btn:focus-visible,
  body.scene-d.cs-gate-up:not(.menu-open) #sD .cs-close:hover,
  body.scene-d.cs-gate-up:not(.menu-open) #sD .cs-close:focus-visible { opacity: 1; }
}

/* Reduced motion — everything visible, nothing travels. */
@media (prefers-reduced-motion: reduce) {
  #sD.scene.active,
  #sD.scene.active .cs-hero-inner > *,
  #sD.scene.active .cs-back,
  #sD.scene.active .cs-hero-img,
  #sD.cs-anim .cs-reveal.is-in,
  #sD .cs-gate-card.err .cs-gate-row,
  #sD.cs-exiting * { animation: none !important; }
  #sD.cs-anim .cs-reveal { opacity: 1; }
  /* PW-2: the sheet/scrim appear without travelling. */
  #sD .cs-modal, #sD .cs-dim { transition: none; }
  /* CS-6 r2: the top bar appears/disappears without travelling. */
  #sD .cs-topbar { transition: none; }
}

/* ============================================================
   BR-2 — the link hover motif (Jacky picked a, 2026-08-12): a hairline
   draws in from the left on hover, on the house easing. Applied to the
   site's true text links (case footer + gate foot today; any future
   prose link joins these selectors). A dedicated link COLOUR is coming
   separately (BR-11) — colour + this motif together become the full
   link signature.
   ============================================================ */
#sD .cs-footer a,
#sD .cs-gate-req,
#sD .cs-gate-foot a {
  text-decoration: none;
  border-bottom: 0;
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 0% 1px;
  transition: background-size var(--t-slow) cubic-bezier(0.22, 1, 0.36, 1),
              color var(--t-base);
  padding-bottom: 2px;   /* optical: the draw-in underline's clearance from
                            descenders — deliberately sub-token (CS-9 L4) */
}
@media (hover: hover) {
  #sD .cs-footer a:hover,
  #sD .cs-gate-req:hover,
  #sD .cs-gate-foot a:hover { background-size: 100% 1px; }
}
