/* ==========================================================================
   nikorantala.com — main stylesheet (B2: graduated from the R3.3 direction
   mock). Design system: Portfolio/DESIGN.md ("Stage and Daylight").
   Two worlds: very dark navy stage for identity moments, blue-tinted
   editorial daylight for reading; only the two blues cross between them.
   ========================================================================== */

/* ============ Fonts — RE-LOCKED 22 Jul 2026 (r7): Neco + Instrument Sans.
   Supersedes the 12 Jul PP Fragment Glare + PP Neue Montreal pair (both
   commercial trials) and the 19 Jul "Neco + Satoshi" park, which never
   shipped. Both families are now licence-clean for a live site.

   Neco (ITF Free Font Licence): CONVERSION AND SUBSETTING ARE PROHIBITED, so
   the official woff2 ship AS-IS and the files must stay out of any public
   repo. The VARIABLE pair is used, not the statics: the statics are only
   400/500/700/900 and the site needs a true 600 (.af-synthesis strong,
   .hero-statement strong) and a true 800 (every display head).
   GOTCHA: Neco's fvar DEFAULT is wght=900, so the descriptor MUST declare the
   full `400 900` range — a bare `font-weight: 800` face would render 900.

   Instrument Sans (OFL, verified to carry NO Reserved Font Name, so
   instancing/subsetting/renaming/glyph composition are all permitted):
   shipped as two files with `wdth` PINNED TO 92 IN THE FILE and `wght` left
   live 400-700, subset to the site charset. Width is pinned in the binary
   rather than via CSS `font-stretch: 92%` because main.js paints the vinyl
   label through Canvas2D `ctx.font`, which cannot express a percentage width.
   A U+2153 (⅓) glyph was composed into both files from the font's own
   1/slash/3 — Instrument Sans lacks the glyph and has no `frac` feature, and
   the vinyl label's "33⅓ RPM" is the one string that needs it.
   Build recipe + unmodified OFL sources: Portfolio/assets/fonts-r7-instrument-sans/.
   Weights are UNCHANGED by the swap (Niko's 21 Jul ruling): body stays 400,
   sans 500 stays; the only weight edit anywhere is `.sep` (below). ============ */
@font-face { font-family: 'Neco'; font-weight: 400 900; font-style: normal; font-display: swap;
  src: url('../fonts/Neco-Variable.woff2') format('woff2'); }
/* the serif italic is the 40KB STATIC, not the 84KB variable (22 Jul, Niko's
   call): every serif italic on the site is 400 (.encore-label, .cs-next-label),
   so the variable italic was paying 44KB per page load for an axis nothing
   asks for. Declared at a single 400 on purpose — a `400 900` range on a
   static file would render a hypothetical future 700 italic silently at 400,
   where a single weight lets the browser synthesise something closer. */
@font-face { font-family: 'Neco'; font-weight: 400; font-style: italic; font-display: swap;
  src: url('../fonts/Neco-Italic.woff2') format('woff2'); }
@font-face { font-family: 'Instrument Sans'; font-weight: 400 700; font-style: normal; font-display: swap;
  src: url('../fonts/InstrumentSans-w92.woff2') format('woff2'); }
@font-face { font-family: 'Instrument Sans'; font-weight: 400 700; font-style: italic; font-display: swap;
  src: url('../fonts/InstrumentSans-w92-Italic.woff2') format('woff2'); }

/* ============ Tokens ============ */
:root {
  --stage: #0c1322;          /* very dark navy: the stage */
  --stage-2: #101a2e;        /* stage gradient partner */
  --navy: #1c2942;           /* CV navy: mid surface */
  --blue: #2e8fdd;           /* CV accent */
  --blue-deep: #2e6fb5;      /* CV accent deep */
  --day: #f6f9fd;            /* daylight surface, blue-tinted off-white */
  --day-2: #eaf1f9;          /* daylight secondary surface */
  --ink: #17202e;            /* text on daylight (navy-cast ink) */
  --ink-soft: #46536a;       /* secondary text on daylight, 7.2:1 on --day */
  --moon: #e8eef7;           /* text on stage */
  --moon-soft: #a9bad3;      /* secondary text on stage, 7:1 on --stage */
  --hairline-dark: rgba(232, 238, 247, 0.14);
  --hairline-light: rgba(23, 32, 46, 0.14);
  --blue-bright: #5fb1ef;    /* lifted blue for small text on --navy (6.2:1)
                                and the fader hover line; never on light */
  --azure: #1b4f8f;          /* daylight-only deep azure, 7.3:1 on --day:
                                reserved for the outcome lines so the payoff
                                stops sharing blue-deep with kickers/links */
  --navy-soft: #1e395b;      /* the meta labels' navy: --navy read too dark
                                there (Niko, 18 Jul round 3) */
  --label-ink: #99aac3;      /* vinyl label ring copy: moon-soft pulled 12%
                                toward the label surface + 0.95 opacity so the
                                print sits IN the vinyl, not painted over */
  /* spacing tokens, promoted from hand-copied literals (refinement round:
     the audit found the DESIGN.md tokens existed only as prose) */
  /* page margin cap 3rem -> 4rem (r5): everything sat too close to the left
     edge; 64px margins at desktop give the pages breathing room. The hero
     shares --page-x horizontally since r5 too (it ran its own 56px). */
  --page-x: clamp(1.25rem, 4.17vw, 5rem);   /* 64 → 80px at 1920 (r6); 4.17vw lands 80.06 there, ~64px on the 1536 laptop */
  --section-y: clamp(4rem, 10vw, 8.5rem);
  --hero-pad: clamp(1.5rem, 4vw, 3.5rem);
  --gap-spread: clamp(2rem, 5vw, 5rem);
  --band-gap: clamp(1.5rem, 4vw, 4rem);   /* encore + next-case bands */
  --head-gap: clamp(2rem, 5vw, 3.5rem);   /* section head to its content */
  --space-doc: 1.4rem;                    /* the artifact-scale separation */
  /* THE READING COLUMN. Was 73ch until 22 Jul 2026; ch is retired sitewide.
     Two reasons. (1) ch is the width of the digit "0", which is not
     proportional to average lowercase width across families, so it does NOT
     hold characters per line through a font swap: this same token held 77
     characters in PP Neue Montreal and 86 in Instrument Sans. (2) ch resolves
     per element, so ONE token produced THREE columns here — 808px on the 18px
     prose, 682px on the 15.2px figure caption. 50.5rem IS 808px, the width the
     prose actually rendered at before the swap, and it is now one number
     meaning one column, immune to both font and body-size changes. */
  --measure: 50.5rem;
  /* THE SANS SIZE LADDER (r7 phase 4, 23 Jul 2026). Six steps, one ratio.
     Before this the sans used SIXTEEN distinct fixed sizes between 11.8 and
     17.7px, several within 0.3px of each other: drift, not design — a decade
     of hand-typed values, then multiplied wholesale by 19/18 when the body
     grew, which preserved every accidental difference to four decimals.
     The ladder is geometric at 1.09 and anchored on the body (19px), so every
     step is a real, visible interval and the whole system retunes from six
     numbers. Mapping was nearest-neighbour in log space: no role moved more
     than 4.2%, and every within-component ordering (head > body > kicker) was
     re-measured afterwards, not assumed.
     Only the SANS is on the ladder. The serif sizes were never drifted (four
     literals, all deliberate) and the display roles are fluid clamps whose
     endpoints are pinned to ladder values below. */
  --t-micro: 0.77rem;    /* 12.3px — uppercase table headers */
  --t-fine:  0.84rem;    /* 13.4px — chips, badges, tags, table detail */
  --t-label: 0.92rem;    /* 14.7px — labels, notes, secondary copy */
  --t-ui:    1rem;       /* 16px   — captions, artifact body, cue */
  --t-lead:  1.09rem;    /* 17.4px — card heads, nav, kickers */
  --t-body:  1.1875rem;  /* 19px   — page prose; the anchor, see `body` */
  --serif: 'Neco', Georgia, serif;
  --sans: 'Instrument Sans', 'Segoe UI', sans-serif;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);      /* expo out */
  --ease-in-out: cubic-bezier(0.87, 0, 0.13, 1);  /* expo in-out */
  --ease-sine: cubic-bezier(0.37, 0, 0.63, 1);    /* sine in-out: ambient sways */
  --z-rail: 40; --z-header: 50; --z-veil: 90;   /* veil covers even the header */
}

/* ============ Scrollbar: the channel fader (18 Jul) ============ */
/* The page scrollbar reads as a mixer channel strip: dark chassis rail with a
   recessed fader slot, and the thumb as a fader cap with the position line
   across its middle. Scoped to html ONLY: inner scrollers (.af-scroll) keep
   native slim bars — the fader is page-level hardware, pairing with the
   waveform rail on the left edge. Chromium/WebKit get the full cap;
   Firefox gets a plain dark rail via scrollbar-color, which MUST stay gated
   behind the -moz @supports: declaring scrollbar-color in Chromium disables
   all ::-webkit-scrollbar styling. Thumb length stays proportional (native
   scrolling semantics; a fixed-size cap would need a JS overlay scrollbar). */
html {
  /* three grip ridges at each end of the fader cap (dark cut + lit lower lip),
     anchored in px from the cap's ends so they hold at any thumb length;
     the middle stays smooth for the position line */
  --fader-grooves:
    linear-gradient(to bottom,
      transparent 4px,
      rgba(0, 0, 0, 0.5) 4px, rgba(0, 0, 0, 0.5) 5px,
      rgba(232, 238, 247, 0.12) 5px, rgba(232, 238, 247, 0.12) 6px,
      transparent 6px, transparent 8px,
      rgba(0, 0, 0, 0.5) 8px, rgba(0, 0, 0, 0.5) 9px,
      rgba(232, 238, 247, 0.12) 9px, rgba(232, 238, 247, 0.12) 10px,
      transparent 10px, transparent 12px,
      rgba(0, 0, 0, 0.5) 12px, rgba(0, 0, 0, 0.5) 13px,
      rgba(232, 238, 247, 0.12) 13px, rgba(232, 238, 247, 0.12) 14px,
      transparent 14px, transparent calc(100% - 14px),
      rgba(0, 0, 0, 0.5) calc(100% - 14px), rgba(0, 0, 0, 0.5) calc(100% - 13px),
      rgba(232, 238, 247, 0.12) calc(100% - 13px), rgba(232, 238, 247, 0.12) calc(100% - 12px),
      transparent calc(100% - 12px), transparent calc(100% - 10px),
      rgba(0, 0, 0, 0.5) calc(100% - 10px), rgba(0, 0, 0, 0.5) calc(100% - 9px),
      rgba(232, 238, 247, 0.12) calc(100% - 9px), rgba(232, 238, 247, 0.12) calc(100% - 8px),
      transparent calc(100% - 8px), transparent calc(100% - 6px),
      rgba(0, 0, 0, 0.5) calc(100% - 6px), rgba(0, 0, 0, 0.5) calc(100% - 5px),
      rgba(232, 238, 247, 0.12) calc(100% - 5px), rgba(232, 238, 247, 0.12) calc(100% - 4px),
      transparent calc(100% - 4px));
}
html::-webkit-scrollbar { width: 14px; }
html::-webkit-scrollbar-button { display: none; height: 0; }
html::-webkit-scrollbar-corner { background: var(--stage); }
html::-webkit-scrollbar-track {
  background:
    linear-gradient(to right,
      transparent calc(50% - 2px), rgba(0, 0, 0, 0.6) calc(50% - 2px),
      rgba(0, 0, 0, 0.6) calc(50% + 2px), transparent calc(50% + 2px)),
    var(--stage);
}
html::-webkit-scrollbar-thumb {
  min-height: 48px;
  border: 1px solid rgba(232, 238, 247, 0.22);
  border-radius: 3px;
  background:
    /* the fader position line */
    linear-gradient(to bottom,
      transparent calc(50% - 1px), var(--blue) calc(50% - 1px),
      var(--blue) calc(50% + 1px), transparent calc(50% + 1px)),
    var(--fader-grooves),
    /* top-lit cap shading */
    linear-gradient(to bottom, #2a3852 0%, var(--navy) 45%, #141d31 100%);
}
html::-webkit-scrollbar-thumb:hover {
  border-color: rgba(232, 238, 247, 0.34);
  background:
    linear-gradient(to bottom,
      transparent calc(50% - 1px), var(--blue-bright) calc(50% - 1px),
      var(--blue-bright) calc(50% + 1px), transparent calc(50% + 1px)),
    var(--fader-grooves),
    linear-gradient(to bottom, #334360 0%, #24314a 45%, #182238 100%);
}
html::-webkit-scrollbar-thumb:active {
  border-color: rgba(232, 238, 247, 0.45);
  background:
    linear-gradient(to bottom,
      transparent calc(50% - 1px), var(--moon) calc(50% - 1px),
      var(--moon) calc(50% + 1px), transparent calc(50% + 1px)),
    var(--fader-grooves),
    linear-gradient(to bottom, #2a3852 0%, var(--navy) 45%, #141d31 100%);
}
@supports (-moz-appearance: none) {
  html { scrollbar-color: var(--navy) var(--stage); }
}

/* ============ Base ============ */
* { box-sizing: border-box; margin: 0; padding: 0; }
/* smooth anchor scrolling is the NO-LENIS fallback only. With Lenis active,
   CSS smooth behavior must never apply: it animates ScrollTrigger's internal
   scroll writes during refresh, so ST reads back stale positions and bakes a
   -scrollY offset into the pin's start — a refresh landing mid-pin (fonts.ready
   after a quick scroll, resize, zoom) then parked the whole hero choreography
   at end-state. Lenis 1.3 does not add the .lenis-smooth class this override
   used to rely on.
   Scoped three ways (build-gate review): :not(.js) keeps smooth off whenever
   the motion layer booted, even if lenis.min.js alone failed to load (the pin
   would otherwise run under CSS smoothing — the exact recorded corruption);
   the media wrap keeps it off under reduced motion, where the plain `html`
   override in the reduced block below loses on specificity to :not() rules. */
@media (prefers-reduced-motion: no-preference) {
  html:not(.js):not(.lenis) { scroll-behavior: smooth; }
}
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
body {
  font-family: var(--sans);
  /* 18px -> 19px (1.125rem -> 1.1875rem), and the weight back to 400, 22 Jul:
     Niko compared 18px/425 against 19px/400 side by side and took the larger,
     lighter one. The whole sans ladder was scaled by the same 19/18 so every
     role keeps its relationship to the body — 59 declarations, the serif is
     untouched. Measured effect on reading: 86 characters per line -> 81, in
     columns that stayed exactly where they were (see --measure below).
     NOT a lever for line length: while a measure is expressed in `ch` or `em`
     it scales WITH the type and the character count cannot move. */
  font-size: var(--t-body);
  font-weight: 400;
  line-height: 1.6;
  color: var(--moon);
  background: var(--stage);
  /* Neco's DEFAULT figures are not lining (measured: the default, `lnum` and
     `onum` digit strings are three different widths), so "5S" in a display head
     came out as oldstyle. Instrument Sans is lining by default — `lining-nums`
     and `normal` measure byte-identical there — so declaring it once on the
     body is a no-op for the sans and covers every serif role, including any
     display copy that gains a digit later. (r7 font swap.) */
  font-variant-numeric: lining-nums;
}
/* ---- The separator middot (r7 font swap). One class, three jobs:
   1. AIR, and this is the one that made the class universal. Instrument Sans
      gives U+00B7 NO side bearings: measured at 100px its ink spans [-1, +11]
      inside a 9.9 advance, where PP Neue Montreal centred the same 10-unit dot
      in a 23.5 advance. Every separator therefore lost about half its air and
      the dot glued itself to the preceding word ("Facilitation· every"). The
      0.14em per side restores PP's optical spacing (26.3 + 7 = 33.3 units of
      left air there; 18.3 + 14 = 32.3 here), scaled to this narrower face.
      Specimen: assets/verify-2026-07-22-r7-fonts/05-separator-specimen.png.
   2. FAMILY. Neco maps U+00B7 to a ZERO-ADVANCE glyph — verified: "A·A" is
      exactly as wide as "AA" — so a middot in a serif run paints on top of the
      next word ("do ·skip ·later" in .af-title). Separators borrow the sans dot;
      inside sans copy the declaration changes nothing.
   3. WEIGHT. At body weight the middot reads too thin (Niko, 22 Jul), so it
      takes +50 over the text around it: 450 against the 400 body (it was 475
      during the brief 425 experiment — the pair moves together, so if the body
      weight is ever retuned again, retune this with it). Separators already sit
      in heavier lines keep their line's weight (the reset below) — a fixed
      value would make them LIGHTER than the text around them, which is the
      opposite of the fix.
   NOT wrapped anywhere: .hero-fields (its separators sit between &ensp; pairs
   and already carry a full em of air), the vinyl label string (the Canvas2D
   path applies its own 0.3em tracking), the inline-SVG diagram label in the
   5S case study (SVG text cannot take a span), and every <title>. ---- */
.sep { font-family: var(--sans); font-weight: 450; margin: 0 0.14em; }
.af-title .sep, .af-overline .sep, .af-wire-label .sep,
.outcome-line .sep, .key-tags .sep { font-weight: inherit; }
/* The one SERIF separator keeps Neco's own dot (Niko, 22 Jul), so the heading
   is not a serif line with two sans glyphs in it. The zero advance is stood in
   for by hand, asymmetrically, because the ink is asymmetric: it sits 0.05em to
   0.25em right of the origin. With the markup's own spaces (0.244em in Neco)
   the gaps land at 0.244 + 0.09 + 0.05 = 0.384em on the left and
   0.39 - 0.25 + 0.244 = 0.384em on the right, i.e. symmetric by construction.
   A plain space cannot do this job: one space is 0.244em and the overhang eats
   0.25em of it, which is exactly why the untreated dot touched the next word;
   a second space would leave 0.238 vs 0.294em, still lopsided and untunable,
   and HTML collapses consecutive spaces anyway. */
.af-title .sep { font-family: inherit; margin: 0 0.39em 0 0.09em; }
img { max-width: 100%; display: block; }
a { color: inherit; }
::selection { background: var(--blue); color: #ffffff; }
:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 2px; }
h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }

/* ============ The smoke curtain (19 Jul r5; replaces the View Transitions
   path entirely, r4's snapshot warp read as "surrealistic twisting", never
   as smoke). Committing to a link raises real smoke from the bottom of the
   stage until it covers the page, the navigation happens under full cover,
   and the new page opens buried in the same smoke, which then lifts.
   Layers: veil.js drives a WebGL fluid canvas (the site's own smoke, dense
   and opaque) above a solid .page-veil floor that guarantees full cover on
   machines whose fluid can't. The floor div ships in the markup of every
   JS page and an inline head script flips html.veiled BEFORE first paint
   when arriving under cover, so the new page never flashes.
   Fallbacks: no WebGL = the floor alone slides over/away (still a
   curtain); reduced motion = never covered, instant navigation; no JS =
   plain navigation (the inline flag-setter never ran). ============ */
.page-veil {
  position: fixed; inset: 0; z-index: var(--z-veil);
  /* EXACTLY the colour the saturated curtain resolves to (the display
     shader's dense tint, vec3(0.08,0.12,0.22) at alpha 1 = rgb(20,31,56)),
     not a gradient (r6f): both halves of the transition thicken to that
     flat navy at the page swap, and for the ~60ms before the arriving
     canvas paints, this floor is what is on screen. A gradient here put a
     ~25/765 colour shift into that gap; matching it exactly makes the seam
     pixel-identical from the last cover frame to the first reveal frame.
     background-COLOR, not the shorthand (r6h): the arriving page's inline
     head script injects a background-image rule for the departing page's
     last frame, and it has to run before this file is even linked, so a
     shorthand here would land later in the cascade and wipe it. */
  background-color: #141f38;
  opacity: 0; visibility: hidden; pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  /* arriving under cover: opaque from the very first paint; if veil.js
     does not claim the reveal (html.veil-live) within 0.6s, the floor
     lifts on its own so no engine failure can wall the visitor out */
  html.veiled .page-veil { opacity: 1; visibility: visible; }
  html.veiled:not(.veil-live) .page-veil {
    animation: veil-clear 1s 0.6s cubic-bezier(0.87, 0, 0.13, 1) both;
  }
}
@keyframes veil-clear { to { translate: 0 -102%; } }
/* the fluid canvas veil.js creates above the floor */
.veil-cv {
  position: fixed; inset: 0; width: 100%; height: 100%;
  z-index: calc(var(--z-veil) + 1); pointer-events: none; opacity: 0;
}
.veil-cv.is-on { opacity: 1; }

/* Film grain over the stage sections only */
.grain::after {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 2;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
  opacity: 0.05; mix-blend-mode: overlay;
}

/* ============ Header ============ */
.site-header {
  /* no-JS default is ABSOLUTE: the header lives on the stage (its markup
     ships data-on="stage") and simply scrolls away with the hero, so it can
     never sit as moon-on-daylight over the reading surfaces. main.js grants
     html.js only after GSAP/ScrollTrigger are confirmed, which is also what
     wires the theme flips that make a fixed header safe. (B2 backlog fix.) */
  position: absolute; inset: 0 0 auto 0; z-index: var(--z-header);
  display: flex; justify-content: space-between; align-items: center;
  padding: clamp(1rem, 2.5vw, 1.6rem) var(--page-x);
  transition: color 0.5s var(--ease-out), transform 0.5s var(--ease-out),
              background-color 0.5s var(--ease-out), visibility 0s linear;
  /* the fixed strip spans the viewport and would eat the deck's pointerdown on
     the disc's top arc; only the links are interactive, so only they hit-test */
  pointer-events: none;
}
html.js .site-header { position: fixed; }
.site-header a { pointer-events: auto; }
.site-header.is-scrolled[data-on="day"] { pointer-events: auto; } /* the scrim is a real surface */
.site-header[data-on="day"] { color: var(--ink); }
.site-header[data-on="stage"] { color: var(--moon); }
/* the scrim exists ONLY over the light day zone. Over the stage (hero, footer)
   the header stays transparent at every scroll position — round 3: the dark
   scrim over the dark hero read as a pointless bar, and the header hides
   before it could ever overlap hero content anyway. The theme flip back to
   data-on="stage" is also what dissolves the scrim when scrolling back up. */
.site-header.is-scrolled[data-on="day"] { background: rgba(246, 249, 253, 0.88); }
/* slight glassmorphism (Niko, 19 Jul r4): the scrim thins to a real glass
   pane — blur + saturate carry the separation instead of opacity. The solid
   rgba above stays as the no-backdrop-filter floor (a 0.6-alpha veil without
   blur would put body text at risk under the nav links). */
@supports (backdrop-filter: blur(1px)) {
  .site-header.is-scrolled[data-on="day"] {
    background: linear-gradient(180deg, rgba(246, 249, 253, 0.72) 0%, rgba(246, 249, 253, 0.52) 100%);
    backdrop-filter: blur(14px) saturate(1.35);
    box-shadow: 0 1px 0 rgba(23, 32, 46, 0.07);
  }
}
/* the gallery is the one page where a STAGE header floats over content
   (everywhere else the stage header sits only over the hero/footer, where
   the r3 ruling keeps it bare); scrolled, it gets the same glass in dark */
body.page-gal .site-header.is-scrolled { pointer-events: auto; background: rgba(12, 19, 34, 0.88); }
@supports (backdrop-filter: blur(1px)) {
  body.page-gal .site-header.is-scrolled {
    background: linear-gradient(180deg, rgba(16, 26, 46, 0.6) 0%, rgba(12, 19, 34, 0.42) 100%);
    backdrop-filter: blur(14px) saturate(1.2);
    box-shadow: 0 1px 0 rgba(232, 238, 247, 0.08);
  }
}
/* the hidden header slides out but its links STAY focusable: tabbing into it
   slides it back via :focus-within, honouring the header contract "returns on
   scroll up or when focus enters it". The earlier visibility:hidden flip
   (B1-era trap fix) removed the links from tab order after its 0.5s delay,
   which made this reveal unreachable for real keyboard users; caught at the
   B4 a11y pass. An off-screen landmark nav in the a11y tree is fine, a nav
   keyboard users cannot reach is not. */
.site-header.is-hidden { transform: translateY(-110%); }
.site-header.is-hidden:focus-within { transform: none; }
.logotype {
  font-family: var(--serif); font-weight: 700; font-size: 1.6rem;
  text-decoration: none; letter-spacing: 0.01em; line-height: 1;
  display: inline-block;
}
/* The sheet-music logotype (refinement round): the name sits ON a faint
   five-line staff like notes, a treble clef opening the system. Replaced
   the 28-bar waveform underline, which repeated the rail's progress motif
   a few hundred pixels away; the waveform now lives on the rail alone. */
.lt-sheet { position: relative; display: inline-block; padding-left: 0.86em; white-space: nowrap; }
.lt-word { position: relative; }
.lt-staff {
  /* geometry tied to the measured type: staff bottom line CENTER on the
     baseline, top line at cap height (0.75em). RE-SEATED FOR NECO 23 Jul
     2026: at line-height 1 Neco's baseline sits 0.18em above the box
     bottom where Fragment Glare's sat 0.225em, so the swap had dropped
     the name 0.045em below the staff (measured 1.16px at 25.6px, Niko's
     catch on the OG card). The clef moved the same 0.045em, keeping its
     staff registration untouched. Neco caps still span the staff
     (cap 0.703em vs line-center span 0.7em); lowercase sits between the
     lower lines like noteheads. */
  position: absolute; left: -0.2em; bottom: 0.155em;
  height: 0.75em; width: calc(100% + 0.55em); overflow: visible;
}
.lt-staff path {
  /* stroke scales by the y-axis only (horizontal lines, viewBox 15 tall at
     0.75em = 15px: 1 unit = 1px). No vector-effect: it moves dash math into
     screen space and breaks the hover draw's pathLength normalization. */
  stroke: currentColor; stroke-opacity: 0.34; stroke-width: 1; fill: none;
}
.lt-clef {
  /* the traditional engraved G clef (public-domain glyph, Wikimedia Commons
     GClef.svg; replaced the self-drawn monoline J-curve, Niko 18 Jul).
     Registered in-browser: the spiral's eye on the staff's G line, the loop
     overshooting the caps, curl and dot below the baseline like notation. */
  position: absolute; left: 0.02em; bottom: -0.37em;
  height: 1.5em; overflow: visible;
}
.lt-clef path { fill: var(--blue); }
/* hover: the staff draws itself left to right, top line first (per-line
   paths on purpose: multi-subpath dash restarts differ across engines) */
.logotype:hover .lt-staff path { animation: staffdraw 0.7s var(--ease-out) backwards; }
.logotype:hover .lt-staff path:nth-of-type(2) { animation-delay: 0.05s; }
.logotype:hover .lt-staff path:nth-of-type(3) { animation-delay: 0.1s; }
.logotype:hover .lt-staff path:nth-of-type(4) { animation-delay: 0.15s; }
.logotype:hover .lt-staff path:nth-of-type(5) { animation-delay: 0.2s; }
@keyframes staffdraw {
  from { stroke-dasharray: 100 100; stroke-dashoffset: 100; }
  to { stroke-dasharray: 100 100; stroke-dashoffset: 0; }
}

/* The staff-draw section opener (refinement round): the logotype's
   sheet-music thread extended to movement openings. A faint staff draws in
   under the head as it enters the viewport, and a rising triad of noteheads
   lands at the phrase end. Injected by the motion layer (decoration: no-JS
   and reduced-motion pages simply do without); hidden on paper with the
   other ornaments. */
.staff-draw {
  /* absolutely placed inside its heading: zero layout impact (motion and
     no-motion sessions keep identical layout), and it inherits the head's
     reveal rise and fade */
  position: absolute; left: 0; top: calc(100% + 0.3rem);
  display: block; width: clamp(9rem, 16vw, 13rem); height: 0.85rem;
  overflow: visible;
}
.section-head, .gal-head, .cs-title { position: relative; }
.staff-draw path {
  /* no vector-effect here: non-scaling-stroke moves dash math into SCREEN
     space and truncated the drawn lines at 160px (found in browser).
     1.18 user units under the 16-to-0.85rem y-scale renders as 1px. */
  stroke: currentColor; stroke-opacity: 0.3; stroke-width: 1.18; fill: none;
}
.staff-draw ellipse { fill: var(--blue-deep); }
/* the gallery's movement openings live on the stage: notes brighten */
.gal-wall .staff-draw ellipse, .gal-merch .staff-draw ellipse { fill: var(--blue); }
.site-nav { display: flex; gap: clamp(1.25rem, 3vw, 2.5rem); }
.site-nav a {
  font-weight: 600; font-size: var(--t-lead); text-decoration: none; position: relative; padding: 0.2rem 0;
}
.site-nav a::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: var(--blue); transform: scaleX(0); transform-origin: 100% 50%;
  transition: transform 0.45s var(--ease-out);
}
.site-nav a:hover::after { transform: scaleX(1); transform-origin: 0 50%; }

/* ============ Waveform rail (the thread) ============ */
.wave-rail {
  /* hugs the screen edge: the rail was crowding the content */
  position: fixed; left: clamp(0.35rem, 0.8vw, 0.8rem); top: 50%; translate: 0 -50%;
  height: 56vh; z-index: var(--z-rail); pointer-events: none;
}
.wave-rail svg { height: 100%; overflow: visible; }
.wave-rail .wave-base { fill: none; stroke-width: 2.6; opacity: 0.22; }
/* played layer is JS-driven: hidden by default so the no-JS and reduced-motion
   states show the quiet base rail instead of a fake end state. The bars filling
   with colour ARE the playhead. */
.wave-rail .wave-play { fill: none; stroke: var(--blue); stroke-width: 2.6; stroke-linecap: butt; visibility: hidden; }
.wave-rail[data-on="stage"] .wave-base { stroke: var(--moon); }
.wave-rail[data-on="day"] .wave-base { stroke: var(--ink); }
@media (max-width: 900px) { .wave-rail { display: none; } }

/* ============ Hero: the stage ============ */
.hero {
  /* aperture = the vinyl's spindle hole; disc centre and clip origin share these */
  --ax: 66%; --ay: 44%;
  position: relative; min-height: 100svh; overflow: clip;
  display: flex; flex-direction: column; justify-content: flex-end;
  padding: var(--hero-pad) var(--page-x);   /* horizontal = the shared page margin (r5) */
  background:
    radial-gradient(120% 85% at 50% -10%, rgba(46, 143, 221, 0.16) 0%, rgba(46, 143, 221, 0.05) 34%, transparent 62%),
    linear-gradient(180deg, var(--stage-2) 0%, var(--stage) 55%);
}
/* ambient stage wash: the only stage light besides the record itself (the
   followspot rig was cut 13 Jul — light is implied, never rendered as an
   object; it read as cartoonish against the drawn vinyl) */
.spot {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: radial-gradient(46% 60% at 50% 30%, rgba(232, 238, 247, 0.13) 0%, rgba(232, 238, 247, 0.045) 45%, transparent 72%);
  filter: blur(2px);
}
/* smoke on the stage: canvases are created by smoke.js (one per [data-smoke]
   section) only when every gate passes. No blend mode: the sim outputs
   premultiplied alpha and composites source-over, so the smoke is a
   translucent veil, not an additive glow */
.smoke {
  position: absolute; inset: 0; width: 100%; height: 100%;
  z-index: 2; pointer-events: none;
  opacity: 0; transition: opacity 1.4s var(--ease-out);
}
.smoke.is-on { opacity: 1; }
/* the smoke is UNTOUCHED by scrolling (18 Jul r3: both the scrub dissolve
   and the pause+1.4s fade read as the zoom killing it): the sim keeps
   running through the whole pin and the daylight burst simply covers it
   (lights-up sits above the canvas). It still pauses under full cover and
   on hidden tabs. */
/* warm ray burst around the aperture while the house lights come up.
   Split into wrap + inner on the recorded lesson: the wrap owns the
   CSS-translate centering and GSAP must NEVER animate it — GSAP folds a
   `translate` property into its own transform timing-dependently (sometimes
   as %, sometimes baked px), and baked px go stale on any window resize.
   GSAP animates only .rays (scale/rotation/opacity). */
.rays-wrap {
  /* 96vmax + restretched mask stops = same visible annulus as a 160vmax
     layer at ~2.8x less rasterized/blended texture */
  position: absolute; left: var(--ax); top: var(--ay); translate: -50% -50%;
  width: 96vmax; aspect-ratio: 1; z-index: 2; pointer-events: none;
}
.rays {
  position: absolute; inset: 0;
  background: repeating-conic-gradient(from 8deg,
    transparent 0deg 9deg,
    rgba(255, 232, 190, 0.11) 12deg 15deg,
    transparent 18deg 24deg);
  /* the #000 below is a MASK: pure alpha data, not a colour on screen */
  -webkit-mask-image: radial-gradient(circle, transparent 0 3.3%, #000 11.7%, transparent 63%); /* impeccable-disable-line design-system-color -- mask alpha, not paint */
  mask-image: radial-gradient(circle, transparent 0 3.3%, #000 11.7%, transparent 63%); /* impeccable-disable-line design-system-color -- mask alpha, not paint */
  mix-blend-mode: screen; opacity: 0;
}
/* daylight layer the lights-up scrub reveals: opens WARM (incandescent house
   lights), then the cool editorial day fades in on top (color-temperature
   shift; the Tungsten Moment — warm hues exist only inside this transition) */
.lights-up {
  position: absolute; inset: 0; z-index: 3; pointer-events: none;
  background: radial-gradient(circle at var(--ax) var(--ay),
    #fff3da 0%, #faecd2 16%, #f7eedd 34%, var(--day) 62%);
  clip-path: circle(0% at var(--ax) var(--ay));
}
.lights-day { position: absolute; inset: 0; background: var(--day); opacity: 0; }
/* the record wins the day zone (r5, widened r6): a fixed body-child layer
   ABOVE the day zone (z6 vs z5) carries the DISC, the rays, the aperture
   ring and a clone of the warm glow, so the whole instrument and its light
   ride OVER the entering "Selected work" strip instead of being cut at its
   top edge (Niko, r6: "make the light and the vinyl stay on top of the
   selected work section as long as they are visible"). The hero keeps the
   stage itself: background, spot, smoke and the name stay under the day
   zone. overflow: clip is load-bearing — it bounds the 7.5x-scaled disc's
   promoted raster to the viewport, exactly as the hero's own clip did.
   The glow clone's gradient is IDENTICAL to .lights-up since r6 (the r5
   fade-to-transparent clone drew a tint seam exactly at the strip's top
   edge) and buries the moved-up disc during the swallow; the wrapper's
   drain (0.91-0.98, r6b) starts only once the burst covers the viewport
   and everything inside the wrapper has faded on its own. JS-created,
   so no-JS/reduced-motion never see it (the disc then stays in the hero).
   --ax/--ay are stamped inline (the hero's values don't inherit out). */
.lights-over {
  position: fixed; inset: 0; z-index: 6;
  overflow: clip; pointer-events: none;
}
.lights-over .ol-up {
  position: absolute; inset: 0; z-index: 3;   /* above the disc (1) and rays/ring (2) */
  background: radial-gradient(circle at var(--ax) var(--ay),
    #fff3da 0%, #faecd2 16%, #f7eedd 34%, var(--day) 62%);
  clip-path: circle(0% at var(--ax) var(--ay));
}

/* ---- The Record: self-drawn vinyl; grooves spin under a static sheen ---- */
.vinyl-wrap {
  position: absolute; left: var(--ax); top: var(--ay); translate: -50% -50%;
  width: min(82vmin, 78vw); aspect-ratio: 1; z-index: 1; pointer-events: none;
}
.vinyl-scale { position: absolute; inset: 0; }
/* one-owner rule: the load timeline owns .vinyl-scale's opacity, the zoom
   timeline owns .vinyl-fade's — the disc visuals fade as one group without
   the tweens ever sharing a property. will-change is load-bearing: an
   unpromoted wrapper made every fade-zone opacity write repaint the whole
   scaled disc subtree (measured median 80 ms in-pin before promotion). */
.vinyl-fade { position: absolute; inset: 0; will-change: opacity; }
.vinyl {
  position: absolute; inset: 0; border-radius: 50%;
  /* the BODY never rotates since 18 Jul r3 (every visibly asymmetric part
     lives on the rotor): will-change stays for the cheap-scale / parked
     re-raster cycle under the zoom */
  will-change: transform;
  background:
    radial-gradient(circle, transparent 0 16.4%, rgba(46, 143, 221, 0.5) 16.6% 17%, transparent 17.2%),
    radial-gradient(circle, #24334f 0 17%, transparent 17.4%),
    conic-gradient(from 40deg, transparent 0 10%, rgba(232, 238, 247, 0.022) 13% 20%, transparent 23% 55%, rgba(232, 238, 247, 0.018) 58% 64%, transparent 67%),
    /* closest-side: % stops resolve against the DISC radius. The default
       (farthest-corner = the square's half-diagonal) silently pushed two of
       these three track-separator rings outside the border-radius clip. */
    radial-gradient(circle closest-side, transparent 0 57.6%, rgba(232, 238, 247, 0.055) 57.8% 58.2%, transparent 58.4% 71.6%, rgba(232, 238, 247, 0.055) 71.8% 72.2%, transparent 72.4% 85.6%, rgba(232, 238, 247, 0.055) 85.8% 86.2%, transparent 86.4%),
    repeating-radial-gradient(circle, #0d1424 0 1.6px, #121b2f 1.6px 3.2px),
    radial-gradient(circle, #101828 0%, #0a101d 100%);
  /* the ONE true black on the site (Niko, 23 Jul 2026): the record's shadow
     keeps rgba(0,0,0,0.6) deliberately, so the vinyl stands apart — the
     sanctioned exception inside The Never-Pure-Black Rule */
  box-shadow: 0 24px 90px -18px rgba(0, 0, 0, 0.6), inset 0 0 0 2px rgba(232, 238, 247, 0.05); /* impeccable-disable-line design-system-color -- the sanctioned true black */
}
@keyframes spin { to { rotate: 1turn; } }
/* the rotor: the ONE rotating surface (streak canvas + label ring + zoom
   canvas twins ride it), so the ticker writes a single rotate per frame and
   the spin survives the zoom. CSS spin is the no-JS fallback; the DJ module
   disables it and drives rotate itself so click-and-hold can scratch. */
.vinyl-rotor {
  position: absolute; inset: 0; pointer-events: none;
  animation: spin 14s linear infinite; /* stylised slow-motion, not literal 33 1/3 */
  will-change: transform;
}
/* the rotating streak glints, canvas-painted by JS. Without JS it stays
   blank and the body's own conic streaks (kept until .is-deck) carry them.
   width/height 100% are load-bearing (19 Jul r4): an absolutely positioned
   REPLACED element ignores inset-stretching and lays out at its intrinsic
   size — the 2x supersampled buffer rendered at twice the disc, anchored
   top-left, and its conic centre orbited the disc as the rotor turned
   (Niko: "their centerpoint has travelled outside the pinhole"). */
.vinyl-streak-cv {
  position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%;
  will-change: transform;    /* own layer: the buffer IS the texture */
}
/* once the DJ module owns the disc, the body drops its painted streaks (the
   rotor's canvas copy carries them; a frozen body streak under a rotating
   copy would double-image). Same stack minus the conic layer. */
.vinyl.is-deck {
  background:
    radial-gradient(circle, transparent 0 16.4%, rgba(46, 143, 221, 0.5) 16.6% 17%, transparent 17.2%),
    radial-gradient(circle, #24334f 0 17%, transparent 17.4%),
    radial-gradient(circle closest-side, transparent 0 57.6%, rgba(232, 238, 247, 0.055) 57.8% 58.2%, transparent 58.4% 71.6%, rgba(232, 238, 247, 0.055) 71.8% 72.2%, transparent 72.4% 85.6%, rgba(232, 238, 247, 0.055) 85.8% 86.2%, transparent 86.4%),
    repeating-radial-gradient(circle, #0d1424 0 1.6px, #121b2f 1.6px 3.2px),
    radial-gradient(circle, #101828 0%, #0a101d 100%);
}
/* the aperture (rebuilt 19 Jul r4): the pinhole is OPEN from the very first
   frame — warm light through it at rest ("light out of a closed hole makes
   no sense"), and its size stays locked at 3% of the disc through the whole
   zoom, so the transition goes through it only because we get closer. The
   ring is just the see-through rim now (transparent centre; the old stage-
   coloured fill read as a black ring around the light): the warm clip
   circle fills it to 89% of the hole and the rim runs 88-100%, overlapping
   1% so no dark hairline can open between them. Drawn in the UNSCALED
   world, sized per frame from the scrub (r3): the composited disc raster
   bakes sub-pixel offsets that the zoom magnifies, so the ring and the warm
   circle share one coordinate space and are concentric by construction. */
.aperture-ring {
  position: absolute; left: var(--ax); top: var(--ay); translate: -50% -50%;
  aspect-ratio: 1; z-index: 2; pointer-events: none;
  border-radius: 50%;
  background: radial-gradient(circle closest-side, transparent 0 88%, rgba(232, 238, 247, 0.2) 88% 100%);
}
/* with JS the aperture element IS the hole; the body's painted spindle hole
   (kept for the no-JS stage) would show its baked sub-pixel offset through
   the ring's open centre */
html.js .vinyl::after { visibility: hidden; }
/* the grab affordance is granted BY the DJ module (is-deck), never by base CSS:
   without JS the disc spins but must not advertise an interaction it can't honor */
.vinyl.is-deck { pointer-events: auto; cursor: grab; touch-action: pan-y; /* border-radius clips hits to the disc */ }
.vinyl.is-scratching { cursor: grabbing; }
/* the disc must not be a full-screen grab surface while it zooms into the
   aperture. Zoom/park state selectors read the OVER-LAYER since r6: the disc
   subtree lives in .lights-over (see it), which mirrors the hero's classes. */
.lights-over.is-zooming .vinyl { pointer-events: none; cursor: default; }
/* parked-scrub crispness fix: will-change pins the layer's raster at the
   scale it was promoted at (1x), so a zoomed disc showed 1 CSS px stretched
   to ~4+ and Chrome never re-rastered even at rest. Dropping the hint
   re-rasters crisp — but doing it for the whole zoom re-rasters every scrub
   frame (median 70 ms, p95 1100 ms on the test iGPU). So the pin timeline
   grants .is-parked only once the scrub has settled (~180 ms quiet): blurry
   while the disc is in motion (invisible), razor-sharp the moment the user
   can actually look. Must pair with the rotation freeze in the ticker (an
   un-promoted disc receiving rotate writes repaints whole, 360 ms/frame). */
.lights-over.is-parked .vinyl, .lights-over.is-parked .vinyl-sheen { will-change: auto; }
.vinyl::after { /* spindle hole: the future aperture into daylight */
  content: ""; position: absolute; left: 50%; top: 50%; width: 3%; aspect-ratio: 1;
  translate: -50% -50%; border-radius: 50%;
  background: var(--stage);
  box-shadow: 0 0 0 1.5px rgba(232, 238, 247, 0.2);
}
/* label ring copy: rides the OUTER EDGE of the label disc, never over the blue
   border ring. The label gradients above resolve % against farthest-corner, so
   the ring's inner edge sits at ~11.6 units of the 100-unit viewBox; the text
   baseline at r=9.3 puts cap height at 10.35 with a clear gap to the ring.
   Sizing must also fit the metric-widest fallback (Arial): 40 glyphs at 1.5px
   + 0.45 tracking stays under the 58.4-unit circumference in every stack. */
/* pointer-events none: border-radius clips the DISC's hit area, but not a
   child's — this square SVG's corners stick past the circular edge and were
   silently grabbable (B2 catch). Decorative; scratches belong to .vinyl. */
.vinyl-label-text {
  position: absolute; inset: 0; pointer-events: none;
  /* own small layer: handing over to the canvas twins at zoom start must
     repaint ~200px of text, never the whole gradient disc (~30 ms) */
  will-change: transform;
}
/* the vinyl keeps spinning through the zoom (Niko's call, refinement round):
   the disc BODY still freezes for raster crispness (its gradients are
   radially symmetric, so a frozen body is invisible), while these canvas
   twins of the label copy carry the visible rotation at pure compositor
   cost. Two pre-rendered resolutions keep the GPU's linear filtering within
   ~2x of native at every zoom scale: mid (2.5x) up to ~2.4x zoom, full
   (7.5x, capped 2048px) beyond. JS paints them; without JS they stay
   invisible and the SVG label above remains the only copy. */
.vinyl-label-cv {
  position: absolute; left: 50%; top: 50%;
  width: 28%; aspect-ratio: 1; translate: -50% -50%;
  pointer-events: none; opacity: 0;
  will-change: transform;    /* own layer: the canvas buffer IS the texture */
  transition: opacity 0.12s linear;
}
.lights-over.is-zooming .vinyl-label-text { visibility: hidden; }
.lights-over.is-zooming .vinyl-label-cv[data-lod="mid"] { opacity: 1; }
.lights-over.is-zooming[data-label-lod="full"] .vinyl-label-cv[data-lod="mid"] { opacity: 0; }
.lights-over.is-zooming[data-label-lod="full"] .vinyl-label-cv[data-lod="full"] { opacity: 1; }
.vinyl-label-text text {
  /* pressed INTO the label, not painted over (18 Jul r3): surface-tinted ink
     + 0.95 opacity. True multiply blending inverts on a dark surface (light
     text × dark label = darker than the label), so tint + opacity carry it. */
  font-family: var(--sans); font-size: 1.5px; font-weight: 600; letter-spacing: 0.45px;
  fill: var(--label-ink); opacity: 0.95;
}
.vinyl-sheen { /* the light does NOT rotate: static reflections are what sell the spin */
  position: absolute; inset: 0; border-radius: 50%;
  /* pinned raster like the body: unpromoted, the sheen painted into the
     .vinyl-fade layer, which re-rasters per frame under the zoom's scale
     (measured +20 ms/frame in-pin); promoted, it rides the same
     blurry-in-motion / parked-sharp cycle as the disc */
  will-change: transform;
  background:
    radial-gradient(58% 58% at 31% 24%, rgba(232, 238, 247, 0.07) 0%, transparent 62%),
    conic-gradient(from 205deg, transparent 0 6%, rgba(232, 238, 247, 0.075) 11% 15%, transparent 20% 48%, rgba(232, 238, 247, 0.05) 53% 57%, transparent 62%);
}
@media (max-width: 900px) { .hero { --ax: 50%; --ay: 38%; } }

.hero-inner { position: relative; z-index: 4; }
/* While the deck is live, the copy painted ABOVE the disc must not hit-test:
   its full-width block boxes covered the disc's whole lower face and ate the
   deck's pointerdown. No links live in here. Scoped to .has-deck (granted by
   the DJ module) so no-JS and reduced-motion visitors, who have no deck to
   protect, keep text selection. */
.hero.has-deck .hero-inner, .hero.has-deck .scroll-cue { pointer-events: none; }
.hero-greeting {
  font-size: clamp(1.375rem, 2.74vw, 2.125rem); font-weight: 600; color: var(--moon-soft);
}
.hero-greeting em { font-style: normal; color: var(--blue); }
.hero-name {
  /* The Hero Exception: past the 6rem display ceiling, here ONLY.
     The vh term is the short-viewport guard (laptop 1536x695: the pure-vw
     size crowded the greeting into the header logo, refinement round);
     at 911px+ heights it stays above the 10.5rem cap and changes nothing. */
  font-family: var(--serif); font-weight: 800;
  /* desktop cap 10.5 -> 10.1875rem (r5: "5px smaller"); the 19.5vh term is
     the short-viewport guard (laptop 695h) and never engages at 911px+ */
  font-size: clamp(3.2rem, min(12.5vw, 19.5vh), 10.1875rem);
  /* the name is EXEMPT from the r4 +0.1 title-air sweep (r5): its two lines
     have no ascender/descender collision and the tight lockup looked better */
  line-height: 0.98; letter-spacing: -0.015em;  /* optical for Fragment Glare */
  margin: 0.5rem 0 1.1rem -0.05em;
}
.hero-name .line { display: block; overflow: clip; }
.hero-name .line > span { display: inline-block; }
.hero-statement {
  /* verbs over titles: the catchphrase IS the identity line */
  font-family: var(--serif); font-weight: 400;
  font-size: clamp(1.35rem, 2.6vw, 2.15rem); line-height: 1.32;
  /* 24ch -> 16.2em: same 558px at 1920 as before the swap. em, not rem, so a
     wrap cap keeps its line break as the display size shrinks with the viewport. */
  max-width: 16.2em; margin-bottom: clamp(1.6rem, 3vw, 2.4rem);
}
.hero-statement strong { font-weight: 600; color: var(--blue); }
.hero-meta {
  display: flex; flex-wrap: wrap; align-items: flex-end;
  gap: 1.2rem 3rem; border-top: 1px solid var(--hairline-dark);
  padding-top: 1.3rem;
}
/* fluid role: the ENDPOINTS sit on the ladder (--t-ui to --t-body), the middle
   stays fluid. The old 1.0028/1.1611rem were 19/18 artifacts of 0.95/1.1rem. */
.hero-fields { font-weight: 600; font-size: clamp(var(--t-ui), 1.58vw, var(--t-body)); color: var(--moon-soft); letter-spacing: 0.01em; }
.scroll-cue {
  /* top right -> bottom right + one ladder step up (23 Jul, Niko's call): the
     cue now closes the hero's bottom line opposite the meta fields, its bottom
     aligned to theirs. desktop only — below 900px the fields wrap across the
     full width, so the corner the cue wants does not exist there and it keeps
     its old top-right perch (measured overlap at 412px, not a guess). */
  position: absolute; z-index: 4; right: var(--page-x); bottom: var(--hero-pad);
  display: flex; align-items: center; gap: 0.7rem;
  font-size: var(--t-lead); font-weight: 600; color: var(--moon-soft);
  /* hit-test transparency under .has-deck lives with the .hero-inner rule above */
}
@media (max-width: 900px) {
  .scroll-cue { bottom: auto; top: clamp(5.5rem, 12vh, 8rem); }
}
/* the pulse travels DOWN and fades out: an unambiguous "scroll" gesture */
.scroll-cue .cue-arrow { width: 12px; height: 40px; color: var(--blue); animation: cuepulse 2s var(--ease-in-out) infinite; }
.scroll-cue .cue-arrow svg { display: block; width: 100%; height: 100%; overflow: visible; }
@keyframes cuepulse {
  0% { transform: translateY(-6px); opacity: 0; }
  35% { opacity: 1; }
  100% { transform: translateY(7px); opacity: 0; }
}

/* ============ Daylight zone ============ */
.day-zone { background: var(--day); color: var(--ink); position: relative; z-index: 5; }
/* homepage only (18 Jul r3): the day zone slides UP behind the pinned hero
   (z5 paints over the fixed, z-auto hero) so "Selected work" starts rising
   on the SAME scroll that finishes the swallow, with no white beat between.
   Gated on html.js: without the pin (no-JS, reduced motion) the negative
   margin would eat the hero's meta line. */
html.js .home-day { margin-top: -12svh; }   /* -9 → -12 (r4): "a touch sooner" */
.section { padding: var(--section-y) var(--page-x); }
.section-head {
  font-family: var(--serif); font-weight: 800; color: var(--ink);
  font-size: clamp(2rem, 4.6vw, 3.6rem); line-height: 1.15; letter-spacing: -0.015em;
  margin-bottom: var(--head-gap);
}
/* the .thin light-weight word styling retired 18 Jul: the 800-vs-300 swing
   was too loud, and single-word heads made the system read inconsistent */

/* ============ Work: piano-key index ============ */
/* A wider instrument cropped at the edges; only the case-study keys are lit.
   Top padding tightened 18 Jul r3 and again r5: the TITLE (not just the
   section edge) must enter the viewport while the vinyl is still fading,
   so the head sits right under the section's top edge. */
.work { padding-top: clamp(1.25rem, 2vw, 1.5rem); padding-bottom: clamp(5rem, 12vw, 10rem); overflow-x: clip; }
.keyboard {
  position: relative;
  display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px;
  width: 112%; margin-left: -6%;
}
.key {
  position: relative; display: flex; flex-direction: column; justify-content: flex-end;
  min-height: clamp(20rem, 46vh, 27rem);
  background: #ffffff; color: var(--ink);
  border: 1px solid var(--hairline-light); border-radius: 0 0 8px 8px;
  text-decoration: none; overflow: clip;
  box-shadow: 0 14px 28px -18px rgba(23, 32, 46, 0.35);
  transition: translate 0.4s var(--ease-out), box-shadow 0.4s var(--ease-out);
}
.key:hover, .key:focus-visible {
  translate: 0 10px;                      /* the key press */
  box-shadow: 0 4px 10px -8px rgba(23, 32, 46, 0.45);
}
.key.is-rest { /* unlit keys: part of the instrument, not missing content */
  background: linear-gradient(180deg, #f5f8fc 0%, #e3eaf4 100%);
  box-shadow: inset 0 -12px 24px -18px rgba(23, 32, 46, 0.35);
  cursor: default;
}
.key.is-rest:hover { translate: 0 6px; }
/* The chord exit (refinement round): the struck key presses deep with a
   flattened shadow, neighbours ripple outward a beat later; the View
   Transition then carries the pressed chord into the case study. Classes
   are granted by the motion layer just before navigation. */
.key.chord-hit {
  translate: 0 18px; transition-duration: 0.12s;
  box-shadow: 0 2px 6px -6px rgba(23, 32, 46, 0.5);
}
.key.chord-rip { translate: 0 8px; transition-duration: 0.16s; }
.key.chord-rip.rip-2 { translate: 0 5px; transition-delay: 0.035s; }
.black-key.chord-rip { translate: 0 5px; transition: translate 0.16s var(--ease-out) 0.02s; }
.key .key-media { position: absolute; inset: 0; opacity: 0; transition: opacity 0.5s var(--ease-out); }
.key .key-media picture { display: block; width: 100%; height: 100%; }
.key .key-media img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
/* the wash starts darkening from the black-key line: the key text lives in the
   middle of the key, not at its foot */
.key .key-media::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(12, 19, 34, 0.12) 0%, rgba(12, 19, 34, 0.62) 42%, rgba(12, 19, 34, 0.9) 100%); }
/* every lit key owns a dark hover surface; since B3 all three carry real
   page captures (the .is-ph placeholder gradient retired with the last one) */
.key:hover .key-media, .key:focus-visible .key-media { opacity: 1; }
/* one shared text zone: on desktop every lit key's copy starts at the same y,
   directly under the black-key row, so the three read as one system no matter
   how many lines each title wraps to. Bottom-anchored flow only below 900px,
   where the black keys are gone. */
.key-body { position: relative; z-index: 1; padding: 1.2rem 1.2rem 1.3rem; transition: color 0.4s var(--ease-out); }
.key:hover .key-body, .key:focus-visible .key-body { color: var(--moon); }
/* mobile key number: Deep Stage Blue for AA — Stage Blue on the white key was
   3.45:1 (B2 backlog fix); the desktop number is sr-only so colour is moot there */
.key-no { display: block; font-weight: 700; font-size: var(--t-label); color: var(--blue-deep); letter-spacing: 0.04em; }
.key:hover .key-no, .key:focus-visible .key-no { color: var(--blue); }
/* cap 1.45 -> 1.6rem and tags label -> lead (23 Jul, Niko's call): the title
   now peaks at the artifact-title size (25.6px at 1920) and the tags share the
   cs-meta dd size (17.4px) instead of sitting at label. The cap engages from
   ~1896px up (1.6rem / 1.35vw); below that the vw term is unchanged. */
.key-title { display: block; font-family: var(--serif); font-weight: 700; font-size: clamp(1.05rem, 1.35vw, 1.6rem); line-height: 1.35; margin: 0.4rem 0 0.5rem; }
.key-tags { display: block; font-size: var(--t-lead); font-weight: 500; color: var(--ink-soft); transition: color 0.4s var(--ease-out); }
.key:hover .key-tags, .key:focus-visible .key-tags { color: var(--moon-soft); }
@media (min-width: 900.02px) {
  /* the absolute text zone owns only the key's lower half, so the key floor
     must guarantee that half fits the longest title+tags at this range's
     column widths — 20rem clipped 24px of copy in short windows (650px tall)
     up to ~1500px wide.
     23.5rem -> 28rem, cap 27 -> 30rem (22 Jul 2026). The floor was still too
     low and CS02's tags were CLIPPED at 1536x695, Niko's own laptop profile,
     by 16px. Derived, not guessed: the text zone starts at 50% + 1.15rem, so
     the key must satisfy H >= 2 x (18.4px + tallest copy + clearance).
     -> 29.75rem, cap 31.75rem (23 Jul, with the title cap 1.6rem + tags at
     --t-lead): the bigger type re-ran the whole derivation. Copy re-measured
     at 1450/1460/1536/1719/1896/1920; the worst case is now CS02 at 1896
     (195.3px content, 24.3px clearance) after BOTH long titles were retitled
     the same evening (Niko's calls, key + page + next-band):
     - CS02 -> "The bottlenecks a startup works around" (the old long title
       alone needed a 527px key at 1896),
     - CS01 -> "A consultancy's web presence, end&nbsp;to&nbsp;end" with the
       NBSP-glued last phrase: "An IT consultancy's" missed the 237px key
       column by ~2px, stranding "An IT" on its own line and orphaning "end";
       the reword + glue give a clean 3-line wrap at EVERY base width and
       turned CS01 from the floor's binding constraint (203.6px at 1536) into
       17.6-76px of air. The IT detail lives on in the page's own h1.
     The floor could drop to ~28.65rem under the new worst case; it stays at
     29.75rem for breathing room (his ask) and piano keys are long. Also part
     of the same fix: the narrow band below grew to 1450 (at 1310 the tags hit
     FIVE lines in a 148px column; no sane floor covers that). */
  .key { min-height: clamp(29.75rem, 46vh, 31.75rem); }
  .key-body { position: absolute; top: calc(50% + 1.15rem); left: 0; right: 0; padding: 0 1.2rem; }
  .key-title { margin-top: 0; }
  /* the visible CS label moves into the black key beside each lit key; this
     copy stays for the accessibility tree only */
  .key-no {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
}
/* narrow-desktop band (half-snapped windows): the absolute text zone gets only
   the key's lower half, and at these column widths the titles wrap to 4-5
   lines. Taller keys + one type step down buy the room; scoped to the band so
   the approved wide-desktop look is untouched. */
@media (min-width: 900.02px) and (max-width: 1450px) {
  /* floor 26rem -> 28.75rem (B2): the "In production" tag on CS02/03 wraps to
     one more line at band widths and overflowed the round-3 floor by 9px; the
     text zone is anchored at 50% of the key, so each extra rem of key height
     only buys half a rem of bottom room. -> 32.25rem 19 Jul r4: at the band's
     NARROW edge (~905-950px) the CS02 title wraps to 7 lines and already
     overflowed the old floor by 30px BEFORE the titles' +0.1 line-height
     (a pre-existing hole; B2 verified the mid-band only). Sized to the
     worst case + margin; longer keys read fine — piano keys are long.
     Band edge 1300 -> 1450 (23 Jul): with the tags at --t-lead, a 1310px
     window puts 17.4px tags in a 148px column = FIVE wrapped lines and a
     -37px clip, and even at 1410 the pair of four-line wraps (title AND tags)
     still needs a 511px key. 1300-1450 belongs with the narrow columns this
     band was built for; from 1450 up the base sizes fit the new base floor
     (measured at 1450/1460/1500: worst content 197px against the 204 budget).
     LESSON while finding this edge: never measure a candidate boundary width
     while it still sits INSIDE the old band — the 1400 probe read band
     typography and predicted a fit the base sizes did not deliver. */
  .key { min-height: clamp(32.25rem, 54vh, 32.5rem); }
  .key-body { top: calc(50% + 0.9rem); }
  .key-title { font-size: 1rem; }
  .key-tags { font-size: var(--t-fine); }
}
/* the band's NARROW half (r4): at 900-1000px the columns are so tight the
   CS02 title wraps to 7 lines — one more type step down keeps the anchored
   text zone inside the key (must follow the band block to win the cascade) */
@media (min-width: 900.02px) and (max-width: 1000px) {
  .key-title { font-size: 0.92rem; }
  /* the tags used to take a SECOND step down here (0.8233rem against the wider
     band's 0.8444). On the ladder both land on --t-fine, so this override is
     gone: it was a 0.34px difference, i.e. drift, and the serif title above
     still carries the narrow band's real step down. */
}
/* black keys: real-octave geometry (C# D# · F# G# A#); the three beside the lit
   keys carry the CS labels, the rest stay decorative.
   top: 0, not -1px — flush is what a real fallboard edge does. */
.black-keys { position: absolute; top: 0; left: 0; width: 100%; height: 50%; pointer-events: none; }
.black-key {
  position: absolute; top: 0; width: 7.4%; height: 100%;
  left: calc(var(--b) * 100% / 7 - 3.7%);
  display: flex; align-items: flex-end; justify-content: center;
  pointer-events: auto; cursor: default;
  background: linear-gradient(180deg, #223052 0%, var(--stage) 92%);
  border-radius: 0 0 6px 6px;
  box-shadow: 0 18px 30px -14px rgba(12, 19, 34, 0.65), inset 0 -3px 0 rgba(232, 238, 247, 0.07);
  transition: translate 0.4s var(--ease-out), box-shadow 0.4s var(--ease-out);
}
.black-key:hover { translate: 0 7px; box-shadow: 0 8px 14px -10px rgba(12, 19, 34, 0.7), inset 0 -2px 0 rgba(232, 238, 247, 0.07); }
/* a labeled sharp that has been wired to its case study (JS grants .is-nav)
   stops swallowing clicks: it presses AND navigates, and says so with the
   cursor (B2 backlog fix). Without JS it stays a decorative press. */
.black-key.is-nav { cursor: pointer; }
/* CS number printed horizontally at the sharp's foot, directly above where
   the white key's title starts (R3.3: rotated glyphs made the reader turn
   their head; the sharps are wide enough for the label at every desktop
   width, so horizontal wins on pure legibility).
   This container is aria-hidden: the accessible copy is the sr-only .key-no
   inside each link. */
.bk-label {
  /* a step ABOVE the key-title scale at every width (his 18 Jul call:
     "slightly larger than the title text in the keys"); fits the 72px
     sharp at the 900px band floor — verify on any retune */
  /* fluid role, endpoints on the ladder: the floor is the body step (--t-body,
     19px), which is still a clear step above .key-title's own 16.8px floor at
     the 900px band; the cap rounds 1.6889 -> 1.6875rem (27.02 -> 27px). */
  font-weight: 700; font-size: clamp(var(--t-body), 1.58vw, 1.6875rem);
  letter-spacing: 0.07em; color: var(--blue); padding-bottom: 0.8rem;
  white-space: nowrap;
}
@media (max-width: 900px) {
  .keyboard { grid-template-columns: 1fr; width: 100%; margin-left: 0; gap: 0.8rem; }
  .key { min-height: 13rem; }
  .key.is-rest, .black-keys { display: none; }
}

/* ============ Case-study spread voice (the homepage CS01 preview spread
   itself was cut 18 Jul; the kicker, figure and link voices live on in the
   case-study pages and bands) ============ */
.spread-kicker { font-weight: 700; color: var(--blue-deep); font-size: var(--t-lead); letter-spacing: 0.02em; }
.spread-figure { grid-column: 1 / -1; margin-top: clamp(1.5rem, 4vw, 3rem); }
.spread-figure .frame {
  height: min(34rem, 74vh); overflow: hidden; border-radius: 6px;
  box-shadow: 0 30px 60px -30px rgba(23, 32, 46, 0.45);
}
/* inner parallax: a top crop of the shipped page glides inside the frame as if
   the shown site were being scrolled (JS drives img y, capped) */
.spread-figure .frame img { width: 100%; height: auto; will-change: transform; }
.spread-figure figcaption { margin-top: 0.8rem; font-size: var(--t-ui); color: var(--ink-soft); }
.spread-link {
  display: inline-block; margin-top: 1.8rem; font-weight: 700; font-size: var(--t-lead);
  color: var(--blue-deep); text-decoration: none; border-bottom: 2px solid currentColor; padding-bottom: 0.15rem;
}
.spread-link:hover { color: var(--blue); }
/* the .spread-soon honest-teaser style retired with B3: every promised page
   now exists and carries a real link (the rule itself stays in DESIGN.md for
   whatever gets promised next) */

/* ============ Encore: gallery teaser (Hard Voltage) ============ */
.encore-band {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
  gap: var(--band-gap); align-items: center;
}
/* 1.25 -> 1.3125rem (21px) 23 Jul, Niko's call; .cs-next-label moves with it —
   the two are the same serif-italic kicker role and were retuned as a pair in
   phase 3 too (a kicker must never be smaller than the body under it). */
.encore-label { font-family: var(--serif); font-style: italic; font-size: 1.3125rem; color: var(--blue-deep); }
.encore-title {
  font-family: var(--serif); font-weight: 800; color: var(--ink);
  font-size: clamp(1.8rem, 3.4vw, 2.9rem); line-height: 1.18; letter-spacing: -0.015em;
  margin: 0.5rem 0 1rem;
}
.encore-text { color: var(--ink-soft); max-width: 30.5rem; }   /* 44ch -> the 487px it rendered at pre-swap */
.encore-media {
  aspect-ratio: 16 / 10; border-radius: 6px; overflow: clip;
  background: linear-gradient(135deg, var(--navy) 0%, var(--stage) 70%);
  display: grid; place-items: center; color: var(--moon-soft); font-weight: 500;
  box-shadow: 0 30px 60px -30px rgba(23, 32, 46, 0.45);
}
@media (max-width: 900px) { .encore-band { grid-template-columns: 1fr; } }

/* ============ About (B2 placeholder; the full section lands in B3) ============ */
/* the shaded-daylight surface returned here 18 Jul r3 (it left with the CS01
   preview spread): banded variation in the day zone, About as its own beat */
#about { background: var(--day-2); }
.about-grid {
  display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(14rem, 1fr);
  gap: var(--gap-spread); align-items: start;
}
.about-text p { max-width: 43rem; color: var(--ink); }   /* 62ch -> 43rem = the 686px it rendered at pre-swap */
.about-text p + p { margin-top: 1rem; }
@media (max-width: 900px) { .about-grid { grid-template-columns: 1fr; } }

/* ============ Footer: back on stage ============ */
.footer {
  position: relative; background: var(--stage); color: var(--moon);
  /* tightened 18 Jul r3: the bottom margin now matches the header's tight
     top spacing, and the freed room came OUT of the footer's height (the
     old 7rem/4rem pads read as dead stage). Top pad shrank again 19 Jul r4:
     the CTA's +0.1 line-height added 21px at 1920, and Niko's rule is the
     footer never grows — the air comes out of the top. */
  padding: clamp(1.7rem, 5vw - 1.1rem, 2.7rem) var(--page-x) clamp(1.2rem, 2vw, 1.6rem);
  overflow: clip;
  /* a quiet edge where the stage floor begins: on the gallery the page and
     the footer share one background, and the smoke veil needs a visible
     boundary to end at (18 Jul); on the day pages the hairline just sits
     inside the light-to-dark cut */
  border-top: 1px solid var(--hairline-dark);
}
/* footer copy sits above the grain (z2) and the smoke canvas (z2): the smoke
   is a veil on the stage floor, never over the words */
.footer-inner { position: relative; z-index: 3; }
.footer .big-cta {
  font-family: var(--serif); font-weight: 800; font-size: clamp(2rem, 5.4vw, 4.4rem);
  /* 18ch -> 21ch -> 13.8em. The cap exists to force the QUESTION to wrap
     ("Got a stage for me in / Finland?") while still containing the email on
     its own line. In `ch` that promise depended on the serif's "0" advance,
     which shrank 17% from Fragment Glare to Neco and left the email overhanging
     its box by 47px; 21ch was the re-derived patch. 13.8em is the same 973px
     the rule rendered at BEFORE the swap, and it satisfies both conditions by
     measurement at 1920: the email is 854px (fits), the unwrapped question
     needs 1014px (still wraps). em, not rem, so the cap tracks the CTA's own
     clamp down the viewport range and the wrap survives. */
  line-height: 1.15; letter-spacing: -0.015em; max-width: 13.8em;
}
.footer .big-cta a {
  text-decoration: none; border-bottom: 3px solid var(--blue);
  /* the email gets its own beat under the question (Niko, 18 Jul r3) */
  display: inline-block; margin-top: 10px;
  /* The email is ONE unbreakable token, so on narrow screens it cannot wrap
     out of trouble: at 412px it ran 16px past the footer's content box (35px
     with the old fonts — PRE-EXISTING, the swap made it smaller, and the
     footer's overflow:clip is what kept it off the page scrollbar). Rather
     than shrink the CTA everywhere, the LINK alone takes whichever is smaller,
     the inherited display size or the size at which it still fits: the address
     measures 12.14x its font-size, and the mobile content box is
     100vw - 2 x 1.25rem, so 8vw - 5px is the fitting size with slack. min()
     means this can only ever engage below ~594px, where the CTA's own clamp
     has bottomed out at 2rem; every desktop and laptop width keeps 100%. */
  font-size: min(100%, 8vw - 5px);
}
.footer .big-cta a:hover { color: var(--blue); }
.footer-meta { display: flex; flex-wrap: wrap; gap: 1rem 2.5rem; margin-top: calc(2.6rem + 10px); color: var(--moon-soft); font-weight: 500; }
.footer-meta a { text-decoration: none; }
.footer-meta a:hover { color: var(--blue); }

/* ==========================================================================
   Case-study pages (template introduced with CS1, B2)
   The page opens directly in daylight: identity moments live on the homepage
   stage; a case study is the reading, and reading happens in the light.
   ========================================================================== */

/* the r3 centered 76rem reading frame was retired 19 Jul r5: with the wider
   64px page margins the CS pages return to LEFT-ALIGNED full content width
   (title + meta band span the content area again), and the extra breathing
   room comes from --page-x 4rem + the 50.5rem measure instead of centering. */

/* opening block: room for the fixed header, then title + meta band */
.cs-hero { padding-top: clamp(7rem, 16vh, 10rem); padding-bottom: clamp(2.5rem, 5vw, 4rem); }
.cs-title {
  font-family: var(--serif); font-weight: 800; color: var(--ink);
  font-size: clamp(2.3rem, 4.6vw, 3.6rem); line-height: 1.16; letter-spacing: -0.015em;
  max-width: 16.9em; margin: 0.5rem 0 0;   /* 22ch -> the 973px it rendered at pre-swap */
}
.cs-meta {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 1.6rem clamp(1.5rem, 3vw, 3rem);
  border-top: 3px solid var(--navy); padding-top: var(--space-doc);
  margin-top: clamp(2rem, 4.5vw, 3.2rem);
}
/* label voice: navy, not gray — the gray uppercase read "depressing"
   (Niko, 18 Jul triage); --navy itself read too dark, so the labels sit a
   step lighter (round 3) */
.cs-meta dt { font-weight: 700; font-size: var(--t-ui); letter-spacing: 0.03em; color: var(--navy-soft); text-transform: uppercase; }
.cs-meta dd { margin: 0.25rem 0 0; font-size: var(--t-lead); line-height: 1.55; color: var(--ink); }
/* dd-scoped so it beats the .cs-meta dd colour above (build-gate review).
   Deep azure, not blue-deep: the payoff line no longer shares its colour
   with every kicker and link (18 Jul triage). */
.cs-meta dd.outcome-line { font-weight: 600; color: var(--azure); }

/* article body: measure-capped prose, quiet heads, wide figures */
.cs-article { padding-top: 0; }
.cs-article .cs-block { margin-top: clamp(3.2rem, 7vw, 5rem); }
.cs-article .cs-block:first-child { margin-top: 0; }
.cs-article h2 {
  font-family: var(--serif); font-weight: 800; color: var(--ink);
  font-size: clamp(1.7rem, 3.4vw, 2.5rem); line-height: 1.2; letter-spacing: -0.015em;
  max-width: 20em; margin-bottom: var(--space-doc);   /* 26ch -> the 799px it rendered at pre-swap */
}
/* prose rules scoped to .cs-block, never .cs-article: the artifact figures are
   .cs-article children too, and an article-wide `p` selector (0,1,1) silently
   beat every single-class af-* paragraph style below — overlines rendered as
   plain ink body text (build-gate review) */
.cs-block p { max-width: var(--measure); color: var(--ink); }
.cs-block p + p { margin-top: 1rem; }
.cs-lead { font-weight: 700; }  /* reflection lead-ins */

/* figures: shipped-site frames and recreated artifacts, wider than the measure */
.cs-figure { margin: clamp(2.2rem, 5vw, 3.5rem) 0 0; max-width: 68rem; }
/* the caption keeps its OWN column. Under the old ch measure this shared
   --measure with the prose but resolved narrower (682px vs 808px) purely
   because the caption text is smaller — an accident of the unit, not a
   decision. 42.65rem preserves the 682px it actually rendered at; revisit
   deliberately if it should match the prose instead. */
.cs-figure figcaption { margin-top: 0.8rem; font-size: var(--t-ui); color: var(--ink-soft); max-width: 42.65rem; }

/* a recreated deliverable reads as a document ON the page: white panel,
   hairline edge, the daylight figure shadow. Card grids inside an artifact
   are the artifact's own content, not site layout grammar. */
.artifact {
  background: #ffffff; border: 1px solid var(--hairline-light); border-radius: 6px;
  padding: clamp(1.4rem, 3vw, 2.4rem);
  box-shadow: 0 30px 60px -30px rgba(23, 32, 46, 0.45);
}
.af-overline { font-weight: 700; font-size: var(--t-label); letter-spacing: 0.02em; color: var(--blue-deep); }
.af-title {
  font-family: var(--serif); font-weight: 700; color: var(--ink);
  font-size: clamp(1.25rem, 2vw, 1.6rem); line-height: 1.3; margin: 0.5rem 0 0.3rem;
}
.af-sub { font-size: var(--t-ui); color: var(--ink-soft); max-width: 37.5rem; }   /* 62ch -> the 598px it rendered at pre-swap */
.af-grid { display: grid; gap: 0.9rem; margin-top: var(--space-doc); }
.af-card {
  background: var(--day); border: 1px solid var(--hairline-light); border-radius: 6px;
  padding: 1.1rem 1.2rem 1.2rem;
}
/* .af-card-scoped so it beats the `.af-card p` size/colour below — the bare
   class lost that cascade and kickers rendered as plain card body text */
.af-card .af-card-kicker { font-weight: 700; font-size: var(--t-fine); letter-spacing: 0.04em; color: var(--blue-deep); }
.af-card h4 { font-family: var(--sans); font-weight: 700; font-size: var(--t-lead); color: var(--ink); margin: 0.35rem 0 0.35rem; line-height: 1.3; }
.af-card p { font-size: var(--t-ui); line-height: 1.5; color: var(--ink-soft); max-width: none; }
.af-card.is-dark { background: var(--navy); border-color: var(--navy); }
/* --blue on --navy is 4.2:1, under AA for this 0.82rem size (audit catch);
   the lifted blue clears it at 6.2:1 without leaving the family */
.af-card.is-dark .af-card-kicker { color: var(--blue-bright); }
.af-card.is-dark h4 { color: var(--moon); }
.af-card.is-dark p { color: var(--moon-soft); }
.af-chips { display: flex; flex-wrap: wrap; gap: 0.45rem; margin-top: 0.8rem; }
.af-chip {
  font-size: var(--t-fine); font-weight: 600; color: var(--moon);
  border: 1px solid var(--blue-deep); border-radius: 999px;
  padding: 0.2rem 0.7rem; background: rgba(46, 143, 221, 0.16);
}
.af-note { font-size: var(--t-label); color: var(--ink-soft); margin-top: 1.1rem; }

/* findings board: 3-wide pattern grid; cards keep their natural heights
   (the tall inverse-question card must not stretch its row-mates) */
.af-findings .af-grid { grid-template-columns: repeat(3, 1fr); align-items: start; }
@media (max-width: 900px) { .af-findings .af-grid { grid-template-columns: 1fr; } }

/* archetype cards: 3-up + synthesis band */
.af-archetypes .af-grid { grid-template-columns: repeat(3, 1fr); }
.af-archetypes .af-metric { font-weight: 600; color: var(--ink); font-size: var(--t-label); margin-top: 0.7rem; }
/* the document's concluding line: same 3px navy top-border grammar as the
   meta rail — the site's established "here is the substance" marker */
.af-synthesis {
  margin-top: var(--space-doc); border-top: 3px solid var(--navy); padding-top: 1rem;
  font-family: var(--serif); font-size: clamp(1.1rem, 1.8vw, 1.3rem); line-height: 1.4; color: var(--ink);
  max-width: 37.9em;   /* 56ch -> the 787px it rendered at pre-swap */
}
/* statement-voice emphasis: browser-default bold (700) resolves to Glare
   ExtraBold, a shout mid-sentence; 600 stays inside the Regular cut */
.af-synthesis strong { font-weight: 600; }
@media (max-width: 900px) { .af-archetypes .af-grid { grid-template-columns: 1fr; } }

/* concept-vs-final: two abstract wireframe panels (greeking bars, no copy) */
.af-concepts .af-grid { grid-template-columns: 1fr 1fr; align-items: start; }
.af-wire { border-radius: 6px; overflow: clip; border: 1px solid var(--hairline-light); }
.af-wire svg { display: block; width: 100%; height: auto; }
.af-wire-label { font-weight: 600; font-size: var(--t-ui); color: var(--ink); margin-top: 0.7rem; }
.af-wire-note { font-size: var(--t-label); color: var(--ink-soft); margin-top: 0.15rem; }
@media (max-width: 700px) { .af-concepts .af-grid { grid-template-columns: 1fr; } }

/* token sheets: type-scale rows + colour rows, dummy brand */
.af-tokens .af-grid { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); align-items: start; }
.af-panel-h { font-weight: 700; font-size: var(--t-ui); color: var(--ink); margin-bottom: 0.7rem; }
.af-type-row {
  display: flex; align-items: baseline; gap: 1rem;
  border-top: 1px solid var(--hairline-light); padding: 0.55rem 0;
}
.af-type-sample { font-weight: 700; color: var(--ink); line-height: 1; }
.af-type-meta { font-size: var(--t-label); color: var(--ink-soft); }
.af-swatch-row { display: flex; align-items: center; gap: 0.8rem; border-top: 1px solid var(--hairline-light); padding: 0.55rem 0; }
.af-swatch { width: 2.1rem; height: 2.1rem; border-radius: 6px; border: 1px solid var(--hairline-light); flex: none; }
.af-swatch-name { font-size: var(--t-label); font-weight: 600; color: var(--ink); }
.af-swatch-role { font-size: var(--t-fine); color: var(--ink-soft); }
@media (max-width: 700px) { .af-tokens .af-grid { grid-template-columns: 1fr; } }

/* one-pager pair: two A4 thumbnails, dark + light variant */
.af-onepagers .af-grid { grid-template-columns: repeat(2, minmax(0, 14rem)); justify-content: start; gap: 1.4rem; }
.af-page { aspect-ratio: 1 / 1.414; border-radius: 6px; overflow: clip; border: 1px solid var(--hairline-light); }
.af-page svg { display: block; width: 100%; height: 100%; }
@media (max-width: 700px) { .af-onepagers .af-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

/* next-case band: honest state — CS2 is in production, the way back is real */
.cs-next-band {
  border-top: 1px solid var(--hairline-light);
  display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
  gap: var(--band-gap); align-items: center;
}
/* "Back to all work" sits bottom-right of the band (r5): flush with the
   content area's right edge, on the same baseline as "Read case study NN"
   (both are the band row's last line, same .spread-link metrics) */
.cs-next-band > div:last-child { justify-self: end; align-self: end; }
.cs-next-label { font-family: var(--serif); font-style: italic; font-size: 1.3125rem; color: var(--blue-deep); }
.cs-next-title {
  font-family: var(--serif); font-weight: 800; color: var(--ink);
  font-size: clamp(1.6rem, 3vw, 2.5rem); line-height: 1.2; letter-spacing: -0.015em;
  margin: 0.5rem 0 0.6rem;
}
@media (max-width: 900px) { .cs-next-band { grid-template-columns: 1fr; } }

/* ==========================================================================
   B3 artifacts (CS2 + CS3). Document depictions keep the dummy teal brand
   (#0e7490 family) for their internal accents; the site's own framing
   (overlines, titles, captions) keeps speaking in the site voice.
   ========================================================================== */

/* screen-reader-only utility (DACI row-header labels) */
.visually-hidden {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* ---- CS2: interview protocol excerpt ---- */
.af-protocol .af-grid { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); align-items: start; gap: var(--space-doc) clamp(1.4rem, 3vw, 2.4rem); }
.af-timeline { list-style: none; margin: 0; }
.af-timeline li {
  display: grid; grid-template-columns: 3.6rem minmax(0, 1fr); gap: 0.8rem;
  border-top: 1px solid var(--hairline-light); padding: 0.55rem 0;
  font-size: var(--t-ui); line-height: 1.5; color: var(--ink-soft);
}
.af-timeline strong { color: var(--ink); font-weight: 700; }
.af-time { font-weight: 700; font-size: var(--t-label); color: #0e7490; letter-spacing: 0.02em; padding-top: 0.1rem; }
.af-quote { border-left: 3px solid #0e7490; padding: 0.2rem 0 0.2rem 1rem; margin-top: 1.1rem; }
.af-quote-text { font-style: italic; font-size: var(--t-ui); color: var(--ink); max-width: none; }
.af-quote-src { font-size: var(--t-label); color: var(--ink-soft); margin-top: 0.45rem; max-width: none; }
.af-probe { border-top: 1px solid var(--hairline-light); padding: 0.55rem 0; }
.af-probe-name { font-weight: 700; font-size: var(--t-label); color: #0e7490; }
.af-probe p:not(.af-probe-name) { font-size: var(--t-label); line-height: 1.5; color: var(--ink-soft); margin-top: 0.15rem; max-width: none; }
@media (max-width: 700px) { .af-protocol .af-grid { grid-template-columns: 1fr; } }

/* ---- CS2: swimlane + DACI pair ---- */
/* the diagram keeps its drawn size and scrolls sideways on small screens
   instead of scaling into illegibility */
.af-scroll { overflow-x: auto; border: 1px solid var(--hairline-light); border-radius: 6px; margin-top: 1.4rem; }
.af-swimlane { display: block; width: 100%; min-width: 760px; height: auto; }
.af-daci-pair { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
.af-daci { width: 100%; border-collapse: collapse; font-size: var(--t-label); }
.af-daci thead th {
  font-weight: 700; font-size: var(--t-micro); letter-spacing: 0.05em; text-transform: uppercase;
  color: var(--ink-soft); text-align: center; padding: 0.35rem 0.2rem;
}
.af-daci thead th:first-child { text-align: left; }
.af-daci tbody th {
  text-align: left; font-weight: 600; font-size: var(--t-fine); color: var(--ink);
  padding: 0.45rem 0.5rem 0.45rem 0; border-top: 1px solid var(--hairline-light);
}
.af-daci td {
  text-align: center; font-weight: 700; color: #0e7490;
  padding: 0.45rem 0.2rem; border-top: 1px solid var(--hairline-light);
}
/* one function wearing two DACI hats at once: the finding, made visible */
.af-daci td.is-two { background: #f9f1e4; box-shadow: inset 0 0 0 2px #c9863a; border-radius: 4px; color: #7a4c14; }
.af-daci td.is-gap { background: #f8eceb; color: #7c2620; font-weight: 600; font-style: italic; font-size: var(--t-fine); border-radius: 4px; }
@media (max-width: 700px) { .af-daci-pair { grid-template-columns: 1fr; } }

/* ---- CS2: six themes into three pillars ---- */
.af-pillars .af-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: stretch; }
.af-pillar-col { display: flex; flex-direction: column; gap: 0.9rem; }
.af-card.is-mini { padding: 0.9rem 1rem 1rem; }
.af-card.is-mini p { font-size: var(--t-label); }
.af-card.is-mini h4 { font-size: var(--t-ui); }
/* the condensation mark: a quiet stem and arrowhead between themes and pillar */
.af-condense { position: relative; height: 1.3rem; margin: 0; flex: none; }
.af-condense::before {
  content: ""; position: absolute; left: 50%; top: 0; bottom: 0.45rem;
  width: 2px; translate: -50% 0; background: var(--hairline-light);
}
.af-condense::after {
  content: ""; position: absolute; left: 50%; bottom: 0; translate: -50% 0;
  border: 5px solid transparent; border-top-color: var(--ink-soft); border-bottom: none;
}
.af-pillar-col .af-card.is-dark { margin-top: 0; flex: 1; }
@media (max-width: 900px) { .af-pillars .af-grid { grid-template-columns: 1fr; } }

/* ---- CS2: the do / skip / later workshop mechanic ---- */
.af-decisions .af-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; }
.af-verdict-col { display: flex; flex-direction: column; gap: 0.9rem; }
.af-verdict { font-weight: 700; font-size: var(--t-ui); display: flex; align-items: center; gap: 0.55rem; }
.af-verdict span {
  display: inline-grid; place-items: center; width: 1.5rem; height: 1.5rem; flex: none;
  border: 2px solid currentColor; border-radius: 50%; font-size: var(--t-fine);
}
.af-verdict.is-do { color: #2f7d4f; }
.af-verdict.is-later { color: #9a6b1f; }
.af-verdict.is-skip { color: #a34840; }
.af-verdict-tally { font-size: var(--t-label); font-style: italic; color: var(--ink-soft); border-top: 1px solid var(--hairline-light); padding-top: 0.6rem; }
@media (max-width: 900px) { .af-decisions .af-grid { grid-template-columns: 1fr; } }

/* ---- CS2: glossary + the three dangerous words ---- */
.af-glossary .af-grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); align-items: start; gap: var(--space-doc) clamp(1.4rem, 3vw, 2.4rem); }
.af-term { border-top: 1px solid var(--hairline-light); padding: 0.55rem 0; }
.af-term-word { font-weight: 700; font-size: var(--t-ui); color: #0e7490; }
.af-term p:not(.af-term-word) { font-size: var(--t-label); line-height: 1.5; color: var(--ink-soft); margin-top: 0.15rem; max-width: none; }
.af-rule { border: 1px solid var(--hairline-light); border-radius: 6px; padding: 0.8rem 0.95rem 0.9rem; margin-top: 0.7rem; }
.af-rule:first-of-type { margin-top: 0; }
.af-rule-word { font-weight: 700; font-size: var(--t-lead); color: var(--ink); }
.af-rule-q { font-weight: 600; font-size: var(--t-label); color: #0e7490; margin-top: 0.1rem; }
.af-rule p:not(.af-rule-word):not(.af-rule-q) { font-size: var(--t-label); line-height: 1.5; color: var(--ink-soft); margin-top: 0.35rem; max-width: none; }
@media (max-width: 700px) { .af-glossary .af-grid { grid-template-columns: 1fr; } }

/* ---- CS3: the 5S plan on one page ---- */
.af-fives .af-grid { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: start; gap: var(--space-doc) clamp(1.4rem, 3vw, 2.4rem); }
.af-phases { list-style: none; margin: 0; }
.af-phases li { border-top: 1px solid var(--hairline-light); padding: 0.7rem 0; }
.af-phase-name { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; font-size: var(--t-ui); color: var(--ink); }
.af-phase-no {
  display: inline-grid; place-items: center; width: 1.45rem; height: 1.45rem; flex: none;
  border-radius: 50%; background: #0e7490; color: #ffffff; font-size: var(--t-fine); font-weight: 700;
}
.af-phase-fi { font-weight: 500; font-style: italic; font-size: var(--t-label); color: var(--ink-soft); }
.af-phases li p:not(.af-phase-name) { font-size: var(--t-label); line-height: 1.5; color: var(--ink-soft); margin: 0.3rem 0 0 2rem; max-width: none; }
.af-freq { display: flex; align-items: center; gap: 0.7rem; border-top: 1px solid var(--hairline-light); padding: 0.55rem 0; font-size: var(--t-label); color: var(--ink-soft); }
.af-freq strong { color: var(--ink); }
/* one hue, dark to light: distance from the hands, encoded and labelled */
.af-freq-dot { width: 0.9rem; height: 0.9rem; border-radius: 50%; flex: none; }
.af-freq-dot.is-1 { background: #0e7490; }
.af-freq-dot.is-2 { background: #5c98a4; }
.af-freq-dot.is-3 { background: #9fb8c0; }
.af-freq-dot.is-4 { background: #d3dde2; border: 1px solid #9fb8c0; }
.af-fives .af-quote { margin-top: 1.1rem; }
@media (max-width: 700px) { .af-fives .af-grid { grid-template-columns: 1fr; } }

/* CS3 before/after sketch: same two-panel geometry as the concept wireframes */
.af-layout .af-grid { grid-template-columns: 1fr 1fr; align-items: start; }
@media (max-width: 700px) { .af-layout .af-grid { grid-template-columns: 1fr; } }

/* ==========================================================================
   Gallery: the encore stays on the stage. Framing text speaks Moonlight;
   the work itself is pixels. No drop shadows here (Stagelight Rule):
   hairline borders separate the black artwork from the black stage.
   ========================================================================== */
.gal-hero {
  position: relative; overflow: clip;
  padding-top: clamp(7rem, 16vh, 10rem); padding-bottom: clamp(1.5rem, 3vw, 2.5rem);
  background: linear-gradient(180deg, var(--stage-2) 0%, var(--stage) 75%);
}
.gal-hero .encore-label { color: var(--blue); }
.gal-title {
  font-family: var(--serif); font-weight: 800; color: var(--moon);
  font-size: clamp(2.3rem, 5.4vw, 4.4rem); line-height: 1.15; letter-spacing: -0.015em;
  margin: 0.5rem 0 0;
}
.gal-intro { max-width: var(--measure); margin-top: clamp(1.2rem, 2.5vw, 1.8rem); }
.gal-intro p { color: var(--moon-soft); }
.gal-intro p + p { margin-top: 0.9rem; }
.gal-attrib { font-size: var(--t-ui); }
.gal-head {
  font-family: var(--serif); font-weight: 800; color: var(--moon);
  font-size: clamp(1.8rem, 3.4vw, 2.9rem); line-height: 1.18; letter-spacing: -0.015em;
  margin-bottom: clamp(1.6rem, 4vw, 2.6rem);
}
.gal-wall { padding-top: clamp(2.5rem, 5vw, 4rem); padding-bottom: clamp(2rem, 4vw, 3rem); }
.gal-strip { margin: 0 0 clamp(1.7rem, 3.2vw, 2.4rem); }
.gal-strip img { width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--hairline-dark); }
.gal-strip figcaption { margin-top: 0.55rem; font-size: var(--t-ui); color: var(--moon-soft); }
.gal-merch { padding-top: 0; }
.gal-merch-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(1.2rem, 2.5vw, 2rem); align-items: start; }
.gal-m { margin: 0; }
/* equal frames, honest pixels (18 Jul triage, applied r3 — r2's log wrongly
   claimed this shipped): all three frames share one aspect so the captions
   land on one baseline; object-fit contain letterboxes on the stage instead
   of cropping anyone's artwork */
.gal-m-frame { aspect-ratio: 6 / 5; border-radius: 6px; overflow: clip; border: 1px solid var(--hairline-dark); }
.gal-m-frame img { width: 100%; height: 100%; object-fit: contain; }
.gal-m figcaption { margin-top: 0.55rem; font-size: var(--t-ui); color: var(--moon-soft); }
.gal-back-line { margin-top: clamp(2.5rem, 6vw, 4rem); }
.gal-back {
  font-family: var(--serif); font-weight: 700; font-size: 1.3rem; color: var(--moon);
  text-decoration: none; border-bottom: 3px solid var(--blue); padding-bottom: 0.1rem;
}
.gal-back:hover { color: var(--blue); }
@media (max-width: 900px) { .gal-merch-grid { grid-template-columns: 1fr; } }
/* a 23:1 band at phone width is an 18px sliver; crop to the centre motif
   instead, the captions carry the specifics. SCREEN-scoped: print pages are
   also narrower than 900px, and paper should get the full strips. */
@media screen and (max-width: 900px) {
  .gal-strip img { height: 3.4rem; object-fit: cover; object-position: center; }
  .gal-strip.is-proof img { height: auto; object-fit: fill; }
}

/* ---- Gallery-page smoke: one veil for the WHOLE page (18 Jul; the
   footer-only smoke cut off unannounced where the identical background
   continued). The body carries the stage gradient so a single fixed canvas
   can sit BEHIND every element: z-index -1 paints above the root background
   but below all in-flow content, so wisps drift under header, text and
   frames alike and never obscure them. gal-hero and the footer go
   transparent so the veil shows through; the footer's hairline top border
   is the one quiet boundary that remains. Wisps ride the glass
   (viewport-fixed) while the page scrolls behind them. */
body.page-gal { background: linear-gradient(180deg, var(--stage-2) 0%, var(--stage) 42rem); }
.page-gal .gal-hero, .page-gal .footer { background: transparent; }
.page-smoke { position: fixed; inset: 0; z-index: -1; pointer-events: none; }

/* ---- Homepage encore montage: four real bands stacked in the frame ---- */
.encore-media.is-montage {
  display: grid; grid-template-rows: repeat(4, minmax(0, 1fr)); gap: 2px;
  place-items: stretch;
}
.encore-media.is-montage picture { display: block; width: 100%; height: 100%; overflow: clip; }
.encore-media.is-montage img { width: 100%; height: 100%; object-fit: cover; object-position: center; }

/* ---- About: the photo joins the text in the daylight ---- */
.about-figure { margin: 0; max-width: 24rem; }
.about-figure img { width: 100%; height: auto; border-radius: 6px; box-shadow: 0 30px 60px -30px rgba(23, 32, 46, 0.45); }
@media (max-width: 900px) { .about-figure { max-width: 19rem; } }

/* ==========================================================================
   404: an empty stage. Static by design; this page loads no script, so
   everything here must hold with CSS alone.
   ========================================================================== */
.lost {
  position: relative; min-height: 100svh; overflow: clip;
  display: grid; place-items: center; text-align: center;
  padding: var(--hero-pad);
  background:
    radial-gradient(120% 85% at 50% -10%, rgba(46, 143, 221, 0.10) 0%, transparent 60%),
    linear-gradient(180deg, var(--stage-2) 0%, var(--stage) 55%);
}
/* one dim pool of light, breathing slightly: sine in-out, the sanctioned
   ambient-sway ease. Reduced motion collapses the animation globally. */
.lost-spot {
  position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(34% 26% at 50% 64%, rgba(232, 238, 247, 0.12) 0%, rgba(232, 238, 247, 0.04) 55%, transparent 78%);
  filter: blur(2px);
  animation: lostbreathe 7s var(--ease-sine) infinite alternate;
}
@keyframes lostbreathe { from { opacity: 0.7; } to { opacity: 1; } }
.lost-inner { position: relative; z-index: 3; }
.lost-kicker { font-weight: 700; font-size: var(--t-lead); letter-spacing: 0.08em; color: var(--blue); }
.lost-title {
  /* capped at the footer-CTA band (4.4rem), the largest sanctioned
     non-hero size: one shout per site, and it lives on the homepage */
  font-family: var(--serif); font-weight: 800; color: var(--moon);
  font-size: clamp(2.6rem, 7vw, 4.4rem); line-height: 1.12; letter-spacing: -0.015em;
  margin-top: 0.5rem;
}
.lost-text { color: var(--moon-soft); max-width: 25rem; margin: 1rem auto 1.8rem; }
.lost-link {
  font-family: var(--serif); font-weight: 700; font-size: 1.3rem; color: var(--moon);
  text-decoration: none; border-bottom: 3px solid var(--blue); padding-bottom: 0.1rem;
}
.lost-link:hover { color: var(--blue); }

/* ============ Reveal defaults: visible without JS ============ */
.reveal { opacity: 1; transform: none; }

/* ============ Reduced motion ============ */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.15s !important; }
  .lights-up, .rays-wrap { display: none; }
  .scroll-cue .cue-arrow { animation: none; opacity: 0.8; }
  /* no .vinyl override needed: the DJ module (the only thing that grants
     .is-deck) never runs under reduced motion */
}

/* ============ Print: browsers drop backgrounds by default, so every
   stage-world (dark) surface flips to ink-on-white instead of leaving its
   moon text invisible on the paper (build-gate review) ============ */
@media print {
  .wave-rail, .scroll-cue, .smoke, .vinyl-wrap, .spot, .rays-wrap, .lights-up,
  .grain::after, .key-media, .staff-draw, .page-smoke, .aperture-ring,
  .lights-over, .page-veil, .veil-cv { display: none; }
  /* these two override html.js-gated screen rules (lines ~359 and ~835), so
     they must carry the same html.js specificity or they silently lose. The
     under-specific originals did lose: the fixed header stamped itself over
     every printed sheet and the -12svh day pull cut the hero name mid-glyph
     (B4 print eyeball catch; print CSS had only ever been code-verified). */
  html.js .site-header { position: absolute; }
  html.js .home-day { margin-top: 0; }
  /* ScrollTrigger's pin is ACTIVE at scroll 0, so the hero carries inline
     position:fixed at print time and Chrome repeats fixed boxes on every
     sheet (the copy showed wherever the day zone was not covering it: the
     doubled, cut hero of the B4 print eyeball). The spacer's inline padding
     is the pin's scroll runway, pure blank on paper. !important because the
     opponent is an inline style. */
  .pin-spacer { padding: 0 !important; height: auto !important; }
  html.js .hero { position: relative !important; inset: auto !important;
    width: auto !important; max-width: none !important;
    transform: none !important; max-height: none !important; }
  #about { background: #ffffff; }
  body, body.page-gal, .footer, .hero { background: #ffffff; color: var(--ink); }
  .site-header[data-on="stage"], .hero-greeting, .hero-statement, .hero-fields,
  .footer .big-cta, .footer-meta { color: var(--ink); }
  .af-card.is-dark { background: var(--day); border-color: var(--hairline-light); }
  .af-card.is-dark h4, .af-card.is-dark p { color: var(--ink); }
  .af-card.is-dark .af-card-kicker { color: var(--blue-deep); }
  .af-chip { color: var(--ink); border-color: var(--ink-soft); background: none; }
  .encore-media { display: none; }
  /* B3 stage-world pages: gallery + 404 flip to ink on paper */
  .gal-hero, .gal-wall, .gal-merch, .lost { background: #ffffff; color: var(--ink); }
  .gal-title, .gal-head, .lost-title, .gal-back, .lost-link { color: var(--ink); }
  .gal-intro p, .gal-strip figcaption, .gal-m figcaption, .lost-text { color: var(--ink-soft); }
  .gal-hero .encore-label, .lost-kicker { color: var(--blue-deep); }
  .gal-strip img, .gal-m-frame { border-color: var(--hairline-light); }
  .lost-spot { display: none; }
  /* teal-background badges lose their backgrounds on paper */
  .af-phase-no { color: var(--ink); }
  .af-freq-dot { border: 1px solid var(--ink-soft); }
  /* the motion layer parks unrevealed sections at inline opacity 0 / y 36;
     paper gets everything, scrolled-to or not. [data-load] and .hero-inner
     joined at the B4 print eyeball: the load entrance parks [data-load] at
     opacity 0 / yPercent 120 (main.js line ~92) and the zoom scrub fades
     .hero-inner (line ~315), so a print mid-pin, or with the entrance never
     run, shipped the hero copy as invisible text. */
  .cs-article .cs-block, .cs-article .cs-figure, .about-grid > *,
  .encore-band > *, .cs-next-band > *, .section-head, .keyboard .key,
  .keyboard .black-key, .gal-head, .gal-strip, .gal-merch-grid > *, .gal-back-line,
  [data-load], .hero-inner {
    opacity: 1 !important; transform: none !important; translate: none !important;
  }
}
