/* ============================================================
   CHILL-AI — Private and Local Intelligence Fabric
   Design system: near-black surface ladder, hairline borders,
   red as a scarce resource, chrome text for the brand voice.
   ============================================================ */

:root {
  --bg-0: #050506;
  --bg-1: #0b0b0d;
  --bg-2: #131316;
  --line: rgba(255, 255, 255, 0.08);
  --line-strong: rgba(255, 255, 255, 0.16);
  --red: #dc2626;
  --red-hot: #ef4444;
  --red-rgb: 220, 38, 38;
  --ink: #e7e7ea;
  --ink-dim: rgba(231, 231, 234, 0.62);
  --ink-faint: rgba(231, 231, 234, 0.4);
  /* Opaque, not alpha. --ink-dim/--ink-faint measure fine on a static
     backdrop but both fail adversarially (mesh live, worst pixel) on a
     surface the mesh shows through, at ANY alpha < 1 -- the alpha blends
     with whatever's behind (card, halo, mesh) instead of overriding it, so
     a translucent "dim" ink is really "however dark the backdrop is,
     scaled down further". --ink-mid keeps the same visual role (dimmer
     than --ink, used for secondary copy on a live-mesh card) without that
     failure mode: it's a solid tone, so the composited pixel IS this
     colour regardless of backdrop brightness. See .terminal .t-dim. */
  --ink-mid: #a6a6ac;
  --silver: #d4d4d8;
  --font-display: "Chakra Petch", system-ui, sans-serif;
  --font-sub: "Space Grotesk", system-ui, sans-serif;
  --font-body: "Inter", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;
  --rail-w: 56px;
  --radius: 10px;
  /* The fixed topbar's bottom edge, and the floor every pinned scene's top
     padding has to clear. Content tucked behind the bar cannot be scrolled
     out from under it while a scene is pinned, so this is the same class of
     failure as overflowing the fold, not a cosmetic one.
     54px is measured, not declared: `.topbar` is 14px of padding either side
     of a 26px row (a 22px brand mark against a 15px name). Change either and
     re-measure HERE — the three pinned scenes read this and nothing else. */
  --topbar-h: 54px;
  --pin-clear: calc(var(--topbar-h) + 12px);   /* 66px */
  /* Copy halo — every tentpole lifts the veil over its own mesh, so every
     one of them needs the same treatment to keep its copy at its colour
     ceiling: tight blurs, stacked. A wide blur over a 2px stroke is nowhere
     near opaque, and a wide one reads as a dark plate behind the line
     instead of disappearing. Without it the pillars' red sub-heading
     measures 4.0:1 on a 320px phone. It lives here, not in a scene, because
     scenes 3 and 4 already want the identical stack and scenes 5-8 will
     too — four literal copies of one measured value is four chances for
     them to drift apart silently. */
  --copy-halo:
    0 0 2px rgba(5, 5, 6, 1),
    0 0 2px rgba(5, 5, 6, 1),
    0 0 2px rgba(5, 5, 6, 1),
    0 0 5px rgba(5, 5, 6, 0.85);

  /* The same halo, made dense enough to be opaque under a 10-13px mono stroke.
     Where a scene's ink is red (--red-hot tops out at 5.41:1 on --bg-0, so it
     has no margin to give) and the backdrop is the brightest the page has —
     #vision's 0.25 veil over the horizon band, #contact's bloom plus the ember
     convergence — the shared halo alone measured 3.75:1 on the vision label,
     because a 2px blur spread over a 1.5px stroke is not opaque. Three more
     near-zero-blur stops close it: the same label measures 5.40:1 with them,
     i.e. at the ink's ceiling. Sized for small type only — on the large
     headlines the shared halo already reaches 17:1 on its own and this would
     only cost fidelity.

     It COMPOSES the shared halo rather than re-listing its stops: the whole
     reason --copy-halo exists is that a measured value copied out by hand is
     a value that can drift, and a "dense variant" that repeats it verbatim
     is exactly that copy under another name. Every stop below --copy-halo is
     an addition to it, and the file has one measured base. */
  --copy-halo-dense:
    var(--copy-halo),
    0 0 1px rgba(5, 5, 6, 1),
    0 0 1px rgba(5, 5, 6, 1),
    0 0 3px rgba(5, 5, 6, 1);
}

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

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

/* Lenis base styles (required by lenis 1.x). Without the scroll-behavior
   override the native smooth-scroll above fights Lenis on anchor jumps.
   NB: no `touch-action` is set anywhere on purpose — the default `auto`
   scrolls fine through a pin AND keeps pinch-zoom, which `pan-y` would
   silently disable (WCAG 1.4.4). */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-stopped { overflow: hidden; }
.lenis.lenis-smooth iframe { pointer-events: none; }

body {
  margin: 0;
  background: var(--bg-0);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.65;
  letter-spacing: 0.01em;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img { max-width: 100%; height: auto; }
a { color: inherit; }

::selection { background: rgba(var(--red-rgb), 0.35); color: #fff; }

/* ---------- fixed layers ---------- */

#fabric {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
}

/* One fixed veil between the fabric and the content. Its opacity is
   scroll-driven (see driveFabric in main.js): dark under reading blocks,
   lifted where the fabric performs. */
#veil {
  position: fixed;
  inset: 0;
  z-index: 1;
  background: var(--bg-0);
  opacity: 0.82;
  pointer-events: none;
}

.noise {
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  opacity: 0.05;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
}

.progress {
  position: fixed;
  top: 0; left: 0;
  width: 100%;
  height: 2px;
  z-index: 60;
  background: transparent;
}
.progress > span {
  display: block;
  height: 100%;
  background: linear-gradient(90deg, rgba(var(--red-rgb), 0.7), var(--red-hot));
  transform-origin: 0 50%;
  transform: scaleX(0);
}

/* ---------- top bar ---------- */

.topbar {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 14px clamp(20px, 4vw, 48px);
  background: rgba(5, 5, 6, 0.55);
  backdrop-filter: blur(18px) saturate(140%);
  -webkit-backdrop-filter: blur(18px) saturate(140%);
  border-bottom: 1px solid transparent;
  transition: border-color 0.4s ease, background 0.4s ease;
}
.topbar.is-scrolled {
  border-bottom-color: var(--line);
  background: rgba(5, 5, 6, 0.78);
}
.topbar .brand {
  display: flex;
  align-items: center;
  gap: 12px;
  text-decoration: none;
}
.topbar .brand img { height: 22px; width: auto; display: block; }
.topbar .brand .brand-name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: 0.22em;
  color: var(--silver);
}
.topbar nav {
  display: flex;
  gap: clamp(14px, 2.4vw, 32px);
}
.topbar nav a {
  font-family: var(--font-sub);
  font-weight: 500;
  font-size: 13px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--ink-dim);
  transition: color 0.25s ease;
}
.topbar nav a:hover, .topbar nav a:focus-visible { color: var(--ink); }
.topbar nav a.is-active { color: var(--red-hot); }
@media (max-width: 860px) {
  .topbar nav { display: none; }
}

/* ---------- route rail ---------- */

.rail {
  position: fixed;
  right: 18px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.rail button {
  position: relative;
  width: 10px; height: 10px;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--line-strong);
  background: transparent;
  cursor: pointer;
  transition: border-color 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
}
.rail button::after {
  content: attr(data-label);
  position: absolute;
  right: 22px;
  top: 50%;
  transform: translateY(-50%);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-dim);
  background: rgba(11, 11, 13, 0.9);
  border: 1px solid var(--line);
  padding: 4px 8px;
  border-radius: 4px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.rail button:hover::after { opacity: 1; }
.rail button.is-active {
  border-color: var(--red-hot);
  background: var(--red);
  box-shadow: 0 0 10px rgba(var(--red-rgb), 0.7);
}
@media (max-width: 860px) {
  .rail { display: none; }
}

/* ---------- shared section scaffolding ---------- */

main { position: relative; z-index: 5; }

.scene {
  position: relative;
  padding: clamp(96px, 14vh, 180px) clamp(20px, 5vw, 64px);
}
.wrap { max-width: 1160px; margin-inline: auto; position: relative; }
.wrap-narrow { max-width: 820px; }

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--red-hot);
  margin: 0 0 20px;
}
.eyebrow::before {
  content: "";
  width: 28px;
  height: 1px;
  background: rgba(var(--red-rgb), 0.6);
}

h1, h2, h3 { margin: 0; }

h2 {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.9rem, 3.4vw + 0.6rem, 3.4rem);
  line-height: 1.08;
  letter-spacing: -0.015em;
  color: var(--silver);
}

.lead {
  max-width: 640px;
  margin: 22px 0 0;
  font-size: clamp(1.02rem, 1.1vw + 0.6rem, 1.2rem);
  color: var(--ink-dim);
}

/* headline treatment: solid warm silver — no gradient. Weight, tracking
   and a faint lift shadow carry the "machined" feel; red stays scarce. */
.chrome-text {
  color: #f0f0f2;
  text-shadow:
    0 1px 0 rgba(255, 255, 255, 0.05),
    0 16px 48px rgba(0, 0, 0, 0.6);
}

/* ---------- hero ---------- */

/* Every vertical step below is capped in vh as well as px, so the hero
   compresses continuously as the viewport gets shorter (landscape phones)
   instead of overflowing the pin. Horizontal padding respects notches. */
.hero {
  min-height: 100vh;    /* fallback: Safari < 15.4 and other pre-svh browsers */
  min-height: 100svh;   /* stable across address-bar show/hide, unlike dvh */
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding-top: min(120px, 11vh);
  padding-bottom: max(min(80px, 5vh), env(safe-area-inset-bottom));
  padding-left: max(clamp(20px, 5vw, 64px), env(safe-area-inset-left));
  padding-right: max(clamp(20px, 5vw, 64px), env(safe-area-inset-right));
}
.hero .wrap { max-width: 980px; }
.hero-logo {
  width: min(560px, 78vw);
  margin: 0 auto 34px;
  display: block;
  filter: drop-shadow(0 0 24px rgba(var(--red-rgb), 0.35));
}
/* the canvas is a raster — anything the tilt or halo pushes past its
   edge is simply cut off, so the box is deliberately wider than the
   logotype (see LOGO_FILL in hero3d.js) */
#logo3d {
  width: min(1180px, 96vw);
  /* height follows the SHORTER axis: on a landscape phone the viewport
     height, not the width, is what the logotype has to fit inside */
  height: clamp(72px, min(22vw, 26vh), 330px);
  margin: 0 auto min(10px, 1vh);
  display: none;
  /* wider than .hero .wrap, so break out and centre on the hero itself —
     auto margins can't centre a box that overflows its parent */
  position: relative;
  left: 50%;
  transform: translateX(-50%);
}
#logo3d canvas { display: block; width: 100%; height: 100%; pointer-events: none; }
.hero.has-3d #logo3d { display: block; }
.hero.has-3d .hero-logo { display: none; }
.hero .tagline {
  font-family: var(--font-mono);
  font-size: clamp(11px, 1.3vw, 14px);
  font-weight: 500;
  letter-spacing: 0.42em;
  text-transform: uppercase;
  color: var(--red-hot);
  margin: 0 0 min(26px, 2.5vh);
  min-height: 1.4em;
}
.hero h1 {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.55rem, min(5.4vw + 0.8rem, 9.5vh), 5rem);
  line-height: 1.05;
  letter-spacing: -0.02em;
  text-wrap: balance;
}
.hero .dek {
  max-width: 620px;
  margin: min(26px, 2.5vh) auto 0;
  font-size: clamp(0.82rem, min(1.2vw + 0.55rem, 3.4vh), 1.25rem);
  color: var(--ink-dim);
}
.hero .dek strong { color: var(--ink); font-weight: 500; }

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  justify-content: center;
  margin-top: min(40px, 3.5vh);
}

.hero-note {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 18px;
  justify-content: center;
  margin-top: min(44px, 3vh);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.08em;
  /* --ink-dim, not --ink-faint. This is the one element in the hero the bed
     above could not rescue, and the measurement says exactly why: with the
     bed in place its backdrop is 0.0015 -- as black as this page ever gets --
     and it STILL read 2.08:1, because `--ink-faint` (alpha 0.4) composites on
     that backdrop to L=0.057 and (0.057+0.05)/(0.0015+0.05) = 2.08 whatever
     sits behind it. A pure ink ceiling; no bed, veil or mesh change reaches
     it. Clearing 4.5:1 on a 0.0015 backdrop needs L >= 0.187, which alpha 0.5
     only just meets (4.54, no margin); `--ink-dim`'s 0.62 composites to
     L~0.283 and reads 6.47:1. The note stays visibly subordinate to `.dek`
     above it -- it is dimmer type at 12px -- it is merely no longer below the
     floor. */
  color: var(--ink-dim);
}
/* Full `--red-hot`, not `rgba(var(--red-rgb), 0.6)`. With the bed above in
   place these diamonds sit on a 0.0015 backdrop and still read 2.08:1 -- the
   same pure-ink ceiling `.hero-note` itself hit, and for the same reason: at
   alpha 0.6 the red composites to L=0.057. They are separators, so an
   exemption argument is available; it is not needed. Full `--red-hot`
   composites to L=0.229 and reads 5.42:1, which is also the documented
   ceiling for red ink on this page (CLAUDE.md: `--red-hot` tops out ~5.4:1 on
   `--bg-0`) -- so this is the most that can be asked of a red glyph here, and
   it clears. */
.hero-note .sep { color: var(--red-hot); }

.scroll-cue {
  position: absolute;
  left: 50%;
  bottom: 28px;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.34em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.scroll-cue::after {
  content: "";
  width: 1px;
  height: 44px;
  background: linear-gradient(180deg, rgba(var(--red-rgb), 0.9), transparent);
  animation: cue-drip 2.2s ease-in-out infinite;
}
@keyframes cue-drip {
  0% { transform: scaleY(0); transform-origin: top; }
  55% { transform: scaleY(1); transform-origin: top; }
  56% { transform-origin: bottom; }
  100% { transform: scaleY(0); transform-origin: bottom; }
}

/* ---------- buttons ---------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 14px 30px;
  font-family: var(--font-sub);
  font-weight: 700;
  font-size: 14px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-decoration: none;
  border-radius: 8px;
  border: 1px solid transparent;
  transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background 0.25s ease;
  will-change: transform;
}
.btn-primary {
  color: #fff;
  background: linear-gradient(180deg, #e33b3b, #b91c1c);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.25),
    0 0 22px -6px rgba(var(--red-rgb), 0.55);
}
.btn-primary:hover {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.3),
    0 0 34px -4px rgba(var(--red-rgb), 0.75);
}
/* The fill was `rgba(255, 255, 255, 0.02)` -- visually a ghost, and optically
   nothing at all: the mesh reads straight through it and takes the silver
   label to 1.27:1 at 390x844 (measured ink L=0.628 on a backdrop of 0.483,
   `bgmax` 1.0000 -- a white flare directly behind the words).

   A radial bed is the wrong instrument here: this is a bordered rounded rect,
   and an ellipse behind a rectangle leaves its corners unprotected while
   darkening well past its edges. It gets a real fill instead.

   The alpha is solved, not guessed. For the label's measured ink (L=0.628) to
   clear 4.5:1 the composited fill must land at L <= (0.628+0.05)/4.5 - 0.05 =
   0.101; against the worst backdrop this scene actually produces (a pure-white
   flare, L=1.0) that needs 1-a <= 0.101, so a >= 0.899. Shipped at 0.9.
   Border, colour and hover are untouched, so it still reads as the secondary
   action beside `.btn-primary`. */
.btn-ghost {
  color: var(--silver);
  border-color: var(--line-strong);
  background: rgba(5, 5, 6, 0.9);
}
.btn-ghost:hover { border-color: rgba(var(--red-rgb), 0.55); color: var(--ink); }

/* ---------- cards (spotlight border) ---------- */

.card {
  position: relative;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  /* 0.8, not 0.72: a Task 5 review round found the pinned #stack terminal's
     red ink ("$", the cloud-calls "0", the term-bar's live dot — --red-hot
     has the least contrast margin of any ink in the file, CLAUDE.md) still
     under 5:1 adversarially even with the halo raised to --copy-halo-dense.
     Raising this card's opacity thins the mesh that gets through the blur
     for every card built on this rule, not just the terminal's — measured
     (same adversarial method, 1280x700 and 1440x900) moving the red
     instances from 4.47-5.09:1 to 4.58-5.13:1: a small, honest gain (this
     scene's true backdrop is already close to --bg-0 at the moment
     measured — see .terminal pre — so there wasn't much mesh left to
     thin), not the lever that clears all three on its own. "$" clears
     >=5:1; "0" and the live dot don't, and can't by construction — see
     the .terminal .t-red comment for the worked ceiling and why that's an
     accepted, precedented residual rather than a gap in this lever.
     Pushed further (tried 0.88 while tuning) the gain kept diminishing
     for the same reason, so this stops at a modest raise rather than
     chasing an unreachable number by flattening the card toward opaque. */
  background: rgba(11, 11, 13, 0.8);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  padding: 28px;
  transition: border-color 0.3s ease, transform 0.3s ease;
}
.card:hover { border-color: var(--line-strong); transform: translateY(-2px); }
.card::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: radial-gradient(280px circle at var(--x, 50%) var(--y, 50%),
    rgba(var(--red-rgb), 0.55), transparent 70%);
  -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;
  opacity: 0;
  transition: opacity 0.35s ease;
  pointer-events: none;
}
.card:hover::before { opacity: 1; }

.card h3 {
  font-family: var(--font-sub);
  font-weight: 700;
  font-size: 1.15rem;
  letter-spacing: 0.02em;
  color: var(--silver);
  margin-bottom: 6px;
}
.card .card-kicker {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--red-hot);
  margin: 0 0 14px;
}
.card p { margin: 10px 0 0; font-size: 0.96rem; color: var(--ink-dim); }

/* ---------- problem grid ---------- */

.problem-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
  gap: 18px;
  margin-top: 56px;
}
.problem-grid .card h3 { color: var(--ink); }
.problem-grid .num {
  font-family: var(--font-mono);
  font-size: 12px;
  color: rgba(var(--red-rgb), 0.75);
  letter-spacing: 0.2em;
  display: block;
  margin-bottom: 16px;
}
/* scenes.js' initProblem owns opacity/transform on these cards directly
   (arrival tween + the whole-section parallax scrub) — the same properties
   .reveal's own transition (below, later in the cascade, so it would
   otherwise win the `transition` shorthand outright) would then also
   apply to. A CSS transition retriggers on ANY change to its property's
   computed value, including a JS write, so it would chase every one of
   GSAP's per-frame writes with its own 0.8s transition and drag the
   cards behind the curve instead of tracking it — worst on the parallax,
   which scrubs for as long as the section is on screen, not just on
   arrival. Scoped narrowly to this selector so `.reveal` still governs
   every other card (pillars-stack, live-stack bento) untouched. */
.problem-grid .card.reveal { transition: border-color 0.3s ease, box-shadow 0.35s ease; }

/* The generic `.card:hover` lift (`transform: translateY(-2px)`) is dead on
   these four cards and cannot be revived as written: the parallax scrub
   above writes an inline `transform` for as long as the section is on
   screen, and an inline style always outranks a rule. Rather than leave
   four cards that visibly don't respond the way every other card on the
   page does, the same "lifts toward you" cue is re-expressed in a property
   GSAP never touches — an elevation shadow — so it reads the same and
   cannot fight the scrub. The spotlight border (`.card::before`) and the
   border-colour change still pair with it exactly as elsewhere. */
.problem-grid .card:hover { box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55); }

/* ---------- pillars (pinned scene) ---------- */

.pillars-stage { position: relative; }
.pillars-head { text-align: center; }
.pillar-track {
  position: relative;
  max-width: 760px;
  margin: 64px auto 0;
  min-height: 320px;
}
.pillar {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  text-align: center;
  opacity: 0;
  visibility: hidden;
}
.pillar .pillar-index {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.3em;
  /* --red-rgb at 0.8 tops out at 2.99:1 on this page's own black, so no
     backing can rescue it — only the colour can. --red-hot measures
     5.41:1 and is already the site's kicker red (.card-kicker), so this
     also brings the pinned pillars in line with the stacked fallback.
     Scoped to this rule rather than to --red-rgb: that token is used in
     ~25 places, almost all of them glow, rule and border colours where
     the alpha is doing real work. */
  color: var(--red-hot);
  margin-bottom: 18px;
}
.pillar h3 {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.8rem, 3vw + 0.5rem, 2.9rem);
  letter-spacing: -0.01em;
}
.pillar .pillar-sub {
  font-family: var(--font-sub);
  font-weight: 500;
  color: var(--red-hot);
  font-size: 1.05rem;
  margin: 12px 0 0;
}
.pillar p.pillar-body {
  max-width: 560px;
  margin: 18px auto 0;
  color: var(--ink-dim);
}
.pillar-progress {
  display: flex;
  gap: 8px;
  justify-content: center;
  margin-top: 48px;
}
.pillar-progress span {
  width: 34px;
  height: 2px;
  background: var(--line-strong);
  transition: background 0.3s ease, box-shadow 0.3s ease;
}
.pillar-progress span.is-lit {
  background: var(--red-hot);
  box-shadow: 0 0 8px rgba(var(--red-rgb), 0.8);
}

/* mobile: plain stacked list, no pin */
.pillars-stack { display: none; }
@media (max-width: 899px) {
  .pillar-track, .pillar-progress { display: none; }
  .pillars-stack { display: grid; gap: 16px; margin-top: 48px; }

  /* ---------- the mesh's own room: full-bleed bands between the cards ----------

     Below this width every one of these grids collapses to a single column
     (`.problem-grid`'s auto-fit minmax, `.bento`'s `> * { span 12 }`, and
     `.hw-layout`'s two columns starting only at 960px), so the space BETWEEN
     the cards runs the full width of the page. And `veilOf` (js/main.js)
     multiplies every section's veil by MOBILE_VEIL_SCALE here, so that space
     is not merely empty, it is UNDIMMED: the animation gets to be the thing
     it is instead of a texture behind a wall of cards. Card, mesh, card.

     Which sections, and why not the others: bands go only where the page
     SCROLLS. A pinned section is held still and must fit one screen, so a tall
     gap inside one is not breathing room, it is unreachable content. On mobile
     `#hero` and `#pillars` are pinned (pillars' pin is unconditional), so they
     take their presence from the brightness and veil work instead; `#stack`
     and `#hardware` do not pin below 1000x700 / 1280x700 and are banded here.

     TOP-LEVEL SIBLINGS ONLY. The four tiles inside `.spec-list` and the steps
     inside `.flow` keep their tight rhythm -- they read as one component
     rather than as separate cards, and banding them too would multiply the
     page length far past what a reader will scroll.

     `clamp`, not `min(20vh, 170px)`, and the FLOOR is the reason. 20vh is a
     band in portrait (113px at the shortest matrix phone, 568 tall) but
     collapses in LANDSCAPE, where the viewport is 320-430 tall and 20vh is
     64-86px -- under the 90px the sweep asks for, i.e. not a band at all,
     just a slightly bigger gap. The 170px ceiling still stops a tall tablet
     (20vh = 205px at 1024) from opening a chasm.

     ⚠ ID-QUALIFIED (1,1,0) DELIBERATELY, and it must stay that way. The bare
     class selectors do not work here: `.bento { gap: 18px }` and
     `.hw-layout { gap: 40px }` are declared LATER in this file than this
     block, so at equal (0,1,0) specificity source order wins and the bands
     silently do not apply -- measured, `.bento` held 18px on every phone row
     with the bare-class version of this rule in place. Qualifying by section
     id outranks them wherever they sit in the file, and has the side benefit
     of reading exactly like BAND_GROUPS in the sweep.

     ⚠ Asserted by BAND_GROUPS in scripts/viewport-sweep.py, as space AND veil
     TOGETHER -- a gap with the veil still over it is just a gap. All three
     containers below are in that list; if you band a fourth, add it there in
     the same commit. `#contact` is deliberately not banded: it has no card
     grid, only a button row and the contact lines.

     ⚠ `row-gap`, NEVER the `gap` shorthand. `.bento` is a 12-column grid
     (`repeat(12, 1fr)`, with `> * { grid-column: span 12 }` stacking the
     children below 900). The shorthand sets the COLUMN gap as well, and 11
     column gutters of 90-170px give that grid a minimum width of ~1000-1900px
     -- measured: the sweep went from a clean page to 921-1458px of horizontal
     overflow (`p.card-kicker`) on every phone row, wide enough that the
     inflated layout viewport then made the sweep think the `#stack` and
     `#hardware` pins should exist and report them missing. The band is
     vertical; only the vertical property belongs here. */
  #problem .problem-grid,
  #stack .bento,
  #hardware .hw-layout { row-gap: clamp(90px, 20vh, 170px); }
}

/* Safety net: scenes.js sets this when the pin could not be built at all.
   The cycling track is useless without a scrubber (four of the five
   pillars would sit at visibility:hidden with no way to advance them),
   and the stacked list is display:none above 900px, so the fallback has
   to be turned on explicitly rather than merely left alone. */
#pillars.is-static .pillar-track,
#pillars.is-static .pillar-progress { display: none; }
#pillars.is-static .pillars-stack { display: grid; gap: 16px; margin-top: 48px; }

/* The pin now runs at every width (see scenes.js). When it is live the
   cycling track wins over the stacked fallback, which stays as the
   reduced-motion and no-JS path.

   Everything vertical is capped in vh as well as px — the pin is 425% of
   viewport height, so a scene that doesn't fit inside one screen has copy
   that is unreachable, not merely cramped. Same treatment the hero gets. */
#pillars.is-pinned .pillar-progress { display: flex; }
#pillars.is-pinned .pillars-stack { display: none; }

/* The floor clears the fixed topbar (--pin-clear in :root): min(120px, 11vh)
   alone lands underneath it on short viewports, and content tucked behind a
   fixed bar cannot be scrolled clear while the scene is pinned — same class
   of failure as overflowing the fold. */
#pillars.is-pinned {
  padding-top: max(min(120px, 11vh), var(--pin-clear));
  padding-bottom: min(120px, 11vh);
}

/* The pillars stack in one grid cell rather than being taken out of flow,
   so the track is always exactly as tall as the tallest pillar. A fixed
   min-height would let taller copy spill out of the measured box and go
   unnoticed by the viewport sweep. */
#pillars.is-pinned .pillar-track {
  display: grid;
  min-height: min(320px, 34vh);
  margin-top: min(64px, 6vh);
}
#pillars.is-pinned .pillar {
  position: relative;
  inset: auto;
  grid-area: 1 / 1;
}
#pillars.is-pinned .pillar h3 {
  font-size: clamp(1.4rem, min(3vw + 0.5rem, 7vh), 2.9rem);
}
#pillars.is-pinned .pillar .pillar-index { margin-bottom: min(18px, 1.8vh); }
#pillars.is-pinned .pillar .pillar-sub { margin-top: min(12px, 1.4vh); }
#pillars.is-pinned .pillar p.pillar-body { margin-top: min(18px, 2vh); }
#pillars.is-pinned .pillar-progress { margin-top: min(48px, 4.5vh); }
#pillars.is-pinned .pillars-head h2 {
  font-size: clamp(1.4rem, min(3.4vw + 0.6rem, 7.5vh), 3.4rem);
}

/* Copy backing — the veil is lifted to 0.35 here, so the copy carries its
   own local darkening rather than sitting straight on the mesh. Two parts,
   because one alone can't do it (measured, not eyeballed — see the notes
   below each):

   1. a soft bed under the whole wrap, head included, that takes the mesh
      down to a reading surface. It stays soft on purpose: a bed deep
      enough to survive a white-hot node core on its own has to black out
      most of the frame, and the mesh performing behind the copy is the
      entire point of this scene.
   2. a tight dark halo on the glyphs themselves for the one thing the bed
      can't reach — a node core sitting directly under a stroke.

   The bed's side bleed has to be capped against the viewport: once the
   wrap hits its max-width the container can be narrower than the bleed,
   and the overhang widens the document (5px of horizontal overflow at
   932x430). --backing-w is centred on the wrap, which is centred in the
   viewport, so the vw cap keeps both edges on screen at every width. */
#pillars.is-pinned .wrap::before {
  --backing-w: min(124%, 96vw);
  content: "";
  position: absolute;
  top: -6%;
  bottom: -6%;
  left: 50%;
  width: var(--backing-w);
  margin-left: calc(var(--backing-w) / -2);
  background: radial-gradient(ellipse at center,
    rgba(5, 5, 6, 0.92) 0%, rgba(5, 5, 6, 0.72) 45%, rgba(5, 5, 6, 0) 75%);
  pointer-events: none;
  z-index: -1;
}

/* the halo itself is --copy-halo in :root — one measured value, every scene */
#pillars.is-pinned .pillars-head :is(p, h2),
#pillars.is-pinned .pillar :is(p, h3) { text-shadow: var(--copy-halo); }
/* the pillar headline keeps its own machined lift under the halo */
#pillars.is-pinned .pillar h3.chrome-text {
  text-shadow: var(--copy-halo),
    0 1px 0 rgba(255, 255, 255, 0.05),
    0 16px 48px rgba(0, 0, 0, 0.6);
}

/* ---------- stack / bento ---------- */

.bento {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: 18px;
  margin-top: 56px;
}
.bento > * { grid-column: span 12; }
@media (min-width: 900px) {
  /* The hub card used to hold a 600x420 diagram and so earned half the
     grid and two rows; the fabric draws that now and all it carries is a
     caption, which reads as a strip across the top rather than as a tall
     column of empty card. */
  .bento .b-stats { grid-column: span 5; }
  .bento .b-term { grid-column: span 7; }
}

.stat-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 22px 18px;
}
.stat .stat-value {
  font-family: var(--font-mono);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  font-size: clamp(1.7rem, 2.4vw, 2.4rem);
  color: var(--silver);
  line-height: 1.1;
}
.stat .stat-value b { color: var(--red-hot); font-weight: 500; }
.stat .stat-label {
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin-top: 6px;
}

/* hub-and-spoke: the fabric draws it, this card only names it */
.b-hub .hub-caption {
  margin-top: 14px;
  font-size: 0.92rem;
  color: var(--ink-dim);
}

/* Static hub-and-spoke figure — the visual the .b-hub card carries on the
   viewports that never pin #stack (the animated spoke labels light only on
   the pinned path). Hidden exactly when the animated version is present, so
   no viewport shows both and none shows neither. r is set as a presentation
   attribute in the markup, not here, so the nodes render even on engines
   without CSS geometry properties. */
.hub-figure {
  display: block;
  width: 100%;
  max-width: 340px;
  height: auto;
  margin: 16px auto 4px;
  overflow: visible;              /* labels reach past the node columns */
}
.hub-figure .hub-links path { stroke: rgba(var(--red-rgb), 0.35); stroke-width: 1; }
.hub-figure .hub-nodes circle { fill: rgba(var(--red-rgb), 0.85); }
/* .hub-core is also a descendant of .hub-nodes, so it needs a selector at
   least as specific as ".hub-figure .hub-nodes circle" (0,2,1) to actually
   win the fill — ".hub-figure .hub-core" alone (0,2,0) silently lost to the
   generic node rule above. */
.hub-figure .hub-nodes circle.hub-core { fill: var(--red-hot); filter: drop-shadow(0 0 4px rgba(var(--red-rgb), 0.7)); }
.hub-figure .hub-lbl text {
  font-family: var(--font-mono);
  /* 11 is a viewBox="0 0 280 200" USER UNIT, not a CSS px — this SVG scales
     with its container (width:100%, max-width:340px above), so its render
     size tracks the container width, not a fixed px. Measured rendered SVG
     width (Playwright, live DOM, getBoundingClientRect on .hub-figure):
     222px at 320, 262px at 360, 292px at 390, 340px (the max-width cap) at
     768+. A prior comment here claimed 207/247/277 — that figure was itself
     a restated-by-hand correction that turned out wrong; these are the
     re-measured, reproducible numbers (cross-checked two ways: the SVG's
     own screen-CTM scale factor at each width — 0.793/0.936/1.043/1.214 —
     times rendered glyph geometry lands on the same widths independently).
     Do not hand-restate this figure again; measure it.

     At font-size 9 (the prior value) the smallest labels' glyphs didn't
     reach full pixel coverage at 320px and read under 4.5:1 for MOST
     labels, not just the two longest — six of ten (SPEECH, GEO INTEL,
     OSINT, DOCS, LLM ROUTER, IDENTITY) measured 3.71-4.09:1, only SEARCH/
     AGENTS/TRANSLATE/KNOWLEDGE GRAPH cleared the bar (4.97-5.17:1). The two
     longest labels (KNOWLEDGE GRAPH, LLM ROUTER) were the tightest FIT
     constraint though: at font-size 9 "KNOWLEDGE GRAPH" used 86.5 of the
     99-user-unit budget between its anchor (x=181) and the viewBox edge
     (x=280), leaving only ~12.5 units of headroom — not enough to raise
     font-size for every label without it overflowing. Shortened to GRAPH /
     ROUTER (index.html; full names stay in this SVG's aria-label for
     screen readers) specifically to free that headroom, THEN font-size
     raised from 9 to 11 — the smallest integer step that cleared 4.5:1 for
     every one of the ten labels with real margin (10 still left DOCS at
     4.11:1; a glyph-coverage ceiling like the terminal red ink's, not a
     width problem — DOCS was never the tight-fit label). At 11, the
     widest labels (TRANSLATE/GEO INTEL, 51-71 rendered px depending on
     viewport) still clear the SVG's left edge with 27-39px to spare at
     every one of 320/360/390/768, and the widest right-side label
     (IDENTITY) clears the right edge with 33-48px to spare — confirmed by
     direct getBoundingClientRect measurement, not computed from the ratio
     above (see the coverage-ceiling note two paragraphs up for why hand
     arithmetic isn't trusted here). */
  font-size: 11px;
  letter-spacing: 0.08em;
  /* --ink-dim (alpha) measured under 4.5:1 at small viewports — for the
     same reason --ink-dim/--ink-faint fail everywhere else on a live-mesh
     surface (see the --ink-mid definition in :root): the alpha blends with
     whatever's behind it instead of overriding it. --ink-mid is the opaque
     token built for exactly this failure mode.

     Re-measured adversarially at font-size 11 (mesh live, pointer parked
     per-label with a small jiggle, worst fully-covered pixel over 14
     sampled frames vs. 99th-percentile background — CLAUDE.md's method;
     harness reused from the fill-token fix below, not reinvented), ALL TEN
     labels, at both required viewports:

       320x568:  SEARCH 5.61 · AGENTS 5.85 · SPEECH 5.31 · TRANSLATE 5.85 ·
                 GEO INTEL 5.73 · OSINT 6.19 · DOCS 5.33 · GRAPH 5.60 ·
                 ROUTER 5.83 · IDENTITY 5.55  (worst: SPEECH 5.31:1)
       360x640:  SEARCH 5.81 · AGENTS 6.18 · SPEECH 6.18 · TRANSLATE 6.26 ·
                 GEO INTEL 6.18 · OSINT 6.26 · DOCS 5.81 · GRAPH 6.33 ·
                 ROUTER 5.63 · IDENTITY 6.21  (worst: ROUTER 5.63:1)

     Every label clears 4.5:1 with real margin (worst case 5.31:1, +0.81)
     at both viewports — a real, large improvement over the font-size-9
     baseline (3.71-5.17:1 at 320x568, six of ten below the bar). */
  fill: var(--ink-mid);
  dominant-baseline: middle;
}

/* Spoke labels sit on the real fabric nodes, positioned from the engine
   rather than guessed percentages — so this overlay is fixed to the
   VIEWPORT, matching the coordinate space Fabric.points() reports, and it
   lives inside `#stack .wrap` (see the comment on the element in
   index.html: while pinned the section is position:fixed, i.e. its own
   stacking context, which seals the copy backing plate in with it).

   Its whole job is to sit BETWEEN two layers of that context:

     -2  #stack.is-pinned .wrap::before   the copy backing plate
     -1  .spoke-labels                    <- here
      0+ the section's own content        h2, .lead, the bento of proof

   Both are negative so the ordering is decided by z-index alone and does
   not depend on tree order or on which of the section's boxes happen to be
   positioned. Above the plate, because at the labels' peak the plate was
   transmitting ~46% and they measured 2.0:1. Below the content, because
   the bento of real proof has to rise OVER them — that is the
   choreography, and it is the reason this is not simply floated on top. */
.spoke-labels { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
.spoke-labels span {
  position: absolute;
  transform: translate(-50%, -50%);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  /* --ink, not --ink-dim: measured adversarially (mesh live, pulses at
     full, pointer parked on the label, worst 99th-percentile background
     across several frames — CLAUDE.md's method, not a single-pixel max,
     which reads ~255 under "lighter" compositing regardless of real
     legibility, and not a per-frame median, which can't see a bright pulse
     passing under a glyph) at this scene's own peak (p=0.53, scale 1.18) —
     --ink-dim's 62%-alpha glyph measured ~2.9-3.3:1 against the ring of
     pixels immediately around it, well under 4.5:1. That moment used to be
     a single instantaneous frame (build=1 coincided with rise=0 only at
     p=0.55); this task turned it into a sustained ~15pt-of-progress hold,
     so a shortfall here is no longer imperceptible. Opaque ink measured
     back up to a comfortable margin at the same worst background — see
     the Task 4 fix report for the full before/after numbers. Do NOT fix a
     future shortfall here by lifting the veil below FLOOR or shortening
     the hold; those are the choreography's own measured floor and its
     entire point, respectively.

     --copy-halo-dense, not a hand-copied literal: this rule and the
     terminal's (.terminal pre, below) both darken small mono/uppercase
     type against the identical live-mesh-through-a-blurred-card problem,
     and a Task 5 review round caught the terminal's fix carrying a second
     literal copy of this exact shadow with a comment that named the WRONG
     token (--copy-halo, which is a different, looser value — see its own
     comment) — precisely the drift CLAUDE.md's copy-halo rule warns
     about. --copy-halo-dense is the token built for this: "opaque under a
     10-13px mono stroke" (see its definition). Both rules share it now. */
  color: var(--ink);
  text-shadow: var(--copy-halo-dense);
  opacity: 0;
  white-space: nowrap;
}

/* ---------- stack: the pinned composition ----------
   The pin holds #stack still for 320% of the viewport, so the whole
   section has to fit inside one screen while it runs — copy below the fold
   is unreachable, not merely cramped (same rule the hero and the pillars
   live by, hence the same vh caps on every vertical value).

   The bento is the one thing that cannot be capped: three cards of real
   proof. Pinned, it lays out as ONE row — the mesh assembles the hub, then
   the proof rises beside it — instead of the tall column the scrolling
   layout uses. scenes.js only builds the pin where that lands (see
   STACK_PIN_QUERY); everywhere else the section keeps its roomy scrolling
   form and none of this applies. */
#stack.is-pinned {
  /* the floor clears the fixed topbar (--pin-clear in :root) */
  padding-top: max(min(112px, 10vh), var(--pin-clear));
  /* 6vh (not 7.5): 7.5vh was chosen so the box cleared 768 (the 1366x768
     sweep row); the binding case is now the 700px floor (Stack/Laptop floor
     rows), which 7.5vh didn't clear — 6vh clears 768 by more and clears 700
     too, trimming dead bottom padding, no layout change. */
  padding-bottom: min(96px, 6vh);
}
/* 5.6vh, not 6.5: at 700 tall the old cap ran the headline at 45.5px —
   within 1px of its 2.9rem maximum on the shortest viewport that pins.
   5.6vh is inert at >=830px tall (the width term or the 2.9rem cap wins
   there), so this only trims the short end. */
#stack.is-pinned h2 { font-size: clamp(1.6rem, min(3vw + 0.5rem, 5.6vh), 2.9rem); }
#stack.is-pinned .lead { margin-top: min(20px, 2vh); }
#stack.is-pinned .bento {
  margin-top: min(40px, 3.2vh);
  gap: min(18px, 1.8vh);
}
#stack.is-pinned .bento .b-hub { grid-column: span 3; }
#stack.is-pinned .bento .b-stats { grid-column: span 3; }
#stack.is-pinned .bento .b-term { grid-column: span 6; }
#stack.is-pinned .bento .card { padding: min(28px, 2.6vh) min(24px, 2vw); }
/* the cards share one row, so the row is as tall as the wordiest card:
   the caption has to narrow with its column or it sets the height of the
   whole bento at the small end of the pin's range */
#stack.is-pinned .b-hub .hub-caption { font-size: clamp(0.78rem, 1.05vw, 0.92rem); }
/* the animated spoke labels on the live mesh replace it on the pinned path */
#stack.is-pinned .hub-figure { display: none; }
#stack.is-pinned .stat-grid { gap: min(22px, 2.2vh) 18px; }
#stack.is-pinned .stat .stat-value { font-size: clamp(1.4rem, min(1.9vw, 3.4vh), 2.1rem); }
/* The terminal is the widest fixed thing on the page: 8 status lines of
   monospace, the longest 54 characters. It takes half the row and its type
   comes down with the box, because a line that wraps doesn't just look
   wrong — it silently makes the card taller than the fold, and it does so
   only sometimes (font swap, fallback metrics), which is a flake, not a
   bug you can see. 54ch at 0.6em advance needs 32.4x the font size; the
   6/12 column gives ~1.25x that at every width the pin runs at — and the
   floor keeps the smallest case at 10.5px, still 40px clear of a wrap.
   The `vh` term is what makes the pinned box fit its own floor: 1.02vw
   alone hits the 12.5px cap by 1226px wide, and the card reserves
   8 x 1.6em + 2 x pad, so at 700px tall the terminal card alone put the
   section 25.6px over the fold. 1.5vh lands on the 10.5px floor at 700 and
   stops biting above ~833px tall, so every viewport that was already
   comfortable is untouched. */
#stack.is-pinned .terminal { font-size: clamp(10.5px, min(1.02vw, 1.5vh), 12.5px); }
/* Reserve the FINAL height, don't let the box grow as the lines type in.
   The base rule's 200px is the scrolling layout's reservation and is wrong
   here (the pinned type is smaller), but dropping it to 0 made the card
   135px shorter for the ~2s the terminal takes to type — and that is the
   exact box the fold check measures. A pinned scene is asserted to fit one
   screen; if the measured box isn't the shipped box the assertion is
   measuring a state no visitor ever sees. So derive it: one 1.6em line box
   per TERM_LINES entry plus the padding (border-box), with --term-lines set
   from the array itself in main.js so adding a line moves this number too.
   It also removes a mid-pin reflow the viewer could see. */
#stack.is-pinned .terminal pre {
  --term-pad: min(16px, 1.6vh);
  min-height: calc(var(--term-lines, 8) * 1.6em + 2 * var(--term-pad));
  padding: var(--term-pad) 16px;
  line-height: 1.6;
}
#stack.is-pinned .marquee { margin-top: min(32px, 2.4vh); }

/* Copy backing — the veil lifts to 0.45 at the mesh's brightest moment and
   the network formation piles its nodes in the middle of the frame, right
   under the reading column. Same two-part treatment the pillars needed at
   0.35 and for the same measured reason: a soft bed takes the mesh down to
   a reading surface, and a tight halo on the glyphs covers the one thing a
   soft bed cannot — a white-hot node core directly under a stroke. The bed
   is capped against the viewport so its bleed can never widen the page.

   z-index -2, not -1: the spoke-label overlay sits at -1 in this same
   stacking context (the pinned section is position:fixed, so it IS one)
   and has to be above the bed. At -1 the bed painted over the labels at
   its densest stop — measured ~46% transmittance, labels at 2.0:1 — while
   still, correctly, sitting under the copy it backs. Both layers negative
   keeps that ordering explicit and independent of tree order. */
#stack.is-pinned .wrap::before {
  --backing-w: min(118%, 96vw);
  content: "";
  position: absolute;
  top: -8%;
  bottom: -8%;
  left: 50%;
  width: var(--backing-w);
  margin-left: calc(var(--backing-w) / -2);
  background: radial-gradient(ellipse at center,
    rgba(5, 5, 6, 0.62) 0%, rgba(5, 5, 6, 0.44) 48%, rgba(5, 5, 6, 0) 76%);
  pointer-events: none;
  z-index: -2;
}
#stack.is-pinned :is(h2, .lead, .eyebrow) { text-shadow: var(--copy-halo); }

/* terminal */
.terminal {
  padding: 0;
  overflow: hidden;
  font-family: var(--font-mono);
  font-size: 12.5px;
}
/* .term-bar sits OUTSIDE .terminal pre (a sibling, not a descendant), so
   it inherits neither the ink raise nor the halo below — both are set
   here explicitly. A review round on this file caught it still reading
   --ink-faint (40% alpha) with no shadow at all: an alpha ink is only
   ever as light as whatever backdrop shows through it (card blur, mesh),
   so on this live-mesh surface it cannot be fixed by darkening the
   backdrop further — see --ink-mid's definition in :root. */
.terminal .term-bar {
  display: flex;
  justify-content: space-between;
  padding: 10px 16px;
  border-bottom: 1px solid var(--line);
  color: var(--ink-mid);
  text-shadow: var(--copy-halo-dense);
  font-size: 11px;
  letter-spacing: 0.12em;
}
/* inherits .term-bar's text-shadow; --red-hot measured adversarially
   below alongside the .t-red spans it shares its ceiling with — see the
   .t-red comment. */
.terminal .term-bar .live { color: var(--red-hot); }
.terminal pre {
  margin: 0;
  padding: 16px;
  line-height: 1.75;
  /* --ink, not --ink-dim: the plain (unwrapped) text here — the service
     names and the typed "chill status" command — inherits this color and
     this shadow. Measured adversarially (mesh live, pointer PARKED ON
     EACH LABEL's own centre — not one fixed spot for the whole card,
     which under-excites labels away from it — worst FULLY-COVERED pixel
     per frame: the label's own peak ink pixels, an absolute tolerance off
     the crop's own max deviation, not a fraction-of-max or a median over a
     broad "core" mask, both of which a review round found could include
     partial-coverage antialiased edge pixels at this font size and read
     low; 99th-percentile background, worst frame) at the pinned floor
     (1280x700 AND 1440x900, p=0.95, the state a visitor reads): every
     unwrapped service name and "chill status" measured >=14.6:1 at both.
     That headroom is real, not a methodology artifact: the network mesh's
     nodes sit in the upper half of this scene at rest (p=0.95 is past
     HOLD_END, so pulses are already 0 and the mesh has compacted) —
     confirmed by screenshot — so the card's true backdrop here is close
     to --bg-0 through its own blur, not a bright patch of mesh.
     --copy-halo-dense (not the single-stop literal an earlier round of
     this fix left, and not --copy-halo — see the .spoke-labels span
     comment on that mix-up) is carried for the classes below that DON'T
     clear the bar on ink alone; see their own comments for numbers. */
  color: var(--ink);
  text-shadow: var(--copy-halo-dense);
  white-space: pre-wrap;
  min-height: 200px;
}
.terminal .t-ok { color: #4ade80; }
/* --red-hot tops out ~5.4:1 on pure --bg-0 (CLAUDE.md), and real
   antialiasing at this font size brings the practical ceiling down
   further still for a rounder glyph: measured against a HYPOTHETICAL
   fully opaque card (alpha 1, no mesh contribution at all) "$"'s own
   peak pixel (L 0.229, matches --red-hot's hand-computed luminance
   exactly) caps at 5.23:1, but "0"'s own peak pixel (L 0.207 — its
   rounded shape has fewer fully-covered pixels at 10.5-12.5px) caps at
   4.81:1 and the live dot (L 0.218) at 5.02:1 — REGARDLESS of card
   alpha. So of the three, "$" clears >=5:1 (measured 5.12-5.13:1 at the
   shipped 0.8 card alpha); "0" and the live dot cannot by construction —
   they land at 4.58-4.93:1, comfortably clear of the ORIGINAL 4.5 floor
   (not the hairline 4.50-4.54 the review flagged) but short of the
   aspirational 5:1 for a reason no lever here can fix. This is the same
   "ceiling, not a preference" situation CLAUDE.md's hardware-scene note
   documents (.flow .flow-note b) — red is kept here because the margin
   over 4.5 is real, not the razor-thin case that prompted this fix. */
.terminal .t-red { color: var(--red-hot); }
/* Opaque, not --ink-faint (0.4 alpha, the pre-existing bug an earlier
   round of this fix left untouched because .terminal pre's own color
   raise only reaches UNWRAPPED text — every .t-dim span keeps overriding
   it). Measured (same adversarial method as .terminal pre, above) worst
   7.48-7.72:1 across all seven .t-dim spans at 1280x700 and 1440x900 —
   clears the >=5:1 bar with real margin; --ink-faint measured 2.45-2.59:1
   in the same conditions, and --ink-dim (62% alpha) would not have
   cleared it either (worked example: at this card's measured backdrop,
   0.62 alpha over near-black composites to ~L 0.29, only ~6.5:1 against
   --bg-0 alone and worse once the mesh's residual brightness is added —
   opaque is the lever that actually holds regardless of backdrop,
   because any alpha blends with it instead of overriding it). */
.terminal .t-dim { color: var(--ink-mid); }
.terminal .cursor {
  display: inline-block;
  width: 7px; height: 14px;
  background: var(--red-hot);
  vertical-align: -2px;
  animation: blink 1s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }

/* marquee */
.marquee {
  margin-top: 40px;
  overflow: hidden;
  border-block: 1px solid var(--line);
  padding: 14px 0;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.marquee-track {
  display: flex;
  gap: 44px;
  width: max-content;
  animation: marquee 42s linear infinite;
}
.marquee:hover .marquee-track { animation-play-state: paused; }
.marquee-track span {
  font-family: var(--font-sub);
  font-weight: 500;
  font-size: 13px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--ink-faint);
  white-space: nowrap;
}
.marquee-track span::after {
  content: "◆";
  margin-left: 44px;
  color: rgba(var(--red-rgb), 0.45);
  font-size: 8px;
  vertical-align: 2px;
}
@keyframes marquee { to { transform: translateX(-50%); } }

/* ---------- hardware ---------- */

.hw-layout {
  display: grid;
  gap: 40px;
  margin-top: 56px;
}
@media (min-width: 960px) {
  .hw-layout { grid-template-columns: 1.1fr 0.9fr; align-items: center; }
}
.spec-list { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.spec {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: rgba(11, 11, 13, 0.72);
  padding: 18px 20px;
}
.spec .spec-value {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 1.35rem;
  color: var(--silver);
}
.spec .spec-label {
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  /* --ink, not --ink-dim, and the reason is the alpha, not the tone.
     `--ink-dim` is 0.62-alpha, so it COMPOSITES with whatever reaches this
     0.72-alpha `.spec` surface instead of overriding it — the same trap
     `.compare-table`'s cells hit, and it held only while the global veil
     was doing the work. Once mobile legibility moved off that veil
     (MOBILE_VEIL_SCALE, js/main.js), enough mesh reached this surface to
     take all four labels from 4.29-4.70:1 to 2.75-3.22:1 at 390 and 320
     alike — a regression this branch caused, and one nothing measured,
     because the contrast sweep carried a hand-written list of elements
     that these four were never on. Opaque `--ink` measures 7.1+ here with
     no bed needed. (The sweep now enumerates instead of listing — see
     scripts/contrast-probe.py.) */
  color: var(--ink);
  margin-top: 4px;
}

.flow {
  display: flex;
  flex-direction: column;
  gap: 0;
  align-items: center;
  text-align: center;
}
.flow .flow-step {
  width: 100%;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  /* 0.72 -> 0.88, and the ink from alpha `--ink-dim` to opaque `--ink-mid`.
     Same regression as `.spec-label` above, same section, same 0.72
     surface, found the same way (the enumerated sweep, not a list): 5.17:1
     -> 3.70:1 at 390x844 as MOBILE_VEIL_SCALE let 3.4x more mesh through
     (backdrop 0.0194 -> 0.0668). Fixed with BOTH levers because each alone
     falls short — 0.62-alpha ink on a 0.72 surface multiplies the mesh in
     twice, and opaque `--ink-mid` alone computes to 3.99 here.
     `--ink-mid`, NOT `--ink`: these three steps must stay dimmer than
     `.flow-core` below, which is `--silver`. Raising them to `--ink` would
     make the ordinary steps BRIGHTER than the highlighted one and inverts
     the emphasis the whole diagram is built on. */
  background: rgba(11, 11, 13, 0.88);
  padding: 20px;
  font-family: var(--font-display);
  font-weight: 600;
  letter-spacing: 0.2em;
  font-size: 14px;
  color: var(--ink-mid);
}
.flow .flow-step.flow-core {
  border-color: rgba(var(--red-rgb), 0.5);
  color: var(--silver);
  box-shadow: 0 0 30px -10px rgba(var(--red-rgb), 0.5);
}
.flow .flow-arrow {
  height: 34px;
  width: 1px;
  background: linear-gradient(180deg, rgba(var(--red-rgb), 0.7), rgba(var(--red-rgb), 0.15));
  position: relative;
}
.flow .flow-note {
  margin-top: 16px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  /* Still `--ink-dim` while `.spec-label` and `.flow-step` above moved to
     opaque ink: this line has NO surface under it (see the block below),
     so what reaches it is the raw mesh and, at this section's veil, a
     pointer-excited flare — which no ink or backing on this line can
     answer. It measures ~1.2:1 under such a flare on this branch and 1.91
     on the pre-branch base, i.e. it was already failing before any of this
     work; it is tracked as a pre-existing finding rather than patched
     here. See task-3-report.md (round 4). */
  color: var(--ink-dim);
}
/* The emphasis is INK, not red, and the reason is a ceiling rather than a
   preference. `--red-hot` (#ef4444, L 0.2277) on the site's own `--bg-0`
   (#050506, L 0.00152) can never exceed **5.39:1**, so the whole budget for
   everything the mesh adds to the backdrop is 0.9 of a ratio point — and
   this is the one line in the section with no surface under it. Measured
   with the mesh live and a pointer parked on the word itself (the real
   worst case: the note sits at the geometric centre of the machine, exactly
   where a mouse mid-screen lands), the red fell to **4.15:1** at 1440x900.
   A shadow cannot buy that back: a near-opaque seven-stop stack measured
   **4.46:1**, still short, because what it is fighting is the ceiling.
   `--ink` at weight 500 inside an `--ink-dim` line is the emphasis the
   hero's own `.dek strong` already uses, and it measures **12.66:1** in the
   same configuration. Red is still spent in this scene where it has a
   surface to sit on — the eyebrow, the core step's ring, the arrows. */
.flow .flow-note b { color: var(--ink); font-weight: 500; }

/* ---------- hardware: the pinned composition (side-by-side) ----------
   The pin holds #hardware still for 180% of the viewport while the mesh folds
   from the network into the machine and turns, so — like the hero, the
   pillars and the live stack — everything has to fit inside one screen while
   it runs, and every vertical value is capped in vh as well as px.

   The one fact that drives the whole layout: the fabric draws the machine
   centred on the VIEWPORT (`buildBox` puts it at W/2, H/2 and has no idea a
   scene exists), and the loop must draw INSIDE that silhouette — so the loop
   is centred on 50vw/50vh and stays there. What moved (this task) is the
   HEADLINE: it used to sit in normal flow ABOVE the centred stage, making it
   the top half's sole tenant whose height the mirrored bottom half doubled —
   which is why the pin needed >=900px tall. Now it is a LEFT COLUMN beside the
   machine, so the top half is no longer a doubled cost and the pin engages on
   landscape laptops (HW_PIN_QUERY, >=1280x700). So:

     · `.wrap` is a flex column, `justify-content:center`, `align-items:
       flex-start` — the eyebrow/h2/lead stack becomes a vertically-centred
       left column. `.hw-layout` (the stage) is absolute, out of that flow.
     · symmetric vertical padding + `height:100vh` keep `.wrap`'s midline the
       viewport's, so the absolute stage (top:50%, translateY(-50%)) is centred
       on the machine.
     · the four spec tiles and the flow share ONE grid cell (as before): tiles
       pushed to the corners by space-between, the loop centred between them.
       The stage's HEIGHT is now min(72vh,560px) (was 27vh) — that spreads the
       corner tiles to the top/bottom of the viewport, OUT of the vertically-
       centred headline's horizontal band, so the left column and the left
       tiles never collide. The tiles are still at the stage corners and the
       loop is still centred in the machine — only the headline moved and the
       stage grew taller.

   Column width is bounded to clear the machine (see the rule below). Note the
   ONE frame it cannot fully clear: at pin start (p=0) the mesh is the wider
   `network` cloud, whose leftmost sparse satellites reach ~24-27% of W — left
   of the column's right edge. That is the same copy-over-mesh case the whole
   site handles with the veil + halo (measured adversarially below); it lasts
   only until the cloud folds into the box (~p<0.3), and the machine SILHOUETTE
   (the box) is cleared with air at every frame.

   The loop is sized against the silhouette geometry, not by eye. From
   fabric.js: `bw = 0.34 m`, `bh = 0.62 bw`, `bd = 0.42 bw` (m = min(W, H)),
   rotated about Y by the scene's spin `s` and projected
   `x = (rx - rz) * 0.866`, `y = (rx + rz) * 0.29 - height`. So

     half-width  = 0.866 * (bw/2 * |cos s - sin s| + bd/2 * |cos s + sin s|)
     half-height = bh/2  + 0.29 * (bw/2 * |cos s + sin s| + bd/2 * |cos s - sin s|)

   which is NOT symmetric in s: a positive spin swings one face toward the
   viewer, so the machine gets NARROWER and taller as the scene turns it, and
   the loop's tightest moment is the END of the pin, not the middle. At
   s = +0.35 (p = 1) the silhouette is ~0.334 m wide and ~0.362 m tall — hence
   the loop capped at 30vmin (0.30 m) wide, which leaves it inside at every
   viewport the pin runs on. (Sizing off the spin-0 silhouette instead would
   read as comfortable and then clip: 0.418 m wide there.) */
#hardware.is-pinned {
  height: 100vh;
  /* the floor clears the fixed topbar (--pin-clear in :root); the bottom
     matches it so the midline stays the viewport's */
  padding-top: max(min(96px, 9vh), var(--pin-clear));
  padding-bottom: max(min(96px, 9vh), var(--pin-clear));
}
#hardware.is-pinned .wrap {
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
}
/* End the headline column left of the machine's leftmost drawn point, with
   air, at every pinning viewport. `.wrap` is max 1160px and centred
   (margin-inline:auto), so 50% of it is the VIEWPORT centre — where buildBox
   draws the machine — regardless of viewport width. The box half-width is
   ~0.227*min(W,H) = min(22.7vw, 22.7vh) (0.34*0.866*... off buildBox, widest
   over the turn). 580px = wrap half-width (1160/2). So the box's left edge is
   at wrap-local (580 - box_half); ending the column 40px before it gives
   `calc(540px - min(22.7vw, 22.7vh))` (540 = 580 - 40). Measured against the
   engine's own points across the pin: ~39-40px of clear air between the
   column's right edge and the box's leftmost drawn point at 1366x768,
   1440x900, 1536x864 and 1920x1080. 40ch caps it for legibility on the
   widest. Bisected here, not guessed — a bigger bound overlaps the box, a
   smaller one wastes width the headline needs at 1280-wide. */
#hardware.is-pinned :is(.eyebrow, h2, .lead) {
  max-width: min(40ch, max(0px, calc(540px - min(22.7vw, 22.7vh))));
  /* Nudge the vertically-centred copy column down a touch so the eyebrow
     clears the top-left spec tile. Flush-left under that tile (both at
     wrap-left), the eyebrow otherwise reads as the tile's caption. The
     "caption" crowding is worst at the NARROWER pinning widths, where the
     ~232px tile spans a big fraction of the viewport and sits squarely over
     the eyebrow; by ~1500px the tiles have pulled out toward the viewport
     corners AND the harder box-clearance width bound wraps the lead to more
     lines, so the copy column nearly fills the tile band and there is neither
     room nor a caption to fix. Hence a width-gated shift: ~22px at <=1478px,
     ramping to 0 by 1500px (1536/1920 keep their shipped, already-tight
     spacing untouched — no regression there). Only the three copy lines move
     (transform, not layout); `.hw-layout` (tiles + loop) is out of this flex
     flow and stays engine-centred, so the machine clearance is unchanged.
     Measured: at 1366x768 the eyebrow gap opens 52->74px while the lead keeps
     ~29px under the bottom-left tile; at the 1280x700 floor 50->72 / 28px. */
  transform: translateY(clamp(0px, calc(1500px - 100vw), 22px));
}
#hardware.is-pinned h2 { font-size: clamp(1.5rem, min(2.6vw + 0.4rem, 4.8vh), 2.4rem); }
#hardware.is-pinned .lead {
  margin-top: min(16px, 1.6vh);
  font-size: clamp(0.95rem, min(1.1vw, 1.9vh), 1.05rem);
}

#hardware.is-pinned .hw-layout {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  height: min(72vh, 560px);
  margin-top: 0;
  display: grid;
  /* one cell, explicitly: the scrolling layout's two columns (the >=960px
     rule above) would otherwise survive and pen both children into the first
     of them, off-centre and 638px wide */
  grid-template-columns: 1fr;
  gap: 0;
}
/* both children share the one cell — corners and centre of the same box */
#hardware.is-pinned .hw-layout > * { grid-area: 1 / 1; }

#hardware.is-pinned .spec-list {
  height: 100%;
  grid-template-columns: repeat(2, minmax(0, min(232px, 21vw)));
  justify-content: space-between;
  align-content: space-between;
  gap: 0;
}
#hardware.is-pinned .spec { padding: min(14px, 1.5vh) 16px; }
#hardware.is-pinned .spec .spec-value { font-size: clamp(1rem, min(1.35vw, 2.4vh), 1.3rem); }

#hardware.is-pinned .flow {
  justify-self: center;
  align-self: center;
  width: min(272px, 30vmin);
}
#hardware.is-pinned .flow .flow-step {
  padding: min(11px, 1.2vh) 12px;
  font-size: clamp(11px, 1.25vmin, 13px);
  letter-spacing: 0.16em;
}
#hardware.is-pinned .flow .flow-arrow { height: min(22px, 2.4vh); }
/* One rule for this selector, not two — the halo below used to live in a
   second block further down the file, where an edit to either would never
   find the other. Its reasoning stayed with it. */
#hardware.is-pinned .flow .flow-note {
  margin-top: min(12px, 1.3vh);
  font-size: clamp(9.5px, 1.05vmin, 11px);
  /* The note is the only line with no surface under it at all, and it sits
     at the densest point of the machine, so it takes the dense variant
     rather than the shared halo. This USED to be `var(--copy-halo), 0 0 3px
     rgba(5,5,6,0.95)` — a third ad-hoc formulation of the same measured
     value. --copy-halo-dense is that base plus three tighter opaque stops
     (1px, 1px, 3px at full opacity), i.e. a strict superset of the old
     stack's coverage, so the swap can only darken the backdrop under the
     glyphs, never lighten it. Measured live (mesh running, pointer parked on
     the line, worst 0.9-coverage pixel, 1440x900) it holds at 5.3:1, matching
     the old form. The emphasis inside the line is handled by its colour, not
     by more shadow (see `.flow .flow-note b`). */
  text-shadow: var(--copy-halo-dense);
}

/* Copy backing over the mesh. The veil sits at the section's declared 0.5
   and the machine runs under the headline and behind the loop, so the copy
   carries its own darkening — exactly as the pillars (0.35) and the live
   stack (0.45) do — rather than a backing plate: the tiles and the loop
   steps bring their own surfaces, and each glyph's own halo holds the
   rest (not one shared halo — see the split below). Measured ADVERSARIALLY
   throughout — mesh live (twinkle, wander and embers running), a real
   pointer parked on each element in turn, worst pixel over several sampled
   frames at 1366×768, 1440×900, 1536×864 and 1920×1080:

     · The tiles/loop floor is `.flow-step` at 4.90:1. Their translucency is
       deliberately NOT raised to chase a bigger margin — measured, a
       0.72 -> 0.9 tint only moves `.flow-step` by 0.04:1, and the machine
       showing through the loop is the point of drawing it there. Red ink
       is its own story: `--red-hot` cannot exceed 5.39:1 on `--bg-0` at
       all, which is why the flow-note's emphasis is `--ink` instead (see
       `.flow .flow-note b`).

     · The headline column sits over the mesh's OFF-centre left flank
       (sparser than the dense centre the machine occupies), and a real
       pointer parked on it brightens the mesh right there, so it gets its
       own pass, split by element: h2 — opaque --silver, thick strokes,
       7–11:1 on its own — keeps the plain halo. `.lead` — the one
       SEMI-transparent copy (--ink-dim, alpha 0.62, so the backdrop reads
       through it by 38%) — and `.eyebrow` — thin red at the top of the
       column, whose red ink has no margin above its own 5.39:1 ceiling —
       both take the DENSE halo instead: it paints the backdrop under each
       stroke to near black, so the solved haloed backdrop measures
       (0,0,0) and `.lead` (L≈0.28) reads ~6:1 while `.eyebrow` reads at
       its red ceiling ~5.4:1. Deepened here rather than by lifting the
       0.5 veil (which is the machine). */
#hardware.is-pinned h2 { text-shadow: var(--copy-halo); }
#hardware.is-pinned :is(.eyebrow, .lead) { text-shadow: var(--copy-halo-dense); }

/* ---------- comparison ---------- */

.compare-table {
  position: relative;
  margin-top: 56px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  /* Same 0.72 measured surface value as `.spec`/`.flow-step` (hardware,
     above) — those two are DOCUMENTED as deliberately unraised; this one
     is an undocumented fork of the same value, not a considered match.
     No visual mismatch today (no 0.72 surface sits beside the `.card`
     0.8 one on screen), so left as-is — noted only so a future pass
     doesn't mistake the gap for a deliberate choice. */
  background: rgba(11, 11, 13, 0.72);

  /* The column split is DECLARED here, not measured at runtime, and one
     number (`--col-local`, the left edge of the CHILL column) drives both
     the column widths below and `.compare-rule`'s own `left` — so the line
     cannot drift off the boundary it is meant to trace. It used to be
     measured once by initCompare and re-measured only on `resize`, which
     was wrong in a way this scene itself provoked: with auto table layout,
     cell CONTENT sizes the columns, and scenes.js scrambles the cloud-side
     text on every row as it builds. Proportional glyphs re-flowed the
     column under a line that had already been placed (measured max 17.9px
     of horizontal drift on a 2px line at 1440x900), and no `resize` ever
     fired to correct it. `table-layout: fixed` below makes content
     irrelevant to column width, which closes the same hole against font
     swap, breakpoint and zoom at the same time. Values match the previous
     content-driven split to within 0.1% at both breakpoints. */
  --col-key: 34%;
  --col-local: 72%;
}
.compare-table table {
  width: 100%;
  /* fixed layout takes its column widths from the FIRST row's cells (or a
     colgroup) — never from the body — so the widths are declared on the
     thead cells below, not on `tbody th`. */
  table-layout: fixed;
  border-collapse: collapse;
  font-size: 0.95rem;
}
.compare-table thead th:first-child { width: var(--col-key); }
.compare-table thead th:nth-child(2) { width: calc(var(--col-local) - var(--col-key)); }
.compare-table thead th.local { width: calc(100% - var(--col-local)); }
.compare-table th, .compare-table td {
  padding: 16px 20px;
  text-align: left;
  border-bottom: 1px solid var(--line);
  vertical-align: top;
  /* Fixed layout cannot widen a column to fit its longest word the way auto
     layout does, and the table clips (`overflow: hidden` above) — so on a
     320px phone the CHILL column's content box is 67.8px against a 84.2px
     "Milliseconds," and the comma is simply cut off. Nothing catches that
     from outside: the clip happens INSIDE an overflow:hidden box, so there
     is no document-level overflow for scripts/viewport-sweep.py to see, and
     it passed 14/14 with the word cut. Letting long words break is the whole
     fix; every word on this table already fits without breaking above 400px,
     so nothing else changes. */
  overflow-wrap: break-word;
}
.compare-table tr:last-child th, .compare-table tr:last-child td { border-bottom: none; }
.compare-table thead th {
  font-family: var(--font-sub);
  font-weight: 700;
  font-size: 13px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-dim);
}
.compare-table tbody th {
  font-weight: 500;
  font-size: 0.9rem;
  /* no `width` here: under `table-layout: fixed` a body cell's width is
     ignored outright. The 34% now lives on `thead th:first-child`. */
}
/* Opaque ink + copy bed, `td.cloud` AND `tbody th` (the row-label column) --
   the same alpha-ink trap every other --ink-faint/--ink-dim instance on a
   live-mesh surface hits (see --ink-mid's definition in :root): the alpha
   composites with whatever's behind it instead of overriding it, so
   darkening the backdrop further can't fix it.

   `td.cloud`: --ink-mid was the first opaque step tried and is NOT enough
   on its own — re-measured with the corrected harness
   (scripts/contrast-probe.py, task-3-report.md, review round 2) at
   3.47-3.55:1 (1440x900) and 1.16-1.29:1 (390x844, worse — this table got
   no mobile-specific treatment at all otherwise).

   `tbody th`: a 2026-07-28 round-3 review caught that this column got NO
   escalation through round 2 — still plain alpha `--ink-dim` — and the
   brighter mesh this branch shipped (Task 1/2's GLOW and node-count
   raises) took it from 5.27:1 to 3.59:1 (glyph-removed instrument:
   backdrop 4.6x, GT 5.01 -> 3.10). A regression this branch itself caused,
   missed because `run_full_sweep()` measured no table cells at all until
   this round added `.compare-table tbody th`/`td.cloud` to it (see
   scripts/contrast-probe.py). `--ink-mid` alone measures 3.78:1 here —
   same story as `td.cloud`, NOT enough.

   `thead th`: the SAME regression as `tbody th`, one row above it, on the
   same alpha `--ink-dim` — and missed for the same reason twice over,
   because round 3 put `thead th` in that measurement's EXCLUDE list (so it
   was looked at, hidden as a co-occupant, and never measured). 5.58:1 ->
   2.97:1 at 390x844, its backdrop rising 6.4x (0.0098 -> 0.0631) as
   MOBILE_VEIL_SCALE let the mesh through to this 0.72-alpha surface. At
   320x568 it reads 2.31 on the pre-branch base too, so that width is part
   regression and part pre-existing; both are fixed here. The harness no
   longer carries a list of elements at all — see scripts/contrast-probe.py.

   All three get the same fix: --ink (the brighter opaque ceiling, same
   lever as the red-ink escalation above) plus the same `::after` copy bed
   every other still-short element in this file uses, unconditional (not
   mobile-scoped) since the desktop reading fails too. `thead th.local`
   keeps its red (its own higher-specificity rule below) and takes only the
   bed — the mark that identifies the CHILL column is worth the ink budget,
   and the bed is what buys it back. */
.compare-table td.cloud,
.compare-table tbody th,
.compare-table thead th {
  /* position: relative WITHOUT z-index -- a 2026-07-28 round-3 A/B (same
     method as the shared mobile bed's own z-index note, above) found the
     `z-index: 0` this rule carried through round 2 made no measurable
     difference here (neighbour cells moved 3.59 -> 3.41, inside noise),
     unlike the shared bed's dramatic z-index bug: table cells don't share
     a line box with sibling inline text the way `.t-red`/`.t-dim` do, so
     there was no sibling to paint over in the first place. Removed as a
     consistency/comment fix, not a live-over-paint fix — position:relative
     alone still correctly scopes the ::after's containing block to the
     cell itself (not `.compare-table`, which is also position:relative
     one level up). */
  position: relative;
  color: var(--ink);
}
.compare-table td.cloud::after,
.compare-table tbody th::after,
.compare-table thead th::after {
  content: "";
  position: absolute;
  z-index: -1;
  /* Same falloff correction `.scene .lead::after` needed (see its own
     rule): at `0 78%` the gradient reaches zero at 78% of its half-extent,
     which on a table cell lands INSIDE the cell's own text, so the ink
     nearest the line-ends sits on bare mesh. Widened and pushed the zero
     stop out to 96%. Measured on the thinnest-margin cell in the table,
     `thead th.local` (--red-hot, whose ceiling over --bg-0 is 5.55:1 and
     so has almost no budget): 4.12 -> 4.62 @320x568, its backdrop falling
     0.0146 -> 0.0076. The other two cell classes move with it but were
     already clear (`tbody th` 14.28, `td.cloud` 14.19 at the same width). */
  inset: -12px -16px;
  background: radial-gradient(ellipse at center,
    rgba(5, 5, 6, 0.92) 0%, rgba(5, 5, 6, 0.82) 60%, rgba(5, 5, 6, 0) 96%);
  pointer-events: none;
}
.compare-table td.local,
.compare-table thead th.local {
  background: rgba(var(--red-rgb), 0.05);
  /* Static fallback so the column still reads under reduced motion or with
     JS disabled — initCompare never runs there (scenes.js bails out at the
     top under prefers-reduced-motion), so `.compare-rule` below is never
     created and this plain border is the ONLY thing marking the column.
     `.has-rule` (added the instant initCompare builds that element — see
     js/scenes.js) suppresses it in favour of the animated line, so the two
     never show at once: a full-height static border sitting underneath a
     line that is also meant to draw from nothing would mean the column
     reads as fully marked from the very first frame, defeating the "draws
     down as you scroll" premise entirely. */
  border-left: 2px solid rgba(var(--red-rgb), 0.6);
  color: var(--ink);
}
.compare-table.has-rule td.local,
.compare-table.has-rule thead th.local { border-left: none; }
.compare-table thead th.local { color: var(--red-hot); }

/* The rule draws down the WHOLE column as ONE line, not a stub per cell.
   A first attempt put a `::before` on every `td.local`/`th.local` reading
   the same `--rule` custom property (which does inherit) — that made every
   cell draw the SAME *fraction*, but each measured against its OWN cell's
   height starting from ITS OWN top, so at any rule between 0 and 1 the
   column read as a ladder of short red dashes with a visible gap before
   each row boundary, not a line advancing down the table (confirmed by
   screenshotting mid-draw and zooming the column — six separate stubs,
   not one). A single element spanning the table's own full height, sitting
   at the declared left edge of the local column (`--col-local` above —
   the same number that sizes the column, so the two cannot disagree),
   fixes it: one line, one height, drawn from one origin. `.compare-table`
   is its containing block (`position: relative` above), so
   `height: calc(100% * var(--rule))` resolves against the table's own full
   height regardless of row count. `var(--rule, 1)` is the safe default if
   `--rule` is never set at all — fully drawn, matching every other border
   on the table, rather than a phantom 0-height line. */
.compare-rule {
  position: absolute;
  top: 0;
  left: var(--col-local);
  width: 2px;
  height: calc(100% * var(--rule, 1));
  background: rgba(var(--red-rgb), 0.75);
  pointer-events: none;
}

/* Holds a cloud-side cell's box still while scenes.js scrambles its text
   — see the scrambleCell() comment in js/scenes.js for why a multi-line
   cell needs this and a single-line .eyebrow does not. Inert until that
   code pins an inline height on it. */
.compare-table .scramble-box { display: block; overflow: hidden; }

@media (max-width: 720px) {
  .compare-table th, .compare-table td { padding: 12px 12px; font-size: 0.84rem; }
  /* Narrow: the CHILL column's answers are the short ones but the cloud
     column's are the long ones, so the two even out (matches the 66/34
     split auto layout was producing here before). */
  .compare-table { --col-local: 67%; }
}

/* ---------- vision ---------- */

/* #vision runs the LIGHTEST veil on the page (data-veil 0.25) and, unlike the
   three tentpoles, it is never pinned — so the copy needs the same treatment
   they get, unconditionally. Measured adversarially (mesh live, pointer parked
   on each element in turn, worst fully-covered pixel over six sampled frames)
   the label read 4.39:1 and the statement 5.07:1 without it, and a bright
   pointer-excited cluster landing behind either of them took both to ~1:1 —
   the mesh's own nodes are the same red as the label. With the halo: 5.40:1
   and 17.7:1. The label takes the DENSE stack because its ink is red and red
   has no margin to give; the statement is solid silver and the shared halo
   is already more than enough for it. */
#vision .v-label { text-shadow: var(--copy-halo-dense); }
#vision blockquote.chrome-text {
  text-shadow: var(--copy-halo),
    0 1px 0 rgba(255, 255, 255, 0.05),
    0 16px 48px rgba(0, 0, 0, 0.6);
}

.vision-block { text-align: center; }
/* The rule between the two statements is what separates them now, so its
   own margins are the gap (the old `.v-item + .v-item` margin no longer
   matches anything — there is always an element between them). */
.vision-block .v-rule {
  height: 1px;
  margin: 44px auto;
  max-width: 520px;
  background: linear-gradient(90deg, transparent, rgba(var(--red-rgb), 0.8), transparent);
  transform-origin: 50% 50%;
  /* NO `transform: scaleX(0)` here, deliberately. The only thing that ever
     draws this rule back is the scrub in js/scenes.js — and scenes.js
     returns before it builds anything under prefers-reduced-motion, or if
     gsap/ScrollTrigger failed to load. A statically collapsed rule would
     therefore be a permanently invisible element on exactly the paths that
     need the static composition to read on its own. So the rule is drawn at
     rest, and initVision collapses it only once it holds a live trigger that
     can draw it back — the same order Tasks 5-8 set for every staged
     element on this page. */
}
.vision-block .v-label {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.34em;
  text-transform: uppercase;
  color: var(--red-hot);
  margin-bottom: 22px;
}
.vision-block blockquote {
  margin: 0 auto;
  max-width: 780px;
  font-family: var(--font-sub);
  font-weight: 500;
  font-size: clamp(1.3rem, 2.2vw + 0.5rem, 2.1rem);
  line-height: 1.4;
  letter-spacing: -0.01em;
}

/* ---------- contact / footer ---------- */

.contact {
  text-align: center;
  overflow: hidden;
}
.contact .bloom {
  position: absolute;
  left: 50%;
  bottom: -30%;
  width: 70vw;
  height: 70vw;
  max-width: 900px;
  max-height: 900px;
  transform: translateX(-50%);
  border-radius: 50%;
  background: radial-gradient(circle, rgba(var(--red-rgb), 0.28), transparent 68%);
  filter: blur(90px);
  mix-blend-mode: screen;
  pointer-events: none;
  z-index: -1;
  /* The bloom rests at FULL strength, and the CTA hover in scenes.js lifts
     it by scale alone. An earlier version rested at 0.85 so the hover had
     opacity to give — but `initContact` never runs on the reduced-motion
     path (scenes.js returns before it) and never runs at all if the vendor
     bundles fail, so those visitors got the section's signature glow
     permanently 15% dimmer than everyone else's, with nothing on the page
     able to raise it. A resting state only JS can undo is a resting state
     that ships wrong. Scale is the whole lift now; the pair is symmetric on
     every path. */
  opacity: 1;
}
/* #contact's veil sits at 0.6, but it is the one section that also paints its
   OWN light: the .bloom above is a 900px red radial at screen blend, directly
   behind the copy, and the mesh is at its densest here (the embers
   formation converges behind the CTA). Measured adversarially, that backdrop
   left the lead at 2.68:1 and the contact lines at ~1.9:1 — worse than any
   pinned scene, at a higher veil than two of them. Same halo, same reason. */
#contact .lead { text-shadow: var(--copy-halo); }
/* the small mono lines — red eyebrow, red-on-hover links, captions — take the
   dense stack for the same reason #vision's label does */
#contact :is(.eyebrow, .contact-lines a, .contact-lines .label) {
  text-shadow: var(--copy-halo-dense);
}
#contact h2.chrome-text {
  text-shadow: var(--copy-halo),
    0 1px 0 rgba(255, 255, 255, 0.05),
    0 16px 48px rgba(0, 0, 0, 0.6);
}

.contact h2 {
  font-size: clamp(2rem, 4.4vw + 0.6rem, 4rem);
}
.contact .lead { margin-inline: auto; }
.contact-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  justify-content: center;
  margin-top: 44px;
}
.contact-lines {
  display: flex;
  flex-wrap: wrap;
  gap: 12px 40px;
  justify-content: center;
  margin-top: 48px;
  font-family: var(--font-mono);
  font-size: 13px;
  letter-spacing: 0.06em;
}
.contact-lines a {
  color: var(--ink-dim);
  text-decoration: none;
  border-bottom: 1px solid var(--line-strong);
  padding-bottom: 2px;
  transition: color 0.25s ease, border-color 0.25s ease;
}
.contact-lines a:hover { color: var(--red-hot); border-color: rgba(var(--red-rgb), 0.6); }
.contact-lines .label {
  /* --ink-faint (0.4 alpha) cannot hold over this backdrop — measured 3.20:1
     with the dense halo already under it, because 60% of the glyph IS the
     backdrop and here that is the bloom plus the ember convergence. Same call
     Task 7 made for #hardware's --ink-faint labels: raise the ink. */
  color: var(--ink-dim);
  font-size: 10px;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  display: block;
  margin-bottom: 6px;
}

footer {
  position: relative;
  z-index: 5;
  border-top: 1px solid var(--line);
  padding: 34px clamp(20px, 5vw, 64px);
  display: flex;
  flex-wrap: wrap;
  gap: 14px 32px;
  align-items: center;
  justify-content: space-between;
  background: rgba(5, 5, 6, 0.85);
}
footer .foot-brand {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 14px;
  letter-spacing: 0.24em;
  color: var(--silver);
}
footer p {
  margin: 0;
  font-size: 12.5px;
  color: var(--ink-faint);
}
footer .foot-motto { font-family: var(--font-mono); letter-spacing: 0.14em; text-transform: uppercase; }

/* Mobile moves legibility off the global veil (see veilOf in js/main.js) and
   onto local surfaces. Card copy already has its card; this is everything
   that floats free — every scene's headline block, and the pinned pillar
   panel, which has no surface behind it at all. The dense halo is the same
   token the pinned hardware copy and the terminal use, not a new value. */
@media (max-width: 899px) {
  .scene :is(.eyebrow, h2, .lead),
  .pillar :is(.pillar-index, .pillar-sub, .pillar-body),
  .pillar h3 { text-shadow: var(--copy-halo-dense); }
}

/* #pillars is pinned at every width (CLAUDE.md), so `#pillars.is-pinned`'s
   own ID-specificity rules (`.pillars-head :is(p, h2)` and
   `.pillar :is(p, h3)`, both --copy-halo, not dense -- see their comments
   above) are ALWAYS live whenever `.pillar` content is on screen, and they
   OUTRANK the plain-class rule just above (an ID beats any number of
   classes) regardless of source order. Measured: with only the rule above,
   `.pillar-index`/`.pillar-sub` (opaque --red-hot, already the thinnest
   margin in the file) and `.pillar-body` (alpha --ink-dim) still failed
   4.5:1 at the scaled-down mobile veil -- e.g. 3.04-4.97:1 across both
   required viewports and both a no-pulse and a pulsing pillar, because the
   halo actually reaching them was still the plain (non-dense) one. This
   isn't a second escalation tier, it's making tier 1 (halo) actually land:
   these selectors match every one of `#pillars.is-pinned`'s own three
   classes plus one, so they win outright on class-count, no ordering
   dependency. `h3` is deliberately not repeated here -- its ink
   (--chrome-text, effectively opaque near-white) already clears with wide
   margin on the plain halo, so it's left at the existing behaviour. */
@media (max-width: 899px) {
  #pillars.is-pinned .pillars-head :is(.eyebrow, h2),
  #pillars.is-pinned .pillar :is(.pillar-index, .pillar-sub, .pillar-body) {
    text-shadow: var(--copy-halo-dense);
  }
}

/* Tier-1 escalation (task-3-report.md): the halo above raises every
   free-floating headline's OPACITY of coverage, but does nothing for an
   ink that was never opaque to begin with. --ink-dim (62% alpha) and
   --ink-faint (40%) still composite with whatever mesh is behind them --
   see --ink-mid's definition in :root for why darkening the backdrop
   can't fix that, only an opaque ink can. These are every alpha-ink
   instance the mobile sweep measured under 4.5:1: the body copy under
   every scene's lead paragraph, the pillar body copy, a card's own body
   paragraph (.problem-grid, primarily), the live-numbers stat label, and
   the two contact-line rows (which already carry the dense halo from
   `#contact :is(...)` below -- this is the matching ink half of that
   fix). Scoped to mobile only: desktop already clears these on the
   original alpha ink, per Task 1's sweep. */
@media (max-width: 899px) {
  .lead,
  .pillar p.pillar-body,
  .card p,
  .stat .stat-label {
    color: var(--ink-mid);
  }
  /* `.b-hub .hub-caption` (main.css, ~line 808: `.b-hub .hub-caption {
     color: var(--ink-dim); }`) is TWO classes -- (0,2,0) -- which outranks
     the plain `.card p` escalation just above -- (0,1,1) -- regardless of
     source order, even though `.hub-caption` IS a `<p>` inside `.card`
     (`<div class="card b-hub">`) and was clearly meant to inherit it. The
     exact same specificity trap the pillars/`#contact .lead` comments
     nearby already document, just not caught for this element until the
     2026-07-27 review round-2 full re-sweep. This rule is real and
     load-bearing -- ink alone, no bed needed: `.card p` itself does not
     carry one either (see the shared-copy-bed comment below, "the two
     exceptions") since `.card`'s own 0.9-alpha fill is enough on its own
     for this weight of ink. The round-2 "measured 2.22:1" this comment
     used to cite was a SMALL-GLYPH HARNESS ARTIFACT, not a real reading
     (2026-07-28 round 3: that harness's background pool still contained
     partially-covered glyph pixels below its own coverage threshold --
     see scripts/contrast-probe.py's module docstring). Re-measured with
     the glyph-removed instrument: --ink-dim (un-escalated) -> --ink-mid
     (this fix) raises 5.37 -> 6.20 at 390x844 -- a real, smaller gain than
     the old number claimed, but real and worth keeping. */
  .b-hub .hub-caption { color: var(--ink-mid); }
  /* `.contact-lines a`/`.label` needed MORE than the opaque ceiling above:
     both sit right beside `.bloom` below -- a second, veil-independent
     light source at the mesh's own densest point (CLAUDE.md) -- and
     `.label` is only 10px uppercase mono, small enough that its own
     antialiasing caps how much of any single pixel is ever really
     covered (the same coverage-ceiling reality `.terminal .t-red`'s
     comment already documents for an even smaller glyph -- no amount of
     backdrop darkening raises a capped ink luminance, only a brighter
     ink does). --ink is the brighter opaque ceiling, the SAME lever
     `.spoke-labels span` already uses for the identical "small mono type
     over the mesh's densest point" problem (see that rule's own
     comment). */
  .contact-lines a, .contact-lines .label { color: var(--ink); }
}

/* `#contact .lead` (below, unconditioned) sets an ID-specificity
   text-shadow that outranks the plain-class `.scene .lead` dense-halo
   rule above regardless of source order (an ID beats any number of
   classes) -- the exact same trap the pillars comment above documents,
   just on a different element. Without this, #contact's lead measured
   1.56:1 at the scaled mobile veil: the rule floated ABOVE (this one) in
   the cascade order never actually reached it. */
@media (max-width: 899px) {
  #contact .lead { text-shadow: var(--copy-halo-dense); }
}

/* Red-ink escalation (task-3-report.md, review round 2 -- a Critical
   review finding that the ORIGINAL harness zeroed each element's own box
   out of the background pool, so every "passing" plate number below was
   measured against a ring outside the plate, never the plate itself; the
   corrected harness (scripts/contrast-probe.py) reads these four back at
   4.2-5.1:1 on --red-hot alone -- close, not catastrophic, but --red-hot
   is already the thinnest margin in the file before any mesh contribution
   at all (5.41:1 on pure --bg-0, its own comment in :root) and cannot go
   more opaque. The fix already has a precedent in this exact file: this
   file's own gist is the SAME "red → --ink where the mesh is densest" move
   `.spoke-labels span` already made. Cheap, precedented, clears every
   instance with real margin against a TYPICAL backdrop -- the bed below
   is still needed for the mesh's own occasional extremes (its own
   comment). Scoped to mobile: desktop's veil is strong enough that these
   clear on --red-hot untouched (Task 1's sweep). */
@media (max-width: 899px) {
  .scene .eyebrow,
  .card .card-kicker,
  .pillar :is(.pillar-index, .pillar-sub) {
    color: var(--ink);
  }
}

/* Shared "copy bed" (task-3-report.md, review round 2). The flat
   per-element plates this replaces had two problems: they were measured
   wrong (see the red-ink comment above -- the harness that judged them
   proved nothing about their real strength), and once measured correctly
   they turned out to be visible hard-edged dark boxes, hiding more mesh
   than the whole point of this branch allows. This is the soft-falloff
   form the brief always specified: modelled on
   `#pillars.is-pinned .wrap::before` (identical radial shape), but sized
   to the SPECIFIC copy block it protects rather than the whole `.wrap`,
   which would flatten the mesh bands Task 4 opens between blocks.

   `position: absolute` means this adds ZERO layout height -- unlike the
   flat background+padding it replaces, which is exactly what forced the
   vh-capped padding values and the short-viewport pillar trims a prior
   pass added below (both gone along with it: the fold overflow they
   existed to prevent doesn't happen when the bed isn't part of layout).

   Every FREE-FLOATING text class gets one, including the ones that clear
   on ink alone against a TYPICAL sampled backdrop (`h2`, `.eyebrow`, the
   pillar index/sub) -- an earlier draft of this fix skipped those on
   exactly that reasoning and a longer-running sweep caught `#stack h2` at
   1.03:1 (ink already opaque, no plate at all) sitting under this scene's
   own "network" formation at a moment of dense node convergence: the
   mesh's node-pulse cycle produces backdrops far brighter than a single
   quick sample reliably catches, and ink alone — however opaque — has no
   ceiling against an arbitrarily bright backdrop; only a surface behind it
   does. So "measured to clear on ink alone" was true of one sample, not a
   guarantee, and every FREE-FLOATING text class gets the same bed rather
   than relying on it not needing one -- proven, not assumed: A/B'd
   directly (bed on vs `content: none` off, worst-of-32-samples each) at
   390x844, `#stack h2` reads 9.69:1 bedded, 1.77:1 unbedded -- the exact
   failure mode this paragraph describes, reproduced on demand.

   `.card p` and `.card .card-kicker` are the two exceptions -- CARD-
   INTERNAL, not free-floating, and deliberately NOT in the list below
   after the same A/B test found their bed is not load-bearing: `.card`'s
   own fill is a 0.9-alpha near-opaque surface (below) that mathematically
   caps how much of ANY mesh extreme can ever leak through at 10% of raw
   luminance -- a hard ceiling `h2` (over raw mesh, no card) does not have.
   Measured: `#problem .card p` reads 7.27:1 bedded vs 6.34:1 unbedded (a
   real 0.9-point difference, but nowhere near the 4.5 floor either way);
   `#stack .b-stats .card-kicker` reads 13.34:1 bedded vs 12.23:1 unbedded,
   `#stack .b-hub .card-kicker` 13.94:1 vs 12.23:1 -- both already
   comfortably clear without a bed. `.stat .stat-value` is ALSO
   card-internal but stayed in the list below because it measured
   differently: bedded 4.99:1, unbedded 4.17:1 (a genuine FAIL) -- its ink
   is `--red-hot`/`--ink` on numerals as small as this card's kicker text,
   and --red-hot's own thin margin (5.41:1 on pure --bg-0, no mesh at all)
   means even the card's capped 10%-of-mesh leakage is enough to drop it
   under 4.5, where `.card p`'s and `.card-kicker`'s brighter/larger ink
   has room to spare. The card cap changes the RISK, not a blanket
   "card-internal is always safe" rule -- each card-internal class here
   was measured on its own, not assumed from the other's result.

   `::after`, not `::before` -- `.eyebrow::before` already owns that
   pseudo-element for its red dash.

   `position: relative` (WITHOUT an explicit `z-index`) on every selector
   below is load-bearing for a different reason than a prior round of this
   comment claimed. `position: relative` is needed so the `::after`'s
   `position: absolute` resolves ITS CONTAINING BLOCK to this element --
   without it, an absolutely-positioned child's containing block is the
   nearest ANCESTOR that IS positioned, here `main { position: relative;
   z-index: 5; }`, which computes `inset` against main's own box (nowhere
   near the glyph) and, separately, paints the bed among MAIN's negative-z
   children -- behind essentially the entire page. That part is real and
   confirmed live.

   A PRIOR round additionally added `z-index: 0` here, reasoning that
   `position: relative` alone does not establish a stacking context. That
   claim did not reproduce under a 2026-07-27 review: `#contact .lead`'s
   own backdrop luminance measured 0.0016 with z-index:0 shipped, 0.0016
   with it removed (z-index:auto), and 0.0083 with the bed off entirely --
   the bed works identically with or without the explicit z-index. What
   `z-index: 0` DOES do, which the prior round never measured, is give
   this element its own stacking context, which paints the element (and
   its now-child `::after`) in the "positioned descendants, stack level 0"
   step of CSS2.1 Appendix E painting order -- ABOVE plain, non-positioned
   INLINE siblings in the same parent stacking context (steps 3-5). For a
   block-level bed sitting alone in its own line that is invisible, but
   `.terminal .t-dim` and its siblings `.t-red`/`.t-ok` are inline spans
   sharing THE SAME LINE inside `.terminal pre` -- so `z-index: 0` put
   `.t-dim`'s own bed on top of the adjacent `.t-red`/`.t-ok` glyphs on
   the same source line, a solid ~0.8-0.95-alpha plate painted OVER
   already-rendered neighbour text, not just behind its own. Solved from
   the shipped pixels: `.t-red`'s "0" glyph's brightest shipped pixel was
   `(159,46,46)` where `--red-hot` is `(239,68,68)` -- 159 = 239(1-a) + 5a
   solves to a=0.342, i.e. a near-black plate at ~34% opacity sitting on
   top of the ink, not a coverage artifact. Fix: delete `z-index: 0`
   (keep `position: relative`). Before/after at 390x844, shipped vs this
   fix, measured with the corrected harness (scripts/contrast-probe.py):
   `.t-red` "$" 1.92->4.69, "0" 2.48->4.78; `.contact-lines .label`
   "Email" 1.71->6.72, "Phone" 1.85->12.20; `#stack .stat-value`
   120+/30+/128 GB 2.79/2.57/2.56 -> 12.11/12.43/12.29; `pillar[0] h3` @
   320x568 peak L 0.486->0.873. `.terminal .t-dim` is handled separately,
   below, for an unrelated reason (its bed's containing block spans ALL of
   its wrapped lines, not just one -- see its own comment) and never
   carried `z-index: 0` in the fix below. */
@media (max-width: 899px) {
  .scene :is(.eyebrow, h2, .lead),
  .pillar :is(.pillar-index, .pillar-sub, .pillar-body),
  .stat .stat-value,
  .stat .stat-label,
  .contact-lines a,
  .contact-lines .label,
  .term-bar .live {
    position: relative;
  }
  .scene :is(.eyebrow, h2, .lead)::after,
  .pillar :is(.pillar-index, .pillar-sub, .pillar-body)::after,
  .stat .stat-value::after,
  .stat .stat-label::after,
  .contact-lines a::after,
  .contact-lines .label::after,
  .term-bar .live::after {
    content: "";
    position: absolute;
    z-index: -1;
    inset: -30px -22px;
    background: radial-gradient(ellipse at center,
      rgba(5, 5, 6, 0.95) 0%, rgba(5, 5, 6, 0.8) 45%, rgba(5, 5, 6, 0) 78%);
    pointer-events: none;
  }
  /* `h2` is the largest, most exposed block on the page (the widest
     headline, and the one an extreme mesh moment measured hardest against
     -- see the comment above) -- a bigger bleed so a convergence cluster
     near its edge is still inside the bed's falloff, not past it. */
  .scene h2::after { inset: -40px -30px; }
  /* `#hardware .lead` measured short (3.87:1) even with the standard bed --
     #hardware's own mesh phase ("box," CLAUDE.md) folds close behind this
     scene's copy at rest more than the others. A stronger centre stop
     alone was not the real fix (2026-07-28 round 3): the shared bed's
     ellipse reaches zero at 78% of its own half-extent, and on a
     `.lead`-sized box (~280x135px with the shared `inset: -30px -22px`)
     that zero point lands INSIDE the text's own line-ends -- the last few
     characters of every line sit past the bed's own falloff, effectively
     unbedded, regardless of how strong the centre stops are. Confirmed
     with the glyph-removed instrument (scripts/contrast-probe.py):
     `#stack .lead` measured backdrop 0.0438, ratio 4.26 (matching the
     reviewer's own reproduction, GT 3.38) even with the stronger stops
     below already in place. Fix: widen `inset` so the falloff's zero
     point clears the text box entirely, and push both the stop position
     and the zero point outward to match (0.97->0.95 at 62%, 0 at 96%,
     vs the shared rule's 45%/78%) -- more of the bed's own radius is
     spent at high opacity before it starts fading. Zero cost: the bed is
     `position: absolute`, so a wider inset adds no layout height and
     cannot reopen the fold overflow the vh-capped pillars padding was
     built to avoid. Measured result: `#stack .lead` 4.26 (GT 3.38) ->
     7.18 (GT 7.14); `#hardware .lead` 5.75 -> 7.14 (GT 7.19); backdrop
     0.0438 -> 0.0049. */
  .scene .lead::after {
    inset: -38px -30px;
    background: radial-gradient(ellipse at center,
      rgba(5, 5, 6, 0.97) 0%, rgba(5, 5, 6, 0.95) 62%, rgba(5, 5, 6, 0) 96%);
  }
  /* `.contact-lines a`'s pair sits right beside `.bloom` (below) -- a
     second, veil-independent light source at the mesh's own densest
     point -- and needed the strongest bed in the file even with ink
     already raised to --ink above. */
  .contact-lines a::after { background: radial-gradient(ellipse at center,
    rgba(5, 5, 6, 0.98) 0%, rgba(5, 5, 6, 0.88) 45%, rgba(5, 5, 6, 0) 78%); }
  /* `.terminal .t-red` is the one glyph in the file measured to a hard
     ceiling regardless of backdrop -- see its own comment below ("$"
     clears >=5:1, "0" and the live dot cannot by construction). A bed
     here would not move that number, so it doesn't get one; nothing
     changes for it in this pass. */
}

/* ---------- `#hero` + `#vision` copy beds — UNCONDITIONAL ----------

   The group above lives inside `@media (max-width: 899px)` because its whole
   reason for existing is `MOBILE_VEIL_SCALE` (js/main.js): below 900px every
   section's veil is multiplied by 0.15, so scenes that WERE protected by their
   own `data-veil` stop being, and their copy needs a local surface instead.

   These two scenes fail for a different reason, and therefore at EVERY width:
     - `#hero` carries `data-veil="0"` BY DESIGN -- the cold open where the mesh
       is the show. Zero times any scale is still zero, so no veil lever, mobile
       or otherwise, has ever reached this copy.
     - `#vision` runs the lightest veil on the page (0.25), over `horizon` --
       the phase whose bright band sits exactly behind this copy.

   Measured with scripts/contrast-probe.py: mesh live, pointer parked on each
   element, worst of 8 frames, and -- the part that makes hero numbers mean
   anything -- staged at the hero pin's p=0.71, the one position where every
   word of hero copy is typed and at full opacity (`makeTyper` starts all
   three EMPTY). `base` is the pre-branch tree, daebd131, served and swept
   with the SAME instrument, so the columns differ only in the site:

                          base@1440   after@1440   after@390
     #hero .tagline           1.03        4.95         4.92
     #hero h1                 2.53       15.22        15.40
     #hero .dek               1.91       14.87        14.91
     #hero .hero-note         1.05        5.61         5.79
     #hero .btn-ghost         4.67       12.02        12.17
     #vision .v-label         1.59        5.07         5.01
     #vision blockquote       4.85       16.40        16.23

   The BASE column is the finding, and it is the DEPLOYED site: four of those
   seven fail on chill-ai.org today, at 1.03-2.53:1, before any of this
   branch's brightness work touched them. They had simply never been measured
   -- every sweep this branch ran was at 390 and 320, where the base mesh sits
   at its dimmest possible resting state (no drift, no pointer) and the same
   copy reads 8-11:1. On a desktop viewport a real cursor excites the mesh and
   nothing damps it, because #hero has no veil. `bgmax` reaches 1.0000 on the
   base hero rows: a pointer-excited cluster blowing to pure white directly
   behind the headline. A `max-width: 899px` bed would have left every one of
   those readings exactly where it was -- which is why this group is
   unconditional and the group above is not.

   `::before`, NOT `::after` -- the exact mirror of the shared group's own note.
   There, `.eyebrow::before` was already taken, so the bed went on `::after`.
   Here `::after` is taken: `.typing::after` (below) is the red typing caret,
   and `makeTyper` (js/main.js) puts `.typing` on exactly `.hero .tagline`,
   `.hero h1` and `.hero .dek` while the hero scrubs. A bed on `::after`
   outranks that rule (0,2,1 vs 0,1,1) and would override the caret's own
   `background` with the bed's radial -- deleting the caret on all three
   elements that have one.

   `position: relative` WITHOUT `z-index`, for both reasons the group above
   documents: relative is required so the bed's containing block resolves to
   this element rather than to `main`, and an explicit `z-index: 0` would give
   the element its own stacking context and paint its bed OVER inline siblings
   sharing its line -- the `.t-dim` over-paint bug. Do not add one.

   Geometry is `.scene .lead::after`'s CORRECTED falloff, not the shared
   45%/78% one: round 3 proved the shared ellipse reaches zero at 78% of its
   own half-extent, which on a narrow centred column lands inside the text's
   line-ends. Hero copy on a phone is precisely that shape. */
/* `#hardware .flow-note` joins them, but ONLY WHILE THE SCENE IS NOT PINNED.
   It is the one line in that scene with no surface of any kind under it, so
   the raw mesh reaches it; measured after the Task 4 bands landed (which move
   this scene's backdrop) it reads 1.13:1 at 390x844, and its `<b>` 1.05:1.

   `:not(.is-pinned)` rather than a copy of HW_PIN_QUERY, for two reasons.
   First, the pinned path does not need this: `#hardware.is-pinned .flow
   .flow-note` already carries `--copy-halo-dense` and measures 5.3:1, and a
   bed there would sit inside the machine the pinned composition draws at the
   centre of the viewport -- darkening the very silhouette the scene exists to
   show. Second, a media query here would be a second copy of a frontier that
   already lives in two places (HW_PIN_QUERY in js/scenes.js and
   CONDITIONAL_SCENES in the sweep); keying off the class the pin itself sets
   cannot drift out of step with it. It also means the reduced-motion and
   no-JS paths -- which never pin, and never run scenes.js -- get the bed,
   which is exactly right for them. */
.hero :is(.tagline, h1, .dek, .hero-note),
#vision :is(.v-label, blockquote),
#hardware:not(.is-pinned) .flow-note { position: relative; }

.hero :is(.tagline, h1, .dek, .hero-note)::before,
#vision :is(.v-label, blockquote)::before,
#hardware:not(.is-pinned) .flow-note::before {
  content: "";
  position: absolute;
  z-index: -1;
  /* ⚠ The horizontal bleed is CLAMPED TO THE GUTTER, and must stay that way.
     An absolutely-positioned box contributes to its ancestor's scrollable
     overflow (a fixed one would not), so a bed that bleeds further sideways
     than the space beside its element widens the PAGE. The shared group above
     never had to care: it is `max-width: 899px` only, and viewport-sweep's
     hOvf guard is structurally vacuous on its own sub-900 rows (under
     `is_mobile` Chromium grows the layout viewport to contain the content, so
     innerWidth tracks scrollWidth and the difference is always 0 -- see the
     sweep's own note). This group is unconditional, so it lands on rows where
     that guard actually works, and it failed three of them the moment it did:
     932x430 by 11px, 1000x700 by 10px, 1024x768 by 8px. At 1000px wide the
     hero's `.wrap` is 960, leaving 20px of gutter against a 30-34px bleed.

     `max()` on a negative length picks the SMALLER magnitude, so this reads
     "bleed 30px, or the gutter less a 6px safety margin, whichever is less" --
     full bleed wherever there is room for it, and never past the viewport
     where there is not. The 6px absorbs the difference between `100vw` (which
     includes a classic scrollbar) and the actual client width. */
  inset-block: -38px;
  inset-inline: max(-30px, calc((100% - 100vw) / 2 + 6px));
  background: radial-gradient(ellipse at center,
    rgba(5, 5, 6, 0.97) 0%, rgba(5, 5, 6, 0.95) 62%, rgba(5, 5, 6, 0) 96%);
  pointer-events: none;
}
/* `h1` is the largest, most exposed block in the scene with the brightest mesh
   and no veil at all -- the same reasoning `.scene h2::after` uses for its own
   wider bleed, one step further. Same gutter clamp, same reason. */
.hero h1::before {
  inset-block: -46px;
  inset-inline: max(-34px, calc((100% - 100vw) / 2 + 6px));
}

/* `#vision` gets a SOFTER bed than the group default, and the reason is
   visual rather than numerical. Its two blockquotes are wide, short boxes
   sitting in the sparsest part of the page (`horizon` at data-veil 0.25),
   so the group's near-opaque 0.97/0.95-to-62% ellipse read on desktop as a
   hard-edged dark RECTANGLE -- not because the plate is lighter than the
   page, but because the mesh texture visibly STOPS at its border. That is
   the exact failure mode this branch already rejected once ("visible
   hard-edged dark boxes ... they hide MORE mesh than a soft radial bed
   would"), and it is against the whole point of the work.

   Affordable because the measurement has room to spare: the group default
   put this element at 16.2-16.5:1 against a 4.5 floor, i.e. ~3.6x headroom,
   so peak alpha can drop and the falloff can start earlier and end later.
   Re-measured after the change (see the table in the hero/vision comment
   above for method) -- still clears with wide margin. `.v-label` is small,
   red and 12px, so it keeps a stronger centre than the blockquote. */
#vision :is(.v-label, blockquote)::before {
  inset-block: -46px;
  background: radial-gradient(ellipse at center,
    rgba(5, 5, 6, 0.80) 0%, rgba(5, 5, 6, 0.52) 55%, rgba(5, 5, 6, 0) 92%);
}
#vision .v-label::before {
  background: radial-gradient(ellipse at center,
    rgba(5, 5, 6, 0.92) 0%, rgba(5, 5, 6, 0.66) 52%, rgba(5, 5, 6, 0) 90%);
}

/* `.terminal .t-dim` copy bed -- deliberately NOT part of the shared
   `::after`-bed group above, and for a reason unrelated to the z-index
   over-paint bug that group's own comment documents. `.t-dim` is an
   INLINE `<span>` living inside flowing, `white-space: pre-wrap` text
   (`.terminal pre`) that DOES wrap -- e.g. `every door, one identity` on
   the sso-gateway line -- and a `position: absolute` `::after` on a
   wrapped inline element takes its containing block from the bounding
   box that would enclose ALL of that element's line fragments, first to
   last, not the one line fragment nearest it. That buried the word
   "identity" under a plate sized for the ENTIRE (multi-line) span, not
   just the fragment it sits in -- independent of whatever z-index the
   element carries; deleting `z-index: 0` above does not fix this one.

   Fixed by painting the bed as this element's own `background-image`
   instead of a positioned pseudo-element: a background paints inside the
   element's own box, so it can never escalate above or bury a sibling the
   way a stacking-context-promoted `::after` can, and with
   `box-decoration-break: clone` (the non-default value -- the default,
   `slice`, computes ONE gradient across the whole unwrapped span and
   slices it per line, which is the same "one shape spanning every line"
   problem in a different guise) each wrapped line fragment gets its OWN
   independent, correctly-centred copy of the radial, exactly the "backs
   paint per line box" behaviour the shared bed can't offer an inline
   wrapper. No bleed (`inset`) is possible this way -- background is
   clipped to the element's own box -- so the gradient's own stops are
   pulled in (55%/85% vs the shared bed's 45%/78%) to fully resolve to
   transparent inside a tight single-line box instead of hard-clipping a
   still-fading edge. */
@media (max-width: 899px) {
  .terminal .t-dim {
    background-image: radial-gradient(ellipse at center,
      rgba(5, 5, 6, 0.95) 0%, rgba(5, 5, 6, 0.8) 55%, rgba(5, 5, 6, 0) 85%);
    -webkit-box-decoration-break: clone;
    box-decoration-break: clone;
  }
}

/* `.hub-figure .hub-lbl text` is SVG -- CSS `background`/`::after` don't
   paint SVG text, so neither lever above can reach it, and its own fill is
   already the opaque --ink-mid ceiling. The one CSS-only lever left is the
   card it sits inside: raising .card's own backdrop a little on mobile
   thins the mesh contribution for every card at once (kicker, hub-lbl,
   terminal reds all sit inside one), the same lever .card's own 0.8 vs
   0.72 comment (above, ~line 493) already used once for the same failure
   mode on desktop. */
@media (max-width: 899px) {
  .card { background: rgba(11, 11, 13, 0.9); }
}

/* ---------- very short viewports (landscape phones) ----------
   Below this the vh-capped rhythm alone can't fit the whole hero inside
   the pin, and anything past the fold is unreachable while pinned. The
   note strip is the only droppable element — its three facts all appear
   again further down the page. */
@media (max-height: 460px) {
  .hero-note { display: none; }
  /* pillars: the eyebrow is the droppable one — the section still carries
     its headline, and the room it frees is what pays for the topbar
     clearance above. Same trade the hero makes with .hero-note.
     (A prior pass also trimmed .pillar-index/.pillar-sub/.pillar-body's
     own padding here, because the flat contrast plate they carried at the
     time added real layout height that pushed the pinned pillars 3-21px
     past the fold at this floor. That plate is gone -- replaced by the
     `::after`-based copy bed above, which is `position: absolute` and
     adds none -- so there is nothing left here to trim.) */
  #pillars.is-pinned .pillars-head .eyebrow { display: none; }
  .hero { padding-top: min(120px, 10vh); padding-bottom: min(80px, 4vh); }
  #logo3d { height: clamp(60px, min(22vw, 21vh), 330px); }
  .hero .tagline { margin-bottom: min(26px, 2vh); }
  .hero h1 { font-size: clamp(1.3rem, min(5.4vw + 0.8rem, 8.2vh), 5rem); }
  .hero .dek { font-size: clamp(0.8rem, min(1.2vw + 0.55rem, 2.9vh), 1.25rem);
               margin-top: min(26px, 2vh); }
  .hero-actions { margin-top: min(40px, 2.6vh); gap: 10px; }
  .scroll-cue { bottom: 10px; font-size: 9px; }
  .scroll-cue::after { height: 26px; }
  .btn { padding: 11px 22px; font-size: 13px; }
}

/* 320px-class phones: the copy re-wraps to five or six lines and the
   buttons stack, so the decorative note strip is what has to go. */
@media (max-width: 340px) {
  .hero-note { display: none; }
}

/* notch-safe chrome in both orientations */
.topbar {
  padding-left: max(clamp(20px, 4vw, 48px), env(safe-area-inset-left));
  padding-right: max(clamp(20px, 4vw, 48px), env(safe-area-inset-right));
}
.rail { right: max(18px, env(safe-area-inset-right)); }
footer {
  padding-left: max(clamp(20px, 5vw, 64px), env(safe-area-inset-left));
  padding-right: max(clamp(20px, 5vw, 64px), env(safe-area-inset-right));
  padding-bottom: max(34px, env(safe-area-inset-bottom));
}

/* typing caret — sits right after the last revealed character */
.typing::after {
  content: "";
  display: inline-block;
  width: 0.45em;
  height: 0.95em;
  margin-left: 0.08em;
  vertical-align: -0.08em;
  background: var(--red-hot);
  box-shadow: 0 0 12px rgba(var(--red-rgb), 0.8);
  animation: blink 0.85s step-end infinite;
}

/* ---------- reveal defaults (JS toggles .is-in) ---------- */

.reveal {
  opacity: 0;
  transform: translateY(26px);
  transition: opacity 0.8s cubic-bezier(0.22, 1, 0.36, 1), transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.is-in { opacity: 1; transform: none; }

/* ---------- reduced motion ---------- */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .reveal { opacity: 1; transform: none; transition: none; }
  .marquee-track { animation: none; flex-wrap: wrap; width: auto; }
  .scroll-cue::after { animation: none; }
  .terminal .cursor { animation: none; }
  /* This is the reduced-motion pillars path at >=900px: scenes.js returns
     before it can set is-pinned/is-static, and .pillars-stack stays
     display:none above 900px — so un-hiding the .pillar panels here is the
     ONLY thing that renders the pillars body. Do not delete on the
     assumption .pillars-stack covers it; it does not above 900px. */
  .pillar { position: static; opacity: 1; visibility: visible; margin-bottom: 40px; }
  .pillar-track { min-height: 0; }
  .pillar-progress { display: none; }
  *, *::before, *::after { transition-duration: 0.01ms !important; }
}
