/* =========================================================================
   Infinity Upstream — fork operations pillar.

   Built strictly on the Infinity Design System (tokens.css + styles.css).
   Adherence rules observed:
     · No raw hex. Every colour is a var(--inf-*) token or derived from one.
     · Space Grotesk (display) + Inter (body) only. Mono is the system stack,
       used for technical tokens and data, per the design system.
     · Glassmorphism is the dominant card treatment:
       --inf-glass-bg + --inf-glass-blur + --inf-glass-border.
     · Buttons are pill-shaped, hover scales 1.05; primary keeps the
       gradient fill and the neon-purple glow.
     · Cards hover at scale(1.02). Main cards / glass panels use radius-xl.
     · Two fixed ambient radial glows at the brand's ~10% opacity.
     · Phosphor icons only. No emoji, no unicode glyphs as icons.
     · 96px (--inf-space-12) between major sections.

   Upstream's distinct register comes from tone, density, diagrams and data —
   NOT from a different palette. The brand system is followed as written.

   ── HOW TO CHECK FOR ORPHANS BEFORE YOU SHIP ────────────────────────────
   Dead selectors and unreachable JS have shipped three times in this loop,
   every time because the sweep was done by eye. It is a two-minute command.
   Run it from the repo root after `npm run build`:

     # every class the four Upstream sheets define — COMMENTS STRIPPED FIRST,
     # or the worked examples inside these very comments come back as
     # "orphans" (`.frag-x9`, `.my-track`: both are illustrations, not rules)
     perl -0pe 's{/[*].*?[*]/}{}gs' src/assets/css/upstream*.css \
       | grep -ohE '^\s*\.[a-zA-Z0-9_-]+' | tr -d ' .' | sort -u > /tmp/declared.txt
     # … against every class the built Upstream pages actually carry
     grep -ohE 'class="[^"]*"' dist/upstream -r \
       | sed 's/class="//;s/"//' | tr ' ' '\n' | sort -u > /tmp/used.txt
     comm -23 /tmp/declared.txt /tmp/used.txt

   Read the output, do not delete it blind — three legitimate kinds of
   survivor appear in that list and none of them is dead:
     1. classes only ever added by JS (`.in`, `.film-paused`, `.is-film`);
     2. classes for a collection that is currently EMPTY — the whole
        `.nb-card` / `.nb-glance` / `.nb-figs` / `.nb-bar` / `.nb-more`
        family in `upstream-notes.css` activates on the first published
        Rebase Note and must not be swept;
     3. element/attribute-selected rules, which the grep never sees.
   Everything else is an orphan. For JS, the equivalent check is: every IIFE
   in `upstream.js` opens with a `querySelector` guard — grep the built HTML
   for that guard's hook (`#rebase-data`, `.upstream-mock`, …) and an IIFE
   whose hook appears nowhere is unreachable.
   ========================================================================= */

.upstream {
  /* Brand-doc ambient recipe: two large radial glows at ~10% opacity. */
  --tw-ambient-strength: 0.1;
  --tw-show-grid: 1;

  /* Semantic aliases — every one resolves to a design-system token. */
  --upstream-accent: var(--inf-electric-blue);
  --upstream-accent-wash: color-mix(in srgb, var(--inf-electric-blue) 12%, transparent);
  --upstream-accent-line: color-mix(in srgb, var(--inf-electric-blue) 34%, transparent);

  /* Flags and concessions use Hyper Pink — an in-system accent.
     (There is no amber in the Infinity palette.) */
  --upstream-flag: var(--inf-hyper-pink);
  --upstream-flag-wash: color-mix(in srgb, var(--inf-hyper-pink) 11%, transparent);

  /* Chart steps: one hue, light -> dark, both derived from Electric Blue. */
  --upstream-chart-current: var(--inf-electric-blue);
  --upstream-chart-prior: color-mix(in srgb, var(--inf-electric-blue) 58%, var(--inf-void));
}

/* Brand doc specifies TWO ambient glows (purple top-left, blue bottom-right).
   Upstream's shell renders only those two, so there is nothing to suppress —
   the old `.blob-3 { display: none }` targeted an element that never exists. */
.upstream-bg .grid {
  opacity: calc(var(--tw-show-grid) * 0.035);
  /* A mask stop is a stencil, not a colour — only its alpha is read — but
     the two raw `#000`s that used to sit here were still the only raw hex in
     the sheet, and every other mask in this file already spells the opaque
     stop `var(--inf-fg-1)`. Same alpha, same render, one idiom. */
  -webkit-mask-image: radial-gradient(ellipse at center top, var(--inf-fg-1) 20%, transparent 70%);
  mask-image: radial-gradient(ellipse at center top, var(--inf-fg-1) 20%, transparent 70%);
}

/* ── Buttons ─────────────────────────────────────────────────────────────
   Upstream reuses the design system's own .btn-primary / .btn-secondary /
   .btn-ghost. The only addition is the brand-specified scale(1.05) hover,
   scoped to .upstream so the parent site's behaviour is untouched.            */
.upstream .btn:hover {
  transform: scale(1.05);
}
.upstream .btn:active {
  transform: scale(0.98);
}

/* ── Nav ─────────────────────────────────────────────────────────────── */
.upstream-nav {
  position: sticky;
  top: 0;
  z-index: 50;
  background: var(--inf-glass-bg);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  border-bottom: 1px solid var(--inf-hairline-soft);
}
.upstream-nav-inner {
  display: flex;
  align-items: center;
  gap: var(--inf-space-4);
  height: var(--inf-space-8);
}
/* ── Brand lockup ──────────────────────────────────────────────────────
   One unit, one string, one style. "Infinity Upstream" is set once — the
   parent brand is a prefix inside the name, not a smaller, dimmer,
   differently-fonted tag hung off the end (that treatment reads as a legal
   disclaimer, not a wordmark). The type is styled on the anchor itself so
   there is exactly one text run in here: one size, one weight, one family,
   one colour, no opacity-reduced attribution.

   Weight is 550, not 700: Space Grotesk's @font-face in tokens.css declares
   `font-weight: 400 700`, so the variable axis interpolates the intermediate
   weights with no new file (verified: canvas advance widths for "Infinity
   Upstream" per 100px step are 836.5 / 839.0 / 839.6 / 840.3 / 841.5 at
   400 / 500 / 550 / 600 / 700, so the axis really is continuous and is not
   snapping to the two named masters). The bar's reference lockups measure 400
   and the ceiling is 600; 550 is the value the mark was drawn against.

   Do NOT reinstate the old justification that once stood here — that the mark's
   ribbon is `0.115em x 24`, Space Grotesk 550's stem width. That reasoning was
   tried and abandoned: matching a line drawing's stroke to a typeface's stem
   width does not produce matching optical weight, because letterforms carry
   stems AND bowls and so pack far more ink per unit area. A stem-matched mark
   read as a washed-out placeholder next to its own wordmark. The ribbon is
   deliberately heavier than the stems now. See finding 2 in
   partials/upstream-mark.njk.

   Tracking is `normal`, not the display -0.01em — negative tracking at nav
   scale only closes counters and muddies the word.

   The size is set here and nowhere else, and the mark is sized in `em`, so the
   mark-to-type ratio is structural at whatever `--brand-mark-size` says —
   currently 1.30, not 1.00. Corrected mechanism 4 permits 1.0–1.5 for a brand
   lockup: its 1.00 figure was measured on menu rows, where a glyph labels a list
   item, while the reference's own nav brand mark stands at 2.0x its adjacent nav
   text. A newly drawn mark does not survive 19px the way a mature one does.

   19px is chosen for typographic quality at nav scale, which is the only thing
   that should choose it. Mechanism 7's 0.40–0.50 height ratio was measured on a
   brand link holding a mark and nothing else, and the corrected bar now scopes
   it to that case; it never governed a mark-plus-wordmark lockup, and forcing
   it here produced a 28px wordmark that pushed the nav links onto a second line
   at 945px. At 19px type with a 1.30 mark the lockup measures 197.06px, the
   links stay on one line at every width where they are shown, and one size
   serves every breakpoint.                                            */
.upstream-brand {
  display: inline-flex;
  align-items: center;
  /* The mark's size lives here, as one local custom property, because the
     clear space is DERIVED from it. Mechanism 7's floor is a ratio to the
     mark's own height, so the two numbers cannot be set independently: when
     the mark went from 1.0em to 1.3em, a hard-coded 0.6em gap silently fell
     from 0.60x of the mark to 0.46x and breached the floor. Expressed as a
     multiple, the ratio is structural — it cannot drift when the mark
     resizes again.

     1.3em = 24.7px against the 19px wordmark. Corrected mechanism 4 licenses
     1.0-1.5x for a brand mark (the reference's own nav mark runs 2.0x its
     adjacent nav text); 1.3 sits mid-band, where the mark partners the
     wordmark instead of competing with it.

     0.52 x the mark = 0.676em = 12.844px. The mechanism 7 floor is 0.5x =
     12.344px, so this clears it by 0.50px and NOT MORE. That tightening was
     deliberate — the lockup was reading as a glyph plus a word rather than one
     object, and the fix was to reclaim the mark's own dead margin (its ink now
     fills 93.3% of its box, up from 79.2%) and close the gap. But 0.5px is not
     headroom, it is a rounding error.

     SO: if --brand-mark-size changes, re-derive this multiplier. It cannot fall
     below 0.5 or mechanism 7 is breached, and at 0.52 there is no room to
     absorb a change by accident. This is the same class of trap that caught the
     1.0em -> 1.3em change, which is why the ratio is expressed as a multiple of
     the mark rather than a fixed length: the multiple survives a resize, but the
     JUDGEMENT that 0.52 is the right multiple does not.

     Both values are in `em`, so they are optical spacing bound to the lockup's
     own type size, not page rhythm, and should not move when the --inf-space-*
     ramp moves. The outboard side gets the shell's 32px padding = 1.296x. */
  --brand-mark-size: 1.3em;
  gap: calc(var(--brand-mark-size) * 0.52);
  font-family: var(--inf-font-display);
  font-size: var(--inf-type-lg-size);
  font-weight: 550;
  letter-spacing: normal;
  line-height: 1;
  color: var(--inf-fg-1);
  text-decoration: none;
  white-space: nowrap;
}
/* The mark is the identity, not a wayfinding icon labelling a list row, and it
   is sized accordingly: 1.3em, i.e. 24.7px against the 19px wordmark, a ratio
   of 1.30. Both dimensions come from --brand-mark-size on .upstream-brand so
   the clear space can be derived from the same number — see the note there for
   why 1.30, and why the two cannot be set independently.

   Sized in `em`, not a length, so the ratio holds through any future change to
   the lockup's font-size.

   The mark overshoots the caps, deliberately and by a lot: ink 22.22px against
   a 13.3px cap height, ink/cap 1.67. That is not a free choice — it falls out
   of the mechanism 4 ratio. This mark's ink fills 0.900 of its box, so
   ink/cap = 0.900 x ratio / 0.70 = 1.286 x ratio, which is 1.67 at ratio 1.30.
   A mark drawn down to cap height reads undersized next to finished type.

   Optical alignment. The mark's ink runs 22.40 x 21.60 in the 24-unit viewBox,
   composed on a diagonal, but its vertical ink centre still lands on 12.0 — the
   box centre — so box-centring and ink-centring coincide. Space Grotesk's
   ascent is 0.98em and its descent 0.29em against a cap height of 0.70em, so
   the CAP-height centre sits (0.98 - 0.29)/2 - 0.70/2 = -0.005em from the
   line-box centre: 0.10px at 19px. `align-items: center` therefore lands the
   ink centre on the cap-height centre well inside the 1px tolerance (measured
   +0.15px at every breakpoint), and a corrective translate would buy nothing
   but sub-pixel rasterising. Re-measured on the rendered page against this
   revision of the mark, not assumed — see the report.

   No tile, no radius, no background, no glow, no filter: mechanism 1 forbids a
   container shape and mechanism 2 forbids the mark being its own colour event.
   Colour arrives through `currentColor` from .upstream-brand, so the mark is
   the same white as the type it sits in rather than a dimmer tone, and one
   file serves every context.                                               */
.upstream-brand-mark {
  width: var(--brand-mark-size);
  height: var(--brand-mark-size);
  flex: none;
  display: block;
}
/* The lockup's one narrower register, kept next to it rather than in the
   responsive block at the foot of the file so the whole thing reviews in one
   place. It is forced by what else is in the bar, not by taste, and it costs
   nothing in mechanism 3, 4, 5 or 7: the string stays one run in one style, the
   mark stays 1.30x the type and the clear space stays 0.52x the mark, because
   both are in `em` off a font-size this rule does not touch.

   Below 560px the full lockup does not fit. The nav is brand + CTA + menu
   button. At 320px there are 256px of shell content, less a 32px nav gap, less
   a 112px CTA zone (a 56px collapsed button, a 40px menu toggle and the 16px
   between them) — so 112px for the brand. "Infinity Upstream" at 19px is
   197.06px, and forcing it visible at 320px puts 53px of the page off-screen
   (measured, by overriding this rule). The parent prefix is the part that can
   go; what is left is the same run in the same style, just the short name, at
   126.23px.

   BE HONEST ABOUT THAT 126.23px: it is larger than the 112px the arithmetic
   above allows, and it avoids PAGE overflow only because it eats the shell's
   right padding. At 320px the menu toggle's right edge lands at 302.23px while
   the shell's content box ends at 288px, so the nav row overruns its own
   content box by 14.23px and the 32px gutter absorbs it — leaving a 17.77px
   right margin instead of 32px. `scrollWidth == clientWidth`, so nothing
   clips and no scrollbar appears, but the nav is tighter to the edge than the
   shell intends and this is the first thing that will break if the CTA or the
   toggle grows.

   560px is not where the string stops fitting. Measured with this rule
   disabled: 457px is clean, 450px overflows by 6px, 440px by 16px — so the real
   threshold is ~456px. 560px is used anyway, to reuse a breakpoint the file
   already has and to keep ~103px of slack rather than 1px.

   The 360-387px overflow this paragraph used to describe is FIXED, and the fix
   was not the lockup's: the CTA now collapses its label at <=399px instead of
   <=359px. The nav needs ~388px with the full 139px label, so every viewport
   from 360px to 387px used to overflow — iPhone SE at 375px included. Measured
   after the change: `scrollWidth - clientWidth` is 0 at 320, 400 and 430px.
   See the note above .upstream-nav-cta's media query for the arithmetic. */
@media (max-width: 560px) {
  .upstream-brand-parent {
    display: none;
  }
}
/* Footer wordmark only. The nav lockup no longer uses this class — it styles
   its single text run on .upstream-brand — but upstream-footer.njk still does,
   so the rule stays.

   What is and is not the same as before this run, stated precisely because an
   earlier version of this comment overclaimed: the footer wordmark's RENDERED
   treatment is unchanged — it still computes to 17px / 700 / -0.17px tracking
   in Space Grotesk at --inf-fg-1. The declaration itself did change: the
   font-size was a literal `17px` and is now `var(--inf-type-md-size)`, which is
   the same 17px expressed as a token. Separately, the footer's body copy was
   edited by the owner this run; that is theirs, not this piece's, and it is
   not to be reverted. */
.upstream-brand-name {
  font-family: var(--inf-font-display);
  font-weight: 700;
  font-size: var(--inf-type-md-size);
  letter-spacing: var(--inf-type-h1-ls);
  color: var(--inf-fg-1);
}
.upstream-nav-links {
  display: flex;
  gap: var(--inf-space-3);
  margin-left: auto;
}
.upstream-nav-links a {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-label-size);
  color: var(--inf-fg-2);
  text-decoration: none;
  transition: color var(--inf-dur) var(--inf-ease);
}
.upstream-nav-links a:hover {
  color: var(--inf-fg-1);
}
.upstream-nav-links a[aria-current="page"] {
  color: var(--upstream-accent);
}
/* `margin-left: auto` lives here, not only on .upstream-nav-links, so the CTA
   stays anchored right when the nav has no page links (V1 ships the homepage
   alone). With links present it is a no-op: they already take the free space. */
.upstream-nav-cta {
  display: flex;
  align-items: center;
  gap: var(--inf-space-2);
  margin-left: auto;
}
.upstream-nav-cta .nav-toggle {
  display: none;
}
/* Narrow phones keep the WORDS and drop the arrow. An icon-only button read
   as a bare arrow in a pill, with nothing saying it books a call, so the label
   is the part that has to survive and the arrow is the part that can go.

   V1's nav is the brand plus this one button: no page links, no menu toggle.
   Measured at a 320px viewport (256px of shell content): the brand lockup is
   126px with its prefix already dropped at 560px, the label-only button is
   ~109px at 16px side padding, so with the row's minimum gap cut to 16px the
   pair needs ~251px and fits with ~5px spare. 375px and up fit the full
   label-plus-arrow at the normal 32px gap, so the breakpoint stays at 399px.

   If the nav links or the menu toggle come back, this arithmetic no longer
   holds: re-measure at 320px before shipping. `aria-label` still names the
   button, so nothing changes for screen readers. */
@media (max-width: 399px) {
  .upstream-nav-inner {
    gap: var(--inf-space-2);
  }
  .upstream-nav-cta .btn-primary {
    padding-left: var(--inf-space-2);
    padding-right: var(--inf-space-2);
  }
  .upstream-nav-cta .btn-primary .ph {
    display: none;
  }
}

/* ── Page rhythm — 96px between major sections, per the brand doc ────── */
.upstream-hero {
  padding-top: var(--inf-space-12);
  padding-bottom: var(--inf-space-8);
  position: relative;
}
.upstream-hero-title {
  font-family: var(--inf-font-display);
  font-weight: 700;
  font-size: clamp(2.375rem, 5.2vw, 4.125rem);
  line-height: var(--inf-type-display1-lh);
  letter-spacing: var(--inf-type-display1-ls);
  color: var(--inf-fg-1);
  margin: 0 0 var(--inf-space-3);
  max-width: 17ch;
  text-wrap: balance;
}
.upstream-hero-title .grad {
  background: var(--inf-gradient-brand);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.upstream-lede {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-body-l-size);
  line-height: var(--inf-type-body-l-lh);
  color: var(--inf-fg-2);
  max-width: 62ch;
  margin: 0 0 var(--inf-space-4);
  text-wrap: pretty;
}
.upstream-section {
  padding: var(--inf-space-12) 0;
  position: relative;
}
.upstream-section--tight {
  padding: var(--inf-space-8) 0;
}
/* First section below the hero: generous air between the sticky nav /
   section divider and the heading, so the opener never reads crushed. */
.upstream-section--first {
  padding-top: calc(var(--inf-space-12) + var(--inf-space-4)); /* 8rem */
}
/* Anchor targets land clear of the sticky nav. */
.upstream-section[id] {
  scroll-margin-top: var(--inf-space-6);
}
/* Glowing divider between major sections — the brand's section break.
   Deliberately a lit filament, not a seam: the 1px line is inset from the
   viewport edges and fades out toward BOTH ends, and its bloom is a soft
   elliptical halo painted in the same band (so the glow dies with the
   line instead of running hard edge-to-edge the way a box-shadow would). */
.upstream-section::before {
  content: "";
  position: absolute;
  top: -0.4375rem;
  left: 6%;
  right: 6%;
  height: 0.875rem;
  pointer-events: none;
  opacity: 0.6;
  background:
    /* the filament — brightest at centre, gone well before the ends */
    linear-gradient(
        to right,
        transparent,
        color-mix(in srgb, var(--inf-deep-nebula) 45%, transparent) 18%,
        color-mix(in srgb, var(--inf-deep-nebula) 90%, transparent) 50%,
        color-mix(in srgb, var(--inf-deep-nebula) 45%, transparent) 82%,
        transparent
      )
      50% 50% / 100% 1px no-repeat,
    /* the bloom — an ellipse whose own shape fades it at both ends */
    radial-gradient(
        48% 100% at 50% 50%,
        color-mix(in srgb, var(--inf-deep-nebula) 36%, transparent),
        color-mix(in srgb, var(--inf-deep-nebula) 16%, transparent) 55%,
        transparent 100%
      )
      50% 50% / 100% 100% no-repeat;
}
/* Kickers are ALL-CAPS MONOSPACE — the hero's register, everywhere on
   Upstream (dev reference: mono / 600 / .18em tracking). Never the parent
   site's proportional-sans eyebrow. */
.upstream-kicker {
  display: inline-flex;
  align-items: center;
  gap: var(--inf-space-1);
  font-family: var(--inf-font-mono);
  font-weight: 600;
  font-size: var(--inf-type-caption-size);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--upstream-accent);
  margin-bottom: var(--inf-space-2);
}
.upstream-kicker::before {
  content: "";
  width: var(--inf-space-2);
  height: 1px;
  background: var(--upstream-accent);
}
.upstream-h2 {
  font-family: var(--inf-font-display);
  font-weight: 700;
  /* ≥3× the 17px reading body at desktop (51px+ by 1440w): section
     headlines are exhibits, same as the hero's register. */
  font-size: clamp(1.875rem, 3.6vw, 3.375rem);
  line-height: 1.12;
  letter-spacing: var(--inf-type-display1-ls);
  color: var(--inf-fg-1);
  margin: 0 0 var(--inf-space-2);
  max-width: 22ch;
  text-wrap: balance;
}
/* Compound terms ("Rate-Matching") must never break across lines. */
.upstream-h2 .nbr {
  white-space: nowrap;
}
/* Rag control for articles/prepositions/conjunctions: a .nbg group
   welds "a", "to" or "and" to its whole phrase so no small word ever
   strands at a line end ("…Is a /", "Three Ways to /"). Each group fits
   the 22ch measure on its own but never combined with what precedes it,
   so the lockup is deterministic at every width. Below 560px the groups
   can no longer fit on one line, so they release; the inner .nbr
   compounds and the no-break spaces keep "Rate-Matching", "Hand Over",
   "a Rate-Matching", "and the Rate" and "the Treadmill" whole even
   then. */
.upstream-h2 .nbg {
  white-space: nowrap;
}
@media (max-width: 560px) {
  .upstream-h2 .nbg {
    white-space: normal;
  }
}
/* Long-form reading column for the argument pages. */
.doc {
  max-width: 72ch;
}
.doc p {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-md-size);
  line-height: 1.68;
  margin: 0 0 var(--inf-space-2);
}
.doc > ul,
.doc > ol {
  margin: 0 0 var(--inf-space-3) var(--inf-space-3);
  max-width: 70ch;
}
.doc li {
  font-size: var(--inf-type-md-size);
  line-height: 1.62;
  margin-bottom: var(--inf-space-1);
  color: var(--inf-fg-2);
}
.doc li::marker {
  color: var(--inf-fg-3);
}
.doc strong {
  color: var(--inf-fg-1);
  font-weight: 600;
}
.doc a {
  color: var(--upstream-accent);
  text-decoration: none;
  border-bottom: 1px solid var(--upstream-accent-line);
  transition: border-color var(--inf-dur) var(--inf-ease);
}
.doc a:hover {
  border-bottom-color: var(--upstream-accent);
}

/* Technical token — milestone numbers, money, durations. */
.tok {
  font-family: var(--inf-font-mono);
  font-size: 0.9em;
  font-weight: 500;
  color: var(--upstream-accent);
  background: var(--upstream-accent-wash);
  padding: 1px var(--inf-space-0_5);
  border-radius: var(--inf-radius-sm);
  white-space: nowrap;
}

/* ── Stat band ───────────────────────────────────────────────────────────
   THERE IS ONE STAT BAND ON UPSTREAM AND IT IS `.bignum`, declared under
   "Big-number band" further down this file, with its modifiers beside it.

   A `.statband` / `.statband .stat` component used to live here: plain white
   numerals at the 32px step, in a single bordered box, on the Rebase Note.
   It was the fourth of four treatments of the same object and it is gone.
   Its one consumer now carries `.bignum .bignum--quad .bignum--boxed`.
   If you need a stat band, reach for `.bignum` and a modifier.            */

/* ── Data tables — glass surface, radius-xl ──────────────────────────── */
.tw {
  overflow-x: auto;
  border: var(--inf-glass-border);
  border-radius: var(--inf-radius-xl);
  background: var(--inf-glass-bg);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  margin: var(--inf-space-3) 0 var(--inf-space-1);
}
.dtable {
  width: 100%;
  border-collapse: collapse;
  min-width: 620px;
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-sm-size);
}
.dtable th {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-2xs-size);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--inf-fg-3);
  text-align: left;
  padding: var(--inf-space-2);
  border-bottom: 1px solid var(--inf-hairline-soft);
  white-space: nowrap;
}
.dtable td {
  padding: var(--inf-space-2);
  border-bottom: 1px solid var(--inf-hairline-soft);
  vertical-align: top;
  color: var(--inf-fg-2);
  line-height: 1.55;
}
.dtable tbody tr:last-child td {
  border-bottom: none;
}
.dtable tbody tr {
  transition: background var(--inf-dur) var(--inf-ease);
}
.dtable tbody tr:hover {
  background: var(--inf-hairline-soft);
}
.dtable td b {
  display: block;
  font-family: var(--inf-font-display);
  font-weight: 700;
  font-size: var(--inf-type-sm-size);
  color: var(--inf-fg-1);
  margin-bottom: 3px;
}
/* ── .dtable--stack — the shared table, re-flowed instead of swiped ──────
   `.dtable` carries `min-width: 620px`, so on a 375px screen it always
   overflows its `.tw` wrapper. The wrapper does scroll (`overflow-x: auto`),
   so nothing is unreachable — but there is no affordance saying so, and a
   column sitting at x 598→793 on a 375 viewport is buried in every sense that
   matters.

   That is tolerable for the data tables on the cost model page, where a
   swipe costs the reader a number. It is NOT tolerable for the three-posture
   table, whose `Security dependency` column carries brief 11.6's honest
   counterweight — the argument that Extended Stable means someone else decides
   which fixes you get, and that volume upstream is merge load downstream. The
   brief's own instruction is to say that on the page rather than bury it, and
   the risk table names omitting it as the thing a competent prospect will
   notice. Off-screen behind an undiscoverable swipe is buried.

   So this modifier stacks each row into a labelled block below 700px. The
   labels come from `data-label` on each cell, which is why the markup carries
   them; nothing is duplicated in CSS. Opt-in, so the cost model's wide data
   tables keep their scroller. */
@media (max-width: 700px) {
  .dtable--stack {
    min-width: 0;
  }
  .dtable--stack thead {
    /* The column heads become per-cell labels, so the row of them is now
       redundant rather than merely awkward. Clipped, not `display: none`, so it
       stays in the accessibility tree as the table's own header row. */
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .dtable--stack tbody tr {
    display: block;
    padding: var(--inf-space-3) 0;
  }
  .dtable--stack tbody tr:first-child {
    padding-top: 0;
  }
  .dtable--stack td {
    display: block;
    padding: 0 0 var(--inf-space-2);
    border: 0;
  }
  .dtable--stack td:last-child {
    padding-bottom: 0;
  }
  .dtable--stack td::before {
    content: attr(data-label);
    display: block;
    font-family: var(--inf-font-sans);
    font-size: var(--inf-type-2xs-size);
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--inf-fg-3);
    margin-bottom: var(--inf-space-1);
  }
  /* The posture name is the row's own heading once the columns are gone, so it
     does not want a label above it repeating the word "Posture". */
  .dtable--stack td:first-child::before {
    content: none;
  }
  .dtable--stack td:first-child b {
    font-size: var(--inf-type-md-size);
  }
  /* `.n` right-aligns a figure against a column edge that no longer exists. */
  .dtable--stack td.n {
    text-align: left;
  }
  /* The wrapper's own scroller is now dead weight on this variant. */
  .tw:has(.dtable--stack) {
    overflow-x: visible;
  }
}

.dtable td.n {
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-label-size);
  font-weight: 500;
  color: var(--inf-fg-1);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.tnote {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-label-size);
  line-height: 1.6;
  color: var(--inf-fg-3);
  max-width: 72ch;
  margin: 0 0 var(--inf-space-3);
}
.tnote strong {
  color: var(--inf-fg-2);
}
/* Three pages needed the note pushed off the block above it and did it with
   an inline `style="margin-top:22px|24px|32px"` — three different raw px, in
   the markup, outside the stylesheet and outside the ramp. One modifier, one
   token, no inline style. */
.tnote--spaced {
  margin-top: var(--inf-space-3);
}

/* The Rebase Note byline — a kicker at the muted tier, sitting flush under
   the lede. Was an inline `style` on the element; same values, in the
   stylesheet where the rest of the page's type lives. */
.byline {
  color: var(--inf-fg-3);
  margin-bottom: 0;
}

/* ── Callouts — glass panels with an accent edge ─────────────────────── */
.callout {
  border: var(--inf-glass-border);
  border-left: 2px solid var(--upstream-accent);
  background: var(--inf-glass-bg);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  border-radius: 0 var(--inf-radius-xl) var(--inf-radius-xl) 0;
  padding: var(--inf-space-3);
  margin: var(--inf-space-4) 0;
  max-width: 74ch;
}
.callout .k {
  display: inline-flex;
  align-items: center;
  gap: var(--inf-space-1);
  /* Same ALL-CAPS monospace register as every Upstream kicker. */
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--upstream-accent);
  margin-bottom: var(--inf-space-1);
}
.callout p {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-body-size);
  line-height: 1.62;
  margin: 0 0 var(--inf-space-1);
}
.callout p:last-child {
  margin-bottom: 0;
}
/* ── THE NEXT STEP, INSIDE A CALLOUT · `.callout-do` ──────────────────────
   Added 8 September 2026 for the bottom-line block on /fork-cost-model/.

   A summary block that ends with what to do next needs that line to be
   findable by someone who read only the summary — which, for the reader this
   page is aimed at, is the likely case. It is separated by a rule rather
   than by a heading, because it is still one sentence of the same paragraph
   run, and set in the body face at full contrast so it reads as the
   conclusion rather than as small print.

   Not a button. The page's asks are its own CTA panel and the audit page it
   links to; a third primary action a screen below the h1 would compete with
   the model the reader came for. */
.callout .callout-do {
  margin-top: var(--inf-space-2);
  padding-top: var(--inf-space-2);
  border-top: 1px solid var(--inf-hairline-soft);
  color: var(--inf-fg-2);
}
.callout--concession {
  border-left-color: var(--upstream-flag);
}
.callout--concession .k {
  color: var(--upstream-flag);
}

/* Internal flag for figures not yet cleared for public use. */
.pending {
  color: var(--upstream-flag);
  border-bottom: 1px dotted var(--upstream-flag);
}

/* ── Calculator — glass panel ────────────────────────────────────────── */
.calc {
  background: var(--inf-glass-bg);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  border: var(--inf-glass-border);
  border-radius: var(--inf-radius-xl);
  padding: var(--inf-space-4);
  margin: var(--inf-space-4) 0;
}
.calc-head {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-caption-size);
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--upstream-accent);
  margin: 0 0 var(--inf-space-0_5);
}
.calc-sub {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-sm-size);
  color: var(--inf-fg-3);
  margin: 0 0 var(--inf-space-3);
  max-width: 60ch;
}
.calc-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--inf-space-3) var(--inf-space-4);
}
.field label {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--inf-space-1);
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-2xs-size);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--inf-fg-3);
  margin-bottom: var(--inf-space-1);
}
.field label .val {
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-xs-size);
  font-weight: 500;
  color: var(--upstream-accent);
  letter-spacing: 0;
  text-transform: none;
}
.field input[type="range"] {
  width: 100%;
  accent-color: var(--inf-electric-blue);
  height: 22px;
  background: transparent;
}
.field select {
  width: 100%;
  background: var(--inf-elevated);
  color: var(--inf-fg-1);
  border: 1px solid var(--inf-hairline);
  border-radius: var(--inf-radius-md);
  padding: var(--inf-space-1) var(--inf-space-1);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-xs-size);
  transition: border-color var(--inf-dur) var(--inf-ease);
}
.field select:hover {
  border-color: var(--inf-hairline-loud);
}
.calc-out {
  margin-top: var(--inf-space-4);
  padding-top: var(--inf-space-3);
  border-top: 1px solid var(--inf-hairline-soft);
  display: flex;
  flex-wrap: wrap;
  gap: var(--inf-space-3) var(--inf-space-6);
  align-items: baseline;
}
.calc-out .biglabel {
  display: block;
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-2xs-size);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--inf-fg-3);
  margin-bottom: var(--inf-space-1);
}
.calc-out .big {
  display: block;
  font-family: var(--inf-font-display);
  font-weight: 700;
  font-size: clamp(2.375rem, 5vw, 3.375rem);
  line-height: 1;
  letter-spacing: var(--inf-type-display1-ls);
  background: var(--inf-gradient-brand);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  font-variant-numeric: tabular-nums;
}
.calc-out .side {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-sm-size);
  line-height: 1.55;
  color: var(--inf-fg-2);
  max-width: 330px;
}
.calc-note {
  margin-top: var(--inf-space-2);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-2xs-size);
  line-height: 1.75;
  color: var(--inf-fg-3);
}
/* ── Diagrams — glass panels ─────────────────────────────────────────── */
.diagram {
  margin: var(--inf-space-4) 0 var(--inf-space-1);
  border: var(--inf-glass-border);
  border-radius: var(--inf-radius-xl);
  background: var(--inf-glass-bg);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  padding: var(--inf-space-4) var(--inf-space-3) var(--inf-space-3);
}
.diagram svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}
/* ── The cadence diagram's type ─────────────────────────────────────────
   Every label in this diagram used to carry
   `font-family="ui-monospace,Menlo,monospace" font-size="11|12"` as a
   presentation ATTRIBUTE, which meant the one diagram on the page rendered
   in the visitor's system mono at a size no token knew about, next to
   fragments and tags set in the self-hosted stack. An SVG presentation
   attribute cannot hold a custom property, so the diagram is typed from CSS
   instead — same two sizes, on the ramp, in the page's own mono. */
.diagram svg.cadence text {
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-2xs-size);
}
.diagram svg.cadence .cd-head {
  font-size: var(--inf-type-caption-size);
}
.diagram figcaption {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-caption-size);
  color: var(--inf-fg-3);
  margin-top: var(--inf-space-2);
  line-height: 1.6;
}

/* ── Sources — Phosphor icons, never a unicode glyph ─────────────────── */
.srclist {
  list-style: none;
  margin: 0;
  padding: 0;
  max-width: 74ch;
}
.srclist li {
  display: flex;
  gap: var(--inf-space-1);
  margin-bottom: var(--inf-space-1);
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-sm-size);
  line-height: 1.6;
  color: var(--inf-fg-2);
}
.srclist li .ph {
  color: var(--inf-fg-3);
  flex: none;
  margin-top: 5px;
  font-size: var(--inf-type-label-size);
}

/* ── Rebase note cards ───────────────────────────────────────────────── */

/* ── CTA panel — the brand's "featured" treatment: 1px gradient border
      wrapping a dark inner surface, on a neon-purple shadow. ─────────── */
.cta-panel {
  padding: 1px;
  border-radius: var(--inf-radius-xl);
  background: var(--inf-gradient-brand);
  box-shadow: var(--inf-glow-purple);
  margin-top: var(--inf-space-1);
}
.cta-panel-inner {
  position: relative;
  overflow: hidden;
  background: var(--inf-deep-space);
  border-radius: calc(var(--inf-radius-xl) - 1px);
  padding: var(--inf-space-6);
}
/* Inner top-edge gradient halo, per the featured-card recipe. */
.cta-panel-inner::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 96px;
  background: var(--inf-gradient-protect-top);
  pointer-events: none;
}
.cta-panel-inner > * {
  position: relative;
}
.cta-panel .upstream-h2 {
  font-size: clamp(var(--inf-type-h2-size), 2.4vw, var(--inf-type-2xl-size));
  margin-bottom: var(--inf-space-2);
}
.cta-panel p {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-md-size);
  line-height: 1.6;
  max-width: 62ch;
  margin: 0 0 var(--inf-space-3);
}

/* ── Footer ──────────────────────────────────────────────────────────── */
.upstream-footer {
  position: relative;
  padding: var(--inf-space-8) 0 var(--inf-space-6);
  border-top: 1px solid var(--inf-hairline-soft);
}
.upstream-footer-grid {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr;
  gap: var(--inf-space-6);
}
.upstream-footer-brand p {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-label-size);
  line-height: 1.6;
  color: var(--inf-fg-3);
  max-width: 46ch;
  margin: var(--inf-space-1) 0 0;
}
.upstream-footer-col h5 {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-2xs-size);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--inf-fg-3);
  margin: 0 0 var(--inf-space-2);
}
.upstream-footer-col ul {
  list-style: none;
  margin: 0;
  padding: 0;
}
.upstream-footer-col li {
  margin-bottom: var(--inf-space-1);
}
.upstream-footer-col a {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-label-size);
  color: var(--inf-fg-2);
  text-decoration: none;
  transition: color var(--inf-dur) var(--inf-ease);
}
.upstream-footer-col a:hover {
  color: var(--upstream-accent);
}
.upstream-footer-bottom {
  display: flex;
  align-items: center;
  gap: var(--inf-space-3);
  flex-wrap: wrap;
  margin-top: var(--inf-space-6);
  padding-top: var(--inf-space-3);
  border-top: 1px solid var(--inf-hairline-soft);
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-caption-size);
  color: var(--inf-fg-3);
}
.upstream-footer-parent img {
  height: 20px;
  width: auto;
  display: block;
  opacity: 0.7;
  transition: opacity var(--inf-dur) var(--inf-ease);
}
.upstream-footer-parent:hover img {
  opacity: 1;
}

/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 900px) {
  .upstream-nav-links {
    display: none;
  }
  .upstream-nav-cta .nav-toggle {
    display: inline-flex;
  }
  .upstream-nav-links.open {
    display: flex;
    position: fixed;
    top: var(--inf-space-8);
    left: 0;
    right: 0;
    z-index: 60;
    flex-direction: column;
    gap: 0;
    background: var(--inf-deep-space);
    border-bottom: 1px solid var(--inf-hairline-soft);
    box-shadow: var(--inf-shadow-lg);
    padding: var(--inf-space-1) var(--inf-space-4) var(--inf-space-3);
  }
  .upstream-nav-links.open a {
    /* Optical: a 44px minimum tap target on a 18px line box. rem, not bare
       px — the token ramp has no 13. */
    padding: 0.8125rem 0;
    border-bottom: 1px solid var(--inf-hairline-soft);
  }
  .upstream-footer-grid {
    grid-template-columns: 1fr;
    gap: var(--inf-space-4);
  }
}
@media (max-width: 700px) {
  .upstream-hero {
    padding-top: var(--inf-space-8);
    padding-bottom: var(--inf-space-6);
  }
  .upstream-section {
    padding: var(--inf-space-8) 0;
  }
  .calc,
  .cta-panel-inner {
    padding: var(--inf-space-3);
  }
}

/* =========================================================================
   Landing-page components — hero console mock, anchor subnav, big-number
   bands, gradient icon-tile cards, offer cards, quote + statement bands,
   step timeline. Token-pure: every colour derives from tokens.css.
   ========================================================================= */

/* ── Centered hero variant ───────────────────────────────────────────── */
.upstream-hero--center {
  text-align: center;
}
.upstream-hero--center .upstream-hero-title {
  margin-left: auto;
  margin-right: auto;
  max-width: 21ch;
}
.upstream-hero--center .upstream-lede {
  margin-left: auto;
  margin-right: auto;
}
.upstream-hero--center .hero-actions {
  justify-content: center;
}
.upstream-hero--center .upstream-kicker::after {
  content: "";
  width: var(--inf-space-2);
  height: 1px;
  background: var(--upstream-accent);
}

/* Anchor targets land clear of the sticky nav. */
.upstream section[id] {
  scroll-margin-top: var(--inf-space-12);
}

/* ── Hero console mock — the "product screenshot" for a service ────────
   The top margin is the SKY BAND: a dedicated empty stage between the
   CTA row and the pipeline card (github.com/home leaves ~300–400px at
   1440w). The whole cast lives inside this band and the glow crest at
   the card's top edge — nothing ever touches the nav, lede or buttons. */
.upstream-mock {
  position: relative;
  max-width: 860px;
  margin: clamp(7rem, 21vw, 18.5rem) auto 0;
}
/* The hero clips the stage overhang so the cast never causes sideways
   scroll; the cast sits well inside the clip at every breakpoint. */
.upstream-hero--center {
  overflow: hidden;
}

/* ── Hero stage — the one horizon light behind the pipeline card ─────────
   ROUND 4: the glossy cartoon cast (Spark, Tab, Lumen) and the twenty-glint
   star field are retired. Against the photoreal cast now hanging on the
   branch graph they read as two brands on one page, and a particle field is
   exactly the generic ornament bar mechanism 7 rules out — the decoration
   on this page is a diagram of the thing being sold.

   What survives is the light, because the pipeline card is still lit by it.
   The stage's y=0 is the card's top edge (the card is the first in-flow
   child of .upstream-mock). ONE light source: a radial horizon glow whose
   ellipse is centred exactly on that edge and spans most of the card's
   width, Gaussian-blurred so no rim is ever traceable, with a single crisp
   sun-line riding outside the blur. Its only motion is one slow symmetric
   breath — the sanctioned ambient register — and it stops under reduced
   motion.                                                               */
.upstream-stage {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
/* Clean darkness for the scene: the page's fixed grid texture (and any
   ambient wash) fades out of the stage zone — sky band plus glow crest —
   under an eased void-coloured ellipse. It is the stage's FIRST painted
   layer (a ::before precedes the element's children), so the horizon
   glow, sun-line, sparkles and cast all render on top of it. Every stop
   is eased and the terminal resolves inside the box, so the occluder has
   no traceable edge of its own — the grid simply dies toward the scene. */
.upstream-stage::before {
  content: "";
  position: absolute;
  left: -40%;
  right: -40%;
  top: -24rem;
  height: 38rem;
  background: radial-gradient(
    50% 58% at 50% 60%,
    var(--inf-void),
    color-mix(in srgb, var(--inf-void) 92%, transparent) 52%,
    color-mix(in srgb, var(--inf-void) 70%, transparent) 72%,
    color-mix(in srgb, var(--inf-void) 38%, transparent) 86%,
    color-mix(in srgb, var(--inf-void) 12%, transparent) 95%,
    transparent 100%
  );
}
/* The stage sky reaches up past the CTA row; hero copy and buttons must
   paint above it (.upstream-mock is positioned, so it wins DOM order alone). */
.upstream-hero--center .upstream-kicker,
.upstream-hero--center .upstream-hero-title,
.upstream-hero--center .upstream-lede,
.upstream-hero--center .hero-actions {
  position: relative;
  z-index: 2;
}

/* The one light source — an ATMOSPHERE, not a shape. Four stacked
   radials share ONE centre that sits exactly on the card's top edge
   (59% of the element's height; the element extends 24% past the card
   on each side and much further up than down, so the bloom can breathe
   past the card's corners and haze the whole sky band). Each layer is
   larger and fainter than the last, and every falloff is eased through
   several low-alpha intermediate stops, so brightness climbs only as
   the eye approaches the lip and no layer ever presents a rim: the
   outermost haze bottoms out below 2% alpha long before its terminal
   stop, and every terminal stop resolves inside the element's box —
   nothing is ever sliced by an edge. The below-lip portion paints
   behind the near-opaque card (with a soft wrap past its top corners);
   above the lip the air itself carries a faint purple haze all the way
   up the band. Base opacity stays high — the sunrise is legible at
   every point of the pulse, not only at its peak.
   On top of the eased stops the whole element carries a REAL Gaussian
   blur (1.75rem): gradient stops alone still band under 2x zoom, and
   the blur is what guarantees no rim is traceable at any magnification.
   Every gradient terminal resolves inside the box (the box is 4rem
   taller than round 5's for exactly this reason — the largest ellipse's
   bottom now ends 2rem above the box edge), so the blur never softens
   a sliced edge, only true falloff. The crisp sun-line lives on the
   sibling .stage-sunline so it stays sharp outside this blur.          */
.stage-horizon {
  position: absolute;
  left: -24%;
  right: -24%;
  top: -26rem;
  height: 48rem;
  filter: blur(1.75rem);
  background:
    /* 1 · bright core hugging the lip */
    radial-gradient(
      26% 9.2% at 50% 54%,
      color-mix(in srgb, var(--inf-fg-1) 38%, var(--inf-deep-nebula)),
      color-mix(in srgb, var(--inf-deep-nebula) 66%, transparent) 38%,
      color-mix(in srgb, var(--inf-deep-nebula) 38%, transparent) 60%,
      color-mix(in srgb, var(--inf-deep-nebula) 17%, transparent) 78%,
      color-mix(in srgb, var(--inf-deep-nebula) 6%, transparent) 90%,
      transparent 100%
    ),
    /* 2 · inner bloom */
    radial-gradient(
        34% 14.7% at 50% 54%,
        color-mix(in srgb, var(--inf-deep-nebula) 46%, transparent),
        color-mix(in srgb, var(--inf-deep-nebula) 24%, transparent) 46%,
        color-mix(in srgb, var(--inf-deep-nebula) 10%, transparent) 72%,
        color-mix(in srgb, var(--inf-deep-nebula) 3%, transparent) 90%,
        transparent 100%
      ),
    /* 3 · outer bloom past the card corners */
    radial-gradient(
        42% 25.7% at 50% 54%,
        color-mix(in srgb, var(--inf-deep-nebula) 18%, transparent),
        color-mix(in srgb, var(--inf-deep-nebula) 10%, transparent) 44%,
        color-mix(in srgb, var(--inf-deep-nebula) 5%, transparent) 68%,
        color-mix(in srgb, var(--inf-deep-nebula) 2%, transparent) 86%,
        transparent 100%
      ),
    /* 4 · sky haze — rises the full band, near-subliminal alpha */
    radial-gradient(
        46% 42% at 50% 54%,
        color-mix(in srgb, var(--inf-deep-nebula) 9%, transparent),
        color-mix(in srgb, var(--inf-deep-nebula) 6%, transparent) 38%,
        color-mix(in srgb, var(--inf-deep-nebula) 3%, transparent) 62%,
        color-mix(in srgb, var(--inf-deep-nebula) 1%, transparent) 84%,
        transparent 100%
      );
  animation: stage-horizon-pulse 7s ease-in-out infinite;
}
/* A thin bright rim hugging the card's top edge — the sun-line of the
   sunrise, on the same centre line as the glow's ellipse. Its gradient
   fades to transparent at both ends, so it has no hard edges either.
   A SIBLING of .stage-horizon (not a pseudo) so the glow's Gaussian
   blur cannot smear it: the sun-line is the one deliberate crisp
   feature of the sunrise. Positioned in stage coordinates — y=0 is the
   card's top edge, and 26% insets match the old 34% of the wider glow
   box, so its span is unchanged.                                       */
.stage-sunline {
  position: absolute;
  left: 26%;
  right: 26%;
  top: -0.075rem;
  height: 0.15rem;
  border-radius: 1rem;
  background: linear-gradient(
    to right,
    transparent,
    color-mix(in srgb, var(--inf-fg-1) 55%, var(--inf-deep-nebula)) 30%,
    color-mix(in srgb, var(--inf-fg-1) 78%, var(--inf-deep-nebula)) 50%,
    color-mix(in srgb, var(--inf-fg-1) 55%, var(--inf-deep-nebula)) 70%,
    transparent
  );
  box-shadow: 0 0 0.9rem 0.15rem color-mix(in srgb, var(--inf-deep-nebula) 75%, transparent);
  /* Same breath as the glow it crowns (it used to inherit this as a
     pseudo of .stage-horizon). */
  animation: stage-horizon-pulse 7s ease-in-out infinite;
}
@keyframes stage-horizon-pulse {
  0%,
  100% {
    opacity: 0.85;
  }
  50% {
    opacity: 1;
  }
}

/* ── The hero branch graph — where the spine is BORN (bar mechanism 1) ────
   Round 3's hero was centred text on black and the thread only began 20%
   down the page; the reference's hero IS the spine's origin. Four curved
   merge lines fill the hero behind and around the headline, carry the cast
   as circular avatars plus milestone tags, a conflict count and a green
   build check at their nodes, descend, converge on ONE merge bead, and the
   trunk runs straight out of the hero's bottom edge into `.spine-line`.

   Geometry lives in a locked 1280×760 box so a node's percentage position
   and the SVG's viewBox coordinate are the same number: `x%` = `x/1280`.
   Nodes centre with the `translate` PROPERTY, not `transform`, so the
   motion law's `transform: var(--rv-from)` entry stays free to move them.
   The whole graph paints at z-index 0, under `.shell` (z-index 1), so the
   headline, the buttons, the pipeline card and the subnav are all above it
   and the trunk crosses BEHIND them — the same crossing the thread makes
   past the panels further down the page.

   The cast are ambient people on a graph. No names, no titles, no employer,
   no quotation: status marks and commit-ish labels only.                  */
.hero-graph {
  position: absolute;
  top: 0;
  left: 50%;
  translate: -50% 0;
  width: min(100%, 80rem);
  aspect-ratio: 1280 / 760;
  z-index: 0;
  pointer-events: none;
  /* The card elevation, restated locally: --cap-shadow-* is declared on
     .spine-track and the hero is outside it. */
  --hg-shadow: color-mix(in srgb, var(--inf-deep-space) 55%, transparent);
  --hg-av: clamp(2.625rem, 4.4vw, 4rem);
  --hg-mk: clamp(1.5rem, 2.4vw, 2.125rem);
}
.hg-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
/* The merge lines. `pathLength="1"` normalises every path, so one dash
   offset draws any of them; `non-scaling-stroke` keeps the hairline a
   hairline at every width instead of thinning with the viewBox. */
.hg-path {
  fill: none;
  stroke: var(--inf-hairline-loud);
  stroke-width: 1.25;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
/* The four branches are not equals, and until now only one of them said so:
   `.hg-p2`, `.hg-p3` and `.hg-p4` were in the markup with no rule of their
   own, so three of the four lines were literally the same weight and the
   graph read flat. They are on a depth ramp now — trunk brightest, patchset
   next, the two working branches receding — which is both the fix for the
   dead classes and the thing that lets the eye find the trunk.
   The upstream trunk: brighter than the branches that feed it, because it is
   the page's thread from here down. */
.hg-p1 {
  stroke: color-mix(in srgb, var(--inf-fg-1) 26%, transparent);
}
/* The patchset branch — the other long-lived line, one step back. */
.hg-p2 {
  stroke: color-mix(in srgb, var(--inf-fg-1) 19%, transparent);
}
/* The two working branches: conflict resolution and build. They exist for one
   milestone, so they sit furthest back. */
.hg-p3,
.hg-p4 {
  stroke: color-mix(in srgb, var(--inf-fg-1) 13%, transparent);
}
/* Entry: the law's curve and duration, staggered by the .rv-* utilities.
   Specificity (0,3,0) — `.upstream [data-reveal]` is (0,2,0) and is declared
   LATER in this file, so a (0,2,0) rule here loses the `transition`
   shorthand on order and the draw never runs. Measured: with the two-class
   selector the paths were snapping to drawn and only fading. The extra
   `.hg-svg` step is what makes the draw actually animate.

   THE ONE DRAW-ON. `.numchart .nc-draw[data-reveal]` (the fork-cost and
   why-now charts) carried a byte-identical copy of this in
   `upstream-numbers.css`; same law, same tokens, one rule now. Anything that
   draws a stroke joins this selector list rather than restating the
   dasharray trick. Note that a `stroke-dashoffset` is reached by neither an
   opacity nor a transform reset, so the reduced-motion blanket at the foot
   of this file CANNOT cover it — every draw-on also needs an explicit pin
   (the hero graph's is in that blanket; the charts' is at the foot of
   `upstream-numbers.css`). */
.upstream .hg-svg .hg-path,
.upstream .numchart .nc-draw[data-reveal] {
  opacity: 0;
  transform: none;
  /* `pathLength="1"` on every path normalises its length to one user unit,
     so one dash value draws any of them. The unit matters: a bare `1` is a
     valid SVG presentation-attribute value but NOT a valid CSS length, and
     Chrome drops it to 0 — measured, the paths were fading in without ever
     drawing. `1px` is one user unit, which is the whole normalised path. */
  stroke-dasharray: 1px;
  stroke-dashoffset: 1px;
  transition:
    opacity var(--inf-dur-reveal) var(--inf-ease-expo) var(--rv-delay, 0ms),
    stroke-dashoffset var(--inf-dur-reveal) var(--inf-ease-expo) var(--rv-delay, 0ms);
}
.upstream .hg-svg .hg-path.in,
.upstream .numchart .nc-draw[data-reveal].in {
  opacity: 1;
  stroke-dashoffset: 0px;
}

/* Nodes. `translate` centres, `transform` is left to the entrance. */
.hg-node,
.hg-tag,
.hg-card {
  position: absolute;
  left: var(--hg-x);
  top: var(--hg-y);
  translate: -50% -50%;
}
/* A face on the graph — the reference's avatar node, in our register: a
   circular crop, a hairline ring and the card's elevation, nothing else.
   Ambient people. Nothing is attributed to them anywhere on the page. */
.hg-av {
  width: var(--hg-av);
  height: var(--hg-av);
  border-radius: var(--inf-radius-pill);
  border: 1px solid var(--inf-hairline-loud);
  background: var(--inf-void);
  overflow: hidden;
  box-shadow: 0 0.375rem 1.25rem var(--hg-shadow);
}
.hg-av img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* THE FACES HAVE TO READ AS FACES. The cast is shot low-key on a near-black
     ground: legible at the source 192px, but scaled into a 42–64px disc on
     the deep-space page they collapsed into grey buttons — two critics and the owner
     all called the portraits the weakest thing in the hero. They are the only
     human signal in an otherwise mechanical graph, so they are lifted here
     rather than dimmed further: brightness carries back the midtones the
     downscale eats, a small contrast step keeps the lift from going milky,
     and the saturation step puts skin tone back in. Corrected at the image,
     not at the ring, so the node chrome stays identical to every other node
     on the graph. */
  filter: brightness(1.62) contrast(1.06) saturate(1.14);
}
/* A status mark on the graph: a merge octicon, a security respin, a green
   build. Phosphor subset only — no inline icon set, no glyph standing in
   for one. */
.hg-mark {
  display: grid;
  place-items: center;
  width: var(--hg-mk);
  height: var(--hg-mk);
  border-radius: var(--inf-radius-pill);
  border: 1px solid var(--inf-hairline);
  background: color-mix(in srgb, var(--inf-void) 92%, var(--inf-deep-space));
  box-shadow: 0 0.25rem 0.75rem var(--hg-shadow);
}
.hg-mark .ph {
  font-size: var(--inf-type-caption-size);
  color: var(--inf-fg-3);
}
.hg-mark--ok .ph {
  color: var(--upstream-accent);
}
/* A commit-ish label: a milestone tag, a file count, a conflict count.
   Mono, never display type — this is a graph label, not a statistic. */
.hg-tag {
  padding: var(--inf-space-0_5) var(--inf-space-1);
  border-radius: var(--inf-radius-sm);
  border: 1px solid var(--inf-hairline);
  background: color-mix(in srgb, var(--inf-void) 92%, var(--inf-deep-space));
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  line-height: 1.2;
  letter-spacing: 0.04em;
  color: var(--inf-fg-3);
  white-space: nowrap;
}
/* ── A content-bearing card on a branch ──────────────────────────────────
   The gap the reference exposes. `/team` hangs cards that SAY something off
   its graph — "califa commented 4 days ago · Could someone take a look at
   this PR?" — while round 4's graph hung bare 20px mono chips. Chips annotate
   a wireframe; cards make it a repository somebody is working in.

   Ours carry fork-operations output in the tool voice the fragment clusters
   already established: a system handle, then one line of what that system
   reported. ATTRIBUTED TO SYSTEMS ONLY — a branch, a CI runner, a security
   respin. Never a person, never a handle that could be read as somebody's
   identity: the portraits on this graph stay ambient and nothing on this page
   is put in anyone's mouth.

   Same material as `.hg-tag` and `.hg-mark` (void-on-deep-space, hairline,
   the graph's shared shadow) so a card is a bigger node, not a new object. */
.hg-card {
  width: 13.5rem;
  /* The hero centres its text; a card is a quoted line of tool output and
     reads left, like every other body of text on the page. */
  text-align: left;
  padding: var(--inf-space-1) var(--inf-space-2) calc(var(--inf-space-1) + var(--inf-space-0_5));
  border-radius: var(--inf-radius-lg);
  border: 1px solid var(--inf-hairline);
  background: color-mix(in srgb, var(--inf-void) 92%, var(--inf-deep-space));
  box-shadow: 0 0.375rem 1.25rem var(--hg-shadow);
}
.hg-card-head {
  display: flex;
  align-items: center;
  gap: var(--inf-space-0_5);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-3xs-size);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--inf-fg-3);
}
.hg-card-head .ph {
  font-size: var(--inf-type-caption-size);
  color: var(--inf-fg-3);
}
/* The one licensed accent on the graph, on the one card that reports a pass —
   the same rationing `.hg-mark--ok` already follows. */
.hg-card--ok .hg-card-head .ph {
  color: var(--upstream-accent);
}
.hg-card-body {
  display: block;
  margin-top: var(--inf-space-0_5);
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-2xs-size);
  line-height: 1.5;
  color: var(--inf-fg-2);
}
/* The convergence. Same bead as every node on the spine below — same core,
   same knockout, same ring, same purple bloom — because it is the same
   thread's first mark. */
.hg-merge {
  width: 1.625rem;
  height: 1.625rem;
  border-radius: var(--inf-radius-pill);
  background:
    radial-gradient(circle closest-side, var(--inf-fg-1) 0 52%, transparent 53%),
    var(--inf-deep-space);
  border: 1px solid var(--inf-hairline-loud);
  box-shadow: var(--inf-glow-purple);
}
/* The trunk. Runs from the merge bead to the hero's bottom edge, where the
   hero's own `overflow: hidden` cuts it and `.spine-line` picks it up at
   full strength. 200vh is simply "further than the hero can ever be". */
.hg-thread {
  position: absolute;
  left: 50%;
  top: 98.03%;
  width: 1px; /* hairline — the documented raw-px exception */
  margin-left: -0.5px; /* sub-pixel optical centring of that hairline */
  height: 200vh;
  background: color-mix(in srgb, var(--inf-fg-1) 26%, transparent);
}

/* Node placement. x% is the viewBox x over 1280, y% the viewBox y over 760,
   so a node and the path point it sits on are literally the same number. */
.hg-n1 {
  --hg-x: 7.81%;
  --hg-y: 9.21%;
}
.hg-t1 {
  --hg-x: 7.73%;
  --hg-y: 20.79%;
}
.hg-n2 {
  --hg-x: 7.66%;
  --hg-y: 32.89%;
}
.hg-t2 {
  --hg-x: 7.66%;
  --hg-y: 41.84%;
}
.hg-n3 {
  --hg-x: 92.19%;
  --hg-y: 13.68%;
}
.hg-t3 {
  --hg-x: 91.95%;
  --hg-y: 25.26%;
}
.hg-n4 {
  --hg-x: 91.25%;
  --hg-y: 39.47%;
}
.hg-t4 {
  --hg-x: 91.25%;
  --hg-y: 48.42%;
}
.hg-n5 {
  --hg-x: 13.28%;
  --hg-y: 61.84%;
}
.hg-t5 {
  --hg-x: 25.78%;
  --hg-y: 81.58%;
}
.hg-n6 {
  --hg-x: 84.38%;
  --hg-y: 65.79%;
}
.hg-n7 {
  --hg-x: 78.91%;
  --hg-y: 78.95%;
}
.hg-n8 {
  --hg-x: 67.19%;
  --hg-y: 80.26%;
}
.hg-t9 {
  --hg-x: 50%;
  --hg-y: 90.79%;
}
.hg-n9 {
  --hg-x: 50%;
  --hg-y: 98.03%;
}
/* Round 5 additions — the density pass. Eight artefact nodes against the
   reference's fourteen was the count the critics measured; these take it to
   thirteen artefacts plus three cards, and two more faces down by the merge
   so the human signal is not confined to the four corners. Every one of them
   sits on a branch point or hangs directly off one. */
.hg-n10 {
  --hg-x: 21.17%;
  --hg-y: 65%;
}
.hg-t10 {
  --hg-x: 21.17%;
  --hg-y: 72.11%;
}
.hg-n13 {
  --hg-x: 33.59%;
  --hg-y: 92.76%;
}
.hg-n14 {
  --hg-x: 60.16%;
  --hg-y: 86.84%;
}
/* The three cards. Placed clear of the shell's text column at every width
   the graph is visible at — they are hidden below 1100px (see the responsive
   block) rather than allowed to collide with the headline. */
.hg-c1 {
  --hg-x: 13.67%;
  --hg-y: 50%;
}
.hg-c2 {
  --hg-x: 35.55%;
  --hg-y: 73.03%;
}
.hg-c3 {
  --hg-x: 84.77%;
  --hg-y: 88.42%;
}
/* Below 1100px the graph box scales with the viewport but a card does not —
   its type has a floor — so the cards grow relative to the frame and start
   crowding the shell's text column. They are the graph's most expendable
   layer: the branches, the faces and the chips all still read without them,
   and the convergence is what has to survive the narrow column. */
@media (max-width: 1100px) {
  .hg-card {
    display: none;
  }
}
/* Entry direction follows the side of the graph the node settles on —
   nothing crosses the trunk to reach its place. */
.hg-n1,
.hg-t1,
.hg-n2,
.hg-t2,
.hg-n5,
.hg-n10,
.hg-t10,
.hg-t5,
.hg-n13,
.hg-c1,
.hg-c2 {
  --rv-from: var(--rv-from-left-lg);
  --rv-dur: var(--inf-dur-reveal-scale);
}
.hg-n3,
.hg-t3,
.hg-n4,
.hg-t4,
.hg-n6,
.hg-n7,
.hg-n8,
.hg-n14,
.hg-c3 {
  --rv-from: var(--rv-from-right-lg);
  --rv-dur: var(--inf-dur-reveal-scale);
}
/* The merge and its milestone label sit ON the trunk: no side to enter
   from, so they only scale up into place. */
.hg-t9,
.hg-n9 {
  --rv-from: scale(var(--inf-reveal-scale));
  --rv-dur: var(--inf-dur-reveal-scale);
}

/* NOTE: the reduced-motion branch for this whole stage block lives BELOW the
   `.upstream-mock.in .mock-window::before` declaration, not here. It used to
   sit at this point in the file and was DEAD: it and the animating rule have
   identical specificity, so the later one won and `lip-light-enter 1.2s` kept
   running under `prefers-reduced-motion: reduce`. Cascade order is part of a
   kill switch working. See the block after @keyframes lip-light-enter.     */

/* The card body is near-opaque so the horizon glow terminates at its top
   edge and silhouettes the window instead of washing the first row. The
   glass LOOK stays — hairline border, and the backdrop blur still acts on
   the whisper of translucency that remains — but no stage light shows
   through the body.                                                     */
.mock-window {
  position: relative;
  text-align: left;
  background: color-mix(in srgb, var(--inf-void) 96%, transparent);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  border: 1px solid var(--inf-hairline);
  /* The lip catches the stage light: the top border itself warms toward
     the glow while the sides and bottom stay neutral hairline.          */
  border-top-color: color-mix(in srgb, var(--inf-deep-nebula) 55%, var(--inf-hairline));
  border-radius: var(--inf-radius-xl);
  box-shadow: var(--inf-shadow-lg);
  overflow: hidden;
}
/* Light spilling over the card's top lip. ROUND 4: the glossy cartoon cast
   is retired, so the five per-mascot layers this carried — two contact
   reflections, two catch-light reinforcements and Spark's pink pool — are
   gone with them; they were reflections of nothing. What remains is the
   part that was always doing the work: ONE continuous centre-weighted
   highlight running the frame's whole top edge, brightest at centre under
   the horizon glow's core and decaying smoothly toward both corners, the
   matching purple bloom washing down from the same centre, and a faint even
   rim so the corners never go dark. Every layer fades to nothing inside this
   strip — the only hard boundary is the card's own rounded border (overflow
   clips it), which is exactly where spilled light would die. Paints above
   the bar chrome (it is light ON the surface) but is pointer-transparent.
   ENTRANCE CLAMP: the whole lip treatment starts at opacity 0 and is gated
   on the .upstream-mock reveal, fading in over the same entrance beat and
   curve — a monotonic ramp from dark to the resting level, so no frame of
   the intro ever shows the lip brighter than it is at rest.              */
.mock-window::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 4rem;
  pointer-events: none;
  opacity: 0;
  background:
    /* THE continuous highlight: one wide, thin ellipse pinned to the top
       edge, spanning the full lip (56% horizontal radius > the 50% half-
       width, so it still carries light at the corners), brightest at
       centre under the glow's core, eased all the way out. Horizontal
       radius is % of card width so it scales at every breakpoint. */
    radial-gradient(
      56% 0.6rem at 50% 0,
      color-mix(in srgb, var(--inf-fg-1) 85%, transparent),
      color-mix(in srgb, var(--inf-fg-1) 52%, transparent) 34%,
      color-mix(in srgb, var(--inf-fg-1) 26%, transparent) 62%,
      color-mix(in srgb, var(--inf-fg-1) 10%, transparent) 84%,
      transparent 100%
    ),
    /* the glow's purple bloom washing down the lip from the same centre */
    radial-gradient(
        58% 3.4rem at 50% 0,
        color-mix(in srgb, var(--inf-deep-nebula) 52%, transparent),
        color-mix(in srgb, var(--inf-deep-nebula) 26%, transparent) 46%,
        color-mix(in srgb, var(--inf-deep-nebula) 10%, transparent) 76%,
        transparent 100%
      ),
    /* faint even rim across the whole lip — corners never go dark */
    linear-gradient(
        to bottom,
        color-mix(in srgb, var(--inf-deep-nebula) 20%, transparent),
        color-mix(in srgb, var(--inf-deep-nebula) 7%, transparent) 30%,
        transparent 100%
      );
}
/* The lip light enters with the card: same duration, same curve, same
   reveal gate — so the card and the light it catches arrive as one event,
   and mid-entrance the lip is always dimmer than at rest, never hotter. */
.upstream-mock.in .mock-window::before {
  animation: lip-light-enter var(--inf-dur-reveal) var(--inf-ease-expo) both;
  animation-delay: var(--inf-stagger);
}
@keyframes lip-light-enter {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* ── Reduced motion: the hero stage's kill switch ────────────────────────
   Placed HERE, after every animating declaration in the stage block, and
   armed with !important. Both matter. Round 2 put this block above
   `.upstream-mock.in .mock-window::before`; the two selectors have identical
   specificity, so the animating rule won on order and the branch never fired
   — measured, the lip light still ran its full entrance under
   `prefers-reduced-motion: reduce`. Order alone would fix it today and break
   again the next time a rule is appended below, so the switch is also made
   unconditional.

   YOU NO LONGER HAVE TO REMEMBER THIS BLOCK. The blanket rule at the END of
   this file zeroes every duration under `.upstream`, so a new animation is
   covered whether or not it is named here. What survives locally is the END
   STATE — the frame a killed animation must rest on. Add a local rule only
   when zero duration alone would leave something mid-entrance.             */
@media (prefers-reduced-motion: reduce) {
  .mock-window::before,
  .upstream-mock.in .mock-window::before {
    opacity: 1;
    animation: none !important;
  }
  .stage-horizon,
  .stage-sunline {
    animation: none !important;
  }
}
.mock-bar {
  display: flex;
  align-items: center;
  gap: var(--inf-space-2);
  padding: var(--inf-space-2) var(--inf-space-3);
  border-bottom: 1px solid var(--inf-hairline-soft);
  background: color-mix(in srgb, var(--inf-void) 75%, transparent);
}
.mock-dots {
  display: flex;
  gap: 6px;
  flex: none;
}
.mock-dots i {
  width: 10px;
  height: 10px;
  border-radius: var(--inf-radius-pill);
}
.mock-dots i:nth-child(1) {
  background: var(--inf-electric-blue);
}
.mock-dots i:nth-child(2) {
  background: var(--inf-deep-nebula);
}
.mock-dots i:nth-child(3) {
  background: var(--inf-hyper-pink);
}
.mock-title {
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  color: var(--inf-fg-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.mock-badge {
  margin-left: auto;
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-2xs-size);
  color: var(--upstream-accent);
  background: var(--upstream-accent-wash);
  border: 1px solid var(--upstream-accent-line);
  border-radius: var(--inf-radius-pill);
  padding: 3px 10px;
  white-space: nowrap;
  flex: none;
}
.mock-body {
  padding: var(--inf-space-1) var(--inf-space-3) var(--inf-space-2);
}
.mock-row {
  display: flex;
  align-items: baseline;
  gap: var(--inf-space-2);
  /* Optical row rhythm — the pipeline card's five rows read as a list, not
     as five stacked cards. rem, not bare px. */
  padding: 0.8125rem 0;
  border-bottom: 1px solid var(--inf-hairline-soft);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-xs-size);
  line-height: 1.5;
}
.mock-row:last-child {
  border-bottom: 0;
}
.mock-row .ph {
  color: var(--upstream-accent);
  font-size: var(--inf-type-sm-size);
  flex: none;
  transform: translateY(2px);
}
.mock-row .lbl {
  color: var(--inf-fg-1);
  font-weight: 500;
}
.mock-row .det {
  margin-left: auto;
  color: var(--inf-fg-3);
  text-align: right;
}
.mock-row--next .ph,
.mock-row--next .lbl {
  color: var(--inf-fg-3);
  font-weight: 400;
}
.mock-pulse {
  flex: none;
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: var(--inf-radius-pill);
  background: var(--upstream-accent);
  box-shadow: var(--inf-glow-blue);
  align-self: center;
  /* AMBIENT REGISTER, per the adjudicated two-register rule. This is an
     infinite symmetric oscillation, so it takes `ease-in-out` and NOT the
     entrance curve: `--inf-ease` resolves to expo-out under `.upstream`
     (see ANCHOR upstream-ease), which is a one-shot deceleration and lands
     a velocity discontinuity at every cycle boundary of a breath. Same
     class of fix as the `.fs-glint` twinkle and the horizon pulse. */
  animation: upstream-pulse 1.6s ease-in-out infinite;
}
@keyframes upstream-pulse {
  0%,
  100% {
    opacity: 0.45;
  }
  50% {
    opacity: 1;
  }
}
@media (prefers-reduced-motion: reduce) {
  .mock-pulse {
    animation: none !important;
  }
}
.mock-caption {
  position: relative;
  text-align: center;
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-caption-size);
  color: var(--inf-fg-3);
  margin: var(--inf-space-2) 0 0;
}
.mock-caption a {
  color: var(--upstream-accent);
  text-decoration: none;
}

/* ── Big-number band — "by the numbers" ──────────────────────────────── */
.bignum {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  /* Numbers get their own stage: generous air between and above figures. */
  gap: var(--inf-space-6) var(--inf-space-6);
  margin-top: var(--inf-space-6);
}
.bignum .bn {
  position: relative;
  padding-left: var(--inf-space-3);
}
/* Column separators: all three are the SAME two-part rule — a full-height
   soft hairline (::before) with a bright accent tick over its top 28px
   (::after). The tick carries its own faint glow so it reads identically
   bright whether its lane sits on dark void or on the edge of a crest. */
.bignum .bn::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--inf-hairline-soft);
}
.bignum .bn::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 1px;
  height: 28px;
  background: var(--upstream-accent);
  box-shadow: 0 0 0.375rem color-mix(in srgb, var(--inf-electric-blue) 55%, transparent);
}
/* ── SCOPED TO THE DIRECT CHILD, 8 September 2026 ─────────────────────────
   This was `.bignum b`, a descendant selector carrying `display: block`, a
   58px display size and a gradient text fill. So ANY `<b>` anyone ever put
   inside a figure's gloss became a second full-size exhibit, on its own
   line, in the middle of a sentence — which is exactly what happened when
   the home band's gloss gained a with-method figure: the caption grew a
   58px gradient numeral mid-sentence and the band's cells went to 254px.

   The figure is always the direct child of `.bn`, so that is what the rule
   selects now. Emphasis inside a gloss is `.bn-alt` below. The same descendant
   trap existed on the two size overrides (`--quad`, `--two-row`) and both are
   scoped the same way for the same reason.                                */
.bignum .bn > b {
  display: block;
  font-family: var(--inf-font-display);
  font-weight: 700;
  /* ≥3× body size (17px doc body → 51px+): the figure is the exhibit. */
  font-size: clamp(3.25rem, 4.6vw, 4rem);
  line-height: 1.05;
  letter-spacing: var(--inf-type-display1-ls);
  font-variant-numeric: tabular-nums;
  /* ONE gradient recipe for all three figures: the gradient box hugs the
     numeral (fit-content) and the sweep is HORIZONTAL with explicit stops
     — a diagonal ramp samples differently on boxes of different aspect
     ratios, so 90deg is the only angle where the first glyph of every
     figure is guaranteed to open on the same hue (electric blue at 0%). */
  width: fit-content;
  background: linear-gradient(
    90deg,
    var(--inf-electric-blue) 0%,
    var(--inf-deep-nebula) 55%,
    var(--inf-hyper-pink) 100%
  );
  background-size: 100% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  padding-bottom: 2px;
}
.bignum .bn > b.pending {
  background: none;
  -webkit-text-fill-color: initial;
  color: var(--upstream-flag);
}
.bignum .bn > span {
  display: block;
  margin-top: var(--inf-space-2);
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-label-size);
  line-height: 1.6;
  color: var(--inf-fg-3);
  max-width: 34ch;
}

/* ── Stat-band modifiers — the three genuine variations ──────────────────
   Four builders solved this object four ways: gradient numerals with a
   hairline rule per cell (index), flat accent numerals with an underline
   (proof), gradient numerals over a purple bloom (why-now), plain white
   numerals in a bordered box (the Rebase Note). The treatment above is the
   one that won the loop and it is now the only one — every band is gradient
   numerals, one hairline-plus-accent-tick divider per cell, each figure on
   its own identical crest.

   What actually differed between the four, and therefore what earns a
   modifier, is three things:

     · a FOOTNOTE SUPERSCRIPT after a figure's label — that is `.fnref`,
       already a component, and it needs nothing here;
     · an ACCENT-COLOURED figure — `b.pending` above, which swaps the
       gradient for the flag colour. That is a semantic marker (a figure not
       yet cleared for public use), not a second style, and it is the reason
       /upstream/proof/ still reads magenta after the conversion;
     · the COUNT and the CONTAINER — the two modifiers below.

   Everything else the four had in common lives in `.bignum` and stays there.
   The crest glints (`.bignum .fs-glint`) are deliberately NOT part of this
   set: their rem offsets are tuned to the index's clamp, and on the quad
   ramp they would sit outside the glyph run. They are a property of the
   index's lit stage, not of the band.                                     */

/* FOUR figures where the index has three. Same ramp one step down, so a
   date-length or short compound figure stays on one line in the narrower
   column instead of wrapping to two. (The two figures this was originally
   measured against were the Rebase Note's "8 Sep 2026" and a security-window
   figure that has since been retired — see `docs/publication-guardrails.md`.
   The ramp is unchanged; only the example is.) */
.bignum--quad .bn > b {
  font-size: clamp(2rem, 3vw, 2.75rem);
}
/* ── EMPHASIS INSIDE A GLOSS ──────────────────────────────────────────────
   A second figure that belongs to the sentence rather than to the exhibit:
   the home band states the cost of a fork kept the usual way as its figure,
   and the same fork kept with a working method inside the gloss. That is a
   comparison, so it needs weight — but it is prose weight, inline, in the
   caption's own size. It reads in the accent because the whole point of the
   sentence is that the second number is smaller than the first. */
.bignum .bn > span .bn-alt {
  display: inline;
  font-family: var(--inf-font-mono);
  font-size: inherit;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
  color: var(--upstream-accent);
  white-space: nowrap;
}
/* ── FOUR TRACKS, DECLARED ─────────────────────────────────────────────────
   Added 8 September 2026, and it fixes a defect that predates the band it
   was found on.

   `.bignum` reflows on `repeat(auto-fit, minmax(220px, 1fr))`, and
   `.bignum--boxed` sets `gap: 0`. At the 1216px shell that is
   floor(1216 / 220) = FIVE tracks, not four. `auto-fit` collapses tracks it
   cannot fill, so a four-figure band coincidentally rendered as four columns
   of 304px and looked correct — but every band with more than four figures
   filled all five. The eight-figure measured-numbers band on
   `/upstream/proof/` was therefore laid out 5 + 3, ragged, while this file's
   own comments and `upstream-proof.css`'s divider exemptions both described
   it as 4 + 4. The exemptions were aiming at cells that were never at the
   head of a row.

   `--quad` means four to a row, so it says four. Measured: identical for the
   four-figure consumers (four tracks of 303.5px either way, because the
   fifth was already collapsing to 0px), and correct for the twelve-figure
   band, which now lays out 4 + 4 + 4.

   ONLY ABOVE 1024px. Below it the component's own `auto-fit` takes back over
   and steps 4 → 3 → 2 → 1 on width, which is the reflow this band has always
   used and which four tracks hard-coded to the bottom would break. Twelve
   figures divide cleanly by 4, 3, 2 and 1, so no column count rags — one of
   the reasons the band went to twelve rather than to ten or eleven.

   `upstream-proof.css` drops every divider below 1024px to match, since
   "which cell opens a row" stops being 4n+1 the moment the track count
   changes. The two breakpoints are the same number for that reason; move one
   and move the other. */
@media (min-width: 1024px) {
  .bignum--quad {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}
/* Four figures share the width three had, so on a spine page the rule at the
   head of column three lands in the thread's lane. MEASURED at 1440, rather
   than repeating the claim this rule inherited: the thread is at x=720 and
   that rule at x=744 — 24px apart, adjacent rather than coincident, which is
   worse than either alone. Two near-parallel verticals 24px apart inside a
   316px column read as an argument about which one is the spine. The lane
   belongs to the thread, so the local hairline and its accent tick come off
   it. Scoped to `.spine-track`: off the thread — the Rebase Note's boxed
   band — there is nothing to collide with and the divider is wanted. */
.spine-track .bignum--quad .bn:nth-child(3)::before,
.spine-track .bignum--quad .bn:nth-child(3)::after {
  content: none;
}

/* A BORDERED CONTAINER, for a band that is not staged on a lit frame. The
   Rebase Note has no `.frame-stage`, so without a container its four figures
   read as loose text on the page rather than as one object. The cells keep
   the component's own divider; the container supplies the glass panel round
   them, and the first cell drops its divider because it would otherwise sit
   directly on the panel's own border. */
.bignum--boxed {
  gap: 0;
  border: var(--inf-glass-border);
  border-radius: var(--inf-radius-xl);
  background: var(--inf-glass-bg);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  overflow: hidden;
}
.bignum--boxed .bn {
  padding: var(--inf-space-3);
}
.bignum--boxed .bn:first-child::before,
.bignum--boxed .bn:first-child::after {
  content: none;
}

/* ── Icon-tile card grids — DELETED, and the note is the point ───────────
   `.icards` / `.icard` / `.icard:hover` / `.gtile` / `.gtile .ph` /
   `.icard h3` / `.icard p` / `.icard p + p` and the `.icard` entry in the
   900px block all lived here. Not one of them matched an element on any of
   the seven Upstream pages — the last icon-tile grid was the fork-cost
   model's six inputs, and that became the `.inputs` definition list (see the
   note in `upstream-numbers.css`). Whatever referenced them was rewritten
   and the CSS stayed behind, which is the same defect Task 3 swept out of
   `.figs` and `.steps`.

   IT WAS ALSO ABOUT TO BE LAUNDERED INTO A "SHARED" RULE. The reconciliation
   brief listed `.tl-card h3` (evidence layer) as a byte-identical duplicate
   of `.icard h3` and asked for one shared card-title rule. The two were duly
   merged here — and only then did the orphan sweep documented at the top of
   this file show that the `.icard` half had no consumer at all. The
   duplication was not resolved by sharing it; it was resolved by deleting
   the dead half, which left `.tl-card h3` with exactly one consumer, so it
   went back to the evidence layer where that consumer lives.

   RUN THE SWEEP BEFORE THE LIFT, NOT AFTER. A dead rule merged into a live
   selector list is invisible forever.                                      */

/* The arrow link — one pattern everywhere a card or section points at
   another page: accent colour, Title Case label, Phosphor arrow that
   nudges right on hover. (Generalised from the offer cards so sections
   can use it too.) */
.upstream .more {
  display: inline-flex;
  align-items: center;
  gap: var(--inf-space-1);
  margin-top: var(--inf-space-2);
  font-family: var(--inf-font-sans);
  font-weight: 600;
  font-size: var(--inf-type-label-size);
  color: var(--upstream-accent);
  text-decoration: none;
}
.upstream .more .ph {
  transition: transform var(--inf-dur) var(--inf-ease);
}
.upstream .more:hover .ph {
  transform: translateX(3px);
}
/* Section-level arrow link — stands under a band, clear of it. */
.upstream .sect-more {
  margin-top: var(--inf-space-4);
}

/* ── Gradient quote band ─────────────────────────────────────────────── */
.quote-band {
  position: relative;
  margin: var(--inf-space-6) 0 var(--inf-space-1);
  padding: clamp(32px, 6vw, 64px);
  border-radius: var(--inf-radius-xl);
  border: 1px solid var(--inf-hairline);
  overflow: hidden;
  background:
    radial-gradient(
      120% 150% at 88% -10%,
      color-mix(in srgb, var(--inf-hyper-pink) 22%, transparent),
      transparent 55%
    ),
    radial-gradient(
      130% 160% at -10% 110%,
      color-mix(in srgb, var(--inf-electric-blue) 20%, transparent),
      transparent 55%
    ),
    linear-gradient(
      135deg,
      color-mix(in srgb, var(--inf-deep-nebula) 45%, var(--inf-deep-space)),
      var(--inf-deep-space) 70%
    );
  box-shadow: var(--inf-glow-purple);
}
.quote-band blockquote {
  margin: 0;
  font-family: var(--inf-font-display);
  font-weight: 700;
  font-size: clamp(1.625rem, 3.6vw, 2.875rem);
  line-height: 1.15;
  letter-spacing: var(--inf-type-display1-ls);
  color: var(--inf-fg-1);
  max-width: 26ch;
  text-wrap: balance;
}
.quote-band figcaption {
  margin-top: var(--inf-space-3);
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-label-size);
  line-height: 1.6;
  color: var(--inf-fg-2);
  max-width: 60ch;
}

/* ── Statement band — full-width nebula wash transition ──────────────── */
.statement-band {
  text-align: center;
  overflow: hidden;
}
.statement-band::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -40%;
  transform: translateX(-50%);
  width: min(1100px, 120vw);
  height: 90%;
  background: radial-gradient(
    ellipse at 50% 100%,
    color-mix(in srgb, var(--inf-deep-nebula) 45%, transparent),
    transparent 65%
  );
  pointer-events: none;
}
.statement-big {
  position: relative;
  font-family: var(--inf-font-display);
  font-weight: 700;
  font-size: clamp(1.875rem, 4.4vw, 3.375rem);
  line-height: 1.15;
  letter-spacing: var(--inf-type-display1-ls);
  color: var(--inf-fg-1);
  max-width: 24ch;
  margin: 0 auto;
  text-wrap: balance;
}
.statement-big .grad {
  background: var(--inf-gradient-brand);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.statement-sub {
  position: relative;
  margin: var(--inf-space-3) auto 0;
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-xs-size);
  color: var(--inf-fg-3);
}

/* ── Landing-component responsive ────────────────────────────────────── */
@media (max-width: 900px) {
  /* Band is ~11–12rem here (21vw); scale the cast down and keep every
     mascot inside it, occlusion ratios intact. The horizon element's
     percentage overhang scales with the card on its own; only the lip
     catch-light hotspots move to track the repositioned mascots. */
  .upstream-stage::before {
    top: -16rem;
    height: 28rem;
  }
}
@media (max-width: 700px) {
  /* A shallower but still-dedicated band; Tab bows out, the two
     crest-riders stay tucked behind the card. */
  .upstream-mock {
    margin-top: clamp(6.5rem, 20vw, 8rem);
  }
  /* Same 54%-lip geometry at half scale (0.54 × 24rem − 13rem ≈ 0), so
     the bright core still sits exactly on the card's top edge. */
  .stage-horizon {
    top: -13rem;
    height: 24rem;
  }
  .upstream-stage::before {
    top: -12rem;
    height: 22rem;
  }
  .mock-row {
    flex-wrap: wrap;
  }
  .mock-row .det {
    flex-basis: 100%;
    /* Wrapped, the detail hangs under the label: past the row icon and its
       gap, derived rather than measured off the screen. */
    margin-left: calc(var(--inf-type-sm-size) + var(--inf-space-2));
    text-align: left;
  }
  .bignum {
    gap: var(--inf-space-3) var(--inf-space-4);
  }
}
@media (max-width: 560px) {
  .mock-badge {
    display: none;
  }
}

/* ── Draft banner (internal only; never renders on a published note) ──── */
.draft-banner {
  border: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--inf-hyper-pink) 40%, transparent);
  background: var(--upstream-flag-wash);
  border-radius: 0;
  margin: 0;
  max-width: none;
  padding: var(--inf-space-2) 0;
}
.draft-banner .k {
  color: var(--upstream-flag);
}
.draft-banner p {
  margin: 0;
  color: var(--inf-fg-2);
}

/* =========================================================================
   Below-hero stagecraft — Piece B.

   The hero's techniques (one horizon light per scene, Gaussian-blurred so
   no rim is ever traceable; clean darkness around the lit subject; tiny
   star glints; calm one-directional entrances) reused at section scale.
   Token-pure: every colour is a var(--inf-*) or a color-mix of tokens;
   every new length is rem/em.
   ========================================================================= */

/* ── THE MOTION LAW — one law, whole surface, no exceptions ────────────────
   styles.css provides the observer contract (main.js adds `.in` once per
   element). Everything else about how Upstream moves is decided here, once:

     easing     var(--inf-ease-expo)      cubic-bezier(0.16, 1, 0.3, 1)
     duration   var(--inf-dur-reveal)     600ms standard
                var(--inf-dur-reveal-scale) 700ms when the entry also scales
     stagger    var(--inf-stagger)        100ms increments — 0/100/200/300
     entry      var(--inf-reveal-shift)   15px translate
                var(--inf-reveal-shift-lg) + scale(.9) for the scale variant

   The previous law (640ms ease-out, 110/220ms .rv-* delays, a 130ms grid
   cascade, and the base 700ms `ease` from styles.css on the hero) is gone —
   migrated onto these tokens rather than left running alongside. The scope is
   `.upstream`, not `.upstream-section`, so the hero obeys the same law as the
   sections; and `--inf-ease` itself is re-pointed at the expo curve for the
   whole subtree below, so hover and state transitions ride the same curve as
   entrances.

   Re-pointing the token is NOT sufficient on its own, and round 1 shipped a
   comment claiming it was. `styles.css` writes three literal `200ms ease`
   transitions — `.btn`, `.nav-toggle-bars` and `.skip-link` — and a token
   cannot reach a value that was never written as a token. Every one of them is
   re-declared below, scoped to `.upstream` so the parent site's own pages keep
   the transitions they were designed with. Measured after the fact, not
   asserted: `transitionTimingFunction` on every animating element under
   `.upstream` is the expo curve.

   ── TWO REGISTERS, NOT TWO LAWS. ADJUDICATED — DO NOT RE-LITIGATE. ──────
   Three rounds of critics have re-found the same 36 `ease-in-out` curves and
   called them a second law. They are not, and the frozen design system now
   says so in writing. Read this before you "fix" them:

     · ENTRANCE REGISTER — THE LAW. Every transition, and every one-shot
       entry animation: cubic-bezier(0.16, 1, 0.3, 1), 600ms / 700ms when the
       entry also scales, 100ms stagger, direction-aware. Hover and state
       feedback ride the same curve at --inf-dur (300ms); the bar's 400ms
       floor governs scroll-gated entrances, not hover.
     · AMBIENT REGISTER — SANCTIONED. Infinite, symmetric oscillations only:
       the horizon breath (stage-horizon-pulse), the frame-stage glints
       (stage-twinkle), and one literal blinking caret (film-caret, steps(2)).
       These may use ease-in-out. Expo-out is a one-shot deceleration curve;
       on an infinitely alternating loop it produces a velocity discontinuity
       at every cycle boundary. It is mechanically wrong for a breath.

   The boundary holds only because it holds EXACTLY. The moment an entrance,
   a hover or any state change picks up ease-in-out, that is two laws and a
   hard fail. Anything you add here must land in one register or the other,
   and must carry a reduced-motion branch either way.

   Verify with BOTH `transitionTimingFunction` AND `animationTimingFunction`,
   on every element plus ::before and ::after, splitting the shorthand with a
   paren-depth-aware parser — a plain comma split chops
   `cubic-bezier(0.16, 1, 0.3, 1)` into four garbage tokens, which is how
   round 2's sweep produced a false pass. Round 4 measured: 0 non-expo
   transitions and 0 non-expo ONE-SHOT animations across all six pages;
   18 non-expo animations on the index, every one of them infinite and
   symmetric.

   Entry is DIRECTION-AWARE. The per-element entry transform is carried on the
   custom property --rv-from rather than in the selector, for two reasons:
   custom properties inherit (so a container's direction reaches the children
   of a .grid-stagger, which animate via @keyframes, not a transition), and it
   keeps `.in { transform: none }` a single rule that no directional override
   can out-specify. --rv-dur and --rv-delay do the same job for the scale
   variant and the stagger.                                                   */
/* ⚠ ANCHOR upstream-ease — LOAD-BEARING. DO NOT DELETE THIS RULE.
   ────────────────────────────────────────────────────────────────────────
   The other end of the pair documented at `tokens.css` → "ANCHOR
   upstream-ease". `--inf-ease` is the PARENT SITE's Material curve
   (cubic-bezier(0.4,0,0.2,1)); this single declaration re-points it to the
   expo entrance curve for the Upstream subtree and nothing else.

   Fourteen transitions inside `.upstream` still spell their easing
   `var(--inf-ease)` — hover feedback on cards, rows, tiles and the film's
   colour ramps. They are on the law ONLY because of the line below. Remove
   it, or move `.upstream` out from under this stylesheet, and all fourteen
   revert to Material with no error and no obvious visual break: exactly the
   kind of silent regression four rounds of sweeps have been catching.

   Two ways to change this safely: re-point the token here (what we do), or
   rewrite all fourteen to name --inf-ease-expo directly. Deleting one half
   of the pair is not one of them.                                         */
.upstream {
  --inf-ease: var(--inf-ease-expo);

  /* ── D8 · THE DIRECTION-AWARE ENTRY VOCABULARY, STATED ONCE ────────────
     Five places across three sheets re-authored these same four transforms
     by hand — `.spine-row--card-*` and the hero graph here, `.nb-row` /
     `.nb-hero` in the notes layer — so "the entry offset" had five
     definitions that merely happened to agree. They are names now. A layer
     names its own columns and points them at a direction; it does not
     restate what a direction IS.

         .nb-row > .nb-fig { --rv-from: var(--rv-from-right); }

     The offsets themselves are still `tokens.css`'s (`--inf-reveal-shift`,
     `--inf-reveal-shift-lg`, `--inf-reveal-scale`) — this only stops the
     four combinations being spelled out over and over. `-lg` is the variant
     that also scales, for caption cards, and rides
     `--inf-dur-reveal-scale`. */
  --rv-from-right: translateX(var(--inf-reveal-shift));
  --rv-from-left: translateX(calc(-1 * var(--inf-reveal-shift)));
  --rv-from-right-lg: translateX(var(--inf-reveal-shift-lg)) scale(var(--inf-reveal-scale));
  --rv-from-left-lg: translateX(calc(-1 * var(--inf-reveal-shift-lg)))
    scale(var(--inf-reveal-scale));
  --rv-from-below: translateY(var(--inf-reveal-shift));
}
/* The three literal `200ms ease` transitions inherited from styles.css,
   re-stated on the one law. Same properties, same order — only the duration
   and the curve move, and the scope is `.upstream` so the parent site keeps
   what it was designed with. */
.upstream .btn {
  transition:
    transform var(--inf-dur) var(--inf-ease-expo),
    box-shadow var(--inf-dur) var(--inf-ease-expo),
    background var(--inf-dur) var(--inf-ease-expo);
}
/* A FOURTH literal, on a pseudo-element, which two rounds of sweeping missed
   because the sweep walked elements and not their ::before/::after boxes:
   `styles.css:254` fades the primary CTA's gradient glow on `opacity 200ms
   ease` while the same button's transform runs expo/300ms — two laws inside
   one control. Re-stated here. The round-3 sweep enumerates pseudo-elements. */
.upstream .btn-primary::after {
  transition: opacity var(--inf-dur) var(--inf-ease-expo);
}
.upstream .nav-toggle-bars,
.upstream .nav-toggle-bars::before,
.upstream .nav-toggle-bars::after {
  transition:
    transform var(--inf-dur) var(--inf-ease-expo),
    background var(--inf-dur) var(--inf-ease-expo),
    top var(--inf-dur) var(--inf-ease-expo);
}
.upstream .skip-link {
  transition: top var(--inf-dur-fast) var(--inf-ease-expo);
}
.upstream [data-reveal] {
  opacity: 0;
  transform: var(--rv-from, translateY(var(--inf-reveal-shift)));
  transition:
    opacity var(--rv-dur, var(--inf-dur-reveal)) var(--inf-ease-expo) var(--rv-delay, 0ms),
    transform var(--rv-dur, var(--inf-dur-reveal)) var(--inf-ease-expo) var(--rv-delay, 0ms);
}
.upstream [data-reveal].in {
  opacity: 1;
  transform: none;
}
/* Sibling stagger: one step per position, never a bespoke number. */
.upstream .rv-1 {
  --rv-delay: var(--inf-stagger);
}
.upstream .rv-2 {
  --rv-delay: calc(var(--inf-stagger) * 2);
}
.upstream .rv-3 {
  --rv-delay: calc(var(--inf-stagger) * 3);
}
/* The hero branch graph lands twelve marks, not four, so the cascade runs
   two steps further. Same 100ms increment, no bespoke number. */
.upstream .rv-4 {
  --rv-delay: calc(var(--inf-stagger) * 4);
}
.upstream .rv-5 {
  --rv-delay: calc(var(--inf-stagger) * 5);
}
/* Card grids reveal as a cascade: the container passes the fade to its
   children (a one-shot keyframe with backwards fill, so it releases the
   transform when done and card hover transitions stay pristine). The
   keyframe reads --rv-from off each child, so the cascade is direction-aware
   too. */
.upstream .grid-stagger[data-reveal] {
  opacity: 1;
  transform: none;
}
.upstream .grid-stagger[data-reveal] > * {
  opacity: 0;
}
.upstream .grid-stagger[data-reveal].in > * {
  opacity: 1;
  animation: upstream-rise var(--inf-dur-reveal) var(--inf-ease-expo) backwards;
}
.upstream .grid-stagger[data-reveal].in > :nth-child(2) {
  animation-delay: var(--inf-stagger);
}
.upstream .grid-stagger[data-reveal].in > :nth-child(3) {
  animation-delay: calc(var(--inf-stagger) * 2);
}
.upstream .grid-stagger[data-reveal].in > :nth-child(n + 4) {
  animation-delay: calc(var(--inf-stagger) * 3);
}
@keyframes upstream-rise {
  from {
    opacity: 0;
    transform: var(--rv-from, translateY(var(--inf-reveal-shift)));
  }
}
/* Nothing more is needed for a cascade's direction: --rv-from is a custom
   property, so a grid child inherits whichever direction its column set, and
   a cascade in the right-hand column enters from the right card by card. */

/* ── Staged visual frames ────────────────────────────────────────────────
   The hero's stagecraft at reduced intensity for big diagram cards: ONE
   soft purple glow rising behind the card's top edge (real Gaussian blur,
   so no rim is traceable), near-black immediately around the frame (the
   grid texture dies toward the scene under an eased void ellipse), and a
   couple of tiny glints. The glow elements precede the figure in the DOM,
   so they paint behind it with no z-index games — and the figure body is
   near-opaque so the light silhouettes the frame instead of washing it. */
.frame-stage {
  position: relative;
  margin: var(--inf-space-6) 0 var(--inf-space-1);
}
/* Clean darkness first: an eased void occluder. The radial's stops fall
   toward transparent, but the ellipse is larger than the box, so on its
   own it would CLIP at the box edge — a hard horizontal seam in the grid
   texture. The vertical mask is the guarantee: a long eased fade wholly
   inside the box, so the occluder always resolves to zero well before
   its own boundary and the grid never steps. */
.frame-stage::before {
  content: "";
  position: absolute;
  left: -10%;
  right: -10%;
  top: -6rem;
  bottom: -6rem;
  pointer-events: none;
  background: radial-gradient(
    52% 58% at 50% 42%,
    color-mix(in srgb, var(--inf-void) 96%, transparent),
    color-mix(in srgb, var(--inf-void) 78%, transparent) 55%,
    color-mix(in srgb, var(--inf-void) 42%, transparent) 76%,
    color-mix(in srgb, var(--inf-void) 14%, transparent) 90%,
    transparent 100%
  );
  -webkit-mask-image: linear-gradient(
    to bottom,
    transparent,
    color-mix(in srgb, var(--inf-fg-1) 30%, transparent) 2.5rem,
    var(--inf-fg-1) 5.5rem,
    var(--inf-fg-1) calc(100% - 5.5rem),
    color-mix(in srgb, var(--inf-fg-1) 30%, transparent) calc(100% - 2.5rem),
    transparent
  );
  mask-image: linear-gradient(
    to bottom,
    transparent,
    color-mix(in srgb, var(--inf-fg-1) 30%, transparent) 2.5rem,
    var(--inf-fg-1) 5.5rem,
    var(--inf-fg-1) calc(100% - 5.5rem),
    color-mix(in srgb, var(--inf-fg-1) 30%, transparent) calc(100% - 2.5rem),
    transparent
  );
}
/* Tiny glints in the glow's sky — same night-sky palette and twinkle as
   the hero field, sparser but bright enough to READ at 1x: the whole
   twinkle cycle stays above the visibility floor (--tw-lo), peaks are
   real points of light, and the static opacity (what reduced-motion
   shows) matches the cycle's midpoint. */
.fs-glint {
  position: absolute;
  width: 0.25rem;
  height: 0.25rem;
  --sparkle-color: var(--inf-fg-1);
  --tw-lo: 0.3;
  --tw-hi: 0.68;
  border-radius: var(--inf-radius-pill);
  pointer-events: none;
  background: radial-gradient(
    circle at 50% 50%,
    color-mix(in srgb, var(--sparkle-color) 90%, transparent),
    color-mix(in srgb, var(--sparkle-color) 42%, transparent) 55%,
    transparent 100%
  );
  filter: blur(0.04rem);
  opacity: 0.5;
  animation: stage-twinkle 6.8s ease-in-out infinite;
}
/* THE KEYFRAMES THIS DECLARATION NEEDS. Round 4 deleted them and left the
   `animation:` line above, so all 16 glints sat frozen at the static 0.5 —
   `getAnimations()` returned nothing for them while the motion-law comment
   above still described a breathing star field. Restored here, immediately
   under the only rule that uses them, so the pair cannot drift apart again.
   AMBIENT REGISTER: infinite, symmetric, ease-in-out — sanctioned, see the
   adjudication in the motion-law block. Reduced-motion branch lives with the
   rest of the kill switches below (`.fs-glint { animation: none }`), and the
   static 0.5 above is the cycle's own midpoint, so the calm state is the
   frame the animation passes through rather than a different look. */
@keyframes stage-twinkle {
  0%,
  100% {
    opacity: var(--tw-lo, 0.3);
  }
  50% {
    opacity: var(--tw-hi, 0.68);
  }
}
.fs-glint--star {
  border-radius: 0;
  background: radial-gradient(
    circle at 50% 50%,
    color-mix(in srgb, var(--sparkle-color) 92%, transparent),
    color-mix(in srgb, var(--sparkle-color) 48%, transparent) 45%,
    transparent 85%
  );
  clip-path: polygon(50% 0, 57% 43%, 100% 50%, 57% 57%, 50% 100%, 43% 57%, 0 50%, 43% 43%);
  filter: blur(0.06rem);
}
.fs-glint.g1 {
  left: 24%;
  top: -2.4rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 62%, var(--inf-deep-nebula));
  --tw-hi: 0.72;
  animation-duration: 7.4s;
  animation-delay: -2.6s;
}
.fs-glint.g2 {
  right: 14%;
  top: -3.2rem;
  width: 0.1875rem;
  height: 0.1875rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 32%, var(--inf-deep-nebula));
  --tw-hi: 0.62;
  animation-duration: 6.1s;
  animation-delay: -1.1s;
}
.fs-glint.g3 {
  left: 58%;
  top: -4.1rem;
  width: 0.5rem;
  height: 0.5rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 62%, var(--inf-deep-nebula));
  --tw-lo: 0.28;
  --tw-hi: 0.8;
  animation-duration: 5.7s;
  animation-delay: -3.8s;
}
/* The framed card: near-opaque so the glow terminates at its lip, a top
   border that warms toward the light, and a faint inset catch-light. */
.frame-stage .diagram {
  position: relative;
  margin: 0;
  background: color-mix(in srgb, var(--inf-void) 96%, transparent);
  border-top-color: color-mix(in srgb, var(--inf-deep-nebula) 55%, var(--inf-hairline));
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--inf-fg-1) 14%, transparent),
    var(--inf-shadow-lg);
}
/* Staged content other than the diagram figure must also paint above the
   glow layers (which precede it in the DOM). */
.frame-stage > .bignum {
  position: relative;
}
/* Copy that borders a staged zone paints above the zone's void occluder,
   so no line of text is ever dimmed by the stage — body copy, kickers,
   headlines and the section's trailing link alike (the occluders'
   masked overhangs reach into their space). */
.upstream-section .doc,
.upstream-section .upstream-lede,
.upstream-section .upstream-kicker,
.upstream-section .upstream-h2,
.upstream-section .sect-more {
  position: relative;
  z-index: 1;
}

/* ── Stage variant ───────────────────────────────────────────────────────
   The same staging extended to the stat band. The wrapper adds no layout of
   its own — the band keeps its margins. (The offer-row variant went with the
   offer cards themselves; there is no `.frame-stage--row` any more.)      */
.frame-stage--band {
  margin: 0;
}
/* Stat band: its own contained lit band — the grid texture dies behind
   the numbers under the void occluder (wide and even, so the leftmost
   column sits on the same darkness as the others), and every figure
   carries its own IDENTICAL crest (.bn-light, below) while the divider
   lanes between the columns stay dark. Deliberately NO shared horizon
   wash: a tying wash filled the valleys and merged the three crests into
   one ambient blob, so the only light in the band is the three identical
   per-figure domes — a horizontal luminance scan across the band shows
   three equal peaks with dark valleys between them. */
.frame-stage--band::before {
  top: -6rem;
  bottom: -6rem;
  left: -14%;
  right: -14%;
  background: radial-gradient(
    72% 70% at 50% 55%,
    color-mix(in srgb, var(--inf-void) 96%, transparent),
    color-mix(in srgb, var(--inf-void) 82%, transparent) 62%,
    color-mix(in srgb, var(--inf-void) 40%, transparent) 84%,
    transparent 100%
  );
}
/* The crest itself — ONE rule, so all three are identical by
   construction: same size, same peak, same centre. The figure <b> is the
   positioning context (position: relative below) and the crest sits at
   left 50% / top 50% with a centring translate, so each crest is centred
   under its own glyph run BY GEOMETRY — no per-column offsets to drift.
   The column is isolated so the crest's negative z-index tucks it behind
   the numeral and the divider rules while still painting above the void
   occluder. */
.bignum .bn {
  isolation: isolate;
}
.bignum b {
  position: relative;
}
.bn-light {
  position: absolute;
  z-index: -1;
  top: 50%;
  left: 50%;
  /* Narrow enough that adjacent domes never touch (a 24rem crest
     overlapped the divider lanes and fused the three into one blob),
     bright enough that each dome is unmistakably its own light. */
  width: 17rem;
  height: 10.5rem;
  transform: translate(-50%, -50%);
  pointer-events: none;
  filter: blur(1.5rem);
  background: radial-gradient(
    50% 50% at 50% 50%,
    color-mix(in srgb, var(--inf-deep-nebula) 56%, transparent),
    color-mix(in srgb, var(--inf-deep-nebula) 26%, transparent) 52%,
    transparent 100%
  );
}
/* Crest glints — three per figure, INSIDE the <b> so they ride the upper
   arc of that figure's own crest dome. Positions are rem offsets from
   the crest's centre line (left 50% + margin-left), so the arc placement
   is geometric and identical across columns: shoulders sit lower, the
   apex glint (the star) sits highest and brightest. All float above the
   glyph run, clear of the numerals. Only twinkle phase and
   micro-position vary column to column. */
.bignum .fs-glint {
  left: 50%;
  /* Whole twinkle cycle stays plainly readable at 1x — real points of
     light (5–8px cores), never zoom-only dust. */
  --tw-lo: 0.42;
}
.bignum .fs-glint.bg1 {
  margin-left: -5.5rem;
  top: -1.35rem;
  width: 0.375rem;
  height: 0.375rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 58%, var(--inf-deep-nebula));
  --tw-hi: 0.78;
  animation-duration: 7.6s;
  animation-delay: -2.9s;
}
.bignum .fs-glint.bg2 {
  margin-left: -0.5rem;
  top: -2.6rem;
  width: 0.5rem;
  height: 0.5rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 72%, var(--inf-deep-nebula));
  --tw-lo: 0.48;
  --tw-hi: 0.9;
  animation-duration: 6.1s;
  animation-delay: -1.2s;
}
.bignum .fs-glint.bg3 {
  margin-left: 5.25rem;
  top: -1.6rem;
  width: 0.3125rem;
  height: 0.3125rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 44%, var(--inf-deep-nebula));
  --tw-hi: 0.72;
  animation-duration: 6.9s;
  animation-delay: -4.1s;
}
/* Column-to-column variety: twinkle phase and micro-position only —
   never brightness grammar, so the three crests stay one lighting rig. */
.bignum .bn:nth-child(2) .fs-glint.bg1 {
  animation-delay: -5.2s;
  top: -1.05rem;
}
.bignum .bn:nth-child(2) .fs-glint.bg2 {
  animation-delay: -3.6s;
  margin-left: 0.75rem;
}
.bignum .bn:nth-child(2) .fs-glint.bg3 {
  animation-delay: -0.6s;
  margin-left: 4.9rem;
  top: -1.75rem;
}
.bignum .bn:nth-child(3) .fs-glint.bg1 {
  animation-delay: -0.9s;
  margin-left: -5.85rem;
}
.bignum .bn:nth-child(3) .fs-glint.bg2 {
  animation-delay: -5.5s;
  margin-left: -1.25rem;
  top: -2.45rem;
}
.bignum .bn:nth-child(3) .fs-glint.bg3 {
  animation-delay: -2.3s;
  top: -1.35rem;
}

/* Lit diagram frame: the light source lives INSIDE the card. A radial
   crest (.fs-inglow) is the figure's first child, centred behind the two
   timeline rows — brightest directly behind the 2-week row, roughly the
   card's vertical middle — and clipped by the card's rounded box
   (overflow: hidden). A bottom fade mask kills the glow entirely before
   the figcaption, so the caption zone stays dark and readable. The span
   overshoots the clip box so its Gaussian blur never erodes the crest at
   the edges. The svg and caption are positioned, so they paint above the
   light: the glow stays behind content and the mono rows stay crisp
   (peak alpha is a modest wash over near-black). */
.frame-stage--lit .diagram {
  overflow: hidden;
  /* The light lives mid-card: the lip treatment stays symmetric-quiet. */
  border-top-color: var(--inf-hairline);
  border-bottom-color: var(--inf-hairline);
  box-shadow: var(--inf-shadow-lg);
}
.fs-inglow {
  position: absolute;
  inset: -1.5rem;
  pointer-events: none;
  /* Tighter blur than the sky glows: the crest must keep a defined band
     edge — staged light, not a blob. */
  filter: blur(0.875rem);
  background:
    /* horizon-crest band behind the 2-week timeline row: a wide, SHALLOW
       ellipse — the bright core is a band, its crest line crisp under
       the reduced blur */
    radial-gradient(
      46% 8% at 50% 58%,
      color-mix(in srgb, var(--inf-deep-nebula) 58%, transparent),
      color-mix(in srgb, var(--inf-deep-nebula) 34%, transparent) 40%,
      color-mix(in srgb, var(--inf-deep-nebula) 12%, transparent) 70%,
      transparent 100%
    ),
    /* faint haze RISING above the crest only — its centre sits above the
       band, so the air over the horizon glows while nothing spreads down */
    radial-gradient(
        58% 30% at 50% 44%,
        color-mix(in srgb, var(--inf-deep-nebula) 15%, transparent),
        color-mix(in srgb, var(--inf-deep-nebula) 6%, transparent) 55%,
        transparent 100%
      );
  /* Hard bottom cut: the light dies just under the crest band, so the
     zone below it — the whole caption region — is pure dark, whatever
     the blur spreads. */
  -webkit-mask-image: linear-gradient(to bottom, var(--inf-fg-1) 60%, transparent 71%);
  mask-image: linear-gradient(to bottom, var(--inf-fg-1) 60%, transparent 71%);
}
.frame-stage--lit .diagram svg,
.frame-stage--lit .diagram figcaption {
  position: relative;
}
/* The frame's glints ride the in-card glow's CREST LINE: seven tiny
   white-to-purple points strung along the horizon band (~48–53% card
   height — the row-gap whitespace directly above the 2-week row), the
   central ones highest and brightest, the outer ones settling slightly
   lower with the band's shallow arc. None over a glyph, none in the
   caption zone. */
.frame-stage--lit .fs-glint {
  /* Crest-riders must read plainly at 1x through the whole cycle:
     5–8px cores, cycle floor high enough to count them in a still. */
  width: 0.3125rem;
  height: 0.3125rem;
  --tw-lo: 0.42;
}
.frame-stage--lit .fs-glint.g1 {
  left: 8%;
  top: 52%;
  --tw-hi: 0.78;
}
.frame-stage--lit .fs-glint.g2 {
  left: 92%;
  top: 52.5%;
  --tw-hi: 0.7;
}
.frame-stage--lit .fs-glint.g3 {
  left: 47%;
  top: 48.5%;
  width: 0.5rem;
  height: 0.5rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 74%, var(--inf-deep-nebula));
  --tw-lo: 0.48;
  --tw-hi: 0.9;
}
.frame-stage--lit .fs-glint.g4 {
  left: 20%;
  top: 51%;
  width: 0.375rem;
  height: 0.375rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 52%, var(--inf-deep-nebula));
  --tw-hi: 0.76;
  animation-duration: 8.2s;
  animation-delay: -4.4s;
}
.frame-stage--lit .fs-glint.g5 {
  left: 71%;
  top: 49.5%;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 74%, var(--inf-deep-nebula));
  --tw-hi: 0.8;
  animation-duration: 6.6s;
  animation-delay: -0.7s;
}
.frame-stage--lit .fs-glint.g6 {
  left: 33%;
  top: 49.5%;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 46%, var(--inf-deep-nebula));
  --tw-hi: 0.7;
  animation-duration: 7.1s;
  animation-delay: -2.1s;
}
.frame-stage--lit .fs-glint.g7 {
  left: 58%;
  top: 50%;
  width: 0.375rem;
  height: 0.375rem;
  --sparkle-color: color-mix(in srgb, var(--inf-fg-1) 60%, var(--inf-deep-nebula));
  --tw-hi: 0.76;
  animation-duration: 5.4s;
  animation-delay: -3.1s;
}

/* The Problem zone: the page's fixed grid texture is masked EVENLY across
   the whole section — one eased void veil, uniform through the middle and
   fading at both ends, so the field reads deliberately masked rather than
   patchy. Lives on ::after (::before is the section's glowing divider),
   in the isolated section's negative layer: above the fixed page grid,
   below the divider and every line of copy. */
#problem {
  isolation: isolate;
}
#problem::after {
  content: "";
  position: absolute;
  inset: -6rem 0;
  z-index: -1;
  pointer-events: none;
  /* The eased ramps live in the overhang OUTSIDE the section (both
     neighbours are staged dark zones, so the ramp is invisible there);
     inside the section the veil is one uniform field. */
  background: linear-gradient(
    to bottom,
    transparent,
    color-mix(in srgb, var(--inf-void) 86%, transparent) 6rem,
    color-mix(in srgb, var(--inf-void) 86%, transparent) calc(100% - 6rem),
    transparent
  );
}

/* ── Stat-band footnotes ─────────────────────────────────────────────────
   Superscript markers on the big-number captions, resolved in one muted
   footnotes line at the bottom of the page. Text numerals, mono — never
   an emoji or a glyph-as-icon. */
.fnref {
  font-family: var(--inf-font-mono);
  font-size: 0.72em;
  font-weight: 500;
  vertical-align: super;
  line-height: 1;
  color: var(--upstream-accent);
  text-decoration: none;
  margin-left: 0.35em;
  letter-spacing: 0;
}
.fnref:hover {
  color: var(--inf-fg-1);
}
.footnotes {
  margin-top: var(--inf-space-6);
  padding-top: var(--inf-space-3);
  border-top: 1px solid var(--inf-hairline-soft);
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-caption-size);
  line-height: 1.7;
  color: var(--inf-fg-3);
  max-width: 74ch;
}
.footnotes p {
  margin: 0 0 var(--inf-space-0_5);
  scroll-margin-top: var(--inf-space-12);
}
.footnotes .fnum {
  font-family: var(--inf-font-mono);
  color: var(--upstream-accent);
  margin-right: 0.55em;
}
.footnotes a {
  color: var(--upstream-accent);
  text-decoration: none;
  border-bottom: 1px solid var(--upstream-accent-line);
  transition: border-color var(--inf-dur) var(--inf-ease);
}
.footnotes a:hover {
  border-bottom-color: var(--upstream-accent);
}

/* ── Calm under reduced motion — content visible, at rest ───────────────
   DURATIONS ARE NOT THIS BLOCK'S JOB ANY MORE. The blanket at the end of the
   file zeroes every duration under `.upstream`, universally. What is left
   here is the END STATE: the frame each killed animation has to rest on.
   Nothing needs registering here when a new animation is added.          */
@media (prefers-reduced-motion: reduce) {
  .upstream [data-reveal],
  .upstream-section [data-reveal],
  .upstream .grid-stagger[data-reveal] > *,
  .upstream-section .grid-stagger[data-reveal] > * {
    opacity: 1 !important;
    transform: none !important;
  }
  /* The hero branch graph draws its merge lines with a stroke-dashoffset
     transition; the [data-reveal] kill above cannot reach that property, so
     the paths are pinned drawn here. */
  .upstream .hg-svg .hg-path {
    opacity: 1 !important;
    stroke-dashoffset: 0px !important;
  }
}

/* ── Piece B responsive ──────────────────────────────────────────────── */
@media (max-width: 700px) {
  .frame-stage::before {
    left: -4%;
    right: -4%;
    top: -4rem;
  }
  /* Columns stack here; each figure keeps its own .bn-light crest
     (self-centred on its <b> at every width — no recentring needed at
     the clamp floor), and the valleys between stacked figures stay dark
     for the same reason the desktop lanes do: no shared wash. */
  .fs-glint.g3 {
    top: -3.2rem;
  }
}

/* ── Piece C · proof plays as film ───────────────────────────────────────
   1. The hero pipeline card replays as a recorded ops run (upstream.js §4):
      rows execute in sequence, metadata types on character by character,
      the conflict counter counts down 58 → 0, and the run parks on the
      markup's own final frame before looping. Everything here is scoped
      under .is-film — the class upstream.js adds when the film starts — so
      no-JS, reduced-motion and pre-scroll states render the static card
      byte-for-byte as authored.
   The treadmill-loop video that used to sit in THE PROBLEM section is gone
   (owner's call, round 4); its CSS and its upstream.js IIFE went with it. */

/* Film pacing lives with the design system: upstream.js reads these props.  */
.mock-window {
  --film-hold: 4000ms; /* rest on the finished state between loops   */
  --film-lead: 900ms; /* let the card's entrance reveal land first  */
}

/* Room for the pause control: a clear strip under the last row, only
   while the film runs. Transitioned so the one-time growth reads as the
   card settling, not snapping.                                          */
.mock-body {
  transition: padding var(--inf-dur-slow) var(--inf-ease);
}
.mock-window.is-film .mock-body {
  padding-bottom: calc(var(--inf-space-2) + 2.4rem);
}

/* Row states. Colour is the ONLY channel that moves, on the token ramp:
   pending rows recede to the muted tiers, the running and finished rows
   sit at the resting palette — so the finished frame needs no class at
   all to look right.                                                    */
.mock-row .lbl,
.mock-row .det {
  transition: color var(--inf-dur) var(--inf-ease);
}
.mock-row.row-idle .lbl {
  color: var(--inf-fg-3);
  font-weight: 400;
}
.mock-row.row-idle .det {
  color: var(--inf-fg-4);
}
/* Pending-step icon: a hollow dot in the same 0.625rem register as the
   pulse dot and the check glyph it hands over to.                       */
.mock-idle {
  flex: none;
  width: 0.625rem;
  height: 0.625rem;
  border-radius: var(--inf-radius-pill);
  border: 1px solid var(--inf-hairline-loud);
  align-self: center;
}
/* Terminal caret on the line being written — a quiet two-step blink,
   nothing under 400ms, killed with the film under reduced motion.      */
.mock-row.row-run .det::after {
  content: "";
  display: inline-block;
  width: 0.4em;
  height: 1em;
  margin-left: 0.2em;
  transform: translateY(0.17em);
  background: color-mix(in srgb, var(--upstream-accent) 70%, transparent);
  animation: film-caret 1.1s steps(2, end) infinite;
}
@keyframes film-caret {
  50% {
    opacity: 0;
  }
}
/* User-paused: the whole scene freezes — pulse dot and caret included. */
.mock-window.film-paused .mock-pulse,
.mock-window.film-paused .row-run .det::after {
  animation-play-state: paused;
}

/* The live log strip — upstream.js seats this fixed-height console tail under
   whichever row is running and streams muted mono output into it: fetch
   remotes, replayed file paths, a conflict fragment, ninja/test noise.
   JS-created with the film, so it never exists for no-JS or reduced-motion
   visitors. Height is reserved once (eased in during the lead, under
   .is-film) and never moves again — lines scroll within it, newest at the
   bottom, so the card stays dimension-stable for the whole loop.         */
.mock-log {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  height: 0;
  overflow: hidden;
  padding-left: calc(0.625rem + var(--inf-space-2)); /* align under .lbl */
  font-family: var(--inf-font-mono);
  font-size: 0.65rem;
  line-height: 1.75;
  letter-spacing: 0.01em;
  color: var(--inf-fg-4);
  transition: height var(--inf-dur-slow) var(--inf-ease);
}
.mock-window.is-film .mock-log {
  height: 5.6em; /* three log lines + breath, constant while playing */
}
.mock-log span {
  display: block;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The freshest line sits one tier up the ramp; older output recedes.   */
.mock-log span:last-child {
  color: var(--inf-fg-3);
}

/* The pause/play affordance — GitHub's corner control in our glass:
   appended by upstream.js (so it never exists without the film), pill glass,
   Phosphor fill glyph, brand 1.05 hover, visible focus ring.            */
.film-ctl {
  position: absolute;
  right: 0.75rem;
  bottom: 0.75rem;
  z-index: 3;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  background: var(--inf-glass-bg);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  border: 1px solid var(--inf-hairline);
  border-radius: var(--inf-radius-pill);
  color: var(--inf-fg-2);
  cursor: pointer;
  transition:
    color var(--inf-dur) var(--inf-ease),
    border-color var(--inf-dur) var(--inf-ease),
    box-shadow var(--inf-dur) var(--inf-ease),
    transform var(--inf-dur) var(--inf-ease);
}
.film-ctl .ph-fill {
  font-size: 0.85rem;
}
.film-ctl:hover {
  color: var(--inf-fg-1);
  border-color: var(--inf-hairline-loud);
  box-shadow: var(--inf-glow-purple);
  transform: scale(1.05);
}
.film-ctl:focus-visible {
  outline: 2px solid var(--upstream-accent);
  outline-offset: 2px;
}

/* Reduced motion: upstream.js never starts the film, so the static final
   frame stands — these kill the residual keyframes if the query flips
   while classes are live.                                               */
@media (prefers-reduced-motion: reduce) {
  .mock-row.row-run .det::after,
  .mock-pulse {
    animation: none !important;
  }
}

/* =========================================================================
   Piece A+B · The spine, the caption cards and the product fragments.

   Bar mechanisms 1, 2, 3 and 4. One continuous 1px thread on the horizontal
   centre of the content column, running from the first section below the hero
   to the final CTA, with the page's words hung off it as alternating caption
   cards and its proof hung off it as cropped, overlapping product fragments.

   THE LAW OF THE BEAD (round 3). A node dot marks a PAIRING: a card on one
   side of the thread and real content on the other, both centred on that
   dot. Nothing else gets a bead. Round 2 also beaded every crossing — the top
   edge of each full-width plate — which put dots on a pricing card's lid, in
   the middle of a centred display sentence, and in empty black. Those are
   gone. A plate the thread passes behind is still a crossing; it is just not
   a pairing, so it carries no mark. The bead now lives INSIDE its card (at
   the card's own mid-line, where the notch is) rather than on the row, so a
   stack of two cards facing one tall visual gets one bead each and both are
   exactly on their notch.

   Token-pure: the only bare px are hairline widths and two documented
   sub-pixel/1px optical offsets, all commented at the point of use.
   ========================================================================= */

/* ── SPINE vs SECTION DIVIDER — the decision, and why ─────────────────────
   `.upstream-section::before` is a horizontal glowing filament: a SEAM. It
   is the exact opposite gesture to the spine. The filament cuts the page into
   bands every 96px; the spine exists to say the page is one timeline. Run
   both and the page argues with itself — a vertical thread claiming
   continuity, crossed over and over by a lit line announcing a new band, and
   the eye believes the brighter one.

   So the spine REPLACES the divider wherever it runs. The filament is
   suppressed for every section inside `.spine-track`, and is left completely
   untouched on the five Upstream pages that carry no spine, where a
   horizontal break is still the right break — and it comes back automatically
   below 900px, where the spine collapses and the page really is a stack of
   bands again. One page, one section-break gesture, at every width.        */
.spine-track .upstream-section::before {
  content: none;
}

/* ── Vertical rhythm inside the track ────────────────────────────────────
   Off the spine, a section's 96px of top and bottom padding is air between
   bands. On the spine there are no bands — there is one timeline — and that
   same padding becomes hundreds of pixels of thread with nothing on either
   side of it, which is the single largest source of the one-sided run. The
   sections keep their own padding everywhere else on Upstream; inside the
   track it comes down to one step, and the row rhythm carries the spacing
   instead. This is also what lands consecutive caption cards inside the
   reference's measured 435–562px band. */
.spine-track .upstream-section,
.spine-track .upstream-section--tight,
.spine-track .upstream-section--first {
  padding: var(--inf-space-4) 0;
}

/* ── A TRACK WITH NO THREAD · `--nothread` ────────────────────────────────
   Added 8 September 2026, for /fork-cost-model/.

   The spine is a bead-and-notch mechanism: caption cards facing evidence
   across a vertical thread. That page's three pairings all faced FABRICATED
   fragment clusters, and every one of them was cut — the page is sold to a
   CTO, whose first question is whether the numbers are real, and a mocked
   terminal labelled "Illustrative" answers it the wrong way one screen below
   a promise that every input is stated.

   With no pairings the thread has nothing to attach to. Measured before this
   rule: 10,722px of line carrying a single bead, 10,301px of it bare. A
   glowing vertical rule down eleven viewports with one dot near the top does
   not read as restraint; it reads as unfinished, and to this reader
   unfinished is a statement about how we build things.

   SO THE THREAD GOES AND THE TRACK STAYS. `.spine-track` is not only a
   thread — it is the layout mode for three constructions the page depends on
   (`> .callout` full-width, `> .doc--two` two-column, `.spine-head`'s own
   measure), so dropping the class would break the page rather than tidy it.
   This modifier removes the thread's consequences instead:

     · the horizontal section divider comes BACK, because it is once again
       the only section-break gesture on the page — the reason it was
       suppressed was that two break gestures fight each other, and now
       there is one;
     · section padding goes back up, because the divider needs air either
       side of it and the row rhythm that used to carry the spacing has gone
       with the rows. Not to the off-track 96px: at nine sections that is
       1,150px of padding on a page already asking a CTO for thirteen
       screens, so it lands halfway, at 48px.

   A page using this modifier must not also carry `.spine-line`.          */
.spine-track--nothread .upstream-section::before {
  content: "";
}
.spine-track--nothread .upstream-section,
.spine-track--nothread .upstream-section--tight,
.spine-track--nothread .upstream-section--first {
  padding: var(--inf-space-6) 0;
}

.spine-track {
  position: relative;
  /* Deliberately no z-index and no transform: the track must NOT open a
     stacking context, or the thread could not slide underneath the panels
     that are meant to occlude it. `overflow-x: clip` is the one exception and
     it is safe: unlike `hidden` it creates no scroll container and no
     stacking context, and the track is full-bleed, so clipping at its box IS
     clipping at the viewport. It is what cuts the bleeding fragments cleanly
     at the frame edge without letting the document report a horizontal
     overflow it cannot actually scroll. */
  overflow-x: clip;
  --spine-gutter: var(--inf-space-12);
  /* The node is the loudest mark in the frame, not a speck: at 1.625rem it
     reads as the thing the thread is attaching, from a full page away. The
     pointer geometry below is all derived from these two, so the arrow always
     lands inside the dot however either is retuned. */
  --spine-node-size: 1.625rem;
  /* The notch reaches to within 0.25rem of the thread's centre — inside the
     bead's solid core (0.40rem radius), not merely inside its knockout ring,
     so the tip visibly MEETS the dot instead of dying in the dark ring. */
  --spine-reach: calc(var(--spine-gutter) / 2 - 0.25rem);
  --spine-arrow-half: 0.875rem;
  /* One hairline value for the caption card's border AND for the hairline
     carried around its notch. Round 2 drew the notch in --inf-hairline (0.1)
     against a card edge in --inf-hairline-soft (0.05); at 5x the notch read
     as a brighter, separate object floating in the gutter. One variable, one
     edge. */
  --cap-line: var(--inf-hairline);
  /* The card's layered shadow, restated as a colour so the same elevation can
     be re-cast as a drop-shadow that follows the notch's silhouette. */
  --cap-shadow-1: color-mix(in srgb, var(--inf-deep-space) 50%, transparent);
  --cap-shadow-2: color-mix(in srgb, var(--inf-deep-space) 60%, transparent);
}

/* The thread. One element for the whole run, so it cannot break at a section
   boundary — it simply passes behind whatever is painted over it and comes
   out the other side. It sits at z-index 1: above the stage occluders and
   ambient glows (which are unpositioned, so z-index 0) and below every glass
   or solid panel later in the DOM at the same level, which is what gives the
   crossings. Fades in from nothing under the hero and out into the CTA.

   Brightness: --inf-hairline-loud (0.2), up from --inf-hairline (0.1). At 0.1
   the accent tick beside a stat figure was measurably brighter than the
   thread running past it, which inverts the hierarchy the spine exists to
   set. The thread is now the strongest continuous line on the page, and the
   accent ticks inside the spine give up their glow (below) so nothing local
   out-shouts it.                                                           */
.spine-line {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 1px; /* hairline — the documented raw-px exception */
  margin-left: -0.5px; /* sub-pixel optical centring of that hairline */
  z-index: 1;
  pointer-events: none;
  /* Fades up out of nothing under the hero, and dies at the terminus node on
     the CTA panel's top edge. --spine-end is the measured distance from the
     bottom of the track (which includes the footnote block below the panel) up
     to that edge. Re-measure --spine-end if the footnote block or the CTA
     panel changes height. */
  /* ── THE SANCTIONED PER-PAGE HOOKS ────────────────────────────────────
     `--spine-start` and `--spine-end` are the ONLY things a page or a layer
     sheet may set on this component. Both are measured distances in rem from
     the track's own top and bottom edge; the gradient that consumes them
     lives here and nowhere else. Three sheets used to own a `--spine-end`,
     and one of them re-declared the whole gradient purely to add a fade-up,
     which is why the start stop exists now.

       .spine-track--proof .spine-line { --spine-end: 29.75rem; }   ✔
       .why-track .spine-line { --spine-start: 9rem; }              ✔
       .my-track .spine-line { background: linear-gradient(…); }    ✘

     --spine-start: where the thread reaches full strength. 0 on the index,
     because the thread does NOT start at the track — it started in the hero
     and `.hg-thread` runs it down to the hero's bottom edge, so a fade-in
     here would put a visible break at the join. A page whose hero does not
     feed the thread sets a real distance and gets the fade-up.
     --spine-end: where it dies, on the terminus node at the CTA panel's top
     edge, measured from the bottom of the track (which includes the footnote
     block below that panel). Re-measure if either block changes height. */
  --spine-start: 0rem;
  --spine-end: 34rem;
  --spine-ink: color-mix(in srgb, var(--inf-fg-1) 26%, transparent);
  background: linear-gradient(
    to bottom,
    transparent,
    var(--spine-ink) var(--spine-start),
    var(--spine-ink) calc(100% - var(--spine-end)),
    transparent calc(100% - var(--spine-end) + 1.5rem)
  );
}

/* Panels the thread must pass BEHIND rather than over need nothing special:
   `.shell` is already `position: relative; z-index: 1` and comes after the
   thread in document order, so every section's content paints on top of it,
   and only the surfaces with an actual background occlude it. The one
   exception is the Problem section, which isolates itself so its grid veil
   can sit above the fixed page grid — isolation puts the WHOLE section in a
   z-index:auto layer, i.e. underneath the thread, and the cadence diagram
   could then never cross in front. Inside the track the veil is re-homed
   into the root layer instead (z-index 0 still clears the fixed page grid,
   which precedes it in document order, and still sits under both the copy
   and the thread), so the isolation can be dropped and the diagram crosses
   the way every other panel does. */
.spine-track #problem {
  isolation: auto;
}
.spine-track #problem::after {
  z-index: 0;
}

/* The statement band is the one full-bleed moment on the page and the most
   literal crossing: the thread dives under a wide void plate, the nebula wash
   plays on top of it, and the thread re-emerges below. The plate is
   `--inf-void` over `--inf-deep-space`, so it reads as a lift in the ground,
   not as a box. No bead: a crossing is not a pairing. */
.spine-track .statement-band {
  z-index: 1;
}
.spine-track .statement-band::before {
  content: "";
  position: absolute;
  inset: 0;
  /* reset the divider geometry this pseudo-element carries by default —
     without them the 0.875rem-tall filament box would win over `bottom` */
  height: auto;
  opacity: 1;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    transparent,
    color-mix(in srgb, var(--inf-void) 92%, transparent) 20%,
    color-mix(in srgb, var(--inf-void) 92%, transparent) 80%,
    transparent
  );
}

/* ── The rows ────────────────────────────────────────────────────────────
   Two equal columns split by a gutter whose centre is the thread. The card
   sits OPPOSITE its visual across the thread — never above or below it — and
   the side alternates row by row down the page.

   DENSITY (bar mechanism 2, corrected). Measured reference spacing between
   consecutive caption cards is 435 / 506 / 562px — a little over half a
   viewport, so two cards are on screen together for most of the run and the
   alternation reads as a beat rather than as isolated incidents. The row
   rhythm below is tuned to land in that band; `.spine-stack` exists for the
   same reason, letting two cards face one tall visual instead of leaving
   300px of one-sided thread above and below a short card.                  */
.spine-row {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  column-gap: var(--spine-gutter);
  align-items: center;
  margin: var(--inf-space-2) 0;
}
/* PITCH. Round 3 ran fourteen cards at a 286px median pitch against the
   reference's 506px — 1.8x too dense, because it was told to halve a figure
   that was already wrong and overshot. Eight cards is the fix for the
   density AND for the proof gap at the same time: the six weakest claims
   were the six with nothing to point at. With half as many cards the rows
   can breathe back out to the reference's measured 435–562px band. */
.spine-row + .spine-row {
  margin-top: var(--inf-space-8);
}
.spine-row + .spine-cross,
.spine-cross + .spine-row {
  margin-top: var(--inf-space-4);
}
/* Two caption rows back to back are the page's actual beat, and the beat is
   what the reference's 435/506/562 measures. Those rows get the wider step;
   a row that follows a section opener or a crossing already has that block's
   own height between it and the previous card. */
.spine-row--card-left + .spine-row--card-right,
.spine-row--card-right + .spine-row--card-left {
  margin-top: calc(var(--inf-space-12) + var(--inf-space-8) * 2);
}
.spine-row--card-left > .spine-cap,
.spine-row--card-left > .spine-stack {
  grid-column: 1;
  justify-self: end;
}
.spine-row--card-left > .spine-visual {
  grid-column: 2;
}
.spine-row--card-right > .spine-cap,
.spine-row--card-right > .spine-stack {
  grid-column: 2;
  justify-self: start;
}
.spine-row--card-right > .spine-visual {
  grid-column: 1;
}
/* Round 4 removed `.spine-prose` and `.spine-row--pair` entirely: a row on
   the thread is now either a PAIRING (caption card + product proof, one bead)
   or a CROSSING (a full-column block the thread passes behind, no bead).
   There is no third kind. Section openers, which used to be half-column
   reading slots, are crossings — see `.spine-head` below.               */
.spine-head .upstream-h2 .nbg {
  white-space: normal;
}
/* Two caption cards facing one tall visual. Each keeps its own bead and its
   own notch, so the thread is attached twice down the visual's height instead
   of once at its mid-point. */
.spine-stack {
  display: grid;
  gap: var(--inf-space-4);
  width: 100%;
  max-width: 30.625rem;
}
/* A card plus the one line of commercial data that belongs to it. Tighter
   gap, because the line is a caption on the card above it, not a second
   beat on the thread. */
.spine-stack--tight {
  gap: var(--inf-space-2);
}

/* A crossing: a full-column plate the thread passes behind. Layout-neutral,
   and — by the law of the bead — unmarked. */
.spine-cross {
  position: relative;
}

/* ── The section opener, as a crossing ───────────────────────────────────
   Round 3 put every section headline in one half of the column. A 52px
   display headline is three or four lines and ~250px tall; the paragraph
   opposite it is ninety; the difference is 170px of thread with nothing on
   the other side, four times down the page — measured, the single largest
   remaining source of one-sided run. Straddling the thread fixes both that
   and the measure: the headline gets its full width back, and the argument
   under it runs in two columns whose gutter IS the thread, so the opener is
   two-sided from its first line to its last.

   No bead, no notch: an opener is a crossing, not a pairing. The soft void
   wash is the same gesture the statement plate makes — it is what lets the
   thread dive under a block of type instead of ruling through it.        */
.spine-head {
  text-align: center;
  padding: var(--inf-space-4) 0;
}
.spine-head::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    transparent,
    color-mix(in srgb, var(--inf-void) 88%, transparent) 14%,
    color-mix(in srgb, var(--inf-void) 88%, transparent) 86%,
    transparent
  );
}
.spine-head .upstream-h2 {
  max-width: 24ch;
  margin-inline: auto;
}
.spine-head .doc {
  max-width: 74ch;
  margin: var(--inf-space-4) auto 0;
  text-align: left;
}
/* A section opener whose supporting line is a lede rather than a `.doc`.
   `.spine-head` centres its own type; the lede takes the same reading
   measure and the same left alignment as the `.doc` it stands in for.
   (Lifted out of `upstream-evidence.css` — this file owns `.spine-head`.) */
.spine-head .upstream-lede {
  max-width: 74ch;
  margin-inline: auto;
  text-align: left;
}
/* A callout used as a crossing in its own right — not inside `.spine-head` —
   has to straddle the thread, or its 74ch measure leaves the right half of
   the column empty for the height of the panel. (Lifted out of the evidence
   layer for the same reason: `.spine-track` is this file's component.) */
.spine-track .spine-cross > .callout {
  max-width: none;
  margin-inline: 0;
}
/* Where the opener carries the section's whole argument, the reading column
   splits and the thread runs down the gutter between the two columns. */
.spine-head .doc--two {
  columns: 2;
  column-gap: var(--spine-gutter);
  max-width: none;
}
/* Deliberately NOT `break-inside: avoid`. With five unbreakable paragraphs
   the balancer can only split 2/3, which left ~150px of column two running
   past the bottom of column one — measured, one of the page's longest
   one-sided runs. Letting a paragraph break across the gutter balances the
   two columns to within a line of each other, which is what a two-column
   measure is for. */
.spine-head .doc--two > :last-child {
  break-inside: avoid;
}

/* ── THE SAME TWO CONSTRUCTIONS, ONE SELECTOR WIDER ───────────────────────
   Lifted out of `upstream-home.css` on 4 September 2026, when
   /fork-cost-model/ grew two prose subsections of its own (fix list items 12
   and 13: the silent-merge block and the mirror-branch method). They were
   page-scoped only because the home page was the only page that had needed a
   prose crossing; two pages make them Upstream components, and this is the
   Upstream component sheet. Selectors and declarations are unchanged, so the
   home page renders exactly as before.

   A CROSSING IS NOT AN OPENER. `.spine-head .doc--two` above is the section
   opener's two-column measure. These are for a two-column argument, or a
   single centred paragraph, sitting on the track between openers — same
   gutter token, so the thread runs down the same line either way.          */
.spine-track .spine-cross > .doc--two {
  columns: 2;
  column-gap: var(--spine-gutter);
  max-width: none;
}
.spine-track .spine-cross > .doc--two > :last-child {
  break-inside: avoid;
  margin-bottom: 0;
}
/* One paragraph has no second column to balance against: in `.doc--two` it
   would sit in column one with the thread down its right edge and nothing
   facing it. Centred on a tighter measure instead, so it straddles the
   thread the way `.spine-head`'s own reading column does. */
.spine-track .spine-cross > .doc--mid {
  max-width: 66ch;
  margin-inline: auto;
  text-align: center;
}

/* ── A prose subsection heading inside the track ──────────────────────────
   Upstream had no h3 treatment until a page needed a named subsection with
   its own paragraphs: every other subordinate claim on the site is either a
   `.cap-claim` inside a caption card or a bold lead-in on a two-tone
   paragraph. These are real subsections, so they get a real heading in the
   document outline.

   Centred, because it sits on a crossing: left-aligned it would put its whole
   height on one side of the thread, which is the defect `.spine-head` exists
   to fix.

   IT CLAMPS, AND A FIXED TOKEN HERE WAS A BUG. `--inf-type-h1-size` is a
   flat 36px while `.upstream-h2` is `clamp(1.875rem, 3.6vw, 3.375rem)`. The
   two cross at 1000px: above it the h3 is subordinate, below it the h3 is the
   LARGER of the pair — 36px against the section headline's 31.8px at 882 and
   30px at 375 — so on every phone and tablet the subsection heading
   outranked the section heading above it. Token-pure and still wrong: purity
   is not ramp correctness.

   The clamp below tracks the h2's at a constant ~0.70 across the whole range
   — 22 vs 30 at 375, 25 vs 36 at 1000, 36 vs 51.8 at 1440 — so it stays
   subordinate everywhere instead of only on desktop.                       */
.prose-h3 {
  font-family: var(--inf-font-display);
  font-weight: 700;
  font-size: clamp(1.375rem, 2.5vw, 2.25rem);
  line-height: 1.18;
  letter-spacing: var(--inf-type-display1-ls);
  color: var(--inf-fg-1);
  text-align: center;
  text-wrap: balance;
  max-width: 34ch;
  margin: 0 auto var(--inf-space-3);
}
/* The section's arrow link is the last thing in the opener; left-aligned at
   the start of a centred measure it is 150px of ink entirely on one side of
   the thread. Centred, it straddles. */
.spine-head .doc > .more {
  display: table;
  margin: var(--inf-space-2) auto 0;
}
.spine-head .doc > :last-child {
  margin-bottom: 0;
}
.spine-head .callout {
  max-width: none;
  margin: 0;
  text-align: left;
}

/* The terminus panel splits too. Left unchanged it was a 62ch measure and a
   button row hard against the left edge — 600px of the thread's last stretch
   with nothing on the right of it, which is the failure the spine is here to
   avoid, committed on the page's most important panel. The argument keeps the
   left half; the two calls to action stack in the right. */
.spine-track .cta-panel-inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  column-gap: var(--inf-space-6);
  align-items: start;
}
.spine-track .cta-panel-inner > .hero-actions {
  grid-column: 2;
  grid-row: 1 / span 2;
  align-self: center;
  flex-direction: column;
  align-items: flex-start;
  margin: 0;
}
/* The footnote block is the last thing on the page and used to run 400px down
   the left half with nothing opposite it. Two columns, so the thread's final
   stretch is flanked to the end. */
.spine-track .footnotes {
  columns: 2;
  column-gap: var(--spine-gutter);
  max-width: none;
}
.spine-track .footnotes p {
  break-inside: avoid;
}

/* ── The bead ────────────────────────────────────────────────────────────
   Maximum-contrast core, a knockout ring of page ground, a light ring, and
   the brand's faint purple bloom. Round 2's bead was a flat pale-lilac disc
   with no knockout: the thread visibly stopped dead either side of it instead
   of threading behind. The knockout is what makes it a bead ON a line.

   It lives inside its caption card and is pinned to that card's vertical
   mid-line — the same line the notch leaves from — so dot and notch can never
   disagree, and a stack of cards gets a bead each.                          */
.spine-node {
  position: absolute;
  top: 50%;
  width: var(--spine-node-size);
  height: var(--spine-node-size);
  margin-top: calc(var(--spine-node-size) / -2);
  /* Above the card's own notch pseudo-element, so the arrow tip runs UNDER
     the bead and the bead stays the terminal mark. */
  z-index: 3;
  border-radius: var(--inf-radius-pill);
  background:
    radial-gradient(circle closest-side, var(--inf-fg-1) 0 52%, transparent 53%),
    var(--inf-deep-space);
  border: 1px solid var(--inf-hairline-loud); /* the light ring */
  box-shadow: var(--inf-glow-purple);
  pointer-events: none;
}
/* Horizontal placement: from the card's padding box out across its own 1px
   border and half the gutter, then back by the bead's radius. The lone 1px is
   the card border — the documented hairline exception. */
.spine-row--card-left .spine-node {
  left: 100%;
  margin-left: calc(1px + var(--spine-gutter) / 2 - var(--spine-node-size) / 2);
}
.spine-row--card-right .spine-node {
  right: 100%;
  margin-right: calc(1px + var(--spine-gutter) / 2 - var(--spine-node-size) / 2);
}

/* ── The caption card ────────────────────────────────────────────────────
   The existing glass material at ~490px. One bold claim line, one muted
   paragraph, and nothing else — no icon, no link, no button, no table.     */
.spine-cap {
  position: relative;
  max-width: 30.625rem;
  /* Roomier than round 3's 32px. Two reasons, both measured: the reference's
     caption card is a substantial object, not a tight label; and a 140px card
     opposite a 280px fragment cluster leaves 70px of one-sided thread above
     AND below it, eight times down the page. Half the fix is here, the other
     half is the shorter plate stacks in the clusters. */
  padding: var(--inf-space-6) var(--inf-space-4);
  /* SURFACE. The glass token composites to rgb(7,7,11) over the page's
     rgb(3,3,5): four levels of separation, which is why round 2's card was
     read as "a card whose right edge is sealed flat" — most of it was not
     visibly a card at all, only a hairline rectangle. The reference's caption
     card is an unmistakable object lifted off its ground. On dark that means
     the elevated surface token, not the void one; the blur, the hairline and
     the layered shadow are unchanged, so it is the same material, lifted. */
  background: color-mix(in srgb, var(--inf-elevated) 72%, transparent);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  border: 1px solid var(--cap-line);
  border-radius: var(--inf-radius-xl);
  /* Layered elevation, carried around the notch by the matching drop-shadows
     below so card and pointer read as one object lifted off the page. */
  box-shadow:
    0 0.25rem 0.75rem var(--cap-shadow-1),
    0 0.75rem 2rem var(--cap-shadow-2);
}
.cap-claim {
  font-family: var(--inf-font-display);
  font-weight: 700;
  font-size: var(--inf-type-body-l-size);
  line-height: 1.3;
  letter-spacing: var(--inf-type-h1-ls);
  color: var(--inf-fg-1);
  margin: 0 0 var(--inf-space-2);
  text-wrap: balance;
}
.cap-body {
  font-family: var(--inf-font-sans);
  font-size: var(--inf-type-body-size);
  line-height: 1.62;
  color: var(--inf-fg-3);
  margin: 0;
  text-wrap: pretty;
}

/* ── The pointer ─────────────────────────────────────────────────────────
   ONE object with the card, not a glyph floating in the gap. Three things
   make it one object, and round 2 had none of them:

     · the same hairline.  ::before is a solid triangle in --cap-line, the
       card's OWN border colour, so the stroke that runs the two slopes is
       literally the card's edge continued into the gutter.
     · the same material.  ::after is the fill: a clipped box carrying the
       card's --inf-glass-bg AND the card's backdrop blur, so the notch
       composites over whatever is behind it exactly as the card does. It
       starts 1px back over the card's border, which opens the card's edge
       across the notch's base — the edge is no longer sealed flat.
     · the same elevation. The card's two-stop box-shadow is re-cast as two
       matching drop-shadows on the outline triangle, so the shadow follows
       the silhouette round the point instead of stopping at a straight edge.

   The tip finishes inside the bead's solid core (--spine-reach above), under
   the bead, which paints at z-index 3.                                     */
.spine-cap::before,
.spine-cap::after {
  content: "";
  position: absolute;
  top: 50%;
  pointer-events: none;
}
/* the outline: a border-triangle whose whole body is the card's edge colour,
   all but a hairline of which the fill then covers */
.spine-cap::before {
  width: 0;
  height: 0;
  margin-top: calc(var(--spine-arrow-half) * -1);
  border-top: var(--spine-arrow-half) solid transparent;
  border-bottom: var(--spine-arrow-half) solid transparent;
  filter: drop-shadow(0 0.25rem 0.75rem var(--cap-shadow-1))
    drop-shadow(0 0.75rem 2rem var(--cap-shadow-2));
}
/* the fill: 1px inset on the slopes (0.1875rem back on the point, which is
   what a 1px perpendicular inset costs at this angle), in the colour the
   card's glass composites to. Stated opaque rather than as --inf-glass-bg
   because the outline triangle underneath is a SOLID triangle of the edge
   colour, and a 60%-opaque fill lets 40% of it through — measured at 4x, the
   notch read a visible step lighter than the card it belongs to. Over
   --inf-deep-space the mix below is the identical colour --inf-glass-bg
   composites to, so card and notch are the same value to the pixel. */
.spine-cap::after {
  --spine-arrow-half: 0.8125rem;
  width: calc(var(--spine-reach) - 0.1875rem);
  height: calc(var(--spine-arrow-half) * 2);
  margin-top: calc(var(--spine-arrow-half) * -1);
  background: color-mix(in srgb, var(--inf-elevated) 72%, var(--inf-deep-space));
}
.spine-row--card-left > .spine-cap::before {
  left: 100%;
  border-left: var(--spine-reach) solid var(--cap-line);
}
.spine-row--card-left > .spine-cap::after,
.spine-row--card-left .spine-stack .spine-cap::after {
  left: calc(100% - 1px);
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.spine-row--card-right > .spine-cap::before {
  right: 100%;
  border-right: var(--spine-reach) solid var(--cap-line);
}
.spine-row--card-right > .spine-cap::after,
.spine-row--card-right .spine-stack .spine-cap::after {
  right: calc(100% - 1px);
  clip-path: polygon(100% 0, 0 50%, 100% 100%);
}
/* Cards inside a stack are not direct children of the row, so the notch
   direction rules above have to reach them too. */
.spine-row--card-left .spine-stack .spine-cap::before {
  left: 100%;
  border-left: var(--spine-reach) solid var(--cap-line);
}
.spine-row--card-right .spine-stack .spine-cap::before {
  right: 100%;
  border-right: var(--spine-reach) solid var(--cap-line);
}

/* ── Product fragments (bar mechanism 3) ─────────────────────────────────
   Never one centred screenshot in a device frame. Each proof slot holds two
   or three real UI pieces at different depths, overlapping, softly shadowed,
   and deliberately clipped by the viewport edge so the UI reads as a window
   onto something larger. Every fragment shows the product mid-task: a rebase
   part-resolved, a build matrix still running, a backport queue with one item
   in review. Figures are illustrative and footnoted; nothing here invents a
   customer, a logo or a quotation.

   THE CROP IS NOT A CONSTANT. Round 3 clipped four of five clusters at
   exactly x=1504 and overlapped every one of them by exactly 80px, which is
   how a mechanism turns into a template. The overrun past the viewport edge
   is now per-cluster (--frag-out, 2.5rem to 7rem below) so no two clusters
   are cut at the same x, and the plate offsets are all different numbers.

   The bleed: the distance from this column's outer edge to the viewport edge
   is (100vw − content width) / 2 + the shell's padding; --frag-out more than
   that puts the fragment's far edge off-frame at every width above the
   collapse breakpoint.

   AND THE FRAME HAS A TOP AND A BOTTOM. The reference clips a comment thread
   by its TOP edge so the UI reads as a window onto something vertically
   larger too; every one of round 3's crops was a side crop. `.frags--vclip`
   clips vertically only — `overflow: visible clip` keeps the horizontal
   bleed intact while the box cuts whatever runs past its top or bottom. */
.frags {
  position: relative;
  min-height: var(--frags-h, 21rem);
  --frag-bleed: calc((100vw - min(100vw, 80rem)) / 2 + var(--inf-space-4) + var(--frag-out, 4rem));
}
.frags--vclip {
  overflow: visible clip;
}
/* ── The cluster's citation ──────────────────────────────────────────────
   Bar mechanism 2 fixes the caption card's contents at a bold claim plus one
   muted paragraph and NOTHING else — no icon, no link, no button, no table.
   Round 4 hung a footnote marker inside two `.cap-body` paragraphs, which is
   a link in the card. The marker belongs to the FIGURES, not to the claim, so
   it moves out here onto the fragment cluster it actually sources, in the
   same mono chrome register the fragments already use. `#fn-4` still
   resolves; nothing is deleted, only re-parented. */
.frag-cite {
  margin: var(--inf-space-1) 0 0;
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-2xs-size);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--inf-fg-4);
}
/* A cluster that bleeds off the LEFT edge is read from its right side, so
   its citation sits there too. */
.frags--bleed-left + .frag-cite {
  text-align: right;
}
.frag {
  position: absolute;
  background: color-mix(in srgb, var(--inf-elevated) 82%, transparent);
  backdrop-filter: var(--inf-glass-blur);
  -webkit-backdrop-filter: var(--inf-glass-blur);
  border: 1px solid var(--inf-hairline);
  border-radius: var(--inf-radius-lg);
  box-shadow:
    0 0.25rem 0.75rem var(--cap-shadow-1),
    0 0.75rem 2rem var(--cap-shadow-2);
  overflow: hidden;
}
/* Depth, AND the plate's position in its cluster.

   ── THE POSITIONAL PRESET MECHANISM, DEFINED ONCE ────────────────────────
   Every cluster on Upstream is the same shape: a height, a crop distance,
   and a `top` + `width` per plate depth. That shape used to be hand-authored
   as three or four separate rules per cluster, twenty-seven times across the
   four sheets — same declarations every time, only the numbers different.
   The declarations are here now; the numbers stay with their cluster, as
   custom properties on the cluster's own rule. A new cluster is ONE rule:

     .frag-x9 {
       --frags-h: 17rem;          the frame's height
       --frag-out: 4rem;          how far the edge plate runs past the frame
       --frag-back-y: 0;          per plate: offset from the frame's top …
       --frag-back-w: 28rem;      … and its width
       --frag-front-y: 6rem;
       --frag-front-w: 21rem;
     }
     .frag-x9 .frag--front { --frag-in-x: 2rem; }   ← see below

   NONE OF THESE var()s TAKES A FALLBACK, deliberately. A cluster with only
   two plates simply does not set the third depth's numbers; the unresolvable
   `var()` is invalid at computed-value time, the property lands on its
   initial `auto`, and the absent plate costs nothing. Add a fallback and
   every unset plate acquires a position it never asked for.

   `--frag-in-x` AND `--frag-mid-x` STAY ON THE PLATE, NOT ON THE CLUSTER,
   and that is not an oversight. `top` and `width` are read by exactly one
   depth each, so hoisting them is lossless. `--frag-in-x` is read by
   whichever plate carries `.frag--in`, and WHICH depth that is varies by
   cluster: it is the back plate on k3, the front plate on most, and BOTH the
   middle and the front on the note index's kD, at different values. Hoist it
   and it inherits to every plate in the cluster — measured, k3's back plate
   silently picked up the front plate's 1.5rem and slid 25px across the
   frame. Custom properties inherit; that is the whole mechanism here, and it
   is also the trap. */
.frag--back {
  z-index: 0;
  opacity: 0.66;
  top: var(--frag-back-y);
  width: var(--frag-back-w);
}
.frag--mid {
  z-index: 1;
  opacity: 0.86;
  top: var(--frag-mid-y);
  width: var(--frag-mid-w);
}
.frag--front {
  z-index: 2;
  top: var(--frag-front-y);
  width: var(--frag-front-w);
}
/* Clipped by the frame. The cluster's outermost plate runs off the viewport
   edge; `body { overflow-x: hidden }` in the design system does the cutting. */
.frags--bleed-left .frag--edge {
  left: calc(-1 * var(--frag-bleed));
}
.frags--bleed-right .frag--edge {
  right: calc(-1 * var(--frag-bleed));
}

.frag-head {
  display: flex;
  align-items: center;
  gap: var(--inf-space-1);
  padding: var(--inf-space-1) var(--inf-space-2);
  border-bottom: 1px solid var(--inf-hairline-soft);
  background: color-mix(in srgb, var(--inf-void) 88%, transparent);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  line-height: 1.4;
  color: var(--inf-fg-3);
  white-space: nowrap;
}
.frag-head .ph {
  font-size: var(--inf-type-label-size);
  color: var(--inf-fg-3);
  flex: none;
}
.frag-head b {
  font-weight: 500;
  color: var(--inf-fg-2);
}
.frag-head .frag-meta {
  margin-left: auto;
  padding-left: var(--inf-space-2);
  color: var(--upstream-accent);
}
.frag-body {
  padding: var(--inf-space-1) var(--inf-space-2) var(--inf-space-2);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  line-height: 1.5;
  color: var(--inf-fg-3);
}
/* A file row in a conflict list or a queue: state mark, path, state word. */
.frag-row {
  display: grid;
  grid-template-columns: 1rem minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--inf-space-1);
  padding: var(--inf-space-0_5) 0;
  white-space: nowrap;
}
.frag-row + .frag-row {
  border-top: 1px solid var(--inf-hairline-soft);
}
.frag-row .ph {
  font-size: var(--inf-type-label-size);
}
.frag-row .p {
  overflow: hidden;
  text-overflow: ellipsis;
}
.frag-row .s {
  color: var(--inf-fg-4);
}
.frag-row--done .ph {
  color: var(--upstream-accent);
}
.frag-row--done .p {
  color: var(--inf-fg-3);
}
/* In-progress: the one row the eye should land on. Brighter path, brand-lit
   state word, and the pipeline card's own pulse mark reused as the spinner —
   it already carries a reduced-motion branch. */
.frag-row--run .p {
  color: var(--inf-fg-1);
}
.frag-row--run .s {
  color: var(--inf-hyper-pink);
}
.frag-row--wait .p,
.frag-row--wait .ph {
  color: var(--inf-fg-4);
}
.frag-dot {
  display: inline-block;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: var(--inf-radius-pill);
  border: 1px solid var(--inf-fg-4);
  justify-self: center;
}
/* Terminal fragment: a real command mid-run, its output, and a caret. */
.frag-term {
  padding: var(--inf-space-2);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  line-height: 1.65;
  color: var(--inf-fg-3);
  white-space: nowrap;
}
.frag-term .cmd {
  color: var(--inf-fg-1);
}
.frag-term .cmd::before {
  content: "$ ";
  color: var(--upstream-accent);
}
/* A wire request line, not a shell command: same emphasis, no prompt. */
.frag-term .req {
  color: var(--inf-fg-1);
}
.frag-term .warn {
  color: var(--inf-hyper-pink);
}
.frag-term .ok {
  color: var(--upstream-accent);
}
.frag-caret {
  display: inline-block;
  width: 0.5rem;
  height: 0.9em;
  vertical-align: text-bottom;
  background: var(--inf-fg-2);
}
/* Build matrix: platform rows, one still running. */
.frag-matrix {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 1px; /* hairline gutter — the cells' own borders make the grid */
  background: var(--inf-hairline-soft);
  border-top: 1px solid var(--inf-hairline-soft);
}
.frag-cell {
  background: color-mix(in srgb, var(--inf-elevated) 70%, transparent);
  padding: var(--inf-space-1);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  line-height: 1.35;
  color: var(--inf-fg-4);
  text-align: center;
  white-space: nowrap;
}
.frag-cell b {
  display: block;
  font-weight: 500;
  color: var(--inf-fg-3);
}
.frag-cell .ph {
  font-size: var(--inf-type-label-size);
}
.frag-cell--done .ph {
  color: var(--upstream-accent);
}
.frag-cell--run {
  background: color-mix(in srgb, var(--inf-deep-nebula) 14%, var(--inf-void));
}
.frag-cell--run b {
  color: var(--inf-fg-1);
}
/* Inventory table scrolled part-way: the first row is cut by the fragment's
   own top edge, which is what makes it read as a viewport onto a longer list
   rather than as a complete little table. */
.frag-table {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
}
.frag-table th,
.frag-table td {
  text-align: left;
  padding: var(--inf-space-0_5) var(--inf-space-2);
  border-bottom: 1px solid var(--inf-hairline-soft);
  white-space: nowrap;
}
.frag-table th {
  font-weight: 500;
  color: var(--inf-fg-4);
  text-transform: uppercase;
  letter-spacing: 0.12em;
}
.frag-table td {
  color: var(--inf-fg-3);
}
.frag-table td.n {
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--inf-fg-2);
}
.frag-table .hi {
  color: var(--inf-hyper-pink);
}
.frag-table .lo {
  color: var(--upstream-accent);
}
/* Scrolled part-way: the column header and the top of the first row are cut
   off by the fragment's own chrome and the last row is cut by its bottom
   edge, so the list reads as a viewport onto a longer one. */
.frag-scroll {
  height: 7.25rem;
  overflow: hidden;
}
/* The table is pulled up INSIDE the clipping box, not with a negative margin
   on the box itself — a negative margin moves the box and clips nothing, so
   the column header ended up printed over the fragment's own title bar. */
.frag-scroll .frag-table {
  margin-top: -2.1rem;
}
/* A second crop distance, so two inventory plates on one page are not cut at
   the same row. This one is scrolled past its first record, not just past
   its header. */
.frag-scroll--mid .frag-table {
  margin-top: -3.35rem;
}
/* Terminal colour classes shared by every mono plate: a churn figure, a
   muted trailing summary, a highlighted count. Round 3's front plates were
   marketing stat tiles instead — a milestone in 24px display type over a
   cyan-to-magenta brand progress bar. No build tool has a brand-gradient
   progress bar, and the moment one appears the whole cluster stops reading
   as tool output. Both the display line (.frag-diff .mile) and the bar
   (.frag-bar) are deleted, markup and all; the front plates are real chrome
   now — a diffstat, a CI pipeline row, a version panel, an artefact listing. */
.frag-term .num {
  color: var(--inf-fg-2);
}
.frag-term .mut {
  color: var(--inf-fg-4);
  white-space: normal;
}
.frag-term .add {
  color: var(--upstream-accent);
}
.frag-term .del {
  color: var(--inf-hyper-pink);
}

/* A CI pipeline's stage strip: the run is three stages in, which is what
   makes it mid-task rather than a summary of a finished job. */
.frag-steps {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--inf-space-0_5) var(--inf-space-2);
  padding: var(--inf-space-2) var(--inf-space-2) var(--inf-space-1);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  line-height: 1.4;
  color: var(--inf-fg-4);
  white-space: nowrap;
}
.frag-step {
  display: inline-flex;
  align-items: center;
  gap: var(--inf-space-0_5);
}
.frag-step .ph {
  font-size: var(--inf-type-label-size);
}
.frag-step--done {
  color: var(--inf-fg-3);
}
.frag-step--done .ph {
  color: var(--upstream-accent);
}
.frag-step--run {
  color: var(--inf-fg-1);
}
.frag-step .frag-dot {
  justify-self: auto;
}

/* The commercial line under a cluster. The three offers used to be a 3-up
   pricing grid the thread bisected; they are three features on the timeline
   now, so the price, the scope and the link ride with the artefact the offer
   produces rather than in a card of their own. Mono, one line, no panel — it
   reads as a caption on the fragments, which is what it is. */
.offer-spec {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--inf-space-1) var(--inf-space-2);
  margin: 0;
  padding: 0 var(--inf-space-4);
  font-family: var(--inf-font-mono);
  font-size: var(--inf-type-caption-size);
  color: var(--upstream-accent);
}
.offer-spec > .ph {
  font-size: var(--inf-type-label-size);
  color: var(--upstream-accent);
  align-self: center;
}
.offer-spec b {
  font-family: var(--inf-font-display);
  font-size: var(--inf-type-body-size);
  font-weight: 700;
  letter-spacing: var(--inf-type-h1-ls);
  color: var(--inf-fg-1);
}
.offer-spec > span {
  color: var(--inf-fg-3);
}
/* The link takes its own line under the name and price rather than being
   pushed to the far edge: right-aligned it stranded on a second line looking
   like a stray. */
.offer-spec .more {
  flex-basis: 100%;
}

/* Cluster geometry. Each cluster is a named arrangement of two or three
   plates at different depths; offsets are optical one-offs in rem, which the
   system permits, and every one of them is stated here rather than inline.
   No two clusters share a plate offset, a plate width or a crop distance —
   see the note on --frag-out above. Three of the eight are cut by the
   frame's top or bottom edge as well as its side. */

/* K1 · the cycle ledger behind the headline cost number. Table scrolled to the
   middle of its own rows, not to its header. */
.frag-k1 {
  --frags-h: 14.25rem;
  --frag-out: 3rem;
  --frag-back-y: 0;
  --frag-back-w: 29rem;
  --frag-front-y: 5rem;
  --frag-front-w: 20.5rem;
}

/* K3 · the rebase in flight — three plates, the terminal off the side. */
.frag-k3 {
  --frags-h: 17.5rem;
  --frag-out: 4rem;
  --frag-back-y: 0;
  --frag-back-w: 26rem;
  --frag-mid-y: 5rem;
  --frag-mid-w: 22rem;
  --frag-front-y: 6.75rem;
  --frag-front-w: 19.5rem;
}

/* Which plate hugs the column edge and which one is pushed out past the
   frame. In a left-hand slot the cluster grows leftwards off-screen; in a
   right-hand slot, rightwards. */
.frags--bleed-left .frag--in {
  right: var(--frag-in-x, 0);
}
.frags--bleed-right .frag--in {
  left: var(--frag-in-x, 0);
}
/* And where a MIDDLE plate sits. A three-plate cluster has a third position
   between the two above; on the index every middle plate also carried
   `--edge`, but four of the clusters across the other pages do not, and
   without this they fall back to their static x=0 and every three-plate
   cluster on the page lines up on the same edge. (Was authored in
   `upstream-evidence.css`, which does not own `.frags`.)

   NO FALLBACK ON THE var(), DELIBERATELY — and this is load-bearing now that
   the rule is global rather than evidence-only. Five three-plate clusters
   across the index, the notes and the numbers pages want their middle plate
   at its STATIC position, and they say so by not setting `--frag-mid-x` at
   all. An unresolvable `var()` with no fallback is invalid at
   computed-value time, so the property falls back to its initial `auto`,
   which is exactly that static position. Write `var(--frag-mid-x, 0)` here
   and all five snap to the frame edge instead — measured, on
   /upstream/rebase-notes/ and /upstream/fork-cost-model/. */
/* `:not(.frag--in)` MATTERS TOO. One middle plate on Upstream — the note
   index's kD terminal — is also the plate that hugs the column edge, and
   `.frag--in` above (0,2,0) is what positions it. Without the second `:not`
   this rule is (0,3,0), out-specifies it, and resolves to `auto`: measured,
   the terminal jumped 108px across the frame on /upstream/rebase-notes/.
   The two idioms are alternatives, so the selector says so. */
.frags--bleed-left .frag--mid:not(.frag--edge):not(.frag--in) {
  right: var(--frag-mid-x);
}
.frags--bleed-right .frag--mid:not(.frag--edge):not(.frag--in) {
  left: var(--frag-mid-x);
}

/* ── Direction-aware entry (the motion law, applied to the spine) ─────────
   An element settling right of the thread enters from the right, one
   settling left enters from the left. Nothing crosses the thread to reach its
   place. Set as --rv-from so it inherits into .grid-stagger children, which
   animate through @keyframes rather than a transition. Caption cards take the
   scale variant: 20px + scale(.9) over 700ms.

   INTERPRETATION, so it stops being re-litigated: the bar's "nothing under
   400ms" governs scroll-gated ENTRANCE motion, which is what the 600/700ms
   tokens are for. Hover and state feedback stays on --inf-dur (300ms) — a
   400ms hover feels sluggish and the reference's own hovers are faster than
   its entrances. One curve everywhere; two duration registers, entrance and
   feedback.                                                                */
.spine-row--card-left > .spine-visual,
.spine-row--card-left > .spine-visual [data-reveal],
.spine-row--card-right > .spine-cap,
.spine-row--card-right > .spine-stack {
  --rv-from: var(--rv-from-right);
}
.spine-row--card-right > .spine-visual,
.spine-row--card-right > .spine-visual [data-reveal],
.spine-row--card-left > .spine-cap,
.spine-row--card-left > .spine-stack {
  --rv-from: var(--rv-from-left);
}
.spine-row--card-left > .spine-cap,
.spine-row--card-left .spine-stack .spine-cap {
  --rv-from: var(--rv-from-left-lg);
  --rv-dur: var(--inf-dur-reveal-scale);
}
.spine-row--card-right > .spine-cap,
.spine-row--card-right .spine-stack .spine-cap {
  --rv-from: var(--rv-from-right-lg);
  --rv-dur: var(--inf-dur-reveal-scale);
}

/* ── The pair sequences: PROOF FIRST, THEN ITS CAPTION ───────────────────
   A bead is a pairing, and the law says siblings sequence rather than land
   together. Through round 4 the two halves of every bead started at the same
   instant and rose in lockstep; entering from opposite sides made it read as
   a hinge, which is better than a flat pop but is still not a stagger.

   The order is fixed and applied to all eight beads the same way: the PROOF
   arrives on the thread first (delay 0), the CAPTION that names it lands one
   stagger step behind. That is the reading order the spine is arguing for —
   you see the thing, then you are told what it is — and it is the direction
   the reference sequences in too.

   One increment, --inf-stagger (100ms). Inside a stack the cascade simply
   continues: visual 0 → caption 100 → the commercial line under it 200, so
   `.rv-1` is re-pointed to step 2 rather than colliding with the caption.  */
.spine-row > .spine-cap,
.spine-row .spine-stack .spine-cap {
  --rv-delay: var(--inf-stagger);
}
.spine-row .spine-stack > .rv-1 {
  --rv-delay: calc(var(--inf-stagger) * 2);
}

/* ── Content tuned for the track ─────────────────────────────────────────
   The accent tick beside each stat figure gives up its glow and most of its
   strength inside the spine. On the stat frame it was measurably the
   brightest line on screen — brighter than the thread running past it, which
   inverts the hierarchy the spine exists to set. The thread is the page's
   strongest continuous line; nothing local out-shouts it. Scoped to the
   TRACK, not to a half-width slot: the stat band is a full-width crossing
   again in round 4, and the rule has to follow it. */
.spine-track .bignum .bn::after {
  box-shadow: none;
  background: color-mix(in srgb, var(--upstream-accent) 55%, transparent);
}
.spine-visual .callout {
  margin: 0;
  max-width: none;
}

/* ── Below 900px: the thread collapses, and so does the alternation ──────
   Single column, caption before visual everywhere, no notches, no nodes, and
   the horizontal filament resumes duty as the section break. Entry motion
   goes back to vertical, because there is no longer a side to enter from.
   Fragment clusters stop overlapping and stack, because a cropped cluster in
   a 350px column is a smear rather than a window.                          */
@media (max-width: 900px) {
  .spine-line,
  .spine-node {
    display: none;
  }
  .spine-track .upstream-section::before {
    content: "";
  }
  .spine-row {
    grid-template-columns: minmax(0, 1fr);
    row-gap: var(--inf-space-4);
    margin: var(--inf-space-6) 0;
  }
  .spine-row + .spine-row {
    margin-top: var(--inf-space-8);
  }
  .spine-row > .spine-cap,
  .spine-row > .spine-stack,
  .spine-row > .spine-visual {
    grid-column: 1;
    justify-self: stretch;
  }
  /* The thread collapses here, so the trunk out of the merge has nothing to
     resolve into and goes with it. The graph itself stays: it is content, not
     ornament. Scaled up past the viewport and centred, the hero's own
     `overflow: hidden` crops the outer branches and what is left is the
     convergence — four merge lines closing on one bead behind the headline,
     which is the part of the diagram that has to survive the narrow column.
     The labels go: at this scale they would sit on top of the lede. */
  .hg-thread,
  .hg-tag {
    display: none;
  }
  .hero-graph {
    width: 260%;
    top: var(--inf-space-8);
  }
  /* Stacked, the caption comes first and the proof under it — the reading
     order the page had before the spine.

     `.spine-stack .spine-cap` IS IN THIS LIST DELIBERATELY. Five of the eight
     beads hang their caption straight off the row and collapsed to the full
     826/696px column; the three that wrap it in a `.spine-stack` kept the
     490px desktop card width, because only the row's DIRECT children were
     released. The collapsed column edge came out ragged at 890 and 760. Every
     card is released now, by the same rule, so the column has one edge.    */
  .spine-row > .spine-cap,
  .spine-row > .spine-stack {
    order: 1;
  }
  .spine-row > .spine-cap,
  .spine-row > .spine-stack,
  .spine-row .spine-stack .spine-cap {
    max-width: none;
  }
  .spine-row > .spine-visual {
    order: 2;
  }
  /* Sequence follows the stack, not the thread: with the caption above its
     proof, the caption leads and the proof lands a step behind it. Same one
     increment, reversed because the geometry reversed. */
  .spine-row > .spine-cap,
  .spine-row .spine-stack .spine-cap {
    --rv-delay: 0ms;
  }
  .spine-row .spine-stack > .rv-1 {
    --rv-delay: var(--inf-stagger);
  }
  .spine-row > .spine-visual {
    --rv-delay: calc(var(--inf-stagger) * 2);
  }
  /* One column: the opener's argument stops splitting, and neither does the
     track's — the crossing form collapses on the same line and by the same
     rule, so the page has one breakpoint and not two. */
  .spine-head .doc--two,
  .spine-track .spine-cross > .doc--two {
    columns: 1;
  }
  .spine-cap::before,
  .spine-cap::after {
    display: none;
  }
  /* No thread, no two-sided terminus: the CTA panel goes back to a single
     stacked column and the actions to a wrapping row. */
  .spine-track .cta-panel-inner {
    display: block;
  }
  .spine-track .cta-panel-inner > .hero-actions {
    flex-direction: row;
    align-items: center;
    margin: var(--inf-space-3) 0 0;
  }
  .spine-track .footnotes {
    columns: 1;
  }
  /* THE single-column collapse, for every multi-column evidence row on
     Upstream. `.arch-flow` (evidence layer) and `.nb-row` / `.nb-hero`
     (notes layer) each carried a byte-identical copy of this in their own
     sheet. Their base tracks stay with them — only the collapse is shared.

     THE `.upstream` PREFIX IS LOAD-BEARING: a media query adds no
     specificity, and both layer sheets load AFTER this one, so a bare
     `.arch-flow` here (0,1,0) would lose to their own base declaration
     (0,1,0, later in cascade order) and the collapse would silently stop
     happening. At (0,2,0) it wins on specificity regardless of load order.
     Anything joining this list needs the prefix for the same reason. */
  .upstream .arch-flow,
  .upstream .nb-row,
  .upstream .nb-row--fig-left,
  .upstream .nb-hero {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--inf-space-4);
  }
  .frags {
    min-height: 0;
    display: grid;
    gap: var(--inf-space-2);
  }
  .frag {
    position: static;
    width: auto !important;
    opacity: 1;
  }
  /* One column: the chrome bar wraps rather than losing its right-hand
     count to the fragment's own overflow. */
  .frag-head {
    flex-wrap: wrap;
    white-space: normal;
  }
  .frag-head .frag-meta {
    padding-left: 0;
  }
  .frag-scroll {
    height: auto;
  }
  .frag-scroll .frag-table {
    margin-top: 0;
  }
  /* A cropped fragment is the mechanism; a word cut through its second
     letter is just a bug. In one column the inventory drops its last field
     instead of being sliced mid-glyph. */
  .frag-table th:last-child,
  .frag-table td:last-child {
    display: none;
  }
  .spine-row--card-left > .spine-cap,
  .spine-row--card-right > .spine-cap,
  .spine-row--card-left .spine-stack .spine-cap,
  .spine-row--card-right .spine-stack .spine-cap,
  .spine-row--card-left > .spine-stack,
  .spine-row--card-right > .spine-stack,
  .spine-row--card-left > .spine-visual,
  .spine-row--card-right > .spine-visual,
  .spine-row--card-left > .spine-visual [data-reveal],
  .spine-row--card-right > .spine-visual [data-reveal],
  .spine-row--card-left > .spine-visual [data-reveal],
  .spine-row--card-right > .spine-visual [data-reveal] {
    --rv-from: var(--rv-from-below);
    --rv-dur: var(--inf-dur-reveal);
  }
}

/* =========================================================================
   REDUCED MOTION — THE BLANKET. Last block in the file, deliberately.

   This is the THIRD distinct reduced-motion bug in five rounds, and all
   three had the same root cause: an ENUMERATED allow-list. Round 2's block
   was dead by cascade order. Round 3's list was missing a selector. Round 4's
   list was complete for everything it knew about and still leaked a 700ms
   reveal duration plus 62 live transitions, because "remember to register
   every future animation here" is not a control that holds across rounds.

   So the list is gone and the rule is universal: under `reduce`, nothing
   under `.upstream` has a duration. Add an animation anywhere on these pages
   and it is already covered — there is nothing left to remember.

   The blanket kills DURATION, not the end state. A duration-only kill would
   leave an element frozen at its entry transform, so the final-state rules
   that follow (and the ones further up this file) stay: they pin opacity to 1
   and transform to none so the page renders at rest, fully visible, exactly
   as it does after a normal entrance has landed.

   Placed at the end of the file so no later declaration can out-order it,
   and armed with !important so no later declaration can out-specify it
   either — belt and braces, because order alone is what failed in round 2.
   ========================================================================= */
@media (prefers-reduced-motion: reduce) {
  .upstream,
  .upstream *,
  .upstream *::before,
  .upstream *::after {
    animation-duration: 0s !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0s !important;
    scroll-behavior: auto !important;
  }
  /* END STATE, not just zero duration: every scroll-gated element renders
     landed. `--rv-from` is neutralised at the source as well as via
     `transform`, so nothing can reintroduce the entry offset. */
  .upstream [data-reveal],
  .upstream [data-reveal] > *,
  .upstream .grid-stagger[data-reveal] > * {
    --rv-from: none;
    --rv-delay: 0ms;
    opacity: 1 !important;
    transform: none !important;
  }
  /* The hero branch graph draws with stroke-dashoffset, which no opacity or
     transform reset reaches. */
  .upstream .hg-svg .hg-path {
    opacity: 1 !important;
    stroke-dashoffset: 0px !important;
  }
  /* The pipeline card's lip light animates in on a keyframe with `both`:
     zero duration lands it on its final frame, and this states that frame
     explicitly rather than relying on it. */
  .upstream .mock-window::before,
  .upstream .upstream-mock.in .mock-window::before {
    opacity: 1 !important;
  }
  /* AMBIENT LOOPS ONLY. A breath has no meaningful "final frame" — zeroing
     its duration parks it on the 100% keyframe, which for a symmetric
     oscillation is the trough, not the resting value. Dropping the animation
     name instead lets each rest on the static value its own rule declares
     (for the glints, 0.5 — the cycle's own midpoint). This is NOT the old
     allow-list coming back: motion is already fully killed by the blanket
     above whether or not a selector appears here; this only chooses which
     frame the calm state shows. */
  .upstream .fs-glint {
    animation-name: none !important;
  }
}

/* ── Touch and narrow screens: no live blur ─────────────────────────────
   Mobile Safari was dropping the page ("a problem repeatedly occurred") and
   scrolling it slowly. The cause is GPU memory, not download size: the two
   ambient glows are 900px squares under a 140px `filter: blur`, which at 3×
   DPR rasterises to roughly 5,000px square each, and they sit in a fixed
   layer under fourteen `backdrop-filter` surfaces (one of them 1,193px tall),
   so every scroll frame re-blurs the glows behind each card.

   On these screens the glows keep their colour and position but fade with a
   longer gradient instead of a filter, and glass drops the live blur for a
   denser fill of the same Void surface. Over a near-black field the blur was
   close to invisible anyway; the fill keeps the grid and glows from reading
   through crisply. Desktop is untouched.                                    */
@media (max-width: 900px), (hover: none) {
  .upstream {
    --inf-glass-blur: none;
    --inf-glass-bg: color-mix(in srgb, var(--inf-void) 88%, transparent);
  }
  .upstream-bg::before,
  .upstream-bg::after {
    filter: none;
  }
  .upstream-bg::before {
    background: radial-gradient(circle, var(--inf-deep-nebula), transparent 70%);
  }
  .upstream-bg::after {
    background: radial-gradient(circle, var(--inf-electric-blue), transparent 70%);
  }
}
