/* site.css — home page stylesheet: tokens, page frame, and scene 01.
   Tokens are transcribed from "Alex Koch Portfolio Design System/tokens/"; only used values are copied here. */

:root {
  --font-display: 'Archivo', system-ui, sans-serif;
  --font-body: 'Space Grotesk', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --accent: #dd6a20;
  --accent-hover: #f0894f;
  --amber: #eda863;
  --scenery: #6e3413;
  --ink: #0c0c0e;
  --ink-deep: #0a0908;
  /* The cover head's ground. Cooler than the face on purpose — see .cover-head. */
  --cover-scrim-top: #0b1018;
  --cover-scrim: #101828;
  --ink-panel: #100b08;
  --hairline: #1c2128;
  --on-accent: #180a03;

  --text-strong: #ffffff;
  --text-body: rgba(255, 255, 255, .62);
  --text-muted: rgba(255, 255, 255, .45);
  --text-faint: rgba(255, 255, 255, .32);
  --border-neutral: rgba(255, 255, 255, .2);
  --border-accent: rgba(221, 106, 32, .34);
  --ink-hot: #1b1408;
  --amber-focus: rgba(255, 180, 84, .35);

  --radius-cta: 3px;
  --radius-chip: 6px;
  --radius-card: 12px;

  --pad-page: 40px;
  /* Every section below the stage shares the hero's rail, so the lockup and section titles line up. Widening the
     hero widens the whole page; body copy caps its own measure instead. */
  --content-max: var(--hero-max);
  /* Scene 01's two side columns, equal so the hero balances about the core. 530px is the measured floor for the
     lockup's ink at 176px; smaller wraps. */
  --hero-col: 530px;
  /* THE HERO CORE, AS ONE DESCRIPTION. The buffer, how far the camera is pulled in, and the ground under it are
     three views of the same object, so the last is DERIVED rather than guessed: sized in vh on a desktop and in
     lane-fractions on a phone, the shade came out 1.43x its core's radius on one and 0.75x on the other.
     hero-core.js reads --core-zoom back rather than restating it.
     .167 is the drawn core's radius as a fraction of the buffer at zoom 1 — the shader fills about a third of
     its buffer — and the camera scales that linearly, so the product is the core's radius on screen whatever
     the two are set to. 1.1 of that is the ground: just past the ring, where its own falloff has nothing left
     to show an edge against. */
  --core-canvas: min(94vh, 900px);
  --core-zoom: 1;
  --core-shade: calc(var(--core-canvas) * .167 * var(--core-zoom) * 1.1);
  /* Three columns wide (lockup, core lane, aside) — wider than the 1400px the sections below share, deliberately,
     since the stage is full-bleed. */
  --hero-max: calc(var(--hero-col) * 3);

  --ease-reveal: cubic-bezier(.4, 0, .2, 1);
  --ease-settle: cubic-bezier(.2, .85, .25, 1);

  /* Scene 01's scroll share, in viewport heights — the only declaration of it, read back by scenes.js from
     computed
     style rather than restated. Hold is 0: the scene is already visible on load, so a hold would only stall the first half-screen of scroll. */
  --scene-hold: 0;
  --scene-exit: .7;

  /* The calculator's pin, in viewport heights — the only declaration of it. scenes.js reads it back rather than
     restating it. Not tracked to scroll: crossing the trigger commits the morph, which then runs to completion on
     its own clock. */
  --morph-run: .7;
  /* Fraction of the pin where the morph commits. Declared here, not in scenes.js, because the checks read it too.
     Must stay above the rail's own .35 commit threshold, or the rail's glide could carry the reader across this line before the rail's own decision is made. */
  --morph-trip: .5;

}

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

html { scroll-behavior: smooth }

html, body { margin: 0; padding: 0 }

/* No scrollbar on the site: a full-bleed cinematic stage should read as one, not as a document with a rail down
   the edge. Hidden, not disabled — overflow stays scrollable via wheel, keys, touch. Labs keep their bars. */
html {
  /* Ground goes on html as well as body: the browser paints its own canvas before body's background arrives, and
     overscroll shows through that gap. color-scheme in CSS too, or scrollbars/form controls stay light. */
  color-scheme: dark;
  background: var(--ink);
  scrollbar-width: none;      /* Firefox */
  -ms-overflow-style: none;   /* legacy Edge */
}

html::-webkit-scrollbar, body::-webkit-scrollbar { width: 0; height: 0 }

body {
  background: var(--ink);
  color: var(--text-strong);
  font-family: var(--font-body);
  -webkit-font-smoothing: antialiased;
}

a { color: var(--accent); text-decoration: none }
a:hover { color: var(--accent-hover) }
::selection { background: var(--accent); color: var(--on-accent) }

@keyframes ak-spin { to { transform: rotate(360deg) } }
@keyframes ak-spin-r { to { transform: rotate(-360deg) } }
@keyframes ak-bounce { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(9px) } }
@keyframes ak-blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }
/* Linear, with the shape written into the keyframes — an ease-out fades the ring almost fully in the first
   quarter, leaving it near-invisible for the rest. Stepping scale/opacity by hand keeps emission quick, drift slow. */
@keyframes ak-halo {
  0%   { transform: scale(1);   opacity: .78 }
  35%  { transform: scale(1.9); opacity: .50 }
  85%  { transform: scale(2.7); opacity: 0 }
  100% { transform: scale(2.9); opacity: 0 }
}

/* The core, on the same clock as the halo: full strength when the next ring leaves, so the eye catches a change
   rather than a light that's simply on. Color/glow, not opacity — opacity would take the halo (its child) down too.
   Holds full for the first third; a deeper or earlier trough reads as going out rather than pulsing. Linear, since
   an ease-out would front-load the drop and undo the hold. */
@keyframes ak-live {
  0%, 30%  { background: var(--accent); box-shadow: 0 0 13px var(--accent) }
  62%, 88% { background: rgba(221, 106, 32, .58); box-shadow: 0 0 6px rgba(221, 106, 32, .7) }
  100%     { background: var(--accent); box-shadow: 0 0 13px var(--accent) }
}

/* Sections below the sticky stage rise in as they arrive, driven by IntersectionObserver rather than the
   scroll listener, which fires every frame where this needs to fire once per element. */
[data-reveal] {
  opacity: 0; transform: translateY(30px);
  transition: opacity .7s var(--ease-settle), transform .7s var(--ease-settle);
}

[data-reveal].shown { opacity: 1; transform: none }

/* One container for every section below the stage, so their left rails line up down the page. */
.inner { max-width: var(--content-max); margin: 0 auto }

/* ---------------- nav ---------------- */

/* Never a solid bar, at any scroll position: it stays a gradient fading to nothing, so no horizon line ever
   crops the ring, and the same bar over hero and document reads as one piece rather than a bolted-on header.
   1fr auto 1fr centers the middle zone against the viewport rather than against the two sides' own widths —
   space-between made the links slide whenever the mark cluster's width changed. */
#nav {
  position: fixed; inset: 0 0 auto; z-index: 60;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 20px;
  padding: 22px var(--pad-page);
  font-family: var(--font-mono); font-size: 12.5px; letter-spacing: .08em;
  background: linear-gradient(180deg, rgba(12, 12, 14, .92), rgba(12, 12, 14, 0));
}

/* baseline, not center: the two items in this cell are different sizes, and baseline is the line the eye reads
   them on.
   No min-width: 0 — the outer columns are 1fr (minmax(auto, 1fr)) and rely on that floor to stay off the center
   links; zeroing it once shrank this column to 55px around a 77px wordmark and overlapped ALEX by 2px. */
#nav .brand { justify-self: start; display: flex; align-items: baseline; gap: 14px }

/* Same gray as the marks opposite it, so both ends of the bar weigh the same and only the lit section reads. */
#nav .mark {
  color: var(--text-faint); font-weight: 700;
  transition: color .2s var(--ease-settle);
}
#nav .mark:hover { color: var(--accent) }#nav .links { display: flex; justify-content: center; gap: 24px; align-items: center }
/* nowrap, or a two-word label breaks inside its own box and grows taller than its neighbors. */
#nav .links a {
  position: relative; padding-bottom: 7px;
  color: var(--text-muted); white-space: nowrap;
  transition: color .2s var(--ease-settle);
}
#nav .links a:hover { color: var(--text-strong) }
#nav .links a[aria-current="true"] { color: var(--accent) }

/* Every item is its own progress bar: the rule under a link fills as that section scrolls, so the nav reads as
   a chapter meter rather than a highlight. nav.js writes --k; each link knows only its own --i. */
#nav .links a::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
  background: var(--border-accent);
  /* No transition — the value is scrubbed, and easing a scrubbed value only makes it lag the scroll. */
  transform: scaleX(clamp(0, calc(var(--k, 0) - var(--i, 0)), 1)); transform-origin: left;
}
#nav .links a[aria-current="true"]::after { height: 2px; background: var(--accent) }

/* Deliberately the quietest thing in the bar: all three are reachable again in CONTACT, so these are marks
   (not a LINKEDIN label) and a shortcut rather than the route. */
#nav .tools { display: flex; justify-self: end; align-items: center; gap: 8px }
#nav .tools a, #nav .tools button {
  display: grid; place-items: center; width: 28px; height: 28px;
  padding: 0; border: 0; background: none; border-radius: var(--radius-cta);
  color: var(--text-faint); cursor: pointer;
  transition: color .2s var(--ease-settle);
}
#nav .tools a:hover, #nav .tools button:hover { color: var(--accent) }
#nav .tools a:focus-visible, #nav .tools button:focus-visible {
  outline: none; color: var(--accent); box-shadow: 0 0 0 2px var(--amber-focus);
}
#nav .tools svg { width: 15px; height: 15px; display: block; fill: currentColor }

/* A mark should say where it goes before it's clicked. Right-aligned since this cluster sits against the page
   edge, where a centerd label would hang off it. */
#nav .tools .tip { position: relative }
#nav .tools .tip::after {
  content: attr(data-tip);
  position: absolute; top: calc(100% + 9px); right: 0;
  padding: 6px 9px; border: 1px solid var(--hairline); border-radius: var(--radius-cta);
  background: var(--ink-deep); color: var(--text-body);
  font-size: 11px; letter-spacing: .04em; text-transform: none; white-space: nowrap;
  opacity: 0; transform: translateY(-4px); pointer-events: none;
  transition: opacity .18s var(--ease-settle), transform .18s var(--ease-settle);
}
#nav .tools .tip:hover::after, #nav .tools .tip:focus-visible::after { opacity: 1; transform: none }

/* The two phone doors and the marks' names. Off everywhere but a phone, and the portrait block below is the only
   place they come back — a bar with room for the list has no use for a button that hides it. */
.nav-now, .nav-more, .tool-name { display: none }

section[id] { scroll-margin-top: 84px }

/* ---------------- the scroll rig ---------------- */

/* THE RUNWAYS ARE SIZED IN svh AND THE STAGES IN dvh, and the split is the whole point.
   dvh tracks the phone's URL bar: it GROWS as the bar hides and shrinks as it returns. A stage wants that — it
   should fill whatever screen there is. A runway must not have it. Every runway is a multiple of a screen, so
   the four of them above Experience turned 84px of returning URL bar into 378px of document, and the page slid
   out from under a reader who had just stopped scrolling. Measured on a 390x844 phone: 53px of visible lurch
   mid-section, after the browser's own scroll anchoring had absorbed what it could.
   svh is the viewport with the bar SHOWING and never changes, so the document height stops depending on it.
   The vh line above each stays as the fallback for a browser without the units.

   The probe carries the same height so scenes.js can MEASURE it: a custom property holding `100svh` reads back
   as that string, not as pixels, and the script's idea of a screen has to be the stylesheet's exactly or every
   scrub is calibrated against a different number than the runway it runs on. visibility, not display: none —
   a display: none element has no height to read. */
#screen-probe {
  position: fixed; top: 0; left: 0; width: 0;
  height: 100vh; height: 100svh;
  visibility: hidden; pointer-events: none; z-index: -1;
}

/* One tall container, one sticky stage, one listener. Every scene is absolutely positioned inside the stage and
   reads its transform from custom properties the listener writes, so a scroll frame touches one element. */
#scroll {
  position: relative;
  height: calc(100vh + (var(--scene-hold) + var(--scene-exit)) * 100vh);
  height: calc(100svh + (var(--scene-hold) + var(--scene-exit)) * 100svh);
}

/* Flat, not a radial gradient — a pool centerd at 50% 42% sat exactly where the core is and read as a second,
   uncast glow behind it. The core brings its own light; the stage brings none.
   --ink here and on every section below is the one ground for the whole page: a section a step darker reads as
   a panel laid on the page rather than more of it. */
#stage {
  position: sticky; top: 0; height: 100vh; height: 100dvh; overflow: hidden;
  background: var(--ink);
}

.scene {
  position: absolute; inset: 0;
  display: flex; flex-direction: column;
  padding-top: 68px;
  opacity: var(--o1, 1);
  transform: translateY(var(--y1, 0px));
  filter: blur(var(--b1, 0px));
  pointer-events: var(--e1, auto);
}

/* The ring's two edges, seen from the surface it encircles: one band, so the curves mirror rather than
   concentric.
   Under the core deliberately — the core blends with LIGHTEN, so a line darker than its glow simply loses where
   they overlap, giving correct occlusion for free. */
#hero-halo {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: var(--ring-o, 1);
  pointer-events: none; z-index: 0;
}

/* preserveAspectRatio="none" maps the viewBox onto the stage's actual shape, the only way the feet stay in the
   corners at every window size. non-scaling-stroke stops that turning the line into a wedge and fixes its width
   in device px. */
#hero-halo path {
  fill: none;
  /* Far below --accent deliberately: this is scenery, sitting behind a lockup that must stay the brightest thing
     in frame. */
  stroke: var(--scenery); stroke-width: 1.5;
  vector-effect: non-scaling-stroke;
}

/* The band opens and clears the frame sideways as the scene leaves, rather than dimming in place; each edge
   needs its own direction, hence two rules. A CSS translate on SVG geometry is in user units, so this scales
   against the viewBox. */
#hero-halo path:first-of-type { transform: translateX(calc(var(--halo-x, 0px) * -1)) }
#hero-halo path:last-of-type { transform: translateX(var(--halo-x, 0px)) }

/* The ground under the core: solid to the drawn core's own edge, then falling off to nothing across as much
   again. --core-shade is the DRAWN core, not the buffer — at zoom 1 the shader fills about a third of its
   canvas and on a portrait phone most of it, so a size taken from the canvas would be wrong at one end or the
   other. It rides --core-s and --ring-o with the core, or a black disc would sit on the stage after the thing
   it grounds had left; it does NOT ride the pulse, which lives in the shader and never touches the canvas box. */
#hero-shade {
  position: absolute; top: calc(50% + 5px); left: 50%;
  width: calc(var(--core-shade) * 2); height: calc(var(--core-shade) * 2);
  transform: translate(-50%, -50%) scale(var(--core-s, 1));
  /* --ink, for the same reason the word scrims are: the ground under the core is the page's ground, and at
     black it was darker than the section it sits in. Every stop carries the ink's own channels, the fully
     transparent one included — fading to `transparent` (which is transparent BLACK) interpolates through
     progressively darker greys in sRGB and leaves a dark ring where the falloff meets the page. */
  background: radial-gradient(closest-side,
    rgb(12, 12, 14) 0%, rgb(12, 12, 14) 40%, rgba(12, 12, 14, .78) 58%,
    rgba(12, 12, 14, .34) 78%, rgba(12, 12, 14, 0) 100%);
  opacity: var(--ring-o, 1);
  /* After #hero-halo in the DOM and on the same layer, so it mutes the ring's two edges where they pass the
     core; under #hero-core's z-index 1, so it never dims the core itself. */
  pointer-events: none; z-index: 0;
}

/* The reactor's core, the only thing in the middle of this scene.
   LIGHTEN, not screen: the shader fills undrawn pixels with near-black rather than zero, and screen would ADD
   that across the whole canvas, reading as a circle behind the core. Lighten takes the per-channel max instead.
   Sized in vh because the shader scales its picture by the buffer's height; sizing by width would make the core
   grow and shrink as the window widened. */
#hero-core {
  /* Centered 5px below the stage's middle: the scene's padding-top and the foot land within 10px of each other,
     so this is the line the columns are actually arranged around. */
  position: absolute; top: calc(50% + 5px); left: 50%;
  width: var(--core-canvas); height: var(--core-canvas);
  /* Collapses into itself on exit — shrinking reads as the reactor leaving the stage, dimming alone would just
     read as a light turning down. */
  transform: translate(-50%, -50%) scale(var(--core-s, 1));
  opacity: var(--ring-o, 1);
  mix-blend-mode: lighten;
  /* The buffer's edge can never be a line. Under `lighten` any pixel the shader leaves above the ground shows,
     so a hard boundary is one step from a visible rectangle — which is exactly what a bright glow reaching the
     edge produced. The fade is inside the room the buffer now has, so it costs no picture. */
  -webkit-mask-image: radial-gradient(closest-side, #000 62%, rgba(0, 0, 0, 0) 100%);
  mask-image: radial-gradient(closest-side, #000 62%, rgba(0, 0, 0, 0) 100%);
  pointer-events: none; z-index: 1;
}
/* Arrives when its shader does, and fades rather than appears — hero-core.js adds this class once, on the first
   frame it has a program to draw with. */
#hero-core { opacity: 0; transition: opacity .9s var(--ease-settle) }
.core-lit #hero-core { opacity: var(--ring-o, 1) }

/* The halo traces itself while the core's shader links, standing in for a fragment shader that can take seconds
   to link on a cold cache — no WebGL here, or it would queue behind the thing it's covering for.
   transform is left alone: scenes.js drives translateX on these paths via --halo-x, and animating transform here
   would pin the halo at one scroll position. Only the dash offset moves. */
html:not(.core-lit) #hero-halo path {
  stroke-dasharray: 100;
  animation: halo-trace 2.6s infinite;
}
/* Both edges together, not staggered — they're two sides of one band, and offsetting them would read as two lines
   rather than one ring being drawn. */
@keyframes halo-trace {
  0%   { stroke-dashoffset: 100; animation-timing-function: cubic-bezier(.22, 1, .36, 1) }
  48%  { stroke-dashoffset: 0 }
  60%  { stroke-dashoffset: 0; animation-timing-function: cubic-bezier(.6, -.4, .74, .05) }
  100% { stroke-dashoffset: 100 }
}
/* Once the core has a program the dash drops entirely, leaving the halo the solid line it's designed as. */
.core-lit #hero-halo path { stroke-dasharray: none; animation: none }

/* ---------------- scene 01 ---------------- */

.hero { flex: 1; display: flex; align-items: center; padding: 0 var(--pad-page); position: relative; z-index: 3 }

/* The hero fades up once, on first load only. Keyed on `booting`, set by an inline script before first paint and
   cleared on DOMContentLoaded — keyed on the document, not the core shader, so a cold visitor isn't left staring
   at an empty hero while the shader links; the words are ready long before the picture. */
.hero-grid, .hero-foot { transition: opacity 3s var(--ease-settle) }
.booting .hero-grid, .booting .hero-foot { opacity: 0 }
/* A reader who asked for less motion gets the page, not a three-second fade to it. */
@media (prefers-reduced-motion: reduce) {
  .hero-grid, .hero-foot { transition: none }
  .booting .hero-grid, .booting .hero-foot { opacity: 1 }
  #hero-halo path { animation: none; stroke-dasharray: none }
}

.hero-grid {
  width: 100%; max-width: var(--hero-max); margin: 0 auto;
  /* Three equal columns; the middle is the core's lane and holds nothing. Equal by construction, not measurement,
     so the balance survives a resize — no column gap, since the lane is the gap. Collapses to one column below 820px. */
  display: grid;
  grid-template-columns: repeat(3, minmax(0, var(--hero-col)));
  column-gap: 0; row-gap: 48px; align-items: center;
}

.kicker {
  font-weight: 600; font-size: clamp(20px, 3.2vw, 44px); line-height: 1.3; letter-spacing: -.5px;
  color: var(--accent);
}

/* Archivo 900 at −5px tracking. display-lockup.js measures each line's real ink edges and retracks the shorter
   line to match — A has almost no left sidebearing and K has a full stem, so box-aligned lines otherwise sit
   visibly indented on the left and ragged on the right. */
.lockup {
  margin: 6px 0 0;
  font-family: var(--font-display); font-weight: 900;
  font-size: clamp(64px, 13vw, 176px); line-height: .82; letter-spacing: -5px;
  color: var(--text-strong);
}

.lockup span { display: block }
/* No entrance of their own. These used to carry a staggered clip wipe and their own rise, arriving as separate
   moves on separate clocks; the scroll already moves and blurs them on hand-off, so on first load they should
   only appear via .hero-grid's fade. */
.lockup .l2 { color: var(--accent) }

/* Column three explicitly — the empty middle column is the core's lane, and a second child would otherwise fall
   into it. */
.aside {
  grid-column: 3;
  color: var(--text-body); font-size: clamp(13px, 1.4vw, 16px); line-height: 1.65;
  text-align: center;
}

/* A GROUND UNDER THE WORDS, at every width. The ring's two edges and the core's glow pass behind the type at
   full strength, and the lockup is only the brightest thing in frame if what is behind it is not. Blurred
   rather than a box: a scrim with an edge of its own reads as a panel laid on the scene.
   z-index -1 stays inside .hero's own stacking context (z-index 3), so it darkens the core and the halo under
   it without reaching the page's ground. */
.hero-grid > div:first-child, .aside { position: relative }
.hero-grid > div:first-child::before, .aside::before {
  content: ''; position: absolute; inset: -20px -26px; z-index: -1;
  /* --ink at half strength, NOT black: this is the page's own ground brought forward over the scene, so at
     full opacity it has to be indistinguishable from the ground either side of it. Pure black read as a dark
     patch laid on the page rather than as more of the page. */
  background: rgba(12, 12, 14, .5); border-radius: 44px; filter: blur(24px);
  pointer-events: none;
}

.hero-foot { padding: 0 var(--pad-page) 30px; position: relative; z-index: 3 }

/* Centerd on the stage rather than the hero's column: "there is more below" is about the screen, not the text
   grid. */
.hero-foot .inner { display: flex; align-items: flex-end; justify-content: center }

/* Never stops bouncing — ambience shouldn't demand attention. Leaves on its own curve, well before the words,
   so a scroll cue still riding up under a blur doesn't compete with the instrument at the wrong moment. */
.cue { font-size: 18px; line-height: 1; color: var(--text-muted); opacity: var(--cue-o, 1) }

.cue span { display: inline-block; animation: ak-bounce 1.6s ease-in-out infinite }

/* ---------------- featured app ---------------- */

/* The morph's pin. Height is the viewport plus the scrub runway; scenes.js derives progress from this exact
   geometry, so the runway is declared once, here. */
#app-scroll { position: relative; height: calc(100vh + var(--morph-run) * 100vh); height: calc(100svh + var(--morph-run) * 100svh) }
/* --m defaults to the shipped state on the element scenes.js writes to, so an inline style can outrank it while
   no-JS/reduced-motion inherit the finished calculator. Defaulting on .phone instead would shadow that and pin
   the morph at 1 forever. */
#app-stage {
  --m: 1;
  position: sticky; top: 0; height: 100vh; height: 100dvh;
  display: flex; align-items: center; overflow: hidden;
}

#app {
  position: relative; width: 100%; padding: 90px var(--pad-page) 60px;
  background: var(--ink);
}

/* Every fallback is the shipped state, so no JS, reduced motion, or a short viewport shows the finished
   calculator rather than a beige faceplate nobody asked to see. */
.then-now { margin-top: 24px; display: grid; gap: 8px; max-width: 62ch }

.tn {
  font-family: var(--font-mono); font-size: 11px; line-height: 1.7; letter-spacing: .06em;
  color: var(--text-faint); transition: color .3s var(--ease-settle);
}

.tn b {
  display: inline-block; min-width: 52px; font-weight: 500;
  text-transform: uppercase; letter-spacing: .18em;
}

#app-stage:not(.is-new) .then { color: var(--text-body) }
#app-stage:not(.is-new) .then b { color: var(--amber) }
#app-stage.is-new .now { color: var(--text-body) }
#app-stage.is-new .now b { color: var(--accent) }

/* Rail to rail, like every other section, with the phone centerd in what's left. Copy is capped at its own
   measure so the fractional track can't set text width; the second track takes the remainder. */
.app-grid {
  display: grid; grid-template-columns: minmax(0, 600px) 1fr;
  gap: clamp(30px, 4.5vw, 68px); align-items: center;
}

.cta-row { --badge-h: 44px; display: flex; gap: 12px; margin-top: 26px; flex-wrap: wrap; align-items: center }

.cta-row .primary {
  display: inline-flex; align-items: center; gap: 10px;
  background: var(--accent); color: var(--on-accent);
  font-family: var(--font-display); font-weight: 800; font-size: 14px;
  padding: 12px 20px; border-radius: var(--radius-cta);
  transition: background .18s var(--ease-settle);
}

/* The store's own mark in the button's ink, not its four brand colors — a full-color logo here reads as a
   pasted-in badge. currentColor also follows the label on hover.
   Four wedges, not one shape: a single path with abutting subpaths fills into a plain triangle and stops being
   the Play mark, so each wedge is its own path pulled toward its center. The out-arrow after the label is dimmer
   and smaller — it says the link leaves, a different fact from which store. */
.cta-row .primary svg { width: 16px; height: 16px; flex: none; fill: currentColor }
.cta-row .primary .ext { width: 13px; height: 13px; margin-left: -2px; opacity: .75 }

.cta-row .primary:hover { background: var(--accent-hover); color: var(--on-accent) }

.phone-wrap {
  justify-self: center; position: relative; z-index: 0;
  /* Fixed at the widest state deliberately: a column tracking the phone's width would recenter the pair as it
     narrows, dragging the copy sideways for the whole scrub. */
  width: min(470px, 82vw);
  display: flex; flex-direction: column; align-items: center; gap: 16px;
  /* Long lens, not wide, the same choice the flow graph makes — a near perspective shrinks the back plane hard
     enough that the silhouette lines run in ahead of where they should meet it. */
  perspective: 2400px;
}

/* Light thrown on the wall behind the device, not a rim around it — pushed back in Z so the perspective spreads
   it wider than the phone, reading as a room rather than an outline. Lit for the whole scrub rather than faded
   in on --m, since the wall doesn't care which object stands in front of it.
   Same lamp as the cartographer floor pool (#cartoPool's numbers, not the accent's) — two near-but-not-quite
   matching light colors in one scroll would read as two mistakes. */
.phone-wrap::before {
  content: ''; position: absolute; z-index: 0; pointer-events: none;
  left: 50%; top: 46%; width: 190%; height: 120%;
  transform: translate(-50%, -50%) translateZ(-260px);
  background: radial-gradient(closest-side, rgba(176, 60, 16, .3), rgba(176, 60, 16, .1) 50%, transparent);
  /* The floor pool's own .34, lifted: that figure is set against a much smaller SVG ellipse, this is blurred
     across the whole wall. */
  opacity: .7;
  /* Kept, unlike the floor pool: this gradient spans ~900px vs. its 300px, and bands visibly on 8-bit displays
     without a blur. */
  filter: blur(34px);
}

.phone { position: relative; z-index: 1 }

/* The morph runs entirely in CSS off one scalar, --m, written by scenes.js on the stage each frame — every
   number below is "old + (new - old) * m" and every color a color-mix by the same scalar, so it's one description
   rather than split across CSS and JS. Each declaration states the shipped value first, so no color-mix support
   or no JS still gets the finished calculator. */
.phone {
  --mix: calc((1 - var(--m)) * 100%);
  width: min(calc((470 + (330 - 470) * var(--m)) * 1px), 82vw);
  /* The padding is the chassis: a faceplate needs a case around its plate, a phone needs a wider one. */
  padding: calc(6px + var(--m) * 4px);
  --r: calc((6 + (38 - 6) * var(--m)) * 1px);
  /* One weight for every line on the object (back outline, edges, power key), so none can drift from the others.
     2px, not the flow graph's 1.4: the top/bottom are steep diagonals, and a 1.4px diagonal renders visibly
     fainter once anti-aliasing spreads it over two pixel columns. */
  --line: 2px;
  border-radius: var(--r);
  background: #16161a;
  background: color-mix(in srgb, #2b2b2d var(--mix), #16161a);
  /* One color for the whole object across the scrub — interpolating a gray bezel to a translucent accent gives a
     line too faint to read, and a second dimmer color for the back would make it read as a different material. */
  border: var(--line) solid var(--accent);
  /* preserve-3d puts the back outline at a real distance behind the front so hit-testing follows the transform.
     --dep is deliberately not a real device's thickness — at an 18° turn, an honest 9mm phone would put the
     outline only 3px out (sin 18° of the depth), reading as a printing slip rather than an object. Unitless,
     since the edges use it as a width and the outline as a Z offset. */
  --dep: calc(110 + (95 - 110) * var(--m));
  transform-style: preserve-3d;
  /* Turns in place. Both ends are centered in the column, so nothing slides sideways during the scrub — a
     translation here pushed the faceplate's left edge geometry off a 393px viewport. */
  transform:
    rotateY(calc((18 + (-15 - 18) * var(--m)) * 1deg))
    /* Tilted at both ends: the top/bottom edges bridge this vertical offset, which at zero would project onto the
       front's own edge and vanish. */
    rotateX(calc((6 + (5 - 6) * var(--m)) * 1deg));
  box-shadow:
    0 calc(24px + var(--m) * 26px) calc(50px + var(--m) * 40px) -22px rgba(0, 0, 0, .9),
    inset 0 1px 0 rgba(255, 255, 255, calc(var(--m) * .07));
}

/* The object's back edge, drawn like the flow graph's phones: one accent line over a dark face. A panelled side
   face would be wrong twice — a rectangle can't follow the 38px corner radius, and a shaded slab is a different
   vocabulary from this page's ruled outlines. border-radius: inherit fixes the corners: same box, same radius,
   standing --dep behind the chassis; the perspective does the rest. */
.phone::before {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  /* Stays black rather than tinting with the line, or the back plane would glow through the front instead of
     sitting behind it. */
  background: rgba(0, 0, 0, .25);
  border-radius: inherit;
  border: var(--line) solid var(--accent);
  /* Nothing but the depth — no scale to cancel the perspective's shrink, so the silhouette lines close on the
     real back plane rather than a corrected drawing of it. */
  transform: translateZ(calc(var(--dep) * -1px));
}

/* The straight lines of the extrusion, front corner-bend to back corner-bend on all four sides. Each edge is a
   plane perpendicular to the front, --dep deep, with only the front-to-back borders painted — the pair of
   straight segments an extruded rounded rect shows on its silhouette, inset by --r so a line starts exactly
   where the front's curve starts. All four exist because the object turns on both axes; whichever edge faces
   away is occluded by the front plane, and one seen end-on correctly projects to nothing. */
.phone > .edge {
  position: absolute; pointer-events: none;
  border: 0 solid var(--accent);
}
.phone > .edge.l, .phone > .edge.r {
  top: var(--r); bottom: var(--r); width: calc(var(--dep) * 1px);
  border-top-width: var(--line); border-bottom-width: var(--line);
}
.phone > .edge.t, .phone > .edge.b {
  left: var(--r); right: var(--r); height: calc(var(--dep) * 1px);
  border-left-width: var(--line); border-right-width: var(--line);
}
.phone > .edge.l { left: 0; transform-origin: left center; transform: rotateY(90deg) }
.phone > .edge.r { right: 0; transform-origin: right center; transform: rotateY(-90deg) }
.phone > .edge.t { top: 0; transform-origin: center top; transform: rotateX(-90deg) }
.phone > .edge.b { bottom: 0; transform-origin: center bottom; transform: rotateX(90deg) }

/* The power key, drawn on the right edge's plane so it turns with the object. Only the phone has one, so it
   rides --m rather than a switch — at the faceplate this edge is a calculator's side and there's nothing there.
   Inset from both ends of the depth, not filling it, since a full-thickness key would read as a slot. */
.phone > .edge.r > .pwr {
  position: absolute; left: 35%; right: 35%; top: 12%; height: 36px;
  /* Filled rather than outlined: the plane stands almost end-on, so a 2px outline lands too faint. */
  background: var(--accent);
  /* Elliptical, not circular: the plane's near-end-on angle collapses its projected width, and a circular cap
     would keep full height while narrowing to a point. */
  border-radius: 50% / 3px;
  opacity: var(--m);
}

.phone-inner {
  /* The screen inside the chassis, clipped so the stack's rows and keypad stop at the bezel's inner curve.
     Flat, inside a preserve-3d parent — the chassis needs 3D for its back outline, but the keypad doesn't, and
     leaving the keys in that context puts fifty-one planes into the browser's 3D sort.
     Pushed one pixel forward: without it the front border shares z=0 with the screen and the compositor can't
     resolve which surface owns a point where the edge planes overlap it — hover stops dead across half the pad
     while elementFromPoint still reports the key. Verify by hovering, not hit-testing APIs, which disagree here. */
  transform-style: flat;
  transform: translateZ(1px);
  overflow: hidden;
  border-radius: calc((4 + (26 - 4) * var(--m)) * 1px);
  background: #0b0b0d;
  background: color-mix(in srgb, #1b1b1d var(--mix), #0b0b0d);
}

.phone:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--amber-focus), 0 30px 60px -24px rgba(0, 0, 0, .85) }

.rpn-readout {
  perspective: 700px;
  /* The brushed plate the LCD sits in, becoming the phone's screen surround by the end. Side padding only while
     it's a faceplate — that seats the LCD into the plate; the phone's screen has no surround, so it goes to zero. */
  padding: calc(8px + var(--m) * 4px) calc((1 - var(--m)) * 10px) 0;
  display: grid; gap: 2px;
  background: transparent;
  background: color-mix(in srgb, #a9aeb2 var(--mix), transparent);
}

/* The brushed strip under the LCD, closing the surround the readout's padding opens on the other sides.
   Height, not opacity: on the phone this part is absent, not transparent, letting the keypad close up against
   the screen. Hairlines are inset shadows so they add no height at zero. */
.rpn-band {
  height: calc((1 - var(--m)) * 16px);
  overflow: hidden;
  display: flex; align-items: center; justify-content: space-between;
  padding: 0 11px;
  background: linear-gradient(180deg, #ced3d7, #9aa0a4 46%, #b7bcc0 54%, #7f8488);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), inset 0 -1px 0 rgba(0, 0, 0, .5);
}

.rpn-band span {
  font-family: var(--font-mono);
  font-size: 8.5px; font-weight: 700; letter-spacing: .18em;
  color: #3c4144; white-space: nowrap;
}

/* Height, not just opacity: collapsing the upper stack levels turns the readout back into the HP's one-line
   display. The IN row never collapses — it's the current line in both machines.
   The stack levels unfold upward off the IN line on their own clocks: hinged at the bottom edge and swinging up,
   via transform-origin, rather than growing out of nothing via height alone. */
.rpn-row {
  --d: 0;
  --rate: 3.2;
  --p: clamp(0, calc((var(--m) - var(--d)) * var(--rate)), 1);
  /* A shallower seat than the keys get: a swinging display panel is a lighter part than a key cap. */
  --e: calc(
    var(--p) * var(--p) * var(--p) * (var(--p) * (var(--p) * 6 - 15) + 10)
    + .25 * var(--p) * var(--p) * var(--p) * (1 - var(--p))
  );
  display: flex; justify-content: space-between; align-items: center; overflow: hidden;
  height: calc((18 + (34 - 18) * var(--m)) * 1px);
  padding: 0 16px;
  font-family: var(--font-mono);
  background: #1c1c1c;
  transform-origin: 50% 100%;
}

.rpn-row:not(.rpn-in) {
  height: calc(clamp(0, var(--e), 1) * 34px);
  opacity: clamp(0, var(--e), 1);
  transform: rotateX(calc((1 - var(--e)) * -78deg)) translateY(calc((1 - var(--e)) * 10px));
}

.rpn-row .n { font-size: 11px; color: rgba(255, 255, 255, .38) }
#app-stage:not(.is-new) .rpn-row { background: #9ba593 }
#app-stage:not(.is-new) .rpn-row .n { color: #59604f }
#app-stage:not(.is-new) .rpn-row .v { color: #262b20 }

.rpn-row .v {
  font-size: calc((18 + (21 - 18) * var(--m)) * 1px);
  color: #fff;
  overflow-wrap: anywhere;
}

.rpn-in { height: 34px }

/* On the faceplate IN is the whole display and carries X; in the app the stack above it already shows X, so an
   idle IN would repeat the number. */
#app-stage.is-new .rpn-in.idle .v { opacity: 0 }

/* The faceplate has no row legends — its display is a bare number, right-aligned. */
#app-stage:not(.is-new) .rpn-in .n { opacity: 0 }
.rpn-in .v.typing::after, .rpn-in.typing .v::after { content: '_'; color: var(--accent); animation: ak-blink 1.05s steps(1) infinite }

/* A positioning context, not a grid: the two keyboards have different grids (10x4 and 4x7), which a grid-template
   cannot interpolate between — each key carries both rectangles and moves between them. Perspective so a key
   lifting off the surface mid-flight reads as lifting rather than just scaling. */
.rpn-pad {
  position: relative;
  perspective: 900px;
  background: rgba(221, 106, 32, .14);
  background: color-mix(in srgb, #141416 var(--mix), rgba(221, 106, 32, .14));
  height: calc((156 + (300 - 156) * var(--m)) * 1px);
  margin-top: calc(4px + var(--m) * 4px);
  pointer-events: none;
}

#app-stage.is-live .rpn-pad { pointer-events: auto }

/* Keys at rest in a state the other keyboard owns are hidden outright, taking them out of tab order too — a
   focusable button with zero size is a dead stop for keyboard users. */
#app-stage.is-app .rpn-pad .hp-only,
#app-stage.is-old .rpn-pad .app-only { visibility: hidden }

/* Every key runs its own clock (--d start, --rate speed, set once in JS), so the keyboard rearranges as a
   mechanism rather than crossfading in lockstep. Smootherstep, not smoothstep, gives the plate weight — flatter
   ends, steeper middle, so it leans in and arrives decisively.
   Only two custom properties per key, everything else inline: each substitution costs per element per frame, and
   at fifty-one keys that doubles frame cost.
   Colors interpolate on the key's own --e rather than swapping at a halfway class — staggered keys means a
   shared halfway class would catch some already across and others still faceplate-colored.
   clamp() guards the color-mix: --e overshoots slightly above 1, and an out-of-range mix percentage is invalid
   and would drop the declaration, leaving the key unpainted. */
.rpn-pad .k {
  --p: clamp(0, calc((var(--m) - var(--d)) * var(--rate)), 1);
  --e: calc(
    var(--p) * var(--p) * var(--p) * (var(--p) * (var(--p) * 6 - 15) + 10)
    + .42 * var(--p) * var(--p) * var(--p) * (1 - var(--p))
  );

  position: absolute; border: 0; cursor: pointer; padding: 0; margin: 0;
  display: grid; place-items: center; overflow: visible;
  border-radius: 5px;
  opacity: clamp(0, calc(var(--o0) + (var(--o1) - var(--o0)) * var(--e)), 1);

  transform:
    translate(calc((var(--tx0) + (var(--tx1) - var(--tx0)) * var(--e)) * 1%),
              calc((var(--ty0) + (var(--ty1) - var(--ty0)) * var(--e)) * 1%))
    translateZ(calc(4 * var(--e) * (1 - var(--e)) * 30px))
    rotateY(calc(4 * var(--e) * (1 - var(--e)) * var(--spin, 0) * 1deg))
    rotateZ(calc(4 * var(--e) * (1 - var(--e)) * var(--tilt, 0) * 1deg))
    scale(calc(var(--sx0) + (var(--sx1) - var(--sx0)) * var(--e)),
          calc(var(--sy0) + (var(--sy1) - var(--sy0)) * var(--e)));

  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 1;
  --mixed: calc(clamp(0, 1 - var(--e), 1) * 100%);
  background: color-mix(in oklab, #232326 var(--mixed), #b4530e);
  color: color-mix(in oklab, #f2f2f0 var(--mixed), #fff);
  transition: filter .12s var(--ease-settle);
}

/* The faceplate's own accents. f and g have no app counterpart, so they mix toward the pad's base rather than a
   color they never reach. */
.rpn-pad .k-shift-f { background: color-mix(in oklab, #c8961e var(--mixed), #b4530e) }
.rpn-pad .k-shift-g { background: color-mix(in oklab, #3f8fc4 var(--mixed), #b4530e) }

/* Undoes the plate's non-uniform scale so the glyph stays proportional; 0.68 of 14px is the 9.5px the faceplate
   legends want. max() guards the divide — a vanishing key scales to zero and would otherwise poison the transform. */
.rpn-pad .cap {
  display: block; white-space: nowrap; pointer-events: none;
  transform: scale(
    calc((.68 + .32 * var(--e)) / max(calc(var(--sx0) + (var(--sx1) - var(--sx0)) * var(--e)), .04)),
    calc((.68 + .32 * var(--e)) / max(calc(var(--sy0) + (var(--sy1) - var(--sy0)) * var(--e)), .04)));
}

/* Retracting plates turn edge-on as they go (faceplate strips itself rather than dissolving); deploying plates
   unfold the other way, arriving flat. */
.rpn-pad .hp-only {
  transform:
    translate(calc(var(--tx) * 1%), calc(var(--ty) * 1%))
    translateZ(calc(var(--arc) * 44px))
    rotateY(calc(var(--e) * -70deg))
    scale(var(--sx), var(--sy));
}

.rpn-pad .app-only {
  transform:
    translate(calc(var(--tx) * 1%), calc(var(--ty) * 1%))
    translateZ(calc(var(--arc) * 44px))
    rotateX(calc((1 - var(--e)) * 72deg))
    scale(var(--sx), var(--sy));
}

.rpn-pad .k-util { background: color-mix(in oklab, #232326 var(--mixed), #5c2a0d) }
.rpn-pad .k-fn2,
.rpn-pad .k-op,
.rpn-pad .k-ent { background: color-mix(in oklab, #232326 var(--mixed), #a04a0c) }

.rpn-pad .k:hover { filter: brightness(1.25) }
.rpn-pad .k.hit { background: var(--accent); color: var(--on-accent) }
.rpn-pad .k:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent) }

/* ---------------- cartographer ---------------- */

/* The pin is zero, deliberately: build ends and strike begins at the section's own seat, so both happen while
   the section travels rather than while it sits still under a pin. The dwell the reader gets there is time, not
   distance — the snap target below stops the gesture and the rig's hold keeps them there.
   The only declaration of it — the container height here, the section spans and stop list in nav.js, and the
   build/strike envelopes in scenes.js all derive from this. */
:root { --loop-run: 0 }

#loop-scroll {
  position: relative;
  height: calc(100vh + var(--loop-run) * 100vh);
  height: calc(100svh + var(--loop-run) * 100svh);
}

/* CSS, not a settle in the rig: a script can only correct where a gesture ENDED, and a flick past the graph has
   already missed it by then. scroll-snap-stop: always arrests the fling itself, both directions.
   proximity, not mandatory: this is the only element declaring an alignment, so it's the only place the page
   snaps, and every other scrubbed scene keeps continuous scroll.
   scenes.js sets `top` in pixels from the same FALL_AT the scrub runs on — a vh here would be a second
   description of that position and could disagree the first time dvh and vh differed. */
html { scroll-snap-type: y proximity }

/* One rule per beat: reactor, graph, and the calculator's two resting states. scenes.js sets each `top` in
   pixels from the numbers its scrubs run on, and clears the alignment once a scene gives its runway back. */
#hero-snap, #loop-snap, #app-snap-old, #app-snap-app, #exp-snap, #labs-snap, #contact-snap {
  /* NOT visibility: hidden — an invisible box would stop being a snap area entirely. */
  position: absolute; left: 0; top: 0; width: 1px; height: 1px; pointer-events: none;
  scroll-snap-align: start; scroll-snap-stop: always;
}
/* Snapping is off until the page is revealed. #hero-snap is the only alignment above the fold, so a fresh load
   would land well down the page with the name already scrolled off — a target at the top doesn't fix this, since
   it just competes for every gesture near zero. Solved on the timeline instead: no snapping while `booting`
   holds, the same flag the hero's fade waits on. */
html.booting { scroll-snap-type: none }

/* --o2/--y2/--gb/--gf default to the seated, built state here, on the element scenes.js writes to — an inline
   style outranks this, so the rig can drive the arrival while no-JS/reduced-motion inherit a section that's
   simply here. Declaring these on #cartographer instead would shadow the inherited value and pin the arrival at its start forever. */
#loop-stage {
  --o2: 1;
  --y2: 0px;
  --gb: 1;
  --gf: 0;
  position: sticky; top: 0; height: 100vh; height: 100dvh; overflow: hidden; display: grid; align-items: center;
}

/* Trails the scroll and comes to strength as the reactor is struck above it, reading as one hand-off. Scrubbed,
   so no transition of its own — the easing is applied once, in scenes.js.
   --claim-gap is the one horizontal gap in this column: two different gaps between two lists of short phrases would read as a mistake. */
#cartographer {
  --claim-gap: 56px;
  position: relative; padding: 70px var(--pad-page) 44px; width: 100%;
  opacity: var(--o2); transform: translateY(var(--y2));
}

/* Two columns, one composition: stacked, this section runs taller than a laptop screen and reads as a pile of
   sections. Terms on the left; the shape of a run — what a list of terms can't say — on the right. Wider than
   the section's measure cap since a two-column grid needs the room. */
.loop-grid { display: grid; grid-template-columns: 5fr 6fr; gap: 46px; align-items: center }
#cartographer .inner > .loop-grid { max-width: none }

.loop-say > :first-child, .loop-show > :first-child { margin-top: 0 }

/* ---- the spec rows ----
   Words, not sentences: a reader scans for terms, and the sentences live in the report the demo opens.
   Nothing here is dimmed or waiting on a scroll — legible at rest, always. */
.terms { margin-top: 28px; display: grid; gap: 20px }
.term > b {
  display: block; margin-bottom: 9px;
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .2em; text-transform: uppercase;
  color: var(--text-faint);
}
.term ul { margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px 14px }

/* RUNS and RESULT are the argument, not a footnote — set at the same weight as the names in the list, not caption
   size. */
.term li { font-size: 15.5px; line-height: 1.45; color: var(--text-strong) }

/* A slash between terms, not a box around each — four rows of boxes stack into one undifferentiated slab. On the
   leading item (::after), not the following one, since a wrap between flex items would else start the next line with a bullet. */
.term li:not(:last-child)::after { content: '/'; margin-left: 14px; color: var(--hairline) }

/* The only prose in the column set in accent — it answers "will it work with what we already have" before the
   spec rows below say anything. Set as a row of three claims, not a sentence.
   One shared gap, not three equal tracks: the claims are different lengths, so equal tracks would put uneven air
   after each; a flex row with a fixed gap measures the space actually seen. Capped to the lede's measure. */
.reach {
  margin: 24px 0 0; max-width: 620px;
  display: flex; flex-wrap: wrap; gap: 8px var(--claim-gap); text-align: left;
  font-family: var(--font-display); font-weight: 800; font-size: clamp(19px, 1.9vw, 25px); letter-spacing: -.3px;
  color: var(--accent);
}

/* A grid, not a wrapped line: six names in two columns of three is a list you count, where a wrapped row reads
   as a sentence. max-content, not 1fr, or the pair drifts apart into equal halves and the gap stops matching the
   reach row above; minmax(0,…) lets a narrow window still shrink them.
   Numbered, not ruled — an accent bar per name would read as six unfinished underlines. */
.term ul.checks {
  display: grid; grid-template-columns: repeat(2, minmax(0, max-content)); gap: 12px var(--claim-gap);
  counter-reset: chk;
}
.term ul.checks li {
  display: flex; align-items: baseline; gap: 11px; counter-increment: chk;
  padding-left: 0; border-left: 0;
  font-weight: 700; font-size: 19.5px; line-height: 1.25; letter-spacing: -.2px; color: var(--text-strong);
}
.term ul.checks li::before {
  content: counter(chk, decimal-leading-zero);
  font-family: var(--font-mono); font-size: 13.5px; letter-spacing: .06em; color: var(--amber); opacity: .72;
}
.checks li:not(:last-child)::after { content: none }

/* A target, not a label with a border — the section's only way out, sized to be hit rather than read. Capped
   well short of the column's width, since a full-rail bar stops reading as a button. */
.demo-cta {
  margin: 34px 0 0; display: inline-flex; align-items: center; gap: 12px;
  padding: 14px 26px;
  border: 1.5px solid rgba(221, 106, 32, .6); border-radius: var(--radius-cta);
  font-family: var(--font-mono); font-size: 12.5px; letter-spacing: .18em; text-transform: uppercase;
  color: var(--accent);
  transition: background .2s var(--ease-settle), color .2s var(--ease-settle);
}
.demo-cta:hover { background: var(--accent); color: var(--on-accent) }
/* animation-name, not background: a running animation's declarations outrank a normal one regardless of selector,
   so the keyframes would otherwise win the color back. */
.demo-cta:hover .live-dot { background: var(--on-accent); animation-name: none }

/* ---- the flow graph ----
   Two written properties build all of it: scenes.js writes --gb and --gf on the stage, and every node/edge
   computes its own progress from that scalar plus its own --d, so a scroll frame touches one element rather than
   twenty-three. Per-element numbers in the markup: --d (build order), --dx/--dy (where a card comes from),
   --sd (a spark's place in the idle circuit).
   .flow is sized off the shorter axis as well as the column, or a px-only cap grows its height with the column
   and pushes the section past its one screen — it may stand taller than the words beside it. */
.flow { display: block; width: 100%; max-width: min(760px, 66vh); height: auto; overflow: visible; margin: 0 auto }

/* Building and striking are two orders, not one read backwards. --gb rises as the graph builds, --gf as it's
   struck. --d builds outward (source first, each rank sliding out of the one in front); --df strikes back to
   front (far rank, near rank, then source), retreating along the path it extended.
   --df is packed tighter than --d so ranks clear before the source finishes leaving frame — scenes.js carries
   that spread as STRUCK, so changing --df here without changing that there ends the collapse in the wrong place.
   Each rate is 1/window (calc can't divide by a variable), fed to scenes.js as WHOLE (build) and STRUCK (strike).
   min() of the two so whichever phase is holding an element back wins. */
.fg-node, .fg-edge, .fg-floor {
  --d: 0; --df: 0; --dx: 0; --dy: 0;
  --p: min(clamp(0, (var(--gb) - var(--d)) * 6.25, 1),
           clamp(0, 1 - (var(--gf) - var(--df)) * 6.9, 1));
}

/* Screens arrive from the one that feeds them, so the build reads as a path extending rather than boxes fading
   up in place. --z is the depth dim, applied on the same opacity as the build so a far screen is never brighter than a near one. */
.fg-node {
  transform-box: fill-box; transform-origin: center;
  opacity: calc(var(--p) * var(--z, 1));
  transform:
    translate(calc(var(--dx) * (1 - var(--p)) * 1px), calc(var(--dy) * (1 - var(--p)) * 1px))
    scale(calc(.84 + .16 * var(--p)));
}
/* The one projection: everything is laid out flat and square, and this turns the whole scene once, so a
   rectangle's border and its wrapping track stay the same shape after the transform. Foreshortening coordinates
   by hand would give two descriptions of one surface.
   The back ranks are the same rectangle at the same three heights — they come out smaller because the
   perspective divide puts them further away, not because they were drawn smaller.
Barely dimmed: depth here is carried by size (2.4x the distance), and dimming a wireframe further takes the lines with it. */
.fg-node.z2 { --z: .94 }

/* Wireframe and see-through, in the hero arches' line color — no screen, speaker, or content, since a drawn UI
   in a 60-unit box is a texture nobody reads. One weight for every edge; what reads as far is the glass — the
   face's translucent black fill knocks back whatever lies behind it, without a second color deciding what's hidden. */
.fg-node > * { vector-effect: non-scaling-stroke; stroke: var(--scenery); stroke-width: 1.4 }
.fg-node .edges { fill: none }
.fg-node .face { fill: rgba(0, 0, 0, .25) }

/* One pool under everything, barely there: nine separate pools would read as nine objects instead of one ground.
   Redder than the accent and dimmer than anything else here deliberately — it's not meant to be looked at. */
.fg-floor { fill: url(#cartoPool); opacity: calc(var(--p) * .34) }
#cartoPool .p0, #cartoPool .p1, #cartoPool .p2 { stop-color: #b03c10 }

/* pathLength normalizes every track to 100 units, so one dasharray draws all three at the same rate whatever
   their real geometry — and the numbers here stay readable instead of tracking bezier arc lengths. */
.fg-edge {
  fill: none; stroke: var(--scenery); stroke-width: 1.25;
  stroke-dasharray: 100; stroke-dashoffset: calc(100 * (1 - var(--p)));
  filter: drop-shadow(0 0 5px rgba(221, 106, 32, .3));
}

/* A dot, not a streak: the dash is one normalized unit against a five-wide stroke, so round caps close over it
   into a bead of light rather than a redrawn track.
   Two clocks for two timed things: the three paths take turns on a cycle of three even slots, and the near phone
   (on all three paths) runs its own spread on a third of that cycle so it fires every beat.
   opacity starts at 0 — before its delay elapses an animated element shows its base style, which for a dashed
   path is a stationary dot at the start; the dash pattern hides it once running.
   Same light as the wavefront (core color, chained halo, --wk depth factor), so it reads as one effect, not two. */
.fg-spark {
  fill: none; stroke: var(--accent-hover); stroke-linecap: round;
  stroke-dasharray: 1 2273; opacity: 0;
  filter: drop-shadow(0 0 calc(4px * var(--wk, 1)) var(--accent-hover))
          drop-shadow(0 0 calc(13px * var(--wk, 1)) var(--accent))
          drop-shadow(0 0 calc(26px * var(--wk, 1)) var(--accent));
  animation: ak-flow 20.7s linear var(--sd, 0s) infinite;
}

/* Thinner and dimmer as it goes, since it's going away. Only the two dashoffset keyframes carry position, so
   the bead crosses at a constant rate; everything between is taper and flicker. The flicker is deliberately
   uneven — a clean-beat pulse reads as a metronome, not energy. */
@keyframes ak-flow {
  0%    { stroke-dashoffset: 0; stroke-width: calc(5.4px * var(--wk, 1)); opacity: 1 }
  0.81% { stroke-width: calc(5px * var(--wk, 1)); opacity: .66 }
  1.58% { stroke-width: calc(4.5px * var(--wk, 1)); opacity: 1 }
  2.27% { stroke-width: calc(4.2px * var(--wk, 1)); opacity: .72 }
  3.11% { stroke-width: calc(3.8px * var(--wk, 1)); opacity: .95 }
  3.85% { stroke-width: calc(3.4px * var(--wk, 1)); opacity: .6 }
  4.44% { stroke-width: calc(3.1px * var(--wk, 1)); opacity: .8 }
  100%  { stroke-dashoffset: -2274; stroke-width: calc(3.1px * var(--wk, 1)); opacity: .8 }
}

/* The wavefront across a device: a ring, not a disc or spokes, that runs from the face's middle out to the
   box's furthest corner — clipped to the whole silhouette so it carries across front, side, and back.
   screen, not normal paint: it ADDS to whatever it crosses, flaring the wireframe lines rather than sitting as a
   tint on top. --wr is per-phone geometry, so near and far phones light on the same beat; `r` animates as a CSS
   property, needing no script.
   The wavefront is a copy of that phone's face path, so it arrives already keystoned to the device's angle;
   non-scaling-stroke keeps the line weight uniform as it grows from hairline to slab.
   Two haloes (tight hot core, wide soft accent), not one flat stroke, to read as energy rather than an outline
   switching on. opacity starts at 0, same reasoning as the spark beads.
   Two animations on one element: the crossing, and a throb of line weight on its own per-device clock — the throb duration is what keeps seven of these from reading as one ticking mechanism. */
.fg-wave {
  fill: none; stroke: var(--accent-hover); opacity: 0;
  vector-effect: non-scaling-stroke;
  /* Chained, not stacked: each drop-shadow takes the previous one's halo as input, compounding the falloff
     outward into a long glow instead of three flat rings. */
  filter: drop-shadow(0 0 calc(5px * var(--wk, 1)) var(--accent-hover))
          drop-shadow(0 0 calc(17px * var(--wk, 1)) var(--accent))
          drop-shadow(0 0 calc(36px * var(--wk, 1)) var(--accent));
  mix-blend-mode: screen;
  transform-box: fill-box; transform-origin: center;
  animation: ak-wave 20.7s linear var(--sd, 0s) infinite,
             ak-throb var(--tb, .44s) ease-in-out infinite;
}
.fg-wave.fade { animation-name: ak-fade, ak-throb }
.fg-wave.beat { animation-name: ak-beat, ak-throb; animation-duration: 6.9s, var(--tb, .44s) }

@keyframes ak-throb {
  0%, 100% { stroke-width: calc(1.8px * var(--wk, 1)) }
  50% { stroke-width: calc(3.6px * var(--wk, 1)) }
}

/* Three legs of one transform: out from the front plane's middle to its edges, bodily front-to-back along the
   sides at full size, then closed onto the back's middle. --wd is the phone's own depth, so the middle leg lands
   exactly on its back ring.
   Not linear: it overshoots --wj past its own outline before settling — a constant-rate grow-and-stop is the
   stiffer version of this and slowing it down wouldn't fix that.
   Legs are uneven: the close onto the back center is the longest, the beat the eye follows, keeping speed into
   the dot so it reads as arriving rather than running out.
   Snaps out rather than fading — full strength to a dot, then gone in a frame, reading as having gone somewhere
   rather than run out. The last device on a path has nowhere to hand off to, so ak-fade lets that one dissolve
   instead. Never quite zero scale — a stroked path at 0 has no box to draw.
   The slot is deliberately longer than the chain's run, or the front would re-light while the previous one is
   still arriving. */
@keyframes ak-wave {
  0%     { transform: translateX(0) scale(.02); opacity: var(--wo, 1); animation-timing-function: cubic-bezier(.1,.8,.3,1) }
  2.71%  { transform: translateX(0) scale(calc(1 + var(--wj, .06)), calc(1 + var(--wj, .06) * .4)); animation-timing-function: cubic-bezier(.4,0,.3,1) }
  3.53%  { transform: translateX(0) scale(1); animation-timing-function: cubic-bezier(.55,0,.45,1) }
  5.07%  { transform: translateX(var(--wd)) scale(1); opacity: var(--wo, 1); animation-timing-function: cubic-bezier(.14,.7,.62,.94) }
  7.49%  { transform: translateX(var(--wd)) scale(.02); opacity: var(--wo, 1) }
  7.55%  { transform: translateX(var(--wd)) scale(.02); opacity: 0 }
  100%   { transform: translateX(var(--wd)) scale(.02); opacity: 0 }
}
@keyframes ak-beat {
  0%     { transform: translateX(0) scale(.02); opacity: var(--wo, 1); animation-timing-function: cubic-bezier(.1,.8,.3,1) }
  8.12%  { transform: translateX(0) scale(calc(1 + var(--wj, .06)), calc(1 + var(--wj, .06) * .4)); animation-timing-function: cubic-bezier(.4,0,.3,1) }
  10.6%  { transform: translateX(0) scale(1); animation-timing-function: cubic-bezier(.55,0,.45,1) }
  15.2%  { transform: translateX(var(--wd)) scale(1); opacity: var(--wo, 1); animation-timing-function: cubic-bezier(.14,.7,.62,.94) }
  22.5%  { transform: translateX(var(--wd)) scale(.02); opacity: var(--wo, 1) }
  22.7%  { transform: translateX(var(--wd)) scale(.02); opacity: 0 }
  100%   { transform: translateX(var(--wd)) scale(.02); opacity: 0 }
}
/* The end of the line, and the only one that dissolves. */
@keyframes ak-fade {
  0%     { transform: translateX(0) scale(.02); opacity: var(--wo, 1); animation-timing-function: cubic-bezier(.1,.8,.3,1) }
  2.71%  { transform: translateX(0) scale(calc(1 + var(--wj, .06)), calc(1 + var(--wj, .06) * .4)); animation-timing-function: cubic-bezier(.4,0,.3,1) }
  3.53%  { transform: translateX(0) scale(1); animation-timing-function: cubic-bezier(.55,0,.45,1) }
  5.07%  { transform: translateX(var(--wd)) scale(1); opacity: var(--wo, 1); animation-timing-function: cubic-bezier(.14,.7,.62,.94) }
  7.49%  { transform: translateX(var(--wd)) scale(.02); opacity: 0 }
  100%   { transform: translateX(var(--wd)) scale(.02); opacity: 0 }
}

/* Nothing idles while the graph is being drawn or struck — a circuit through half-built edges would read as two
   animations fighting, and pausing holds its phase to resume where it left off.
   The fade is on the group, not the pulses: each pulse's opacity is under keyframe control, and a running
   animation outranks a transition, so hiding them individually could only be a hard cut. */
.fg-sparks { transition: opacity .16s linear }
#loop-stage.is-drawing .fg-sparks { opacity: 0 }
#loop-stage.is-drawing .fg-spark,
#loop-stage.is-drawing .fg-wave { animation-play-state: paused }

/* Narrower measure than the page's, but capped on the CONTENT rather than on .inner: shrinking the container
   would
   center it and pull the left edge in from every other section, and that misalignment is visible the moment you
   scroll past. Capping the children keeps the left rail true and only brings the right edge in. */
#cartographer .inner > * { max-width: 1060px }

/* The section's one claim, sized between the title and the body so it is read before either. */
.tagline {
  margin: 18px 0 0;   /* width is governed by the section cap above; a local max-width here is dead */
  font-weight: 600; font-size: clamp(19px, 2.1vw, 28px); line-height: 1.3; letter-spacing: -.4px;
  color: var(--text-strong);
}

.tagline + .sec-lede { margin-top: 20px }

/* A steady core with a halo leaving it, rather than a dot switching on and off. A blink reads as a fault light;
   a pulse going outward reads as something transmitting, which is the state being claimed. */
.live-dot {
  position: relative; flex: none;
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 10px var(--accent);
  animation: ak-live 1.9s linear infinite;
}

/* Same duration is the sync: two animations on one clock, not one driving the other, so the brightest core and
   the leaving ring stay on the same frame — different lengths would drift apart within seconds. */
.live-dot::after {
  content: ''; position: absolute; inset: 0;
  border-radius: 50%; background: var(--accent);
  animation: ak-halo 1.9s linear infinite;
}
/* ---------------- holding page ---------------- */

.holding { min-height: 100vh; min-height: 100dvh; display: flex; align-items: center; padding: 0 var(--pad-page) }
.holding .sec-title { margin-top: 12px }
.holding .cta-row { margin-top: 34px }

/* ---------------- experience ---------------- */

#experience { position: relative; padding: 110px var(--pad-page); background: var(--ink) }.expertise { margin-top: 34px; display: grid; gap: 10px; max-width: 92ch }

.expertise div { font-size: 14px; line-height: 1.6; color: var(--text-body) }

.expertise b {
  display: inline-block; min-width: 92px;
  font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: .18em;
  text-transform: uppercase; color: var(--amber);
}

/* ---- the back catalog ----
   Seventeen shipped products as a rack of cases and one display case, on two columns holding exactly one screen.
   The product leads; the employer is withheld until one is picked.
   The shelf in the markup is still the source of every record — see catalog.js. This changes presentation only. */

/* The written-out records, hidden once the rack is built and only then — they stay in the DOM because catalog.js
   reads them, and a crawler with no JS gets all seventeen in full. If the script never runs, the shelf is what shows. */
#experience.js-cat > .inner { display: none }
#experience .cat { display: none }
#experience.js-cat .cat { display: grid }
/* The section is its own full-height stage once the rack is up, so vertical padding belongs to .cat; the gutter
   stays here since .cat carries the content rail.
The section clips, not the grid: the case is drawn wider than its own box when it turns (the wire's viewBox is
   twice the case on both axes), so clipping at the content rail would cut the far corners off mid-swing. */
/* CLIPPED SIDEWAYS ONLY. The fan and the company bar run off both edges by design and must not widen the page;
   the case and the blades are 3D objects whose drawn extent is taller than the box that lays them out, and
   `overflow: hidden` cut 138px off the bottom of the open case. `clip` on one axis is what allows the other to
   stay visible — `hidden` on x would compute y to `auto` and turn the section into a scroll container. */
#experience.js-cat { padding: 0 var(--pad-page); overflow-x: clip; overflow-y: visible }

.cat {
  /* One screen, nothing scrolling: the carousel takes whatever the blocks above it leave, measured in catalog.js.
     overflow: hidden makes anything that doesn't fit a visible bug rather than a scrollbar. */
  height: 100vh;
  height: 100dvh;
  /* On the page's own rail, like every other section — .inner caps and centers content at --content-max. */
  max-width: var(--content-max); margin: 0 auto;
  /* More padding over the title than under the rack: the rack takes whatever's left after the blocks above it,
     and the nav floats over the top while the foot has only the page's edge below. */
  padding: 132px 0 92px;
  background: var(--ink);
  overflow: visible;
  /* The rack takes the wider half — the case is sized from its own height, so its column only needs to be wide
     enough for it; the rack is the one thing here that's width-bound. */
  display: grid; grid-template-columns: 7fr 5fr; gap: 72px;
}

/* ---- the left column ---- */

/* Centerd, like the case column beside it — top-aligned once put the case 51px below the title (measured at
   1440: content ran 132..763 in a box ending at 808), so the two columns shared no center line.
min-width: 0 is load-bearing: a grid item's automatic minimum is its content, so without it this column grows to its widest child and takes the case's track with it. */
.cat-left { display: flex; flex-direction: column; justify-content: center; min-width: 0; min-height: 0 }

/* The page's own section title treatment (size, caps, second line in accent) unchanged — its own numbers exist
   only because it lives in a narrower column.
   No text-wrap: balance — the heading already carries its own break, and balance re-flowed it into three lines
   at 1440 (177px of head instead of 118), taking the difference out of the rack. */
.cat-left h2 {
  margin: 0;
  /* .sec-title's clamp reads 5vw against the full content rail; this column is seven twelfths of it, so the
     shared cap would wrap a two-line title into three. */
  font-size: clamp(32px, 4.3vw, 58px);
}
.cat-left h2 span { color: var(--accent) }

/* The degree sits under the title as its subtext — the one credential in the section, not a product, so it
   belongs with the heading rather than a tools list.
   White, not accent: the title above already spends the orange, and a second accent line would compete rather than read as a subhead. */
.cat-degree {
  margin: 16px 0 0; font-family: var(--font-display); font-weight: 800;
  font-size: clamp(17px, 1.55vw, 22px); letter-spacing: -.3px; line-height: 1.2;
  color: var(--text-strong);
}
/* The university, at full strength, not dimmed as a mere qualifier — the institution is half the credential.
   Amber and mono already mark it as the attribution; the dot below keeps its own opacity as a quieter separator. */
.cat-degree em {
  font-style: normal; color: var(--amber);
  font-family: var(--font-mono); font-weight: 400; font-size: 12px; letter-spacing: .09em;
  white-space: nowrap;
}
/* A dot, not a rule — a degree and who issued it are one fact, and a dot separates without claiming otherwise. */
.cat-degree em::before {
  content: ''; display: inline-block; width: 3px; height: 3px; border-radius: 50%;
  margin: 0 9px 4px; background: currentColor; opacity: .65;
}

/* ---- the key words ---- */

.keys { margin-top: 16px; display: flex; flex-direction: column; gap: 5px }
.key-row { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 14px; align-items: baseline }
.key-row > b {
  font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; letter-spacing: .14em;
  text-transform: uppercase; color: var(--text-muted);
}
/* These three lists are the section's plain prose, so they take .sec-lede's size and color rather than a pair
   of custom numbers — a near-white of its own would make them brighter than the lede under any other title. */
.key-row > p {
  margin: 0; font-size: clamp(14px, 1.5vw, 17px); line-height: 1.65; color: var(--text-body);
}

/* ---- the company bar ---- */

/* Navigation, not a filter: every employer stays listed regardless of what the carousel shows, and the one
   under the playhead lights, so the bar doubles as a readout, the same job the nav's chapter meter does upstairs.
   Centerd over the rack, like the act line under it, since --rack-w (measured by catalog.js) is left-aligned and
   narrower than its column — centering in the column instead put the chips off to the right of what they label.
   On the column's rail, like the title, degree and key rows above; centerd would float free of a column whose
   every other row starts hard left.
   One row that scrolls, not two that wrap — ten companies wrapping to a second line cost the rack its own
   height twice over. The right-edge fade is the only affordance; the scrollbar is hidden since a 2px trough
   under a 28px row is more furniture than the row itself. */
.firms {
  /* min-width: 0, or none of this works — a flex item's automatic minimum is its content, so without it the bar
     grows wider than its column and runs off under the section's clip instead of overflowing itself to scroll. */
  margin-top: 18px; min-width: 0; display: flex; flex-wrap: nowrap; gap: 5px;
  overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
  /* Set by catalog.js from scroll position, so an end you've reached carries no fade. */
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 var(--fade-l, 0px),
                      #000 calc(100% - var(--fade-r, 34px)), transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 var(--fade-l, 0px),
              #000 calc(100% - var(--fade-r, 34px)), transparent 100%);
}
.firms::-webkit-scrollbar { display: none }
.firms button {
  /* 12px and --text-body, not 11px and --text-muted (45% white, the dimmest token) — that combination made the
     ten-employer bar the hardest thing on the section to read. Weight stays 700 so the current chip's 800 stands
     out. */
  font-family: var(--font-mono); font-weight: 700; font-size: 12px; letter-spacing: .085em;
  flex: none; padding: 4px 8px; border: 1px solid var(--hairline); border-radius: var(--radius-chip);
  background: none; color: var(--text-body); cursor: pointer; white-space: nowrap;
  transition: color .2s var(--ease-settle), border-color .2s var(--ease-settle), background .2s var(--ease-settle);
}
.firms button:hover { color: var(--text-strong); border-color: var(--border-accent) }
/* The current chip, in the heaviest weight the face has, so it reads at a glance across a bar of ten.
   800 is JetBrains Mono's ceiling — 900 would silently resolve back to 700. The text-stroke adds weight beyond
   that without changing the chip's width (currentColor, so it tracks either color changing). */
.firms button[aria-current="true"] {
  color: var(--on-accent); background: var(--accent); border-color: var(--accent);
  font-weight: 800;
  -webkit-text-stroke: .25px currentColor;
  paint-order: stroke fill;
}
.firms button:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--amber-focus) }

/* ---- the carousel ---- */

/* flex: 0 1 auto: as tall as the rack in it, so the column centers the whole stack rather than this growing to
   swallow the slack. catalog.js sizes it from the column and the fixed blocks above, never from this rule. */
/* THE CRATE FADES OUT AT ITS OWN EDGES, the same treatment (and the same gradient shape) the company bar uses,
   so the fan reads as continuing past the column rather than being sliced off by whatever clips the section.
   ON THE WRAP, NOT ON .reel: a mask is a grouping property and forces the masked element's transform-style to
   flat, which would collapse the 3D fan into a stack of flat cards. .reel-wrap carries no 3D of its own, so the
   blades keep their perspective inside it.
   mask-repeat: repeat-y, NOT the default `repeat`: the profile is uniform vertically, so tiling down preserves
   a leaning blade's overflow above and below the reel's own box (which .reel keeps `overflow: visible` for),
   while NOT tiling sideways is what stops anything rendering past the left and right bounds. */
.reel-wrap { flex: 0 1 auto; min-height: 0; margin-top: 20px; display: flex; flex-direction: column; gap: 10px }

/* A 3D stage, not a row that scrolls — every box is placed by catalog.js from its distance to the playhead.
   perspective-origin is dead center since the fan is symmetric; off-center would show one side more edge-on
   than the other. touch-action: pan-y hands vertical touch back to the page — sideways belongs to the crate.
flex: 0 0 auto, height set by catalog.js from the rack in it — left to grow, the reel took the column's whole
   remainder and the carousel floated in empty space (measured at 1600x1000: 361px of box around 299px of cards). */
.reel {
  /* THE CRATE FADES OUT AT ITS OWN EDGES, the same treatment (and the same gradient shape) the company bar uses,
     so the fan reads as continuing past the column rather than being sliced off by whatever clips the section.
     ON .reel AND NOT ON .reel-wrap: the wrap holds the act line too, and masking that faded the first word of
     "Act III: ...". A mask does force this element's transform-style to flat — which costs nothing here,
     because the blades are z-ordered explicitly in place() (zIndex = 100 - distance) rather than sorted by
     depth, and each blade still carries its own preserve-3d under .reel's perspective.
     mask-repeat: repeat-y, NOT the default `repeat`: the profile is uniform vertically, so tiling down preserves
     a leaning blade's overflow above and below the box (which overflow: visible below exists for), while NOT
     tiling sideways is what stops anything rendering past the left and right bounds. */
  --reel-fade: 56px;
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 var(--reel-fade),
                      #000 calc(100% - var(--reel-fade)), transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 var(--reel-fade),
              #000 calc(100% - var(--reel-fade)), transparent 100%);
  -webkit-mask-repeat: repeat-y;
  mask-repeat: repeat-y;
  /* overflow: visible — clipping forced covers down to a fraction of fit, since a leaning blade's near edge
     swings forward and the perspective divide makes it taller than the cover it copies. The section still clips outward. */
  flex: 0 0 auto; min-height: 0; position: relative; overflow: visible;
  perspective: 1400px; perspective-origin: 50% 50%;
  transform-style: preserve-3d;
  touch-action: pan-y; cursor: grab;
}
.reel:active { cursor: grabbing }

/* Names the centerd box's act, changing as the reel crosses into the next one — carries narrative without four
   shelf rows, and sits above the crate to name what you're looking at rather than caption what's finished.
   Amber, not white — it's a label on the rack, not a heading over it (the section already has one). */
.reel-act {
  flex: none; padding-bottom: 2px;
  font-family: var(--font-mono); font-weight: 500; font-size: 12px; letter-spacing: .13em;
  color: var(--amber);
}

/* ---- the box, on the reel ---- */

/* Standing face out, not spined — a shop shelf faces the room; a spine is a library, where you already know
   what you're looking for. Every box is the same size; the front one facing you does the work, so making the picked one bigger too would say it twice.
   Turned about its own center, since a hinge on one edge would make one side of the fan swing toward the reader
   and the other away, reading as lopsided.
   No transition on the transform — each blade carries a drawn wireframe beside its print, and a CSS transition
   would ease one while the other snapped. catalog.js tweens the rack's position as one number both read.
   perspective lives on the blade itself, since CSS perspective reaches one level and .box-print is its direct
   child — same value case3d.js divides by, so drawing and print agree by construction. */
.box {
  position: absolute; left: 50%; top: 50%; margin-top: calc(var(--box-w, 200px) * -.667);
  width: var(--box-w, 200px); aspect-ratio: 3 / 4;
  transform-origin: 50% 50%;
  perspective: 1400px; perspective-origin: 50% 50%;
  background: none; border: 0; padding: 0; cursor: pointer; display: block;
  --wire-line: var(--scenery);
  opacity: var(--vis, 1);
  transition: opacity .3s linear;
  /* The button itself takes no pointer: its box is the cover's full unrotated rectangle, but the visible blade
     is the thin turned rim, so hittable it would overlap a dozen neighbors. The rotated .box-print answers instead; clicks still reach the button by bubbling. */
  pointer-events: none;
}
.box-print { position: absolute; inset: 0; transform-style: preserve-3d; pointer-events: auto }

/* Each blade's spines, standing in the side walls the drawing paints — centerd first, then pushed to half the
   width, since a rim's push is along the axis it faces. Centerd by position, not alignment: the name is wider
   than the rim, and Chrome falls back to start-alignment for an overflowing flex/grid item, which would park the type off-center. */
.box-spine {
  position: absolute; top: 0; bottom: 0;
  width: var(--blade-d, 12px); left: calc(50% - var(--blade-d, 12px) / 2);
  overflow: hidden; backface-visibility: hidden;
}
.box-spine-l { transform: rotateY(-90deg) translateZ(calc(var(--blade-w, 200px) / 2)) }
.box-spine-r { transform: rotateY(90deg) translateZ(calc(var(--blade-w, 200px) / 2)) }
/* The two rims lean toward each other: a blade left of center shows its left rim, right of center its right
   rim, and turning them opposite ways keeps the type leaning into the middle from both sides, reading top to
   bottom like a shelf of cases — the trade is that the left half then reads bottom to top. */
.box-spine span {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%) rotate(90deg);
  white-space: nowrap;
  font-family: var(--font-display); font-weight: 800; letter-spacing: -.01em;
  /* Sized to the exposed rim (--blade-step), not the whole depth — blades pack STEP apart, so a name set to the
     full rim would run under the next blade. Fraction kept well under 1 so caps don't crowd both rim edges. */
  font-size: calc(var(--blade-step, 12px) * .76); line-height: .8;
  color: var(--text-strong);
}
.box-spine-l span { transform: translate(-50%, -50%) rotate(-90deg) }
/* The drawing runs outside its own box once the blade turns, so it's given room and centerd rather than clipped
   to the print's rectangle. */
.box-wire { position: absolute; left: -50%; top: -50%; width: 200%; height: 200%; overflow: visible }
/* Scales with the object: 1.4px is the stage case's weight on a 494px case, which would be 2.5x too heavy on a
   189px blade. Same fraction of width instead, floored at .8 so the far end of the fan still reads as a line. */
.box-wire .c3-edge { stroke: var(--wire-line); stroke-width: max(.8px, calc(var(--blade-w, 200px) * .00283)) }
/* The blade draws everything the stage case draws — same paths, same rules, smaller — since dropping the seam
   and thumb notch as a simplification made a blade read as a different object from the case it stands for.
   A blade is almost all edge at 79 degrees — the cover is about a sixth of its own width on screen, so the
   border and thickness are what's worth drawing; the art still turns into view as the blade comes round. */
.box.blade:hover .box-face { border-color: var(--amber); box-shadow: 0 0 0 1px var(--amber) }

/* The print only — outline, glass and side walls all come from the drawing under it, so a rectangular border
   here would be a second, wrong-shaped description of the same edge. */
.box-face {
  position: absolute; inset: 0;
  /* On the front plane, not center: case3d.js draws a prism whose front face stands half the depth toward the
     reader. At z=0 the print would sit half a rim behind its own outline, reading as unstuck from the case at
     every angle but square on — matches .c3-front's own treatment below. */
  transform: translateZ(calc(var(--blade-d, 12px) / 2));
  /* Same radius the drawing rounds by (case3d.js: min(w,h) * .05, cover is 3:4) so overflow: hidden clips the
     print to the case's actual shape — without it the foot's gradient shows square corners past the rounded line. */
  border-radius: calc(var(--blade-w, 200px) * .05);
  overflow: hidden; backface-visibility: hidden;
}
/* Hovering a leaning box lifts it toward the reader, the affordance a crate has always had.
Hover is one color across the outline, rim name and artwork ramp (all --amber) — splitting them would read as
   two states rather than one box answering the pointer. .box.picked:hover needed its own rule, or its later
   --accent declaration would win on source order and the hovered box wouldn't respond. */
.box:hover, .box.picked:hover { --wire-line: var(--amber) }
.box:hover .box-spine span { color: var(--amber) }

/* The company's own boxes stay lit while everything else dims, rather than the marked ones brightening — there's
   no headroom to brighten into on a rack this dense. */
.reel.marking .box { opacity: calc(var(--vis, 1) * .18) }
.reel.marking .box.of-firm { opacity: var(--vis, 1); --wire-line: var(--accent) }
.reel.marking .box.of-firm .box-spine span { color: var(--accent) }
.box.picked { --wire-line: var(--accent) }
.box:focus-visible { outline: none }
.box:focus-visible { --wire-line: var(--amber-focus) }

/* ---- the cover art ----
   The artwork is the whole face, not a picture in the upper field. Each of seventeen is composed on a 300x400
   box with its subject centerd on y=237 (midpoint between the tagline's ink and the foot text), so they only
   line up as a set if drawn into the same box at the same scale.
   preserveAspectRatio stays default for the same reason — letterboxing one cover would move its center. */
.box-face, .c3-front { container-type: inline-size }
.cover-art { position: absolute; inset: 0; display: block; width: 100%; height: 100% }

/* Every shape names a role, not a color, so retinting the back catalog is these eight rules. Fill and stroke
   are separate families since a shell is a deep fill with a line edge while a screen is a deep fill with a lit
   edge; an element with no fill/stroke role class inherits, which lets a shared <use> part take its color from placement.
   Scoped to the defs block too, not just .cover-art — a shared part lives in defs and is drawn by <use>, and its
   shadow content resolves style against the part's own place in the tree, not where it's used. */
:is(.cover-art, .cover-defs) .f-line { fill: var(--accent) }
:is(.cover-art, .cover-defs) .f-lit { fill: var(--amber) }
:is(.cover-art, .cover-defs) .f-deep { fill: var(--ink-deep) }
:is(.cover-art, .cover-defs) .f-strong { fill: var(--text-strong) }
:is(.cover-art, .cover-defs) .f-none { fill: none }
:is(.cover-art, .cover-defs) .s-line { stroke: var(--accent) }
:is(.cover-art, .cover-defs) .s-lit { stroke: var(--amber) }
:is(.cover-art, .cover-defs) .s-deep { stroke: var(--ink-deep) }
:is(.cover-art, .cover-defs) .s-none { stroke: none }

/* Gradient stops name the same roles. `on` and `off` are the two ends of a mask, which is coverage and not ink. */
:is(.cover-art, .cover-defs) .g-lit { stop-color: var(--amber) }
.cover-defs .g-on { stop-color: #fff }
.cover-defs .g-off { stop-color: #000 }
.cover-defs { position: absolute; width: 0; height: 0; overflow: hidden }

/* A glyph drawn INTO the picture — the question mark on Store Companion — rather than part of the lockup. */
:is(.cover-art, .cover-defs) text { font-family: var(--font-display); font-weight: 900; text-anchor: middle }

/* ---- the lockup ----
   In HTML, over the art, so the title stays selectable and reflows rather than being baked into the drawing as
   <text>. Every measurement is a fraction of the face's own width (cqw), since the same cover is a 200px blade
   and a 500px case and must read as one layout seen smaller.
   The band is fixed — kicker at top, title centerd in 5.5%-24% of height, tagline at 26% — so only the title's
   size changes (set by catalog.js from the name's own measure), which is what makes seventeen read as a series. */
.cover-lock {
  position: absolute; inset: 0; display: block; pointer-events: none;
  text-align: center; text-transform: uppercase;
  /* Quieter than the artwork when this face is seen through the case — see apply() in catalog.js. Unset on every
     blade, since a blade never turns past edge-on. */
  opacity: var(--through, 1);
}

/* One painted block down to the tagline, gone by 37.5%, the same scrim on all seventeen so type sits on
   identical ground. Cooler than the face deliberately — a neutral scrim over amber artwork silts up.
   Carries no type itself: the three lines are siblings, not children, since a percentage inside the head would
   resolve against the head (18.5% would land at 18.5% of 37.5%) rather than the face. */
.cover-head {
  position: absolute; inset: 0 0 auto; height: 37.5%;
  background: linear-gradient(180deg,
    var(--cover-scrim-top) 0, #0d1420 36%, var(--cover-scrim) 62%,
    rgba(16, 24, 40, .9) 70%, rgba(16, 24, 40, .6) 79%,
    rgba(16, 24, 40, .32) 87%, rgba(16, 24, 40, .12) 94%, rgba(16, 24, 40, 0) 100%);
}

.cover-kicker, .cover-title, .cover-tag {
  position: absolute; left: 0; right: 0; padding: 0 6.67cqw;
}

.cover-kicker {
  top: 3.4%; font-family: var(--font-mono); font-weight: 700;
  font-size: 3cqw; line-height: 1; letter-spacing: .87cqw; color: var(--amber);
}

/* The title's band, not its box: a fixed slot words center inside, so a one-line and a two-line name both finish
   in the same place. */
.cover-title {
  top: 5.5%; height: 18.5%;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--font-display); font-weight: 900;
  font-size: calc(100cqw * var(--t-scale, .14));
  line-height: .88; letter-spacing: -.033em; color: var(--text-strong);
  text-wrap: balance;
}
.cover-title > span { display: block }

.cover-tag {
  top: 24%; font-family: var(--font-display); font-weight: 700;
  font-size: 3.5cqw; line-height: 1; letter-spacing: .83cqw; color: var(--accent);
}

/* The record's own two facts, set as print on the box's bottom edge rather than a caption. */
.cover-foot {
  position: absolute; left: 0; right: 0; bottom: 4.5%;
  display: flex; justify-content: space-between;
  padding: 0 6.67cqw;
  font-family: var(--font-mono); font-size: 2.67cqw;
  letter-spacing: .53cqw; color: rgba(255, 255, 255, .55);
}

/* Hover lifts the artwork's line, but only the accent ramp moves — the lit shape is the one thing on a cover
   that is already at full strength. */
.box:hover .cover-art .s-line { stroke: var(--amber) }


/* ---- the right: the display case ---- */

/* The button sits on the column's floor, the case centers in what's left above it. No justify-content here —
   the auto block margins on .case-box below take all the free space and split it evenly, centering the case and
   flushing the foot in one move; justify-content: center would instead center the pair together and float the button. */
.case { display: flex; flex-direction: column; align-items: center; gap: 12px; min-width: 0; min-height: 0 }

/* The flip is the section's own mechanism — every other section has one (graph builds, calculator turns, reactor
   is struck), and picking a box off the shelf and turning it over is how anyone reads what a boxed thing does.
   The box is sized to the case, not the column: filling the column at 78% case size left 22% dead air inside the
   box, pushing the button away from the object it belongs to. 92% is what's left of the column once the
   button's own 34px and the 12px gap come out. */
/* minmax(0, 1fr), not the implicit `auto` track: an auto track is sized to its item's max-content, and
   .case-body's 3/4 ratio takes its width from its height — so on a narrow column the track grew to 396px inside
   a 349px box and the front face's text ran 29px off the screen. The 0 floor is what lets max-width: 100% bind. */
.case-box {
  flex: 0 1 auto; height: 92%; min-height: 0; margin-block: auto; width: 100%;
  display: grid; grid-template-columns: minmax(0, 1fr); place-items: center;
}

/* Thrown in and out harder the further you jumped — stepping to a neighbor is a nudge, clicking nine products
   away sends the old one off the edge. catalog.js writes distance and duration; this only says which properties move. */
/* Same minmax(0, 1fr) as .case-box, and for the same reason: this is the second auto track between the column
   and the case, and capping only the outer one left .case-body free to overrun it here instead. */
.case-slide {
  width: 100%; height: 100%; transform-style: preserve-3d;
  display: grid; grid-template-columns: minmax(0, 1fr); place-items: center;
}
/* ---- the case: a drawing, and text over it ----
   The wireframe is SVG since a CSS face is a quad and can't follow a rounded corner. The print is HTML over it,
   in a 3D box under the same perspective and angles case3d.js projects with, so the two line up by construction.
Perspective lives here, on the print layer's own parent — CSS perspective reaches direct children only, and
   .c3-print sits two levels under .case-box, so without this it had no perspective while the drawing beside it did.
The case stands well off its own edges, with real air over and under — the section holds a title, credential,
   three key rows, ten chips, an act line and seventeen covers on one screen, and the case and reel are the only
   things that can give height back.
   max-width binds on a short, narrow window: at 3:4 height decides width, and without it the case would grow sideways out of its column. */
.case-body {
  /* 3/4 is the FLOOR, not the shape: --case-min-h is what fitBack() writes when a record's write-up needs more
     room than the ratio leaves, and the case extends downward rather than putting a scroll view on its back. */
  position: relative; height: 100%; aspect-ratio: 3 / 4; max-height: 100%; max-width: 100%;
  min-height: var(--case-min-h, 0px);
  perspective: 1400px; perspective-origin: 50% 50%;
  /* pan-y, not none: a vertical drag is the page scrolling and must stay the page's, or the case swallows every
     swipe that crosses it. A HORIZONTAL drag is what grabs the object — the browser hands those through as
     pointer events — and a press-and-hold takes the vertical axis too (see catalog.js's hold timer). */
  cursor: grab; touch-action: pan-y;
}
/* Held: the object owns both axes for as long as the finger is down. */
.case-body.held { touch-action: none }
.case-body.held { cursor: grabbing }

/* The drawing overflows its own box when the case turns, so it's given room and centerd rather than clipped to
   the print's rectangle. */
.c3-wire {
  position: absolute; left: -50%; top: -50%; width: 200%; height: 200%;
  overflow: visible; pointer-events: none;
}
.c3-wire path { vector-effect: non-scaling-stroke }
.c3-edge { fill: none; stroke: var(--scenery); stroke-width: 1.4; stroke-linejoin: round }

/* Transparent black panels: front and back are glass you look through, sitting darker than the page ground. */
.c3-face { fill: rgba(0, 0, 0, .42); stroke: none }
/* The back of a blade, darker, and shaped like the case since this path is the case's outline projected at the
   current yaw. Only the rack gets this weight — fourteen bodies stacked into a haze the names compete with,
   where the stage case is one object with nothing behind it. */
.box-wire .c3-face-far { fill: rgba(0, 0, 0, .75) }

/* The side walls, transparent black like every surface on the case. Two paths (toward/away the reader), not one
   even-odd band, since that distinction can't otherwise be made. Denser toward the viewer for more molding along
   the sight line, not a different color choice — nothing here is gray, since gray would read as a solid object. */
.c3-wall { fill-rule: nonzero; stroke: none }
.c3-wall-toward { fill: rgba(158, 82, 34, .34) }
.c3-wall-away { fill: rgba(158, 82, 34, .17) }

/* The joint between the two halves: dimmer than an outline, since it's where moldings meet rather than where the
   case meets the air. */
.c3-seam { fill: none; stroke: var(--scenery); stroke-width: 1; stroke-linecap: round; opacity: .45 }

/* The thumb notch, drawn on the case (not a face) so it turns with the object and disappears round the back. */
.c3-notch { fill: none; stroke: var(--scenery); stroke-width: 1; opacity: .7 }

/* ---- the print ---- */

/* No transition here — the drawing beside this layer is redrawn by hand, and a CSS transition on the print would
   run two clocks (text easing while the wireframe snaps), tearing the case apart on every move. catalog.js
   tweens the angle and both layers read it, the only arrangement where they can't disagree. */
.c3-print {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
}

/* Every face pushes out to half the depth, back turned first — a face at 180° has its +Z pointing back down the
   box, so the same push expression puts front at +d/2 and back at -d/2 without the two disagreeing on thickness.
   No backface-visibility here: hiding the far side made the case opaque, since the front's glass would have
   nothing behind it to be transparent to. catalog.js fades each face by how squarely it faces instead. */
.c3-side { position: absolute; inset: 0; overflow: hidden; border-radius: calc(var(--case-w, 400px) * .05) }
.c3-front {
  display: flex; flex-direction: column; justify-content: flex-end;
  transform: translateZ(calc(var(--case-d, 40px) / 2));
}
.c3-back { transform: rotateY(180deg) translateZ(calc(var(--case-d, 40px) / 2)) }


/* Centerd first, then pushed to half the width — a rim's push is along the axis it faces, x rather than z for the
   left edge. */
.c3-spine {
  width: var(--case-d, 40px); left: calc(50% - var(--case-d, 40px) / 2);
  transform: rotateY(-90deg) translateZ(calc(var(--case-w, 400px) / 2));
  /* Clipped to the rim: turned past the back the spine face is only ~15px of screen, and any name overflow past
     it would read as a white sliver stuck to the box edge rather than type on a spine. */
  overflow: hidden;
}

/* Centerd by position, not alignment — the name is wider than the rim, and Chrome falls back to start-alignment
   for an overflowing grid/flex item, so place-items: center would quietly park it off-center. +90 so the name
   reads top to bottom, the way a game case, DVD or book spine prints in this market. */
.c3-spine span {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%) rotate(90deg);
  white-space: nowrap;
  font-family: var(--font-display); font-weight: 800;
  /* Sized so ink is four fifths of the rim (a real spine's ratio) — ink is cap height, ~.7 of type size in this
     face, so .92 of the depth gets there; line-height .8 keeps the em box (which carries unused ascender/descender space) from exceeding the rim. */
  font-size: calc(var(--case-d, 40px) * .92); line-height: .8; letter-spacing: -.01em;
  color: var(--text-strong);
}

/* the back: what a box back carries — the blurb, then the spec panel
Rules drawn in dimmed --scenery, not --hairline: --hairline's blue gray is right for the page's own furniture
   but reads as a second material on an object outlined entirely in amber.
   Solid grays on this panel, not the page's alpha-on-white tokens — this face sits on a plane whose opacity
   changes with every turn and is read through the far panel's glass, so alpha would composite differently at
   every angle. These three (label/meta/copy) are the same steps resolved once against the ink ground, frozen.
   Every size here is a fraction of --case-w (what sizeCase measures and draws the corner radius from), so type
   and margins ride the same number as the object. --pad is the one place that number is decided, so the spec
   panel's negative margins can't drift from the padding they match. */
.c3-back {
  --rule-box: rgba(110, 52, 19, .55);
  --ink-label: #7a7c83;
  --ink-meta: #9a9ca3;
  --ink-copy: #c3c5cb;
  --pad: clamp(22px, calc(var(--case-w, 400px) * .0847), 52px);
  --t-label: clamp(9px, calc(var(--case-w, 400px) * .0233), 15px);
  /* Smaller than the rest of the panel: up to twenty chips at label size made this block taller than the record
     above it — a spec table is scanned, not read. */
  --t-chip: clamp(8px, calc(var(--case-w, 400px) * .0186), 12px);
  /* The panel's reading size — lede and outcomes match, or the list reads as a footnote to claims it's actually
     making. */
  --t-body: clamp(11.5px, calc(var(--case-w, 400px) * .0275), 18px);
  --t-meta: clamp(9.5px, calc(var(--case-w, 400px) * .0254), 16px);
  display: flex; flex-direction: column; padding: var(--pad) var(--pad) 0;
  /* NEITHER SCROLLS NOR CLIPS: catalog.js's fitBack() extends the case until the write-up fits, so there is
     nothing here to scroll past. A scroll view on the back of a physical object read as a panel pasted onto it,
     and every gesture near the case was ambiguous between turning it and scrolling inside it. */
  overflow: hidden;
}
/* What it is, then what it did — the lede is one paragraph, brighter than the list under it, since it's the
   sentence a reader who stops after one sentence gets.
A record can carry more than one lede (e.g. a second paragraph on how the work was handed off); both set the same, reading as one voice. */
.b-lede + .b-lede { margin-top: 10px }
.b-lede {
  margin: 14px 0 0; font-size: var(--t-body);
  line-height: 1.5; color: var(--ink-copy);
}
/* Outcomes marked with a CSS-drawn chevron rather than a glyph — a character depends on the face carrying it
   (how the store chips once rendered tofu) — staying in the object's straight-line vocabulary while pointing to
   carry a list of outcomes better than a static mark would. */
.b-wins { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 7px }
.b-wins li {
  position: relative; padding-left: 15px;
  font-size: var(--t-body);
  line-height: 1.45; color: var(--ink-copy);
}
.b-wins li::before {
  content: ''; position: absolute; left: 0; top: .22em;
  /* A clipped shape, not two rotated borders — the border trick only makes a fixed 90° chevron, where clipping
     sets the angle from the box's proportions and the weight from the polygon independently. */
  width: 6px; height: 13px;
  background: var(--accent);
  clip-path: polygon(0% 0%, 100% 50%, 0% 100%, 0% 61%, 40% 50%, 0% 39%);
}
/* The figure, not the sentence around it — a box back is scanned before it's read, so full white (not a third
   accent color) is what a first pass picks up. */
.b-lede b, .b-wins b { font-weight: 700; color: var(--text-strong) }
/* The headline slot, carrying the role at the same size/weight/place a product name once used — line-height up
   from 1.05, since a multi-word job title wraps where a product name rarely would. */
.c3-back .b-lead { font-family: var(--font-display); font-weight: 900; font-size: clamp(20px, calc(var(--case-w, 400px) * .0593), 40px); line-height: 1.12; letter-spacing: -.5px; color: var(--text-strong) }
/* The role gets its own row rather than sharing one wrapped line with employer and dates, where it would become
   a middle phrase nobody's eye catches. flex-basis: 100% on the role is what breaks the row. */
.c3-back .b-at { margin-top: 10px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; font-family: var(--font-mono); font-size: var(--t-meta); letter-spacing: .1em; color: var(--ink-meta) }
.c3-back .b-at .product {
  flex: 0 0 100%;
  font-size: calc(var(--t-meta) * 1.22); font-weight: 700; letter-spacing: .04em;
  color: var(--text-strong);
}
/* Amber marks the employer, dates drop to the panel's quiet gray — previously both were the same gray, putting no
   emphasis on either. */
.c3-back .b-at .firm { font-weight: 700; color: var(--amber) }
/* Underlined on hover only — a permanent rule would be a second horizontal line on a panel that already rules
   off its spec footer. pointer-events explicit since the case is a drag surface (see the guard in catalog.js). */
.c3-back .b-at a.firm { color: var(--amber); text-decoration: none; pointer-events: auto }
.c3-back .b-at a.firm:hover { text-decoration: underline; text-underline-offset: 3px }
.c3-back .b-at a.firm:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--amber-focus); border-radius: 2px }
/* The agency a contract ran through: a qualifier on the employer, not a second employer, so it stays meta
   gray/normal weight while the client above keeps amber/700. */
.c3-back .b-at .via { font-weight: 400; color: var(--ink-meta) }
.c3-back .b-at .via a { color: inherit; text-decoration: none }
.c3-back .b-at .via a:hover { text-decoration: underline; text-underline-offset: 3px; color: var(--text-strong) }
.c3-back .b-at .via a:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--amber-focus); border-radius: 2px }
/* Its own row for every record, not just ones with a contract — sharing the employer's row made the dates get
   pushed whenever anything else sat beside it. */
.c3-back .b-at .when { flex: 0 0 100%; color: var(--ink-meta) }

/* ---- the stores' own badges ----
   Unedited by policy, not style — Apple and Google both forbid recoloring, restyling or rebuilding their badge
   artwork. These rules only decide size and placement.
   The two assets aren't the same shape: Apple's SVG is pure artwork, Google's PNG bakes in clear space on all
   four sides (artwork is ~2/3 of file height), so matching `height` alone renders them at different sizes.
   --badge-h is the height of the artwork — Apple sizes straight to it, Google scales up and pulls the built-in
   margin back off, and the container's gap supplies Google's required clear space instead. */
.store { display: inline-flex; text-decoration: none; border-radius: 6px }
.store:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--amber-focus) }
.store img { display: block }
.store.ios img { height: var(--badge-h, 36px) }
.store.play img {
  height: calc(var(--badge-h, 36px) * 250 / 168);
  margin: calc(var(--badge-h, 36px) * -41 / 168);
}

/* Right-aligned at the panel's foot: the blurb above is left-set prose, and ranging the non-prose badges to the
   other edge stops them reading as one more paragraph. */
.b-get { margin-top: auto }
.b-get-row {
  --badge-h: clamp(24px, calc(var(--case-w, 400px) * .068), 40px);
  display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end;
  gap: calc(var(--badge-h) * .42);
}

/* The spec panel, at the foot: the part of a game box that's a table rather than a paragraph, the one place a
   chip still earns its border. A rule, not a slab — a shaded panel would be a different vocabulary from the
   ruled outlines the rest of the case and page use, reading as a sticker rather than part of the box.
   The rule needs to run full width, so the panel's own padding is canceled and re-applied here off --pad, or it would stop short of the edge when the case resizes. */
.b-spec { margin-top: 16px; margin-left: calc(var(--pad) * -1); margin-right: calc(var(--pad) * -1);
  /* Off --pad like every other edge — flat 16/18px values once made the chips sit twice as close to the case's
     bottom as its sides. */
  padding: calc(var(--pad) * .34) var(--pad) calc(var(--pad) * .62);
  border-top: 1px solid var(--rule-box) }
.b-chips { display: flex; flex-wrap: wrap; gap: 4px }
.b-chips span {
  font-family: var(--font-mono); font-size: var(--t-chip); letter-spacing: .06em; padding: 2.5px 6px;
  /* --text-body, not --text-muted — these chips are the most concrete thing on the panel, and muted (45% white,
     the dimmest token) read too faint against the blurb above. */
  border: 1px solid var(--rule-box); border-radius: var(--radius-chip); color: var(--ink-copy);
}

/* ---- the case's own control ---- */

.case-foot { display: flex; align-items: center; gap: 14px; flex: none }
/* The icon alone — a bordered "TURN IT OVER" chip would be one more rectangle on an object made of rectangles,
   and the case already invites the gesture. The label stays in the DOM, off-screen, as the accessible name; catalog.js rewrites it as the case turns. */
.flip {
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%;
  background: none; color: var(--text-muted); cursor: pointer;
  transition: color .2s var(--ease-settle);
}
.flip span {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
.flip:hover { color: var(--text-strong) }
.flip:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--amber-focus) }
.flip svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round }

/* ---- short windows ----
   A 1366x768 laptop has 132px less height than the frame was drawn against. The title gives up the most, since it's the one block that says the same thing at any size. */
@media (max-height: 830px) {
  .cat-left h2 { font-size: clamp(30px, 3.4vw, 46px) }
  .cat-degree { margin-top: 12px; font-size: 15px }
  .keys { margin-top: 16px; gap: 4px }
  .key-row > p { font-size: 11px }
  .firms { margin-top: 16px; gap: 5px }
  .reel-wrap { margin-top: 18px }
  .case { gap: 14px }
}

/* ---- narrow ----
   One column, case leads: on a phone the reel is a strip you swipe and the box is what you came to look at. The
   one-screen rule is a desktop claim — below this the section is a document again, like every pinned scene here. */
@media (max-width: 1080px) {
  .cat { height: auto; overflow: visible; grid-template-columns: minmax(0, 1fr); gap: 36px; padding-bottom: 80px }
  /* Reading order, not display order: the crate is the index and the open case is what one entry in it looks
     like, so the case follows the carousel rather than standing above it and the copy. */
  .case-box { height: 62vh }
  .reel-wrap { height: 46vh; margin-top: 18px }
}

@keyframes role-sweep {
  from { clip-path: inset(0 0 100% 0) }
  to { clip-path: inset(0 0 0 0) }
}@keyframes role-rule {
  from { transform: translateY(0); opacity: 1 }
  88% { opacity: 1 }
  to { transform: translateY(var(--role-h, 480px)); opacity: 0 }
}/* ---------------- labs ---------------- */

#labs { position: relative; padding: 110px var(--pad-page) }

.sec-label { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .22em; color: var(--amber) }

.sec-title {
  font-family: var(--font-display); font-weight: 900;
  font-size: clamp(34px, 5vw, 64px); line-height: .92; letter-spacing: -2px;
  margin: 12px 0 0; color: var(--text-strong);
}

.sec-lede { font-size: clamp(14px, 1.5vw, 17px); line-height: 1.65; color: var(--text-body); margin: 18px 0 0; max-width: 620px }

/* Three equal cards, deliberately — a lead card sized larger would rank the instruments without saying why. The
   row stretches so all three come out the same height. */
.lab-grid { display: grid; gap: 14px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 40px }

.lab-card {
  display: flex; flex-direction: column; padding: 20px 22px; color: inherit;
  background: linear-gradient(180deg, rgba(22, 15, 10, .86), rgba(11, 9, 12, .86));
  border: 1px solid var(--hairline); border-radius: var(--radius-card);
  transition: border-color .18s var(--ease-settle), transform .18s var(--ease-settle);
}

.lab-card:hover { border-color: var(--accent); transform: translateY(-2px); color: inherit }
.lab-card .tag { font-family: var(--font-mono); font-size: 10px; letter-spacing: .2em; color: var(--amber) }
.lab-card .name { font-family: var(--font-display); font-weight: 800; font-size: 20px; letter-spacing: -.5px; margin-top: 8px }
.lab-card p { margin: 8px 0 0; color: var(--text-body); font-size: 13.5px; line-height: 1.6 }

/* margin-top:auto pins this to the card's floor, so the three affordances line up across cards whose
   descriptions are different lengths. */
.lab-card .go {
  margin-top: auto; padding-top: 16px;
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .18em; color: var(--text-faint);
  transition: color .18s var(--ease-settle);
}

.lab-card:hover .go { color: var(--accent) }

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

/* The last thing on the page, centerd — a left-railed block with nothing under it reads as unfinished.
   A full screen of its own, not shorter: any shorter and contact would be a footer stuck under labs' tail, with
   neither becoming the thing you're looking at. It also makes labs a beat — the rail releases a reader past its
   last beat at last + RAIL_PAST screens, so a beat needs that much page beneath it or every scroll below labs
   gets dragged back. Contact carries no beat of its own, since it ends exactly where the document does. */
#contact {
  min-height: 100vh;
  min-height: 100dvh;
  padding: 100px var(--pad-page) 96px;
  background: var(--ink);
  text-align: center;
  display: flex; flex-direction: column; justify-content: center;
}
/* An explicit width beats stretch: .inner's `margin: 0 auto` on a flex/grid item shrinks it to content and
   centers that, which once took the heading off the page's left rail (534 instead of every other section's 40). */
#contact .inner { width: 100% }

/* Anchored to the section, not fixed to the screen, so the glow arrives with contact instead of sitting under
   every section above it — contact's own bottom is the viewport's bottom once you're there.
   Redder than --accent, which is the page's link orange and too yellow to sit under type without looking like a highlight. */
#contact { position: relative }
#contact::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 360px;
  /* A glow, not a band: twice the reach at two thirds the strength with a mid-ramp stop, so it lingers rather
     than reading as a short edge treatment. */
  background: linear-gradient(0deg,
    rgba(198, 74, 24, .10), rgba(198, 74, 24, .052) 36%, rgba(198, 74, 24, 0));
  pointer-events: none;
}

.contact-links {
  margin-top: 30px; display: flex; gap: 22px; flex-wrap: wrap; justify-content: center;
  font-family: var(--font-mono); font-size: 13px; letter-spacing: .06em;
}

/* ---------------- share dialog ---------------- */

/* The QR control reads as one of the links, which is what it is — a fourth way to reach the same person. A
   bordered chip made it the only rectangle in the row and read as the primary action. Stays a <button> since it
   opens a dialog rather than navigating, a distinction that belongs in the markup rather than the paint. */
.contact-links button {
  font: inherit; letter-spacing: inherit; cursor: pointer;
  background: none; border: 0; padding: 0;
  /* The links' own color, not inherited body color — `inherit` picked up --text-strong and made this the one
     white item in a row of orange links. */
  color: var(--accent);
  transition: color .18s var(--ease-settle);
}

.contact-links a:hover, .contact-links button:hover { color: var(--accent-hover) }

/* Blur is for floating chrome over live content, which is exactly what this is. */
#qr-dialog {
  position: fixed; inset: 0; z-index: 120;
  display: flex; align-items: center; justify-content: center; padding: 24px; cursor: pointer;
  background: rgba(6, 5, 9, .82); backdrop-filter: blur(6px);
}

#qr-dialog:focus { outline: none }

#qr-dialog[hidden] { display: none }

.qr-panel {
  display: flex; flex-direction: column; align-items: center; gap: 16px;
  padding: 26px; max-width: 340px;
  background: var(--ink-panel); border: 1px solid var(--hairline); border-radius: var(--radius-card);
  box-shadow: 0 24px 60px -20px rgba(0, 0, 0, .5);
}

/* image-rendering:pixelated — the PNG is rendered at whole-module pixels, and letting the browser smooth it
   back up softens the module edges a scanner needs to threshold cleanly. */
#qr-image { display: block; border-radius: 4px; image-rendering: pixelated }

.qr-url { font-family: var(--font-mono); font-size: 12px; letter-spacing: .1em; color: var(--text-muted) }

/* ---------------- the instruments, as objects ---------------- */

/* The object sits above the card's text as its picture, in its own stacking context so the 3D inside it can't
   interact with the card's hover lift.
A fixed height, not the sketch's 4:3 — the solids are drawn at fixed pixel sizes, so an aspect ratio would only
   decide surrounding empty space and once swallowed the lead card at 660px tall against its 880px width. */
.lab-card .obj { height: 240px; aspect-ratio: auto; margin: 0 0 18px }

/* Idle only runs while the row is on screen — three continuous animations are atmosphere in view, battery out of
   it. .wh.on gates the throat's own play state; these two gate the rest. */
.lab-card .obj.cold *, .lab-card .obj.off * { animation-play-state: paused }

/* ---------------- lab objects, cue 05 ----------------
   Three stylised solids in CSS 3D, one per lab. No canvas: the page already runs one WebGL context for the hero
   reactor, and three more would each hold buffers whether or not they were drawing.
   ~110 elements across the three, mostly static geometry; everything that animates is transform or opacity, so
   the row is compositor-only work. Every static transform is the object's composed pose, so the global
   reduced-motion rule leaves three assembled solids rather than blank boxes. */

.obj { position: relative; width: 100%; aspect-ratio: 4 / 3; perspective: 560px; margin-bottom: 14px; overflow: hidden }
.obj-3d { position: absolute; inset: 0; transform-style: preserve-3d }
.obj-3d > * { position: absolute; left: 50%; top: 50% }

/* ---- the throat ----
   One keyframe describes a bent path through space; every ring runs it with a negative delay, so at any instant
   the fourteen occupy fourteen points along that bend and the tunnel reads as a curve rather than a bore.
Held only slightly off-axis — enough for a rib down the tunnel to project to something, not so much that the
   throat stops rushing the viewer. The bend comes from the path, not from turning the camera.
   Pivot is the mouth, not the far end: transform-origin's positive z sets the rotation center inside the near
   rings, so the mouth the viewer sits in barely moves and the far end swings — the reverse would wheel the
   tunnel around the camera while the distance held still. */
.wh .obj-3d { transform-origin: 50% 50% 320px; animation: wh-sway 18s linear infinite }
/* The roll: a rotateZ about the tube's own axis turns the wall while sweeping the bend in a full circle around
   the throat, since the far end carries all the offset while the mouth sits on the axis. Own wrapper so it composes with the sway rather than fighting it. */
.wh .roll { position: absolute; inset: 0; transform-style: preserve-3d; animation: wh-roll 52s linear infinite }
.wh .roll > * { position: absolute; left: 50%; top: 50% }
@keyframes wh-roll { to { transform: rotateZ(360deg) } }
/* Hover deliberately doesn't touch this — speeding the roll would swing the bend, a different thing from the wall
   turning, which is what should answer a hover.
Four keyframes tracing an orbit rather than two on an alternate, so the far end circles instead of wagging.
   Amplitude is generous since perspective shrinks the moving far end to about a third. */
@keyframes wh-sway {
  0% { transform: rotateY(6deg) rotateX(0deg) }
  25% { transform: rotateY(0deg) rotateX(5deg) }
  50% { transform: rotateY(-6deg) rotateX(0deg) }
  75% { transform: rotateY(0deg) rotateX(-5deg) }
  100% { transform: rotateY(6deg) rotateX(0deg) }
}
/* One description of the ring cycle. The script spaces the fourteen delays as fractions of it (delay = index x
   duration / 14 at both speeds) — fixed-second delays would bunch rings into the first half when duration changed. */
.wh { --fly: 7.2s }
/* Hover doubles travel alongside the wall — every ring is identical, so rescaling the cycle slides the whole set
   by less than one gap rather than scattering it. */
.wh:hover { --fly: 3.6s }
.wh .rg {
  width: 158px; height: 158px; margin: -79px 0 0 -79px;
  border: 1px solid var(--accent); border-radius: 50%;
  /* Hidden until arrival — a paused animation renders its element's base style, not its first keyframe, so a
     visible base would show all fourteen rings in resting poses before blinking out at 0%. */
  opacity: 0;
  /* Paused with positive delays so the throat builds on arrival rather than being caught mid-flight —
     animation-play-state pauses the delay too, so nothing counts down while off screen. */
  animation: wh-fly var(--fly) linear infinite;
  animation-play-state: paused;
}
.wh.on .rg { animation-play-state: running }
/* Offset grows with the square of distance from the far end, so the vanishing point sits dead center and the
   near mouth carries all the displacement — linear spreading would read as a skewed straight tube, not a bend.
   The five stops sample the shared centerline function at u = 1, .75, .5, .25, 0; both ends fade to nothing so
   the object dissolves rather than ending and can sit anywhere on a page.
   Travel runs far to near (rings rise out of the distance and sweep past), so reversing direction is just reading that stop list the other way round. */
@keyframes wh-fly {
  0% { transform: translate3d(170px, -74px, -1250px) rotateY(23deg) rotateX(-15deg); opacity: 0 }
  14% { opacity: .5 }
  25% { transform: translate3d(95.6px, -41.6px, -830px) rotateY(12.9deg) rotateX(-8.4deg) }
  50% { transform: translate3d(42.5px, -18.5px, -410px) rotateY(5.8deg) rotateX(-3.8deg) }
  75% { transform: translate3d(10.6px, -4.6px, 10px) rotateY(1.4deg) rotateX(-.9deg) }
  86% { opacity: .42 }
  100% { transform: translate3d(0, 0, 430px) rotateY(0deg) rotateX(0deg); opacity: 0 }
}
/* The wall's longitudinal lines are short segments chained along it, fixed in space — carrying them on the rings
   instead would give a stub a fixed length in tube units, magnifying near ones into streaks.
   The wall is a chain of stations, each spinning about the tube's axis at its own position — a rotateZ on the
   whole throat would move the centerline too, since the bend lives in x and y. Rotating each station in place
   leaves every center where it was and turns only the blades. */
.wh .wst { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d }
.wh .wsp { position: absolute; left: 0; top: 0; width: 0; height: 0; transform-style: preserve-3d; animation: wall-spin 30s linear infinite }
@keyframes wall-spin { to { transform: rotateZ(360deg) } }
/* Hover spins the wall faster; the bend keeps its own slow roll deliberately untouched. */
.wh:hover .wsp { animation-duration: 7s }

/* Each blade's opacity comes in as --o rather than set directly, so the wall fades up as a set without an
   opacity on any ancestor — an ancestor opacity would flatten the 3D and collapse the tube, the same trap the
   rings hit. Same accent as the hoops so the tube reads as one object. */
.wh .wb {
  /* 1.4px, not 1px — a 1px bar is already sub-pixel once perspective shrinks it, and a sub-pixel line renders
     dotted rather than faint as the rasterizer drops the thin parts. */
  position: absolute; left: 0; top: 0; height: 1.4px; margin-top: -.7px;
  transform-origin: 0 50%;
  background: var(--accent);
  opacity: 0;
  /* Stagger lives in --d, not an inline transition-delay, so leaving can override it — an inline delay would win
     over any stylesheet rule. */
  transition: opacity .9s var(--ease-settle) var(--d, 0s), background-color .35s var(--ease-settle);
}
.wh.on .wb { opacity: var(--o) }
/* Hoops and blades brighten identically (same two colors, same duration) — an extra opacity multiplier on the
   blades would put the wall and rings on different clocks. A hoop's opacity is animation-driven and can't be
   transitioned, so color is the one lever both can pull. */
.wh.on:hover .wb { background: var(--accent-hover) }
.wh .rg { transition: border-color .35s var(--ease-settle), opacity .35s var(--ease-settle) }
.wh:hover .rg { border-color: var(--accent-hover) }
/* Leaving is a clear, not a performance — the departure fires nearly off screen with nobody watching, so it's
   quick and unstaggered rather than a choreographed exit.
   Runs the sweep backwards: --dr staggers from the near station outward, so the wall retracts as it reached
   toward the viewer. Separate property since an inline delay would win over any rule, and the two directions want different durations. */
.wh:not(.on) .wb { transition: opacity .4s var(--ease-settle) var(--dr, 0s), background-color .35s var(--ease-settle) }
/* ---- the core ----
   Goo, and nothing else in the frame. Three stacked layers each carry a fixed asymmetric border-radius and turn
   at a different rate, so the silhouette never repeats; the container squashes and stretches on top of that —
   all transforms, since animating border-radius directly would repaint the blob every frame.
The hover swell lives on a wrapper, not the core — on the core it would fight the squash animation for the
   same property, snapping to base transform the moment the animation is removed. On a wrapper the two compose. */
.rx .blob {
  width: 116px; height: 116px; margin: -58px 0 0 -58px;
  transition: transform .45s var(--ease-settle), filter .45s var(--ease-settle);
}
.rx:hover .blob { transform: scale(1.24); filter: brightness(1.35) }
/* Dormant before arrival and after departure, so the core has somewhere to arrive from rather than reading its
   pulse as a twitch. */
.rx.cold .blob { transform: scale(.06); opacity: 0 }
.rx.off .blob { animation: rx-collapse .6s var(--ease-settle) forwards }
@keyframes rx-collapse {
  0% { transform: scale(1); opacity: 1 }
  26% { transform: scale(1.16, 1.08); filter: brightness(1.4) }
  100% { transform: scale(.06); filter: brightness(.5); opacity: 0 }
}
.rx .core { position: absolute; inset: 0; animation: goo-squash 5.4s ease-in-out infinite alternate }
@keyframes goo-squash {
  0% { transform: scale(1.06, .93) rotate(-4deg) }
  100% { transform: scale(.94, 1.08) rotate(5deg) }
}
/* The dark center is off-center, which is why it moves — a gradient centerd at 50% 50% is invariant under
   rotation and would look painted on. Offset and elliptical, the existing rotations carry it: three cores at three rates overlap into one wandering void. */
.rx .core b {
  position: absolute; inset: 0;
  border-radius: 62% 38% 44% 56% / 55% 43% 57% 45%;
  background: radial-gradient(ellipse 68% 78% at 43% 40%, #150b04 0%, #2b1307 34%, var(--accent) 71%, #ffc890 88%, rgba(221, 106, 32, .14) 100%);
  animation: goo-a 7s linear infinite;
}
.rx .core b:nth-child(2) {
  border-radius: 39% 61% 66% 34% / 42% 63% 37% 58%;
  background: radial-gradient(ellipse 80% 66% at 57% 46%, #150b04 0%, #2b1307 32%, var(--accent) 70%, #ffc890 88%, rgba(221, 106, 32, .12) 100%);
  opacity: .72; animation: goo-b 11s linear infinite;
}
.rx .core b:nth-child(3) {
  border-radius: 52% 48% 33% 67% / 63% 36% 64% 37%;
  background: radial-gradient(ellipse 62% 84% at 49% 58%, #150b04 0%, #2b1307 36%, var(--accent) 72%, #ffc890 89%, rgba(221, 106, 32, .1) 100%);
  opacity: .5; animation: goo-c 17s linear infinite;
}
@keyframes goo-a {
  0% { transform: rotate(0deg) scale(1) }
  50% { transform: rotate(180deg) scale(1.1) }
  100% { transform: rotate(360deg) scale(1) }
}
@keyframes goo-b {
  0% { transform: rotate(0deg) scale(1.07) }
  50% { transform: rotate(-180deg) scale(.92) }
  100% { transform: rotate(-360deg) scale(1.07) }
}
@keyframes goo-c {
  0% { transform: rotate(30deg) scale(.96) }
  50% { transform: rotate(210deg) scale(1.12) }
  100% { transform: rotate(390deg) scale(.96) }
}
/* The lab's own event: a pulse tears through the core. */
.rx.go .core { animation: rx-pulse .95s var(--ease-settle) }
@keyframes rx-pulse {
  0% { transform: scale(1) }
  30% { transform: scale(1.3, 1.18); filter: brightness(1.7) }
  62% { transform: scale(.9, 1.06); filter: brightness(1.1) }
  100% { transform: scale(1); filter: brightness(1) }
}

/* ---- the tube ----
   Yaw is wider than a monitor would want, since the back is half the object and has to be seen — the sway
   swings it from nearly three-quarter to nearly square on, so the funnel comes into view and goes again. */
.crt .obj-3d { transform: rotateX(6deg) }
.crt .tube { animation: crt-sway 19s ease-in-out infinite alternate }
@keyframes crt-sway {
  0% { transform: rotateY(-38deg) rotateX(4deg) }
  100% { transform: rotateY(-17deg) rotateX(1deg) }
}
/* A Predicta tube on its own: no cabinet, no stand, nothing on the face but glass and a hairline of trim. The
   funnel behind it is half the object, built in the script off a single interpolation. */
.crt .tube { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d }
.crt .tube > * { position: absolute; left: 0; top: 0; border: 1px solid var(--scenery) }

/* Wireframe in the Cartographer's own vocabulary (--scenery edges over transparent black), so the two "device
   seen in space" objects on this page agree about what a device looks like. Only .face is exempt, keeping its
   gradient, scanlines and phosphor since it's a picture rather than a form. */
.crt .fr {
  width: 180px; height: 136px; margin: -68px 0 0 -90px; transform: translateZ(15px); border-radius: 44px;
  background: rgba(0, 0, 0, .25);
}
/* No rim panels — four quads around the face's edge would read as arms bracketing the screen, so the plate
   stack starts flush with the face instead. It is the other half of the object, not a cap — plates step back and in from the face, each
   interpolated off a single --t. The count is the trick: too few read as a second lobe, since you see each
   plate's face rather than a joining surface; packed close enough the slivers overlap into a continuous cone.
   No filter here — it would force an element out of the preserve-3d context and flatten the whole cone; shading
   is baked into color instead. The lit top edge does most of the perceptual work, accumulating into a highlight
   the length of the cone — without it a dark form on a dark ground is just a hole.
   Contour lines, unfilled: twenty-eight stacked transparent-black faces would compound back to opaque and rebuild
   the solid cone this replaced. --e is written per plate by lab-objects.js so edges fade with depth. */
.crt .fn {
  position: absolute; left: 0; top: 0; background: none;
  border: 1px solid color-mix(in srgb, var(--scenery) calc(var(--e, .5) * 100%), transparent);
}

/* The glass reaches the shell — an 8px ring is the entire bezel, and a warm rather than white hairline reads as
   brushed metal instead of a drawn border. */
.crt .face {
  position: absolute; inset: 8px;
  border-radius: 32px; overflow: hidden;
  border: 1px solid rgba(255, 216, 180, .26);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .5), inset 0 2px 9px rgba(0, 0, 0, .72);
  background: radial-gradient(124% 106% at 50% 40%, #321c0a, #140b06 60%, #090609 100%);
}
.crt .face::before { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(180deg, rgba(255, 255, 255, .055) 0 1px, transparent 1px 3px) }
/* The sheen is the curvature. At this size it is the only part of a curved face the eye actually checks. */
.crt .face::after { content: ''; position: absolute; inset: 0; background: linear-gradient(126deg, rgba(255, 255, 255, .17) 0 16%, rgba(255, 255, 255, .04) 30%, transparent 44%) }
.crt s {
  position: absolute; left: 0; right: 0; top: 0; height: 2px; text-decoration: none;
  background: linear-gradient(90deg, transparent, var(--accent-hover), transparent);
  transform: translateY(52px); opacity: .5;
  animation: crt-scan 4.2s linear infinite;
}
@keyframes crt-scan {
  0% { transform: translateY(-4px); opacity: 0 }
  9% { opacity: .95 }
  91% { opacity: .95 }
  100% { transform: translateY(134px); opacity: 0 }
}
/* The degauss: two copies of the picture's glow, tinted and pulled sideways. Fringing the raster instead would
   be wrong twice — horizontal offsets are invisible against horizontal scanlines, and vertical ones would drop
   tinted lines into the gaps and turn the screen into color stripes. Held while hovered, not fired, since a magnet against the glass doesn't tick. */
.crt .face u {
  position: absolute; inset: 0; opacity: 0; mix-blend-mode: screen;
  background: radial-gradient(124% 106% at 50% 40%, currentColor, transparent 64%);
  transition: opacity .3s var(--ease-settle), transform .3s var(--ease-settle);
}
.crt .face u.r { color: rgba(255, 62, 38, .34) }
.crt .face u.g { color: rgba(84, 236, 116, .19) }
.crt .face u.b { color: rgba(72, 132, 255, .3) }
.crt .face { transition: transform .3s var(--ease-settle), background-color .55s var(--ease-settle) }
.crt .face::before, .crt s { transition: opacity .55s var(--ease-settle) }
/* The tube goes unstable under the pointer, like a failing supply — approach it and it stops holding a picture.
   Brightness only, since the hover already owns .face's transform for the skew, and animating transform too
   would replace it right when the convergence error is supposed to appear.
   steps(1, end), not linear: a failing supply snaps between levels; eased would read as a slow pulse.
   Filters are safe on .face specifically — the preserve-3d flattening risk applies to the funnel plates, 3D siblings of this. */
@keyframes crt-flicker {
  0%, 4%   { filter: brightness(.84) }
  5%       { filter: brightness(.52) }
  7%       { filter: brightness(.9) }
  9%       { filter: brightness(.58) }
  11%, 36% { filter: brightness(.82) }
  38%      { filter: brightness(1.04) }
  40%      { filter: brightness(.55) }
  43%, 62% { filter: brightness(.85) }
  64%      { filter: brightness(.48) }
  66%, 88% { filter: brightness(.83) }
  90%      { filter: brightness(.6) }
  92%, 100% { filter: brightness(.84) }
}
.crt:hover .face {
  transform: skewX(.9deg) scale(1.015, .99);
  /* 3.1s against the scanline's 4.2s, so the two clocks drift and never land on the same frame twice in a row. */
  animation: crt-flicker 3.1s steps(1, end) infinite;
}
/* All three guns orbit rather than park — one keyframe, three negative delays a third of a cycle apart, so
   they screen together back to neutral where they overlap: white in the middle, color at the edges. */
@keyframes rgb-orbit {
  0% { transform: translate(7px, 0) }
  25% { transform: translate(0, 5px) }
  50% { transform: translate(-7px, 0) }
  75% { transform: translate(0, -5px) }
  100% { transform: translate(7px, 0) }
}
.crt:hover .face u { opacity: 1 }
.crt:hover .face u.r { animation: rgb-orbit 2.4s linear infinite }
.crt:hover .face u.g { animation: rgb-orbit 2.4s linear infinite -.8s }
.crt:hover .face u.b { animation: rgb-orbit 2.4s linear infinite -1.6s }

/* Cold: no raster, scanline, glow or convergence to lose — the state before the section arrives, which is what
   makes the power-on true (an already-lit tube can only flicker). Last, so it wins over hover while dark. */
.crt.cold .face { background: #08060a }
.crt.cold .face::before, .crt.cold s, .crt.cold .face u { opacity: 0 }

/* The power-down is the reverse of the power-up, not a fade — the picture squeezes to a bright line and flares
   out as the raster collapses. The one departure worth watching, so this object leads the exit. */
.crt.off .face { animation: crt-off .55s var(--ease-settle) forwards }
@keyframes crt-off {
  0% { transform: scaleY(1); filter: brightness(1) }
  42% { transform: scaleY(.9); filter: brightness(1.8) }
  100% { transform: scaleY(.012); filter: brightness(2.6) }
}
.crt.go .face { animation: crt-on .85s var(--ease-settle) }
@keyframes crt-on {
  0% { transform: scaleY(.015); filter: brightness(2.8) }
  55% { transform: scaleY(1.05); filter: brightness(1.5) }
  100% { transform: scaleY(1); filter: brightness(1) }
}

/* ---------------- narrow and short ---------------- */

/* Narrow and short are different faults with different answers: too narrow is a layout problem (stack columns),
   too short is a type-size problem — a landscape phone (852x393) is wide enough for two columns but nowhere near
   tall enough for a width-sized lockup. Collapsing on either alone would still overflow the other axis.
   820/500 are the same pair the labs use. */
@media (max-width: 820px) {
  :root { --pad-page: 22px }

  .hero-grid { grid-template-columns: minmax(0, 1fr); gap: 26px }
  .app-grid { grid-template-columns: minmax(0, 1fr); gap: 40px }

  .expertise b { min-width: 0; display: block }
  .lab-grid { grid-template-columns: minmax(0, 1fr) }
  .lockup { letter-spacing: -3px }
  /* Release the column with the grid — pinned to 3 against a single-column template, the aside would open an
     implicit third column and land on top of the lockup. */
  .aside { grid-column: auto; max-width: 46ch }
  /* THE BAR COLLAPSES TO TWO DOORS, NOT TO A WORDMARK. Six chapter labels and four marks do not fit across
     393px beside the wordmark; dropping the meter outright took away the one readout that says where in the
     page a reader is. Both panels are hidden only once nav.js has flagged itself in, so with no JS the list and
     the marks stay in the bar as they were rather than sitting behind a button that cannot open them.
     1fr auto 1fr, as on a desktop: the middle cell is centered against the VIEWPORT rather than against
     whatever the wordmark and LINKS happen to weigh, so the current chapter holds still as its name changes. */
  #nav { grid-template-columns: 1fr auto 1fr; gap: 12px }
  .nav-now, .nav-more { display: flex }
  /* OPEN AND CLOSED ARE A TRANSFORM, NOT A DISPLAY SWITCH: display cannot be transitioned, so both panels stay
     laid out and are lifted out of reach instead. visibility rather than display keeps them out of the tab
     order and off the pointer while closed, and it is held for the length of the slide so the panel is still
     there to animate on the way back up. */
  #nav.js-nav .links, #nav.js-nav .tools {
    visibility: hidden; opacity: 0; pointer-events: none;
    transition: opacity .2s var(--ease-settle), transform .24s var(--ease-settle), visibility 0s .24s;
  }
  #nav.js-nav .links.is-open, #nav.js-nav .tools.is-open {
    visibility: visible; opacity: 1; pointer-events: auto;
    transition: opacity .2s var(--ease-settle), transform .24s var(--ease-settle), visibility 0s;
  }

  .nav-now, .nav-more {
    position: relative; align-items: center; gap: 7px;
    padding: 5px 2px 8px; border: 0; background: none;
    font: inherit; letter-spacing: .1em; text-transform: uppercase;
    color: var(--text-muted); cursor: pointer;
  }
  .nav-more { justify-self: end; color: var(--text-faint) }
  .nav-now[aria-expanded="true"], .nav-more[aria-expanded="true"] { color: var(--accent) }
  .nav-now:focus-visible, .nav-more:focus-visible {
    outline: none; color: var(--accent); box-shadow: 0 0 0 2px var(--amber-focus); border-radius: 3px;
  }
  .nav-now .nav-now-name { color: var(--accent) }

  /* HOW MUCH OF THE SECTION NAMED ABOVE IS LEFT. --sp is nav.js's span reading, not the --k meter: the label
     says which section the reader is IN, so the rule under it has to answer the same question rather than
     report the page. No transition — the value is scrubbed. */
  .nav-now::after {
    content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
    background: var(--accent);
    transform: scaleX(var(--sp, 0)); transform-origin: left;
  }

  /* Both panels hang off the fixed bar, so they scroll with nothing. */
  #nav .links, #nav .tools {
    position: absolute; top: calc(100% - 14px); z-index: 1;
    flex-direction: column; gap: 0;
    /* z-index -1 on the plate below is clamped to the panel's own context by this, so it sits behind the rows
       instead of behind #nav's gradient. */
    isolation: isolate;
  }

  /* THE PLATE, on both panels. A blurred layer behind rather than a background on the box: the blur is what
     gives the edges their falloff, and run on the panel itself it would take the names with it. */
  #nav .links::before, #nav .tools::before {
    content: ''; position: absolute; z-index: -1;
    background: rgba(0, 0, 0, .5); border-radius: 18px; filter: blur(11px);
  }

  /* THE CHAPTERS CARRY NO PANEL. No plate, no border: the list is centered under the name it belongs to and
     reads as that name opening out. Both axes live in one transform, since translateX is what centers it and
     the slide has to compose with that rather than replace it. */
  /* Sized to its widest name (CARTOGRAPHER) rather than to a min-width: a fixed 210 left air either side of
     every shorter row, and the plate under it made that air visible. */
  /* stretch, not center: centered, each row was a flex item only as wide as its own name, so ALEX's rule was a
     third the length of CARTOGRAPHER's and the six bars read as six different meters. Stretched, every row is
     the panel's width — which max-content still sets from the longest name, so the panel is one constant width
     and the rules under the rows are one constant length. */
  #nav .links {
    left: 50%; align-items: stretch; text-align: center;
    width: max-content; min-width: 0; padding: 6px 0;
    background: none; border: 0; box-shadow: none;
    transform: translate(-50%, -12px);
  }
  #nav .links.is-open { transform: translate(-50%, 0) }
  #nav .links::before { inset: -2px -10px }

  /* With no plate under them the names sit straight on whatever section is passing, and the top rows landed
     inside a paragraph unreadable. A shadow is the one way to buy contrast without the panel the list is
     deliberately not carrying. */
  #nav .links a {
    padding: 11px 6px; border-radius: 3px; text-align: center;
    text-shadow: 0 0 5px #000, 0 0 14px #000;
  }

  /* The panel answers the same question its own button does. Opening a menu that lit a different row from the
     one the button just named would be the disagreement collapsing to one label was meant to remove, so on a
     phone the accent follows [data-here] — the span the reader is inside — and the rail's aria-current keeps
     the bar it is filling to itself. */
  #nav .links a[aria-current="true"] { color: var(--text-muted) }
  #nav .links a[data-here] { color: var(--accent) }

  /* The per-item rule sits under its own row rather than spanning the panel, so six stacked bars still read as
     six chapters — and each fills on the span reading, so the row the button names is the row part-filled.
     --h is the index of that row; adding it back turns the fraction into a playhead the shared rule can read. */
  #nav .links a::after {
    left: 12px; right: 12px; bottom: 4px;
    transform: scaleX(clamp(0, calc(var(--h, 0) + var(--sp, 0) - var(--i, 0)), 1));
  }
  #nav .links a[aria-current="true"]::after { height: 1px; background: var(--border-accent) }
  #nav .links a[data-here]::after { height: 2px; background: var(--accent) }

  /* THE MARKS DO CARRY ONE, because they are four names over whatever section is passing under them. Sized to
     its widest row rather than to a min-width, and right-justified into the page's own edge so the column of
     names lines up with LINKS above it. */
  /* No side padding: the panel's right edge IS --pad-page, the same rail LINKS is justified to, so a row's
     right edge lands under the trigger's own. Any inset here and the marks hang left of the word that opens
     them. The plate takes the air instead, since it is outside the box. */
  #nav .tools {
    right: var(--pad-page); width: max-content; min-width: 0;
    align-items: stretch; text-align: right;
    padding: 8px 0; background: none; border: 0; box-shadow: none;
    transform: translateY(-12px);
  }
  #nav .tools.is-open { transform: translateY(0) }
  #nav .tools::before { inset: -4px -16px }
  /* Faint is right for four marks in a bar; in a list over live content it is not readable. */
  #nav .tools a, #nav .tools button {
    color: var(--text-muted);
    /* The plate falls off before its own left edge, which is where the names start. */
    text-shadow: 0 0 5px #000, 0 0 14px #000;
  }

  /* The marks become rows: a name beside each icon, which is what the hover tip carries on a desktop and what a
     touch pointer never gets. The icon trails the name so the names hold the right edge together. */
  #nav .tools a, #nav .tools button {
    display: flex; flex-direction: row-reverse; justify-content: flex-start; align-items: center; gap: 12px;
    width: auto; height: auto; padding: 9px 0; border-radius: 3px;
  }
  .tool-name { display: inline; letter-spacing: .1em; text-transform: uppercase }
  /* The name is in the row now; a tip repeating it would open over the row below. */
  #nav .tools .tip::after { content: none }
  #labs, #contact { padding-top: 72px; padding-bottom: 72px }

  /* The bar is fixed over sections whose own padding no longer clears it, so the scrim has to carry the
     separation on its own: at the desktop's .92 -> 0 ramp a heading passing under the mark stayed legible
     through it and read as a collision. */
  #nav {
    background: linear-gradient(180deg,
      rgba(12, 12, 14, .97) 46%, rgba(12, 12, 14, .8) 72%, rgba(12, 12, 14, 0));
    padding-bottom: 30px;
  }
}

/* THE BEATS ARE OFF ON A PHONE, at the source rather than per target: one declaration cannot fall out of step
   with the seven-target list the way clearing each alignment could, and it covers a reader with no JS.
   A barrier is for arresting a gesture that would cross a beat, which assumes the beat is a place the whole of
   fits on screen; here the content is taller than the screen, so a target under the reader arrests a flick they
   meant to carry. scenes.js stands the rail down on this same pair — the 820/500 the labs use. */
@media (max-width: 820px), (max-height: 500px) {
  html { scroll-snap-type: none }
}

/* ---------------- portrait phone ----------------
   One axis of room and no pointer. Everything here is a POSITION, not a restyle: the pinned scenes were drawn
   against a landscape stage and place their parts side by side, which on a 393x852 screen puts content off the
   bottom of a pin or leaves a third of a section empty. */
@media (max-width: 820px) and (orientation: portrait) {

  /* The core's lane, declared once — #hero-core is sized from it and .hero-grid reserves it as a row, so the
     picture and the hole left for it cannot disagree. */
  :root { --hero-lane: min(78vw, 38vh) }

  /* WHERE EVERY SECTION PUTS ITS FIRST LINE. One figure, used as the top padding of each section that has a
     title, so arriving at any of them looks the same — they were landing at 64 / 102 / 156 / 168, which read as
     four different pages. 104 clears the bar's 82px box with air to spare.
     scroll-margin-top goes to 0 with it: the padding IS the clearance now, and the 84 the stylesheet declares
     for a desktop anchor would apply it a second time. */
  :root { --title-top: 104px }
  section[id] { scroll-margin-top: 0 }
  /* The padding alone decides where the first line sits. .sec-title carries a 12px top margin for the rhythm
     BETWEEN blocks, and as a section's first child that margin was adding to the padding — #app and #labs
     landed at 116 against Cartographer's and Experience's 104. Exactly the fault .loop-say already zeroes for
     its own first child; this is the same rule for the rest. */
  .inner > :first-child, .cat-left > :first-child, .app-copy > :first-child { margin-top: 0 }

  /* THE NAME OVER THE CORE, THE PARAGRAPH UNDER IT.
     #hero-core is absolutely positioned outside .scene and must stay there — inside it, it would inherit the
     words' rise-and-blur exit, which is the whole point of scenes.js running two envelopes off one scroll. So
     the lane it sits in is centered by construction instead: equal 1fr rows above and below it, inside a .hero
     box the foot's padding makes symmetric with .scene's own 68px padding-top. */
  .hero-foot { padding-bottom: 50px }
  /* THE BUFFER OVERFLOWS THE LANE. Sized to the lane exactly, the core's glow was still bright where the
     canvas ended and the boundary read as a step — measured 12,12,14 outside against 25,16,14 inside. The lane
     is where the CORE goes; the canvas needs room around it for the falloff, so it is 1.7x that and spills over
     the words either side, which is what the scrims under them are for. The zoom comes down to match, or a
     bigger buffer would just draw a bigger core — and --core-shade follows both without being restated. */
  :root {
    --core-canvas: calc(var(--hero-lane) * 1.7);
    --core-zoom: 1.4;
  }
  #hero-core { top: 50% }
  #hero-shade { top: 50% }

  .hero { align-items: stretch }
  .hero-grid {
    height: 100%;
    grid-template-rows: 1fr var(--hero-lane) 1fr;
    row-gap: 0;
    justify-items: center; text-align: center;
  }
  /* Seated against the lane from above. display-lockup.js fits the kicker to the lockup's ink width, so both
     boxes are the same width and centering them centers the pair — its own margin correction lands at ~0. */
  .hero-grid > :first-child {
    align-self: end; margin-bottom: 10px;
    display: grid; justify-items: center;
  }
  /* Row 3 explicitly: auto-placement would drop the paragraph into the core's lane. */
  .aside { grid-row: 3; align-self: start; margin-top: 18px; max-width: 34ch }

  /* THE PIN ENGAGES WHERE THE READER STOPS NEEDING THE TITLE.
     #app measures 1096px at --m 1 against an 852px screen, so something above the phone has to leave. Not the
     copy: THEN/NOW is the one block that CHANGES as the morph runs, so hiding it hides the readout the scrub
     exists to drive. So the section scrolls normally — title, lede, all of it — and pins only once THEN/NOW
     has reached the header, by sticking at a NEGATIVE top of exactly that distance. From there the copy that
     matters holds under the bar and the phone has the rest of the screen at full size.
     --app-lift is measured by scenes.js, not written here: it is the distance from #app's top to .then-now,
     which is a text block's height at whatever width and font the reader actually got. min-height carries the
     lift too, or the stage's BOTTOM would rise by it and leave a strip of the next section under the pin. */
  #app-stage {
    display: block; align-items: initial;
    position: sticky; top: calc(var(--app-lift, 0px) * -1);
    height: auto; min-height: calc(100dvh + var(--app-lift, 0px));
    background: var(--ink);
    /* THE CALCULATOR SHOWS PAST THE STAGE, on one axis. The desktop's `overflow: hidden` (declared once, above)
       is what a phone inherits, and with the pin's negative top the stage's box no longer bounds what the
       calculator has to show — a key that leans out of the plate is cut at the edge instead of leaning.
       Not `visible`: a retracting key's cap carries an inverse scale that reaches 25x, and released sideways
       that hands a 390px phone a horizontally scrollable page. `clip` on x is the same trade #experience.js-cat
       makes for the open case, and for the same reason. */
    overflow-x: clip; overflow-y: visible;
  }
  /* 90, not the desktop's 90/60: the bar's box runs to 82px here (22 + content + the 30px scrim tail), and
     scenes.js reads this very number as the clearance the pin lands THEN/NOW at. */
  /* THE PIN NEEDS TRAVEL TO BE SCRUBBED THROUGH. At the desktop's .7 the stage caught at local 232 and released
     at 364 — 132px of hold for a turn the reader is supposed to drive. 1.3 gives 644px, which is what lets
     scenes.js run --m off the scroll here instead of firing a fixed clock at a threshold. */
  :root { --morph-run: 1.3 }

  /* scenes.js reads this back as the pin's clearance, so the calculator's THEN/NOW lands on the same line every
     other section's title does. */
  /* The bottom is the SHADOW'S ROOM, not spacing. The chassis casts 0 50px 90px -22px, which reaches 73px below
     it, and at 28px only 22px of that fell inside the ink panel — the rest landed on #cat, which is opaque and
     paints later, cutting the shadow off in a straight line across the object's foot. Nothing was clipping it;
     raising the stage instead would put the calculator over the section that is meant to slide across it.
     scenes.js reads padding-TOP as the pin's clearance and never this, so the two move independently. */
  #app { padding: var(--title-top) var(--pad-page) 80px }
  /* Stacked, the store badge and the device sit nose to tail and read as one block; the desktop's 30-68px gap
     is a COLUMN gap and buys nothing here. Room enough that the badge is a link and not the calculator's lid. */
  .app-grid { gap: 34px }

  /* The cover is sized from the height this leaves it (catalog.js's `fit`), so the wrap is what decides how
     big the crate reads. 175 gave a 108px cover; 300 gives ~195, which is where the printed title on the spine
     becomes legible on a phone. */
  .reel-wrap { height: min(52vh, 300px) }

  /* WIDTH DECIDES HERE, NOT HEIGHT. With height: 100% the 3/4 ratio loses to max-width on a narrow column and
     the case is laid out 349x528 — a 2:3 box, which case3d.js then draws as one, since it takes both edges from
     this element. Driving it from the width keeps the proportion the drawing is built for. */
  .case-body { width: 100%; height: auto; max-height: none }
  .case-box { height: auto }

  /* .cat has exactly two children, so this gap is the one between the crate and the open case. Stacked, 36px
     read as the case being part of the carousel rather than the thing the carousel indexes — and the blades
     lean toward the reader, so they need clearance the desktop's side-by-side columns never asked for.
     padding-top matches #labs's 72. The 1080px rule overrides only .cat's padding-BOTTOM, so a phone inherited
     the desktop's 132 — which is right there, where the section starts at the viewport top under a floating
     bar, and wrong here: #experience.js-cat has no top padding of its own, so those 132px were the whole gap
     and the title landed 60px lower than every other section's after an anchor jump. */
  .cat { gap: 64px; padding-top: var(--title-top) }

  /* KEPT FULL HEIGHT, though the reason changed. It was sized to give the rail a release region below #labs,
     and the rail is off here — but it is also the page's last screen, and the base rule already centers its
     content in it. Collapsed to its 276px of content it stopped being a closing screen and became a footer, and
     the header's own chapter meter had CONTACT as a sliver. */
  #contact { padding-top: 96px; padding-bottom: 96px }
  #labs { padding-top: var(--title-top) }
}

/* vh caps, not replacements — the width clamp still governs on a tall narrow phone, and this only bites when
   the viewport is too short to seat the type the width asked for.
   Below this threshold the section falls back to a plain block in its shipped state; it must match the media
   query scenes.js checks, or the page could pin a scene the script has stopped driving. */
@media (max-height: 620px) {
  #app-scroll { height: auto }
  #app-stage { position: static; height: auto; display: block; overflow: visible }
  #app { padding: 90px var(--pad-page) }
}

/* The loop scene needs more room than the morph, so it pins on two conditions. Measured: 687-727px of content at
   1080px+ wide, ~840px once columns collapse below that. scenes.js checks this same pair; disagreement pins a scene the script has stopped driving. */
@media (max-height: 760px), (max-width: 1080px) and (max-height: 900px) {
  #loop-scroll { height: auto }
  #loop-stage { position: static; height: auto; display: block; overflow: visible }
  #cartographer { padding: 110px var(--pad-page) 40px }
}

/* Tighter rhythm between 760-880px: the pin holds to 760 but the terms column overflows the stage in that gap
   (six rows at one test per row vs. two wrapped), losing the CTA off the bottom. Nothing is dropped or resized — just the air between blocks. */
@media (max-height: 880px) {
  #cartographer { padding-top: 48px; padding-bottom: 30px }
  .tagline { margin-top: 12px }
  .reach { margin-top: 16px }
  .terms { margin-top: 20px; gap: 14px }
  .term ul.checks { gap: 9px var(--claim-gap) }
  .demo-cta { margin-top: 22px; padding: 12px 22px }
}

/* Two columns stop paying at 920px — narrower than the pin's own 1080 threshold, since that one asks whether
   there's room to seat the section while this asks whether there's room for two columns side by side. */
@media (max-width: 920px) {
  .loop-grid { grid-template-columns: minmax(0, 1fr); gap: 34px }
  /* The graph goes under the copy and stops being the widest thing on the page — at full column width its cards
     outgrow their labels. */
  .flow { max-width: 460px; margin: 0 }
}

@media (max-height: 500px) {
  .scene { padding-top: 54px }
  .kicker { font-size: min(clamp(20px, 3.2vw, 44px), 7vh) }
  .lockup { font-size: min(clamp(64px, 13vw, 176px), 21vh); letter-spacing: -2px }
  .aside { font-size: 12.5px; line-height: 1.5 }
  .hero-foot { padding-bottom: 14px }
}


/* ---- Cartographer, on a portrait phone ----
   MEASURED AT 979px AGAINST AN 852px SCREEN, so the section ran over by a section's worth of nothing and the
   graph — the one thing here that is a picture rather than a paragraph — was the part squeezed. The copy is
   genuinely short (517px of it), so the fix is air rather than words: the margins between seven stacked blocks
   give back 32px, the grid gap and the section's own padding another 28, and the graph takes the remaining 67
   by capping at 272 rather than the column's full 349.
   ITS OWN BLOCK, AT THE END. The `max-height: 880px` rule below sets the same margins and sits after the
   portrait block, so these have to come after both to survive the cascade. */
@media (max-width: 820px) and (orientation: portrait) {
  /* TOP-ALIGNED, NOT CENTERED. Centering it in a screen put its title at a different height from every other
     section's and pinned the whole section to one viewport, which is what the graph was being shrunk to fit.
     Off that budget it can simply be as tall as it needs. */
  #cartographer { padding-top: var(--title-top); padding-bottom: 56px }

  /* THE HERO HOLDS BEFORE IT LEAVES. 0 is right on a desktop, where the scene is already visible on load and a
     hold would only stall the first half-screen. A phone reads the same scene through a third of the width, so
     the words start rising almost as soon as the thumb moves and the reactor is gone before it has been looked
     at. Half a screen of stationary scroll first — scenes.js reads this back and #scroll's height already adds
     it to the runway, so this one declaration is the whole change. */
  :root { --scene-hold: .5 }
  .loop-grid { gap: 20px }
  .tagline { margin-top: 8px }
  .loop-say .sec-lede { margin-top: 14px }
  .reach { margin-top: 12px }
  /* THE THREE CLAIMS STAY ON ONE LINE. Measured: the row is 275px of text, so at the flat 56px gap it needs
     388px of column and wraps on every phone narrower than ~432px — and wrapped, "Any LLM" alone on a second
     line reads as an afterthought rather than the third of three. The gap is what gives first, not the words.
     Still ONE gap: --claim-gap is overridden rather than .reach's own, so the checks grid below tracks it and
     the column keeps the single horizontal rhythm the desktop rule sets. Both cap at 56px by 800px wide, so
     nothing above this breakpoint moves. */
  #cartographer { --claim-gap: clamp(12px, 7vw, 56px) }
  .reach { flex-wrap: nowrap; font-size: clamp(15.5px, 4.4vw, 19px) }
  /* The gap can absorb a narrow screen; a claim broken across two lines cannot be absorbed at all. */
  .reach span { white-space: nowrap }
  .terms { margin-top: 14px }
  .demo-cta { margin-top: 16px }
  /* Squarish, so this is its height too — and the height is what the section did not have. */
  /* The full column, now that the section is not squeezed into one screen. margin-inline restores what the
     920px rule's `margin: 0` took. */
  .flow { max-width: 100%; margin-inline: auto }
}


/* The scene rig collapses to a plain static section at its end state — no sticky stage, no scrubbed transform. */
@media (prefers-reduced-motion: reduce) {

  /* A flickering screen is the one effect here that can harm somebody — repeated luminance steps are a
     photosensitivity trigger, so it's cut rather than slowed. The dim alone carries the hover state. */
  .crt:hover .face { animation: none; filter: brightness(.84) }

  html { scroll-behavior: auto }
  /* The beats go with the rigs — scenes.js clears them too, but this is what a reader with JS off gets. */
  #hero-snap, #loop-snap, #app-snap-old, #app-snap-app, #exp-snap, #labs-snap, #contact-snap { scroll-snap-align: none }
  #scroll { height: auto }
  #stage { position: static; height: auto; min-height: 100vh }
  #app-scroll { height: auto }
  #app-stage { position: static; height: auto; display: block; overflow: visible }
  #loop-scroll { height: auto }
  #loop-stage { position: static; height: auto; display: block; overflow: visible }
  .tn { transition: none }
  .scene { position: static; min-height: 100vh; opacity: 1; transform: none; filter: none }
  .kicker, .aside, .lockup span { animation: none }
  /* Same photosensitivity reasoning as the CRT flicker above — the live dot's pulse rests at full strength
     instead. */
  .cue span, .live-dot, .live-dot::after { animation: none }
  /* The graph keeps its shape and loses its circuit: --gb defaults to 1 and --gf to 0, so it's simply drawn. */
  .fg-spark, .fg-wave { animation: none; opacity: 0 }
  [data-reveal] { opacity: 1; transform: none; transition: none }
  /* The rule appears rather than draws — the same clamp multiplied hard enough to step straight to full on entry. */
  #nav .links a::after { transform: scaleX(clamp(0, calc((var(--k, 0) - var(--i, 0)) * 1000), 1)) }

  /* The instrument bodies, still. lab-objects.js composes them and skips the entrance, but the ambience is a
     dozen infinite CSS animations it can't reach — stopping them here leaves each at its 0% (composed) pose.
     State transitions (rx-collapse, crt-off) are left alone since they run on departure, which never happens here. */
  .wh .obj-3d, .wh .roll, .wh .wsp, .wh .rg,
  .rx .core, .rx .core b,
  .crt .tube, .crt s { animation: none }

  /* .wh .rg is drawn at opacity 0 and lit by its own keyframes, so stopping the animation would leave the tunnel
     invisible — the per-ring resting pose the script writes IS the picture here. */
  .wh .rg { opacity: 1 }

  /* Hover ambience, named apart from the resting-ambience block above. */
  .crt:hover .face u.r, .crt:hover .face u.g, .crt:hover .face u.b { animation: none }
}
