/* ═════════════════════════════════════════════════════════════════
   vanta-os.com — the interface page

   Rewritten clean 2026-09-22 against AD's revision list. The previous
   file was corrupted by a block-removal script of mine that
   mis-parsed @media and @keyframes bodies as top-level rules and
   deleted real declarations. Patching that would have left a
   stylesheet matching no version of itself, which is the exact
   2026-09-20 failure. Rebuilt instead.

   Everything structural is ui/ — AD's Black Monochrome Design Library
   v1.3 at --layout-scale .8. This file holds only what the library
   has no opinion about.

   🔴 TWO RULES
   1. Every selector below appears EXACTLY ONCE.
   2. Before adding a class, check it against the library's namespace.
      Eight collisions were found here the hard way — `.sheet` set
      visibility:hidden from components.css and the chat panel opened
      to nothing.
   ═════════════════════════════════════════════════════════════════ */

/* 🔴 iOS SAFARI OVERRIDES AUTHORED TYPE SIZES WITHOUT THIS.
   Safari's text autosizing inflates type inside narrow blocks to make
   non-responsive pages readable. On 2026-09-24 it silently undid a
   whole composition's scale — the boxes shrank, the text did not, and
   captions landed on top of each other on AD's phone while every
   desktop-engine check passed. `100%` means "the size I wrote is the
   size I meant"; it does not stop the user pinch-zooming. */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }

/* 🔴 BLACK MONOCHROME ONLY — AD, 2026-09-22.

   The ember atmosphere is gone and so is every other use of hue. This
   is the library's rule 1 restored, and the library's own answer to
   what replaces it: "The palette is black and white. Light — sheen,
   rim-light, frosted glass, gradient sweeps — carries the richness,
   not colour." (brand/README.md)

   The fields below are the SAME two fields in the same two corners at
   the same alphas. Only the hue is gone, so the page still has depth
   and a light source; it simply has no colour. */

/* ═══ COLOUR, AND EXACTLY WHERE IT IS ALLOWED ═════════════════════
   AD, 2026-09-23: "We can also use orange, green, red, and yellow,
   but the colors should remain minimal for the chat interfaces and
   widgets. The messages you send to Vanta can resemble iMessage
   messages; the interface and voice notes are mostly blue, and your
   messages can also appear blue."

   ⚠️ THIS IS SCOPED, AND THE SCOPE IS THE WHOLE POINT. Colour is for
   what is INSIDE a device or a chat surface — a status flag, a sent
   message, a voice note. It is NOT for the page: no coloured
   headings, no tinted backgrounds, no accent on a button. His
   2026-09-22 instruction, "no orange on the bg", still stands and is
   not in conflict with this one; the two describe different layers.

   🔑 THE VALUES ARE THE APP'S OWN, NOT SAMPLED AND NOT INVENTED.
   They are the flag palette already shipping in
   design/vanta_invest/blackmonochrome/prometheus/app.js:87-91, which
   is the same source the orb now comes from. Sampling AD's four
   reference screenshots gave #eb672c / #67c868 — close, but a JPEG's
   idea of the colour is not the colour (lessons.md 2026-07-28: a
   measurement is a hypothesis).

   ⚠️ There is no canonical YELLOW. AD named four colours and the app
   ships three; #fb923c covers amber. A yellow gets added when the
   app has one, not before.

   ⚠️ THE LIBRARY'S RULE 2 — "No hue, never add an accent" — IS NOT
   REPEALED. These tokens live in this page's own layer, never in
   brand/ad-apple-black/. The library stays monochrome. */
:root {
  --bar-h:   calc(56px * var(--layout-scale) / .8);
  --band-p:  clamp(var(--s10), 10vw, calc(var(--s11) * 1.5));

  /* In-device only. See the block above before using any of these. */
  --ok:    #10b981;   /* optimal · normal            app.js:87-88 */
  --warn:  #fb923c;   /* low · high                  app.js:89-90 */
  --bad:   #fb7185;   /* abnormal                    app.js:91    */
  --brand: #c95400;   /* the Vanta orange            app.js:209   */

  /* iMessage blue, the DARK-mode value. #007AFF is the light-mode
     one and it is noticeably muddy on black. */
  --send:  #0a84ff;
}

html { overflow-x: clip; scroll-behavior: smooth; }

body {
  overflow-x: clip;
  background: var(--bg);
  /* The chat bar is fixed and always present, so the page owes it
     room or the last section sits underneath it. (There is no footer
     any more — AD removed it 2026-09-23.) */
  padding-bottom: calc(var(--bar-h) + var(--s9));
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

/* ═══ Atmosphere ══════════════════════════════════════════════════
   Two fields, two corners, fixed behind everything. Hueless since AD's
   2026-09-22 call. The alphas are the ones the first page arrived at
   after a build that read brown end to end — unchanged, because the
   problem was never the amount of light, only its colour. */
.atmos { position: fixed; inset: 0; z-index: -1; pointer-events: none; background: var(--bg); }

.atmos__ember { position: absolute; border-radius: 50%; filter: blur(90px); will-change: transform; }

.atmos__ember--a {
  top: -44vh; right: -32vw; width: 84vw; height: 84vw;
  background: radial-gradient(circle at 50% 50%,
    rgba(255,255,255,.045) 0%,
    rgba(255,255,255,.018) 34%,
    rgba(255,255,255,.007) 58%,
    transparent 74%);
  animation: drift 34s var(--ease-mirror) infinite alternate;
}

.atmos__ember--b {
  bottom: -46vh; left: -34vw; width: 92vw; height: 92vw;
  background: radial-gradient(circle at 50% 50%,
    rgba(255,255,255,.030) 0%,
    rgba(255,255,255,.012) 42%,
    transparent 70%);
  animation: drift 46s var(--ease-mirror) infinite alternate-reverse;
}

.atmos__core {
  position: absolute; inset: 0;
  background: radial-gradient(ellipse 58% 52% at 50% 46%, #000 0%, rgba(0,0,0,.78) 46%, transparent 78%);
}

.atmos__grain {
  position: absolute; inset: 0; opacity: .5; mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='.34'/%3E%3C/svg%3E");
}

@keyframes drift {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(-4vw, 3vh, 0) scale(1.14); }
}

@media (prefers-reduced-motion: reduce) {
  .atmos__ember { animation: none; }
}

/* ═══ Nav ═════════════════════════════════════════════════════════ */

/* ═══ Sections ════════════════════════════════════════════════════ */
.band {
  position: relative;
  max-width: var(--shell);
  margin-inline: auto;
  padding: var(--band-p) var(--gutter);
  display: grid;
  grid-template-columns: 1fr;
  justify-items: center;
  align-items: center;
  gap: var(--s8);
}

.band__c { display: flex; flex-direction: column; align-items: flex-start; gap: var(--s5); max-width: min(100%, 520px); }

.band__c--centre { align-items: center; text-align: center; max-width: none; margin-inline: auto; }

.sec__eye { color: var(--text-3); }

.sec__b { margin: 0; max-width: 42ch; font-size: var(--t-row); line-height: 1.55; color: var(--text-2); }

.sec__b--wide { max-width: 50ch; }

/* ─── One line, and it is not nowrap ─────────────────────────────
   AD, 2026-09-22: "display a single line… not multiple lines."

   ⚠️ `white-space: nowrap` would guarantee one line by guaranteeing
   horizontal scroll on a phone — a wrapped heading traded for a
   broken page. The size is bound to the VIEWPORT instead, so the line
   shrinks to fit; below 820px it is allowed to wrap, because one line
   at 390px would be 9px type nobody can read. */
.sec__h {
  margin: 0;
  max-width: none;
  text-align: center;
  font-size: clamp(var(--t-say), 3.6vw, calc(56px * var(--layout-scale)));
  line-height: 1.06;
  letter-spacing: -.036em;
  text-wrap: balance;
}

/* Two lines, and the second is the turn — dim, so the eye takes them
   in order rather than as one long sentence. */
.sec__h--two {
  max-width: 26ch;
  margin-inline: auto;
  font-size: clamp(var(--t-figure), 3.1vw, calc(46px * var(--layout-scale)));
  line-height: 1.14;
}

.sec__h--two em { display: block; margin-top: var(--s4); font-style: normal; color: var(--text-3); }

/* ═══ 01 · Hero ═══════════════════════════════════════════════════
   AD, 2026-09-23: the line centred on the screen over a background
   image. The image is Vanta's own — frame 001 of the evolution
   sequence already on the live site. */
.hero {
  position: relative;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: var(--s11) var(--gutter) calc(var(--bar-h) + var(--s10));
  overflow: clip;
}

.hero__bg { position: absolute; inset: 0; z-index: 0; }

/* TWO LAYERS, PERFECTLY REGISTERED — same frame, same size, same
   object-fit, so they cannot drift apart. The scale is fixed at 1.06
   and exists only so `object-fit: cover` never exposes an edge; the
   30s drift is gone, because AD asked for the LIGHT to move and
   anything that moves the frame moves the bust with it. */
.hero__bg img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover; object-position: center 42%;
  transform: scale(1.06);
}

.hero__base { opacity: .62; }

/* 🔴 THE ONLY THING ON THIS PAGE THAT MOVES BY ITSELF.
   The ring alone, lifted from frame 001 by luminance ∩ annulus so
   the bust's own speculars stayed behind (2.29% of the frame). The
   base has it knocked back 55%, so this breathes it from dim to full
   instead of stacking a second light on a baked one.

   9s, mirrored easing, infinite alternate: long enough that it reads
   as breathing rather than as a pulse, and `alternate` means there is
   no seam to hide. Opacity only — the compositor carries it. */
.hero__ring {
  opacity: .30;
  animation: ringbreath 9s var(--ease-mirror) infinite alternate;
}

@keyframes ringbreath {
  from { opacity: .18; }
  to   { opacity: .92; }
}

@media (prefers-reduced-motion: reduce) {
  .hero__ring { animation: none; opacity: .55; }
}



/* 🔴 THE TYPE SITS ON THE BUST, so the bust has to give way under
   it — but only there. A flat overlay would kill the halo, which is
   the best thing in the frame. An ellipse centred on the headline
   darkens the middle and leaves the ring glowing around it. */
.hero::after {
  content: '';
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background:
    radial-gradient(ellipse 58% 34% at 50% 52%, rgba(0,0,0,.92) 0%, rgba(0,0,0,.6) 55%, transparent 80%),
    linear-gradient(to bottom, rgba(0,0,0,.55) 0%, transparent 22%, transparent 62%, rgba(0,0,0,.92) 96%);
}

.hero__h {
  position: relative;
  z-index: 2;
  margin: 0;
  max-width: 17ch;
  text-align: center;
  font-size: clamp(var(--t-d1), calc(5.4vw * var(--layout-scale)), calc(80px * var(--layout-scale)));
  line-height: .98;
  letter-spacing: -.042em;
}

/* ═══ 02 · The orb ════════════════════════════════════════════════
   AD: sources in a horizontal row, the orb beneath it, everything
   ending up inside it.

   🔴 IT NEEDS SCROLL RUNWAY. As an ordinary one-viewport band, all
   seven sources absorbed inside a single flick of the wheel —
   measured, every --t was already 1.00 by the time the section was
   on screen. An absorption nobody can see is a still image with extra
   code. The section is now two-and-a-half viewports tall with the
   contents pinned, so the intake happens AT READING SPEED.

   🔴 THE HEIGHT USED TO BE DECLARED HERE AND IT NEVER APPLIED.
   `.tall--orb` and `.tall` are both one class, and `.tall { height:
   300svh }` sits ~120 lines further down — same specificity, later
   wins. All four sections have always rendered at 300svh; these three
   modifiers were dead the day they were written, which is why the
   page never paced the way the comments describe.

   Removed rather than repaired: AD reviewed and approved the desktop
   at 300svh, so making them work now would change something he did
   not ask to change. Per-step pacing IS live on phone, declared
   after the base — see the 820px block. */

/* 🔴 SPECIFICITY, NOT SOURCE ORDER. This modifier lives in section
   02's block but the base `.tall__pin` is declared in section 03's,
   further down the file. Same specificity + later wins, so the base
   overrode the modifier and the orb section rendered as two columns
   with the heading pushed left and the row half off-screen.

   Written as a descendant it is (0,2,0) against the base's (0,1,0)
   and wins wherever either sits. A modifier that depends on being
   below its base is a landmine for the next edit. */
/* Marrow and choice are pinned too now: their reveals are earned by
   scrolling, so each needs runway — which `.tall` supplies. Their own
   height declarations were dead; see the note above `.tall--orb`. */

.tall--marrow .tall__pin {
  grid-template-columns: 1fr;
  justify-items: center;
  align-content: center;
  gap: var(--s7);
  padding-block: var(--s8);
}

/* AD, 2026-09-23 — REVERSED the same day: "text should be on left and
   the mobile should be on the right." The phone still shows only its
   bottom 30%; the sides are swapped.

   ⚠️ SWAPPED IN THE GRID, NOT IN THE MARKUP. The crop still comes
   first in the DOM because the widget swap, the crop mask and the
   1080px stack all read it as the first child. `order` moves the box
   and leaves the source — and therefore the reading and tab order —
   alone, which is what a purely visual swap should do. */
.tall--choice .tall__pin {
  grid-template-columns: minmax(0, 1fr) minmax(0, .9fr);
  align-items: center;
  gap: var(--s9);
}

.tall--choice .crop { order: 2; }

.tall--choice .choice__h { order: 1; }

.tall--orb .tall__pin {
  grid-template-columns: 1fr;
  justify-items: center;
  align-content: center;
  gap: var(--s6);
  /* ⚠️ WAS 76px AT THE TOP, RESERVED FOR THE FIXED NAV. The nav was
     removed 2026-09-23, so that reservation became 76px of dead space
     above every pinned heading — which is most of the gap AD flagged
     between the hero and this section. Symmetric now, so the pin
     actually centres. */
  padding-block: var(--s8);
}

.orb { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: var(--s7); }

/* Seven across. They shrink rather than wrap — a second row would
   stop the line reading as one intake. */
.orb__row {
  display: flex; align-items: flex-end; justify-content: center;
  gap: clamp(var(--s4), 3vw, var(--s9));
  width: 100%; min-height: clamp(62px, 7vw, 88px);
}

/* --t runs 0 → 1 with the scroll. The source falls toward the mark
   and shrinks into it. Only transform and opacity move, so the whole
   intake is composited. */
.orb__src {
  flex: 1 1 0; min-width: 0; max-width: clamp(44px, 6.4vw, 74px); margin: 0;
  display: flex; flex-direction: column; align-items: center; gap: var(--s2);
  /* 🔴 THEY CONVERGE, THEY DO NOT JUST FALL.
     Translating on Y alone left every source dropping straight down
     its own column and scattering past the mark — AD asked for them
     "displayed within it". --dx is each item's own distance to the
     centre, measured in JS, so the row collapses inward as it falls. */
  transform:
    translate(calc(var(--dx, 0px) * var(--t, 0)), calc(var(--drop, 330px) * var(--t, 0)))
    scale(calc(1 - var(--t, 0) * .82));
  opacity: calc(1 - var(--t, 0) * var(--t, 0));
  will-change: transform, opacity;
}

.orb__src img { display: block; width: 100%; height: auto; }

/* 🔴 NOT `nowrap`. The caption is as wide as its word, and the item
   is as wide as the row divides — so SUPPLEMENTS ran straight into
   NUTRITION and rendered as one collided string. Letting it wrap
   keeps every label inside its own column. */
.orb__src figcaption {
  color: var(--text-3);
  text-align: center;
  line-height: 1.25;
  max-width: 100%;
  opacity: calc(1 - var(--t, 0) * 2.4);
}

/* 🔴 AD, 2026-09-23: "reduced by 70%… I want a smaller orb."
   360px → 110px, which is 30% of what it was. At this size it reads
   as a MARK rather than as a graphic that owns the screen — which is
   what it is. One number to dial back if it is now too quiet. */
/* --charge is written by orb.js as the seven sources arrive. */
.orb__mark { position: relative; width: min(100%, 110px); aspect-ratio: 1; --charge: 0; }

/* mobius() builds its own <svg> at the measured size and injects it.
   The brightening is opacity ONLY, so the dots' own depth shading
   stays Prometheus's. */
.orb__mark > svg {
  /* The mark fills its box and is scaled by its viewBox — never by a
     pixel size written in at build time. See mountOrb() in orb.js. */
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: calc(.74 + var(--charge) * .26);
  transition: opacity .5s var(--ease);
}

/* ═══ 03 · The three words ════════════════════════════════════════
   A tall section with a sticky screen inside it, so one scroll
   advances one word. Nothing else is on this screen — AD's
   instruction, and the reason the section is worth three viewports. */
.tall { position: relative; height: 300svh; }

.tall__pin {
  position: sticky; top: 0; height: 100svh;
  max-width: var(--shell); margin-inline: auto; padding-inline: var(--gutter);
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  align-items: center; gap: var(--s9);
}

/* ─── The cropped phone ──────────────────────────────────────────
   The top 30% here, the bottom 30% in the next section. It is the
   SAME device cut twice — one object seen twice reads as a product,
   two half-phones read as two props. */
.crop { position: relative; justify-self: center; width: min(100%, clamp(250px, 27vw, 330px)); }

/* ═══ THE RAIL ════════════════════════════════════════════════════
   AD, 2026-09-23, with two reference frames — a top crop for §04 and
   a bottom crop for §05. What is different about them is not the
   crop, it is the EDGE: a bright rim-lit metal rail with the corner
   curves and the side buttons legible against black.

   That is also the answer to his previous note ("we should see the
   mobile"). The old shell's bezel was rgba(255,255,255,.10) — one
   tenth of white on a black page, effectively invisible — so the
   screen floated. A crop with a bright rail reads as a phone; a
   whole device with an invisible one does not.

   ⚠️ THIS IS NOT THE OBSIDIAN TRAP. The library bans chasing a 3D
   render in CSS because a caustic's shape derives from the form it
   reflects off, and a flat div has no form (lessons.md 2026-08-11).
   A phone rail is not a caustic — it is an extruded rounded
   rectangle lit from one side, and its highlight runs along a path
   we can state exactly. A conic-gradient IS that path.

   The stops are angular positions around the frame, so the rail is
   brightest where a real milled edge catches a key light — the two
   opposing corners — and darkest on the faces turned away. */
.crop__shell {
  position: relative;
  /* ⚠️ `display: flex` IS LOAD-BEARING. The shell gets its size from
     `min-height`, and a child asking for `height: 100%` against a
     min-height parent resolves to auto — so the glass came out 204px
     inside a 293px shell and 89px of bare conic gradient showed
     below the screen as a bright blob. Flex makes the glass stretch
     to the shell whatever the shell's height turns out to be. */
  display: flex;
  padding: calc(2.5px * var(--layout-scale) / .8);
  /* 🔴 LINEAR, NOT CONIC — second mechanism, and the reason is that
     a conic's stops are ANGLES, so on a tall narrow box the corners
     land at wildly different angles than on a square and the bright
     segments fall in the wrong places. The first build had a bright
     top-left and right edge with a dim top, which is not what a
     milled rail does. A linear sweep runs along the frame's length
     and behaves the same at any aspect ratio. */
  background: linear-gradient(158deg,
    rgba(255,255,255,.96)   0%,
    rgba(255,255,255,.42)  16%,
    rgba(255,255,255,.88)  38%,
    rgba(255,255,255,.30)  58%,
    rgba(255,255,255,.80)  78%,
    rgba(255,255,255,.44) 100%);
  box-shadow:
    0 34px 70px -28px rgba(0,0,0,.95),
    0 0 90px -34px rgba(255,255,255,.20);
  overflow: hidden;
}

/* The glass sits inside the rail. Its own radius is the rail's minus
   the rail's thickness, or the corners double up and read as a
   printed outline rather than as a machined edge. */
.crop__glass {
  position: relative;
  flex: 1; min-width: 0;
  display: flex; flex-direction: column;
  background: #040404;
  overflow: hidden;
}

/* The two buttons on the left edge and one on the right. Small, and
   they are most of why the silhouette reads as a phone rather than
   as a rounded rectangle — the reference has them and the old shell
   did not. */
.crop__btn {
  position: absolute; z-index: 2;
  width: calc(3px * var(--layout-scale) / .8);
  border-radius: var(--r-pill);
  background: linear-gradient(to right,
    rgba(255,255,255,.14), rgba(255,255,255,.80));
}

.crop__btn--vu { left: calc(-1px * var(--layout-scale) / .8); top: 20%;  height: 9%; }
.crop__btn--vd { left: calc(-1px * var(--layout-scale) / .8); top: 32%;  height: 9%; }

.crop__btn--pw {
  left: auto; right: calc(-1px * var(--layout-scale) / .8); top: 24%; height: 13%;
  background: linear-gradient(to left,
    rgba(255,255,255,.14), rgba(255,255,255,.80));
}

/* Feathered, not sliced: a hard edge reads as a crop, a feather reads
   as a window onto something larger. The mask is on the SHELL, so it
   feathers the rail too — feathering only the screen would leave a
   bright rail terminating in mid-air, which is worse than either. */
.crop--top .crop__shell {
  border-radius: calc(46px * var(--layout-scale) / .8) calc(46px * var(--layout-scale) / .8) 0 0;
  min-height: clamp(260px, 36svh, 360px);
  mask-image: linear-gradient(to bottom, #000 76%, transparent 100%);
}

.crop--top .crop__glass {
  border-radius: calc(44px * var(--layout-scale) / .8) calc(44px * var(--layout-scale) / .8) 0 0;
}

/* ⚠️ THE CONTENT INSET MOVED. The old shell padded 10px, which is
   what held the widget off the bezel; the rail only pads 2.5px, so
   when the bezel became a rail the widget went flush to the glass
   edge. The inset belongs to the SCREEN, not to the frame — which
   is also true of a real phone. */
.crop--low .crop__glass {
  border-radius: 0 0 calc(44px * var(--layout-scale) / .8) calc(44px * var(--layout-scale) / .8);
  justify-content: flex-end;
  padding: var(--s4) var(--s4) calc(18px * var(--layout-scale) / .8);
}

.crop--low .crop__shell {
  border-radius: 0 0 calc(46px * var(--layout-scale) / .8) calc(46px * var(--layout-scale) / .8);
  min-height: clamp(210px, 29svh, 290px);
  display: flex; flex-direction: column; justify-content: flex-end;
  mask-image: linear-gradient(to top, #000 76%, transparent 100%);
}

.crop__notch {
  position: absolute; left: 50%; top: calc(9px * var(--layout-scale) / .8);
  width: 30%; height: calc(18px * var(--layout-scale) / .8);
  transform: translateX(-50%); border-radius: var(--r-pill); background: #000;
}

.crop__bar { width: 34%; height: 3px; margin: var(--s4) auto 0; border-radius: var(--r-pill); background: var(--fill-3); }

/* ─── The lock screen, and the notifications on it ───────────────
   AD, 2026-09-23: the phone holds still and only the notifications
   change. So everything below is FIXED except .tile's opacity. */
/* ⚠️ THE SCREEN NEEDS ITS OWN TONE. At #050505 on a #000 page the
   device did not read as a device — the bezel, the screen and the
   background were the same black and only the notification floated.
   A very faint achromatic wallpaper is what separates them. It is
   luminance, not hue: 6% white at the top, nothing at the bottom. */
/* The screen fills the device and the notification sits at the TOP
   of it, which is where iOS puts one — leaving the rest of the
   screen empty is what makes the device read as a device with room
   in it rather than as a box sized to its contents. */
/* The screen inside the top crop. The notification sits at the top,
   which is where iOS puts one, and the rest stays empty — the empty
   screen below it is what makes the crop read as a window onto a
   phone rather than as a box sized to its contents. */
.scr {
  position: relative;
  flex: 1; min-height: 0;
  display: flex; flex-direction: column; gap: var(--s5);
  padding: calc(34px * var(--layout-scale) / .8) var(--s4) var(--s4);
  background:
    radial-gradient(120% 70% at 50% -6%, rgba(255,255,255,.075) 0%, transparent 62%),
    linear-gradient(to bottom, rgba(255,255,255,.03), transparent 66%);
}

.scr__bar {
  display: flex; align-items: center; justify-content: space-between;
  padding: 0 var(--s3) var(--s6);
  color: var(--text-2);
}

.scr__time { font-size: calc(12px * var(--layout-scale) / .8); letter-spacing: -.01em; color: var(--text); }

.scr__sys { display: flex; align-items: center; gap: var(--s3); }

/* Three rising bars. `align-items: flex-end` is what makes them rise
   rather than hang. */
.scr__sig { display: flex; align-items: flex-end; gap: 1.5px; height: 9px; }

.scr__sig b { width: 2px; background: var(--text-2); border-radius: 1px; }
.scr__sig b:nth-child(1) { height: 45%; }
.scr__sig b:nth-child(2) { height: 72%; }
.scr__sig b:nth-child(3) { height: 100%; }

.scr__bat {
  position: relative; width: 16px; height: 9px;
  border: 1px solid var(--text-3); border-radius: 2px;
}

/* The cell, then the terminal nub. */
.scr__bat::before {
  content: ''; position: absolute; inset: 1.5px; right: 5px;
  background: var(--text-2); border-radius: 1px;
}

.scr__bat::after {
  content: ''; position: absolute; left: 100%; top: 50%;
  width: 1.5px; height: 3.5px; margin-left: 1px;
  transform: translateY(-50%);
  background: var(--text-3); border-radius: 0 1px 1px 0;
}

/* 🔴 ONE CELL, THREE GROUPS STACKED IN IT. The grid is what keeps the
   screen's height identical across the three steps — the cell is as
   tall as the TALLEST group, always, so nothing moves when the copy
   changes length or when a step carries two cards instead of one.
   Absolute positioning would have done the same but would size the
   cell to nothing.

   ⚠️ THE GROUP, NOT THE CARD, IS WHAT GRID-STACKS. Social carries two
   notifications (AD, 2026-09-23), so the unit that overlays its
   siblings has to be the step, with its own cards flowing normally
   inside it. Stacking the cards directly put both Social cards on top
   of each other. */
.tiles { display: grid; }

.tileg {
  grid-area: 1 / 1;
  display: flex; flex-direction: column; gap: var(--s3);
  opacity: 0;
  transition: opacity var(--slow) var(--ease);
}

.tileg.is-on { opacity: 1; }

/* 🔴 A HOME-SCREEN WIDGET, NOT A NOTIFICATION — AD, 2026-09-23:
   "these shouldn't look like Vanta notifications but rather a widget
   on the home screen - the logo should be the particle orb - but not
   moving."

   What changes, and each is what actually separates the two surfaces
   on iOS:
     · no app-name row and NO TIMESTAMP. A notification is an event,
       so it is stamped; a widget is a standing surface and is not.
     · a much larger corner radius — iOS widgets are ~22pt, and the
       proportion is most of the tell.
     · the mark sits IN the tile as the widget's owner, not as a 14px
       chip beside a name.
     · more padding: the tile is the content's frame, not a strip
       pushed onto the screen. */
.tile {
  position: relative;
  display: flex; flex-direction: column; gap: var(--s2);
  padding: var(--s5);
  border-radius: calc(22px * var(--layout-scale) / .8);
  background: rgba(255,255,255,.055);
  box-shadow: inset 0 0 0 1px var(--line);
}

/* Same rule header as the widgets, so the lock screen and the app
   read as one product. The hairline takes the slack between the app
   name and the timestamp. */
.tile__app { display: flex; align-items: center; gap: var(--s3); color: var(--text-3); }

.tile__rule { flex: 1; height: 1px; background: var(--line); }

/* 🔑 THE REAL MARK, FROZEN. It was a rounded square with a ring
   knocked out of it — a stand-in, on the reasoning that a Möbius is
   illegible at 14px. AD: "the logo should be the particle orb - but
   not moving."

   So it is the actual shared mobius(), rendered once and then frozen
   — see mountStaticMark() in orb.js. Bigger than the old chip,
   because a dot cloud needs the room, and top-right so it reads as
   the widget's owner rather than as a bullet before the text. */
.tile__mark {
  position: absolute;
  top: var(--s5); right: var(--s5);
  /* 36px is mobius()'s NATIVE size — the density, the dot radii and
     the glow std are all tuned against it. At 26px the ribbon's own
     radius is only 8px and 600 dots collapse into a grey smudge,
     which is the exact illegibility the old stand-in chip existed to
     avoid. At native size it reads as the cloud it is. */
  width: calc(36px * var(--layout-scale) / .8);
  height: calc(36px * var(--layout-scale) / .8);
  opacity: .92;
  pointer-events: none;
}

.tile__mark svg { display: block; }


.tile__app b { font-weight: inherit; flex: none; }

/* ─── The trend block ───────────────────────────────────────────
   AD's Twin and Labs screens pair a big number with a sparkline and
   a delta. Reproduced here, in the one notification whose own copy
   states a level.

   ⚠️ `preserveAspectRatio: none` STRETCHES THE STROKE with the box —
   a 1px line becomes a 3px wedge at this aspect. `vector-effect:
   non-scaling-stroke` on the polyline is what keeps it hairline, and
   it is on the markup because it is a presentation attribute the
   renderer needs, not a style choice. */
.trends { display: flex; flex-direction: column; gap: var(--s5); padding: var(--s5) 0 var(--s4); }

/* 🔴 REBUILT TWICE, AND THE SECOND TIME WAS A REAL BUG NOT A TASTE
   CALL. AD: "extremely pathetic" — and the render deserved it. I had
   the chart `position: absolute` with `z-index: -1` so the figure
   could sit on top of it. Two things broke:

     · it was taller than its row, so it overflowed upward and drew
       straight through the marker name and the number
     · z-index:-1 put it behind .tile's own background, so the area
       fill never painted at all and only the stroke escaped

   The lesson is the one I keep relearning: I reached for overlap to
   make it look richer, and overlap is where collisions live. The
   chart now has its OWN grid cell. It cannot cross anything, the
   fill paints, and it is bigger than the thumbnail version ever was.

     k  f        name and flag
     v  g        figure, and the trend beside it */
.trend {
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-areas:
    "k f"
    "v g";
  align-items: center;
  gap: var(--s2) var(--s5);
}

.trend__k { grid-area: k; color: var(--text-3); }

.trend__f { grid-area: f; justify-self: end; font-style: normal; color: var(--warn); }

.trend__v {
  grid-area: v;
  display: flex; align-items: baseline; gap: var(--s2);
  font-size: calc(24px * var(--layout-scale) / .8);
  letter-spacing: -.035em; line-height: 1;
  color: var(--text);
}

.trend__v em {
  font-style: normal;
  font-size: calc(10px * var(--layout-scale) / .8);
  letter-spacing: 0; color: var(--text-3);
}

/* 🔴 RENAMED OUT OF THE LIBRARY'S NAMESPACE. This was `.spark`, and
   ui/elements.css:537 owns `.spark path { fill: none }` — CSS beats a
   presentation attribute, so the library silently killed the
   `fill="url(#sgA)"` on every area path, and its own `.spark` box
   rules fought the grid. That is the NINTH collision found on this
   page, and page.css's own header says to check the namespace before
   adding a class. I did not. `.wave` was the tenth, found in the same
   sweep — ui/objects.css owns that too.

   The check is one command and it is now in the README:
     intersect the classes in index.html against every `.name` in ui/*.css

   ⚠️ `color`, NOT `fill`, drives this: the gradient stops use
   `stop-color: currentColor`, so stroke and fill inherit one value.
   `preserveAspectRatio: none` stretches the stroke with the box,
   which is what non-scaling-stroke on the polyline is for. */
.trend__g {
  grid-area: g;
  width: 100%;
  height: calc(32px * var(--layout-scale) / .8);
  color: var(--warn);
  display: block;
}

.trend__g polyline {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}





/* The heading keeps clear of the mark; the body runs full width,
   because the mark is only as tall as the first line or two. */
.tile__h {
  margin: 0;
  padding-right: calc(44px * var(--layout-scale) / .8);
  font-size: calc(13px * var(--layout-scale) / .8);
  line-height: 1.25; letter-spacing: -.015em; color: var(--text);
}

/* ─── The eight legs ────────────────────────────────────────────
   One bar per Hyrox station, drawn from a zero line. --d is the
   split delta against the rival, −1…1; positive is time LOST.

   ⚠️ THE ZERO LINE IS THE WHOLE POINT, so the bars grow from the
   middle rather than from the bottom. A bar chart with no baseline
   cannot show "faster" at all, and half of these are faster —
   without it the widget would claim every station was a loss. */
.legs { padding: var(--s4) 0 var(--s3); }

.legs__g {
  margin: 0; padding: 0; list-style: none;
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  gap: calc(4px * var(--layout-scale) / .8);
  align-items: end;
}

.legs__g li {
  display: flex; flex-direction: column; align-items: center;
  gap: calc(5px * var(--layout-scale) / .8);
}

/* The track is a fixed height with the zero line through its middle;
   the bar is positioned off that line in whichever direction. */
.legs__g i {
  position: relative;
  width: 100%;
  height: calc(34px * var(--layout-scale) / .8);
  border-radius: 2px;
  background:
    linear-gradient(var(--text-3), var(--text-3)) 0 50% / 100% 1px no-repeat;
}

.legs__g i::after {
  content: '';
  position: absolute; left: 0; right: 0;
  /* lost → grows up from the line; gained → grows down from it */
  top: calc(50% - max(0px, var(--d) * 17px * var(--layout-scale) / .8));
  /* ⚠️ --m IS THE MAGNITUDE, WRITTEN IN THE MARKUP. CSS abs() would
     be the obvious way to get it from --d, and it works in current
     Chrome — but it is new enough that an older Safari drops the
     whole declaration and every bar renders at zero height, which is
     a chart that silently shows nothing. Two custom properties cost
     nothing and work everywhere. */
  height: calc(var(--m) * 17px * var(--layout-scale) / .8);
  border-radius: 1.5px;
  background: var(--text-2);
}

/* The two stations that account for the gap. */
.legs__bad i::after { background: var(--warn); }

.legs__g .label { color: var(--text-3); font-size: calc(7.5px * var(--layout-scale) / .8); }

/* ─── Continuity ────────────────────────────────────────────────
   A dated schedule, not a texture. The first version drew 18
   anonymous ticks on a track — AD: "I don't understand the purpose of
   the line with vertical separators", and he was right: it encoded
   the count and lost every one of the changes. Three dated rows with
   a spine through them say longitudinal AND stay readable, and the
   last row is in the future, which is what makes it a schedule rather
   than a log. */
.cont { display: flex; flex-direction: column; gap: var(--s4); padding: var(--s4) 0 var(--s2); }

.tl { margin: 0; padding: 0 0 0 var(--s5); list-style: none; position: relative; display: flex; flex-direction: column; gap: var(--s4); }

/* The spine. Inset so it runs through the dots, and stopped short at
   both ends so it reads as a continuing line rather than a bracket. */
.tl::before {
  content: '';
  position: absolute;
  left: calc(3px * var(--layout-scale) / .8);
  top: .45em; bottom: .45em;
  width: 1px;
  background: var(--line-bright);
}

.tl li {
  position: relative;
  display: flex; align-items: baseline; gap: var(--s4);
  font-size: calc(11.5px * var(--layout-scale) / .8);
  letter-spacing: -.01em;
  color: var(--text);
}

.tl li::before {
  content: '';
  position: absolute;
  left: calc(-1 * var(--s5) + 1px); top: .42em;
  width: calc(5px * var(--layout-scale) / .8);
  height: calc(5px * var(--layout-scale) / .8);
  border-radius: 50%;
  background: var(--text-2);
}

/* The one that has not happened yet: hollow, so the eye reads the
   schedule's direction without a legend. */
.tl__ahead::before { background: var(--bg); box-shadow: inset 0 0 0 1px var(--text-2); }

.tl__ahead .tl__x { color: var(--text-2); }

/* ⚠️ 62px, NOT 46. At 46 the mono uppercase wrapped — "3 WEEKS" and
   "NEXT THU" both broke onto two lines and pushed their rows out of
   alignment. `white-space: nowrap` as well, so a longer label
   overflows visibly rather than silently re-breaking the row. */
.tl__d {
  flex: none;
  width: calc(62px * var(--layout-scale) / .8);
  white-space: nowrap;
  color: var(--text-3);
}

.tl__x { min-width: 0; }

.tile__b {
  margin: 0;
  font-size: calc(11.5px * var(--layout-scale) / .8);
  line-height: 1.4; color: var(--text-2);
}

/* ─── The words ──────────────────────────────────────────────────
   Only the live one is lit. The other two stay on screen, because a
   word that appears from nothing gives no sense of how many are
   left. */
/* 🔴 ONE WORD, not three with two dimmed. AD's reference shows a
   single word on the right and nothing else, so the others leave
   rather than recede — they are stacked in the same cell so the live
   one is always centred against the phone. */
.words { margin: 0; padding: 0; list-style: none; display: grid; }

.word {
  grid-area: 1 / 1;
  align-self: center;
  font-size: clamp(var(--t-figure), 4.4vw, calc(64px * var(--layout-scale)));
  line-height: 1.04; letter-spacing: -.038em; color: var(--text);
  opacity: 0; transform: translateY(10px);
  transition: opacity var(--slow) var(--ease), transform var(--slow) var(--ease);
}

/* The lead-in is dim and much smaller — it is the part that does not
   change, so it must not compete with the part that does. */
.word span {
  display: block;
  margin-bottom: var(--s2);
  font-size: clamp(var(--t-row), 1.5vw, calc(26px * var(--layout-scale)));
  letter-spacing: -.02em;
  color: var(--text-3);
}

.word.is-on { opacity: 1; transform: none; }

/* ═══ The notification ════════════════════════════════════════════ */
.note {
  display: flex; align-items: flex-start; gap: var(--s3);
  padding: var(--s3) var(--s4);
  border-radius: var(--r-inner);
  background: rgba(22,22,22,.86);
  box-shadow: inset 0 1px 0 0 var(--line-bright), inset 0 0 0 1px var(--line);
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
  opacity: 0; transform: translateY(-10px) scale(.97);
  transition: opacity .55s var(--ease), transform .7s var(--ease);
}

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

.note__ic { flex: none; display: inline-flex; color: var(--text-2); padding-top: 1px; }

.note b { display: block; font-weight: inherit; font-size: calc(11px * var(--layout-scale) / .8); color: var(--text); letter-spacing: -.01em; }

.note span { display: block; font-size: calc(9.5px * var(--layout-scale) / .8); line-height: 1.35; color: var(--text-3); }

.note--big { position: relative; padding: var(--s4) var(--s6); border-radius: var(--r-pill); opacity: 1; transform: none; }

.note--big b { font-size: var(--t-row); }

.note--big span { font-size: var(--t-meta); }

/* ═══ 04 · Simple and default ═════════════════════════════════════
   Two columns: the claim, and the device it lives on. */
.band--choice { grid-template-columns: minmax(0, 1fr) minmax(0, .9fr); justify-items: stretch; gap: var(--s9); }

.band--choice .band__c { justify-self: end; max-width: 34ch; }

.band--choice .crop { justify-self: start; }

/* Two sizes in one heading, and the second carries the emphasis. The
   split is the point — "simple" is what is promised, "& default" is
   what makes it survive a bad week. */
.choice__h {
  margin: 0;
  font-size: clamp(var(--t-title), 2.1vw, calc(30px * var(--layout-scale)));
  line-height: 1.2;
  /* ⚠️ nowrap is held to desktop only. At 390px it forced the line
     217px wide inside a 358px column with the block centred, so it
     started at x = -39 and put 119px of horizontal scroll on the
     page. A heading that will not wrap breaks the page instead. */
  white-space: nowrap; letter-spacing: -.028em; color: var(--text);
}

.choice__big {
  display: block; margin-top: var(--s2);
  font-size: clamp(var(--t-figure), 4.2vw, calc(62px * var(--layout-scale)));
  line-height: 1.02; letter-spacing: -.04em;
}

/* Two stages: "simple" arrives, and only a further scroll brings
   "& default". They share one line, so the second lands as the
   sentence finishing rather than as a new sentence starting. */
.choice__big em {
  font-style: normal;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity .6s var(--ease), transform .8s var(--ease);
}

.choice__big em[data-step="0"] { color: var(--text); }

/* 🔴 FULL WHITE — AD, 2026-09-23. It was --text-3, on the reasoning
   that a dimmer second half makes the eye read the two in order. But
   the order is already carried by the SCROLL: "& default" does not
   exist until you have earned it, so it needs no second device to say
   it comes last — and arriving grey reads as arriving unfinished.
   Both halves are the claim. */
.choice__big em[data-step="1"] { color: var(--text); margin-left: .28em; }

.choice__big em.is-on { opacity: 1; transform: none; }

/* ─── The streak widget ──────────────────────────────────────── */
.streak { display: flex; flex-direction: column; gap: var(--s4); padding: var(--s5) var(--s4) 0; }

/* A tick per day. Drawn with repeating-linear-gradient rather than 66
   elements — the bar is one object and the DOM should say so. */
.streak__bar {
  height: calc(26px * var(--layout-scale) / .8);
  background: repeating-linear-gradient(to right,
    var(--fill-3) 0 2px, transparent 2px 5px);
  mask-image: linear-gradient(to right, #000 86%, transparent 100%);
}

.streak__meta { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }

.streak__day { font-size: calc(13px * var(--layout-scale) / .8); letter-spacing: -.01em; color: var(--text); }

.streak__meta .label { color: var(--text-3); }

.streak__foot { display: flex; align-items: center; justify-content: space-between; padding-top: var(--s3); }

.streak__chip {
  display: inline-flex; align-items: center; gap: var(--s2);
  padding: 3px 8px; border-radius: var(--r-pill);
  box-shadow: inset 0 0 0 1px var(--line);
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: var(--tr-label); color: var(--text-2);
}

.streak__chip i { width: 8px; height: 8px; border-radius: 50%; box-shadow: inset 0 0 0 1.4px var(--line-bright); }

/* The Möbius mark, as a ring rather than a fifth canvas. */
.streak__mark {
  width: 16px; height: 16px; border-radius: 50%;
  box-shadow: inset 0 0 0 1.4px var(--line-bright);
  background: radial-gradient(circle at 34% 30%, rgba(255,255,255,.26), transparent 62%);
}

.wid {
  flex: 1; display: flex; flex-direction: column; gap: var(--s3);
  padding: var(--s5); border-radius: var(--r-inner);
  background: var(--fill-1); box-shadow: inset 0 0 0 1px var(--line);
}

/* ─── The widget, in the app's own language ─────────────────────
   Taken from AD's four screen references (Fuel · Protocol · Labs ·
   Twin, 2026-09-23). The constants across all four are what is
   reproduced here; the accent colours in them are not (this page is
   monochrome — see the note in index.html). */
.wid__k { color: var(--text-3); }

/* A title with a full stop, as on "Fuel." and "Twin.". The stop is
   the whole device: it makes a noun read as a statement. */
.wid__t {
  margin: 0;
  font-size: calc(26px * var(--layout-scale) / .8);
  letter-spacing: -.035em; line-height: 1.05;
  color: var(--text);
}

.wid__h {
  margin: 0;
  font-size: calc(15px * var(--layout-scale) / .8);
  line-height: 1.3; letter-spacing: -.02em;
  color: var(--text);
}

.wid__b { margin: 0; font-size: calc(11px * var(--layout-scale) / .8); line-height: 1.45; color: var(--text-2); }

/* 🔑 THE RULE HEADER — the single most repeated element in the four
   references: a mono uppercase label, a hairline that takes up the
   slack, and a right-aligned counter. "TODAY ———— 0 OF 7 DONE",
   "MEALS ———— 2 LOGGED", "BIOMARKERS ———— 41 TRACKED".

   The rule is a flex child with flex:1, so it stretches to whatever
   the two labels leave — which is why it works at any width without
   a single magic number. */
.rhead { display: flex; align-items: center; gap: var(--s4); }

.rhead .label { color: var(--text-3); flex: none; }

.rhead i { flex: 1; height: 1px; background: var(--line); }

/* Checklist rows: hollow circle, name. The circle is hollow on
   purpose — every row in the reference's TODAY list is unchecked,
   which is what makes it read as the day ahead rather than a report. */
.task { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: var(--s3); }

/* Name left, spec right, as every row in AD's Protocol and Fuel
   screens does it. The spec is what makes the row informative rather
   than a label — "Training" says nothing, "Training · push
   hypertrophy · 70 min" is the day. */
.task li {
  display: flex; align-items: baseline; gap: var(--s4);
  font-size: calc(12.5px * var(--layout-scale) / .8);
  letter-spacing: -.012em;
  color: var(--text);
}

.task li em {
  margin-left: auto;
  font-style: normal;
  font-size: calc(10.5px * var(--layout-scale) / .8);
  letter-spacing: -.005em;
  color: var(--text-3);
  text-align: right;
}

.task__o {
  flex: none;
  width: calc(13px * var(--layout-scale) / .8);
  height: calc(13px * var(--layout-scale) / .8);
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--line-bright);
}

/* Without the circle the rows need their own indent, or they sit
   flush and read as a paragraph rather than a list. */
.task--plain li { color: var(--text-2); padding-left: calc(2px * var(--layout-scale) / .8); }

.wid__bar { margin-top: auto; height: 3px; border-radius: var(--r-pill); background: var(--fill-2); overflow: hidden; }

.wid__bar i { display: block; height: 100%; width: var(--w, 30%); background: var(--inverse); }

/* ═══ 05 · Marrow ═════════════════════════════════════════════════
   AD's reference, 2026-09-22: two cards a side, the score turning
   between them. */
.band--marrow { gap: var(--s8); }

.mrx {
  position: relative;
  width: min(100%, 1120px);
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center;
  gap: clamp(var(--s5), 2.4vw, var(--s9));
}

.mrx__side { display: flex; flex-direction: column; }

/* The pair overlaps, and the overlap is deliberate — two cards with a
   gap between them read as a list of two, two that overlap read as a
   stack you are partway through. */
.mrx__side--l { align-items: flex-end; }

.mrx__side--r { align-items: flex-start; }

/* 🔴 THE OVERLAP MUST COVER PADDING, NEVER TEXT.
   At -s7 the front card sat on the back one's reply line and ate it:
   "Third short night. Tomorrow drops to Zone 2." was half a sentence
   with a card on top of the rest. The back card now carries extra
   bottom padding exactly equal to the overlap, so what is hidden is
   dead space and the cascade still reads. */
.mrx__side .ex:first-child { padding-bottom: calc(var(--s5) + var(--s7)); }

.mrx__side--l .ex:last-child { margin-top: calc(var(--s7) * -1); margin-right: calc(var(--s8) * -1); }

.mrx__side--r .ex:last-child { margin-top: calc(var(--s7) * -1); margin-left: calc(var(--s8) * -1); }

.mr { position: relative; width: clamp(250px, 27vw, 400px); aspect-ratio: 1; }

.mr canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }

.mr__labels { position: absolute; inset: 0; pointer-events: none; }

.mr__tag {
  position: absolute; left: 0; top: 0;
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: var(--tr-label); text-transform: uppercase;
  color: var(--text-2); white-space: nowrap;
}

/* ─── An exchange ────────────────────────────────────────────────
   A card, not a bubble pair floating in space. Each carries what you
   sent and what came back, because an interaction needs two sides —
   a single line would be a statement. */
/* Revealed one at a time by the scroll stepper — keyed to the card
   itself, not to the section, because they now arrive individually.
   The section-level `.is-in` it used to depend on belonged to a
   `.band--marrow` that no longer exists. */
.ex.is-in { opacity: 1; transform: none; }

.ex {
  position: relative;
  width: min(100%, 272px);
  display: flex; flex-direction: column; gap: var(--s3);
  padding: var(--s5);
  border-radius: var(--r-outer);
  background:
    linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.008) 46%, rgba(0,0,0,.36)),
    rgba(12,12,12,.72);
  box-shadow:
    inset 0 1px 0 0 var(--line-bright),
    inset 0 0 0 1px var(--line),
    0 26px 52px -26px #000;
  backdrop-filter: blur(18px) saturate(140%);
  -webkit-backdrop-filter: blur(18px) saturate(140%);
  opacity: 0; transform: translateY(12px);
  transition: opacity .8s var(--ease), transform 1s var(--ease);
}

/* 🔵 BLUE IS ONLY WHAT THE CUSTOMER SENDS — AD, 2026-09-23.
   One class was doing two jobs: the member's message AND Vanta's own
   artifact chips (Photo, Stack, Recovery). Colouring it turned the
   chips blue too, so they read as labels rather than as anything
   sent, and the asymmetry that makes iMessage legible at a glance —
   one side is a decision, the other is an answer — was gone.

   .ex__me   what the member sends. Blue.
   ⚠️ .ex__chip is GONE. AD removed both Vanta chips on 2026-09-23
   ("Remove 'Recovery' 'Stack'"), so the class had no call site left
   and an unused class is a class someone reuses wrongly later. */
.ex__me {
  align-self: flex-end;
  display: inline-flex; align-items: center; gap: var(--s3);
  padding: var(--s3) var(--s5); border-radius: var(--r-pill);
  background: var(--send); color: #fff;
  font-size: var(--t-meta); line-height: 1.4;
}

/* A photo the member sent. It sits where the blue bubble sat and
   keeps the same alignment, because an attachment is still a message
   — it is just one with no words in it. */
.ex__img {
  align-self: flex-end;
  display: block;
  width: min(100%, calc(150px * var(--layout-scale) / .8));
  height: auto;
  border-radius: var(--r-inner);
  box-shadow: inset 0 0 0 1px var(--line);
}

.ex__v { font-size: var(--t-meta); line-height: 1.5; color: var(--text-2); }

/* ─── The in-chat widget ────────────────────────────────────────
   AD, 2026-09-23: "there are widgets that let you interact with the
   chat. Those are what you need to showcase here." His own reference
   is the Replace-session card — a titled block, rows of
   before/after, and two buttons.

   Every exchange now ends in one of these rather than in prose,
   because the claim of the section is that the OS *acts*. A chat
   that only talks is a chatbot; a chat that logs a session, swaps
   tonight's training and orders the stack is an operating system.

   ⚠️ THE BUTTONS ARE tabindex="-1" AND THE WHOLE FLANK IS
   aria-hidden. They are a depiction of the product, not controls —
   and a control that looks live and does nothing is the 2026-08-11
   failure. Nothing here is focusable or clickable. */
.log {
  margin-top: var(--s2);
  display: flex; flex-direction: column; gap: var(--s3);
  padding: var(--s4);
  border-radius: var(--r-inner);
  background: rgba(255,255,255,.035);
  box-shadow: inset 0 0 0 1px var(--line);
}

.log__l { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: var(--s3); }

.log__l li,
.log__row {
  display: flex; align-items: baseline; gap: var(--s3);
  font-size: calc(11.5px * var(--layout-scale) / .8);
  letter-spacing: -.01em;
  color: var(--text);
}

/* ⚠️ nowrap ON THE SPEC, and it is safe because the spec is short and
   the NAME is what absorbs a narrow column. Without it "3×15 · 60,
   70, 80 kg" broke after the last number and dropped a lone "kg"
   onto its own line, which reads as a layout fault rather than as a
   wrap. */
.log__l li em,
.log__row em {
  margin-left: auto;
  flex: none;
  white-space: nowrap;
  font-style: normal;
  color: var(--text-3);
  text-align: right;
}

/* The struck-through half of a swap, exactly as the reference draws
   it: the old session stays visible so the change is legible as a
   change rather than as a new instruction. */
.log__row s { color: var(--text-3); text-decoration-thickness: 1px; margin-right: .4em; }

/* The one badge on the page. A PR is the single fact in this section
   worth colouring, and green is `optimal` in app.js:87. */
.log__pr {
  display: inline-block;
  margin-left: var(--s2);
  padding: 1px calc(5px * var(--layout-scale) / .8);
  border-radius: var(--r-xs);
  background: color-mix(in srgb, var(--ok) 20%, transparent);
  color: var(--ok);
  font-style: normal;
}

.log__note {
  margin: 0;
  font-size: calc(10.5px * var(--layout-scale) / .8);
  line-height: 1.45; color: var(--text-3);
}

.act { display: flex; gap: var(--s3); padding-top: var(--s2); }

.act__b {
  flex: 1;
  padding: calc(7px * var(--layout-scale) / .8) var(--s4);
  border: 0; border-radius: var(--r-pill);
  background: var(--fill-2); color: var(--text-2);
  box-shadow: inset 0 0 0 1px var(--line);
  font-family: inherit; font-size: calc(11px * var(--layout-scale) / .8);
  letter-spacing: -.01em;
  cursor: default;
}

.act__b--y { background: var(--inverse); color: var(--text-inverse); box-shadow: none; }

/* A single button that REPORTS rather than asks takes the full width
   — a lone pill sitting half-width reads as the other half having
   gone missing. */
.act__b--wide { flex: 1 0 100%; }

/* A voice note has to look like one, and the bars have to move or it
   is a picture of a voice note. */
.vwav { display: inline-flex; align-items: center; gap: 2px; height: 14px; }

/* 🔵 "the interface and voice notes are mostly blue" — AD. The vwav
   sits inside .ex__me, which is already the blue bubble, so the bars
   are white-on-blue rather than blue-on-black: a blue waveform on a
   blue bubble is invisible, and on black it would be a second,
   competing blue. currentColor does the right thing in both places
   without a second rule. */
.vwav b { width: 2px; border-radius: 1px; background: currentColor; height: 40%; animation: wv 1.1s var(--ease) infinite; }

.vwav b:nth-child(2) { animation-delay: .08s; }

.vwav b:nth-child(3) { animation-delay: .16s; }

.vwav b:nth-child(4) { animation-delay: .24s; }

.vwav b:nth-child(5) { animation-delay: .32s; }

.vwav b:nth-child(6) { animation-delay: .40s; }

.vwav b:nth-child(7) { animation-delay: .48s; }

.ex__me em { font-style: normal; font-family: var(--font-mono); font-size: var(--t-label); letter-spacing: var(--tr-label); opacity: .7; }

@keyframes wv {
  0%, 100% { height: 30%; }
  50%      { height: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .vwav b { animation: none; height: 60%; }
}

/* ═══ 06 · The few, and the ask ═══════════════════════════════════
   🔴 THIS BLOCK DID NOT EXIST. The section shipped rendered but
   unstyled: `.ask`, `.ask__row`, `.ask__go`, `.ask__done`, `.few__sub`
   and `.badge` had no rule anywhere, so the three inputs sat at the
   browser's default ~150px and the button hung off to the left. The
   only `.ask__row` on disk was inside the 1080px media query — a
   `grid-template-columns` override on an element that was never a
   grid. An override with no base is the tell.

   AD, 2026-09-23: "a simple panel which basically grabs their name,
   email ID, and country. When they click 'Send me an invite', we
   shift it into the 'Download on App Store' icon with 'Use invite
   code'." So it is ONE box that changes state, not two stacked. */
.few__sub {
  margin: 0;
  max-width: 34ch;
  text-align: center;
  font-size: var(--t-row);
  line-height: 1.5;
  color: var(--text-2);
}

/* ─── The member card ───────────────────────────────────────────
   A small object resting above the panel. Built from four layers,
   each doing one job — which is the difference between this and the
   obsidian attempts, where one layer was asked to be a whole
   material:

     base    the card itself, near-black with a hairline rim
     ember   a warm off-centre glow, the only hue in the section
     sheen   a hard diagonal band, the reflection of a light source
     type    wordmark, role, number

   ⚠️ THE EMBER IS THE ONLY COLOUR HERE and it is well under the
   threshold where the section stops reading as monochrome — it is a
   glow inside an object, not a tint on the page. */
/* ─── The member card ───────────────────────────────────────────
   The real asset from the deck, at the size the deck draws it.
   Measured on a local copy of /tldr rather than guessed: 27 × 44.

   The CSS reconstruction that was here — base, ember, sheen, type —
   is gone. It was a decent likeness and it was still a likeness;
   the actual card existed the whole time. */
.mcard {
  display: block;
  width: calc(27px * var(--layout-scale) / .8);
  height: auto;
  margin-bottom: var(--s3);
  transform: rotate(-4deg);
}

/* The panel. Solid card, not glass — the page already spends its one
   glass moment on the composer dock, and the material budget is one
   per screen. */
.ask {
  width: min(100%, 460px);
  padding: var(--s6);
  border-radius: var(--r-card);
  background: var(--fill-1);
  box-shadow: inset 0 0 0 1px var(--line);
}

.ask__form,
.ask__done { display: flex; flex-direction: column; gap: var(--s5); }

/* Name and email share a line; country takes its own, because a
   country field at half width invites a two-letter code and the
   label says Country. */
/* Name across the top, then email and country sharing a line with
   country the narrower of the two — a country field at half width
   invites a two-letter code, and it is a select now anyway. */
.ask__row { display: grid; grid-template-columns: 1fr .62fr; gap: var(--s4); }

.fld--wide { grid-column: 1 / -1; }

/* ⚠️ An input is replaced-ish: it takes its width from the `size`
   attribute, NOT from its flex parent, so `.fld { display:flex }`
   alone leaves it at ~150px. This is the line that was missing.
   A <select> has the same problem for the same reason. */
.ask .fld input,
.ask .fld select { width: 100%; min-width: 0; }

/* The select matches the inputs exactly — same height, same fill,
   same inset hairline — and draws its own chevron, because the
   native one is a different grey on every platform. */
/* ⚠️ SCOPED TO BOTH FORMS. It was `.ask .fld select` only, and the
   slide-out panel has the same field — a rule scoped to one call
   site is how two surfaces of the same form drift apart. */
.ask .fld select {
  appearance: none; -webkit-appearance: none;
  height: calc(40px * var(--layout-scale) / .8);
  padding-inline: var(--s4) var(--s7);
  border: 0; border-radius: var(--r-inner);
  background-color: var(--fill-1);
  background-image:
    linear-gradient(45deg, transparent 50%, var(--text-3) 50%),
    linear-gradient(135deg, var(--text-3) 50%, transparent 50%);
  background-position: right var(--s5) center, right calc(var(--s5) - 4px) center;
  background-size: 4px 4px, 4px 4px;
  background-repeat: no-repeat;
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--text); font-family: inherit; font-size: var(--t-row);
  letter-spacing: -.01em; cursor: pointer;
}

.ask .fld select:focus { outline: none; box-shadow: inset 0 0 0 1px var(--line-bright); }

/* A native option list is drawn by the OS and inherits nothing, so
   it needs its own colours or it renders white-on-white on some
   platforms. */
.ask .fld select option { background: #101010; color: #fff; }

.ask__go { width: 100%; justify-content: center; }

.ask__note {
  margin: 0;
  font-size: var(--t-meta);
  line-height: 1.5;
  color: var(--text-3);
}

.ask__thanks {
  margin: 0;
  font-size: var(--t-figure);
  line-height: 1.15;
  letter-spacing: -.03em;
}

.ask__cta { display: flex; align-items: center; gap: var(--s5); flex-wrap: wrap; }

.badge { display: inline-flex; border-radius: var(--r-xs); transition: opacity var(--fast) var(--ease); }

.badge:hover { opacity: .82; }

.badge img { display: block; height: calc(44px * var(--layout-scale) / .8); width: auto; }

.ask__code {
  border: 0; background: transparent; padding: 0; cursor: pointer;
  color: var(--text-2);
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: var(--tr-label); text-transform: uppercase;
  text-decoration: underline; text-underline-offset: .3em;
  text-decoration-color: var(--line-bright);
  transition: color var(--fast) var(--ease);
}

.ask__code:hover { color: var(--text); }

/* ═══ 07 · Outlive Ordinary ═══════════════════════════════════════
   The ethos, and the last thing on the page.

   🔤 VANTA TITLE. Reserved for the wordmark in tokens.css; extended
   here on AD's instruction and his own precedent — see the note in
   index.html and the amendment in ui/type.css.

   🎨 THE GRADIENT IS LUMINANCE, NOT HUE. White through to a mid grey
   and back. Rule 2 of the library bans an accent colour, not a
   gradient, and every stop below is achromatic — verified by
   sampling the rendered pixels, not by reading the source. */
.band--close { padding-block: calc(150px * var(--layout-scale) / .8); gap: var(--s5); }

.close__eye { color: var(--text-3); }

/* The type is bound to the viewport so the two words stay on one line
   without `nowrap` — the same trap that put 119px of scroll on the
   choice heading. At 390px it lands around 34px and still fits. */
.close__h {
  position: relative;
  margin: 0;
  font-family: var(--font-title), var(--font);
  font-weight: 700;
  /* ⚠️ THE CAP IS MEASURED, and it went back up when the orb left the
     line. With the orb inline the content was 1284px in a 992px box
     and had to drop to 74 to fit. On its own line the two words need
     about 14px of width per px of font-size, so 992 ÷ 14 ≈ 70 — 85
     unscaled lands at 68 rendered with room to spare. Verified at
     1440 and 390 rather than assumed. */
  font-size: clamp(calc(26px * var(--layout-scale)), 6.6vw, calc(85px * var(--layout-scale)));
  white-space: nowrap;
  line-height: 1.02;
  letter-spacing: -.03em;
  text-align: center;
}



/* Both layers are the same string in the same box, so they register
   exactly. The second is hidden from assistive tech in the markup —
   the sentence must be announced once. */
.close__t,
.close__sheen {
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.close__t {
  display: block;
  background-image: linear-gradient(168deg,
    #fff 0%, #f2f2f2 26%, #8f8f8f 58%, #d8d8d8 82%, #fff 100%);
}

/* 🔴 THIS ONE ANIMATES background-position, AND THAT IS A DELIBERATE
   EXCEPTION TO THE LIBRARY'S "transform and opacity only".

   Light shaped like letters requires `background-clip: text`, which
   paints the gradient INSIDE the glyph boxes. Translating the element
   moves the glyphs, not the light — so there is no transform that
   produces this. The alternatives were a contrivance (an SVG text
   mask) or dropping the idea.

   What makes it affordable: ONE element, ONE run of 1.5s, never a
   loop, and `will-change` is dropped the moment it ends so nothing is
   promoted for the rest of the page's life. */
.close__sheen {
  position: absolute;
  inset: 0;
  background-image: linear-gradient(100deg,
    transparent 42%, rgba(255,255,255,.96) 50%, transparent 58%);
  background-size: 280% 100%;
  background-position: 128% 0;
  background-repeat: no-repeat;
  opacity: 0;
  pointer-events: none;
}

.close__h.is-lit .close__sheen {
  will-change: background-position;
  animation: rake 1.5s cubic-bezier(.22, 1, .36, 1) .25s 1 both;
}

@keyframes rake {
  0%   { opacity: 0;  background-position: 128% 0; }
  18%  { opacity: 1; }
  82%  { opacity: 1; }
  100% { opacity: 0;  background-position: -46% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .close__h.is-lit .close__sheen { animation: none; opacity: 0; }
}

.close__b {
  margin: 0;
  max-width: 44ch;
  text-align: center;
  font-size: var(--t-row);
  line-height: 1.55;
  color: var(--text-2);
}



/* ═══ The chat bar ════════════════════════════════════════════════
   The Prometheus composer, and the page's one glass moment. Always on
   screen, because the ask lives in its right-hand corner. */
/* 🔴 TWO OBJECTS, NOT ONE — AD, 2026-09-23: "Think chat bar should be
   separate and request invite should be separate."

   They were one pill with the CTA tucked inside it, which made the
   invite look like a part of the composer — a send button, almost —
   rather than the page's one commercial action. They now sit side by
   side as separate surfaces with their own glass and their own
   shadow.

   ⚠️ THE GLASS MOVED TO THE CHILDREN, and it had to. A blurred
   parent creates a containing block and a single backdrop pass
   across the whole strip, so a gap between two children would still
   carry the blur — the two would read as one object with a slot cut
   in it. Each surface blurs its own ground (lessons.md, 2026-07-29).

   The wrapper keeps the fixed position and nothing else. */
.bar {
  position: fixed; left: 50%; bottom: calc(var(--s4) + env(safe-area-inset-bottom)); z-index: 70;
  width: min(calc(100vw - var(--gutter) * 2), 620px);
  transform: translateX(-50%);
  display: flex; align-items: center; gap: var(--s4);
}



.bar__open {
  flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--s3);
  border: 0; color: var(--text-3);
  background: rgba(14,14,14,.72);
  box-shadow: inset 0 1px 0 0 var(--line-bright), inset 0 0 0 1px var(--line),
              0 18px 44px -16px rgba(0,0,0,.9);
  font-family: inherit; font-size: var(--t-row); letter-spacing: -.01em;
  text-align: left; cursor: pointer;
  height: var(--bar-h);
  /* 🔴 THE MARK WAS 4px FROM THE EDGE — AD, 2026-09-24: "place the
     logo right", alongside the panel composer captioned "good
     spacing". Measured both: the panel gives the mark a 16px left gap
     and the dock gave it 4, so the same object was inset four times
     less in the more prominent of the two places. The dock now
     matches the panel. */
  padding: 4px var(--s5) 4px var(--s5);
  border-radius: var(--r-pill);
  backdrop-filter: blur(22px) saturate(150%); -webkit-backdrop-filter: blur(22px) saturate(150%);
  transition: color var(--fast) var(--ease);
}

.bar__open:hover { color: var(--text-2); }

/* The Möbius mark as a CSS ring rather than a fifth canvas. */
/* 🔑 THE LIVE MARK — AD, 2026-09-23: "this should have the particle
   orb animation on the left."

   ⚠️ ANIMATED HERE, FROZEN IN THE WIDGETS. The two are deliberately
   different: a home-screen widget's app icon does not move, and the
   composer is the one place on the page where Vanta is listening —
   so the mark turning is the affordance. mobius() pauses itself off
   screen, and the dock is fixed, so it runs only while the page is
   open.

   36px is its native size and it is not shrunk to the old 18px dot;
   the bar grew to hold it. A Möbius at 18px is a smudge — that is
   the same finding as the widget marks. */
.bar__mob {
  flex: none;
  /* Same 26px as the panel's. It was 30, which on a 56px bar sat
     heavier than the same mark on the taller 71px composer — the two
     read as different sizes when they should read as one object. */
  width: calc(26px * var(--layout-scale) / .8);
  height: calc(26px * var(--layout-scale) / .8);
  display: grid; place-items: center;
  opacity: .92;
}

.bar__mob svg { display: block; }

.bar__ph { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* The short label. Hidden until the phone breakpoint swaps them. */
.bar__ph--s { display: none; }

/* 🔴 font-family is RESTATED, not omitted. elements.css also owns .kbd
   and sets the mono face — and the Aoi Mono cut on disk is 67 glyphs
   with no ⌘ and no slash, so in mono this chip rendered as two tofu
   boxes. Aeonik has both. */
.kbd {
  flex: none; padding: 2px 7px; border-radius: var(--r-sm);
  box-shadow: inset 0 0 0 1px var(--line);
  font-family: var(--font); font-size: calc(10px * var(--layout-scale) / .8);
  letter-spacing: .02em; text-transform: none; color: var(--text-3);
}

/* Its own object now, so it takes the bar's full height rather than
   the inset height it had while it lived inside the pill. */
.bar__cta {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  height: var(--bar-h); padding-inline: var(--s7);
  border: 0; border-radius: var(--r-pill);
  background: var(--inverse); color: var(--text-inverse);
  box-shadow: 0 18px 44px -16px rgba(0,0,0,.9);
  font-family: inherit; font-size: var(--t-meta); letter-spacing: -.01em;
  cursor: pointer; white-space: nowrap;
  /* It is an <a> now: no underline, and no blue on the one platform
     that still insists. */
  text-decoration: none; -webkit-tap-highlight-color: transparent;
  transition: transform var(--fast) var(--ease);
}

.bar__cta:active { transform: scale(.97); }

/* ═══ Panels ══════════════════════════════════════════════════════
   ⚠️ NOT `.sheet` — components.css owns that name and sets
   visibility:hidden, which made this open to nothing while every
   probe said it was open. Renamed out of the library's namespace
   rather than fought. */
.veil {
  position: fixed; inset: 0; z-index: 80;
  background: rgba(0,0,0,.6);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  opacity: 0; transition: opacity var(--base) var(--ease);
}

.veil.is-on { opacity: 1; }

.pane {
  position: fixed; z-index: 90;
  left: 50%; bottom: calc(var(--s4) + env(safe-area-inset-bottom));
  width: min(calc(100vw - var(--gutter) * 2), 860px);
  max-height: min(76svh, 640px);
  display: flex; flex-direction: column;
  border-radius: var(--r-sheet);
  background: rgba(16,16,16,.92);
  box-shadow: inset 0 1px 0 0 var(--line-bright), inset 0 0 0 1px var(--line), 0 40px 80px -30px #000;
  backdrop-filter: blur(24px) saturate(150%); -webkit-backdrop-filter: blur(24px) saturate(150%);
  overflow: hidden;
  transform: translateX(-50%) translateY(12px) scale(.985);
  opacity: 0;
  transition: opacity var(--base) var(--ease), transform var(--base) var(--ease);
}

.pane.is-on { opacity: 1; transform: translateX(-50%); }

/* 🔴 `display: flex` BEATS the `hidden` attribute — the UA stylesheet
   gives [hidden] display:none at the lowest possible specificity, so
   any display on the element wins. Every element toggled by `hidden`
   that also carries a display needs this restated. */
.pane[hidden], .veil[hidden], .wid[hidden] { display: none; }

.pane__top { display: flex; align-items: center; justify-content: space-between; gap: var(--s5); padding: var(--s5) var(--s5) var(--s4) var(--s6); border-bottom: 1px solid var(--line); }

.pane__top .label { color: var(--text-2); }

/* ⚠️ `auto 1fr`, NOT two fractions. The list sets its own width now,
   and a .8fr column measured 344px against a 268px list — 76px of
   dead space inside the border, which reads as a mistake because it
   is one. `auto` lets the list govern and gives the rest to the
   answer. */
.pane__body { flex: 1; min-height: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); }

/* ─── The question list ─────────────────────────────────────────
   Always open. On a customer-facing FAQ the questions are the
   content, so hiding them behind a collapsed strip costs a visitor
   the thing they arrived for. */
.qlist {
  flex: none;
  width: calc(268px * var(--layout-scale) / .8);
  min-height: 0; overflow-y: auto;
  border-right: 1px solid var(--line);
}

.qlist__inner { padding: var(--s5) 0 var(--s6); }

.qsec {
  padding: var(--s5) var(--s5) var(--s2);
  font-family: var(--font-mono); font-size: var(--t-label);
  letter-spacing: var(--tr-label); text-transform: uppercase;
  color: var(--text-3);
}

.qsec:first-child { padding-top: 0; }

/* ⚠️ THE QUESTIONS WRAP, they do not ellipsis. The collapsed rail
   truncated them with `nowrap` + ellipsis, which is fine for a
   history row you already know and useless for a question you are
   reading for the first time. */
.qrow {
  display: block; width: calc(100% - var(--s5)); margin-inline: var(--s3);
  padding: var(--s3) var(--s4);
  border: 0; border-radius: var(--r-inner);
  background: transparent; color: var(--text-2);
  font-family: inherit; font-size: var(--t-meta); line-height: 1.4;
  text-align: left; cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}

.qrow:hover { background: var(--fill-1); color: var(--text); }

.qrow.is-on { background: var(--fill-2); color: var(--text); }

/* ─── The thread ────────────────────────────────────────────────
   AD, 2026-09-24: the right pane is a chat. What you asked sits
   right and blue, Vanta answers left, and the exchange runs down the
   pane with the newest at the bottom.

   ⚠️ IT ACCUMULATES. A second question continues the thread instead
   of replacing the first — which is the point of chat orientation
   here rather than a document pane: you can read back what you
   already asked without clicking it again. */
.thread {
  flex: 1; min-width: 0; min-height: 0;
  overflow-y: auto;
  display: flex; flex-direction: column;
  align-items: flex-start;
  gap: var(--s4);
  padding: var(--s6);
  scroll-behavior: smooth;
}

.bub {
  max-width: 82%;
  padding: var(--s4) var(--s5);
  border-radius: var(--r-inner);
  font-size: var(--t-row);
  line-height: 1.55;
  white-space: pre-line;
}

/* Vanta. Quiet, left, and the wider of the two because it carries
   the paragraph. */
.bub--v {
  align-self: flex-start;
  background: var(--fill-1);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--text-2);
}

/* 🔵 What the member sent. Blue, right, and narrower — a question is
   shorter than its answer, and a full-width blue block would read as
   the louder half of the exchange. */
.bub--u {
  align-self: flex-end;
  max-width: 70%;
  background: var(--send);
  color: #fff;
}

.pane__ask { display: flex; align-items: center; gap: var(--s3); padding: var(--s4) var(--s4) var(--s4) var(--s5); border-top: 1px solid var(--line); }

/* The mark in the composer, as on the dock. Static here — the panel
   already has one moving thing in the dock behind it, and two marks
   turning at different phases reads as a glitch. */
.pane__mob {
  flex: none;
  width: calc(26px * var(--layout-scale) / .8);
  height: calc(26px * var(--layout-scale) / .8);
  display: grid; place-items: center;
  opacity: .9;
}

.pane__mob svg { display: block; }

.pane__in { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text); font-family: inherit; font-size: var(--t-row); letter-spacing: -.01em; }

.pane__in:focus { outline: none; }

.pane__in::placeholder { color: var(--text-3); }

.iconbtn--send { background: var(--inverse); color: var(--text-inverse); border-radius: 50%; }

/* ═══ The invite form ═════════════════════════════════════════════ */
.fld { display: flex; flex-direction: column; gap: var(--s2); }

.fld .label { color: var(--text-3); }

.fld input {
  height: calc(40px * var(--layout-scale) / .8); padding-inline: var(--s4);
  border: 0; border-radius: var(--r-inner);
  background: var(--fill-1); box-shadow: inset 0 0 0 1px var(--line);
  color: var(--text); font-family: inherit; font-size: var(--t-row); letter-spacing: -.01em;
}

.fld input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--line-bright); }

.btn--block { width: 100%; justify-content: center; }

/* ═══ Tap targets ═════════════════════════════════════════════════
   44×44 on every control, without any of them getting 44px taller on
   screen. The hit area is a pseudo-element centred on the control. */

/* ═══ Responsive ══════════════════════════════════════════════════ */
@media (max-width: 1080px) {
  /* The three words move under the phone rather than beside it. */
  .tall__pin { grid-template-columns: 1fr; align-content: center; gap: var(--s7); }

  /* 🔴 THESE WERE MISSING AND THE MISS WAS SILENT.
     Three earlier edits targeted anchor strings that no longer
     existed after page.css was rebuilt, and `str.replace()` returns
     the input unchanged when it finds nothing — so the rules were
     never added and nothing said so. At 390px the choice section and
     the marrow flank both stayed two-column and pushed 119px off
     screen. Every edit to this file now asserts its anchor. */
  .band--choice { grid-template-columns: 1fr; }

  .tall--choice .tall__pin { grid-template-columns: 1fr; align-content: center; gap: var(--s7); }

  .ask__row { grid-template-columns: 1fr; }

  .band--choice .band__c,
  .band--choice .crop { justify-self: center; }

  .band--choice .band__c { max-width: none; text-align: center; }

  /* The flank becomes a stack: the score first, then the four cards
     side by side rather than wrapped around something that is no
     longer between them. */
  .mrx { grid-template-columns: 1fr; justify-items: center; gap: var(--s6); }

  .mr { order: -1; }

  .mrx__side { flex-direction: row; flex-wrap: wrap; justify-content: center; gap: var(--s4); }

  .mrx__side .ex:first-child { padding-bottom: var(--s5); }

  .mrx__side--l .ex:last-child,
  .mrx__side--r .ex:last-child { margin: 0; }

  .ex { width: min(100%, 300px); }

  .pane__body { grid-template-columns: 1fr; }

  /* The rail becomes a scrolling strip above the thread; a 200px
     column of questions beside a 200px thread serves neither. */
  /* The list sits above the answer on a phone rather than beside it,
     and never takes more than a third of the panel. */
  .qlist { width: 100%; max-height: 34%; border-right: 0; border-bottom: 1px solid var(--line); }
}

@media (max-width: 820px) {
  /* ⚠️ nowrap is a DESKTOP affordance. At 390px it forced this
     heading 217px wide in a 358px column, centred, so it started at
     x = -39 and put 119px of horizontal scroll on the page. A
     heading that refuses to wrap breaks the page instead. */
  .choice__h { white-space: normal; }

  .hero__h { max-width: 14ch; }

  /* ═══ 02 · Stack, do not scale ══════════════════════════════════
     🔴 I SHRANK THE DESKTOP COMPOSITION AND IT WAS ILLEGIBLE ON THE
     DEVICE. AD, 2026-09-24, from his own phone: "Very pathetic,
     largely illegible, not Vanta standards at all."

     TWO FAILURES, AND THE SECOND IS THE ONE THAT MATTERS.

     The mechanical one: `zoom` shrank the boxes and iOS Safari's TEXT
     AUTOSIZING inflated the type straight back up inside them. The
     page carried no `-webkit-text-size-adjust`, so Safari treated a
     narrow block as a column to be made readable and overrode the
     author. Captions that should have drawn at 3px drew at 11 and
     collided — "NUTRITIO" sitting on top of "LABS". That is also why
     the emulator passed and the phone did not: Chrome's desktop
     engine does not autosize text.

     The judgement one: even with autosizing off it was wrong. 3px
     type is not a small version of 11px type, it is nothing. The
     house pattern is in the deck's own stylesheet, in nineteen media
     queries all saying the same thing — `grid-template-columns: 1fr`.
     Columns become a stack. Nothing is scaled, nothing is hidden.
     The sizing contract is vantasociety.org's, stated in its own
     comment: every size is clamp(REM floor, viewport share, REM cap),
     and the floor is whatever a person can actually read. */
  .orb { width: 100%; }

  /* Seven across a phone is 38px each, with no room for a caption
     that is not touching its neighbour. They wrap to two rows at a
     size where the object reads as the object. */
  /* ═══ 02 · A ring around the mark, absorbed into it ═════════════
     AD, 2026-09-24: "The different things need to be on the same line
     or arranged all around the mobius strip — and all the elements
     need to be absorbed into the mobius strip."

     🔴 I HAD TURNED THE ABSORPTION OFF. The intake is driven by
     scroll progress, and once this section became a single snap
     screen there was no scroll left for it to read — so I made it a
     still grid and called the convergence a desktop pleasure. It is
     not a pleasure, it is the sentence the section is making: seven
     things you already use, taken into one. On a phone it runs on a
     timeline instead of a scrub — see mountOrb() in orb.js.

     The ring is the arrangement that makes the absorption legible:
     every object equidistant from the mark, so they arrive together
     from all sides rather than collapsing along a line. Seven points
     at 360/7, starting at the top. */
  .orb {
    position: relative; display: block;
    width: min(84vw, 330px); aspect-ratio: 1;
    margin-inline: auto;
  }

  .orb__row { position: absolute; inset: 0; display: block; min-height: 0; gap: 0; }

  .orb__src {
    position: absolute; left: var(--px); top: var(--py);
    width: clamp(42px, 12.5vw, 56px); max-width: none;
    /* ⚠️ THE CENTRING TRANSLATE IS PART OF THE TRANSFORM, not a
       margin, because the item's height varies with its caption.
       It composes in front of the convergence, so --dx/--drop still
       measure from the item's true centre. */
    transform:
      translate(-50%, -50%)
      translate(calc(var(--dx, 0px) * var(--t, 0)), calc(var(--drop, 0px) * var(--t, 0)))
      scale(calc(1 - var(--t, 0) * .82));
  }

  .orb__src:nth-child(1) { --px: 50%;   --py: 13%; }
  .orb__src:nth-child(2) { --px: 79%;   --py: 27%; }
  .orb__src:nth-child(3) { --px: 86%;   --py: 58%; }
  .orb__src:nth-child(4) { --px: 66%;   --py: 83%; }
  .orb__src:nth-child(5) { --px: 34%;   --py: 83%; }
  .orb__src:nth-child(6) { --px: 14%;   --py: 58%; }
  .orb__src:nth-child(7) { --px: 21%;   --py: 27%; }

  .orb__src figcaption { font-size: var(--t-label); }

  .orb__mark {
    position: absolute; left: 50%; top: 50%;
    transform: translate(-50%, -50%);
    width: min(34vw, 130px);
  }

  /* The mock is the screen on a phone, not a prop beside a column —
     and its notifications now carry real type, which needs the
     width. AD's frames 22-25 draw it at roughly 85%. */
  .crop {
    width: min(86vw, 340px);
    /* 🔴 RESTORED — I DELETED THIS REWRITING §03. The rule sat inside
       the range a slice-replace took out, and nothing errored: the
       mock simply went back to its full 640px and pushed "Vanta is
       Proactive" 29px under the fold on a 553px screen. Same failure
       as 2026-09-20, same fix — know every rule inside the range.

       The mock is ALREADY a crop, masked to transparent at its lower
       edge, so showing less of it on a short screen costs nothing.
       Stated in the reserve tokens rather than a tuned constant, so
       it follows them. */
    max-height: calc(100svh - var(--cta-h) - var(--dock-h) - 110px);
    overflow: hidden;
    -webkit-mask-image: linear-gradient(to bottom, #000 82%, transparent 100%);
    mask-image: linear-gradient(to bottom, #000 82%, transparent 100%);
  }

  /* One line at phone width would be 9px type. It wraps. */
  .sec__h { font-size: clamp(23px, 6.2vw, 30px); letter-spacing: -.026em; }

  /* AD's frames 22–23: "Proactive" is the loudest thing on its
     screen. 34px was a caption next to a phone. */
  .word { font-size: clamp(30px, 9vw, 42px); letter-spacing: -.03em; }

  /* ═══ Centred on a phone — AD, 2026-09-24 ═══════════════════════
     "Vanta is proactive, continuous, and social, making healthier
     choices simple. For the mobile view, the default could be
     centre-aligned; right now it's left-aligned."

     Both sets read as captions to the phone above them, and a
     caption under a centred object is centred. Left alignment is a
     desktop decision: there the type sits in a column BESIDE the
     mock, where a left edge gives the eye something to return to.
     Stacked, there is no column and nothing to align to but the
     object itself. */
  .words, .word, .word span { text-align: center; }

  .choice__h, .choice__big { text-align: center; }

  /* ⚠️ TWO LINES, NOT ONE, AND NOTHING MOVES. AD, 2026-09-24:
     "simple and default — simple changes its position — shouldn't be
     like that, looks nasty."

     The two halves share a line on the desktop, where "& default"
     waits at opacity 0 so its arrival does not reflow. Centred on a
     phone that puts "simple" left of centre, and collapsing the
     hidden half — which I did first — centres it but makes it JUMP
     sideways when the second half lands. Both wrong, and they are
     the same problem: one centred line cannot hold a word that is
     not there yet.

     So on a phone they stack, and the block reserves both lines from
     the start. "simple" is centred on its own line and stays exactly
     where it is; "& default" fades in underneath it. Nothing
     reflows, because nothing changes size. */
  .choice__big { display: grid; justify-items: center; }

  .choice__big em { display: block; margin: 0; }

  .choice__big em[data-step="1"] { margin-left: 0; }


  .tall { height: 280svh; }

  /* 🔴 THE DOCK WAS STARVED — AD, 2026-09-24: "the website is shit on
     mobile". Measured on a real iPhone viewport: composer 58px, CTA
     277px, and the composer's label rendering at 0px wide.

     These four rules were written when the dock was ONE pill and the
     CTA lived inside it. Splitting them into two surfaces on 09-23
     made `display:none` on the label and `flex:1` on the CTA into a
     bare orb beside a giant button. The rules survived the change
     that invalidated them, which is the whole failure. */
  /* ═══ The dock splits — AD's mockups, 2026-09-24 ════════════════
     Six frames, and the dock is the same in all six: **Request Invite
     is a pill at the top right**, and the FAQ bar runs the full width
     at the bottom with its label whole.

     It is the better arrangement and the reason is that they are not
     the same kind of control. The CTA is a destination — it belongs
     where a nav would be, out of the way, always reachable. The FAQ
     bar is an input, and an input on a phone belongs under the thumb.
     Side by side they were competing for one strip and each was
     starved: the composer rendered 58px wide and its label 0px.

     The short label is retired with the competition. */
  .bar__ph { display: block; }

  .bar__ph--s { display: none; }

  .bar {
    left: 0; right: 0; width: auto;
    transform: none;
    padding-inline: var(--gutter);
  }

  .bar__open { flex: 1 1 auto; min-width: 0; }

  /* 🔴 OUT OF THE FLEX ROW ENTIRELY. Fixed to the top right, above
     the panel's veil but below the panel itself. */
  .bar__cta {
    position: fixed;
    top: calc(clamp(8px, 1.4svh, 16px) + env(safe-area-inset-top));
    right: var(--gutter);
    bottom: auto; left: auto;
    z-index: 71;
    flex: 0 0 auto;
    /* AD, 2026-09-24: "Request invite button is too big imo on
       mobile." It is a corner affordance, not a hero button — the
       page's real CTA is the form in §06. 56px tall at 13px was
       sized for a dock it no longer sits in. */
    min-height: 0; height: auto;
    padding: clamp(9px, 1.25svh, 12px) clamp(14px, 4vw, 20px);
    font-size: clamp(11.5px, 1.45svh, 13px);
  }

  /* ═══ The dock covers the bottom of every screen ════════════════
     AD, 2026-09-24: "the website is shit on mobile."

     🔴 MEASURED, NOT GUESSED. The dock is 56px tall and sits 13px off
     the bottom, so it occupies the last 69px of a 659px iPhone
     viewport — and NOTHING on the page accounted for it. Three
     sections centred their content into a box whose bottom was
     already spoken for: the three words sat behind the composer, and
     "Send me an invite" — the page's only conversion — was underneath
     it. On a desktop the dock floats over empty margin and the miss
     is invisible, which is why it survived every desktop review.

     One token, applied everywhere something centres or ends. Fix the
     class, not the instance. */
  /* ⚠️ THE TWO RESERVED STRIPS ANSWER TO THE SCREEN TOO. Fixed at 96
     and 78 they cost 174px of every screen, which a 874px phone can
     spare and a 659px one cannot — the invite form came out 30px
     over. The floors are what the controls actually occupy: the
     composer is 56 tall sitting 13 off the bottom, the pill 56 under
     a 16px inset. */
  :root {
    --dock-h: clamp(78px, 11svh, 96px);
    --cta-h:  clamp(54px, 7.2svh, 64px);
  }

  /* ⚠️ `.tall .tall__pin`, NOT `.tall__pin`. Both `.tall--marrow
     .tall__pin` and `.tall--orb .tall__pin` declare `padding-block`,
     and at (0,2,0) they beat a bare (0,1,0) class even inside a media
     query — media queries add no specificity. Measured: the dock
     reserve applied to two sections out of four, and the tallest
     exchange card still finished 58px underneath the composer. This
     is the trap the comment at the top of section 02 already warns
     about, one rule further down. */
  /* The Request Invite pill is fixed to the top right and the
     composer to the bottom, so a pinned screen has two strips it
     does not own. The composer was reserved the day before; the pill
     was not, and it landed on the phone mock's status bar. Both
     reserved, both from tokens. */
  .tall .tall__pin {
    padding-top: calc(var(--cta-h) + env(safe-area-inset-top));
    padding-bottom: var(--dock-h);
    gap: var(--s5);
  }


  /* ═══ 03 · Four on one screen, the score behind ═════════════════
     AD, 2026-09-24: "all notifs should be on the same screen with the
     marrow on the background — appearing one by one."

     🔴 THIS REVERSES WHAT I BUILT AN HOUR AGO. I gave each exchange
     its own screen because four cards and the radar are 1105px of
     content at desktop sizes and a phone has ~625px. That measurement
     was right and the conclusion was wrong twice over: first I shrank
     the composition until it was unreadable, then I broke it into
     five screens, and neither is the thing. The section's sentence is
     four different inputs talking to ONE score — said simultaneously
     or not said at all.

     ⚠️ SO THE CARDS ARE SMALL, AND THAT IS THE TRADE. Body copy lands
     around 10–11px here, under the 14px floor the rest of the page
     holds to. It is deliberate: at this size the cards are OBJECTS in
     a composition, the same status they have on the desktop, and the
     heading carries the argument. The earlier failure was never
     "small" — it was 3px type with captions colliding because iOS
     autosizing fought a `zoom`. */
  .mrx {
    position: relative;
    width: 100%;
    grid-template-columns: 1fr 1fr;
    align-items: start;
    justify-items: stretch;
    gap: clamp(5px, 1.4svh, 13px);
  }

  /* The flanks dissolve so the four cards become the grid's own
     items — two columns, two rows, in source order. */
  .mrx__side { display: contents; }

  /* Behind, not beside. It is the thing they are all talking about,
     so it sits under them and reads through the gaps. */
  .mr {
    position: absolute; left: 50%; top: 50%;
    transform: translate(-50%, -50%);
    width: min(78vw, 310px);
    order: 0; opacity: .55; z-index: 0; pointer-events: none;
  }

  .ex {
    position: relative; z-index: 1;
    width: 100%; max-width: none;
    padding: clamp(6px, 1.35svh, 13px);
    gap: clamp(3px, .7svh, 7px);
    --layout-scale: .58;
    /* ⚠️ A CARD CANNOT BE TALLER THAN HALF THE COMPOSITION. The four
       are a 2 × 2, so each row is bounded by whatever the screen has
       left after the two reserved strips and the heading — and the
       exchanges are not equal, the hyrox one being nearly twice the
       supplements one. Without a cap the tallest sets both rows and
       the grid runs past the fold on anything shorter than AD's
       phone. Capped with the same fade the phone mock uses, so a card
       that does not fit ends in a fade rather than a cut edge. */
    max-height: calc((100svh - var(--cta-h) - var(--dock-h) - 96px) / 2);
    overflow: hidden;
    -webkit-mask-image: linear-gradient(to bottom, #000 86%, transparent 100%);
    mask-image: linear-gradient(to bottom, #000 86%, transparent 100%);
  }

  /* ⚠️ The library's type tokens were resolved at :root, so lowering
     --layout-scale above does NOT reach them — only the call-site
     calcs in this file re-derive. Everything token-driven inside a
     card is restated. */
  .ex__v { font-size: clamp(9.5px, 1.25svh, 11px); line-height: 1.4; }

  /* Aoi Mono's .14em tracking adds a trailing space after the last
     glyph, which put "180 G TARGET" 4px past its card. Tightened
     only inside a card, where the column is 176px. */
  .ex .label { font-size: clamp(6.5px, .95svh, 8.5px); letter-spacing: .05em; }

  /* 🔴 RESTORED, AND THIS IS THE THIRD TIME TODAY A SLICE-REPLACE
     TOOK A RULE I STILL NEEDED. This one raised the small mono
     labels inside the phone mock — the eight hyrox segment names,
     SKI / SLED / PULL / BURP / ROW / CARRY / LUNGE / WALL — off
     their desktop size. It sat inside the range I replaced when
     rebuilding §03 and went out with it, silently, and production
     shipped them at 7.5px.

     ⚠️ THE MOCK IS NOT THE COMPOSITION. §03's cards are small on
     purpose — AD's call, they are objects in a picture. These are
     inside a phone screen that IS the screen on §04 and §05, and
     they are read, so they take the label floor. */
  .crop .label, .tile .label, .log .label, .trend .label, .legs__g .label {
    font-size: var(--t-label);
  }

  /* 🔴 THE SAME `min-width: auto` TRAP AS THE NOTIFICATION TILES.
     Every two-column row inside a card — the workout table, the
     protocol timeline, the Decline/Accept pair — is a grid or flex
     item, and those default to min-content width, not zero. In a
     176px column "Contrast therapy · 20 min" and "Sauna & massage"
     simply refused to wrap and ran out past the card's edge. One
     rule, because it is one class of defect and it is scoped to the
     card. */
  .ex, .ex * { min-width: 0; }

  /* Six labels on an arm each, drawn BEHIND four cards, read as
     stray words in the gaps rather than as a chart. The shape still
     says what it needs to at this size; the words do not survive
     being covered. */
  .mr__tag { display: none; }

  .ex .log__row, .ex .act__b, .ex .tl__t, .ex .rhead, .ex .log__note { font-size: clamp(9px, 1.15svh, 10px); }

  /* 🔴 THE CARDS DEPICT AN APP; THEY ARE NOT ONE. Decline, Accept,
     Add it and the rest are <button> elements inside a picture of a
     notification — focusable, tappable, announced by a screen reader
     as five buttons, and doing nothing. `tabindex="-1"` had already
     taken them out of the tab order, which means someone saw half of
     it; this takes them out of the accessibility tree and out of
     reach of a finger as well.

     They are also the only controls on the page under 44px, which is
     the right answer for a depiction and the wrong answer for a
     control — the two are indistinguishable until you say which it
     is. */
  .ex .act__b, .wid .act__b, .tile .act__b { pointer-events: none; }

  .ex__img { max-height: clamp(30px, 6.5svh, 70px); object-fit: cover; }

  /* ═══ The handoff ═══════════════════════════════════════════════
     `--vis` is written by handoff() in page.js: it fades a pinned
     screen out as its section leaves and the next one in as it
     arrives, so two section headings are never both at full strength.
     Phone only — on desktop there is room for the overlap to read as
     a transition rather than a collision. Defaults to 1, so if the
     script never runs nothing is hidden. */
  .tall .tall__pin { opacity: var(--vis, 1); }

  /* ═══ Section length ════════════════════════════════════════════
     🔴 ONE VALUE FOR FOUR SECTIONS WAS THE BUG. `.tall { height:
     280svh }` sat later in the file than `.tall--orb`/`--marrow`/
     `--choice` at equal specificity, so it silently overrode all
     three — the two-step choice section got exactly as much scroll as
     the four-step marrow. The page ran 9,258px, fourteen screens.

     Each section now gets scroll in proportion to the number of steps
     it actually has.

     ⚠️ ORDER IS LOAD-BEARING. `.tall` and `.tall--orb` are both one
     class, so the LAST one wins — the base must be declared before
     the variants or it overrides every one of them. That is the bug
     above, and it is one line away from being re-created. */
  /* ═══ Snap scroll — AD, 2026-09-24: "snapscroll works better" ════
     ONE IDEA PER SCREEN, and the heights are what make the snap
     points land on the step boundaries rather than between them.

     `steps()` divides a section's SPAN — its height minus one
     viewport — into n equal parts. So for step k to begin exactly at
     k screens of scroll, the span must be n screens and the height
     must be n + 1. Any other height puts the snap point mid-step,
     which reads as the page stopping in the wrong place.

     ⚠️ `mandatory`, and it took a device to settle it. `proximity`
     only snaps when the scroll already STOPS near a point, so a flick
     that runs out of momentum mid-section stays there — AD's word was
     "misplaced", and his screenshot is the choice section parked half
     a screen down with the phone mock running under the dock.
     Mandatory always lands. It is only safe because of the rule
     below, which makes every screen exactly one viewport. */
  html { scroll-snap-type: y mandatory; }

  /* ═══ One screen, one rule ══════════════════════════════════════
     AD, 2026-09-24: "irregular."

     Every section carried its own vertical rhythm — `.band` padded
     with --band-p, `.band--close` with 150px of its own, the pinned
     sections with something else again. On a desktop you scroll past
     that and never notice. In a snap scroll each section becomes a
     STOP, and six different rhythms read as the page stopping in the
     wrong place six times over.

     So there is ONE screen box and everything uses it: a viewport
     tall, the CTA pill's strip reserved at the top, the composer's
     at the bottom, content centred in what is left. */
  .hero, .band {
    min-height: 100svh;
    padding-block: calc(var(--cta-h) + env(safe-area-inset-top)) var(--dock-h);
    align-content: center;
    gap: clamp(11px, 2.4svh, 30px);
    scroll-snap-align: start;
    scroll-snap-stop: always;
  }

  /* One zero-width marker per step, written by page.js, because the
     step counts live there. */
  .tall__snap {
    position: absolute; left: 0; width: 1px; height: 100svh;
    scroll-snap-align: start; pointer-events: none;
    /* ⚠️ `always` — one flick, one screen. Without it a hard swipe
       jumps two or three snap points at once, which is exactly how
       you skip a transition that lives between two of them. */
    scroll-snap-stop: always;
  }

  .tall { height: 400svh; }             /* 3 words  + 1 */

  /* ═══ Two screens, and the swipe between them IS the merge ══════
     AD, 2026-09-24: "if a user swipes up immediately, they miss the
     transition… the point you're trying to convey doesn't come
     across."

     Both of those are the same fault and it is not a tuning problem.
     A timed one-shot in a snap scroll races the reader and the reader
     wins — longer makes it worse, shorter makes it unreadable, and a
     loop turns an argument into a screensaver.

     So each of these sections is TWO snap screens with its pin held
     between them: screen one is the state before, screen two the
     state after, and the animation is what the scroll does in
     between. It cannot be missed, because the only way past it is
     through it. A mandatory snap turns the flick into a smooth
     scroll, so it plays at a readable speed however hard you swipe —
     and a slow drag scrubs it by hand. */
  .tall--orb { height: 200svh; }        /* ring → absorbed  */

  .tall--marrow { height: 200svh; }     /* four arrive across one swipe */

  .tall--choice { height: 300svh; }     /* 2 states + 1 */


  /* ═══ The panel — AD's proportions, 2026-09-24 ══════════════════
     From a photograph of his own phone on vanta-os.com: "The FAQs bar
     should largely occupy 75% of the screen, with 40% of the tab
     occupied by the FAQs alone."

     So the panel is 75svh and the question list is 40% OF THE PANEL.
     Both are visible at once — which settles a design question rather
     than leaving it open. I had built a list-then-answer switch with
     a back arrow, on the reasoning that 393px has room for one view;
     he wants both, sized properly, and the numbers he gave make that
     work: 40% of 75svh is 198px on his phone, enough for five or six
     questions with the rest scrolling under them.

     ⚠️ `height`, not `max-height`. A max-height lets the panel shrink
     to its content, which is what put the list in a 92px strip with a
     third of the panel empty underneath — the state in his photo. */
  /* AD, 2026-09-24, twice: "FAQs tab need to occupy 80% of screen
     when opened like i earlier said." 75 measured 64% on the device
     because `svh` is the viewport WITH Safari's bars showing and the
     panel is measured against the visible area, which is larger. 80
     lands it where he wants it. */
  .pane {
    /* --vvh is the visible height, keyboard excluded; see
       keyboardInsets() in page.js. It falls back to 100svh, which is
       what this used before and is correct with no keyboard up. */
    height: min(80svh, calc(var(--vvh, 100svh) - var(--s8)));
    max-height: calc(var(--vvh, 100svh) - var(--s8));
    bottom: calc(var(--kb, 0px) + var(--s4) + env(safe-area-inset-bottom));
    transition: height var(--base) var(--ease), bottom var(--fast) linear;
  }

  /* The composer follows the keyboard too, or a visitor typing into
     the invite form types into something they cannot see. */
  .bar { bottom: calc(var(--kb, 0px) + var(--s4) + env(safe-area-inset-bottom)); }

  /* ═══ The answer takes the room when there is an answer ══════════
     AD, 2026-09-24: "when I select a question, the answer is cut off,
     so I can't view it fully. What should we do in that case?"

     The list at 40% of an 80% panel leaves the thread 172px, and the
     longer answers are twice that. Three ways out and this is the
     third: don't hide the list (he asked for it), don't shrink the
     type (that was the last complaint), RE-PROPORTION. The moment an
     answer exists the panel grows to 92% and the list gives up most
     of its share — 40% → 22% — so the thread goes from 172px to
     around 470px, which holds every answer in the bank. The list is
     still there and still scrolls; it is just no longer the thing
     being read. */
  .pane.has-answer { height: min(92svh, calc(var(--vvh, 100svh) - var(--s6))); }

  .pane.has-answer .qlist { height: 22svh; max-height: 22svh; }

  .qlist { transition: height var(--base) var(--ease); }

  /* ⚠️ 40% OF THE PANEL, NOT 40% OF ITS OWN ROW. `height: 40%` on a
     grid item resolves against the grid AREA, and the area was
     auto-sized from content — so it fell back to content height and
     came out at 21%. 40% of 75svh is 30svh, which needs no parent to
     resolve against and is exact on any screen. */
  .pane__body { grid-template-rows: auto minmax(0, 1fr); }

  /* The conversation sits on the floor of its area and grows upward,
     which is what every chat does and what his own screenshot shows.
     `margin-top: auto` on the first child, not `justify-content`, so
     the scroll still starts at the top once the thread is long. */
  .thread > :first-child { margin-top: auto; }

  .qlist { height: 32svh; max-height: 32svh; min-height: 0; border-bottom: 1px solid var(--line); }

  /* ═══ 16px is not a design choice, it is an iOS rule ════════════
     🔴 EVERY FIELD ON THE PAGE WAS 13.6px, AND SAFARI ZOOMS THE
     VIEWPORT ON FOCUS FOR ANYTHING UNDER 16px. Tap Name and the page
     jumps to ~120% and stays there for the rest of the visit — the
     layout is not broken, but it reads as broken, and it happens on
     the one interaction the page exists for.

     There is no way to opt out that is worth having:
     `user-scalable=no` would stop it and also stop a visitor
     pinch-zooming anything, which is an accessibility failure.
     So the field is 16px on a phone. Desktop keeps --t-row.

     The height goes with it: 40px was under the 44px target the
     library requires, and these are the only controls a visitor
     must hit accurately. */
  /* ⚠️ THE SELECTOR HAS TO MATCH `.ask .fld select` AT (0,3,0), and a
     media query adds no specificity — a bare `select` at (0,0,1) set
     the height and lost the font-size, so the control got taller and
     still zoomed. `height`, not `min-height`, for the same reason:
     the base rule declares a fixed height and min-height cannot win
     against a value that is already larger than nothing.

     ⚠️ `.fld` is the LABEL WRAPPER, not the control — the fields are
     bare `<input>`/`<select>` inside `<label class="fld">`. */
  .ask .fld input, .ask .fld select,
  .fld input, .fld select { font-size: 16px; height: 44px; }

  .pane__in { font-size: 16px; }

}

/* ═══ Short phones ═════════════════════════════════════════════════
   🔴 ONE VIEWPORT HEIGHT IS NOT A BREAKPOINT. Everything above was
   measured at 393×659 — a current iPhone — and it fit that to the
   pixel. An SE is 375×553 and a small Android 320×568, and at those
   heights the marrow pin overflowed again by 43px and pushed the
   exchange card 112px underneath the dock.

   Below ~620px of viewport the score and an exchange do not coexist,
   and no amount of shrinking makes them. The score goes: it is the
   ambient shape, the exchange is the content, and a clipped card is
   worse than an absent radar. */
@media (max-width: 820px) and (max-height: 620px) {
  /* The score is already gone at this width; what is left to reclaim
     is the run-in, and the heading is doing that work anyway. */
  .tall .tall__pin { gap: var(--s4); padding-top: var(--s5); }

  /* 🔴 THE PHONE MOCK IS 512px TALL AND THE THREE WORDS SIT UNDER IT.
     On a 553px screen that is 512 + 16 + 56 = 584 against 484 of
     usable height, so "Vanta is / Proactive" — the payload of the
     whole section — rendered behind the composer.

     `--layout-scale` is the library's own mechanism and it is the
     right lever: redefined here it scales the mock's type, spacing
     and radii together, and unlike `transform: scale()` it changes
     the LAYOUT height, so the words move up with it. .8 of the
     global .8. */
  .crop { --layout-scale: .8; }

}

@media (max-width: 400px) {
  .hero__h { font-size: calc(36px * var(--layout-scale) / .8); letter-spacing: -.036em; }
}

/* A 320×568 Android — narrower and shorter than any current iPhone,
   and the last size where the mock plus the three words did not fit:
   the phone was clipped 23px at the top and the words went 12px under
   the composer. One more step down the same scale. */
@media (max-width: 360px) and (max-height: 620px) {
  .crop { --layout-scale: .7; }
}
