/* prefrontal.dev — the whole stylesheet.
   World: Clean Proof. Warm black ground, off-white ink, amber reserved for the
   live channel (the primary control, focus rings, the mark's circuit, the
   flood's caret). Source Sans 3 is the product voice; JetBrains Mono is the
   data/agent voice (filenames, terminal lines, meta), never decoration.

   What this sheet is: one page, one action. The hero states the promise, offers
   the one control that installs the product, and carries the flood — which is
   also where the five filenames are named, so it is the only inventory the page
   has. There is no second section, no card grid and no scroll-reveal, so there
   are no tokens or rules for them.

   Motion rules: one authored moment (the hero's flood loop, plus a one-time
   entrance), exponential ease-out, a token for every duration, state changes at
   the 150ms ceiling, and a reduced-motion path that removes spatial movement
   instead of erasing feedback. */

:root {
  --ground: #0a0908;
  --ink: #f0ece0;
  --ink-soft: #d4cec0;
  --ink-dim: #bab2a1;
  /* The accent is declared once as a triplet so the wash derives from the same
     hue. Retyping it meant changing --amber would silently leave the 9% wash on
     the old colour. */
  --amber-rgb: 196 123 43;
  --amber: rgb(var(--amber-rgb));
  --amber-wash: rgb(var(--amber-rgb) / 9%);
  --rule: rgba(232, 228, 216, 0.13);
  --rule-strong: rgba(232, 228, 216, 0.22);
  /* The visible boundary of an interactive control must clear WCAG 1.4.11's
     3:1; --rule measures 1.31:1 on the ground. Controls get their own step,
     separators keep the quieter rules above. */
  --border-control: rgba(232, 228, 216, 0.4);

  /* Two surface washes, and only two: one for a resting container, one for a
     hovered row or control. Nothing in the system invents a third. */
  --surface-card: rgba(240, 236, 224, 0.018);
  --surface-hover: rgba(240, 236, 224, 0.04);

  /* The flood's legibility levels. Measured against the ground: noise 8.81:1,
     pruned 3.34:1, strike 5.22:1, and the kept lines stay the brightest thing in
     the frame. */
  --flood-noise: #b4ac98;
  --flood-noise-dim: #6b634f;
  --flood-strike: #8a8270;
  --flood-keep: #e2dccc;
  /* The kept tone as channels, so the arrival's bloom derives from the tone it
     belongs to rather than repeating it — the same reason `--amber-rgb` exists.
     A derived value derives. */
  --flood-keep-rgb: 226, 220, 204;
  --flood-opacity: 1;
  --flood-opacity-keep: 0.95;

  /* Type scale. Roles, not values: caption (tags, markers, meta), mono (code,
     filenames, terminal), ui (controls and labels), body (prose), lead. */
  --text-caption: 0.8125rem;
  --text-mono: 0.9rem;
  --text-ui: 0.95rem;
  --text-body: 1.0625rem;
  --text-lead: 1.15rem;

  /* 4-unit spacing scale: 8 / 16 / 24 / 40 / 64 / 112px. */
  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;
  --space-4: 2.5rem;
  --space-5: 4rem;
  --space-6: 7rem;

  --radius-sm: 0.3rem;
  --radius-md: 0.4rem;

  /* A mask's "fully opaque" stop. This is an alpha value inside a mask-image
     gradient, never a painted ink — the no-pure-black rule covers ground and
     text, not the mask channel. */
  --mask-opaque: #000;

  /* State changes are high-frequency, so they sit at the 150ms ceiling; only
     the authored moments are allowed to be slow. */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-state: 150ms;
  --dur-hero: 500ms;
  --dur-flood-line: 450ms;
  --dur-strike: 500ms;
  /* The kept line's arrival: one transient bloom that fades to nothing, so the
     still frame carries no shadow at all. */
  --dur-glow: 900ms;
  --stagger: 60ms;
  --stagger-prune: 40ms;

  /* Two motion values used to live as literals in this sheet — the flood's 70ms
     line stagger and the caret's 1s blink — which is the parallel vocabulary
     these tokens exist to prevent, in a sheet whose own header promises a token
     for every duration. (`--ease-glide` and `--dur-cue` arrived with them and left
     again when the disclosure cue's cross-fade went with the `<details>`; a token
     with no user is worse than the literal it replaced.) */
  --stagger-line: 70ms;
  --dur-caret: 1s;
  /* The gap between a kept line's arrival and the two beats that finish it: the
     leader draws, then the filename lands at its end. It is a step, not a
     duration — nothing moves for a whole `--lead-step` — so it is neither a
     `--dur-*` nor the line stagger it is added to. */
  --lead-step: 90ms;

  /* The three family stacks, named once. */
  --font-sans: 'Source Sans 3', 'Source Sans Pro', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', ui-monospace, monospace;
  --font-logo: 'DejaVu Sans Mono', ui-monospace, SFMono-Regular, monospace;

  --content-max: 74rem;
  --prose-max: 33rem;
  /* The prompt's measure, and the width of the modal that shows it. Measured
     rather than chosen: its longest line is 83 characters of JetBrains Mono at
     0.9rem, which is 717px, plus the block's own padding, so at this width none
     of it wraps. */
  --prompt-max: 52rem;
  --gutter: clamp(1.25rem, 4vw, 4rem);
}

/* The logotype face the owner named, subset to the logotype's characters
   (324KB TTF -> 9.3KB woff2) and committed to site/fonts/ with its licence. The
   file holds one weight, so it is declared at 700 only: anything else would be
   synthesised. `optional` rather than `swap` because the face sets the h1, and a
   late swap would move the whole hero. */
@font-face {
  font-family: 'DejaVu Sans Mono';
  src: url('/fonts/dejavu-sans-mono-bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: optional;
}

/* Latin subsets only, woff2 only. The @fontsource entries also carried
   cyrillic, greek and vietnamese ranges plus a woff fallback for engines that
   have not needed it since 2016; both are gone with the build step. */
@font-face {
  font-family: 'Source Sans 3';
  src: url('/fonts/source-sans-3-latin-400-normal.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Source Sans 3';
  src: url('/fonts/source-sans-3-latin-600-normal.woff2') format('woff2');
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'JetBrains Mono';
  src: url('/fonts/jetbrains-mono-latin-400-normal.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
/* ------------------------------------------------------------- base -- */

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

/* The UA stylesheet ships `code { font-family: monospace }`, which beats the
   inherited stack: the prompt block would render in the platform default mono
   while the labels beside it rendered in JetBrains Mono. Inherit instead, so the
   declared mono role reaches the actual glyphs. */
code {
  font-family: inherit;
  font-size: inherit;
}

html {
  /* No `scroll-behavior: smooth` here. The header's nav is a single external
     link, so the only in-page fragment on the page is the skip link — a
     keyboard-initiated jump, and animating those is a block under the review
     standard this sheet is held to: it delays the exact moment a keyboard
     reader is waiting for. The page is 1392px tall, so smooth scrolling had
     almost nothing to buy. */
  scrollbar-color: var(--ink-dim) var(--ground);
  -webkit-tap-highlight-color: transparent;
  -webkit-text-size-adjust: 100%;
  /* The body carries `overflow-y: clip` to trim the scrollable overflow the
     one-time hero entrance adds. That value must not be allowed to propagate:
     when the root's overflow is `visible` in both axes, the first body child's
     overflow is applied to the viewport instead, and `clip` on a viewport is
     interpreted as `hidden`, which kills wheel, touch and keyboard scrolling
     while leaving programmatic `scrollTo()` working. Declaring the axis here
     keeps the root non-`visible` and stops the propagation. Change these two
     together or not at all. */
  overflow-y: auto;
}

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

body {
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: var(--text-body);
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  caret-color: var(--amber);
  /* The page is one screen: header, hero, footer, with the hero taking whatever
     the other two leave. `svh` rather than `vh` so a phone's retracting URL bar
     does not make the one screen a scroll; `min-height` rather than `height` so
     content that genuinely cannot fit (a 320px window under text zoom) still
     grows and scrolls instead of being cut. There is no height media query and
     no magic header constant: the three parts measure themselves. */
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  /* `clip` rather than `hidden`: it trims the entrance's translateY without
     turning the body into a scroll container. There is deliberately no
     `overflow-x: hidden` here — it turned real layout overflow at 320px and
     under text zoom into content that could not be reached. Those overflows are
     fixed at their source instead. */
  overflow-y: clip;
}

h1,
h2 {
  font-family: var(--font-sans);
  font-weight: 700;
  letter-spacing: -0.025em;
  color: var(--ink);
  margin: 0;
  text-wrap: balance;
}

a {
  color: inherit;
}

/* The page's only text that is for assistive tech rather than for the eye: the
   pause control's name. The standard screen-reader clip, so an icon button can
   keep a real accessible name without painting a label under the flood. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The drawn glyphs reused through <use>. Kept out of the flow by size and
   position rather than by display:none, which some engines will not instantiate
   through a <use> reference. */
.sprite {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* Touch hygiene: a tap on a control must not wait out a double-tap zoom or
   raise the iOS callout. Prose and the prompt stay selectable — the copy script
   selects the block as its own fallback. */
button,
a,
summary,
[role='button'] {
  touch-action: manipulation;
}

button,
.btn-primary,
.header-brand,
.site-nav a {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

::selection {
  background: var(--ink);
  color: var(--ground);
}

:focus-visible {
  outline: 2px solid var(--amber);
  outline-offset: 3px;
}

/* Sits over the first line of the header rather than under a notch. */
.skip-link {
  position: fixed;
  top: max(0.75rem, env(safe-area-inset-top));
  inset-inline-start: max(0.75rem, env(safe-area-inset-left));
  z-index: 30;
  display: inline-flex;
  align-items: center;
  min-height: 2.75rem;
  padding: 0.6rem 1rem;
  background: var(--ground);
  color: var(--ink);
  font-weight: 600;
  font-size: var(--text-mono);
  text-decoration: none;
  border: 1px solid var(--amber);
  border-radius: var(--radius-sm);
  transform: translateY(-150%);
  transition: transform var(--dur-state) var(--ease-out);
}

.skip-link:focus {
  transform: translateY(0);
}
/* --------------------------------------------- pointer-gated hover states -- */
/* A touch tap latches :hover until the next tap, so every hover that paints
   (colour, wash, border, transform, underline) is gated to a fine pointer.
   :active is state rather than hover and stays outside. */

@media (hover: hover) and (pointer: fine) {
  .header-brand:hover,
  .site-nav a:hover {
    color: var(--ink);
  }

  .site-nav a:hover::after {
    transform: scaleX(1);
  }

  .btn-primary:hover {
    background: var(--amber-wash);
    transform: translateY(-1px);
  }

  .btn-secondary:hover {
    background: var(--surface-hover);
    border-color: var(--rule-strong);
    transform: translateY(-1px);
  }

  /* The prompt control is a text control, so it brightens like one, and its
     mark follows the count rather than jumping to the label's tone. */
  .prompt-open:hover {
    color: var(--ink);
  }

  .prompt-open:hover .prompt-open-cue {
    color: var(--ink-soft);
  }

  /* The modal's close control, an icon button of the same family as the pause
     control below. */
  .prompt-dialog-close:hover {
    color: var(--ink);
    border-color: var(--rule-strong);
  }

  .flood-control:hover {
    color: var(--ink);
    border-color: var(--rule-strong);
    background: var(--surface-hover);
  }

  .text-link:hover {
    color: var(--ink-soft);
  }
}

/* -------------------------------------------------------------- header -- */

/* Static, not fixed. There is one screen of content and no in-page navigation,
   so a header that follows the scroll would cost a scrim, a scroll state and a
   sentinel to buy nothing. The hairline is the only depth it needs. */
.site-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* Wraps rather than overflows. At 200% text zoom on a 320px screen the brand
     and the nav cannot share one line, and a flex row would push the nav off the
     right edge instead of moving it down. */
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  max-width: var(--content-max);
  margin: 0 auto;
  /* The brand sits at the inline start and the nav at the inline end, so the
     header must be as wide as the page up to the 74rem cap. `margin: 0 auto` is
     what centres that capped width, and in the body's flex column an auto
     cross-axis margin cancels `stretch` — without this the header shrink-wrapped
     to 341px and centred the brand in the middle of the window. */
  width: 100%;
  padding: max(var(--space-3), env(safe-area-inset-top)) var(--gutter) var(--space-2);
  border-bottom: 1px solid var(--rule);
}

/* The header carries the mark and the domain. The full lockup is reserved for
   the hero, where the name is being introduced rather than restated in passing. */
.header-brand {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--ink-soft);
  text-decoration: none;
  transition: color var(--dur-state) var(--ease-out);
}

.header-brand:active {
  color: var(--ink);
}

.header-brand img {
  display: block;
  width: 1.65rem;
  height: auto;
}

/* The domain is set in the logotype face so the header and the hero look like
   one brand rather than two. */
.header-wordmark {
  font-family: var(--font-logo);
  font-weight: 700;
  font-size: 0.8125rem;
  letter-spacing: -0.01em;
  /* The domain is one unbreakable token. At 200% text zoom on a 320px screen it
     is wider than the column, so it is allowed to break rather than run off the
     edge — the mark and the words are already two flex items, and this is the
     last line of defence for the words alone. */
  overflow-wrap: anywhere;
}

/* Extend the hit area to ~44px tall without enlarging the visible mark. */
.header-brand::before {
  content: '';
  position: absolute;
  inset: -12px -10px;
}

.site-nav {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

.site-nav a {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.2rem;
  font-weight: 600;
  font-size: var(--text-ui);
  color: var(--ink-soft);
  text-decoration: none;
  transition: color var(--dur-state) var(--ease-out);
}

.site-nav a:active {
  color: var(--ink);
}

.site-nav a::before {
  content: '';
  position: absolute;
  inset: -12px -8px;
}

.site-nav a::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  bottom: -0.3em;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: inline-start center;
  transition: transform var(--dur-state) var(--ease-out);
}

/* The external-link arrow, drawn rather than typed: a bare ↗ glyph renders at
   the mercy of whatever fallback the reader's system happens to have, and no
   shipped face here covers U+2197. */
.link-arrow {
  width: 0.7em;
  height: 0.7em;
  flex: none;
  opacity: 0.75;
}

/* ------------------------------------------------------------- the mark -- */

/* The owner's supplied mark: proof-ink brain, live-channel amber circuit. The
   amber here is part of the live-channel budget and is the one place amber
   paints as a mark rather than as a control. */
.header-brand img,
.lockup-mark {
  transition: opacity var(--dur-state) var(--ease-out);
}

/* -------------------------------------------------------------- lockup -- */

/* Mark above the title, the title stacked two lines deep in the owner's named
   face. Only ever set at weight 700; see the @font-face note above. */
.lockup {
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
  color: inherit;
}

.lockup-mark {
  display: block;
  width: var(--lockup-mark);
  height: auto;
}

.lockup-words {
  display: flex;
  flex-direction: column;
  margin-top: var(--lockup-gap);
  font-family: var(--font-logo);
  font-weight: 700;
  line-height: 1.02;
  letter-spacing: -0.012em;
}

.lockup-line {
  display: block;
}

/* The owner's 2026-09-16 note: the mark read too small, so it grew about 11%.
   The name's size is derived from this value below, so the mark-to-name ratio
   holds across the whole range. */
.lockup-hero {
  --lockup-mark: clamp(2.5rem, 4.6vw, 3.625rem);
  --lockup-gap: 0.5rem;
}

/* Derived from the mark rather than given its own clamp: two independent clamps
   held the approved ratio only in the middle of the range. 1.075 is the ratio
   the mark's own clamp was tuned against (3.625rem mark, 3.9rem name).

   The second term is a hard ceiling at the viewport: the name is ten characters
   of DejaVu Sans Mono, whose advance is 0.602em, so 6.1 approximates the em
   width that still fits the gutters. Both terms are rem/vw based, so under text
   zoom the mark's floor doubles and the name would otherwise push the layout
   past a 320px screen — a display heading that stops growing is a smaller
   failure than one that leaves the screen. */
.lockup-hero .lockup-words {
  font-size: min(calc(var(--lockup-mark) * 1.075), calc((100vw - 2 * var(--gutter)) / 6.1));
}
/* ---------------------------------------------------------------- hero -- */

/* `#main` is the home page's own main — the 404 page's `main.notfound` is a
   single block and must not be turned into a flex column by this. It is what
   takes the space the header and the footer leave, and it has to be the flex
   container for the hero's own `flex` to mean anything: as a plain block, main
   was content-height, so the hero grew to its content and the leftover height
   pooled under the footer — a page that scrolled nowhere but still sat in the
   top two-thirds of the window. The skip link already gives it the id. */
#main {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.hero {
  display: grid;
  grid-template-columns: 5fr 6fr;
  align-items: center;
  /* Centres the composition in the height the hero is given instead of letting
     the single row stretch and the items float inside it. On a phone, where the
     hero is shorter than its content, there is no free space and this does
     nothing. */
  align-content: center;
  gap: clamp(2rem, 5vw, 5rem);
  max-width: var(--content-max);
  margin: 0 auto;
  /* The auto inline margins are what centre the capped 74rem column, and in a
     flex parent auto cross-axis margins cancel `stretch`. Without this the hero
     — and, worse, the header and the footer — shrink-wrap to their content and
     centre that instead of the page. */
  width: 100%;
  /* Takes the rest of the first screen, whatever the header and the footer
     measure: the page is exactly one viewport tall. The padding is symmetric
     now that the grid centres what it holds — the old 4.5vh-top/3.5vh-bottom
     pair was doing the centring by hand and would now bias the composition
     low. It stays height-aware: a 900px window can afford 2.5rem of air, a
     768px one cannot, and `min-height: 0` plus auto margins keep it shrinking
     with the window rather than the content. */
  flex: 1 1 auto;
  min-height: 0;
  padding: clamp(1rem, 3vh, 2.5rem) var(--gutter);
}

/* The DOM puts the argument first, so reading order and tab order lead with the
   h1 and the page's only control. The two-column composition wants the flood on
   the leading side, which `order` restores at >=901px; below that the media
   query drops it, and the argument keeps the lead. */
.hero-proof {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.hero-visual {
  position: relative;
  order: -1;
  /* The flood is the container the mapping sizes itself against, because no
     viewport breakpoint can say how wide the column is: the hero is two columns
     above the 900px step, where the flood gets 5fr and is 356px wide at a 901px
     window, and one column below it, where the same window width gives it 828px.
     `inline-size` containment is free here — the column's width comes from the
     grid track either way. */
  container-type: inline-size;
}

.hero-flood {
  position: relative;
  /* As tall as the transcript it holds, plus the fade bands — no more. A fixed
     `svh` height was the old shape, and it was wrong twice over: at a 768px window
     60svh clipped the last kept line, whose filename is the thing the mapping
     exists to show, and at 900px it left 41px of slack that the mask's 12% bottom
     band then ate, so the payoff row was permanently dimmed. Sizing to the content
     puts every row inside the opaque band at every width, and the percentage mask
     becomes two pixel bands over the padding, where they fade nothing. */
  height: auto;
  padding-block: 2.25rem;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(
    to bottom,
    transparent 0,
    var(--mask-opaque) 2.25rem,
    var(--mask-opaque) calc(100% - 2.25rem),
    transparent 100%
  );
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    var(--mask-opaque) 2.25rem,
    var(--mask-opaque) calc(100% - 2.25rem),
    transparent 100%
  );
}

/* The h1 is the lockup, so its type is set by .lockup-hero, not by h1's own. */
.hero-proof h1 {
  margin: 0;
}

/* The promise, and the only sentence in the hero at full ink: it is the claim
   the rest of the page (and the social card) repeats. */
.hero-promise {
  margin: var(--space-3) 0 0;
  max-width: 30rem;
  font-size: var(--text-lead);
  font-weight: 600;
  color: var(--ink);
  text-wrap: pretty;
}

/* What the promise buys, in the owner's words rather than the inventory's: it
   used to count the five files, and the count is now carried where it belongs —
   the prompt that creates them, the description a search result shows, and the
   social card, which lists them beside this line. At 480px it is two lines at
   every width down to 320, one line less than the two-sentence version it
   replaced, which took 30px off the phone's page height. */
.hero-mechanism {
  margin: var(--space-2) 0 0;
  max-width: 30rem;
  font-size: var(--text-lead);
  font-weight: 400;
  color: var(--ink-dim);
  text-wrap: pretty;
}

/* -------------------------------------------------------------- actions -- */

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  margin-top: var(--space-3);
  min-width: 0;
}

/* Both buttons in the hero's action row. Icon on the inline start, so that side
   gets 2px less padding than the text side — equal padding reads as off-centre.
   line-height and min-height are declared together rather than left to each
   element's own default: a button and an anchor computed 45px and 49px for the
   same box, because the UA's line-height reaches one and not the other. */
.btn-primary,
.btn-secondary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.55rem;
  min-height: 2.75rem;
  padding: 0.7rem 1.15rem 0.7rem 1.02rem;
  border-radius: var(--radius-sm);
  background: transparent;
  /* Ink label on both: amber text at this size measures APCA Lc 40.6, under the
     60 this system holds non-body text to. The border carries the hierarchy. */
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: var(--text-ui);
  line-height: 1.2;
  text-decoration: none;
  white-space: nowrap;
  transition: background-color var(--dur-state) var(--ease-out), transform var(--dur-state) var(--ease-out),
    border-color var(--dur-state) var(--ease-out);
}

/* The min-width holds the box while the label swaps to "Copied", so a press does
   not reflow the row. */
.btn-primary {
  min-width: 15.5rem;
  border: 1px solid var(--amber);
}

/* The GitHub control beside it: same box, same type, a quieter boundary. Amber
   is the live channel and the page has exactly one primary action, so a second
   amber control would flatten the hierarchy the layout depends on. It still gets
   --border-control (3.20:1) rather than a hairline, because WCAG 1.4.11 needs
   3:1 to identify a control at all. */
.btn-secondary {
  border: 1px solid var(--border-control);
}

.btn-primary:active,
.btn-secondary:active {
  transform: translateY(0) scale(0.96);
}

.btn-mark {
  flex: none;
  width: 1rem;
  height: 1rem;
}

/* The three checkable facts that qualify the action, in the register the rest
   of the page keeps facts in: dim, small, one line. */
.hero-fine {
  margin: var(--space-3) 0 0;
  max-width: 26rem;
  font-size: var(--text-ui);
  line-height: 1.6;
  color: var(--ink-dim);
  text-wrap: pretty;
}
/* ------------------------------------------------------- prompt block -- */

/* The prompt is fifty lines, so it opens in a modal while the control that
   copies it stays visible in the hero: the reader who wants to install the
   product never has to open it, and the reader who wants to read it gets a
   surface built for reading.

   This replaced an inline <details>, at the owner's call. Inline, opening it grew
   the argument column and the hero's `align-items: center` dragged the flood down
   the page with it, and fifty lines want a close affordance rather than a
   collapse. `index.html` records what the modal trades away. */

/* Text, not a box. It was a full-width strip at --surface-card, which on a page
   with one primary action and one secondary box read as a third, empty one: the
   widest rectangle on the page, its label stranded against the far edge by a
   `minmax(0, 1fr)` column with a typed `+` opposite it. It now sits in the
   register the rest of the page keeps secondary things in — the label at
   --ink-soft, the count and the mark at --ink-dim, and no surface of its own —
   so the amber control above it stays the only thing that looks like a
   destination. */
.prompt-open {
  /* The argument column is a flex container, so a control that should be as wide
     as its own words has to say so; `stretch` would hand it the column again. */
  align-self: flex-start;
  position: relative;
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
  margin-top: var(--space-2);
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: var(--ink-soft);
  text-align: start;
  cursor: pointer;
  transition: color var(--dur-state) var(--ease-out);
}

/* ~44px target without a visible 44px control, as the header's links, the
   paused control and the modal's close button all do it. */
.prompt-open::before {
  content: '';
  position: absolute;
  inset: -12px -8px;
}

/* A text control moves between ink tones rather than taking a surface: the same
   language the header links and the text links speak. */
.prompt-open:active {
  color: var(--ink);
}

.prompt-open:focus-visible {
  outline: 2px solid var(--amber);
  outline-offset: 3px;
}

.prompt-open-label {
  font-size: var(--text-ui);
  font-weight: 600;
  color: inherit;
}

.prompt-open-hint {
  font-family: var(--font-mono);
  font-size: var(--text-caption);
  color: var(--ink-dim);
}

/* One mark, not two. The old cue cross-faded a `+` into a `−` because the
   disclosure had an open state; a modal has none, so the cross-fade went with
   it, and with it the `--ease-glide` and `--dur-cue` tokens that existed only
   to name that curve and that duration. This is a plain disclosure arrow and
   never changes. */
.prompt-open-cue {
  align-self: center;
  width: 0.75rem;
  height: 0.75rem;
  color: var(--ink-dim);
}

/* A modal `<dialog>` is inert, but the document behind it is still a scroll
   container: a real wheel over the backdrop scrolled the page 492px with the
   modal open, and `overscroll-behavior: contain` on `.prompt-doc` never covered
   that — it governs only what happens after the prompt's own region reaches its
   end. Locking the root is the one rule that covers the whole page, including
   the backdrop and the modal's head, and `:has()` applies it with no listener.
   Written against `:modal` rather than `[open]` on purpose: `app.js` shows the
   dialog in place when `showModal` is missing, and an inline dialog must leave
   the page scrollable. Scroll position is preserved rather than reset, so
   closing leaves the reader where they were. */
html:has(.prompt-dialog:modal) {
  overflow: hidden;
}

/* The modal. `showModal()` puts it in the top layer, so opening it cannot reflow
   the page, which is the whole point of the change. `--prompt-max` is the width
   at which the prompt stops wrapping: its longest line is 83 characters of the
   mono face, 717px, plus the block's own padding. */
.prompt-dialog {
  width: min(var(--prompt-max), calc(100vw - 2rem));
  max-width: none;
  max-height: min(86svh, 48rem);
  margin: auto;
  padding: 0;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius-md);
  background: var(--ground);
  color: var(--ink);
  overflow: hidden;
}

/* Written against `[open]` rather than the bare class, so the UA's
   `dialog:not([open]) { display: none }` keeps a closed dialog out of the layout
   without a specificity fight. */
.prompt-dialog[open] {
  display: flex;
  flex-direction: column;
  /* A modal is an occasional surface, so it is allowed the site's whole state
     step, and it starts from 0.97 rather than from 0: nothing appears from
     nothing. Closing is instant, which is the asymmetry a response should have. */
  transition: opacity var(--dur-state) var(--ease-out), transform var(--dur-state) var(--ease-out);
}

@starting-style {
  .prompt-dialog[open] {
    opacity: 0;
    transform: scale(0.97);
  }
}

/* A scrim, which is the site's own device for one surface sitting over another:
   the translucent ground, not a gradient, not glass, and not a shadow. */
.prompt-dialog::backdrop {
  background: rgb(10 9 8 / 72%);
}

.prompt-dialog-head {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding: 0.7rem var(--space-3);
  border-bottom: 1px solid var(--rule);
  background: var(--surface-card);
}

.prompt-dialog-title {
  margin: 0;
  font-size: var(--text-ui);
  font-weight: 600;
  color: var(--ink);
}

/* A box control like the flood's pause button, so it takes the same 0.96 scale,
   the same 44px target through a transparent ::before, and an inset ring to
   match the control that opens the modal. */
.prompt-dialog-close {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink-dim);
  cursor: pointer;
  transition: color var(--dur-state) var(--ease-out), border-color var(--dur-state) var(--ease-out),
    background-color var(--dur-state) var(--ease-out), transform var(--dur-state) var(--ease-out);
}

.prompt-dialog-close:active {
  transform: scale(0.96);
}

.prompt-dialog-close:focus-visible {
  outline: 2px solid var(--amber);
  outline-offset: -2px;
}

/* ~44px target without a visually large control: 32 + 6 + 6, as the pause
   control does. */
.prompt-dialog-close::before {
  content: '';
  position: absolute;
  inset: -6px;
}

.prompt-dialog-x {
  width: 0.75rem;
  height: 0.75rem;
}

/* The prompt is the only thing that scrolls, and only when fifty lines outrun
   the viewport. The head stays put, so the close control never scrolls away, and
   `overscroll-behavior` keeps the page behind from moving once this region ends. */
.prompt-doc {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--space-3) var(--space-3) var(--space-4);
}

/* The provenance note: where the quoted text comes from and where an agent can
   fetch it, so the reader knows this is not the site's own paraphrase. */
.prompt-note {
  margin: 0 0 var(--space-3);
  max-width: var(--prose-max);
  font-size: var(--text-ui);
  line-height: 1.6;
  color: var(--ink-dim);
  text-wrap: pretty;
}

.prompt-body {
  margin: 0;
  padding: 0;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  font-weight: 400;
  line-height: 1.8;
  color: var(--ink);
  /* `pre-line`, not `pre-wrap`, and at every width rather than only below the
     640px step the first cut of this rule used. The prompt's own lines run to 83
     characters with hanging indents of up to 17, against a column that gives this
     face 57 characters per line. Under `pre-wrap` those indents are preserved
     mid-wrap, so a wrapped fragment lands at a left edge a full indent away from
     the line it belongs to and the block reads as a zig-zag: 50 source lines
     rendered as 81 rows with the text stepping in and out of the margin at every
     desktop width, not just on a phone. `pre-line` keeps the line breaks and
     collapses each run of whitespace to one space, so the numbered steps still
     break where they break and the left edge stays nearly flush, at the cost of
     the file/description list's alignment under `The files and what each owns`,
     which a 57-character column destroys either way. The copy control and
     `npm run check` both read the text node, so neither the copied text nor
     `/setup.txt` changes by a byte. */
  white-space: pre-line;
  overflow-wrap: break-word;
  tab-size: 2;
}
/* links */

.text-link {
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  /* The repository slug is one 35-character token with no break opportunity,
     which ran off the page under text zoom. Let it break rather than overflow. */
  overflow-wrap: anywhere;
}

/* The footer link's only response to a tap, for the same reason as the file
   row: its hover is pointer-gated and the tap highlight is off. */
.text-link:active {
  color: var(--ink-soft);
}

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

.site-footer {
  max-width: var(--content-max);
  margin: 0 auto;
  /* Same auto-margin/stretch interaction as the header: the footer's line is
     centred on the page, and a shrink-wrapped box is only centred by accident. */
  width: 100%;
  /* The hero's own bottom padding is the gap above this line; all the footer adds
     is room under it, and on a phone the home indicator's inset replaces that
     rather than adding to it. */
  padding: 0 var(--gutter) max(var(--space-3), env(safe-area-inset-bottom));
  text-align: center;
}

.footer-meta {
  margin: 0;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--text-caption);
  color: var(--ink-dim);
  max-width: 100%;
  overflow-wrap: anywhere;
}
/* ---------------------------------------------------------------- 404 -- */

/* Served by the Worker for any path that is not a file. It reuses the site's own
   type, palette and control, so a wrong URL still looks like the site. */
.notfound {
  max-width: var(--content-max);
  margin: 0 auto;
  /* It is a direct child of the body's flex column, where an auto cross-axis
     margin cancels `stretch`; without a width it shrink-wrapped and centred a
     narrow column instead of filling the page up to the cap. */
  width: 100%;
  padding: var(--space-6) var(--gutter);
}

.notfound h1 {
  margin: var(--space-4) 0 0;
  font-size: clamp(1.5rem, 3vw, 2rem);
}

.notfound p {
  margin: var(--space-3) 0 0;
  max-width: var(--prose-max);
  color: var(--ink-soft);
}

/* ------------------------------------------- one-time hero entrance -- */
/* The hero assembles once on load, echoing the flood's own generate -> prune ->
   hold story. `both` keeps each element in its start/end state outside its
   active window so nothing flashes unstyled. Gated on `html.js` so a reader
   without the script never sees a hidden-then-abandoned hero. */

@keyframes hero-in {
  from {
    opacity: 0;
    transform: translateY(14px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

html.js .header-brand,
html.js .site-nav a,
html.js .lockup-hero,
html.js .hero-promise,
html.js .hero-mechanism,
html.js .hero-actions,
html.js .hero-fine,
html.js .prompt-open {
  animation: hero-in var(--dur-hero) var(--ease-out) both;
}

html.js .site-nav a,
html.js .hero-promise {
  animation-delay: var(--stagger);
}

html.js .hero-mechanism {
  animation-delay: calc(var(--stagger) * 2);
}

html.js .hero-actions {
  animation-delay: calc(var(--stagger) * 3);
}

html.js .hero-fine,
html.js .prompt-open {
  animation-delay: calc(var(--stagger) * 4);
}

/* ----------------------------------------------------------- the flood -- */

.flood {
  position: relative;
  height: 100%;
}

.flood-session {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.6rem;
  height: 100%;
}

/* The `hidden` attribute loses to `display: flex` in the cascade (author origin
   beats the UA sheet), so without this rule every session lays out in flow below
   the visible box and the hero is blank whenever the script has advanced past
   session one. This line is what makes `hidden` mean anything. */
.flood-session[hidden] {
  display: none;
}

.flood-line {
  position: relative;
  margin: 0;
  /* One row wherever the column can hold one. A destination that drops to a line
     of its own reads as one more line of transcript rather than as where the
     sentence above it went — at 1440px two of the five tags did exactly that, and
     four of the five at 375px. So the tag does not wrap: the sentence absorbs the
     squeeze by wrapping inside its own box, and the rail stays straight. Under
     24rem of column there is no room for either, and the `@container` block
     further down gives the tag a row of its own on purpose. */
  display: flex;
  align-items: flex-end;
  column-gap: 0.75rem;
  row-gap: 0.25rem;
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: clamp(0.82rem, 1vw, 0.9rem);
  line-height: 1.45;
  color: var(--flood-noise);
  opacity: var(--flood-opacity);
}

.flood-text {
  position: relative;
  /* The tag is what must not shrink, so every pixel of squeeze lands here, where
     wrapping can absorb it. */
  flex: 0 1 auto;
  min-width: 0;
}

.flood-line.is-keep {
  color: var(--flood-keep);
  opacity: var(--flood-opacity-keep);
  font-weight: 400;
}

/* The mapping, and the only thing in the frame that names a file. It grows to the
   line's right edge, so the five filenames end on one rail down the right margin
   and every leader reaches it — a contents list with one destination column,
   instead of five tags parked wherever their own sentence happened to stop. The
   names are what sits flush: they differ in length, so the arrows introducing
   them cannot. */
.flood-tag {
  display: flex;
  /* Grows to the rail; never shrinks below arrow, name and the caret's slot. */
  flex: 1 0 auto;
  justify-content: flex-end;
  align-items: baseline;
  gap: 0.35em;
  font-size: var(--text-caption);
  color: var(--ink-soft);
  opacity: 0;
  transition: opacity var(--dur-state) var(--ease-out);
}

/* The leader: the hairline that draws the connection from the sentence to its
   file, so the mapping is a line rather than an adjacency. It takes the tag's own
   tone at half strength — derived from the tone it belongs to rather than a fifth
   flood token — and draws from its text end, the same gesture the strike uses. */
.flood-tag::before {
  content: '';
  flex: 1 1 auto;
  align-self: center;
  /* Long enough to read as a connection even on the squeezed lines, where the
     sentence has taken every pixel it can use and the leader is all that is
     left. Measured rather than guessed: at 1rem the two wrapped lines drew a
     16px dash, which is an adjacency again. */
  min-width: 2.5rem;
  height: 1px;
  background: currentColor;
  opacity: 0.5;
  transform: scaleX(0);
  transform-origin: inline-start center;
  transition: transform var(--dur-state) var(--ease-out);
}

/* The drawn arrow tying a kept line to its file. It replaces a typed U+2192,
   which no shipped face covers — the mono subset has U+2191/U+2193/U+2212 but
   not the rightwards arrow — so the mark carrying the motif's whole meaning
   rendered as tofu wherever no symbol fallback existed. */
.flood-arrow {
  width: 0.6em;
  height: 0.6em;
  flex: none;
}

/* One frame with transitions off, so the forced reflow in app.js commits the
   pre-generate line state (opacity 0, translateY(4px), strike scaleX(0), tag
   opacity 0, leader scaleX(0)) and `is-generating` then animates from a real
   start. Without it a resumed session retargets every property to the value it
   already has, and the prune plays backwards. */
html.js .flood-session.is-reset .flood-line,
html.js .flood-session.is-reset .flood-line .flood-text::after,
html.js .flood-session.is-reset .flood-tag {
  transition: none !important;
}

/* Struck-through noise: a hairline drawn across the text, animated in. The mark
   keeps --flood-strike rather than currentColor so it stays visible when the
   struck text itself recedes. */
.flood-line:not(.is-keep) .flood-text::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  top: 50%;
  height: 1px;
  background: var(--flood-strike);
  transform: scaleX(0);
  transform-origin: inline-start center;
  transition: transform var(--dur-strike) var(--ease-out);
}
/* ------------------------------------------------- animation sequencing -- */

html.js .flood-line {
  opacity: 0;
  transform: translateY(4px);
  transition: opacity var(--dur-flood-line) var(--ease-out), transform var(--dur-flood-line) var(--ease-out),
    color var(--dur-strike) var(--ease-out);
  transition-delay: calc(var(--i) * var(--stagger-line));
}

html.js .flood-session.is-generating .flood-line,
html.js .flood-session.is-pruning .flood-line,
html.js .flood-session.is-mapping .flood-line,
html.js .flood-session.is-held .flood-line {
  opacity: var(--flood-opacity);
  transform: translateY(0);
}

/* Streaming is the one beat where nothing is distinguished: every line, kept or
   not, arrives in the noise tone at the noise opacity. The story is "a wall of
   transcript" first and "which of it matters" second, so the keep styling must
   not be visible before the prune makes it mean something. */
html.js .flood-session.is-generating .flood-line.is-keep,
html.js .flood-session.is-reset .flood-line.is-keep {
  color: var(--flood-noise);
  opacity: var(--flood-opacity);
}

html.js .flood-session.is-pruning .flood-line.is-keep,
html.js .flood-session.is-mapping .flood-line.is-keep,
html.js .flood-session.is-held .flood-line.is-keep {
  opacity: var(--flood-opacity-keep);
}

/* The kept line's arrival, on the prune's own beat: one bloom that fades back to
   no shadow, so what survives the pass reads as lit rather than merely brighter.
   The mono face has no 600 or 700 to move to (the only bold in the repo is the
   logotype's DejaVu), so luminance is the whole highlight — a synthetic bold
   would smear the 13px glyphs. */
@keyframes keep-bloom {
  from {
    text-shadow: 0 0 0.85em rgba(var(--flood-keep-rgb), 0.55);
  }
  to {
    text-shadow: 0 0 0 rgba(var(--flood-keep-rgb), 0);
  }
}

html.js .flood-session.is-pruning .flood-line.is-keep .flood-text {
  animation: keep-bloom var(--dur-glow) var(--ease-out);
  animation-delay: calc(var(--i) * var(--stagger-line));
}

html.js .flood-session.is-pruning .flood-line:not(.is-keep) .flood-text::after,
html.js .flood-session.is-mapping .flood-line:not(.is-keep) .flood-text::after,
html.js .flood-session.is-held .flood-line:not(.is-keep) .flood-text::after {
  transform: scaleX(1);
}

/* Proofreader's order: each strike starts a beat after the one above it, so the
   marks read as a pass down the page instead of one simultaneous flash. */
html.js .flood-session.is-pruning .flood-line:not(.is-keep) .flood-text::after {
  transition-delay: calc(var(--i) * var(--stagger-prune));
}

/* Pruned: no opacity group here, so the strike keeps its own contrast while the
   text colour does the receding. */
html.js .flood-session.is-pruning .flood-line:not(.is-keep),
html.js .flood-session.is-mapping .flood-line:not(.is-keep),
html.js .flood-session.is-held .flood-line:not(.is-keep) {
  color: var(--flood-noise-dim);
  opacity: 1;
  transition-delay: 0ms;
}

/* The mapping is its own beat, and the last one: the prune has settled which
   lines matter, and only then do the lines and arrows draw to name the file each
   one belongs to. Two beats, both on the line's own `--i` step: the leader draws,
   and the filename lands at its end. Staggering them with the line is what keeps
   five tags from arriving as one flash. */
html.js .flood-line.is-keep .flood-tag::before {
  transition-delay: calc(var(--i) * var(--stagger-line) + var(--lead-step));
}

html.js .flood-line.is-keep .flood-tag {
  transition-delay: calc(var(--i) * var(--stagger-line) + var(--lead-step) * 2);
}

html.js .flood-session.is-mapping .flood-line.is-keep .flood-tag,
html.js .flood-session.is-held .flood-line.is-keep .flood-tag {
  opacity: 1;
}

html.js .flood-session.is-mapping .flood-line.is-keep .flood-tag::before,
html.js .flood-session.is-held .flood-line.is-keep .flood-tag::before {
  transform: scaleX(1);
}

/* The last line carries a blinking cursor, like a proof still being typed. Its
   slot is reserved on every tag rather than only on the last one, so the caret
   cannot push the last filename off the rail the other four sit on. */
.flood-tag::after {
  content: '';
  display: inline-block;
  flex: none;
  width: 0.5em;
  height: 1em;
  margin-inline-start: 0.35em;
  background: transparent;
  opacity: 0;
}

.flood-line:last-child .flood-tag::after {
  background: var(--amber);
}

html.js .flood-session.is-held .flood-line:last-child .flood-tag::after {
  animation: caret-blink var(--dur-caret) step-end infinite;
}

/* Paused: the caret is part of the motion, so it stops with it. */
html.js .flood.is-paused .flood-line:last-child .flood-tag::after {
  animation: none;
  opacity: 0;
}

@keyframes caret-blink {
  0%,
  49% {
    opacity: 1;
  }
  50%,
  100% {
    opacity: 0;
  }
}

/* No-JS rest state: session one, already pruned and tagged. A still frame that
   tells the whole story on its own, so the hero works with no script at all. */
html:not(.js) .flood-session {
  display: none;
}

html:not(.js) .flood-session:first-child {
  display: flex;
}

html:not(.js) .flood-line:not(.is-keep) {
  color: var(--flood-noise-dim);
}

html:not(.js) .flood-line:not(.is-keep) .flood-text::after {
  transform: scaleX(1);
}

html:not(.js) .flood-line.is-keep .flood-tag {
  opacity: 1;
}

html:not(.js) .flood-line.is-keep .flood-tag::before {
  transform: scaleX(1);
}

html:not(.js) .flood-line.is-keep,
html:not(.js) .flood-line:last-child .flood-tag::after {
  animation: none;
}
/* ------------------------------------------------------- pause control -- */

/* WCAG 2.2.2 for the looping flood. An icon button rather than a labelled one: a
   word under the flood competes with the lines it describes, and the pair of
   shapes is legible at a glance. The name moves into a visually hidden span the
   script rewrites with the state. */
.flood-control {
  position: relative;
  /* Hidden until JavaScript is present: with no script the flood never cycles,
     so the control would be a visible, focusable no-op. */
  display: none;
  align-items: center;
  justify-content: center;
  /* Square at 2rem, matching the primary control's height. The visible box stays
     small and the ::before below carries the target to 44px. */
  width: 2rem;
  height: 2rem;
  margin-top: var(--space-2);
  padding: 0;
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink-dim);
  cursor: pointer;
  transition: color var(--dur-state) var(--ease-out), border-color var(--dur-state) var(--ease-out),
    background-color var(--dur-state) var(--ease-out), transform var(--dur-state) var(--ease-out);
}

html.js .flood-control {
  display: inline-flex;
}

.flood-control-icon {
  width: 0.875rem;
  height: 0.875rem;
  fill: currentColor;
}

/* The triangle's corners are rounded by its own stroke, so it shares the corner
   language the 4.8px radii give the rest of the controls. */
.flood-icon-play {
  display: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linejoin: round;
}

/* One icon at a time, swapped on the `data-paused` the script writes, so no
   second piece of state has to be kept in sync. `data-paused` is in the markup
   so the correct icon paints on the first frame, before the script runs. */
.flood-control[data-paused='true'] .flood-icon-pause {
  display: none;
}

.flood-control[data-paused='true'] .flood-icon-play {
  display: block;
}

.flood-control[data-paused='true'] {
  color: var(--ink);
  border-color: var(--rule-strong);
}

/* 0.96 rather than the 0.97 an external standards table would suggest: this
   repo recorded 0.96 when it tuned the primary button's press, and a press
   language with two scales is not one language. Both sit inside the 0.95-0.98
   band the standard asks for anyway. */
.flood-control:active {
  transform: scale(0.96);
}

/* ~44px target without a visually large control: 32 + 6 + 6. */
.flood-control::before {
  content: '';
  position: absolute;
  inset: -6px;
}

/* -------------------------------------------------------------- layout -- */

/* The column ratio stays 5fr/6fr at every width above the 900px step, so the
   argument keeps the wider column. A 6fr/5fr flip at >=1120px was built, because
   it is what would let all five of the flood's `-> FILENAME` tags sit inline with
   the line they belong to (the flood's longest tagged line needs 490px and the
   hero's action row needs 393px, with no room for both below 1120px). It was
   rejected on measurement rather than taste: taking 90px off the argument column
   drops the prompt's measure from 494px to 403px, which is 57 characters of the
   mono face per line down to 47, and that costs the prompt the owner asked to
   keep in place more than it buys the flood's tag rhythm. */

/* Below 24rem of column, a kept sentence and its filename cannot share a row
   without the sentence breaking mid-clause — the longest sentence needs 415px and
   the longest filename 143px — so the tag takes a row of its own: still on the
   right rail, still joined to its line by the leader, which is capped here so it
   stays a dash into the arrow rather than a rule across the row. The threshold is
   measured, not chosen: 356px of column is where an inline tag leaves the
   sentence 201px and breaks it into three rows. */
@container (max-width: 24rem) {
  .flood-line.is-keep {
    flex-wrap: wrap;
  }

  .flood-line.is-keep .flood-tag {
    flex-basis: 100%;
  }

  .flood-line.is-keep .flood-tag::before {
    max-width: 2.5rem;
    /* The leader's own 2.5rem floor is what pushed the tag past the column under
       200% text zoom — at a 32px root that floor is 80px of a 272px column, and
       the box clips what leaves it. The dash is the part that may give way; the
       filename is the part that may not. */
    min-width: 0;
  }
}

@media (max-width: 900px) {
  .hero {
    /* minmax(0, 1fr) rather than a bare 1fr: a 1fr track still carries an
       automatic minimum, so the flood's content could hold the column wider than
       the viewport under text zoom. */
    grid-template-columns: minmax(0, 1fr);
    padding-top: clamp(2rem, 8vw, 3rem);
    padding-bottom: var(--space-4);
  }

  /* Drops the base rule's `order: -1`, so the argument keeps the lead and the
     flood follows it — the same order the DOM already reads in. */
  .hero-visual {
    order: 0;
  }

  /* The session is anchored to the bottom of its box here rather than the top:
     the box holds roughly half the session at a phone's width, and the end of the
     transcript is where the payoff lands — the last kept line, its filename and
     the caret. Anchored to the top, the four latest lines (two of them kept, one
     of them the last) were simply never in frame, so the mapping could not finish
     visibly at all. One row less of noise, three of the five mappings in frame,
     and the same `--lead-step` beats; 20rem rather than 18 is what buys the third
     one. No box short of the whole viewport fits all five. */
  .hero-flood {
    height: min(46svh, 20rem);
    -webkit-mask-image: linear-gradient(
      to bottom,
      transparent 0%,
      var(--mask-opaque) 10%,
      var(--mask-opaque) 88%,
      transparent 100%
    );
    mask-image: linear-gradient(to bottom, transparent 0%, var(--mask-opaque) 10%, var(--mask-opaque) 88%, transparent 100%);
  }

  .flood-session {
    justify-content: flex-end;
  }
}

@media (max-width: 640px) {
  /* The hero is the whole page now, so its bottom padding is the only gap the
     footer gets — the footer carries none of its own. One step, not two. */
  .hero {
    padding-bottom: var(--space-4);
  }

  .site-header {
    padding: var(--space-2) var(--gutter) var(--space-2);
  }

  .site-nav {
    gap: var(--space-2);
  }

  .hero-actions {
    align-items: stretch;
  }

  /* The labels fit on one line at desktop. On a phone they do not: at 320px the
     primary button ran past the viewport, and under text zoom the label itself
     was the part that got cut. Both buttons take the full column and stack, so
     neither is squeezed and neither can overflow. */
  .btn-primary,
  .btn-secondary {
    min-width: 0;
    width: 100%;
    max-width: 100%;
    white-space: normal;
    text-align: center;
  }
}
/* ---------------------------------------------------- reduced motion -- */

@media (prefers-reduced-motion: reduce) {
  /* Reduced motion removes spatial movement and keeps meaning-carrying
     feedback: the flood cross-fades instead of rising, and hover and focus
     colours still change instantly. */
  html.js .flood-line {
    transform: none !important;
    transition: opacity var(--dur-state) var(--ease-out), color var(--dur-state) var(--ease-out) !important;
    transition-delay: 0ms !important;
  }

  .flood-tag {
    transition: opacity var(--dur-state) var(--ease-out) !important;
    transition-delay: 0ms !important;
  }

  /* The leader is a rule rather than a path: with the movement removed it is
     simply drawn, and the tag's own fade is what carries the arrival. */
  .flood-tag::before {
    transform: none !important;
    transition: none !important;
  }

  .flood-line:not(.is-keep) .flood-text::after {
    transition: none !important;
  }

  .flood-line:last-child .flood-tag::after {
    animation: none !important;
  }

  /* The bloom is a flourish rather than feedback, and the still frame it would
     settle into is no shadow at all, so there is nothing here to keep. */
  html.js .flood-line.is-keep .flood-text {
    animation: none !important;
  }

  html.js .header-brand,
  html.js .site-nav a,
  html.js .lockup-hero,
  html.js .hero-promise,
  html.js .hero-mechanism,
  html.js .hero-actions,
  html.js .hero-fine,
  html.js .prompt-open {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }

  .skip-link,
  .btn-primary,
  .btn-secondary,
  .link-arrow,
  .header-brand,
  .site-nav a,
  .site-nav a::after,
  .flood-control,
  .prompt-open,
  .prompt-dialog-close,
  .prompt-dialog[open] {
    transition: none !important;
  }

  /* The modal fades rather than scaling: reduced motion drops the movement and
     keeps the appearance, so the `@starting-style` transform goes entirely and
     the dialog simply arrives. */
  .prompt-dialog[open] {
    transform: none !important;
  }

  /* The script settles the flood on one session instead of cycling it, so
     nothing is moving and there is nothing to pause. */
  html.js .flood-control {
    display: none;
  }
}

/* ------------------------------------------------------------- contrast -- */

/* The sheet honours reduced motion but not a request for more contrast. This
   widens the surface gaps at no cost to the default look. */
@media (prefers-contrast: more) {
  :root {
    --rule: rgba(232, 228, 216, 0.3);
    --rule-strong: rgba(232, 228, 216, 0.45);
    --border-control: rgba(232, 228, 216, 0.6);
    --ink-dim: #d4cec0;
    --ink-soft: #e2ddd2;
  }
}









