/* =============================================================================
   WHIM Management — design system + site styles
   Dark, cinematic, futuristic-minimalist. Near-black canvas, large elevated
   rounded cards, one saturated accent, fast subtle motion.
   Token reference lives in product_requirement_document.md §6.
   ========================================================================== */

/* ------------------------------------------------------------------- fonts */
/* Self-hosted, latin subset, variable weight — one file per family. Serving
   these from the same origin removes two third-party round trips before first
   paint, and `font-display: swap` against a same-origin file that arrives with
   the CSS means the fallback is on screen for a frame or two at most, rather
   than for the length of a DNS + TLS handshake to fonts.gstatic.com. That
   handshake was the site's entire cumulative layout shift. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("../fonts/inter-var-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url("../fonts/space-grotesk-var-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ------------------------------------------------------------------ tokens */
:root {
  /* colour ladder */
  --canvas: #0a0a0c;
  --canvas-deep: #060608;
  --surface: #121216;
  --surface-2: #17171d;
  --surface-hover: #22222a;
  --line: #26262e;
  --line-strong: #33333d;

  --ink: #ffffff;
  --ink-2: #a8a8b6;
  --ink-3: #80808e;

  /* THE accent, and it is blue since M75 (the client's own pair: Bright Blue
     #0087BF, Light Blue #A4CEDA). Contrast, measured against the surfaces each
     one actually lands on: --accent on --canvas is 4.89:1, --accent-soft is
     11.6:1, and --accent-ink on an --accent fill is 4.89:1 — so accent TEXT is
     still legal at body size, and the pill still takes dark ink. --accent-2 is
     the third gradient stop (what --gold was to the red): the same blue pushed
     light and cyan, used only in gradients, flares and the particle palette. */
  --accent: #0087bf;
  --accent-hover: #17a4dc;
  --accent-soft: #a4ceda;
  --accent-ink: #0a0a0c;
  --accent-2: #3fd3f2;
  /* The same three as bare CHANNELS. Every tint in this file used to carry a
     literal rgba(var(--accent-rgb), …) because CSS cannot pull channels out of a hex,
     and that is precisely how a recolour leaves a stray red glow behind. Every
     one of them is now rgba(var(--accent-rgb), …) — the palette moves from
     here, once. */
  --accent-rgb: 0, 135, 191;
  --accent-soft-rgb: 164, 206, 218;
  --accent-2-rgb: 63, 211, 242;
  --ok: #3ed598;
  --err: #ff6b6b;

  /* type
     The two self-hosted webfonts are LATIN-ONLY subsets, which is exactly why
     first paint is free. That makes the CJK tail below load-bearing rather than
     decorative: in 中文 or 日本語 every glyph falls straight past Space Grotesk
     and Inter, and without a named CJK family after them the page renders tofu
     on any machine whose default sans lacks the range. Ordered per-OS —
     Apple, then Windows, then Android/Linux/Noto. */
  --font-cjk: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "PingFang SC",
    "Yu Gothic UI", "Yu Gothic", "Microsoft YaHei", Meiryo,
    "Noto Sans JP", "Noto Sans SC", "Source Han Sans", sans-serif;
  --font-display: "Space Grotesk", "Inter", ui-sans-serif, system-ui, -apple-system,
    "Segoe UI", Roboto, Helvetica, Arial, var(--font-cjk);
  --font-body: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
    Helvetica, Arial, var(--font-cjk);

  --fs-display-xl: clamp(2.75rem, 7vw, 5.25rem);
  --fs-display: clamp(2rem, 4.4vw, 3.25rem);
  --fs-h3: clamp(1.25rem, 2vw, 1.625rem);
  --fs-h4: 1.0625rem;
  --fs-body-lg: clamp(1.0625rem, 1.4vw, 1.1875rem);
  --fs-body: 1rem;
  --fs-sm: 0.875rem;
  --fs-eyebrow: 0.75rem;
  --fs-stat: clamp(2.5rem, 5.5vw, 4rem);

  /* space — 4px scale */
  --s-1: 4px;  --s-2: 8px;  --s-3: 12px; --s-4: 16px; --s-5: 24px;
  --s-6: 32px; --s-7: 48px; --s-8: 64px; --s-9: 96px; --s-10: 128px;

  /* 20px floor, raised from 16 (M38): at 360–390px the 16px minimum read as
     cards touching the screen edge. 5vw hands over from the floor at 400px. */
  --gutter: clamp(20px, 5vw, 40px);
  /* The full-bleed works gallery's OUTER gutter (FR-19b, M45): deliberately a
     fraction of --gutter so the photographs run nearly edge to edge — 24px on
     a desktop, never under M38's 20px floor on a phone. */
  --works-gutter: clamp(20px, 2vw, 24px);
  --section-y: clamp(64px, 9vw, 128px);
  --card-pad: clamp(20px, 3.4vw, 40px);
  --maxw: 1240px;
  --nav-h: 68px;

  /* shape */
  --r-pill: 999px;
  --r-card: 24px;
  --r-tile: 16px;
  --r-inner: 10px;

  /* motion */
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  /* The works zoom flight (FR-25b): near-expo out — all the distance is spent
     early, the landing is a glide. */
  --ease-zoom: cubic-bezier(0.16, 1, 0.3, 1);
  --t-fast: 160ms;
  --t-mid: 300ms;
  --t-slow: 520ms;
  /* The capability takeover's horizontal move. One token because THREE things
     have to travel for exactly the same duration or the illusion breaks: the
     takeover coming in, the services stage going out, and the visibility swap
     that takes the closed takeover out of the hit test. main.js §11 reads it
     too, so the node is put back only after the motion has finished. */
  --t-take: 360ms;
}

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

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

body {
  margin: 0;
  background: var(--canvas);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--fs-body);
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}

body.is-locked { overflow: hidden; }

img, svg { display: block; max-width: 100%; }
h1, h2, h3, h4, p, figure, ul, ol { margin: 0; }
ul, ol { padding: 0; list-style: none; }
button, input, select, textarea { font: inherit; color: inherit; }
a { color: inherit; text-decoration: none; }

h1, h2, h3, h4, .stat__num, .logo__text {
  font-family: var(--font-display);
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
}
:focus:not(:focus-visible) { outline: none; }

::selection { background: var(--accent); color: var(--accent-ink); }

/* ----------------------------------------------------------------- helpers */
.container {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  top: var(--s-2); left: var(--s-2);
  z-index: 200;
  transform: translateY(-160%);
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
  padding: var(--s-3) var(--s-5);
  border-radius: var(--r-pill);
  transition: transform var(--t-fast) var(--ease);
}
.skip-link:focus { transform: translateY(0); }

.eyebrow {
  display: inline-block;
  font-family: var(--font-display);
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent-soft);
  margin-bottom: var(--s-4);
}

.lede {
  font-size: var(--fs-body-lg);
  color: var(--ink-2);
  max-width: 62ch;
}

.section {
  padding-block: calc(var(--section-y) / 2);
  scroll-margin-top: calc(var(--nav-h) + 24px);
}

.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  padding: var(--card-pad);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}

.section__head { margin-bottom: var(--s-6); }
.section__head h2 {
  font-size: var(--fs-display);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.025em;
}
.section__head .lede { margin-top: var(--s-4); }

/* ----------------------------------------------------------------- buttons */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  padding: 14px var(--s-5);
  border: 1px solid transparent;
  border-radius: var(--r-pill);
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: 600;
  letter-spacing: 0.01em;
  cursor: pointer;
  white-space: nowrap;
  transition: background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease),
    color var(--t-fast) var(--ease), opacity var(--t-fast) var(--ease);
}
.btn--primary { background: var(--accent); color: var(--accent-ink); }
.btn--primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
.btn--ghost { background: transparent; border-color: var(--line-strong); color: var(--ink); }
.btn--ghost:hover { background: var(--surface-hover); border-color: var(--ink-3); transform: translateY(-1px); }
.btn--sm { padding: 10px var(--s-4); }
.btn[disabled] { opacity: 0.6; cursor: not-allowed; transform: none; }

.btn__spinner {
  width: 15px; height: 15px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.link-accent {
  color: var(--accent-soft);
  font-weight: 500;
  border-bottom: 1px solid rgba(var(--accent-soft-rgb), 0.35);
  transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.link-accent:hover { color: var(--ink); border-color: var(--ink); }

/* --------------------------------------------------------------- nav/header */
.header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  height: var(--nav-h);
  display: flex;
  align-items: center;
  background: transparent;
  border-bottom: 1px solid transparent;
  transition: background-color var(--t-mid) var(--ease),
    border-color var(--t-mid) var(--ease), backdrop-filter var(--t-mid) var(--ease);
}
.header.is-stuck {
  background: rgba(10, 10, 12, 0.82);
  backdrop-filter: blur(14px) saturate(140%);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  border-bottom-color: var(--line);
}
/* Put aside while the capability takeover is open. §11 sets this on EVERY body
   child except the takeover itself, alongside the `inert` attribute — the class
   is the fallback for browsers with no `inert`.

   Sweeping every sibling rather than naming a few is deliberate. The side panel
   this replaced tried to shield the page with a veil, and a veil cannot: it was
   a child of #svc-stage, sealed in that section's stacking context, so it could
   never paint over the fixed z-index:100 header, and it only spanned the right
   column, so it never covered the copy card's three rail links. Both sat
   outside the focus trap, fully clickable, over an open dialog. */
.is-behind-modal { pointer-events: none; }
.header.is-behind-modal {
  opacity: 0.3;
  transition: opacity var(--t-mid) var(--ease-out);
}
.header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-5);
}

.logo { display: inline-flex; align-items: center; border-radius: var(--r-inner); }
.logo img { height: 30px; width: auto; transition: opacity var(--t-fast) var(--ease); }
.logo:hover img { opacity: 0.75; }
.footer .logo img { height: 34px; }

.nav { display: flex; align-items: center; gap: var(--s-1); }
.nav__link {
  position: relative;
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm);
  font-weight: 500;
  color: var(--ink-2);
  transition: color var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
.nav__link:hover { color: var(--ink); background: var(--surface-hover); }
.nav__link[aria-current="true"] { color: var(--ink); }
.nav__link[aria-current="true"]::after {
  content: "";
  position: absolute;
  left: 50%; bottom: 2px;
  width: 16px; height: 2px;
  transform: translateX(-50%);
  background: var(--accent);
  border-radius: var(--r-pill);
}

.header__cta { margin-left: var(--s-3); }

/* -- language switcher ---------------------------------------------------- */
/* A native <select>: keyboard, screen readers and every mobile OS already know
   what to do with one, and it survives having no JS (it simply does nothing).
   Stays visible at every width — the nav and the CTA hide below 900px, the
   language control must not. */
.lang { position: relative; display: inline-flex; }
.lang__select {
  appearance: none;
  -webkit-appearance: none;
  padding: 8px 30px 8px var(--s-4);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-pill);
  background: var(--surface-2);
  color: var(--ink-2);
  font-family: inherit;
  font-size: var(--fs-sm);
  line-height: 1.2;
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease),
    color var(--t-fast) var(--ease);
}
.lang__select:hover { background: var(--surface-hover); border-color: var(--ink-3); color: var(--ink); }
/* The list itself is drawn by the OS, which uses the page's colours for neither
   — name them so the open menu is not white-on-white in a dark UI. */
.lang__select option { background: var(--surface); color: var(--ink); }
.lang::after {
  content: "";
  position: absolute;
  right: 13px; top: 50%;
  width: 6px; height: 6px;
  margin-top: -4px;
  border-right: 1.5px solid var(--ink-3);
  border-bottom: 1.5px solid var(--ink-3);
  transform: rotate(45deg);
  pointer-events: none;
}

.burger {
  display: none;
  width: 42px; height: 42px;
  align-items: center;
  justify-content: center;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-inner);
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.burger:hover { background: var(--surface-hover); border-color: var(--line-strong); }
.burger__box { position: relative; width: 18px; height: 12px; }
.burger__box span {
  position: absolute;
  left: 0;
  width: 100%; height: 2px;
  background: var(--ink);
  border-radius: 2px;
  transition: transform var(--t-mid) var(--ease), opacity var(--t-fast) var(--ease);
}
.burger__box span:nth-child(1) { top: 0; }
.burger__box span:nth-child(2) { top: 5px; }
.burger__box span:nth-child(3) { top: 10px; }
.burger[aria-expanded="true"] .burger__box span:nth-child(1) { transform: translateY(5px) rotate(45deg); }
.burger[aria-expanded="true"] .burger__box span:nth-child(2) { opacity: 0; }
.burger[aria-expanded="true"] .burger__box span:nth-child(3) { transform: translateY(-5px) rotate(-45deg); }

.mobile-menu {
  position: fixed;
  inset: 0;
  z-index: 99;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--s-2);
  padding: calc(var(--nav-h) + var(--s-5)) var(--gutter) var(--s-7);
  background: rgba(6, 6, 8, 0.97);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-8px);
  /* visibility flips instantly on open (0s, no delay) and only after the fade
     on close (0s, delayed) — transitioning it would leave the panel computed
     `hidden` on the frame we try to move focus into it. */
  transition: opacity var(--t-mid) var(--ease), transform var(--t-mid) var(--ease),
    visibility 0s linear var(--t-mid);
}
.mobile-menu.is-open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition: opacity var(--t-mid) var(--ease), transform var(--t-mid) var(--ease),
    visibility 0s linear 0s;
}
.mobile-menu a {
  display: block;
  padding: var(--s-4) var(--s-4);
  border-radius: var(--r-tile);
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 7vw, 2.25rem);
  font-weight: 700;
  letter-spacing: -0.02em;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.mobile-menu a:hover { background: var(--surface-hover); }
.mobile-menu .btn { margin-top: var(--s-5); align-self: flex-start; font-size: var(--fs-body); }

/* -------------------------------------------------------------------- hero */
/* The hero is the globe (PRD FR-15i, M85): main.js §9g's scene — the land-lit
   Earth with the seven market pins, turned by the scroll and shattered into
   the official wordmark — on the sticky stage the old bloom scene occupied.
   `--hero-p` runs 0 → 1 as the hero scrolls past; main.js §9b writes it once
   per frame (plus `--hero-g`, the eased drift below), and the `0` declared
   here is the composition that paints with JavaScript disabled, blocked or
   still parsing. Only `transform` and `opacity` ever appear on the right-hand
   side, so no scroll position can cost layout. The beats, every one of them a
   pure function of p (§9g owns the numbers; these are the shape):

     0    → 0.10  the turn: the globe swings west and settles on Asia (118°E),
                  the seven pins fading up as it lands
     0.10 → 0.34  the hold: dead still, pins readable. The copy scrolls away
                  above it and the scene drifts from the copy's column to the
                  centre of the viewport (--hero-g, 0.08 → 0.26)
     0.34 → 0.72  the break: the pins fade first (0.28 → 0.34), then every
                  particle flies to a pixel of the wordmark
     0.73 → 0.78  the landing: the crisp vector fades in over the particles
     0.75 → 1     the hand-off: the section's bottom edge rises through the
                  stage (--hero-cut fades the scene ahead of it) and the
                  manifesto's band covers the landed mark from ~0.86 */
.hero {
  --hero-p: 0;
  --hero-g: 0;
  /* Where the hero's bottom edge currently falls inside the sticky scene box,
     as a percentage of that box (§9b writes it; see .hero__bg's mask). ON THE
     SECTION, not on .hero__bg: a custom property declared on the element
     itself beats the inherited one, and the pre-M85 hero declared its default
     on the box — so the mask read 200% for its whole life and the vignette
     layer did the hand-off. 200% keeps the mask fully opaque until measured. */
  --hero-cut: 200%;
  /* One viewport at least: the stage box is 100lvh tall and the globe sits at
     its centre, so a shorter section (the still states — reduced motion, no
     WebGL, no JS — have no scroll budget) would clip the globe at its own
     bottom edge. */
  min-height: 100lvh;
  /* Geometry the scene and the copy SHARE, so the two columns can never
     disagree: the container's content width, of which the copy takes 60% and
     the globe column 40% — and the wordmark's landed width, which is also the
     sphere's scale (§9g: R = 0.42 × mark width) and the poster's size. */
  --hero-cw: min(var(--maxw), 100vw);
  --hero-content: calc(var(--hero-cw) - 2 * var(--gutter));
  --hero-logo-w: clamp(280px, calc(var(--hero-content) * 0.45), 620px);
  position: relative;
  padding-bottom: clamp(40px, 6vw, 80px);
  isolation: isolate;
  /* `clip`, not `hidden`, and the difference is load-bearing: `hidden` would
     make the hero a scroll container, which silently turns the sticky stage
     below back into a static box. `clip` keeps the scene inside the section
     without creating one. Browsers too old for `clip` fall back to `hidden` and
     get the stage pinned to the top of the hero — the pre-scrub composition. */
  overflow: hidden;
  overflow: clip;
}
/* The scroll budget: 300vh of scrub beyond the copy's own viewport, only where
   the scene actually runs — WebGL built (§9g adds js-hero3d synchronously, so
   the height is settled before first paint and nothing below the fold shifts
   later) AND motion allowed. Without either the hero is exactly as tall as its
   copy: a still frame needs no scroll to play. The cue lives at the foot of
   the FIRST viewport for the same reason — and only here, where there is a
   scrub to invite. */
@media (prefers-reduced-motion: no-preference) {
  html.js-hero3d .hero { min-height: calc(100lvh + 300vh); }
  html.js-hero3d .hero__cue { display: flex; }
}
/* Zero-height so it takes no space in flow, sticky so the scene it carries
   holds the viewport for as long as the hero owns the screen. Without this the
   globe scrolls out of frame by p≈0.3 and the break plays where nobody can
   see it. Sticky ONLY where the scrub runs: a still frame (no WebGL, no JS,
   reduced motion) is a picture beside — or, on a phone, below — the copy,
   and it has to scroll away WITH the copy. Pinned, the phone's globe would
   sit at the same place below the fold on every frame and never come up. */
.hero__stage {
  position: relative;
  height: 0;
  z-index: -1;
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  html.js-hero3d .hero__stage { position: sticky; top: 0; }
}
.hero__bg {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 100vh;
  height: 100lvh;             /* the large viewport, so a retracting mobile URL
                                 bar cannot open a strip of bare canvas */
  overflow: hidden;
  pointer-events: none;
}
/* A viewport-tall scene inside a shorter section gets guillotined by the
   hero's own bottom edge — a hard horizontal seam straight through the globe
   or the landed mark, which is worse than no scene at all. §9b writes
   `--hero-cut` (on .hero — see there): where that edge currently sits, as a
   percentage of this box. The scene fades out over the 20% above it, so the
   section boundary is always a falloff, never a cut. PINNED scenes only: a
   still frame (no WebGL, no JS, reduced motion — the stage is not sticky
   there) scrolls away with the copy and needs no curtain. */
@media (prefers-reduced-motion: no-preference) {
  html.js-hero3d .hero__bg {
    mask-image: linear-gradient(to bottom, #000 calc(var(--hero-cut) - 20%), transparent var(--hero-cut));
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(var(--hero-cut) - 20%), transparent var(--hero-cut));
  }
}
/* The scene's box: the globe column on wide screens, the full width on a
   phone (the max-width block below). Its CENTRE is what matters — §9g draws
   the sphere and lands the mark at the box's centre, 0.82 of the content in
   from the container's left edge — and the box is wider than the 40% column
   on purpose (0.64 of the content: 0.10 over the copy's column on the left,
   0.14 past the container on the right, clipped by .hero), so the pin fan
   and the break's swell are never cut at a column edge. The canvas paints
   nothing but the scene, so the overhang under the copy column is
   transparent — and the sphere's own limb stays ~18px clear of the copy
   column's right edge at every width (FR-15c is measured on pixels, and a
   land sprite on the limb under the headline's box read 1.95:1 at 0.80). */
.hero__globe {
  position: absolute;
  top: 0;
  height: 100%;
  left: calc(50vw - var(--hero-cw) / 2 + var(--gutter) + var(--hero-content) * 0.50);
  width: calc(var(--hero-content) * 0.64);
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  /* The drift (--hero-g, §9b): from the column's centre to the viewport's —
     exactly −0.32 × content, which lands the box's centre on 50vw at every
     width (box centre = 50vw + 0.32·container − 0.64·gutter = 50vw +
     0.32·content) — once the copy has scrolled away, so the hold and the
     break play centre-stage. Transform only, eased in JS. */
  transform: translate3d(calc(var(--hero-g) * var(--hero-content) * -0.32), 0, 0);
}
.hero__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
/* The mark: the landing target and the crisp ending. Invisible until the
   particles have arrived (they fade out underneath it in the shader over the
   same window), never a mask — no filter (FR-64). `position: relative` puts
   it ABOVE the absolute canvas: a positioned later sibling paints over a
   positioned earlier one. Without js-hero3d there are no particles to land,
   so it stays hidden and the poster below carries the frame. */
.hero__mark {
  position: relative;
  width: var(--hero-logo-w);
  height: auto;
  opacity: 0;
}
html.js-hero3d .hero__mark { opacity: clamp(0, calc((var(--hero-p) - 0.73) / 0.05), 1); }
/* No scene: the baked poster of the settled frame (assets/img/hero-globe.jpg,
   tools/gen-hero-poster.mjs — the live scene rendered at p = 0.30, cropped to
   1.24 mark-widths around the globe), sized by the SAME token as the live
   mark so the globe is the same size either way. `no-js` is the markup's
   truth (the head script removes it before first paint); `js-nogl` is §9g's
   verdict when no context can be made, the mark cannot be sampled, or the
   context is lost. Neither class ⇒ the file is never requested. */
html.no-js .hero__globe,
html.js-nogl .hero__globe {
  background: url("../img/hero-globe.jpg") 50% 50% / calc(var(--hero-logo-w) * 1.24) auto no-repeat;
}
/* The bubble physics canvas (main.js §9c) — PARKED (HERO_BUBBLES = false,
   FR-15f). Sized to fill the stage box, absolutely positioned so it costs no
   layout, inert to input and assistive tech. `display: none` until JS mounts
   it, so no visitor pays for a canvas nothing draws into. */
.hero__bubbles {
  display: none;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
html.js-bubbles .hero__bubbles { display: block; }

/* The copy's guarantee (FR-15c). The globe lives in its own column, so the
   canvas behind the text is bare --canvas — but the pin fan and the break's
   swell reach past the column, and a phone stacks the two. The scrim keeps
   the copy's column dark at every width, painted LAST in the box, and gone
   before the globe column begins: it is a floor, not part of the scene. It
   lets go on --hero-g — the drift carries the globe INTO the copy's column
   once the copy has scrolled away, and a static scrim would sit on it there
   (it did: the landed mark read grey on its left half). */
.hero__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg,
    var(--canvas) 0%, rgba(10, 10, 12, 0.92) 30%, rgba(10, 10, 12, 0.55) 46%,
    rgba(10, 10, 12, 0.12) 56%, transparent 62%);
  opacity: calc(1 - var(--hero-g));
}
/* Copy rises with the scrub, and fades — but only to 0.75. At that floor the
   subhead's --ink-2 still measures 5.07:1 against --canvas, so there is no
   scroll position at which the hero's own text fails AA (PRD FR-15c). Lowering
   this number is a contrast regression, not a taste call.

   Two columns, everything in the first: the headline wraps INSIDE the copy's
   60% and never runs under the globe, whatever the viewport. */
.hero__copy {
  /* The hero's top padding lives here rather than on .hero, so the sticky stage
     starts flush with the top of the section instead of below the nav gap. */
  padding-top: calc(var(--nav-h) + clamp(48px, 10vw, 112px));
  display: grid;
  grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
  transform: translate3d(0, calc(var(--hero-p) * -40px), 0);
  opacity: calc(1 - var(--hero-p) * 0.25);
}
.hero__copy > * { grid-column: 1; }
/* M90: beside the globe (768px and up) the copy centres in the FIRST
   viewport, the same box the globe is centred in. A fixed top padding read
   fine on a landscape screen and pinned the headline to the top third of a
   tall one (880×1180), the globe floating alone below it. Equal top and
   bottom padding keeps the centre the viewport's own and clears the fixed nav;
   copy taller than the box simply grows it. The phone keeps its top-anchored
   copy: its globe is parked BELOW the copy, not beside it (FR-15i). */
@media (min-width: 768px) {
  .hero__copy {
    --hero-pad-y: calc(var(--nav-h) + clamp(16px, 4vh, 48px));
    min-height: 100vh;
    min-height: 100lvh;
    box-sizing: border-box;
    padding-top: var(--hero-pad-y);
    padding-bottom: var(--hero-pad-y);
    align-content: center;
  }
}

/* Scoped, and temporary: main.js §9b adds .is-scrubbing while the hero is on
   screen and drops it the moment it leaves, so the composited layers are not
   held in memory for the whole of the rest of the page. */
.hero.is-scrubbing .hero__globe,
.hero.is-scrubbing .hero__copy { will-change: transform, opacity; }

.hero__badge {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-4);
  margin-bottom: var(--s-5);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm);
  color: var(--ink-2);
  justify-self: start;
}
.hero__badge .dot {
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16);
}

/* M36: wordmark scale — the headline IS the hero's voice, tighter leading and
   tracking than --fs-display-xl. M85 took one step down from 6.5rem so it
   wraps to four lines inside the 60% column beside the globe rather than
   five: "We connect / brands with the / fans who drive / Southeast Asia." */
.hero h1 {
  font-size: clamp(3rem, 7.2vw, 5.5rem);
  font-weight: 700;
  line-height: 0.94;
  letter-spacing: -0.04em;
  max-width: 14ch;
}
.hero h1 .grad {
  background: linear-gradient(96deg, var(--accent-soft) 6%, var(--accent) 42%, var(--accent-2) 96%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.hero__sub {
  margin-top: var(--s-5);
  font-size: var(--fs-body-lg);
  color: var(--ink-2);
  max-width: 56ch;
}
.hero__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  margin-top: var(--s-6);
}

/* M36's technical register: three verified facts as uppercase micro-labels
   over a hairline, in the landonorris.com idiom. --ink-2, not --ink-3: these
   sit inside the copy column, whose floor is AA at every scroll position
   (FR-15c) — the letter-spacing does the "small print" work instead. */
.hero__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-6);
  margin-top: var(--s-7);
  padding-top: var(--s-4);
  border-top: 1px solid var(--line);
  list-style: none;
  max-width: 56ch;
}
.hero__meta li {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ink-2);
  white-space: nowrap;
}
.hero__meta li::before {
  content: "/ ";
  color: var(--accent);
  letter-spacing: 0;
}

/* The scroll cue: a label and a hairline that breathes. transform-only
   keyframe — never on the scroll path, frozen wholesale by the reduced-motion
   block. Shown only where the scrub runs (the media block above: js-hero3d
   and motion), at the foot of the first viewport — the hero is four
   viewports tall there, so `bottom` would put it under the manifesto. Hidden
   under 768px, where the hero is cramped and scrolling needs no invitation. */
.hero__cue {
  position: absolute;
  left: 50%;
  top: calc(100lvh - 88px);
  transform: translateX(-50%);
  display: none;
  flex-direction: column;
  align-items: center;
  gap: var(--s-2);
  pointer-events: none;
  z-index: 1;
}
.hero__cue-label {
  font-size: 0.66rem;
  font-weight: 600;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.hero__cue-line {
  width: 1px;
  height: 44px;
  background: linear-gradient(to bottom, var(--ink-3), transparent);
  overflow: hidden;
  position: relative;
}
.hero__cue-line::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background: linear-gradient(to bottom, var(--accent), transparent);
  animation: cue-drop 2.2s var(--ease) infinite;
}
@keyframes cue-drop {
  from { transform: translateY(-100%); }
  to   { transform: translateY(100%); }
}

/* -------------------------------------------------------------- manifesto */
/* M36's centrepiece (PRD FR-15g, main.js §9d): a short manifesto pinned for
   a stretch of scroll while its words go dim → bright with progress. The
   section buys the scroll with `height` (the sticky-stage rule: real layout
   height, never padding tricks), the inner band is sticky for the
   difference, and §9d writes exactly ONE number here — `--mf-p` — per frame.
   Each word's opacity is a calc() off that variable and its own index
   (--wi), so lighting thirty words costs one custom-property write and
   paint, no per-word JS, no filter, nothing on FR-64's banned list.

   The DIM state hangs off html.js-manifesto, which §9d adds only past its
   guards (JS present, motion allowed, >=768px) — same contract as
   html.js-reveals: the hidden state must never be reachable without the JS
   that animates out of it. Base rules below are the no-JS truth: a plain,
   fully bright statement in a viewport-tall band. */
.manifesto {
  position: relative;
  background: var(--canvas);
}
.manifesto__pin {
  display: flex;
  align-items: center;
  min-height: 100vh;
}
.manifesto__kicker {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: var(--s-5);
}
.manifesto__kicker::before {
  content: "/ ";
  color: var(--accent);
  letter-spacing: 0;
}
.manifesto__line {
  font-family: var(--font-display);
  font-size: clamp(2.5rem, 6.2vw, 5rem);
  font-weight: 700;
  line-height: 1.06;
  letter-spacing: -0.03em;
  color: var(--ink);
  max-width: 22ch;
}
.manifesto__words { display: block; }
/* Each split word: --wi is its index, --wn the total (both set by §9d).
   A word starts lighting when the sweep front (--mf-p mapped across the
   token count plus a ~3-word ramp) reaches its index, and takes the ramp's
   width of progress to reach full ink — a travelling gradient, not a
   page-wide crossfade. Floor 0.14: dark enough to read as "not yet", light
   enough that the line's shape is visible from the moment it pins. The
   spans are aria-hidden (the audited, screen-read copy is the sr-only
   sibling), and every word ends at opacity 1 anyway. */
.mf-w--accent { color: var(--accent); }

@media (prefers-reduced-motion: no-preference) and (min-width: 768px) {
  /* The pin — and the dim state itself — exist only where the scrub is
     allowed to run: 100vh of band plus 130vh of scrubbed travel. Guarded by
     the same media terms as §9d's own init checks, so a preference or width
     change AFTER the class landed (§9d checks once, at init) degrades to
     static bright text rather than words frozen mid-dim. */
  /* The floor is a VARIABLE, and it defaults to 1 — fully bright (M76, Q17).
     The dim is a scroll-driven state, so it must not exist before the scroll
     that animates out of it: the same contract as `html.js-manifesto` itself,
     one step further. §9d latches --mf-floor to 0.14 the instant the scroll
     exists or the band is in view, and at rest the section sits entirely
     below the fold (y = 1003 at the 1350 × 940 desktop preset), so nothing a
     visitor can see changes. What does change is that axe stops scoring
     thirteen 80px words at 1.41:1 on a page nobody has scrolled — Q17, open
     since the scrub shipped. Substituting floor = 1 collapses the whole
     expression to 1; floor = 0.14 reproduces the original exactly. */
  html.js-manifesto .mf-w {
    opacity: calc(var(--mf-floor, 1) + (1 - var(--mf-floor, 1)) * clamp(0,
      (var(--mf-p, 0) * (var(--wn, 1) + 3) - var(--wi, 0)) / 3, 1));
  }
  /* M47: + --mf-pan, the FR-36q slide budget §10 publishes on the root. The
     extra height keeps the band PINNED through the camera pan to Services —
     the sweep itself still spans only the original 130vh (§9d subtracts the
     pan from its range), so the words are fully lit, hold their beat, and
     then the whole band exits stage-left instead of ever scrolling upward. */
  html.js-manifesto .manifesto { height: calc(230vh + var(--mf-pan, 0px)); }
  html.js-manifesto .manifesto__pin {
    position: sticky;
    top: 0;
    height: 100vh;
    min-height: 0;
    /* The exit half of the pan: §10 mirrors its eased --svc-in here as --mf-x
       (0 at rest → 1 landed), so the manifesto leaves left in exact lockstep
       with the grid arriving from the right — one number pair, one frame,
       reversible. Transform on the sticky element is applied after
       positioning, and a leftward offset never grows the scrollable area. */
    transform: translate3d(calc(var(--mf-x, 0) * -100vw), 0, 0);
  }
  /* M47: the stage overlaps the still-pinned manifesto by one viewport plus
     the pan, so its own sticky inner engages the instant the sweep's travel
     ends — the pan window is [sweep end, sweep end + pan] and BOTH stickies
     are on screen across all of it. No blank-canvas beat, no vertical scroll
     between the sections. The stage paints later in the DOM, but it owns no
     opaque background (the body's canvas shows through) and its backdrop
     develops on --svc-in, so the manifesto stays visible to its left while
     the grid slides in. Gated by the same class + media terms as the pin:
     reduced motion and <768px keep the plain stacked flow. */
  html.js-manifesto .svc-stage { margin-top: calc(-100vh - var(--mf-pan, 0px)); }
  /* The stage's negative margin COLLAPSES through #services (the section is
     flush: no top padding or border between them), so the wrapper's box top
     moves up with the stage and sits exactly at the pan's start — where a
     bare #services anchor jump (nav, footer, people.html) would land on the
     fully-lit manifesto with an empty right half. A NEGATIVE scroll-margin
     (spec-legal, margin-like) re-aims the jump one pan PAST the box top:
     --svc-in is 1 there, capability 01 faces the visitor. The nav needs no
     allowance — the pinned inner owns the whole viewport under the floating
     header exactly as it does mid-scrub. */
  html.js-manifesto #services { scroll-margin-top: calc(0px - var(--mf-pan, 0px)); }
}

.platforms {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-5);
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
}
.platforms__label {
  font-size: var(--fs-sm);
  color: var(--ink-3);
  margin-right: var(--s-2);
}
.chip {
  display: inline-flex;
  align-items: center;
  padding: 6px var(--s-3);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm);
  font-weight: 500;
  color: var(--ink-2);
}

/* ------------------------------------------------------------------- works */
/* Two labelled chip groups since M44 (FR-21): Category, then Market. The
   labels are plain uppercase micro-text, not controls — the chips are the
   controls, and one chip is active at a time across both groups. */
.filters {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3) var(--s-7);
  margin-bottom: var(--s-6);
}
.filters__group {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
}
.filters__label {
  margin-right: var(--s-1);
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.filter {
  padding: 8px var(--s-4);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm);
  font-weight: 500;
  color: var(--ink-2);
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease);
}
.filter:hover { background: var(--surface-hover); color: var(--ink); border-color: var(--line-strong); }
.filter[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

/* The photo gallery (FR-19/19a/20, M44 — the landonorris.com register, second
   pass): CSS multi-column masonry, no JS layout. Columns fill column-major, so
   the data-shape aspect cycle (4/3 → 1/1 → 4/5, set by §5 off the tile's
   position) varies heights WITHIN each column and the columns stagger the way
   the reference's helmet cards do. `break-inside: avoid` because a button
   fragmented across two columns is two half-buttons.

   FULL-BLEED since M45 (FR-19b): the grid sits outside the section's
   .container, spans the whole viewport, and carries only --works-gutter of
   air at the edges — the photographs own the screen. Tiles get LARGER as the
   viewport widens (a 1440 screen shows three ~450px columns where the boxed
   grid showed ~390px); a fourth column only arrives once the screen is wide
   enough that four tiles still beat the old boxed three. */
/* FR-19c: the parallax rise needs a clip ancestor. A rising tile is displaced
   DOWNWARD from its slot — up to 0.88·vh below it — so its pixels can hang
   past the section's bottom edge; clipped at the SECTION they never paint
   over the stats band below, and the tiles read as flowing up from the bottom
   of the page. `clip`, NEVER `hidden`: hidden makes the section a scroll
   container, and one stray programmatic scroll would shear the layout
   (PRD §10). */
#works { overflow: clip; }

/* FR-19c/M63: the works STAGE. The section's content (head, filters, grid)
   sits in .works-stage__inner; §9f adds .is-pinned to the stage on a multi-
   column layout, which makes the inner STICKY at the viewport top and grows
   the stage by --works-budget of extra scroll (the ::after spacer — a real
   content-box child, because a sticky box is bounded by its containing
   block's CONTENT edge, so padding would not extend the hold). The visitor
   scrolls the section to the top of the screen and sees it EMPTY — every tile
   displaced below the fold — then the inner holds for one budget of scroll
   while the two column groups rise into the grid (p = scroll into the stage /
   budget), and releases exactly at p = 1 so the grid's lower rows scroll
   normally. No JS / reduced motion / one-column phones: no class, no pin, no
   spacer, plain flow. The section's own top padding moves INTO the inner and
   includes the fixed header's height, so the head clears the nav both when
   pinned (top: 0 puts the inner's top under the nav) and in plain flow;
   scroll-margin-top 0 so a nav click lands at the stage top — the empty
   section, p = 0 — not one nav-height short of it. */
#works { padding-top: 0; scroll-margin-top: 0; }
.works-stage {
  --works-budget: 100vh;
  /* No scroll anchoring inside the stage: a filter rebuild replaces every
     tile while the inner may be pinned, and Chromium re-anchoring the
     viewport to a node that just moved (or vanished) nudges scrollY by
     tens of px — which, on a scrubbed section, is a visible jump in p. */
  overflow-anchor: none;
}
.works-stage__inner { padding-top: calc(var(--nav-h) + var(--s-6)); }
.works-stage.is-pinned .works-stage__inner { position: sticky; top: 0; }
.works-stage.is-pinned::after { content: ''; display: block; height: var(--works-budget); }

.works-grid {
  columns: 3;
  column-gap: var(--s-5);
  padding-inline: var(--works-gutter);
}
@media (min-width: 1680px) { .works-grid { columns: 4; column-gap: var(--s-6); } }
@media (max-width: 1099px) { .works-grid { columns: 2; } }
/* Phones keep TWO columns (M91, FR-31b): one full-width photo per screen made
   the gallery twenty-six screens long. Tighter gutters and seat gaps so a
   ~170px tile is mostly photograph; the corner tab shrinks to fit it. */
@media (max-width: 767px) {
  .works-grid { column-gap: var(--s-3); }
  .works-grid .work { margin-bottom: var(--s-3); }
  /* The tab stacks — client over year — so a 14-letter client name fits a
     ~170px tile whole instead of ellipsing to "GENSHIN I…". Scoped to the grid
     for specificity: the base tab rules come later in the file. */
  .works-grid .work__label {
    max-width: calc(100% + 1px);
    flex-direction: column;
    align-items: flex-start;
    gap: 1px;
    padding: 7px 10px 6px 12px;
    border-top-left-radius: 14px;
  }
  .works-grid .work__client { max-width: 100%; letter-spacing: 0.06em; line-height: 1.25; overflow-wrap: anywhere; }
  .works-grid .work__year { font-size: 0.6875rem; line-height: 1.2; }
}

/* FR-31b: the phone batch control. Rendered by §5 only while tiles are held
   back, so desktop and a fully shown filter never carry an empty box. */
.works-more {
  display: flex;
  justify-content: center;
  padding: var(--s-2) var(--works-gutter) 0;
}
.works-more .btn { min-height: 48px; }
.works-more__count { color: var(--ink-2); font-weight: 500; }

/* A state box (empty filter result, FR-27 failure) inside the full-bleed
   multicol container: span every column and hold the words to a readable
   measure — a message flowed into the leftmost of three columns reads as a
   rendering accident, not a state. */
.works-grid > .state {
  column-span: all;
  max-width: calc(var(--maxw) - 2 * var(--gutter));
  margin-inline: auto;
}

.work {
  display: block;
  width: 100%;
  margin: 0 0 var(--s-5);
  break-inside: avoid;
  text-align: left;
  padding: 0;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  overflow: hidden;
  cursor: pointer;
  transition: border-color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.work:hover,
.work:focus-visible { border-color: var(--line-strong); transform: translateY(-4px); }
.work:hover .work__img,
.work:focus-visible .work__img { transform: scale(1.06); }

/* The filter-change entrance (FR-19c/FR-21): §5 rebuilds the grid in place
   while the visitor is looking at it, so the tiles carry a one-shot cascade
   animation instead of waiting on a scroll observer that will never fire. It
   is the SHORTENED form of the scroll-entry card drop — same keyframes, a
   fixed 72px fall instead of the full from-the-header one — with per-tile
   `--rd`/`--drop`/`--tilt` written by §5 from the tile's MEASURED column and
   its seat within it (choreographGrid), so filtering ripples left-to-right
   exactly like the first entrance. Reduced motion cuts the animation
   entirely; the tiles are simply there, at full opacity, the base state. */
.work--in {
  /* `backwards`, not `both`: the 100% frame equals the static state (opaque,
     untransformed), and a forwards fill would pin a live identity matrix on
     the tile forever — the suite (and anything else) polls "settled" as
     computed transform NONE. */
  animation: work-drop 420ms var(--rd, 0ms) backwards;
}

/* FR-19c: the time-based card drop — since M54 used ONLY by the filter
   cascade above (the scroll entrance is §9f's scrub, inline styles, no
   keyframes). Segment one is the gravity fall — accelerating ease-in from
   `--drop` above the slot, carrying a ~1° tilt whose sign alternates per
   tile. Segment two is the landing: a few px of overshoot past the slot
   while the tilt swings partway back, then an ease-out settle to identity.
   Transform + opacity only — the tile's box never moves, so this cannot
   cost a pixel of CLS. */
@keyframes work-drop {
  0% {
    opacity: 0;
    transform: translate3d(0, var(--drop, -24px), 0) rotate(var(--tilt, 0deg));
    animation-timing-function: cubic-bezier(0.5, 0, 0.85, 0.42);
  }
  34% { opacity: 1; }
  74% {
    opacity: 1;
    transform: translate3d(0, 9px, 0) rotate(calc(var(--tilt, 0deg) * -0.4));
    animation-timing-function: cubic-bezier(0.18, 0.65, 0.3, 1);
  }
  100% { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .work--in { animation: none; }
}

/* FR-19c/M54: §9f's measurement guard. Rest geometry (each tile's docTop) is
   read with mid-flight transforms and any running cascade animation switched
   off, so a re-measure during the scrub or the filter cascade records where
   the tile LIVES, not where it currently happens to be painted. JS adds the
   class, reads, removes it within one layout pass — it never paints. */
.works-grid.is-measuring .work {
  transform: none !important;
  animation: none !important;
  /* transition too (M55): .work transitions transform for the hover lift, so
     without this the guard's "transform: none" itself TRANSITIONS — a rect
     read in the same turn still sees the old mid-flight transform, and every
     re-measure taken while tiles were airborne recorded docTops hundreds of
     px from the truth. */
  transition: none !important;
}

.work__media {
  position: relative;
  display: block;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background: var(--canvas-deep);
}
/* The masonry rhythm. Position-cycled by §5; it needs two columns to exist at
   all, and since M91 every width has at least two. */
.work[data-shape="t"] .work__media { aspect-ratio: 4 / 5; }
.work[data-shape="s"] .work__media { aspect-ratio: 1 / 1; }
.work__img {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform var(--t-slow) var(--ease-out);
}
.work__fallback {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-size: 2.5rem;
  font-weight: 700;
  letter-spacing: -0.03em;
  color: rgba(255, 255, 255, 0.9);
  background: linear-gradient(135deg, #1a1020, #2a1220 45%, #201a12);
}
/* Rest state: the notched corner tab (FR-19, the reference's signature). A
   surface-coloured label seated INTO the tile's bottom-right corner — rounded
   on its top-left, flush with the card edges, with two quarter-round inverse
   fillets painted where it meets the photo, so it reads as a cut-out of the
   card rather than a strip laid over the image. The tab's own background is
   the reading floor: solid surface, so the client name never sits on an
   unvetted photograph at all. -1px offsets tuck it over the card's border. */
.work__label {
  position: absolute;
  right: -1px; bottom: -1px;
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  padding: 9px 16px 9px 18px;
  background: var(--surface);
  border-top-left-radius: 18px;
  transition: opacity var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.work__label::before,
.work__label::after {
  content: "";
  position: absolute;
  width: 18px;
  height: 18px;
  pointer-events: none;
  /* Inside the circle: transparent, the photo. Outside it, in the square's
     corner nearest the tab: the surface — the concave fillet. */
  background: radial-gradient(circle 18px at 0 0, transparent 17.5px, var(--surface) 18px);
}
.work__label::before { left: -18px; bottom: 0; }
.work__label::after { right: 0; top: -18px; }
.work__client {
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink);
}
.work__year {
  font-family: var(--font-display);
  font-size: var(--fs-eyebrow);
  font-weight: 700;
  letter-spacing: 0.1em;
  color: var(--accent-soft);
}
/* The hover scrim (client, 2026-09-01: "add a darker transparent background
   to make sure the text is readable"). The moment the veil rises, the WHOLE
   photograph dims under one translucent tint — the bottom gradient alone left
   the title sitting on whatever the photo happened to be at that height.
   Translucent, never opaque: the photograph stays visible through it.
   z-order is explicit because the hover-scaled img is a stacking context that
   paints in tree order — an unindexed scrim (::before, first in tree) would
   slide UNDER the zoomed photo. Scrim 1; label and veil 2, above it. */
.work__media::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: rgba(8, 8, 10, 0.48);
  opacity: 0;
  transition: opacity 300ms var(--ease);
  pointer-events: none;
}
.work:hover .work__media::before,
.work:focus-visible .work__media::before { opacity: 1; }
.work__label, .work__veil { z-index: 2; }
/* The veil (FR-19a, M44 cascade). Opacity-hidden, NEVER display: none — its
   words are part of the tile's accessible name. The gradient covers the lower
   half and dies out by 78% of the tile's height: the top of the photograph is
   never covered, and the photo stays visible through the translucent band.
   Entrance is a cascade on the zoom curve (~420ms expo-out): the gradient
   rises first, then title → rule → tease → chips on their own ~60ms delays,
   the accent rule drawing itself from zero width. Exit faster than arrival,
   delays zeroed so everything sheathes together. */
.work__veil {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-7) var(--s-4) var(--s-4);
  background: linear-gradient(to top, rgba(8, 8, 10, 0.94), rgba(8, 8, 10, 0.62) 55%, transparent);
  opacity: 0;
  transform: translateY(26px);
  transition: opacity 170ms var(--ease), transform 170ms var(--ease);
  pointer-events: none;
}
.work__veil > * {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 170ms var(--ease), transform 170ms var(--ease);
}
/* The rule multiplies its travel with the draw — scaleX(0) at rest, drawn to
   full width on the same delay train. Declared after `.work__veil > *` so the
   equal-specificity transform wins by order. */
.work__rule {
  display: block;
  height: 2px;
  width: 42%;
  margin-top: 2px;
  border-radius: 1px;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  transform: translateY(16px) scaleX(0);
  transform-origin: left center;
}
.work:hover .work__veil,
.work:focus-visible .work__veil {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 420ms var(--ease-zoom), transform 420ms var(--ease-zoom);
}
.work:hover .work__veil > *,
.work:focus-visible .work__veil > * {
  opacity: 1;
  transform: none;
  transition: opacity 420ms var(--ease-zoom), transform 420ms var(--ease-zoom);
}
.work:hover .work__veil > :nth-child(2),
.work:focus-visible .work__veil > :nth-child(2) { transition-delay: 60ms; }
.work:hover .work__veil > :nth-child(3),
.work:focus-visible .work__veil > :nth-child(3) { transition-delay: 115ms; }
.work:hover .work__veil > :nth-child(4),
.work:focus-visible .work__veil > :nth-child(4) { transition-delay: 170ms; }
/* The corner tab hands over to the veil so the two never stack. */
.work:hover .work__label,
.work:focus-visible .work__label { opacity: 0; transform: translateY(8px); }
/* Touch and any other no-hover context never sees the veil at all — the
   description lives in the case study the tap opens anyway. The tab stays. */
@media (hover: none) {
  .work__veil { display: none; }
  .work__media::before { display: none; } /* no veil text → nothing to shield */
  .work:hover .work__label { opacity: 1; transform: none; }
}
/* Reduced motion: the veil is a plain fade, no travel, no draw, no stagger. */
@media (prefers-reduced-motion: reduce) {
  .work__veil, .work__veil > *, .work__rule { transform: none; transition-delay: 0ms; }
  .work:hover .work__veil, .work:focus-visible .work__veil,
  .work:hover .work__veil > *, .work:focus-visible .work__veil > * {
    transform: none;
    transition: opacity 200ms var(--ease);
    transition-delay: 0ms;
  }
}
.work__title {
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--ink);
}
.work__tease {
  font-size: var(--fs-sm);
  line-height: 1.5;
  color: var(--ink-2);
}
.work__tags { display: flex; flex-wrap: wrap; gap: 6px; padding-top: var(--s-2); }
.tag {
  padding: 4px 10px;
  background: rgba(255, 255, 255, 0.05);
  border-radius: var(--r-pill);
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--ink-2);
}

/* skeleton / empty / error
   The works grid has no skeleton any more — the campaigns ship inside the page
   (assets/js/works-data.js) and the tiles render in the boot turn, so there is
   no in-flight moment to fill. What is left here is the ROSTER's skeleton on
   people.html, which is still fetched and still needs to reserve its height or
   it reintroduces CLS. Do not delete .skeleton__line or .shimmer with the works
   loader; .person--skeleton composes both. */
.skeleton__line { height: 12px; border-radius: 6px; margin: var(--s-3) var(--s-5); }
.skeleton__line:last-child { width: 55%; margin-bottom: var(--s-5); }
.shimmer {
  background: linear-gradient(100deg, #17171d 30%, #22222a 50%, #17171d 70%);
  background-size: 220% 100%;
  animation: shimmer 1.4s var(--ease) infinite;
}
@keyframes shimmer { from { background-position: 180% 0; } to { background-position: -80% 0; } }

.state {
  grid-column: 1 / -1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-4);
  text-align: center;
  padding: var(--s-8) var(--s-5);
  background: var(--surface-2);
  border: 1px dashed var(--line-strong);
  border-radius: var(--r-tile);
}
.state__title { font-family: var(--font-display); font-size: var(--fs-h3); font-weight: 600; }
.state__body { color: var(--ink-2); max-width: 46ch; }
.state__body code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.85em;
  color: var(--ink-3);
}

/* ------------------------------------------------------------------- modal */
.modal {
  position: fixed;
  inset: 0;
  z-index: 150;
  display: grid;
  place-items: center;
  padding: var(--gutter);
  opacity: 0;
  visibility: hidden;
  /* Same reasoning as .mobile-menu: visibility must not interpolate, or the
     dialog is still computed `hidden` when we move focus to its close button. */
  transition: opacity var(--t-mid) var(--ease), visibility 0s linear var(--t-mid);
}
.modal.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--t-mid) var(--ease), visibility 0s linear 0s;
}
.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(6, 6, 8, 0.78);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 0;
  cursor: pointer;
}
.modal__panel {
  position: relative;
  width: min(880px, 100%);
  max-height: min(88vh, 900px);
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-card);
  transform: translateY(12px) scale(0.985);
  transition: transform var(--t-mid) var(--ease-out);
}
.modal.is-open .modal__panel { transform: translateY(0) scale(1); }
/* The shared-element flight (FR-25b, M39). `.is-zoom` is added by §5b only
   when a real grid rect exists — under it the stock slide steps aside and the
   panel cross-fades in late, beneath the ghost layer while the ghost lands.
   Every fallback path (reduced motion, cold deep links, history re-opens,
   lost or far-off tiles, the CTA close) simply never adds the class, so the
   fade above stays the default. */
.modal.is-zoom .modal__panel {
  transform: none;
  opacity: 0;
  /* `none`, NOT a duration: the panel's before-change opacity is 1 (its
     stock state), so a transition here would spend the flight animating
     1 → 0 — and the `.is-open` reversal a frame later would catch it still
     at ~1, leaving the article opaque under the whole flight. The drop to
     the pre-open state must be a snap. */
  transition: none;
}
.modal.is-zoom.is-open .modal__panel {
  transform: none;
  opacity: 1;
  transition: opacity 260ms var(--ease) 140ms;
}
/* The close direction is its own class: `.is-zoom` on close would fall into
   the pre-open snap above and blink the article out instead of fading it
   under the departing ghost. */
.modal.is-zoom-out .modal__panel {
  transform: none;
  opacity: 0;
  transition: opacity 180ms var(--ease);
}
/* The transient ghost layer: the tile's surface and its photograph, fixed
   above the modal (z 151), moved by transform + a late opacity only (FR-64 —
   no filter, no animated box-shadow, no layout properties). Ghosts are laid
   out at the PANEL-side rects, so `transform: none` IS the article and the
   tile end is a pure translate(...) scale(...) written by §5b. */
.work-zoom {
  position: fixed;
  inset: 0;
  z-index: 151;
  pointer-events: none;
}
.work-zoom__ghost {
  position: absolute;
  transform-origin: 0 0;
  will-change: transform, opacity;
}
.work-zoom.is-fly .work-zoom__ghost {
  transition: transform 420ms var(--ease-zoom), opacity 200ms var(--ease) 220ms;
  opacity: 0;
}
.work-zoom__surface {
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-card);
}
.work-zoom__photo {
  overflow: hidden;
  border-radius: var(--r-card) var(--r-card) 0 0;
  background: var(--canvas-deep);
}
.work-zoom__photo img { display: block; width: 100%; height: 100%; object-fit: cover; }
.modal__close {
  position: absolute;
  top: var(--s-4); right: var(--s-4);
  z-index: 2;
  width: 40px; height: 40px;
  display: grid;
  place-items: center;
  background: rgba(10, 10, 12, 0.72);
  backdrop-filter: blur(8px);
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--ink);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.modal__close:hover { background: var(--surface-hover); border-color: var(--line-strong); }
.modal__media { position: relative; aspect-ratio: 16 / 8; overflow: hidden; background: var(--canvas-deep); }
.modal__media img { width: 100%; height: 100%; object-fit: cover; }
.modal__body { padding: var(--card-pad); min-width: 0; }
/* FR-25c: nothing in a case study scrolls sideways. Every value wraps
   (overflow-wrap: anywhere on the glance values, min-width: 0 on every grid
   and flex child); this clip is only the floor under that, never the fix —
   M89's clipped title was a long URL widening the body past the panel. */
.modal__panel { overflow-x: hidden; overflow-x: clip; }
.modal__client {
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent-soft);
}
.modal__title {
  margin-top: var(--s-2);
  font-size: clamp(1.5rem, 3.4vw, 2.25rem);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.025em;
}
.modal__tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--s-4); }
.modal__block { margin-top: var(--s-6); }
.modal__block h4 {
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: var(--s-3);
}
.modal__block p { color: var(--ink-2); max-width: 68ch; }
.glance { display: grid; gap: var(--s-5); min-width: 0; }
/* Figures — the numbers. Flex, one row of --n (main.js writes the count; at
   most four on any campaign today) so the row is always FULL: no tile ever
   sits beside an empty slot. A phone takes two a row, and an odd last tile
   grows to the width it has rather than leaving a hole. */
.glance__figures {
  --cols: var(--n, 4);
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
}
.glance__figure {
  flex: 1 1 calc((100% - (var(--cols) - 1) * var(--s-3)) / var(--cols));
  min-width: 0;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-inner);
  padding: var(--s-4);
}
.modal__block .glance__num {
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 2.4vw, 1.5rem);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  overflow-wrap: anywhere;
}
.modal__block .glance__label { font-size: var(--fs-sm); color: var(--ink-2); margin-top: 2px; }
.modal__block .glance__note { font-size: 0.75rem; color: var(--ink-3); margin-top: 2px; line-height: 1.4; }
/* Details — names, places, formats. Body type, label left / value right, a
   hairline between rows: read like a spec sheet, not shouted like a stat. */
.glance__details {
  display: grid;
  grid-template-columns: minmax(7rem, max-content) minmax(0, 1fr);
  column-gap: var(--s-5);
  margin: 0;
}
.glance__dt,
.glance__dd {
  min-width: 0;
  padding-block: 10px;
  border-top: 1px solid var(--line);
}
.glance__dt { font-size: var(--fs-sm); color: var(--ink-3); }
.glance__dd { margin: 0; color: var(--ink); overflow-wrap: anywhere; }
.glance__details > :nth-child(-n + 2) { border-top: 0; padding-top: 0; }
.glance__chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; }
.glance__chip {
  padding: 3px 10px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm);
  color: var(--ink);
}
/* Links — an address is a destination, so it is a link that says where it
   goes, with the external-arrow the rest of the page uses for "leaves WHIM". */
.glance__links { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--s-3); }
.glance__link {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  max-width: 100%;
  padding: 10px 16px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-pill);
  color: var(--accent-soft);
  font-weight: 600;
  font-size: var(--fs-sm);
  text-decoration: none;
  overflow-wrap: anywhere;
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.glance__link svg { flex: none; }
.glance__link:hover { background: var(--surface-hover); border-color: var(--accent); color: var(--ink); }
.glance__link:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
@media (max-width: 600px) {
  .glance__figures { --cols: 2; }
  /* One column: the label sits above its value, so a long value never fights
     a 7rem label column for a phone's width. */
  .glance__details { grid-template-columns: minmax(0, 1fr); }
  .glance__dt { padding-bottom: 2px; }
  .glance__dd { border-top: 0; padding-top: 0; }
  .glance__details > :nth-child(2) { padding-top: 0; }
}
.modal__cta { margin-top: var(--s-6); }
/* ---------------------------------------------------------------- services */
/* ONE construct: .svc-stage — the scroll-scrubbed 3D tube. Ten photo panels;
   the ONLY place the capabilities are enumerated. (The pillar strip that used
   to close the section left with M50; the pillar names live in the rail.)
   Everything in the stage derives from --svc-p, a single 0→1 number main.js §10
   writes on .svc-stage. styles.css declares --svc-p: 0 so the p=0 composition is
   correct with no JS at all; the scrub only ever takes over. */

.section--flush { padding-block: 0; }

.svc-stage {
  --svc-p: 0;
  /* Face N faces the visitor at --svc-p = N/9: ten faces, 36deg apart, so the
     tube turns through 9 × 36 = 324deg over the whole stage. */
  --svc-rot: calc(var(--svc-p) * 324deg);
  /* FR-36q: the slide-in progress. Defaults to 1 — landed — so no-JS, reduced
     motion (§10 never attaches) and mobile (§10 zeroes the budget) all paint
     the composition in place; the scrub only ever takes over. */
  --svc-in: 1;
  padding-block: calc(var(--section-y) / 2);
}
/* minmax(0, 1fr), not the implicit `auto`: an auto column is floored at its
   content's min-content width, so the rail's widest unbreakable row could push
   the column — and with it the container — past the viewport. That is exactly
   what boxing the copy did at 320px, where the card's padding pushed the rail's
   min-content 22px over the edge. */
.svc-stage__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--s-7);
  align-items: center;
}

/* -- the full-bleed backdrop ---------------------------------------------- */
/* BASE = off. The backdrop only exists inside the 3D build further down, where
   the stage is sticky and one photo at a time can own the viewport. On a phone
   and under reduced motion the tube is a stacked list of ten panels that each
   carry their own photo already; ten more behind them would be noise, and ten
   blurred full-bleed layers on a phone GPU would be worse than noise. */
.svc-bg { display: none; }

/* -- the copy card -------------------------------------------------------- */
/* The left column is a real elevated card, not bare text on the canvas. Once
   the backdrop puts an unvetted photograph behind this column, an opaque-enough
   surface is the only thing standing between the lede and an unreadable frame.

   There is NO backdrop-filter here, and adding one back would be a performance
   regression, not a polish pass. A backdrop blur has to re-snapshot and re-blur
   whatever is behind the element every time that changes — and what is behind
   this card is a cross-fading, drifting photograph, i.e. it changes every frame,
   at device pixel ratio. It was the most expensive single declaration in the
   section. The backdrop is pre-blurred in the asset now (see .svc-bg below), so
   there is nothing left for it to do that the alpha does not already do. */
.svc-stage__copy {
  /* Scales down on a narrow phone: 32px of padding each side out of a 320px
     viewport is a fifth of the screen spent on air. */
  padding: clamp(20px, 5vw, 32px);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  /* 0.94, measured rather than picked: at this alpha the brightest pixel the
     card can reach — a blown-out backdrop photo showing through — is rgb(32),
     which keeps every line of copy inside it above AA. Raised from 0.92 when
     the blur came off, since the blur was previously flattening the highlights
     this alpha now has to survive on its own. */
  background: rgba(18, 18, 22, 0.94);
}
.svc-stage__copy h2 {
  font-size: var(--fs-display);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.025em;
  margin-top: var(--s-3);
}
.svc-stage__copy .lede { margin-top: var(--s-4); }

/* -- live capability preview (FR-36p) ------------------------------------- */
/* §10 builds .svc-preview inside .svc-copy__hold only where the 3D tube is
   actually live, and adds .is-preview to the stage once the scrub is genuinely
   under way (--svc-p > 0.04). Overlay, not reflow: the preview is absolutely
   stacked over the h2 + lede, so the card never changes height mid-scrub and
   the rail below never moves. The text is opacity-hidden, never display: none
   or visibility: hidden — #services-h is the section's aria-labelledby target
   and must stay in the accessibility tree. Transitions are opacity-only
   (FR-64); the art is the face's own 1600×900 photograph (assets/img/cap/),
   requested by this <img> alone on the 3D build — the face's <img> is
   display: none there — one capability at a time as the tube turns (M87). */
.svc-copy__hold { position: relative; }
.svc-copy__hold h2,
.svc-copy__hold .lede {
  transition: opacity var(--t-mid) var(--ease);
}
.svc-stage.is-preview .svc-copy__hold h2,
.svc-stage.is-preview .svc-copy__hold .lede {
  opacity: 0;
}
.svc-preview {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-mid) var(--ease);
}
.svc-stage.is-preview .svc-preview { opacity: 1; }
.svc-preview__layer {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 320ms var(--ease);
}
.svc-preview__layer.is-on { opacity: 1; }
.svc-preview__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--r-tile);
  border: 1px solid var(--line-strong);
  display: block;
}
.svc-preview__cap {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4) var(--s-4);
  border-radius: 0 0 var(--r-tile) var(--r-tile);
  /* A caption is a reading surface, not atmosphere: everywhere a glyph can
     land holds at least 0.8 alpha of near-black — measured, the brightest
     photo pixel under it still leaves the ink AA. The soft falloff into the
     photograph lives on the ::before ABOVE the strip, outside the box the
     glyphs occupy, so prettiness never trades against the floor. */
  background: linear-gradient(to top, rgba(8, 8, 10, 0.9), rgba(8, 8, 10, 0.8));
}
.svc-preview__cap::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  height: 44px;
  background: linear-gradient(to top, rgba(8, 8, 10, 0.8), transparent);
  pointer-events: none;
}
.svc-preview__no {
  font-family: var(--font-display);
  font-size: var(--fs-eyebrow);
  font-weight: 700;
  letter-spacing: 0.14em;
  color: var(--accent-soft);
  white-space: nowrap;
}
.svc-preview__name {
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--ink);
}

/* -- pillar rail ---------------------------------------------------------- */
/* --on is 1 while the tube is inside this pillar's band and 0 outside it, with
   a short cross-fade at each edge. Pure calc: no per-frame class toggling, and
   no second scroll listener. Base is 1 so the list is fully legible wherever the
   scrub is not running (mobile, reduced motion, no JS). */
.svc-rail { margin-top: var(--s-6); display: grid; gap: var(--s-2); list-style: none; }
.svc-rail__item { --on: 1; }
.svc-rail__item a {
  position: relative;
  display: grid;
  /* minmax(0, 1fr) for the same reason as the grid above — the pillar names are
     long, and an auto track refuses to go below its longest word. */
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  padding-left: var(--s-5);
  border-radius: var(--r-inner);
  color: var(--ink);
  /* 0.85, not 0.75, and neither of those is 0.6. The dimmed state still has to
     clear AA, and the floor has been raised twice by measurement: --ink-2 at 0.6
     over the canvas is 3.4:1 on 12px text, which Lighthouse caught; 0.75 bought
     5.1:1 over the CANVAS, but the rail now sits on the copy card, whose surface
     is lighter than the canvas — and there the same 0.75 measured 4.18:1. At
     0.85 it is 4.9:1 against the brightest the card can get. The accent bar
     below is what carries the active state anyway. */
  opacity: calc(0.85 + 0.15 * var(--on));
  transition: background-color var(--t-fast) var(--ease);
}
.svc-rail__item a:hover { background: var(--surface-hover); }
.svc-rail__item a::before {
  content: "";
  position: absolute;
  left: 0; top: 10%; bottom: 10%;
  width: 3px;
  border-radius: 999px;
  background: var(--accent);
  transform: scaleY(var(--on));
  transform-origin: 50% 50%;
}
.svc-rail__no {
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--accent-soft);
}
.svc-rail__name { font-family: var(--font-display); font-weight: 600; font-size: var(--fs-h4); }
/* --ink-2, not --ink-3: this text is already being dimmed by the rail's own
   opacity, and --ink-3 under that dimming does not clear AA. */
.svc-rail__meta { font-size: var(--fs-eyebrow); color: var(--ink-2); white-space: nowrap; }

/* -- the tube ------------------------------------------------------------- */
/* BASE = the fallback, not the enhancement. Below 768px, under reduced motion,
   and in any engine without CSS cos(), these rules alone render the ten
   capabilities as a plain stacked card list — no perspective, no rotation, no
   absolutely-positioned faces to mis-stack. The 3D version is layered on top
   inside a single @supports/@media block further down. */
.svc-tube-wrap { position: relative; }
.svc-tube__glow { display: none; }
.svc-tube { display: grid; gap: var(--s-4); list-style: none; }
.svc-face {
  position: relative;
  overflow: hidden;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
}
/* The photograph. A span rather than a bare <img> so the scrim can be its
   ::after and travel with it into the 3D build, where the same element becomes
   the panel's full-bleed background. */
.svc-face__art {
  display: block;
  position: relative;
  background: var(--canvas-deep);
}
.svc-face__art img {
  display: block;
  width: 100%;
  /* height:auto is load-bearing. The <img> carries width/height attributes for
     CLS, and the height attribute is a presentational hint with a real
     specified value — which silently beats `aspect-ratio` and rendered every
     photo 600px tall on a phone. */
  height: auto;
  aspect-ratio: 16 / 9;
  /* Cap the crop so ten stacked panels stay a scrollable list on a phone rather
     than ten full-width posters. object-fit: cover does the cropping.
     M52 raised the cap (132 → 168) and the presence (0.72 → 0.92): the grade
     moved into the layered scrim below, so the photograph no longer needs to
     be dimmed flat to hold the copy — the copy sits BELOW the photo here, not
     on it. */
  max-height: 168px;
  object-fit: cover;
  object-position: center 45%;
  opacity: 0.92;
  transition: opacity var(--t-fast) var(--ease);
}
/* The grade (M52). Two layers instead of one flat dim: an accent wash falling
   in from the top corner — the duotone read, priced as plain alpha compositing,
   no blend modes and no filters — and a deep-canvas fade at the foot so the
   photo meets the card surface instead of ending on a hard edge. */
.svc-face__art::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(155deg, rgba(var(--accent-rgb), 0.22) 0%, rgba(var(--accent-rgb), 0.06) 34%, rgba(var(--accent-rgb), 0) 56%),
    /* M77: the foot of the fade deepens 0.82 → 0.92 and the head 0.25 → 0.34,
       matching the slat's pass in the 3D build. This is the STACKED card, so
       the copy sits below the photo rather than on it and the AA numbers were
       never the problem here — the ghosted index numeral is, and so is the
       seam where the photo meets the card surface. The middle stop is
       untouched at 0.05: the photograph's own subject stays at full presence,
       which is the difference between a deeper scrim and a black box. */
    linear-gradient(180deg, rgba(9, 9, 11, 0.34) 0%, rgba(9, 9, 11, 0.05) 35%, rgba(9, 9, 11, 0.92) 100%);
  pointer-events: none;
}
/* display: block — the body is a <span> (it lives inside a <button>), and an
   inline box's side padding never reaches its block children: the stacked
   list's copy sat flush against the card edge until M91 opened it up to view. */
.svc-face__body { display: block; position: relative; z-index: 2; padding: var(--s-4) var(--s-5) var(--s-5); }
/* The index as a graphic (M52). No longer a small label in the copy column —
   an oversized ghosted figure anchored to the card's top-right (the button is
   the positioning context in both presentations), which on this stacked
   fallback puts it over the photograph's quiet corner. Outlined where the
   engine can stroke text, a faint solid fill where it cannot; aria-hidden in
   the markup, so it never reaches the button's accessible name. */
.svc-face__no {
  position: absolute;
  z-index: 2;
  top: var(--s-3);
  right: var(--s-4);
  font-family: var(--font-display);
  font-size: clamp(3.25rem, 8vw, 4.25rem);
  font-weight: 700;
  line-height: 0.85;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  color: rgba(255, 255, 255, 0.14);
  pointer-events: none;
  transition: color var(--t-fast) var(--ease);
}
@supports (-webkit-text-stroke: 1px #000) {
  .svc-face__no {
    color: transparent;
    -webkit-text-stroke: 1.5px rgba(255, 255, 255, 0.34);
    transition: -webkit-text-stroke-color var(--t-fast) var(--ease);
  }
  .svc-face__btn:hover .svc-face__no,
  .svc-face__btn:focus-visible .svc-face__no {
    -webkit-text-stroke-color: var(--accent-soft);
  }
}
@supports not (-webkit-text-stroke: 1px #000) {
  .svc-face__btn:hover .svc-face__no,
  .svc-face__btn:focus-visible .svc-face__no { color: rgba(var(--accent-rgb), 0.30); }
}
/* The photograph lifts under the cursor — the grade stays, the dim goes. */
.svc-face__btn:hover .svc-face__art img,
.svc-face__btn:focus-visible .svc-face__art img { opacity: 1; }
/* The pillar kicker (M52): every face names the pillar it belongs to, in the
   pillar's own i18n string, so a panel met mid-scroll carries its context. */
.svc-face__kicker {
  display: block;
  font-family: var(--font-display);
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent-soft);
}
.svc-face__name {
  display: block;
  margin-top: var(--s-2);
  font-family: var(--font-display);
  font-size: clamp(1.3125rem, 4.2vw, 1.5rem);   /* M64: one step up, with the tube's */
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.01em;
}
.svc-face__brief {
  margin-top: var(--s-2);
  font-size: var(--fs-body);   /* M64: was --fs-sm */
  line-height: 1.55;
  color: var(--ink-2);
}
/* M60: one more sentence under the brief — what the capability actually
   DOES, in plain words. One size below the brief, secondary ink, and clamped
   to three lines so no translation can outgrow the slat it rides on (the
   suite measures the body's scrollHeight against its clientHeight). */
.svc-face__desc {
  display: block;
  display: -webkit-box;
  margin-top: var(--s-2);
  font-size: var(--fs-sm);   /* M64: was 0.8125rem */
  line-height: 1.5;
  color: var(--ink-2);
  overflow: hidden;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
}

/* -- the capability panel as a button ------------------------------------- */
/* The panel IS the control. A <button> wrapping the whole face rather than a
   "read more" link tucked in a corner, because on the 3D tube the panel is the
   only thing the visitor can see to aim at.

   Every child of the button is a <span>, not a <p> — a button's content model is
   phrasing content, and a <p> inside it is invalid markup that some screen
   readers flatten badly. */
.svc-face__btn {
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: inherit;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  position: relative;
  transition: background-color var(--t-fast) var(--ease);
}
.svc-face__btn:hover { background: rgba(255, 255, 255, 0.05); }
.svc-face__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.svc-face__brief { display: block; }
/* The affordance. Without it a panel that happens to be a photograph with text
   on it looks exactly like a panel that is not clickable. */
.svc-face__more {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-top: var(--s-3);
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--accent-soft);
}
.svc-face__chev { transition: transform var(--t-fast) var(--ease); }
.svc-face__btn:hover .svc-face__chev { transform: translateX(3px); }

/* -- the travelling border ------------------------------------------------ */
/* On hover (and keyboard focus inside the card) a 2px trace runs around the
   card's perimeter: a conic-gradient comet — transparent tail building to an
   accent head — spun by animating the registered angle it starts from.

   Priced by the FR-64 discipline even though it is hover-only and therefore off
   the scroll path:
     - While NOT hovered the ::after has no background and no mask — an empty
       absolutely-positioned box paints nothing and costs nothing. The paint,
       the mask and the animation exist only inside the :hover/:focus-within
       rule.
     - No filter, no box-shadow. The "glow" is the gradient's own soft tail.
     - The ring is its OWN layer (::after), never a rewrite of the slat: in the
       3D build .svc-face::before is the load-bearing slat surface with its own
       mask, and this must not touch it. ::after is unused on .svc-face in both
       presentations, so the ring rides there — after the slat in paint order,
       under the copy (the button is position: relative and later in the tree).

   The ring-only shape is the two-mask exclude trick, which has the documented
   failure mode (see the slat): an engine that composites `add` instead renders
   a union — a FULL conic rectangle pasted over the card. So the whole effect is
   gated on the engine understanding one of the two composite keywords:
   unsupported means no ring at all, never a wrong one. `--bd-a` is registered
   with @property so the angle interpolates; an engine with mask-composite but
   without @property flips the angle discretely, which renders as a static
   ring — dull, not broken.

   In the 3D tube the face's own opacity (the f² dimming) multiplies the ring,
   so a trace on a receding slat fades with it — only the front face can be
   hovered anyway, since the faces overlap in hit-testing. */
@property --bd-a {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
@keyframes svc-border-spin {
  to { --bd-a: 360deg; }
}
@supports ((mask-composite: exclude) or (-webkit-mask-composite: xor)) {
  .svc-face::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 2px;
    pointer-events: none;
  }
  .svc-face:hover::after,
  .svc-face:focus-within::after {
    background: conic-gradient(from var(--bd-a),
      transparent 0 55%,
      rgba(var(--accent-rgb), 0.06) 68%,
      rgba(var(--accent-rgb), 0.55) 86%,
      rgba(var(--accent-2-rgb), 0.9) 97%,
      #ffe9d2 100%);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    animation: svc-border-spin 2.8s linear infinite;
  }
}

/* -- the expanded capability ---------------------------------------------- */
/* The prose ships inside its own .svc-face, hidden, and is MOVED into the
   takeover on open (main.js §11). It is never rendered inside the face — not on
   a phone, not under reduced motion, not without cos(). ONE presentation, so
   there is one set of rules to reason about and one thing to assert.

   `display: none` is on the FACE's copy rather than on .svc-face__detail
   itself, so the same node lights up the moment it is adopted by the takeover
   host without needing a class toggled on it. */
.svc-face .svc-face__detail { display: none; }
.svc-face__detail[hidden] { display: none; }

/* FR-36u: the phone pillar accordion. main.js §10b inserts one header per
   pillar into the tube; they exist only on phones, and only with JS
   (html.js-svc-acc) does a folded pillar hide its faces — no-JS keeps all ten. */
.svc-acc { display: none; }
@media (max-width: 767px) {
  .js-svc-acc .svc-rail { display: none; }
  .js-svc-acc .svc-tube { gap: var(--s-3); }
  .js-svc-acc .svc-acc { display: block; }
  .js-svc-acc .svc-face:not(.is-acc-open) { display: none; }
  /* An anchor to a capability (#cap-btn-N) lands clear of the fixed header. */
  .svc-face__btn { scroll-margin-top: calc(var(--nav-h) + var(--s-3)); }
  .svc-acc__h { margin: 0; font-size: inherit; }
  .svc-acc__btn {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: "no name chev" "no meta chev";
    align-items: center;
    column-gap: var(--s-3);
    row-gap: 2px;
    width: 100%;
    min-height: 64px;
    padding: var(--s-3) var(--s-4);
    text-align: left;
    color: var(--ink);
    background: var(--surface-2);
    border: 1px solid var(--line);
    border-radius: var(--r-tile);
    cursor: pointer;
    transition: border-color var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
  }
  .svc-acc__btn:hover { background: var(--surface-hover); }
  .svc-acc__btn:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 3px; }
  .svc-acc__btn[aria-expanded="true"] { border-color: var(--accent); }
  .svc-acc__no {
    grid-area: no;
    font-family: var(--font-display);
    font-size: var(--fs-sm);
    font-weight: 700;
    color: var(--accent-soft);
  }
  .svc-acc__name {
    grid-area: name;
    font-family: var(--font-display);
    font-weight: 600;
    font-size: var(--fs-h4);
    line-height: 1.2;
  }
  .svc-acc__meta { grid-area: meta; font-size: var(--fs-eyebrow); color: var(--ink-2); }
  .svc-acc__chev {
    grid-area: chev;
    color: var(--accent-soft);
    transition: transform var(--t-fast) var(--ease);
  }
  .svc-acc__btn[aria-expanded="true"] .svc-acc__chev { transform: rotate(180deg); }
}
.svc-detail__no {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--accent-soft);
}
.svc-detail__name {
  margin: var(--s-1) 0 0;
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.02em;
}
.svc-detail__prose > p {
  margin: var(--s-4) 0 0;
  color: var(--ink-2);
  font-size: var(--fs-sm);
  line-height: 1.65;
}
.svc-detail__label {
  margin: var(--s-5) 0 0;
  font-family: var(--font-display);
  font-size: var(--fs-eyebrow);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink);
}
.svc-detail__list {
  margin: var(--s-3) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--s-2);
}
.svc-detail__list li {
  position: relative;
  padding-left: var(--s-5);
  color: var(--ink-2);
  font-size: var(--fs-sm);
  line-height: 1.5;
}
.svc-detail__list li::before {
  content: "";
  position: absolute;
  left: 4px;
  top: 0.62em;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
}
.svc-detail__foot { margin: var(--s-5) 0 0; }

/* -- the capability takeover ---------------------------------------------- */
/* The full-page explanation. It replaces the side panel that used to sit on top
   of the tube: clicking a capability slides the services stage out to the LEFT
   and slides this in from the RIGHT, one continuous horizontal move, and it owns
   the entire viewport — header included — until Back or Escape reverses it.

   The direction is not arbitrary and it is not a preference: the control that
   opens this reads "Read more ›" with the chevron pointing right, so the page
   it opens has to arrive from the right, the way the next page of anything
   does. It shipped mirrored — arriving from the left against a right-pointing
   arrow — and read as the page going backwards. If the chevron is ever flipped,
   flip these two transforms and the one on .svc-stage__inner with it.

   Fixed and at body level, so nothing above it in the document can clip it or
   out-stack it. Transform and opacity only; the transform is the whole
   animation, and `visibility` is what keeps a closed takeover out of the hit
   test and out of the accessibility tree without a display change that would
   cancel the transition. */
.svc-take {
  position: fixed;
  inset: 0;
  z-index: 300;
  background: var(--canvas);
  transform: translate3d(100%, 0, 0);
  transition: transform var(--t-take) var(--ease-out);
  overflow: hidden;
  overscroll-behavior: contain;
}
/* The `hidden` ATTRIBUTE is the whole closed state — out of the hit test, out
   of the accessibility tree, out of layout. main.js removes it before opening
   and puts it back after the slide has finished.

   Deliberately NOT a `visibility` transition. That is the obvious way to write
   this and it is a trap: `visibility` is transitionable, so the closed rule
   ends up carrying a 360ms delay on it, and an engine mid-delay still reports
   the subtree as hidden — which makes .focus() on anything inside a silent
   no-op. The takeover opened with focus on <body>, and every flush and rAF
   thrown at it made no difference, because the element genuinely was not
   focusable yet. Off-screen at translate(100%) is enough: nothing can be
   clicked at +100vw either. */
.svc-take[hidden] { display: none; }
.svc-take.is-open { transform: translate3d(0, 0, 0); }
.svc-take__inner {
  display: grid;
  grid-template-rows: 34vh 1fr;
  height: 100%;
}
.svc-take__art { position: relative; overflow: hidden; }
.svc-take__art img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 45%;
}
/* The photograph is atmosphere under the copy, never competing with it: a scrim
   in the canvas colour so the body column reads as one surface with the art
   rather than as a picture with text next to it. */
.svc-take__art::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(10, 10, 12, 0.35) 0%, rgba(10, 10, 12, 0.72) 58%, var(--canvas) 92%, var(--canvas) 100%);
}
.svc-take__body {
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding: var(--s-5) var(--s-5) var(--s-8);
}
.svc-take__back {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-bottom: var(--s-5);
  padding: 10px 16px 10px 12px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--ink);
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: 600;
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease);
}
.svc-take__back:hover { background: var(--surface-hover); }
.svc-take__back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.svc-take__host > .svc-face__detail { display: block; }
.svc-take__host .svc-detail__name {
  margin-top: var(--s-2);
  font-size: clamp(1.75rem, 6vw, 2.5rem);
  line-height: 1.05;
}
.svc-take__host .svc-detail__prose > p { font-size: var(--fs-body); }

@media (min-width: 900px) {
  /* Two columns once there is room: the art holds the left third full-bleed to
     the viewport edges, the copy scrolls on the right. */
  .svc-take__inner {
    grid-template-rows: none;
    grid-template-columns: minmax(0, 38%) minmax(0, 1fr);
  }
  /* The last stop has to REACH the canvas colour before the column ends, not at
     it: a gradient that only arrives at #121212 on its final pixel leaves a
     visible vertical seam between the photograph and the copy. */
  .svc-take__art::after {
    background: linear-gradient(90deg, rgba(10, 10, 12, 0.28) 0%, rgba(10, 10, 12, 0.62) 58%, var(--canvas) 88%, var(--canvas) 100%);
  }
  .svc-take__body {
    display: flex;
    flex-direction: column;
    padding: var(--s-7) clamp(var(--s-6), 5vw, 88px) var(--s-8);
  }
  .svc-take__back { align-self: flex-start; }
  .svc-take__host { max-width: 62ch; }
  .svc-take__host .svc-detail__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* Reduced motion gets the same takeover, arriving instantly. The destination is
   not the problem the setting is about; the 100vw slide is. */
@media (prefers-reduced-motion: reduce) {
  .svc-take,
  .svc-take.is-open { transition: none; }
}

/* -- the tube, in 3D ------------------------------------------------------ */
/* Gated three ways on purpose. cos() is what turns --svc-p into a per-face
   dimming factor; without it every `opacity: calc(... cos ...)` is invalid at
   computed-value time and the faces would inherit nothing and stack on top of
   each other. `no-preference` means reduced motion never reaches the 3D at all
   — it keeps the stacked list, which IS the correct static state, rather than a
   frozen cylinder showing one label and hiding nine. */
@supports (opacity: calc(1 * cos(45deg))) {
  @media (min-width: 768px) and (prefers-reduced-motion: no-preference) {
    /* 1120vh, doubled from 560vh (which itself was up from 320vh) at the
       user's request: the stretch between the manifesto and Selected Works
       still felt like "a little bit of scroll". One rotation of the cylinder
       is mapped onto the whole stage, so the stage height IS the reading
       time: each of the ten capabilities now gets ~112vh of scroll to
       arrive, be read and leave, instead of 56vh. This is the number to
       change if the dwell ever feels wrong; nothing else needs touching,
       because everything downstream is a function of --svc-p. */
    /* overflow-x: clip, NOT hidden, and NOT on both axes. The sticky inner
       translates a full viewport to the left when a capability takeover opens,
       and without a clip here that is 100vw of horizontal page scroll. `hidden`
       would make this element a scroll container, which is exactly what kills
       the stickiness of its own child — the same trap documented on .hero and
       on .svc-stage__inner. `clip` on one axis with `visible` on the other is
       explicitly allowed and creates no scrollport. */
    .svc-stage {
      padding-block: 0;
      /* 1290vh = ~1010vh of tube dwell + ~180vh spent by FR-36q's pan budget
         (§10 carves min(1.8 × inner height, 20% of travel) off the front — the
         M47 figure, back since M85 moved the logo scene into the hero and the
         pan is one crossing again: 1590vh / 4.6 × paid for the three-act pan
         from M58 to M84, and every growth was paid for HERE so the tube dwell
         kept its scroll; the shrink gives the same scroll back). */
      min-height: 1290vh;
      overflow-x: clip;
      overflow-y: visible;
    }
    .svc-stage__inner {
      position: sticky;
      top: var(--nav-h);
      height: calc(100vh - var(--nav-h));
      display: flex;
      align-items: center;
      /* clip, not hidden: `hidden` would make this a scroll container and kill
         its own stickiness — the same trap documented on .hero. */
      overflow: clip;
    }

    /* The backdrop, switched on. It fills the sticky box — which IS the
       viewport while the stage is pinned — so the active capability's photo
       owns the whole section, not just the panel it rides on.

       There is deliberately NO `filter` on any of this. The blur, the
       desaturation and the darkening are baked into assets/img/svc-bg/*.jpg by
       tools/gen-assets.mjs. A blur here is a per-pixel cost paid on the
       compositor at device pixel ratio, on ten viewport-sized layers, on every
       frame of the scrub — and it is invisible to rAF timing, which is how this
       section shipped stuttering while measuring as smooth. Baked, it is one
       texture upload, and the 4x upscale from 480px adds softness for free.

       The parallax lives on the CONTAINER, not on the ten layers: one animated
       transform instead of ten, with the children left doing opacity only. */
    .svc-bg {
      display: block;
      position: absolute;
      /* Bled past every edge so the drift below never exposes a hard edge, and
         so the upscaled 480px source never has to reach exactly to a corner. */
      inset: -5%;
      z-index: 0;
      pointer-events: none;
      transform: translate3d(0, calc(var(--svc-p) * -34px), 0);
      /* FR-36q: the backdrop develops in place on the same number the grid
         slides on — a photograph sliding under a sliding card reads as two
         objects; one fading in behind it reads as a room lighting up. */
      opacity: var(--svc-in);
    }
    .svc-bg__layer {
      position: absolute;
      inset: 0;
      /* --f is the face's own cos() factor: 1 dead ahead, 0 edge-on. The eighth
         power is what makes this a cross-fade rather than a pile — at a face,
         the neighbour 36deg away is down to 0.18, and the one after that to
         0.001. Squaring three times, because CSS has no pow(). */
      --f: max(0, cos(calc(var(--i) * -36deg + var(--svc-rot))));
      --f2: calc(var(--f) * var(--f));
      --f4: calc(var(--f2) * var(--f2));
      opacity: calc(var(--f4) * var(--f4));
    }
    /* Layers more than two steps from the front face are already invisible —
       the eighth power put them at 0.001 — but invisible is not free: the
       engine still recalculates their style when --svc-p changes and still
       composites them. content-visibility takes them out of style, layout and
       paint entirely. main.js §10 sets .is-far when the FRONT INDEX changes,
       which happens ten times across the whole stage, never per frame; a ±2
       window means a layer is laid out two full steps before anyone can see it,
       so nothing pops. With no JS the class is never applied and all ten
       render — which at --svc-p: 0 is still exactly one visible photo.

       THE BACKDROP LAYERS ONLY. This was briefly applied to .svc-face too, and
       it cost more than it saved: content-visibility: hidden takes a subtree out
       of the accessibility tree and out of find-in-page, so nine of the ten
       capability names stopped being readable text — which is FR-36a, the one
       thing the tube is not allowed to break. The backdrop is aria-hidden
       decoration and has no such content to lose. The faces are cheap enough not
       to need it anyway: their surface is three static gradients dimmed by
       `opacity`, with no photo, border, shadow or filter on any of them. */
    .svc-bg__layer.is-far { content-visibility: hidden; }
    .svc-bg__layer img {
      width: 100%;
      height: 100%;
      /* aspect-ratio: auto beats the width/height attributes, which are only
         there so the lazy image reserves a ratio rather than a size. */
      aspect-ratio: auto;
      object-fit: cover;
      object-position: center 45%;
    }
    /* Fades to canvas at the top and bottom of the sticky box, so the section
       has no edge: the photo arrives out of the page above it and dissolves
       back into the page below.

       The horizontal pass is the section's own contribution to contrast: it
       darkens the left side, where the copy card sits, over an unvetted
       photograph. Since M21 it is no longer the last line of defence for the
       capability copy — each face carries its own translucent slat and that is
       what the AA measurement lands on — but it is still what keeps the gaps
       BETWEEN the slats from going bright, and it is a property of the section,
       which is why it lives here. */
    .svc-bg::after {
      content: "";
      position: absolute;
      inset: 0;
      /* M75: deepened WHERE THE COPY IS, and only there. The client read the
         section as washing out its own text; the middle of the frame was the
         problem — 0.26 vertical over 0.20 horizontal let 59% of a bright
         photograph through, and the gaps BETWEEN the face slats sat on that.
         The left two-thirds (the copy card, then the tube) now run 0.84 → 0.62
         over a 0.44 vertical, roughly a third of the light they used to pass.
         The right third does the OPPOSITE and lifts to 0.05, because the
         section still has to read as photography: darkening it uniformly is
         what M28/M30 shipped and had to undo, and the suite fails on it (`the
         backdrop still reads as a photograph beside the tube`). One gradient
         pair, two jobs — scrim under the words, window to the right of them. */
      background:
        linear-gradient(180deg,
          var(--canvas) 0%, rgba(10, 10, 12, 0.74) 14%,
          rgba(10, 10, 12, 0.44) 50%,
          rgba(10, 10, 12, 0.78) 86%, var(--canvas) 100%),
        linear-gradient(90deg,
          rgba(10, 10, 12, 0.84) 0%, rgba(10, 10, 12, 0.62) 45%,
          rgba(10, 10, 12, 0.30) 70%, rgba(10, 10, 12, 0.05) 100%);
    }

    .svc-stage__grid {
      /* Above the backdrop. Without this the grid is a static sibling of an
         absolutely-positioned layer and paints under it. */
      position: relative;
      z-index: 1;
      grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.2fr);
      /* Headroom for the parallax below. Without it the eyebrow drifts up under
         the sticky header and `overflow: clip` shaves the top off it. */
      padding-block: var(--s-6);
      /* FR-36q: the whole composition arrives from off-screen right, scrubbed
         by §10 on --svc-in (0 → 1, eased in JS so up and down retrace the same
         frames). Deliberately NOT on .svc-stage__inner — the inner permanently
         carries `transition: transform` for the takeover slide and would trail
         every scroll event by 360ms. The grid has no transition; .svc-stage's
         overflow-x: clip swallows the off-screen phase. By the time a
         capability can be clicked --svc-in is 1, so this transform and the
         takeover's never fight. */
      transform: translate3d(calc((1 - var(--svc-in)) * 100vw), 0, 0);
    }

    /* Copy drifts up a little slower than the page — the parallax that sells
       the tube as a separate object floating in front of it. Transform only;
       see the note on .svc-stage__copy above for why there is no blur here. */
    .svc-stage__copy {
      transform: translate3d(0, calc(var(--svc-p) * -24px), 0);
    }

    .svc-tube-wrap {
      height: min(86vh, 760px);
      /* Opened up from 1500px along with the radius below. Perspective is the
         distance from the viewer to the z=0 plane: keeping it at 1500 while the
         cylinder grew to ~580px of radius would have thrown the receding faces
         into a fisheye. */
      perspective: 2200px;
      perspective-origin: 50% 50%;
      display: grid;
      place-items: center;
      /* The panels above and below the front one run off the top and bottom of
         the wrap mid-sentence. A mask dissolves them into the section instead of
         guillotining them — it is the difference between "a cylinder continuing
         past the frame" and "text that got cut off". Mask, not overflow: hidden,
         because hidden here would clip the 3D and flatten the depth. */
      -webkit-mask-image: linear-gradient(180deg,
        transparent 0%, #000 16%, #000 84%, transparent 100%);
      mask-image: linear-gradient(180deg,
        transparent 0%, #000 16%, #000 84%, transparent 100%);
    }
    /* THERE IS NO WASH OVER THE TUBE, AND THERE MUST NOT BE ONE.
       A `.svc-tube-wrap::before` gradient lived here through two rewrites — once
       at 0.66 alpha holding the capability copy above AA, then at a sixth of
       that as insurance against the gaps between slats flaring on a bright
       photograph. Both were the same mistake in different strengths: the tube
       rotates and a viewport-nailed rectangle does not, so a visitor reads it as
       exactly what it is — a dark box pasted over the photography, with panels
       sliding around underneath it. The client saw it at 0.34 as clearly as at
       0.66.

       Every job it was doing has a home that turns with the tube: the copy's
       contrast is the slat surface on .svc-face::before below, and the gaps
       between slats are the section's business, handled by the horizontal pass
       on .svc-bg::after. If a bright photograph ever flares through the tube
       again, adjust the falloff of the slat mask or that pass. Do not put a
       rectangle back here. (.svc-tube-wrap keeps `position: relative` — the glow
       below is absolutely positioned against it.) */
    .svc-tube__glow {
      display: block;
      position: absolute;
      left: 50%; top: 50%;
      width: 130%;
      aspect-ratio: 1;
      transform: translate3d(-50%, -50%, 0) scale(calc(0.82 + 0.28 * var(--svc-p)));
      border-radius: 50%;
      /* Softer than it was: there is a photograph behind the tube now, and the
         glow's job shrank from "light the section" to "lift the front panel off
         the backdrop".
         No `filter: blur()`. It never needed one — this is a radial gradient,
         which is already a smooth ramp; the blur was re-rasterising a
         near-viewport-sized layer on every frame to soften something that was
         soft. The stops below carry the falloff instead. */
      background: radial-gradient(circle at 50% 50%,
        rgba(var(--accent-rgb), 0.15) 0%, rgba(var(--accent-rgb), 0.1) 22%,
        rgba(var(--accent-2-rgb), 0.05) 46%, transparent 72%);
      pointer-events: none;
    }
    .svc-tube {
      position: relative;
      display: block;
      width: 100%;
      /* --face-h is the panel height; --tube-r is the cylinder radius that makes
         ten panels of that height meet edge to edge, plus a little air:
         r = (h / 2) / tan(180deg / 10) = h / 0.6498, i.e. h × 1.539.

         The radius is DERIVED from the height, not typed next to it. They were
         two independent numbers before, which is a standing invitation for one
         to be tuned and the other forgotten — and the failure mode is silent:
         the panels stop meeting edge to edge and the cylinder reads as ten
         floating slabs.

         The height itself is sized to the copy, not chosen. It has to stay
         taller than a number + a two-line name + a four-line brief + the "read
         more" row, or the body overflows the face it is supposed to sit inside:
         clipped on screen, and — the trap this suite keeps re-teaching — the
         contrast check then samples the photograph BEHIND the panel instead of
         the panel. Bigger than the old flat 300px on every viewport that can
         afford it, because the whole point of this pass was that the tube was
         too close and too cramped to read. */
      --face-h: clamp(300px, 42vh, 400px);
      --tube-r: calc(var(--face-h) * 1.539);
      height: var(--face-h);
      transform-style: preserve-3d;
      /* The -r push-back is what keeps the front panel at its LAYOUT size.
         Without it every face is translateZ(+r) toward a 1500px perspective and
         renders ~1.33x too wide, spilling over the copy column beside it. Pushed
         back by exactly r, the front face sits at z=0 (scale 1) and only the
         receding ones shrink — which is the depth cue we actually wanted. */
      transform: translate3d(0, 0, calc(var(--tube-r) * -1)) rotateX(var(--svc-rot));
    }
    /* THE SLAT. Each face carries a soft translucent surface that turns with
       it, and that surface is the whole reason the section reads as a drum.

       The history here matters, because this rule has been to both extremes.
       It began as ten opaque cards, each with its own full-bleed <img> and a
       60px-blur drop shadow whose alpha was recomputed from --f every frame —
       ten large painted-and-composited layers per frame, and it looked like a
       card floating on a photograph of itself. So it was stripped to nothing:
       bare text on a single static wash. That fixed the cost and broke the
       illusion. With no surface anywhere on the tube, the only dark shape in
       the frame was the wash, the wash does not rotate, and the client reported
       — correctly — that nothing looked like it was spinning.

       What is here now is the third answer: a surface, but a CHEAP one. A
       layered linear-gradient and a radius. No photograph, no border, no
       filter, no box-shadow (FR-64), no per-face will-change. Three gradients
       in one `background` shorthand is still one paint, and the paint is static
       — brightness comes from the element's `opacity`, which the compositor
       handles, never from an alpha recomputed inside the gradient.

       The three layers, top to bottom:
       1. a soft light-to-dark shading down the face, so the slat reads as a
          surface that caught the light along its top and turned away below.
          This used to be a 1px hairline at each edge, which was the right
          drawing for a card and the wrong one for something with no edges — the
          mask below removes precisely the pixels a hairline lives in;
       2. a broad diagonal sheen, brightest where the panel faces up-left;
       3. the body itself, 0.80 alpha under the copy column and letting go
          toward the right edge so the backdrop photograph shows through the
          side of every slat.

       WHY THE SURFACE IS A ::before AND NOT THE FACE'S OWN BACKGROUND.
       The slat has no edges — it dissolves into the photograph on all four
       sides, which is the difference between a panel of light turning past and
       a hard-cornered card sliding by. That dissolve is a `mask-image`, and a
       mask applies to the element's WHOLE subtree: on .svc-face it would eat the
       capability name, the brief, the chevron and the accent bar along with the
       paint. So the paint moved down one level, onto a pseudo-element with
       nothing inside it, and the copy sits above it untouched.

       Paint order is why this works without a z-index on the surface: the ::before
       is absolutely positioned, and so is .svc-face__btn (`position: relative`),
       so both paint in the positioned pass — and the button comes later in tree
       order, which puts every pixel of copy on top of the surface. Remove
       `position: relative` from the button and the text disappears under it. */
    .svc-face {
      position: absolute;
      inset: 0;
      margin: 0;
      border: 0;
      /* The fallback's flat card surface, off. What replaces it is the masked
         ::before below — and the face itself must carry NO background, or its
         unmasked rectangle would draw the hard edge the mask exists to remove. */
      background: none;
      border-radius: var(--r-card);
      backface-visibility: hidden;
      /* Face i sits at i × 36deg around the axis; the tube's +rotateX above
         brings i to the front when --svc-p = i/9. */
      transform: rotateX(calc(var(--i) * -36deg)) translateZ(var(--tube-r));
      /* --f is 1 when this face is dead ahead, 0 once it is edge-on or behind. */
      --f: max(0, cos(calc(var(--i) * -36deg + var(--svc-rot))));
      --f2: calc(var(--f) * var(--f));
      --f4: calc(var(--f2) * var(--f2));
      /* SQUARED, not to the fourth. The falloff moved down here from the copy
         (see .svc-face__body) and it had to get gentler on the way, because the
         two layers want opposite things. A drum needs its receding slats to
         stay VISIBLE — at the old fourth power the neighbour 36deg off-axis was
         at 0.43 and the one after it at 0.03, so there was exactly one panel on
         screen and a drum with one slat is a sign. Squared, those land at 0.66
         and 0.10: three surfaces in frame, each dimmer than the last, which is
         what a cylinder looks like. The copy re-applies the missing square
         itself so the TEXT hierarchy is unchanged. */
      opacity: calc(0.02 + 0.98 * var(--f2));
    }
    /* THE SLAT SURFACE ITSELF — three static gradients, then dissolved.
       Everything about the brightness of this thing is handled one level up by
       `opacity` on .svc-face, which the compositor animates for free. Nothing
       here reads --f. An alpha recomputed from --f inside a gradient stop is
       what re-rasterises ten layers a frame, and it is what got the original
       photo cards deleted; do not reintroduce it, here or in the mask.

       The mask is a single ellipse rather than a pair of composited linear
       fades. Two masks need `mask-composite: intersect` with a `-webkit-` twin
       that takes a DIFFERENT keyword, and when an engine ignores either one the
       layers fall back to `add` — a union, which is no feather at all and looks
       exactly like the rectangle we are removing. One radial cannot fail that
       way: unsupported means unmasked, supported means correct.

       The geometry, off a 378 × ~700px face: rx 76% and ry 52% put the solid
       core over the copy column and take every edge and all four corners to
       zero. The centre is at 42% so the copy's left margin sits inside the core
       — the copy is not centred in the face, so neither is the light. Checked
       against the pixels: the name's top-left, the furthest point of copy from
       the centre, still sits at ~0.84 of full surface, and the AA measurement in
       verify.mjs samples there. The body gradient carries 0.80 rather than the
       0.72 it had when it was unmasked, which is where that 16% went. */
    .svc-face::before {
      content: "";
      position: absolute;
      inset: 0;
      border-radius: var(--r-card);
      pointer-events: none;
      background:
        linear-gradient(180deg,
          rgba(255, 255, 255, 0.10) 0%, rgba(255, 255, 255, 0.02) 14%,
          rgba(255, 255, 255, 0) 46%, rgba(0, 0, 0, 0.18) 86%,
          rgba(0, 0, 0, 0.3) 100%),
        linear-gradient(128deg,
          rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.018) 42%,
          rgba(255, 255, 255, 0) 78%),
        /* M64: deeper. 0.8/0.7/0.38/0.2 → 0.92/0.86/0.62/0.42 — "the cards
           need to be darker so the text is more readable". M77: deeper again,
           → 0.97/0.95/0.86/0.70, the same note a second time.

           M64 solved the wrong half. The copy column already measured 15:1
           against its own surface; what the client was reading as "hard to
           read" is the RIGHT of the slat, where 0.42 of deep canvas let a
           bright backdrop photograph through at full texture right beside the
           sentence — and the receding neighbours, whose whole surface is
           multiplied by their f² opacity, so 0.86 of tint at 0.66 of opacity
           is 0.57 of tint and the photo comes back through the panel that is
           still carrying legible type.

           The card is now an opaque dark surface across the copy column and
           only lets the photograph through at its outer edge. That edge is
           still real — the radial mask feathers the last 24% of the slat to
           nothing regardless of what the gradient says, so the panel keeps its
           lit turning edge and the backdrop still reads THROUGH the tube; it
           just no longer competes with the type. */
        linear-gradient(96deg,
          rgba(13, 13, 17, 0.97) 0%, rgba(13, 13, 17, 0.95) 62%, rgba(13, 13, 17, 0.70) 84%, rgba(13, 13, 17, 0.44) 100%);
      -webkit-mask-image: radial-gradient(76% 52% at 42% 50%,
        #000 0 40%, rgba(0, 0, 0, 0.9) 62%, rgba(0, 0, 0, 0.55) 82%,
        transparent 100%);
      mask-image: radial-gradient(76% 52% at 42% 50%,
        #000 0 40%, rgba(0, 0, 0, 0.9) 62%, rgba(0, 0, 0, 0.55) 82%,
        transparent 100%);
    }
    /* The accent rule that replaced the panel's border. One 3px bar down the
       left of the front face, sharpened by the fourth power so only the
       capability being read is marked. */
    .svc-face__btn::before {
      content: "";
      position: absolute;
      left: 0;
      top: 28%;
      bottom: 28%;
      width: 3px;
      border-radius: 999px;
      background: var(--accent);
      opacity: calc(var(--f2) * var(--f2));
      pointer-events: none;
    }

    /* The photograph is not deleted — it is switched off. The stacked-list
       fallback below 768px and under reduced motion still shows photo + name +
       brief per panel, and that is the correct presentation there: no backdrop
       exists on that path, so the photo is the only imagery in the section.
       A display: none subtree costs nothing in style, layout or paint here. */
    .svc-face__art { display: none; }

    /* The button has to fill the face, or `height: 100%` on the body below
       resolves against an auto-height parent and the copy stops being centred
       in the panel it belongs to. */
    .svc-face__btn { height: 100%; }
    /* No background-colour hover — there is no surface left to lighten. The
       affordance is the chevron and the copy brightening. */
    .svc-face__btn:hover { background: none; }
    .svc-face__btn:focus-visible { outline-offset: 4px; }
    /* The copy carries the OTHER square. .svc-face dropped from f⁴ to f² so the
       slat surfaces stay visible as they recede; multiplying that by another f²
       here puts the text back on exactly the f⁴ curve it was tuned to — the
       neighbour 36deg off-axis reads at 0.66 × 0.66 = 0.43, unchanged. Surface
       and type now fall away at different rates, which is the correct answer
       for both: you can see three panels turning, but only one of them is
       asking to be read. */
    .svc-face__body {
      height: 100%;
      display: flex;
      flex-direction: column;
      justify-content: center;
      max-width: 78%;
      padding: var(--s-5) var(--s-6);
      opacity: calc(0.04 + 0.96 * var(--f2));
    }
    /* Bigger than the boxed version. The panel is no longer competing with its
       own border for attention, and the face grew — so the name can carry the
       weight the card used to. */
    /* M64: one step bigger across the card — name 1.5–2rem → 1.75–2.25rem,
       brief 1rem → 1.125rem, sentence 0.875rem → 1rem. The name stays on two
       lines at 1440×900 (the longest, "Creative Press Release", fits 2 lines
       at 2.25rem in the 78% column) and the three-line clamp on the sentence
       still holds inside the slat — the fit check measures it. */
    .svc-face__name {
      font-size: clamp(1.75rem, 2.5vw, 2.25rem);
      letter-spacing: -0.02em;
    }
    .svc-face__brief {
      max-width: 44ch;
      margin-top: var(--s-3);
      font-size: 1.125rem;
      line-height: 1.55;
      color: rgba(255, 255, 255, 0.9);
    }
    .svc-face__desc {
      max-width: 48ch;
      font-size: var(--fs-body);
      line-height: 1.55;
      color: rgba(255, 255, 255, 0.8);
    }
    /* The poster layer (M52). The ghost numeral scales up to the slat and sits
       in its top-right quarter — outside the copy column (the body is
       max-width 78%, left-anchored, vertically centred), so it never sits
       behind the name or brief the AA check samples. It rides the face's own
       f² opacity, not the body's second square: as a slat recedes its numeral
       fades with the surface, a beat behind the copy — a graphic, not text.
       Text-stroke + colour transitions only; nothing here is animated by the
       scrub, so FR-64's per-frame budget is untouched. */
    .svc-face__no {
      top: var(--s-5);
      right: var(--s-5);
      font-size: clamp(4.25rem, 6.5vw, 6.75rem);
    }
    .svc-face__kicker {
      font-size: 0.75rem;
      letter-spacing: 0.18em;
    }

    /* -- leaving for the takeover ----------------------------------------- */
    /* Clicking a capability does not open something on top of this stage — the
       stage LEAVES. The sticky inner carries the backdrop, the copy card and
       the tube, so translating that one element takes the whole composition
       out to the LEFT in a single transform while #svc-take arrives from the
       RIGHT over exactly the same --t-take — the direction the "Read more ›"
       chevron promises. The two halves must always be opposites: the stage
       going the same way as the incoming page reads as a shove, not a turn.

       Only the inner moves. Translating #svc-stage itself would make it a
       containing block for its own sticky child and drop the pin mid-scrub;
       translating the three children separately would be three animated layers
       where one will do. */
    .svc-stage__inner {
      transition: transform var(--t-take) var(--ease-out);
    }
    .svc-stage.is-take-open .svc-stage__inner {
      transform: translate3d(-100%, 0, 0);
    }

    /* Promoted only while the stage is actually on screen, and only the four
       elements that carry an animated transform. will-change on the ten faces
       or the ten backdrop layers would be ten more GPU textures at viewport
       size each — the cure costing more than the disease. */
    .svc-stage.is-scrubbing .svc-stage__grid,
    .svc-stage.is-scrubbing .svc-stage__copy,
    .svc-stage.is-scrubbing .svc-bg,
    .svc-stage.is-scrubbing .svc-tube,
    .svc-stage.is-scrubbing .svc-tube__glow { will-change: transform; }

    /* Only inside the 3D build does the rail track the scrub. Elsewhere --on
       stays 1 and the three pillars simply read as a list. */
    .svc-rail__item {
      --on: calc(
        clamp(0, calc((var(--svc-p) - var(--lo)) * 40), 1) *
        clamp(0, calc((var(--hi) - var(--svc-p)) * 40), 1)
      );
    }
  }
}

/* ------------------------------------------------- people (Life at WHIM) */
/* A second page, so it needs its own top spacing: index.html opens with a full
   hero and this one opens directly under the sticky header. */
.section--top { padding-top: calc(var(--nav-h) + var(--section-y) / 2); }
.section--top h1 {
  font-size: var(--fs-display);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.025em;
}

.people-note {
  margin-bottom: var(--s-6);
  padding: var(--s-4) var(--s-5);
  border: 1px solid rgba(var(--accent-2-rgb), 0.32);
  border-radius: var(--r-tile);
  background: rgba(var(--accent-2-rgb), 0.07);
  color: var(--ink-2);
  font-size: var(--fs-sm);
}

.people-grid {
  display: grid;
  gap: var(--s-4);
  grid-template-columns: repeat(4, minmax(0, 1fr));
}
.person {
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  overflow: hidden;
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease),
    transform var(--t-fast) var(--ease);
}
.person:hover { background: var(--surface-hover); border-color: var(--line-strong); transform: translateY(-3px); }
.person__media {
  position: relative;
  /* Fixed ratio + width/height on both <img>s: the roster is above the fold on
     this page, so anything that resolves its own height after the fetch is a
     layout shift. Skeletons of this exact ratio hold the space in the meantime. */
  aspect-ratio: 4 / 5;
  background: var(--canvas-deep);
}
.person__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity var(--t-mid) var(--ease);
}
.person__photo--off { opacity: 0; }

/* Three ways in, one visual result. :focus-within is what makes the keyboard
   path work — the real <button> inside the card is what receives that focus. */
.person:hover .person__photo--off,
.person:focus-within .person__photo--off,
.person.is-flipped .person__photo--off { opacity: 1; }
.person:hover .person__photo--pro,
.person:focus-within .person__photo--pro,
.person.is-flipped .person__photo--pro { opacity: 0; }

.person__fallback {
  display: grid;
  place-items: center;
  font-family: var(--font-display);
  font-size: 2.25rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--ink-3);
  background: linear-gradient(150deg, var(--surface-2), var(--canvas-deep));
}

.person__toggle {
  position: absolute;
  right: var(--s-3);
  bottom: var(--s-3);
  width: 36px; height: 36px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--line-strong);
  background: rgba(10, 10, 12, 0.72);
  color: var(--ink);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.person__toggle:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.person__toggle[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }

/* Fixed floor + clamped fields = a card whose height does not depend on how
   long anyone's job title is. That is what keeps CLS at 0: the skeletons that
   hold the grid open before the fetch resolves can only reserve the right
   height if the real card's height is knowable in advance. */
.person__body { padding: var(--s-4) var(--s-4) var(--s-5); min-height: 232px; }
.person__clamp {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.person__name {
  font-size: var(--fs-h4);
  font-weight: 700;
  line-height: 1.25;
  -webkit-line-clamp: 2;
}
.person__role {
  margin-top: 2px;
  font-size: var(--fs-sm);
  color: var(--ink-2);
  -webkit-line-clamp: 2;
}
.person__market {
  margin-top: var(--s-2);
  font-family: var(--font-display);
  font-size: var(--fs-eyebrow);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent-soft);
}
.person__line {
  margin-top: var(--s-3);
  font-size: var(--fs-sm);
  color: var(--ink-3);
  -webkit-line-clamp: 3;
}

.person--skeleton { border-style: dashed; }
.person--skeleton .person__media { border-radius: 0; }
.person--skeleton .skeleton__line + .skeleton__line { margin-top: var(--s-3); }

.people-cta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  margin-top: var(--s-7);
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
}

/* ------------------------------------------------------------------- stats */
/* Five tiles since M75 — the creator count joined the four campaign averages.
   Five across at desktop; the 1099px breakpoint drops it to 3 + 2. */
.stats { display: grid; gap: var(--s-5); grid-template-columns: repeat(5, minmax(0, 1fr)); }
.stat {
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  padding: var(--s-5);
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.stat:hover { background: var(--surface-hover); border-color: var(--line-strong); }
.stat__num {
  font-size: var(--fs-stat);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
}
.stat__label { margin-top: var(--s-3); font-size: var(--fs-sm); color: var(--ink-2); overflow-wrap: anywhere; }
.stats__note { margin-top: var(--s-5); font-size: var(--fs-sm); color: var(--ink-3); }

/* ----------------------------------------------------------------- clients */
.marquee {
  position: relative;
  overflow: hidden;
  margin-top: var(--s-5);
  /* Reserve both rows' height so populating the wall causes no layout shift. */
  min-height: 176px;
  mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
}
.marquee__track {
  display: flex;
  width: max-content;
  gap: var(--s-6);
  padding-block: var(--s-3);
  animation: marquee 52s linear infinite;
  will-change: transform;
}
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track { animation-play-state: paused; }
.marquee__row:nth-child(2) .marquee__track { animation-duration: 64s; animation-direction: reverse; }
@keyframes marquee {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}
/* WHIM's client logos arrive in two incompatible forms: some are dark artwork on
   a transparent ground, others are full-colour marks on white. Neither survives
   a single dark treatment — half the wall disappears. Seating every mark on the
   same light rounded tile (object-fit: contain, so the tile shows through) makes
   the wall legible and uniform whatever the source file looks like. Desaturated
   and dimmed at rest, full colour on hover. */
.client-logo {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  transition: opacity var(--t-fast) var(--ease), filter var(--t-fast) var(--ease),
    transform var(--t-fast) var(--ease);
  opacity: 0.68;
  filter: grayscale(1);
}
.client-logo:hover { opacity: 1; filter: grayscale(0); transform: scale(1.07); }
.client-logo img {
  width: 64px; height: 64px;
  padding: 7px;
  border-radius: 15px;
  object-fit: contain;
  background: #edeef2;
}

/* --------------------------------------------------------------- ecosystem */
.eco { display: grid; gap: var(--s-3); margin-top: var(--s-5); list-style: none; }
/* The <li> is the grid cell; the card inside it is an <a> for the three ATTN
   siblings that have a site of their own, and a <div> for WHIM itself. */
.eco > li { display: grid; }
.eco-card {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: var(--s-4);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  padding: var(--s-4);
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease),
    transform var(--t-fast) var(--ease);
}
.eco-card:hover { background: var(--surface-hover); border-color: var(--line-strong); transform: translateY(-3px); }
.eco-card--self { border-color: rgba(var(--accent-rgb), 0.42); }
/* Link cards: the whole rectangle is the hit area, so the card itself carries
   the hover step and the focus ring rather than the heading inside it. */
.eco-card--link { color: inherit; text-decoration: none; padding-right: var(--s-6); }
.eco-card--link:hover { border-color: var(--line-strong); }
.eco-card--link:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.eco-card__go {
  display: inline-block;
  margin-top: var(--s-3);
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--accent-soft);
}
.eco-card__ext {
  position: absolute;
  top: var(--s-3); right: var(--s-3);
  font-size: 0.875rem;
  line-height: 1;
  color: var(--ink-3);
  transition: color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.eco-card--link:hover .eco-card__ext,
.eco-card--link:focus-visible .eco-card__ext {
  color: var(--accent-soft);
  transform: translate3d(2px, -2px, 0);
}
.eco-card__art {
  flex: 0 0 auto;
  width: 62px; height: 84px;
  border-radius: var(--r-inner);
  object-fit: cover;
  background: var(--canvas-deep);
}
.eco-card--self .eco-card__art { object-fit: contain; padding: var(--s-2); }
.eco-card h4 { font-size: var(--fs-h4); font-weight: 600; }
.eco-card p { margin-top: var(--s-2); font-size: var(--fs-sm); color: var(--ink-2); }

.about-card { margin-top: var(--s-5); }
.about-grid { display: grid; gap: var(--s-6); grid-template-columns: 1.15fr 1fr; align-items: start; }
.about-copy p + p { margin-top: var(--s-4); }
.about-side__lede { margin-top: var(--s-3); font-size: var(--fs-sm); color: var(--ink-2); }
.about-sub {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: 700;
  letter-spacing: -0.015em;
  margin-bottom: var(--s-4);
}
.about-copy .about-sub:not(:first-child) { margin-top: var(--s-7); }
/* The section's own heading since M49 (the strip lives after Contact now and
   the "Why brands bring us in" card above it is gone) — h2 scale, but inside
   the card's copy column rather than a .section__head band. */
.about-h2 {
  margin-top: var(--s-3);
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.1;
}
.about-copy .about-h2 + .lede { margin-top: var(--s-4); }

/* -- operating footprint --------------------------------------------------- */
/* Country name is always spelled out; the flag is decorative (Windows renders
   regional-indicator pairs as letters, which is fine but is not the label) and
   the ISO code is metadata. Wraps to one per row at 360px without overflowing. */
.ops {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-top: var(--s-5);
  list-style: none;
}
.ops-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-width: 0;
  padding: 8px var(--s-4) 8px var(--s-3);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-pill);
  background: var(--surface-2);
  font-size: var(--fs-sm);
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.ops-chip:hover { background: var(--surface-hover); border-color: var(--ink-3); }
.ops-chip__flag { font-size: 1.05rem; line-height: 1; }
.ops-chip__name { color: var(--ink); font-weight: 500; }
.ops-chip__code {
  font-family: var(--font-display);
  font-size: var(--fs-eyebrow);
  font-weight: 700;
  letter-spacing: 0.08em;
  color: var(--ink-3);
}

/* ----------------------------------------------------------------- contact */
.contact-grid { display: grid; gap: var(--s-6); grid-template-columns: 1fr 1.1fr; align-items: start; }
.contact-aside p { color: var(--ink-2); }
.contact-aside .direct { margin-top: var(--s-6); display: grid; gap: var(--s-3); }
.direct-link {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-4);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-tile);
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease),
    transform var(--t-fast) var(--ease);
}
.direct-link:hover { background: var(--surface-hover); border-color: var(--line-strong); transform: translateY(-2px); }
.direct-link__label {
  display: block;
  font-size: var(--fs-eyebrow);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: 2px;
}
.direct-link__value { display: block; font-weight: 600; }
/* The registered office, three lines (FR-51 — Q18 closed at M75). */
.contact-aside__addr { margin-top: var(--s-5); font-size: var(--fs-sm); line-height: 1.6; color: var(--ink-3); }

.form { display: grid; gap: var(--s-4); }
.field { display: grid; gap: 6px; }
.field--half { min-width: 0; }
.form__row { display: grid; gap: var(--s-4); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.field label { font-size: var(--fs-sm); font-weight: 500; }
.field label .req { color: var(--accent-soft); }
.field input, .field select, .field textarea {
  width: 100%;
  padding: 13px var(--s-4);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-inner);
  color: var(--ink);
  transition: border-color var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
.field textarea { min-height: 128px; resize: vertical; }
.field select {
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--ink-2) 50%),
    linear-gradient(135deg, var(--ink-2) 50%, transparent 50%);
  background-position: calc(100% - 20px) 22px, calc(100% - 14px) 22px;
  background-size: 6px 6px, 6px 6px;
  background-repeat: no-repeat;
  padding-right: 42px;
}
.field select option { background: var(--surface); color: var(--ink); }
.field input::placeholder, .field textarea::placeholder { color: var(--ink-3); }
.field input:hover, .field select:hover, .field textarea:hover { border-color: var(--line-strong); }
.field input:focus, .field select:focus, .field textarea:focus {
  border-color: var(--accent);
  background: var(--surface);
  outline: none;
}
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.field [aria-invalid="true"] { border-color: var(--err); }
.field__error {
  min-height: 0;
  font-size: var(--fs-sm);
  color: var(--err);
}
.field__error:empty { display: none; }

.honeypot {
  position: absolute !important;
  left: -9999px;
  width: 1px; height: 1px;
  opacity: 0;
  pointer-events: none;
}

.form__foot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-4); margin-top: var(--s-2); }
.form__fineprint { font-size: var(--fs-sm); color: var(--ink-3); max-width: 34ch; }

.panel {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s-4);
  padding: var(--s-6);
  border-radius: var(--r-tile);
  border: 1px solid var(--line);
  background: var(--surface-2);
}
.panel__icon {
  display: grid;
  place-items: center;
  width: 44px; height: 44px;
  border-radius: 50%;
  font-size: 20px;
  font-weight: 700;
}
.panel--ok .panel__icon { background: rgba(62, 213, 152, 0.16); color: var(--ok); }
.panel--err .panel__icon { background: rgba(255, 107, 107, 0.16); color: var(--err); }
.panel--ok { border-color: rgba(62, 213, 152, 0.32); }
.panel--err { border-color: rgba(255, 107, 107, 0.32); }
.panel h3 { font-size: var(--fs-h3); font-weight: 600; }
.panel p { color: var(--ink-2); }
.panel__actions { display: flex; flex-wrap: wrap; gap: var(--s-3); }
[hidden] { display: none !important; }

/* ------------------------------------------------------------------ footer */
.footer {
  background: var(--canvas-deep);
  border-top: 1px solid var(--line);
  padding-block: var(--s-8) var(--s-6);
  margin-top: var(--section-y);
}
.footer__grid { display: grid; gap: var(--s-6); grid-template-columns: 1.4fr 1fr 1fr; }
.footer__tagline { margin-top: var(--s-4); color: var(--ink-2); font-size: var(--fs-sm); max-width: 34ch; }
.footer__h {
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: var(--s-4);
}
.footer__links { display: grid; gap: var(--s-2); }
.footer__links a {
  display: inline-block;
  padding: 4px 8px;
  margin-left: -8px;
  border-radius: var(--r-inner);
  font-size: var(--fs-sm);
  color: var(--ink-2);
  transition: color var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
.footer__links a:hover { color: var(--ink); background: var(--surface-hover); }
.footer__bottom {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--s-3);
  margin-top: var(--s-7);
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
  font-size: var(--fs-sm);
  color: var(--ink-3);
}

/* ----------------------------------------------------------------- reveals */
/* Scroll-entry for every major block on both pages. Three variants, one
   mechanism: transform + opacity only, so this can never cost a pixel of CLS.
   `--rd` is the stagger, written by main.js §4 from the element's position among
   its reveal siblings — position, not arrival order, so a group always populates
   left-to-right no matter which corner of it crossed the threshold first.

   NEVER nest a .reveal inside another .reveal. The child would fade inside a
   fading parent and the two opacities multiply into mud; the whole markup is
   arranged so each block belongs to exactly one reveal group. */
.reveal {
  opacity: 0;
  /* translate3d, not translateY: this is the one place a compositor layer is
     worth asking for, and the reveals fire while the hero or tube scrub may be
     running. */
  transform: translate3d(0, 18px, 0);
  transition: opacity var(--t-slow) var(--ease-out) var(--rd, 0ms),
              transform var(--t-slow) var(--ease-out) var(--rd, 0ms);
}
/* Tiles and logos scale up slightly instead of rising — a grid of twelve things
   all sliding the same distance reads as one slab moving. */
.reveal--tile { transform: translate3d(0, 10px, 0) scale(0.965); }
/* Side-by-side halves come in from their own side. */
.reveal--left { transform: translate3d(-26px, 0, 0); }
.reveal--right { transform: translate3d(26px, 0, 0); }

/* FR-19c/M54: the works tiles are NOT reveals — their entrance is §9f's
   scroll scrub, inline transform/opacity written per frame and CLEARED at
   settle. The base .work is visible and untransformed on purpose: that is
   the no-JS and reduced-motion state, and the scrub's first write happens in
   the boot turn before anything paints. The only CSS the scrub owns is the
   measuring guard (with the works styles) — everything else is inline. */

.reveal.is-in { opacity: 1; transform: none; }

/* If IntersectionObserver is missing, or JS never runs at all, .js-reveals is
   never added and everything is visible with no transition. That is the escape
   hatch: the hidden state must never be reachable without the code that undoes
   it. */
html:not(.js-reveals) .reveal { opacity: 1; transform: none; transition: none; }

/* --------------------------------------------------------- vault preloader */
/* A cover over the viewport while the page's own assets land, which then splits
   horizontally and opens — top half up, bottom half down.

   THE SAME ESCAPE-HATCH RULE AS .reveal, and for the same reason: this thing
   hides the entire page, so its hidden state must never be reachable without
   the code that undoes it. The markup is inert until `html.js-vault` is set,
   and that class is set by a tiny inline script in <head> — not by main.js. If
   main.js 404s, throws, or is blocked, the vault is simply never armed and the
   visitor sees the page. If main.js loads and then dies mid-way, the inline
   script's own timeout opens it anyway. A preloader whose only way out is the
   script it is covering for is a trap, not a preloader.

   Reduced motion never arms it either: a full-screen cover that appears and
   disappears is exactly the kind of motion that setting exists to refuse. */
.vault { display: none; }

html.js-vault .vault {
  display: block;
  position: fixed;
  inset: 0;
  /* Above the sticky header (100) and the modal (200) — while it is up, it is
     the only thing on screen. */
  z-index: 900;
  pointer-events: auto;
}
/* Nothing in here may become the LCP element: no large text, no large image.
   A preloader that IS the largest contentful paint means every millisecond it
   stays up is a millisecond of measured LCP, and it would trade a nicer entry
   for a worse performance score. Hence a 44px mark and a hairline, no more. */
.vault__half {
  position: absolute;
  left: 0;
  right: 0;
  height: 50.5%;              /* 50.5, not 50: a half-pixel viewport leaves a seam */
  background: var(--canvas-deep);
  transition: transform 820ms cubic-bezier(0.76, 0, 0.24, 1);
  will-change: transform;
}
.vault__half--top { top: 0; }
.vault__half--bottom { bottom: 0; }
/* The seam. A single accent hairline down the middle where the two halves meet,
   so the split is legible as a door opening rather than the page flickering. */
.vault__seam {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 1px;
  transform: translateY(-50%);
  background: linear-gradient(90deg,
    transparent, rgba(var(--accent-rgb), 0.55) 30%, rgba(var(--accent-2-rgb), 0.55) 70%, transparent);
  opacity: 0;
  transition: opacity 300ms var(--ease);
}
html.js-vault.is-vault-ready .vault__seam { opacity: 1; }
/* The wordmark, not a monogram. It is the header's own file at the header's own
   aspect ratio, so the identity a visitor meets behind the doors is the one
   they see the moment the doors open. Sized in vw with a ceiling rather than in
   px, so it reads at the same relative scale on a phone and on a 27-inch
   display; the aspect ratio is locked to the artwork's 315:115.

   Anchored by BOTTOM, not centred on top:50% — centring it put the wordmark's
   own vertical middle exactly on .vault__seam's hairline below, which is the
   same line .vault__half--top/--bottom split along when the doors open. The
   seam (and the gap that opens as the halves slide apart) ran straight across
   the letterforms and read as the logo being cut in two. The mark now sits
   entirely in the top half, comfortably clear of both the seam and of where
   that half's own retreating edge is during its fade — the fade is 240ms,
   under a third of the 820ms slide, so the top half has barely begun moving
   by the time the mark has fully disappeared. Do not recentre this on 50%
   again without re-checking verify.mjs's vault-mark geometry check, which
   asserts the clearance this comment describes. */
.vault__mark {
  position: absolute;
  left: 50%;
  bottom: calc(50% + 20px);
  width: min(200px, 44vw);
  aspect-ratio: 315 / 115;
  transform: translateX(-50%);
  transition: opacity 240ms var(--ease);
}
.vault__mark img { width: 100%; height: auto; display: block; }

/* Open. The halves travel a full viewport each so neither can be caught
   mid-frame at the edge of a tall page.

   The seam and the mark are children of .vault, NOT of the halves — so they do
   not travel with the doors and have to be faded out explicitly. Missing this
   left a bright accent hairline and the logo pinned across the middle of the
   page for the ~900ms between the doors opening and the element being removed.
   It was invisible in a screenshot taken after removal and obvious in one taken
   during it: the hero contrast check found the seam sitting across the subhead
   and reported 1.6:1 on a phone. */
html.js-vault.is-vault-open .vault { pointer-events: none; }
html.js-vault.is-vault-open .vault__mark,
html.js-vault.is-vault-open .vault__seam { opacity: 0; }
html.js-vault.is-vault-open .vault__half--top { transform: translate3d(0, -100%, 0); }
html.js-vault.is-vault-open .vault__half--bottom { transform: translate3d(0, 100%, 0); }

/* ------------------------------------------------------- cursor follower */
/* Decoration, and it is priced like decoration: one fixed element, transform
   and opacity only, no layout reads, and it rides the SAME rAF loop as the two
   scrubs (main.js §9). A second animation loop for a glow is how an unrelated
   section starts dropping frames.

   No `filter: blur()` on it, ever (FR-64). A radial gradient is already a
   smooth ramp; blurring one is a full-size layer re-rasterised behind every
   pointer move, which is the exact cost the tube's glow was rewritten to avoid.

   This is the ONLY thing on the page that follows the pointer. An eye that
   watched the cursor was tried here and removed at the client's request; the
   glow it had replaced is what came back. If a second follower is ever
   proposed, it replaces this one rather than joining it.

   Gated on a fine hovering pointer, so a touch device never renders it — and
   `display: none` there, not just hidden, so it costs nothing at all. */
.cursor { display: none; }

@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
  html.js-cursor .cursor {
    display: block;
    position: fixed;
    left: 0;
    top: 0;
    width: 32px;
    height: 32px;
    margin: -16px 0 0 -16px;
    z-index: 400;
    pointer-events: none;
    border-radius: 50%;
    /* An ANNULUS painted in the gradient, not a `border`. The ring used to be a
       1px border at 0.85 alpha, and a hard bright edge is the one part of this
       that a contrast measurement can see: parked over the hero subhead it took
       that copy to 3.49:1, measured on rendered pixels. The gradient's ring
       peaks at 0.40 and ramps on both sides, so the worst pixel it can put
       behind text is far darker while the shape still reads as a ring.

       These numbers are AT the ceiling, not under it. The check in verify.mjs
       that parks the glow on the subhead and measures reads 5:1 here; at 0.46
       and 32px it read 4.47:1 and failed. Anything brighter has to buy the room
       from somewhere else — a smaller ring, or a scrim under the copy. */
    background: radial-gradient(circle at 50% 50%,
      rgba(var(--accent-rgb), 0.06) 0%, rgba(var(--accent-rgb), 0.1) 34%,
      rgba(var(--accent-rgb), 0.4) 62%, rgba(var(--accent-rgb), 0.14) 82%,
      transparent 100%);
    opacity: 0;
    /* Only these transition. The translate is written every frame by the rAF
       loop and must NOT be transitioned — a transition on a per-frame transform
       is a permanent lag between the pointer and the glow. */
    transition: opacity 220ms var(--ease), width 180ms var(--ease-out),
                height 180ms var(--ease-out), margin 180ms var(--ease-out);
  }
  html.js-cursor .cursor.is-live { opacity: 1; }
  /* Over anything clickable it opens up. Size only — the ring does not also
     brighten, because the hot state is exactly when the glow is most likely to
     be sitting on top of a label. */
  html.js-cursor .cursor.is-hot {
    width: 52px;
    height: 52px;
    margin: -26px 0 0 -26px;
  }
  /* While a dialog owns the screen the glow gets out of the way — it would
     otherwise sit on top of the veil looking like a stray artefact. */
  html.js-cursor .cursor.is-off { opacity: 0; }
}

/* ------------------------------------------------------------- error page */
.errorpage {
  min-height: 100vh;
  display: grid;
  place-items: center;
  text-align: center;
  padding: var(--gutter);
}
.errorpage__code {
  font-family: var(--font-display);
  font-size: clamp(4rem, 18vw, 10rem);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.05em;
  background: linear-gradient(96deg, var(--accent-soft), var(--accent) 45%, var(--accent-2));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* ------------------------------------------------------------ breakpoints */
@media (max-width: 1099px) {
  .people-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  /* 3 + 2, never 4 + 1: an orphan tile on its own row reads as a mistake. */
  .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .about-grid, .contact-grid { grid-template-columns: minmax(0, 1fr); }
}

@media (max-width: 900px) {
  .nav, .header__cta { display: none; }
  .burger { display: flex; }
  .footer__grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}

@media (max-width: 767px) {
  /* Two columns and FIVE tiles is 2 + 2 + 1 — the orphan the 1099px rule above
     exists to avoid, reappearing one breakpoint down (M75 added the fifth tile
     and only re-laid the wider two). The lead stat spans the row instead, so
     the band reads 1 + 2 + 2: a deliberate headline over its supporting
     figures, with no lone tile anywhere. Tied to :first-child rather than to
     the creator tile specifically, because the fix is about the COUNT being
     odd, not about which number happens to lead. */
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .stats > .stat:first-child { grid-column: 1 / -1; }
  /* The cramped hero needs no scroll invitation, and the manifesto band
     doesn't need a whole viewport when it isn't a pinned moment. */
  .hero__cue { display: none !important; }
  .manifesto__pin { min-height: 0; padding: var(--section-y) 0; }

  /* The hero stacks here: the copy runs the full width, and the globe sits
     UNDER it rather than beside it, rising to the centre of the screen on
     the same --hero-g drift the desktop uses sideways, as the copy scrolls
     away above it. At rest the box is pushed down by its own height plus
     --hero-over — main.js §9b's heroFit() writes that: the smaller lift that
     leaves the crown 40px above the fold OR 24px below the copy's last line,
     whichever is LOWER, measured (the copy is four lines of headline in four
     languages, and on a 664px-tall phone it runs past the fold on its own).
     Its default, 0px, parks the centre at 150lvh — below any copy on any
     phone — which is what no-JS gets. Copy and globe never share a pixel at
     any scroll position, which is what keeps FR-15c trivially true here. */
  .hero__copy { grid-template-columns: minmax(0, 1fr); }
  /* Two viewports tall, so the parked globe is INSIDE the box it is drawn in
     (the box clips its overflow, and the fade mask clips to its border box):
     the first viewport is the copy's, the second is where the globe rests.
     The scrim covers the first only. */
  .hero__bg { height: calc(200lvh + var(--hero-over, 0px)); }
  .hero__globe {
    left: 0;
    width: 100%;
    height: 100lvh;
    transform: translate3d(0, calc((100lvh + var(--hero-over, 0px)) * (1 - var(--hero-g))), 0);
  }
  .hero__scrim { height: 100lvh; }
  /* The still states (no scrub: reduced motion, no WebGL, no JS) keep the
     globe parked under the copy, so the section has to be tall enough to
     show it there: the box's centre sits at 150lvh + --hero-over, and the
     pin fan reaches ~200px below it. The scrub's own 400vh budget (above)
     wins where it applies. */
  .hero { min-height: calc(150lvh + var(--hero-over, 0px) + 240px); }

  /* Copy runs the full width here, so the wide-screen left-hand scrim protects
     nothing. Top-down instead: the text lives in the upper band and always
     exits upward, so that is the band that needs holding dark — and it lets
     go on the same --hero-g that lifts the globe into the centre, because a
     static band would sit on the globe once it is there. Copy and globe are
     never on screen together past the crown, so the floor is belt and braces
     for a short viewport, not the thing FR-15c rides on here. */
  .hero__scrim {
    background: linear-gradient(to bottom,
      rgba(10, 10, 12, 0.94) 0%, rgba(10, 10, 12, 0.6) 40%, transparent 70%);
    opacity: calc(1 - var(--hero-g));
  }
}

@media (max-width: 679px) {
  .people-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .form__row { grid-template-columns: minmax(0, 1fr); }
  .footer__grid { grid-template-columns: minmax(0, 1fr); }
  .hero__actions .btn { flex: 1 1 auto; }
}

/* Below ~420px two stat columns cannot hold a 40px numeral plus its label
   without forcing the grid wider than the viewport. */
@media (max-width: 440px) {
  .stats { grid-template-columns: minmax(0, 1fr); gap: var(--s-3); }
  .stat { padding: var(--s-4); }
  /* Two 4:5 portraits plus a name and a role do not fit side by side here. */
  .people-grid { grid-template-columns: minmax(0, 1fr); }
}

/* ------------------------------------------------------- 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.01ms !important;
    scroll-behavior: auto !important;
  }
  /* No scrub: main.js §9b and §10 attach no scroll listener at all under
     reduced motion (§9g draws the globe ONCE, settled on Asia with its pins —
     a still, not an animation), and these pin both scenes to their p=0
     composition so a media-query flip mid-session cannot leave a stale inline
     value behind (an !important declaration outranks the inline property JS
     would have set). The services tube needs no such pin for its geometry — the
     3D build is inside `prefers-reduced-motion: no-preference`, so here the ten
     capabilities are a plain stacked list — but --svc-p still drives the rail. */
  .hero { --hero-p: 0 !important; --hero-g: 0 !important; --hero-cut: 200% !important; }
  /* --hero-over is layout (where the phone's globe rests), not motion: §9b's
     heroFit() still writes it here. */
  .svc-stage { --svc-p: 0 !important; }
  /* Both are gated on `no-preference` in their own rules already; this pin is
     belt and braces for a preference flipped after the class was added, where
     the doors — or a glow the JS has stopped driving — would otherwise linger
     across the page. */
  .vault, .cursor { display: none !important; }
  .hero__cue-line::after { animation: none; }
  /* §9d never adds js-manifesto under reduced motion, so the words are never
     dimmed; this pin covers a preference flipped after the class landed. */
  .manifesto { --mf-p: 1 !important; }
  .reveal { opacity: 1; transform: none; }
  .marquee { mask-image: none; -webkit-mask-image: none; overflow: visible; }
  .marquee__track {
    animation: none;
    flex-wrap: wrap;
    width: 100%;
    justify-content: center;
    gap: var(--s-4);
  }
  /* Drop only the duplicated loop copies — every real client logo stays visible. */
  .marquee__track .is-dup { display: none; }
}

/* =========================================================================
   VENUE WALKTHROUGH (venue.html) — PRD §4.11
   The whole 3D card. Nothing in here is loaded by index.html or people.html
   beyond these bytes; the stylesheet is shared, the JavaScript is not.
   ========================================================================= */

.venue { margin-top: var(--s-6); }

/* The stage reserves its own height by ASPECT RATIO, before any script runs.
   Everything a visitor can see — poster, canvas, gate, progress, error,
   no-WebGL — is absolutely positioned inside it, so no state change can move a
   pixel of the page. That is the whole of this page's CLS budget (FR-79a). */
.venue__stage {
  position: relative;
  /* `width` MUST be definite. With only `aspect-ratio` + `min-height`, a narrow
     viewport resolves the other way round — the browser takes the 320px floor
     and derives the WIDTH from it (320 × 4/3 = 427px inside a 308px column),
     which pushed the whole document 79px wide on a 390px phone. Pinning the
     width makes height the derived axis again, so min-height raises the box
     without ever widening it. */
  width: 100%;
  max-width: 100%;
  aspect-ratio: 16 / 9;
  min-height: 320px;
  max-height: 74vh;
  overflow: hidden;
  border-radius: var(--r-tile);
  background: var(--canvas-deep);
  border: 1px solid var(--line);
  outline: none;
}
.venue__stage:focus-visible {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.35);
}
.venue__poster,
.venue__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.venue__poster {
  object-fit: cover;
  transition: opacity var(--t-slow) var(--ease);
}
.venue__poster.is-hidden { opacity: 0; }
.venue__canvas { touch-action: none; }
.venue__stage[data-mode="walk"] .venue__canvas { cursor: grab; }
.venue__stage[data-mode="walk"][data-locked="true"] .venue__canvas { cursor: none; }

/* One panel shape for all four states, so gate / loading / error / no-WebGL
   read as the same object changing its mind rather than four components. */
.venue__panel {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-3);
  padding: var(--s-5);
  text-align: center;
  background: linear-gradient(180deg, rgba(6, 6, 8, 0.62), rgba(6, 6, 8, 0.88));
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}
.venue__panel[hidden] { display: none; }
.venue__panel-title {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: 600;
  color: var(--ink);
}
.venue__panel-body {
  color: var(--ink-2);
  max-width: 48ch;
  font-size: var(--fs-sm);
  line-height: 1.6;
}
.venue__panel-meta {
  font-size: var(--fs-sm);
  color: var(--ink-2);
  font-variant-numeric: tabular-nums;
  /* Ships EMPTY and is filled from the HEAD response ("7.6 MB · 3D model").
     In a centred flex column an empty line is 0px, so the text arriving nudged
     the title and button — a real, if small, layout shift (CLS 0.002, and
     intermittent, which is the worst kind to chase). Reserve the line up front;
     the number is allowed to be late, the space is not. */
  min-height: 1.4em;
}

.venue__bar {
  width: min(340px, 74%);
  height: 6px;
  border-radius: var(--r-pill);
  background: var(--surface-hover);
  overflow: hidden;
}
.venue__bar-fill {
  display: block;
  height: 100%;
  width: 0;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  transition: width 180ms linear;
}
/* No Content-Length ⇒ no honest percentage, so the bar travels instead of
   claiming a number it does not have (FR-79). */
.venue__bar.is-indeterminate .venue__bar-fill { animation: venue-indet 1.3s var(--ease) infinite; }
@keyframes venue-indet {
  from { transform: translateX(-110%); }
  to { transform: translateX(320%); }
}

.venue__fs {
  position: absolute;
  top: var(--s-3);
  right: var(--s-3);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: var(--r-tile);
  border: 1px solid var(--line-strong);
  background: rgba(10, 10, 12, 0.66);
  color: var(--ink-2);
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.venue__fs:hover { background: var(--surface-hover); color: var(--ink); }
.venue__fs[hidden] { display: none; }

.venue__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3) var(--s-5);
  margin-top: var(--s-4);
}

/* Segmented pair. Two buttons, one pressed — aria-pressed carries the state and
   the paint follows it, so there is no second source of truth. */
.venue__modes {
  display: inline-flex;
  padding: 3px;
  gap: 3px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
}
.venue__modes[hidden] { display: none; }
.venue-mode {
  padding: 8px var(--s-4);
  border: 0;
  border-radius: var(--r-pill);
  background: transparent;
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--ink-2);
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.venue-mode:hover { background: var(--surface-hover); color: var(--ink); }
.venue-mode[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); }

.venue__pills {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}
.venue__pills[hidden] { display: none; }
.venue-pill {
  padding: 8px var(--s-4);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  font-size: var(--fs-sm);
  font-weight: 500;
  color: var(--ink-2);
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease);
}
.venue-pill:hover { background: var(--surface-hover); color: var(--ink); border-color: var(--line-strong); }
.venue-pill[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

.venue__caption {
  margin-top: var(--s-4);
  color: var(--ink-2);
  font-size: var(--fs-body);
  max-width: 68ch;
  min-height: 1.6em;   /* the caption swapping must not resize the card */
}
.venue__help {
  margin-top: var(--s-3);
  color: var(--ink-3);
  font-size: var(--fs-sm);
  line-height: 1.7;
  max-width: 74ch;
}
.venue__help strong { color: var(--ink-2); font-weight: 600; }
.venue__help kbd {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.85em;
  padding: 1px 5px;
  border: 1px solid var(--line-strong);
  border-radius: 5px;
  background: var(--surface-2);
  color: var(--ink-2);
}

.venue__facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-top: var(--s-6);
  list-style: none;
}

@media (max-width: 640px) {
  .venue__stage { aspect-ratio: 4 / 3; max-height: 62vh; }
  .venue__controls { gap: var(--s-3); }
  .venue__pills { gap: var(--s-1) var(--s-2); }
}

@media (prefers-reduced-motion: reduce) {
  .venue__poster { transition: none; }
  .venue__bar-fill { transition: none; }
  .venue__bar.is-indeterminate .venue__bar-fill { animation: none; width: 100%; }
}
