/* ─────────────────────────────────────────────────────────────────────────
   Outfall Works, everything below the hero.

   The hero is the ThreeUI Sylva template, loaded verbatim from sylva.css.
   This sheet carries the same material into a scrolling document: Lexend at
   300, the paper cards at #f2f3ef, the two Sylva easings, the clip-and-rise
   entrance, and the hero's own three column guides continued the length of
   the page.

   Two rules keep the two sheets from fighting:
     · nothing here selects a Sylva class except the liquid-metal control,
       which is reused on purpose for the pilot request;
     · the design unit --u is used for frame alignment only. Sylva's frame is
       a fixed 1600 × 880 stage, and scaling body copy by it puts a 19u lede
       at nine pixels on a phone. Type below the hero is clamped instead.
   ───────────────────────────────────────────────────────────────────────── */

/* Tokens on :root rather than on .page, and the reason is the definition
   tooltip. It is a fixed overlay, so terms.mjs appends it to document.body —
   the one place on the page with no transformed ancestor that could quietly
   become its containing block — which puts it outside .page and outside
   anything scoped to it. A token that resolves to nothing does not fall back
   to something sensible; `calc(var(--radius) * .55)` is simply an invalid
   declaration, and the tooltip shipped with square corners and inherited text
   for exactly as long as that went unnoticed. Anything a floating object needs
   belongs next to Sylva's own :root tokens.

   Radius is a named scale rather than a clamp retyped at each use, which is
   shadcn's one genuinely portable idea for a page that has no components to
   install: name the steps once, spend them everywhere. Its own scale derives
   every step from a single `--radius` by `calc()`. That is not done here, and
   the reason is worth stating: the pane and the paper panel are tuned to
   different objects — a translucent container of rows against the one opaque
   workpaper on the page — and forcing a ratio between them would move a
   measured value to satisfy an arithmetic that means nothing. The convention
   is the payload; the false derivation is not. */
:root{
  --radius:     clamp(18px, 1.9vw, 30px);   /* a pane */
  --radius-lg:  clamp(24px, 2.5vw, 44px);   /* the paper panel */
  --radius-sm:  calc(var(--radius) * .4);   /* the skip link */

  /* Light type on a dark ground reads a weight lighter than it is, so body
     copy sits here rather than at Sylva's --ink-soft, which is tuned for a
     22u lede over a lit canvas and not for a long scroll over flat ground. */
  --ink-mid: rgba(255,255,255,.74);
}

.page{
  /* ── type roles ───────────────────────────────────────────────────────
     One family, nine jobs. The jobs are separated by scale and weight, not
     by nine variations on the same tracked-out capital label: a page where
     the section index, the schedule date, the gate, the terms key and the
     rule state all wear 11px uppercase letterspacing communicates no
     structure at all, it just looks busy. Capitals survive in exactly three
     places, each at a different size and in a different context:
     .sec-index, the chain's node kind, and a state chip. Everything else
     that used to be a label is now set in sentence case and told apart by
     size, weight and colour.

     Ladder, at 1600px: 11 · 15 · 17 · 18 · 21 · 28 · 48. */
  --t-micro:    .7rem;                                       /* section index */
  --t-node:     .62rem;                                      /* chain node kind */
  --t-chip:     .68rem;                                      /* state chip */
  --t-key:      .95rem;                                      /* dates, keys, gates */
  --t-notation: .92rem;                                      /* formulas, identifiers */
  --t-body:     clamp(.98rem, .34vw + .87rem, 1.08rem);
  --t-note:     clamp(1rem, .42vw + .88rem, 1.13rem);
  --t-lead:     clamp(1.1rem, .5vw + .96rem, 1.28rem);
  --t-sub:      clamp(1.32rem, 1.1vw + 1.06rem, 1.72rem);
  --t-title:    clamp(1.78rem, 2.7vw, 3rem);
  --t-title-lg: clamp(2.1rem, 3.6vw, 3.9rem);
  --t-figure:   clamp(2.1rem, 3.1vw, 3.4rem);

  --lh-body: 1.62;
  --measure: 66ch;

  /* State semantics, desaturated onto the sage ground. Never carried by
     colour alone: every state also has a word and a marker. */
  --block:      #dd8659;
  --warn:       #dfba6a;
  --ok:         #a6c496;
  --rule-2:     rgba(255,255,255,.10);
  --rule-3:     rgba(255,255,255,.17);
  /* the same three on paper, dark enough to hold contrast on #f2f3ef */
  --block-ink:  #a84d1e;
  --warn-ink:   #8a6612;
  --ok-ink:     #47693a;
  --paper-rule: rgba(35,38,31,.15);

  position:relative;
}

/* ── skip link and scroll progress ───────────────────────────────────── */
.skip{
  position:absolute; left:calc(46 * var(--u)); top:0;
  z-index:30; padding:10px 16px;
  transform:translateY(-140%);
  background:var(--card); color:var(--card-ink);
  border-radius:0 0 var(--radius-sm) var(--radius-sm);
  font-size:13px; font-weight:500; text-decoration:none;
}
.skip:focus-visible{ transform:none; }

.progress{
  position:fixed; inset:0 0 auto; height:1px; z-index:20;
  background:rgba(255,255,255,.06);
  opacity:0; pointer-events:none;
  transition:opacity .5s var(--ease);
}
.progress.on{ opacity:1; }
.progress i{
  display:block; height:100%;
  background:rgba(255,255,255,.52);
  transform:scaleX(var(--p,0)); transform-origin:0 50%;
}

/* ── the hero's column guides, continued ─────────────────────────────────
   Same box as .stage and the same three offsets, so the hairlines the moss
   sits between run the whole length of the document. Dropped under 900px,
   where Sylva re-lays the stage on a 760 unit frame. */
.rails{
  position:absolute; z-index:0; top:0; bottom:0; left:50%;
  width:calc(1600 * var(--u)); margin-left:calc(-800 * var(--u));
  pointer-events:none;
}
.rails i{ position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,.042); }
.rails i:nth-child(1){ left:calc(405 * var(--u)); }
.rails i:nth-child(2){ left:calc(748 * var(--u)); }
.rails i:nth-child(3){ left:calc(1091 * var(--u)); }

/* ── section frame ───────────────────────────────────────────────────────
   The content column is the hero's: 1600u wide, inset by the 46u the
   headline is inset by, so every heading on the page starts on the same
   vertical as "Prove every". */
.sec{
  position:relative; z-index:1;
  width:calc(1600 * var(--u)); max-width:100%;
  margin-inline:auto;
  padding:clamp(74px, 7vw, 148px) calc(46 * var(--u));
}
.sec + .sec{ border-top:1px solid rgba(255,255,255,.055); }

/* The head is an editorial split, but not the same split seven times. A single
   ratio repeated down the page is what makes a long scroll read as a template
   rather than as a document, so each section gets the axis its content wants
   and the page never shows the same head shape twice in a row. */
.sec-head{
  display:grid;
  grid-template-columns:minmax(0,1.06fr) minmax(0,.94fr);
  column-gap:clamp(24px, 3.4vw, 72px);
  row-gap:clamp(18px, 2vw, 30px);
  align-items:end;
  padding-bottom:clamp(40px, 4.6vw, 76px);
  border-bottom:1px solid var(--rule-2);
}

/* One statement, wide, with its qualification set under the right half. Used
   where the section opens an argument rather than labelling a table. */
#stakes .sec-head{ grid-template-columns:minmax(0,1fr); }
#stakes .sec-title{ grid-column:1; max-width:30ch; }
#stakes .sec-note{
  grid-column:1; justify-self:end;
  max-width:48ch; padding-top:clamp(10px, 1.2vw, 22px);
}

/* The anchor section keeps the balanced split: it is the one head on the page
   that sits directly over a dense interactive panel and wants symmetry. */
#evidence .sec-head{ grid-template-columns:minmax(0,1.02fr) minmax(0,.98fr); }

/* The ledger below is 16 rows of small type, so its head stays tight and the
   note is narrow enough not to compete with the table's own column rhythm. */
#rules .sec-head{ grid-template-columns:minmax(0,1.34fr) minmax(0,.66fr); }
#rules .sec-note{ max-width:40ch; }

/* Reversed: qualification first, statement second, reading right. The three
   layer panes below are left-weighted, so the head leans the other way. */
#how .sec-head{ grid-template-columns:minmax(0,.72fr) minmax(0,1.28fr); }
#how .sec-title{ grid-column:2; max-width:26ch; }
#how .sec-note{ grid-column:1; grid-row:2; max-width:42ch; align-self:start; }

/* A single wide statement. The four phases below carry all the detail, so a
   note beside the title here would only be read twice. */
#pilot .sec-head{ grid-template-columns:minmax(0,1fr); }
#pilot .sec-title{ max-width:34ch; }
#pilot .sec-note{ grid-column:1; max-width:62ch; }

/* Six short lines follow, so the head is the smallest on the page. */
#boundary .sec-head{
  grid-template-columns:minmax(0,1fr) minmax(0,1fr);
  padding-bottom:clamp(26px, 3vw, 46px);
}

.sec-title{
  grid-column:1; max-width:23ch;
  font-size:var(--t-title);
  line-height:1.06; font-weight:300; letter-spacing:-.02em;
  color:var(--ink);
}
.sec-title--lg{ font-size:var(--t-title-lg); max-width:20ch; }
.sec-note{
  grid-column:2; max-width:56ch;
  font-size:var(--t-note);
  line-height:1.58; font-weight:300; color:var(--ink-soft);
}

/* Rhythm, not a constant. The evidence panel is the thing the page is for,
   so it gets the most air above it; the boundary list is six short lines and
   needs the least. */
.sec-body{ padding-top:clamp(28px, 3.2vw, 56px); }
#evidence .sec-body{ padding-top:clamp(38px, 4.8vw, 88px); }
#boundary .sec-body{ padding-top:clamp(24px, 2.6vw, 44px); }

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

/* ── the pane ────────────────────────────────────────────────────────────
   Sylva's dock is the only translucent object in the hero, and its recipe is
   a lit top edge over a tinted fill with a 1px rim: no backdrop-filter. The
   authored comment says why, and it is measured: the dock sits over a canvas
   that repaints every frame, so a blurred backdrop has to be re-sampled and
   re-blurred every frame too, which cost the whole page about two thirds of
   its frame rate for no visible gain at that size.

   The same holds here for a different reason. Behind these panels there is
   nothing but flat ground and three hairlines, so a blur has nothing to blur
   and still costs a composite pass on every scroll frame. What actually makes
   glass read as glass at this scale is the lit edge, and that is free.

   The tint is lighter than the dock's rather than darker, because the dock
   floats over a lit canvas and these float over #383b34. Same recipe,
   inverted for the ground it sits on. */
.pane{
  position:relative;
  border-radius:var(--radius);
  border:1px solid rgba(255,255,255,.085);
  background:
    linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,0) 46%),
    rgba(255,255,255,.032);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.075),
    0 20px 44px -26px rgba(8,11,7,.55);
}
/* Panes hold rows; rows are never panes of their own. One level of
   containment, always. Dividers are declared per pane, because a pane whose
   children are grid columns rather than stacked rows must not get them. */
.pane-rows > * + *{ border-top:1px solid rgba(255,255,255,.07); }

/* The head of a pane that groups things: what it is, how many, and one plain
   sentence about it. */
.group-head{
  display:grid; grid-template-columns:minmax(0,1fr) auto;
  align-items:baseline; column-gap:20px; row-gap:8px;
  padding:clamp(20px, 2.1vw, 30px) clamp(20px, 2.2vw, 34px);
}
.group-head h3{
  display:flex; align-items:baseline; gap:12px;
  font-size:var(--t-sub); line-height:1.12; font-weight:400;
  letter-spacing:-.012em; color:var(--ink);
}
.group-mark{ flex:none; margin-bottom:2px; width:9px; height:9px; }
.group-mark[data-state="built"]{ background:var(--ok); }
.group-mark[data-state="spec"]{ box-shadow:inset 0 0 0 1px var(--ink-faint); }
.group-count{
  justify-self:end;
  font-size:var(--t-key); font-weight:400; color:var(--ink-faint);
  font-variant-numeric:tabular-nums;
}
.group-note{
  grid-column:1 / -1; max-width:64ch;
  font-size:var(--t-body); line-height:1.5; font-weight:300; color:var(--ink-soft);
}

/* A plain-language aside: the same tier as body copy, marked by its lead word
   rather than by a box, so it does not read as another grouped object. */
.plain{
  max-width:var(--measure); margin-top:clamp(22px, 2.4vw, 34px);
  font-size:var(--t-body); line-height:var(--lh-body); font-weight:300; color:var(--ink-soft);
}
.plain b{ font-weight:500; color:var(--ink); }

/* ── the glossary ────────────────────────────────────────────────────────
   Eight terms, because a permit engineer knows all eight and nobody else knows
   any of them, and the rest of the page is unreadable without them. Added
   rather than substituted: no domain identifier anywhere else on the page was
   softened to make room for this. */
.words{ margin-top:clamp(16px, 1.8vw, 26px); }
.words > .group-head{ grid-template-columns:1fr; }
.words .glossary{ border-top:1px solid rgba(255,255,255,.07); }
.glossary{
  display:grid; grid-template-columns:repeat(2, minmax(0,1fr));
  padding:clamp(6px, .8vw, 12px) clamp(20px, 2.2vw, 34px) clamp(20px, 2.1vw, 30px);
  column-gap:clamp(24px, 3vw, 56px);
}
.glossary > div{ padding:clamp(15px, 1.6vw, 21px) 0; }
.glossary > div:nth-child(odd){ border-right:1px solid rgba(255,255,255,.055); padding-right:clamp(20px, 2.4vw, 44px); }
.glossary > div:nth-child(n+3){ border-top:1px solid rgba(255,255,255,.055); }
.glossary dt{
  margin-bottom:5px;
  font-size:var(--t-key); font-weight:500; color:var(--ink);
}
.glossary dd{
  max-width:46ch;
  font-size:.92rem; line-height:1.52; font-weight:300; color:var(--ink-soft);
}
.glossary dd b{ font-weight:500; color:var(--ink); letter-spacing:.02em; }

/* ── 01 · what is at stake ───────────────────────────────────────────────
   Three figures, but not three equal metric tiles with a big number over a
   small label: that shape is the same on every SaaS page on the internet and
   it flattens an argument into a scoreboard. This is the argument, in order,
   as running sentences: 46,000 facilities, 9.3% of them, over half of those.
   The figure scale falls with each step because the population does, and
   because "Over half" is a qualifier and should not be set at the same size
   as a measurement. Each line carries the source it came from, which is the
   whole point of the product. */
.claims{ list-style:none; }
.claims li{
  display:grid;
  grid-template-columns:minmax(0, clamp(112px, 12vw, 196px)) minmax(0, 1fr);
  column-gap:clamp(18px, 2.4vw, 46px);
  align-items:baseline;
  padding:clamp(20px, 2.4vw, 34px) clamp(20px, 2.2vw, 34px);
}
.claims b{
  font-size:calc(var(--t-figure) * var(--fs, 1));
  line-height:.96; font-weight:300; letter-spacing:-.03em;
  color:var(--ink); font-variant-numeric:tabular-nums;
}

/* ── the figures arrive a digit at a time ─────────────────────────────────
 *
 * Technique from transitions.dev's "number pop-in": digits rise in sequence,
 * left to right, instead of the whole figure fading in as one block.
 * Reimplemented rather than installed, because that library and the three
 * alongside it ship React components through the shadcn CLI, and this page has
 * no build step and no runtime dependency to hang them on.
 *
 * The blur the original pairs with the rise is dropped. It does not trigger
 * layout, so it would pass the rule this file is written to, but blurring
 * 300-weight text for half a second is the decorative half of the effect and
 * this page is meant to read like a workpaper. The rise and the stagger are
 * the part that carries meaning.
 *
 * It earns the motion here specifically. These three numbers are the only
 * quantities on the page cited to somebody else's public record, and a figure
 * that assembles itself reads as one being counted rather than announced.
 * `tabular-nums` on the line above is what makes it safe: every glyph is the
 * same width, so a digit can move without shifting the ones after it, and no
 * layout property is animated.
 *
 * `--n` is the digit's index, assigned by app.mjs from DOM order. */
.js .claims b .d{
  display:inline-block;
  opacity:0;
  transform:translate3d(0, .34em, 0);
}
.js .claims.on b .d{
  opacity:1;
  transform:none;
  transition:opacity .5s var(--ease) calc(var(--n, 0) * 42ms),
             transform .62s var(--ease-out) calc(var(--n, 0) * 42ms);
}
/* a separator is not a digit: it holds still, so the number keeps its shape
 * while the digits land around it */
.js .claims b .sep{ display:inline-block; }
.claims li:nth-child(2) b{ --fs:.82; }
.claims li:nth-child(3) b{ --fs:.62; }
.claims p{
  max-width:var(--measure);
  font-size:var(--t-lead); line-height:1.48; font-weight:300; color:var(--ink);
}
.claims a{
  grid-column:2; margin-top:14px; justify-self:start;
  display:inline-flex; align-items:baseline; gap:11px;
  font-size:.8rem; font-weight:400; color:rgba(255,255,255,.5);
  text-decoration:none;
  transition:color .2s var(--ease);
}
.claims a::before{
  content:''; flex:none; margin-bottom:.34em;
  width:15px; height:1px; background:var(--rule-3);
}
.claims a:hover{ color:var(--ink); text-decoration:underline; text-underline-offset:4px; }

/* The outcome is the loudest thing in this block, so it is set as a
   statement and the consequence runs underneath it as body copy. It used to
   be the other way round: a 13px tracked-out capital label carrying "Report
   too low" above a 20px paragraph. */
.fork{
  display:grid; grid-template-columns:repeat(2, minmax(0,1fr));
  gap:clamp(16px, 1.8vw, 26px);
  margin-top:clamp(30px, 3.2vw, 52px);
}
.fork-row{ padding:clamp(22px, 2.4vw, 34px) clamp(20px, 2.2vw, 32px); }
.fork-row dt{
  display:flex; align-items:baseline; gap:14px;
  font-size:var(--t-sub); line-height:1.12; font-weight:400; letter-spacing:-.012em;
  color:var(--ink);
}
/* Filled for a state that was met, hollow for one that is held: the marker
   carries the distinction so it survives greyscale and the colour only
   reinforces it. */
.fork-row dt::before{ content:''; flex:none; margin-bottom:1px; width:9px; height:9px; background:var(--ink-faint); }
.fork-row[data-state="warn"] dt::before{ background:none; box-shadow:inset 0 0 0 1px var(--warn); }
.fork-row[data-state="block"] dt::before{ background:none; box-shadow:inset 0 0 0 2px var(--block); }
.fork-row dd{
  margin:13px 0 0 23px; max-width:56ch;
  font-size:var(--t-lead); line-height:1.5; font-weight:300; color:var(--ink-mid);
}

/* ── 02 · the evidence panel ─────────────────────────────────────────────
   The one paper object on the page, because it is the one thing here that a
   reviewer would actually read on a workbench. */
.panel{
  border:0; padding:0; min-width:0;
  background:var(--card); color:var(--card-ink);
  border-radius:var(--radius-lg);
  box-shadow:0 30px 70px rgba(16,21,13,.30);
  display:grid; grid-template-columns:minmax(0,330px) minmax(0,1fr);
  overflow:hidden;
  transition:box-shadow .8s var(--ease);
}
.panel:hover{ box-shadow:0 38px 84px rgba(16,21,13,.36); }

.params{
  border-right:1px solid var(--paper-rule);
  display:flex; flex-direction:column;
}
/* the gate: what the four states add up to, sitting at the foot of the column
   rather than leaving it as blank paper */
.params-gate{
  margin-top:auto;
  padding:clamp(18px, 1.9vw, 26px) clamp(20px, 2vw, 30px);
  border-top:1px solid var(--paper-rule);
  background:rgba(35,38,31,.035);
  font-size:.88rem; line-height:1.5; font-weight:300; color:#5a604f;
}
.params-gate b{
  display:flex; align-items:baseline; gap:10px; margin-bottom:6px;
  font-size:var(--t-key); font-weight:500; color:var(--block-ink);
}
.params-gate b::before{
  content:''; flex:none; margin-bottom:1px; width:7px; height:7px;
  box-shadow:inset 0 0 0 2px var(--block-ink);
}
.param{
  position:relative;
  display:grid; grid-template-columns:minmax(0,1fr) auto;
  align-items:baseline;
  gap:3px clamp(10px, 1.2vw, 18px);
  padding:clamp(17px, 1.7vw, 24px) clamp(20px, 2vw, 30px);
  cursor:pointer;
  transition:background .3s var(--ease);
}
.param + .param{ border-top:1px solid var(--paper-rule); }
.param:hover{ background:rgba(35,38,31,.045); }
.param-name{ grid-column:1; font-size:1.02rem; font-weight:400; color:var(--card-ink); }
.param-stat{ grid-column:1; font-size:.8rem; font-weight:300; color:var(--card-label); }
.param-value{
  grid-column:2; grid-row:1;
  font-size:var(--t-notation); font-weight:400; letter-spacing:.02em;
  color:var(--card-ink); font-variant-numeric:tabular-nums;
}
.chip{
  grid-column:2; grid-row:2;
  justify-self:end; display:inline-flex; align-items:baseline; gap:7px;
  font-size:var(--t-chip); font-weight:500; letter-spacing:.1em; text-transform:uppercase;
  color:var(--card-label);
}
/* Ready is met, so it is filled. Warning and blocked are held, so they are
   hollow, and blocked carries the heavier ring. Three forms, greyscale-safe,
   each also named in words beside it. */
.chip::before{
  content:''; flex:none; margin-bottom:1px; width:7px; height:7px; border-radius:50%;
  background:currentColor;
}
.chip[data-state="warn"]::before{ background:none; box-shadow:inset 0 0 0 1px currentColor; }
.chip[data-state="block"]::before{ background:none; box-shadow:inset 0 0 0 2px currentColor; }
.chip[data-state="ready"]{ color:var(--ok-ink); }
.chip[data-state="warn"]{ color:var(--warn-ink); }
.chip[data-state="block"]{ color:var(--block-ink); }

/* the selected row wears the whiter paper, plus a filled edge, so the
   selection survives greyscale */
#p-bod:checked  ~ .params .param[for="p-bod"],
#p-tss:checked  ~ .params .param[for="p-tss"],
#p-nh3:checked  ~ .params .param[for="p-nh3"],
#p-cu:checked   ~ .params .param[for="p-cu"]{ background:#fbfcf8; }
#p-bod:checked  ~ .params .param[for="p-bod"] .param-name,
#p-tss:checked  ~ .params .param[for="p-tss"] .param-name,
#p-nh3:checked  ~ .params .param[for="p-nh3"] .param-name,
#p-cu:checked   ~ .params .param[for="p-cu"] .param-name{ font-weight:500; }
#p-bod:checked  ~ .params .param[for="p-bod"]::before,
#p-tss:checked  ~ .params .param[for="p-tss"]::before,
#p-nh3:checked  ~ .params .param[for="p-nh3"]::before,
#p-cu:checked   ~ .params .param[for="p-cu"]::before{
  content:''; position:absolute; left:0; width:3px; top:0; bottom:0;
  background:var(--card-ink);
}
/* the radios are visually hidden, so keyboard focus has to be drawn on the
   label that stands in for them */
#p-bod:focus-visible ~ .params .param[for="p-bod"],
#p-tss:focus-visible ~ .params .param[for="p-tss"],
#p-nh3:focus-visible ~ .params .param[for="p-nh3"],
#p-cu:focus-visible  ~ .params .param[for="p-cu"]{
  outline:2px solid rgba(35,38,31,.85); outline-offset:-4px; border-radius:4px;
}

.chains{ min-width:0; }
.chain{ list-style:none; padding:clamp(22px, 2.2vw, 36px) clamp(20px, 2.4vw, 40px); display:none; }
#p-bod:checked ~ .chains .chain[data-for="bod"],
#p-tss:checked ~ .chains .chain[data-for="tss"],
#p-nh3:checked ~ .chains .chain[data-for="nh3"],
#p-cu:checked  ~ .chains .chain[data-for="cu"]{ display:block; }

/* The section is called "one number, six links", so the links are numbered.
   A counter rather than markup, so the chains stay pure content. */
.chain{ counter-reset:link; }
.chain li{ position:relative; counter-increment:link; padding:0 0 clamp(17px, 1.8vw, 25px) 34px; }
.chain li:last-child{ padding-bottom:0; }
.chain li::before{
  content:counter(link);
  position:absolute; left:0; top:2px;
  width:20px; height:20px; border-radius:50%;
  display:grid; place-items:center;
  font-size:.63rem; font-weight:500; letter-spacing:.02em;
  font-variant-numeric:tabular-nums;
  background:var(--card-ink); color:var(--card);
}
.chain li::after{
  content:''; position:absolute; left:9.5px; top:26px; bottom:0; width:1px;
  background:var(--paper-rule);
}
.chain li:last-child::after{ display:none; }
.chain b{
  display:block; margin-bottom:5px;
  font-size:var(--t-node); font-weight:500; letter-spacing:.16em; text-transform:uppercase;
  color:var(--card-label);
}
/* The value is what the reader came for, so it outranks the reference that
   produced it: 1.12rem at 500 against .9rem at 300. */
.chain em{
  display:block;
  font-style:normal; font-size:1.12rem; line-height:1.34; font-weight:500;
  color:var(--card-ink); font-variant-numeric:tabular-nums; letter-spacing:-.005em;
}
.chain span{
  display:block; margin-bottom:3px;
  font-size:.9rem; line-height:1.5; font-weight:300; color:#4d5347;
}
.chain em + span{ margin-top:8px; }
/* two lab revisions for one cell: each gets its own ruled row, or the four
   alternating references and values read as one jumbled paragraph */
.chain .split + .split{ margin-top:10px; padding-top:10px; border-top:1px solid var(--paper-rule); }

/* State has to outrank the plain node above, so these come last. A break
   interrupts the spine with a dashed run rather than recolouring it. */
.chain li[data-state="ready"]::before{ background:var(--ok-ink); }
.chain li[data-state="warn"]::before{ background:var(--warn-ink); }
.chain li[data-state="block"]::before{
  background:transparent; color:var(--block-ink);
  box-shadow:inset 0 0 0 1.5px var(--block-ink);
}
.chain li[data-state="block"]::after{
  background:repeating-linear-gradient(180deg, var(--block-ink) 0 3px, transparent 3px 7px);
  opacity:.55;
}
.chain em[data-state="block"]{ color:var(--block-ink); text-decoration:line-through; text-decoration-thickness:1px; }
.chain li[data-state="block"] span{ color:#6a4028; }

/* ── the chain assembles ─────────────────────────────────────────────────
   Choosing a parameter used to be a hard cut: one `display:none` traded for a
   `display:block`, six links replaced between two frames. This panel is the
   one interactive object on the page and the section above it is called "one
   number, six links", so the switch earns the same care the three figures in
   01 get, and for the same reason. A chain that assembles itself reads as one
   being followed. A chain that is simply there reads as a picture of one.

   Animation rather than transition, because the mechanism is already in the
   markup and costs nothing to reuse. An element whose ancestor is
   `display:none` has no running animations; the moment the radio moves and
   that ancestor is displayed again, they start from their first frame. So one
   set of declarations serves the first arrival and every switch after it, and
   the selection stays four radios and CSS with nothing listening for it.

   Gated on `.panel.on` rather than on the sheet, so the first chain waits for
   the panel's own entrance instead of playing to an empty screen while the
   reader is still four sections above it.

   The index is `:nth-child` rather than the scripted `--i` the staggered
   groups use. Those groups are expected to grow; a chain is six links by
   definition here, and the count is the section's own title. */
@keyframes link-in{  from{ opacity:0; transform:translate3d(0, 9px, 0); } }
@keyframes disc-in{  from{ transform:scale(.5); } }
@keyframes spine-draw{ from{ transform:scaleY(0); } }
@keyframes spine-fade{ from{ opacity:0; } }

.chain li{ --l:0; --at:calc(var(--l) * 64ms); }
.chain li:nth-child(2){ --l:1; }
.chain li:nth-child(3){ --l:2; }
.chain li:nth-child(4){ --l:3; }
.chain li:nth-child(5){ --l:4; }
.chain li:nth-child(6){ --l:5; }

.js .panel.on .chain li{
  animation:link-in .54s var(--ease) var(--at) backwards;
}
/* the numbered disc lands just behind its own link, so the link arrives and
   then takes its place in the count */
.js .panel.on .chain li::before{
  animation:disc-in .5s var(--ease-out) calc(var(--at) + 60ms) backwards;
}
/* and the spine draws downward out of the disc it just left, which is the one
   thing on this panel that is literally a chain being followed */
.js .panel.on .chain li::after{
  transform-origin:50% 0;
  animation:spine-draw .5s var(--ease-out) calc(var(--at) + 170ms) backwards;
}
/* A broken run is dashed, and the dash is a state marker rather than
   decoration. Scaling it would stretch its period from nothing up to 7px on
   the way down and deform the one mark on this spine that has to survive
   greyscale, so a blocked link fades its spine in on the same beat instead of
   drawing it. */
.js .panel.on .chain li[data-state="block"]::after{
  animation-name:spine-fade;
}

.live{
  margin-top:clamp(18px, 2vw, 28px); max-width:var(--measure);
  font-size:var(--t-key); font-weight:300; color:var(--ink-soft);
}

/* ── 03 · the rule library ───────────────────────────────────────────────
   Reading order is name, then the filed error it catches, then the
   arithmetic. What it catches is the payload of this table and it used to sit
   third, fainter than the rule's own name. The formula is now recessive and
   tracked out, so it reads as notation rather than as another sentence set at
   the same size as everything around it. */
.group + .group{ margin-top:clamp(16px, 1.8vw, 26px); }
.ledger-scroll{ overflow-x:auto; padding:0 clamp(20px, 2.2vw, 34px) clamp(12px, 1.4vw, 20px); }
.ledger{ width:100%; min-width:560px; border-collapse:collapse; text-align:left; }
.ledger th, .ledger td{
  padding:15px 24px 15px 0; vertical-align:top;
  border-bottom:1px solid var(--rule-2);
  font-size:var(--t-body); line-height:1.46; font-weight:300; color:var(--ink-soft);
}
.ledger thead th{
  padding-bottom:13px; border-bottom:1px solid var(--rule-3);
  font-size:.82rem; font-weight:400; letter-spacing:0; text-transform:none;
  color:var(--ink-faint);
}
.ledger tbody th{ font-weight:400; color:var(--ink); width:24%; }
.ledger td[data-label="Catches"]{ width:44%; color:var(--ink-mid); }
.ledger .form{
  width:32%; padding-right:0;
  font-size:var(--t-notation); font-weight:400; letter-spacing:.035em;
  color:rgba(255,255,255,.56); font-variant-numeric:tabular-nums;
}
/* Built and specified used to be a fourth column repeating one of two words
   sixteen times. It is now the pane the row sits in, said once. */
.ledger tbody tr:last-child th,
.ledger tbody tr:last-child td{ border-bottom:0; }
/* Rows run 24 to 44 percent of the pane wide; a hover tint keeps the eye on
   one of them while it crosses. */
.ledger tbody tr{ transition:background .18s var(--ease); }
.ledger tbody tr:hover{ background:rgba(255,255,255,.028); }
.ledger tr[data-state="spec"] th{ color:var(--ink-soft); }

/* ── 04 · how it runs ────────────────────────────────────────────────────
   A staircase, not three equal cards: each layer steps further right, the
   way the work moves from reading to deciding. The 70px ghost numeral each
   row used to lead with is gone. It was the same size as the figures in 01
   and it was the least useful thing in the row. */
.lanes{ list-style:none; }
.lanes{ display:grid; gap:clamp(16px, 1.8vw, 26px); }
.lane{
  display:grid;
  grid-template-columns:minmax(0,290px) minmax(0,1fr);
  column-gap:clamp(22px, 3vw, 52px); row-gap:16px;
  padding:clamp(26px, 2.9vw, 44px) clamp(22px, 2.3vw, 36px);
}
.lane:nth-child(2){ margin-left:5%; }
.lane:nth-child(3){ margin-left:10%; }
.lane-ord{
  margin-bottom:11px;
  font-size:var(--t-key); font-weight:400; color:var(--ink-faint);
}
.lane-head h3{
  font-size:var(--t-sub); line-height:1.12;
  font-weight:400; letter-spacing:-.012em; color:var(--ink);
}
.lane-foot{
  margin-top:16px;
  font-size:var(--t-key); line-height:1.5; font-weight:400; color:var(--ink-mid);
}
.lane-body p{
  max-width:var(--measure);
  font-size:var(--t-body); line-height:var(--lh-body); font-weight:300; color:var(--ink-mid);
}
.lane-body p + p{ margin-top:15px; }

/* ── 05 · the pilot schedule ─────────────────────────────────────────────
   Four phases used to be four separate panes, each one 200px tall and each one
   the same shape as the last, which is the card wall this document bans
   everywhere else and had quietly built here. It also said the wrong thing:
   four containers read as four independent offers, and a pilot is a sequence
   with gates between its parts.

   One pane, four rows on a numbered spine — the evidence chain's own device,
   reused rather than invented, because the page has already taught the reader
   what a numbered spine down the left means and this is the second thing on
   the page that is genuinely a sequence. The ordinal comes off the markup and
   onto a CSS counter for the same reason it did there.

   No `pane-rows` hairlines under these. The spine is already the structure,
   and a divider across every row on top of it would be two grids arguing. */
.schedule{ list-style:none; counter-reset:phase; --pad:clamp(17px, 1.8vw, 25px); --px:clamp(20px, 2.2vw, 32px); }
.phase{
  position:relative; counter-increment:phase;
  display:grid;
  grid-template-columns:minmax(0,104px) minmax(0,1fr) minmax(0,310px);
  column-gap:clamp(18px, 2.2vw, 40px); row-gap:9px;
  align-items:start;
  padding:var(--pad) var(--px) var(--pad) calc(var(--px) + 34px);
}
.phase::before{
  content:counter(phase);
  position:absolute; left:var(--px); top:calc(var(--pad) + .12em);
  width:20px; height:20px; border-radius:50%;
  display:grid; place-items:center;
  font-size:.63rem; font-weight:500; letter-spacing:.02em;
  font-variant-numeric:tabular-nums;
  background:rgba(255,255,255,.14); color:var(--ink);
}
/* The run reaches a row's own `--pad` past its bottom edge, which is exactly
   where the next disc starts, so the spine is continuous down the pane rather
   than four separate stubs with the row gaps showing through. */
.phase::after{
  content:''; position:absolute;
  left:calc(var(--px) + 9.5px);
  top:calc(var(--pad) + 25px); bottom:calc(-1 * var(--pad)); width:1px;
  background:var(--rule-2);
}
.phase:last-child::after{ display:none; }
.phase-when{
  padding-top:.3em;
  font-size:var(--t-key); font-weight:400; color:rgba(255,255,255,.6);
  font-variant-numeric:tabular-nums;
}
.phase-main h3{
  font-size:var(--t-lead); line-height:1.2;
  font-weight:400; letter-spacing:-.008em; color:var(--ink);
}
.phase-main p{
  margin-top:7px; max-width:58ch;
  font-size:var(--t-body); line-height:1.55; font-weight:300; color:var(--ink-mid);
}
.phase-gate{
  display:grid; gap:6px; padding-top:.34em; max-width:36ch;
  font-size:var(--t-key); line-height:1.46; font-weight:300; color:var(--ink-mid);
}
.phase-gate span{
  display:flex; align-items:baseline; gap:10px;
  font-size:var(--t-key); font-weight:400; color:var(--ok);
}
.phase-gate span::before{ content:''; flex:none; margin-bottom:1px; width:7px; height:7px; box-shadow:inset 0 0 0 1px var(--ok); }

/* ── 06 · the boundary ───────────────────────────────────────────────── */
.never{ list-style:none; }
.never li{
  display:flex; align-items:baseline; gap:17px;
  padding:clamp(18px, 2vw, 25px) clamp(22px, 2.3vw, 34px);
  font-size:var(--t-lead); line-height:1.46; font-weight:300; color:var(--ink);
}
.never li::before{ content:''; flex:none; margin-bottom:2px; width:9px; height:9px; box-shadow:inset 0 0 0 1px var(--block); }

/* ── the hero's request control ──────────────────────────────────────────
   The page used to close on a third liquid-metal control, wired to a mailto.
   That section is gone until there is an inbox behind it, and the control went
   with it — a button that opens a mail client addressed to nothing is worse
   than no button. This rule stays because the hero's own explore control
   carries the same class. */
.liquid-button--explore.btn{ text-decoration:none; }

/* The hero's play control is a toggle, so the glyph says which state it is in
   alongside the accessible name the module swaps. Two paths in one authored
   svg: no extra node, no layout change, nothing in the vendored sheet touched. */
.liquid-button--play .ico-stop{ display:none; }
.liquid-button--play[data-playing] .ico-play{ display:none; }
.liquid-button--play[data-playing] .ico-stop{ display:inline; }

/* ── the dock, kept on screen ────────────────────────────────────────────
   The authored dock is `position:absolute` inside `.hero`, so it left with the
   first screen. Fixed, it stays, and the acrylic fill below is what makes that
   bearable: an opaque bar riding over the document would read as browser
   chrome, and a blurred one reads as part of the page it is over.

   Two things had to move with it, and neither is obvious.

   `.hero` carries `isolation:isolate`, which makes it a stacking context, and a
   fixed child cannot paint out of the context it belongs to however high its
   own z-index is. `.hero` and `.page` are siblings at the same level, so the
   dock — z-index 5 inside a context that paints first — went *behind* every
   section on the page. The hero is lifted above the page to fix it, which is
   safe because the hero is `overflow:hidden` and exactly one viewport tall, so
   the only thing it can paint outside its own box is the fixed dock itself.

   Everything above the dock stays above it: the progress hairline at 20, and
   the definition tooltip at 40, which has to be able to cover it. */
.hero{ z-index:6; }
.dock-wrap{ position:fixed; }

/* A fixed bar means every anchor on the page now lands under it. The eyebrow
   was the casualty: `scrollIntoView` puts a section's top edge at the viewport
   top, which is exactly where the dock is.

   The clearance mirrors the authored geometry rather than guessing at it —
   `top` plus `height`, both read off the vendored sheet — and it has to be
   restated under 900px because Sylva re-lays its whole frame on 760 units
   there and floors the dock at 56px, so the same expression evaluates to a
   different, wrong number. The extra pixels are breathing room, not padding
   for an error. */
:root{ --dock-clear:calc(84 * var(--u) + 18px); }
.sec, .foot{ scroll-margin-top:var(--dock-clear); }

/* ── the dock, made acrylic ──────────────────────────────────────────────
   The authored sheet refuses `backdrop-filter` here, and says why in a comment
   worth taking seriously: the dock floats over a canvas that repaints every
   frame, so a blurred backdrop is re-sampled and re-blurred every frame too,
   and it was measured costing about two thirds of the page's frame rate.

   That measurement is old, and it was re-run before this rule was written
   rather than argued with. Chrome 151, Apple M4 Pro through Metal, the scene
   live at 1600 × 1000 at DPR 2, with the pointer swept across the dock so its
   own spring and rim highlight stayed busy the whole time: 46 fps without the
   filter and 42 with it on the first pass, then 64 and 64 on the second. The
   difference is inside the run-to-run spread. What used to be a two thirds cut
   is now nothing, because the compositor grew a fast path for exactly this.

   So the fill comes down from the authored `.74` to `.34` and the blur does
   the work of holding the text off the scene. The lit top edge, the rim and
   the shadow are all still the authored ones — this changes what is behind the
   dock, not what the dock is.

   Guarded, and the guard is load bearing rather than ceremonial: without the
   filter a 34% panel over the hero's bright light pool is unreadable, so a
   browser that cannot blur keeps the authored 74% instead. This and the
   `.dock-mark` tile are now the only two Sylva rules this sheet overrides. */
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)){
  .dock{
    background:
      linear-gradient(180deg, rgba(255,255,255,.075), rgba(255,255,255,0) 42%),
      rgba(34,40,31,.34);
    -webkit-backdrop-filter:blur(18px) saturate(1.6);
    backdrop-filter:blur(18px) saturate(1.6);
  }
}

/* ── the hero cards are the picture ──────────────────────────────────────
   Both cards carried a label and a title over their plate — "Outfall 001",
   "Where the number starts." — and the plate got whatever height was left.
   The captions are gone and the window now fills the card to its own 14u
   mount, which leaves the paper reading as a mount rather than as a card with
   a picture stuck on it.

   It also changes what the plate has to be. A near-square window has room for
   the whole argument, so the second one stopped being a photograph of held
   water — a metaphor for a blocked cell — and became the blocked cell: the
   parameter, the limit, the two lab revisions that disagree, the exception
   number, and the one line that says why any of it matters. That plate is a
   claim, so `sylva.test.mjs` hashes it like it hashes the photograph. */
.card--about figure, .card--stove figure{
  top:calc(14 * var(--u)); bottom:calc(14 * var(--u)); height:auto;
}
/* `<picture>` is inline, so without this the plate sits on a text baseline
   inside a block trying to be exactly the window's size. */
.portal-media picture{ display:block; width:100%; height:100%; }

/* ── the dock reads as one row of equal things ───────────────────────────
   It had three different highlights: the pale brand tile, a paper fill on the
   current section, and a lighter fill on Enter, none of which meant the same
   thing as the others. One is kept — the brand tile, which is the only thing
   in the bar that is not a section — and the other two come back to the same
   pill every other item wears. The current section is still marked, by ink and
   a slightly firmer rim rather than by becoming a different material.

   The gap goes from 3u to 7u, and the mark takes a little more after it, so
   the brand reads as separate from the sections rather than as the first of
   them. */
.dock{ gap:calc(7 * var(--u)); }
.dock-mark{ margin-right:calc(4 * var(--u)); }
.dock-item.is-active{
  background:rgba(255,255,255,.10);
  border-color:rgba(255,255,255,.17);
  color:var(--ink);
  box-shadow:none;
}
.dock-item.is-active .glyph{ opacity:.92; }
.dock-item--enter{ color:var(--ink-faint); background:rgba(255,255,255,.04); }

/* ── and it folds to the mark once the hero is behind you ────────────────
   The lockup earns its width on the first screen, where it is the only place
   the company is named. Past that the dock is a permanent bar over someone's
   reading, so the name folds away and the mark holds the corner.

   The first version of this animated the word's `max-width` and expected the
   tile to follow. It did not, and the reason is worth writing down: Sylva's
   `drawDock` puts an inline pixel `width` on every dock item from its first
   frame, so the tile was pinned. The word shrank inside a box that would not
   move, and then `measureDock` cleared that width at the end and the tile
   snapped to its new size in one frame. All of the motion was in the wrong
   place and all of the change was in the last frame.

   So `app.mjs` sets the tile's inline width to `auto` for the duration. With
   the pin gone the tile is content-sized again, and it tracks the word's
   `max-width` frame by frame for free — no width animation, no measured
   target, no arithmetic in the script. The pills to its right slide with it
   because the row simply re-lays. When the word settles, a resize hands the
   dock back to `measureDock`, which re-caches and re-pins at the right size.

   `max-width` is owned by the script rather than declared here, because the
   distance has to be the word's actual width. Left as a round number it would
   spend the first third of the transition closing a gap that was never open,
   which is its own kind of sudden.

   This is the one place on the page that transitions a layout property. The
   exception is deliberate: the pills have to actually move, so something has
   to reflow, and no transform does that. It is one shot on a class change and
   not a per-frame effect, which is what the rule exists to prevent. */
.dock-item.dock-mark{
  /* the mark stays put and the name is what leaves, so the tile shrinks from
     its trailing edge rather than closing in on its own centre */
  justify-content:flex-start;
  transition:column-gap .55s var(--ease-out),
             color .18s var(--ease), border-color .2s var(--ease), background .2s var(--ease);
}
.dock.is-compact .dock-mark{ column-gap:0; }
.dock-item.dock-mark .logo-word{
  overflow:hidden;
  transition:max-width .55s var(--ease-out), opacity .34s var(--ease);
}
.dock.is-compact .dock-item.dock-mark .logo-word{ opacity:0; }

@media (prefers-reduced-motion:reduce){
  .dock-item.dock-mark,
  .dock-item.dock-mark .logo-word{ transition:none; }
}

/* ── the logo ────────────────────────────────────────────────────────────
   Sylva's dock leads with a 36u square tile holding a glyph, because Sylva's
   logo is a glyph. Ours has a name, so the tile becomes the lockup: the mark,
   then "Outfall" in medium with "Control" trailing it lighter.

   This is one of the handful of Sylva rules this sheet overrides, and it
   touches nothing but the tile's own box. The dock's magnification, specular rim, entrance clip and
   pollen burst all keep working, because `measureDock` clears the inline width,
   re-reads `getBoundingClientRect` and re-runs on resize and `fonts.ready`, and
   the mark's growth is a flat 14u rather than a fraction of its width.

   The mark: a reported cell, the chain below it, a gap where the proof has to
   come from, and the source record it rests on.  */
.dock-item.dock-mark .logo-word{
  display:block;
  font-weight:500; text-transform:none; white-space:nowrap;
}
.dock-item.dock-mark .logo-word i{ font-style:normal; font-weight:300; opacity:.74; }

@media (min-width:901px){
  .dock-mark{
    width:auto;
    gap:calc(9 * var(--u));
    padding:0 calc(14 * var(--u)) 0 calc(12 * var(--u));
  }
  .dock-mark .logo-mark{ width:calc(15 * var(--u)); height:calc(16.4 * var(--u)); }
  .dock-item.dock-mark .logo-word{
    font-size:calc(13 * var(--u)); letter-spacing:calc(-.1 * var(--u));
  }
}

/* Sylva strips every dock label under 900px, where the bar is floored at 44px
   targets and its own type would be four pixels tall. The mark keeps its name
   there, shortened to the short form, because a nav with no name on a phone
   leaves the company anonymous until the footer. */
@media (max-width:900px){
  .dock-mark{
    width:auto;
    gap:max(7px, calc(8 * var(--u)));
    padding:0 max(12px, calc(13 * var(--u))) 0 max(11px, calc(12 * var(--u)));
  }
  .dock-mark .logo-mark{ width:max(15px, calc(16 * var(--u))); height:max(16.4px, calc(17.5 * var(--u))); }
  .dock-item.dock-mark .logo-word{
    font-size:max(12.5px, calc(13.5 * var(--u))); letter-spacing:0;
  }
  .dock-item.dock-mark .logo-word i{ display:none; }
}

/* ── footer ──────────────────────────────────────────────────────────── */
.foot{
  position:relative; z-index:1;
  border-top:1px solid var(--rule-2);
  width:calc(1600 * var(--u)); max-width:100%;
  margin-inline:auto;
  padding:clamp(52px, 5.4vw, 92px) calc(46 * var(--u)) clamp(52px, 5vw, 84px);
}
/* The provenance list went, then the footer's section index went after it, and
   then the rail on the right edge went too. There is no grid left to describe:
   the footer is a mark, a name and the way back up. The dock stays on screen
   the whole way down, which is the only section index the page needs. */
.foot-grid{ display:flex; }
.foot-brand{
  display:flex; align-items:center; gap:12px;
  font-size:1.05rem; font-weight:400; letter-spacing:-.005em; color:var(--ink);
}
.foot-mark{ width:18px; height:20px; flex:none; }
.foot-mark svg{ width:100%; height:100%; display:block; fill:var(--ink); }
/* The brand column used to be one line centred in a stretched grid cell, which
   left the mark floating in the middle of a column of air. It carries the
   status line and the way back up now, and sits at the top of its own track. */
.foot-id{ display:grid; gap:14px; align-content:start; }
.foot-top{
  justify-self:start;
  font-size:var(--t-key); font-weight:400; color:var(--ink-soft);
  text-decoration:underline; text-decoration-color:var(--rule-3); text-underline-offset:3px;
}
.foot-top:hover{ color:var(--ink); text-decoration-color:currentColor; }

/* ── defined terms ───────────────────────────────────────────────────────
   The page carries its jargon on purpose and defines all eight words in one
   pane at the end of 01. Six sections then go on using them. This attaches the
   definition to the word where it is used, for the four that recur in running
   prose — not in the evidence panel and not in the ledger, because a workpaper
   and a sixteen row table are the two places on this page where a dotted
   underline would be noise rather than help.

   It is an anchor, not a button, and that is the whole design: with no
   JavaScript, or before terms.mjs runs, it jumps to the glossary entry, which
   is a complete answer rather than a degraded one. The tooltip is the
   enhancement on top.

   Interaction timing is Radix's tooltip contract, which is the part of shadcn
   worth having on a page with no components: 600ms to open on hover, nothing
   to close, and a 400ms window in which the next term opens instantly, so a
   paragraph with two marked words behaves like one control instead of two. */
/* A button, because it reveals something on demand and has to be reachable
   from the keyboard for that to be true for everyone. Everything a button
   brings with it — its own font, its padding, its background, its focus ring
   box — is taken back off, so it sets as the word it is and nothing else. */
.term{
  appearance:none; -webkit-appearance:none;
  font:inherit; color:inherit; letter-spacing:inherit;
  background:none; border:0; padding:0; margin:0;
  cursor:help; text-align:inherit; vertical-align:baseline;
  border-bottom:1px dotted rgba(255,255,255,.45);
  transition:color .2s var(--ease), border-color .2s var(--ease);
}
.term:hover, .term:focus-visible{ color:var(--ink); border-bottom-color:var(--ink); }

/* The pane recipe, with one change. Every other pane on this page floats over
   flat ground and can take the ground's own colour through a 3% white; this
   one floats over whatever it happens to be anchored above, including the
   paper panel, so it carries an opaque fill of its own. Paper was the obvious
   alternative and is wrong: paper on this page means workpaper, and it is
   spent on the evidence panel. */
.termtip{
  position:fixed; z-index:40; left:0; top:0;
  max-width:min(38ch, calc(100vw - 32px));
  /* terms.mjs places the box flush against the word on purpose, so that a
     pointer crossing from one to the other never passes over dead ground and
     the tooltip can close instantly instead of on a timer. The gap therefore
     has to be inside the box: this padding is the visual separation, and the
     surface below is inset out of it.

     It sits on the bottom by default rather than only under `[data-side]`,
     because the module measures `offsetHeight` to decide which side it is
     going on and reads it before that attribute is written. With the gap
     declared only on the two side rules, the first open of the page would
     measure a box 9px shorter than the one it then paints. Both sides carry
     the same total padding, so the measurement holds either way. */
  --tip-air:9px;
  --tip-pad:clamp(13px, 1.3vw, 17px);
  padding:var(--tip-pad) clamp(15px, 1.5vw, 19px) calc(var(--tip-pad) + var(--tip-air));
  isolation:isolate;
  font-size:.92rem; line-height:1.5; font-weight:300; color:var(--ink-mid);
  /* not `display:none`: it is named by `aria-describedby` while it is open, and
     a description cannot be read out of a subtree that is not rendered */
  visibility:hidden; opacity:0;
  pointer-events:none;
}
.termtip[data-side="bottom"]{
  padding:calc(var(--tip-pad) + var(--tip-air)) clamp(15px, 1.5vw, 19px) var(--tip-pad);
}

/* The pane recipe, inset out of the gap above, with one change to it. Every
   other pane on this page floats over flat ground and can take that ground's
   own colour through a 3% white; this one floats over whatever it is anchored
   above, including the paper panel, so it carries an opaque fill of its own.
   Paper was the obvious alternative and is wrong: paper on this page means
   workpaper, and it is spent on the evidence panel. */
.termtip::before{
  content:''; position:absolute; z-index:-1;
  inset:0 0 var(--tip-air);
  border-radius:calc(var(--radius) * .55);
  border:1px solid rgba(255,255,255,.11);
  background:
    linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,0) 52%),
    #40443b;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.08),
    0 18px 40px -18px rgba(8,11,7,.7);
}
.termtip[data-side="bottom"]::before{ inset:var(--tip-air) 0 0; }

.termtip b{ font-weight:500; color:var(--ink); font-variant-numeric:tabular-nums; }
.termtip.on{ visibility:visible; opacity:1; pointer-events:auto; }

/* A hint answers a pointer, so it moves on the pointer's clock and not the
   page's. The entrance easings above are 1.05s because a section arriving is an
   event; a definition that took that long to appear, 600ms after the reader
   already stopped moving, would read as lag. */
.termtip[data-side="top"]{ transform:translateY(4px); }
.termtip[data-side="bottom"]{ transform:translateY(-4px); }
.termtip.on{ transform:none; transition:opacity .18s var(--ease), transform .24s var(--ease-out); }

/* ── entrance ────────────────────────────────────────────────────────────
   One rise distance and one duration applied to every element on the page is
   the tell that the motion was added rather than designed: a heading, a table
   row and a photograph all want different amounts of travel, and when they get
   the same amount the page reads as a single template fading in.

   So the reveal is orchestrated instead. Three roles, each with the travel its
   own mass justifies, and a stagger index set by script so children arrive in
   reading order rather than all at once. Transform and opacity only, so
   nothing here can trigger layout.

   The durations and easings are still Sylva's, because the hero establishes
   them and the page below should not contradict its own first screen. */
.js [data-r]{
  opacity:0;
  transform:translate3d(0, var(--rise, 15px), 0);
  will-change:opacity, transform;
}
.js [data-r].on{
  opacity:1; transform:none;
  transition:opacity 1.05s var(--ease) var(--d,0ms),
             transform 1.25s var(--ease-out) var(--d,0ms);
}
.js [data-r].on{ will-change:auto; }

/* A heading carries the most weight, so it travels furthest and sets the beat
   the rest of its section follows. */
.js .sec-title[data-r]{ --rise:26px; }
.js .sec-note[data-r]{ --rise:16px; }

/* ── section statements arrive a line at a time ──────────────────────────
   Every one of the four collections this page borrows from ships this as a
   component, and all four build it the same way: split the text, stagger the
   pieces, and cross-fade a `filter: blur()` alongside the travel. Two of them
   even reach for the easing already in this sheet — beautifului.dev and beUI
   both hard-code `cubic-bezier(.16,1,.3,1)`, which is Sylva's `--ease-out`
   exactly, so the timing needed no reconciliation at all.

   Two things are dropped anyway.

   The blur, for the reason the figures in 01 already dropped it: it is the
   decorative half of the effect, it is the single most expensive thing in any
   of these libraries, and it is the same repaint-per-frame cost the hero
   measured and rejected for its dock. Travel and sequence carry the meaning.

   And the per-word stagger. A statement is a sentence, and a reader takes a
   sentence a line at a time; twelve words arriving one after another turns a
   claim into an animation of a claim. So the unit is the rendered line, which
   is why app.mjs measures rather than guesses where to cut.

   This replaces the 26px block rise the statement used to get. The mask is a
   stronger move than a fade and it is spent in one place only: the section
   statements. The note and every grouped row keep the travel their own mass
   justifies. */
.js .sec-title .ln{
  display:block;
  overflow:hidden;
  /* `overflow` clips at the padding box, and `line-height:1.06` leaves a
     descender hanging below it. The padding lets the tail of a g or a y
     through and the negative margin takes the space back, so a masked
     heading occupies exactly the leading an unmasked one does. */
  padding-bottom:.22em;
  margin-bottom:-.22em;
}
.js .sec-title .ln > span{
  display:block;
  transform:translate3d(0, calc(100% + .26em), 0);
}
/* The split element is not the thing that moves any more, so it is taken out
   of the plain reveal it shares with its note. Written at
   `[data-r][data-split]` to outrank `.js [data-r].on`, which carries one class
   more than the rule it would otherwise be fighting. */
.js .sec-title[data-r][data-split]{
  opacity:1; transform:none; transition:none;
}
.js .sec-title[data-split].on .ln > span{
  transform:none;
  transition:transform 1.25s var(--ease-out) calc(var(--d, 0ms) + var(--n) * 90ms);
}

/* Grouped children stagger. `--i` is assigned by app.mjs from DOM order, so a
   row's delay is a property of where it sits, not a number hand-typed into the
   markup that goes stale the moment a row moves. */
.js [data-stagger] > *{
  opacity:0;
  transform:translate3d(0, var(--rise, 12px), 0);
}
.js [data-stagger].on > *{
  opacity:1; transform:none;
  transition:opacity .8s var(--ease) calc(var(--i, 0) * var(--step, 55ms)),
             transform .95s var(--ease-out) calc(var(--i, 0) * var(--step, 55ms));
}

/* Dense tables get a faster, shallower cadence: sixteen rows at the heading's
   travel and interval would take four seconds to settle and read as a loading
   state rather than an entrance. */
.js .ledger tbody[data-stagger]{ --step:26ms; --rise:7px; }
.js .params[data-stagger]{ --step:44ms; --rise:10px; }
.js .lanes[data-stagger]{ --step:90ms; --rise:20px; }
.js .schedule[data-stagger]{ --step:70ms; --rise:16px; }
.js .claims[data-stagger]{ --step:110ms; --rise:22px; }
.js .glossary[data-stagger]{ --step:22ms; --rise:8px; }
.js .never[data-stagger]{ --step:50ms; --rise:12px; }
.js .fork[data-stagger]{ --step:140ms; --rise:18px; }

@media (prefers-reduced-motion:reduce){
  /* `.js [data-r].on` carries a class more than `.js [data-r]`, so a reset
     written at the plainer specificity loses to it and leaves a live transition
     declaration on a settled element. Nothing visibly moved, because the
     element was already at its resting opacity, but any later class toggle
     would have animated against a stated preference. Match `.on` here. */
  .js [data-r], .js [data-r].on,
  .js [data-stagger] > *, .js [data-stagger].on > *,
  .js .claims b .d, .js .claims.on b .d{
    opacity:1; transform:none; transition:none; filter:none;
  }
  /* app.mjs does not split a statement under this preference, so there are no
     masks to reset. These are here for the one case that reaches them: the
     preference turned on after load, with the split already in the DOM. */
  .js .sec-title .ln > span,
  .js .sec-title[data-split].on .ln > span{ transform:none; transition:none; }
  .progress{ transition:none; }
  .termtip, .termtip.on{ transition:none; }
  .panel, .param, .claims a{ transition:none; }
  /* The chain is animation rather than transition, so the reset above does not
     reach it. A switch under a stated preference should be the hard cut the
     panel used to be everywhere. */
  .js .panel.on .chain li,
  .js .panel.on .chain li::before,
  .js .panel.on .chain li::after{ animation:none; }
}

/* ── one column ──────────────────────────────────────────────────────── */
@media (max-width:1080px){
  .fork{ grid-template-columns:1fr; }
  .panel{ grid-template-columns:1fr; }
  .params{ border-right:0; border-bottom:1px solid var(--paper-rule); display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
  .param + .param{ border-top:1px solid var(--paper-rule); }
  .param:nth-child(even){ border-left:1px solid var(--paper-rule); }
  .param:nth-child(1), .param:nth-child(2){ border-top:0; }
  /* The gate is the foot of the parameter column, not one cell of it. Once the
     column becomes a two-track grid, `margin-top:auto` no longer reaches it. */
  .params-gate{ grid-column:1 / -1; }
  .phase{ grid-template-columns:minmax(0,148px) minmax(0,1fr); }
  .phase-gate{ grid-column:2; max-width:var(--measure); }
  .lane{ grid-template-columns:minmax(0,240px) minmax(0,1fr); }
  .lane:nth-child(2){ margin-left:3%; }
  .lane:nth-child(3){ margin-left:6%; }
}

@media (max-width:900px){
  /* Under 900px Sylva turns each card on its side — 692 x 250, with the plate
     as a thumbnail on the outer edge and the copy filling the rest. With the
     copy gone that leaves most of the card as blank paper, so the window fills
     here too. The plate it holds is a different shape at this width, and a
     near-square render cropped into a 2.8 strip loses the half that carries
     the argument, so `index.html` ships a second, wider render and lets the
     browser choose. It says less, because there is less room to say it in. */
  .card--about figure, .card--stove figure{
    left:calc(16 * var(--u)); right:calc(16 * var(--u));
    top:calc(16 * var(--u)); bottom:calc(16 * var(--u));
    width:auto; height:auto;
  }

  /* 30u of offset over a dock floored at 56px, on Sylva's 760 unit frame. */
  :root{ --dock-clear:calc(30 * var(--u) + max(56px, 58 * var(--u)) + 14px); }
  .rails{ display:none; }
  .sec, .foot{ width:100%; padding-left:max(20px, calc(34 * var(--u))); padding-right:max(20px, calc(34 * var(--u))); }
  .skip{ left:max(20px, calc(34 * var(--u))); }
  .sec-head{ grid-template-columns:1fr; }
  .sec-title, .sec-note{ grid-column:1; max-width:34ch; }
  .sec-note{ max-width:56ch; }
  /* The per-section head compositions above are id scoped, so they outrank a
     plain `.sec-head` rule and survived into the phone layout, squeezing a
     section title into a 166px column and pushing #how's title to the right of
     a 390px screen. Asymmetry is a desktop affordance: there is no second axis
     on a phone to be asymmetric across. Reset all of them by id here. */
  #stakes .sec-head, #evidence .sec-head, #rules .sec-head,
  #how .sec-head, #pilot .sec-head, #boundary .sec-head{
    grid-template-columns:1fr;
  }
  #stakes .sec-title, #stakes .sec-note,
  #how .sec-title, #how .sec-note,
  #rules .sec-note, #pilot .sec-title, #pilot .sec-note{
    grid-column:1; grid-row:auto; justify-self:start; padding-top:0;
  }
  #how .sec-note{ max-width:56ch; }
  #rules .sec-note{ max-width:56ch; }
  .glossary{ grid-template-columns:1fr; }
  .glossary > div:nth-child(odd){ border-right:0; padding-right:0; }
  .glossary > div:nth-child(n+2){ border-top:1px solid rgba(255,255,255,.055); }
  .group-head{ grid-template-columns:1fr; }
  .group-count{ justify-self:start; }
  .claims li{ grid-template-columns:1fr; row-gap:10px; }
  .claims li:nth-child(1) b{ --fs:.86; }
  .claims li:nth-child(2) b{ --fs:.74; }
  .claims li:nth-child(3) b{ --fs:.6; }
  .claims a{ grid-column:1; margin-top:6px; }
  .foot-grid{ grid-template-columns:1fr; }
}

@media (max-width:720px){
  .lane, .phase{ grid-template-columns:1fr; }
  .lane{ margin-left:0 !important; }
  .phase-gate{ grid-column:1; }
  .params{ grid-template-columns:1fr; }
  .param:nth-child(even){ border-left:0; }
  .param:nth-child(2){ border-top:1px solid var(--paper-rule); }

  /* the ledger loses its header row and each cell carries its own label */
  .ledger{ min-width:0; }
  .ledger thead{ display:none; }
  .ledger tbody, .ledger tr{ display:block; }
  .ledger th, .ledger td{ display:block; }
  /* The desktop column percentages are set on .ledger tbody th, .ledger .form
     and .ledger td[data-label="Catches"], all of which outrank a
     plain `.ledger td{width:auto}` here. Reset them at their own specificity or
     the stacked cells stay 12 to 38 percent of the viewport and every value
     wraps four words at a time. */
  .ledger tbody th,
  .ledger td[data-label="Catches"],
  .ledger .form{ width:auto; }
  .ledger tr{ padding:20px 0; border-bottom:1px solid var(--rule-2); }
  .ledger tbody tr:last-child{ border-bottom:0; }
  .ledger th, .ledger td{ border:0; padding:0; }
  .ledger tbody th{ font-size:var(--t-lead); font-weight:400; }
  .ledger td{ margin-top:10px; }
  .ledger td[data-label="Catches"]{ margin-top:6px; }
  /* Sentence case, told apart by size and colour. A tracked-out capital here
     would be a fourth one, sixteen times down the page. */
  .ledger td::before{
    content:attr(data-label);
    display:block; margin-bottom:3px;
    font-size:var(--t-key); font-weight:400;
    color:var(--ink-faint);
  }
  .ledger td[data-label="Catches"]::before{ content:none; }
}

/* Sylva's design unit is `100vw / 1600`, and `vw` counts the scrollbar the
   viewport does not actually offer. So on any platform that reserves a classic
   scrollbar, every 1600u box on the page — the section frame, the footer, and
   the column guides that have no `max-width` to save them — is about eight
   pixels wider than the room there is, and the document carries a horizontal
   scrollbar of its own at every width.

   Clipped rather than corrected, and the distinction matters. Capping the
   guides would decouple them from the hero's stage, which is measured in the
   same unit inside `overflow:hidden` and so never had the symptom; they would
   then sit eight pixels off the hairlines they exist to continue. The whole
   composition is consistently centred on 100vw, and `clip` keeps it that way
   while refusing the scrollbar. `clip` and not `hidden`: `hidden` would make
   the document a scroll container, and `scroll-behavior` and the dock's fixed
   positioning both depend on it not being one. */
html{ overflow-x:clip; }

/* In-page navigation, including the dock and both card knobs, runs through
   scrollIntoView, so the smoothing lives here rather than in the module. */
@media (prefers-reduced-motion:no-preference){
  html{ scroll-behavior:smooth; }
}
