
:root {
  color-scheme: dark;
  --bg: #0a0e15;
  --bg-tint: #101726;
  --panel: #131a27;
  --panel-2: #18202f;
  --panel-3: #1e2839;
  --scrollbar-hover: #2c3852;
  --history-bg: rgba(19, 26, 39, 0.55);
  --stage-bg: #c3c6cd;
  --bg-fade-0: rgba(10, 14, 21, 0);
  --bg-fade-80: rgba(10, 14, 21, 0.8);
  --ink: #eaeef6;
  --ink-2: #b6bfd0;
  --muted: #8690a2;
  --code-ink: #cfd8ea;
  --code-keyword: #c299ff;
  --code-string: #7fd99a;
  --code-function: #59c2e8;
  --code-number: #e6975a;
  --code-comment: #8896b3;
  --code-base: #cfd8ea;
  --hairline: rgba(255, 255, 255, 0.075);
  --hairline-2: rgba(255, 255, 255, 0.14);
  --accent: #7c9cff;
  --accent-hi: #a5b9ff;
  --accent-soft: rgba(124, 156, 255, 0.13);
  --accent-glow: rgba(124, 156, 255, 0.06);
  --accent-underline: rgba(124, 156, 255, 0.4);
  --accent-select: rgba(124, 156, 255, 0.3);
  --accent-ink: #0a1020;
  --good: #56d68a;
  --diff-add-fill: rgba(86, 214, 138, 0.12);
  --warning: #e5b04d;
  --owed-dot: var(--accent);
  --warning-soft: rgba(229, 176, 77, 0.12);
  --warning-ink: #f0cd8c;
  --warning-border: rgba(229, 176, 77, 0.35);
  --warning-line: rgba(229, 176, 77, 0.4);
  --warning-border-strong: rgba(229, 176, 77, 0.45);
  --warning-ring: rgba(229, 176, 77, 0.5);
  --warning-fade: rgba(229, 176, 77, 0);
  --critical: #f0757a;
  --critical-soft: rgba(240, 117, 122, 0.08);
  --critical-border: rgba(240, 117, 122, 0.25);
  --diff-del-fill: rgba(240, 117, 122, 0.12);
  --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-2: 0 6px 20px -6px rgba(0, 0, 0, 0.55);
  --ring: 0 0 0 3px rgba(124, 156, 255, 0.28);
}

@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
  color-scheme: light;
  --bg: #eef1f7;
  --bg-tint: #f7f9fc;
  --panel: #ffffff;
  --panel-2: #f5f6fb;
  --panel-3: #e2e6f0;
  --scrollbar-hover: #c9d0e2;
  --history-bg: rgba(255, 255, 255, 0.55);
  --stage-bg: #e6e8ee;
  --bg-fade-0: rgba(238, 241, 247, 0);
  --bg-fade-80: rgba(238, 241, 247, 0.8);
  --ink: #171c2a;
  --ink-2: #3c4459;
  --muted: #515c76;
  --code-ink: #3a4c78;
  --code-keyword: #7a3fd6;
  --code-string: #1a7a3d;
  --code-function: #0d6f96;
  --code-number: #a15a00;
  --code-comment: #5b6479;
  --code-base: #3a4c78;
  --hairline: rgba(0, 0, 0, 0.1);
  --hairline-2: rgba(0, 0, 0, 0.18);
  --accent: #3251c9;
  --accent-hi: #2a46b8;
  --accent-soft: rgba(50, 81, 201, 0.13);
  --accent-glow: rgba(50, 81, 201, 0.06);
  --accent-underline: rgba(50, 81, 201, 0.4);
  --accent-select: rgba(50, 81, 201, 0.3);
  --accent-ink: #f5f8ff;
  --good: #007530;
  --diff-add-fill: rgba(0, 117, 48, 0.12);
  --warning: #805300;
  --owed-dot: #e5b04d;
  --warning-soft: rgba(128, 83, 0, 0.12);
  --warning-ink: #7a4a00;
  --warning-border: rgba(128, 83, 0, 0.35);
  --warning-line: rgba(128, 83, 0, 0.4);
  --warning-border-strong: rgba(128, 83, 0, 0.45);
  --warning-ring: rgba(128, 83, 0, 0.5);
  --warning-fade: rgba(128, 83, 0, 0);
  --critical: #b81b1b;
  --critical-soft: rgba(184, 27, 27, 0.08);
  --critical-border: rgba(184, 27, 27, 0.25);
  --diff-del-fill: rgba(184, 27, 27, 0.12);
  --shadow-1: 0 1px 2px rgba(16, 24, 40, 0.06);
  --shadow-2: 0 6px 20px -6px rgba(16, 24, 40, 0.12);
  --ring: 0 0 0 3px rgba(50, 81, 201, 0.28);
}
}

:root[data-theme="light"] {
  color-scheme: light;
  --bg: #eef1f7;
  --bg-tint: #f7f9fc;
  --panel: #ffffff;
  --panel-2: #f5f6fb;
  --panel-3: #e2e6f0;
  --scrollbar-hover: #c9d0e2;
  --history-bg: rgba(255, 255, 255, 0.55);
  --stage-bg: #e6e8ee;
  --bg-fade-0: rgba(238, 241, 247, 0);
  --bg-fade-80: rgba(238, 241, 247, 0.8);
  --ink: #171c2a;
  --ink-2: #3c4459;
  --muted: #515c76;
  --code-ink: #3a4c78;
  --code-keyword: #7a3fd6;
  --code-string: #1a7a3d;
  --code-function: #0d6f96;
  --code-number: #a15a00;
  --code-comment: #5b6479;
  --code-base: #3a4c78;
  --hairline: rgba(0, 0, 0, 0.1);
  --hairline-2: rgba(0, 0, 0, 0.18);
  --accent: #3251c9;
  --accent-hi: #2a46b8;
  --accent-soft: rgba(50, 81, 201, 0.13);
  --accent-glow: rgba(50, 81, 201, 0.06);
  --accent-underline: rgba(50, 81, 201, 0.4);
  --accent-select: rgba(50, 81, 201, 0.3);
  --accent-ink: #f5f8ff;
  --good: #007530;
  --diff-add-fill: rgba(0, 117, 48, 0.12);
  --warning: #805300;
  --owed-dot: #e5b04d;
  --warning-soft: rgba(128, 83, 0, 0.12);
  --warning-ink: #7a4a00;
  --warning-border: rgba(128, 83, 0, 0.35);
  --warning-line: rgba(128, 83, 0, 0.4);
  --warning-border-strong: rgba(128, 83, 0, 0.45);
  --warning-ring: rgba(128, 83, 0, 0.5);
  --warning-fade: rgba(128, 83, 0, 0);
  --critical: #b81b1b;
  --critical-soft: rgba(184, 27, 27, 0.08);
  --critical-border: rgba(184, 27, 27, 0.25);
  --diff-del-fill: rgba(184, 27, 27, 0.12);
  --shadow-1: 0 1px 2px rgba(16, 24, 40, 0.06);
  --shadow-2: 0 6px 20px -6px rgba(16, 24, 40, 0.12);
  --ring: 0 0 0 3px rgba(50, 81, 201, 0.28);
}

:root {
  /* non-color tokens: theme-independent, so they live in one shared block rather
     than being duplicated into both DARK and LIGHT */

  --r-sm: 6px;
  --r-md: 10px;
  --r-lg: 14px;
  --r-pill: 999px;

  /* spacing scale (dense end of standard: this is a working surface) */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;

  --dur: 160ms;
  --ease: cubic-bezier(0.16, 1, 0.3, 1);

  /* How far a scroll-to-anchor has to clear the sticky .board-head. Measured in
     Chrome: the header is 81.4px at ordinary widths, so 88px leaves a small gap
     -- but BELOW the 560px breakpoint .board-head becomes a column and grows to
     115.4px, and a hardcoded 88 then parks the target 27px BEHIND the header it
     was supposed to clear. One token, overridden in that same media query, so
     the two can never disagree again. Every scroll-margin-top on the page reads
     it: .round, whose top the pager scrolls to on every page flip, and
     .question-block, whose top the questions-left pill scrolls to. */
  --head-clear: 88px;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
  min-height: 100vh;
  background: var(--bg);
  color: var(--ink);
  font: 14px/1.6 "Inter var", Inter, -apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui, sans-serif;
  font-feature-settings: "cv05" 1, "ss01" 1;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}
/* one soft light source behind the content column; costs nothing and stops the
   page reading as a flat sheet of #0a0e15 */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  background:
    radial-gradient(900px 520px at 50% -10%, var(--bg-tint), transparent 70%),
    radial-gradient(700px 420px at 100% 0%, var(--accent-glow), transparent 65%);
}
code, pre { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; font-variant-ligatures: none; }
a { color: var(--accent); text-decoration-color: var(--accent-underline); text-underline-offset: 2px; }
a:hover { color: var(--accent-hi); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
button { cursor: pointer; }
button:disabled { cursor: default; }
svg { flex: none; }
::selection { background: var(--accent-select); }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--panel-3); border: 3px solid transparent; background-clip: content-box; border-radius: var(--r-pill); }
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); background-clip: content-box; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  * { transition: none !important; animation: none !important; }
}

/* No trailing bottom padding: the send bar (.send-bar, sticky and in-flow, near
   the bottom of this file) is the shell's own last child, so its bottom edge
   IS the document's bottom edge -- a reviewer scrolled to the end sees the bar's
   lower edge flush with the page's, not a band of bare background sized for
   nothing. That is the rule wherever the bar is on screen holding the floor.
   Where it is not -- a sent round, and every archive, since body.readonly hides
   the bar outright -- the round pager's floating dock is what the last block
   runs under, and the reservation near '.round-pager-dock' below carves that
   room back out. Pinned by test/check-round-end.mjs, which fails if this
   padding grows a bottom value here and if the reservation there ever stops
   matching the expression the Tray clears the dock with. */
.board-shell { position: relative; z-index: 1; max-width: 1120px; margin: 0 auto; padding: 0 var(--space-5); }

/* the board's identity stays on screen: a long board scrolls for a while, and
   "which board am I in, how many rounds deep" is the first thing you lose */
.board-head {
  position: sticky; top: 0; z-index: 20;
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
  margin-bottom: var(--space-5); padding: var(--space-4) 0 var(--space-3);
  background: linear-gradient(to bottom, var(--bg) 62%, var(--bg-fade-0));
  backdrop-filter: blur(10px);
  /* No border-bottom, deliberately. The gradient above already is the edge: it
     fades the header's own background out to transparent so content reads as
     passing UNDER it, and a hairline drawn across the bottom of that fade
     contradicts it -- a hard line where the treatment has just said there is
     none. One or the other; the fade is the one that survives the scroll. */
}
.board-head-title { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
.board-head h1 { font-size: 20px; margin: 0; font-weight: 650; letter-spacing: -0.015em; }
.board-head .meta { color: var(--muted); font-size: 11.5px; font-family: ui-monospace, "SF Mono", Menlo, monospace; margin-top: 2px; }
/* the one way back to the thread
   index. Absent under body.readonly -- a standalone file://
   archive has no daemon behind "/" to navigate to -- same idiom as
   .mode-toggle/.send-bar just above/below: kept in the markup (one
   byte-identical page, live or archived) and hidden structurally, not merely
   disabled.

   The slot holds the mark itself (markSvg, above), not an arrow in a framed
   button: it was already at favicon proportions, so brand and home became one
   control instead of two sitting side by side. That means no panel fill and no
   hairline -- a frame around the tile would read as a second, competing corner
   radius -- so hover is carried by value alone. The global :focus-visible
   outline near the top of this file still lands on it, keyboard reach unchanged. */
.back-to-index {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; padding: 0; border: 0; background: none;
  opacity: 0.88; transition: opacity var(--dur) var(--ease);
}
.back-to-index:hover { opacity: 1; }
body.readonly .back-to-index { display: none; }
.board-head-actions { flex: none; display: flex; align-items: center; gap: var(--space-3); }

/* AC 6, AC 8, AC 11: the page board's own pill/meta slot (ADR.md entry 40,
   "the pill may hold a label alone"). Sits in '.board-head-actions', which
   entry 40's condensing rule never touches (only 'h1'/'.meta' hide there), so
   ONE element renders in both the expanded header and the condensed pill with
   no second copy and no extra selector -- exactly AC 6's "in both the expanded
   header and the condensed pill". A muted figure, never a chip: entry 40
   corrects entry 40's assumption that this slot needed a real control, and a
   label alone is what the countdown and 'read-only' both are. Hidden on every
   board that is not laid out as a page board -- src/render.mjs renders it on
   every board (test/check-pure.mjs's emitter scan needs the class to appear
   somewhere), but only a page board has anywhere for it to mean anything. */
.round-meta { display: none; flex: none; font: inherit; color: var(--muted); font-size: 11.5px;
  font-weight: 550; letter-spacing: 0.02em; white-space: nowrap; }
body.page-board .round-meta { display: inline; }
/* ADR.md entry 40: an ordinary board's pill carries the same
   state label a page board's does -- the element and its text are already
   rendered/kept current for every board (src/render.mjs, src/ui.mjs's
   refreshAwaitDisplay), so this is the one CSS rule standing between it and
   showing here too. */
body:not(.page-board) .round-meta { display: inline; }

/* the comment-mode toggle: visible chrome, not a held modifier -- this IS
   discoverability. Off by default, so the page behaves exactly as before until
   the reviewer turns it on (true by construction). */
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; background: var(--panel-2);
  border: 1px solid var(--hairline); color: var(--ink-2); font-size: 11.5px; font-weight: 600;
  border-radius: var(--r-pill); padding: 6px 13px;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease), background var(--dur) var(--ease); }
.mode-toggle:hover:not(:disabled) { border-color: var(--hairline-2); color: var(--ink); }
.mode-toggle.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
/* ADR.md entry 46: a page board nobody is listening to is uncommentable, so the
   control that turns commenting on is not on offer there either. ADR 98 widens
   this with a third, independent fact: a round with nothing Commentable on it
   ('html'/'mermaid', wherever it appears) is uncommentable regardless of who's
   listening, on ANY board, not only a page one. ADR 101 widens it once more with
   a fourth: a round that has been SENT is uncommentable too, which is what
   closed entry 46's own "an ordinary content round is untouched" carve-out --
   the page board already lost its compose surface the moment it went out
   (renderPageCommentPanel, src/render.mjs), and an ordinary one went on
   offering the mode over an exchange that had already left.
   Four classes rather than one because they are four different facts -- an
   archive is read-only whatever it holds; 'page-uncommentable' is about a page
   board's round being unawaited in particular; 'round-uncommentable' is about
   the round's own CONTENT, awaited or not; 'sent-page' is about its STATUS,
   whatever it holds and whoever is listening (src/render.mjs sets all three at
   first paint, src/ui.mjs's refreshAwaitDisplay/refreshPager keep them true
   afterwards -- against the clock for the first, against every round flip for
   the other two). */
body.readonly .mode-toggle, body.page-uncommentable .mode-toggle, body.round-uncommentable .mode-toggle,
body.sent-page .mode-toggle { display: none; }

/* the theme control (src/theme.mjs): reuses .mode-toggle's chrome above rather
   than duplicating it, plus this icon-only modifier -- no visible label, so
   symmetric padding around a single glyph instead of text-plus-icon spacing. */
.mode-toggle-icon { padding: 7px; }
/* Unlike .mode-toggle, this control stays live in a read-only archive -- an
   archive reader is exactly who needs to switch theme. Same for an
   uncommentable page board, same again for a round with nothing Commentable on
   it, and same once more for a round that has been sent (ADR 101): nothing
   there is answerable or anchorable, but the theme of whatever IS on screen is
   exactly what a reader still wants to change -- a settled round is precisely
   what gets re-read -- so all four of .mode-toggle's hiding gates above are
   carved out here. An id
   selector outranks body.readonly .mode-toggle's class selector regardless of
   source order, so that rule's own wording (asserted verbatim by
   test/check-archive.mjs) never has to change to carve this control out of it.
   Tag-qualified ('button#theme-toggle', not bare '#theme-toggle') because
   '#theme-toggle' is not reserved: src/markdown.mjs's slugify turns a heading
   '## Theme toggle' into a second id="theme-toggle" on an <h2>, and board
   content is exactly the input that gets to choose its own headings. The tag
   qualifier is what the real button has and
   a markdown-minted heading never can -- see src/ui.mjs's matching
   'button#theme-toggle' lookup and its own comment on the same collision. */
body.readonly button#theme-toggle, body.page-uncommentable button#theme-toggle, body.round-uncommentable button#theme-toggle,
body.sent-page button#theme-toggle { display: inline-flex; }

.readonly-banner { display: none; background: var(--warning-soft); border: 1px solid var(--warning-border);
  color: var(--warning-ink); font-size: 12.5px; padding: 10px 14px; border-radius: var(--r-md); margin-bottom: var(--space-4); }
body.readonly .readonly-banner { display: block; }
body.readonly .send-bar { display: none; }
/* none of this work's new chrome belongs in a frozen
   archive -- the send bar it would dock is already gone on the line above, and
   the rail loses its reason to exist alongside it (nothing left to arrive at). */
body.readonly .round-end { display: none; }
body.readonly input, body.readonly textarea, body.readonly button.card-choice { pointer-events: none; opacity: 0.7; }

/* A page already sent is read-only (ADR.md entry 42) -- the guarantee the
   deleted history rail carried, now carried by the pager: flipping back to a
   sent round puts this class on <body> (src/ui.mjs's goToRound), flipping off it
   takes it away. This is the THIRD lock on a sent round, not a replacement for
   either of the two that already exist (the server renders its widgets
   disabled, and markRoundHistory disables them live) -- it is the one that
   reaches what neither of those can: the send bar, whose buttons live OUTSIDE
   every round section and would otherwise still submit the open round from a
   page that is not it. Same two-rule idiom body.readonly uses just above, for
   the same reason (QUIRKS.md "Readonly is locked twice"). */
body.sent-page .send-bar { display: none; }
body.sent-page input, body.sent-page textarea, body.sent-page button.card-choice { pointer-events: none; opacity: 0.7; }
/* ADR 101: and the whole-block comment control goes with them. commentArea
   (src/render.mjs) has always rendered a sent block's compose form disabled --
   'historical' -- so the button was already opening a form nothing could be
   typed into; this takes away the affordance rather than leaving one that lies.
   Hidden, not disabled, matching what the page board's own sent surface does
   (renderPageCommentPanel emits no compose form at all once the round is out)
   and matching the toggle above, which is display:none under this same class.
   The comment LIST is untouched, here as everywhere: what was already said
   stays readable on a settled round -- that is most of why anyone flips back to
   one. The rule keys on the block button alone, never '.comment-form', which
   the lens still hosts a copy of (.lens-form-host) for a round that is open. */
body.sent-page .comment-btn { display: none; }

.blocks { display: flex; flex-direction: column; gap: var(--space-6); }

/* a round is a session-scoped batch, and a PAGE of this board (ADR.md entry 42):
   every round is rendered, exactly one carries .round-current, and only that one
   is displayed. The history rail this replaced stacked a sent round above the
   open one -- which a round that fills the viewport cannot do -- so the earlier
   pages absorb its job and .round-pager (below) is how you reach them.

   display:none rather than an off-screen position: nothing here is measured
   while it is hidden (the pins and the pager's own caption are recomputed on
   every flip, src/ui.mjs's goToRound), and a hidden page must not add scroll
   height to the page that IS showing.

   scroll-margin-top clears the sticky .board-head when a flip scrolls the
   arriving page's top back under it. */
.round { display: none; flex-direction: column; gap: var(--space-4); scroll-margin-top: var(--head-clear); }
.round.round-current { display: flex; }
.round-label {
  align-self: flex-start;
  font-size: 10.5px; font-weight: 600; letter-spacing: 0.11em; text-transform: uppercase;
  color: var(--ink-2); background: var(--panel-2);
  border: 1px solid var(--hairline); border-radius: var(--r-pill); padding: 4px 12px;
}
/* a sent page reads as settled rather than live -- the surface half of "a page
   already sent is read-only" (entry 42), alongside body.sent-page below. What
   went with the rail is its GEOMETRY (the indent, the left border, the gap that
   separated two stacked rounds); two rounds are never on screen together now, so
   there is nothing left to separate or step back from. */
.round-history .round-label { color: var(--muted); background: transparent; }
.round-history .block { background: var(--history-bg); box-shadow: none; }
.round-history .md-content, .round-history .question-prompt { opacity: 0.86; }
/* the round's own bottom -- a divider with a tag
   naming the round and its question count, so reaching it is a visible event
   rather than the absence of one (the round's top already has .round-label;
   this is the twin at the other end). Open rounds only -- see
   renderRoundSection's own comment. */
.round-end { display: flex; align-items: center; gap: var(--space-2); padding: 4px 0 var(--space-2); }
.round-end .line { flex: 1; height: 1px; background: var(--hairline-2); }
.round-end .tag {
  font-size: 9.5px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted); padding: 0 var(--space-2);
}

.block {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--hairline);
  border-radius: var(--r-lg);
  padding: var(--space-4) var(--space-5) var(--space-5);
  box-shadow: var(--shadow-1);
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.round-open .block:hover { border-color: var(--hairline-2); box-shadow: var(--shadow-2); }
.block .block-kicker {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2);
  font-size: 10.5px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted); margin-bottom: var(--space-3);
}

.md-content h1, .md-content h2, .md-content h3, .md-content h4 { font-weight: 650; line-height: 1.3; letter-spacing: -0.01em; margin: 1.1em 0 0.5em; }
.md-content > :first-child { margin-top: 0; }
.md-content h1 { font-size: 19px; }
.md-content h2 { font-size: 16.5px; }
.md-content h3, .md-content h4 { font-size: 14px; }
.md-content p { margin: 0.65em 0; color: var(--ink-2); }
.md-content ul, .md-content ol { padding-left: 1.35em; color: var(--ink-2); }
.md-content li { margin: 0.3em 0; }
.md-content li::marker { color: var(--muted); }
.md-content strong { color: var(--ink); font-weight: 600; }
.md-content code { background: var(--panel-3); color: var(--code-ink); padding: 0.12em 0.4em; border-radius: var(--r-sm); font-size: 12.5px; }
.md-content pre { background: var(--panel-2); border: 1px solid var(--hairline); padding: 12px 14px;
  border-radius: var(--r-md); overflow-x: auto; font-size: 12.5px; line-height: 1.55; }
.md-content pre code { background: none; padding: 0; font-size: inherit; }

/* The markdown-fence language label -- src/render.mjs's highlightFenceHtml wraps a fence in this div
 * only when 'lang' names a grammar this build vendored, never for a lang-less or
 * unrecognised fence. 'position: relative' is the div's whole job: it gives the
 * '::before' below a containing block that is NOT '.md-content pre' itself, which
 * matters because that element is 'overflow-x: auto' and an absolutely
 * positioned box whose containing block scrolls right along with it --
 * this wrapper never scrolls, so the label stays pinned in the corner no matter
 * how far a long line has been dragged out of view underneath it.
 *
 * The label is 'content: attr(data-lang)', not a text node -- same technique as
 * '.code-row::before' above for the identical reason: generated content is never
 * part of the DOM a selection can walk, so this div cannot change what
 * '<pre><code>' itself contains or what copying it yields (AC 8's promise, which
 * this fence inherited unchanged from highlightFenceHtml's own contract).
 * 'user-select: none' is redundant with that, kept for the same belt-and-braces
 * reason '.code-row::before' keeps it. '--muted' on this div's own transparent
 * background reads through to '.md-content pre'/'--panel-2' beneath it, the same
 * pairing test/check-contrast.mjs already asserts at 4.5:1 for every body-text
 * token against every surface token -- no new token, no new contrast check. */
.fence-lang { position: relative; }
.fence-lang::before { content: attr(data-lang); position: absolute; top: 8px; right: 12px;
  font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--muted); user-select: none; pointer-events: none; }

.md-content blockquote { margin: 0.8em 0; padding: 2px 14px; border-left: 2px solid var(--accent);
  color: var(--ink-2); background: var(--accent-soft); border-radius: 0 var(--r-sm) var(--r-sm) 0; }
.md-content blockquote p { color: inherit; }
.md-content table { border-collapse: separate; border-spacing: 0; margin: 0.8em 0; width: 100%;
  border: 1px solid var(--hairline); border-radius: var(--r-md); overflow: hidden; }
.md-content th, .md-content td { border-bottom: 1px solid var(--hairline); padding: 7px 12px; font-size: 13px; text-align: left; }
.md-content th { background: var(--panel-2); color: var(--ink); font-weight: 600; font-size: 11.5px;
  letter-spacing: 0.05em; text-transform: uppercase; }
.md-content tr:last-child td { border-bottom: none; }
.md-content hr { border: none; border-top: 1px solid var(--hairline); margin: 1.4em 0; }

/* scroll-margin-top clears the sticky .board-head when the questions-left pill
   scrolls a block's own top on screen (src/ui.mjs's goToQuestion), the same
   reason .round carries it for a page flip -- see --head-clear in :root. */
.question-block { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
  column-gap: var(--space-5); row-gap: var(--space-3); align-items: start;
  scroll-margin-top: var(--head-clear); }
/* The note and the footer answer FOR the question, not for either column, so
   they take their own full-width rows under both -- a two-column question used
   to get a note box only as wide as its options while the context card beside
   it ended halfway up the card. Row spacing is the grid's own row-gap, so the
   widget's trailing margin (.options and friends carry one) is zeroed where it
   would double up against it. */
.question-block > .note-field, .question-block > .question-footer { grid-column: 1 / -1; }
.question-main > :last-child { margin-bottom: 0; }
/* ADR.md entry 26: a question carrying a rendered stage (anywhere in its
   options or its context, src/render.mjs's questionCarriesStage) never emits
   a '.question-context' card at all -- its context renders as prose inside
   '.question-main' instead (see '.question-context-prose' below). That is
   the whole full-width mechanism: this ONE selector already collapsed a
   context-free question to one column before this entry existed, and a
   stage-carrying question now qualifies the same way, with no separate
   modifier class. A stage-free question keeps a real '.question-context'
   card exactly as before and is unaffected. */
.question-block:not(:has(.question-context)) { grid-template-columns: minmax(0, 1fr); }
/* the send guard's ring around the first outstanding question once a click on
   Send has armed instead of submitted -- toggled
   client-side by src/ui.mjs's armSendGuard/disarmSend, never present in
   server-rendered markup. Overrides .block's own border/box-shadow (this
   section carries that class too); every other question keeps .block's
   plain hairline. */
.question-block.flagged { border-color: var(--warning-border-strong); box-shadow: 0 0 0 3px var(--warning-soft); }
.question-main { min-width: 0; }
.question-context { min-width: 0; background: var(--panel-2); border: 1px solid var(--hairline);
  border-radius: var(--r-md); padding: var(--space-3) var(--space-4); }
/* The prose counterpart to '.question-context' above, for a question that
   carries a rendered stage (ADR.md entry 26): no background, no border, no
   padding -- just vertical stacking between the prompt and the widget, each
   context item in reading order. Each item supplies its own typography
   ('.md-content', '.mermaid-block', '.html-stage') exactly as it would
   top-level; only '.compare-side''s own card look is stripped below, since a
   compare nested here is still "context", not a card. */
.question-context-prose { display: flex; flex-direction: column; gap: var(--space-3); margin: 0 0 var(--space-3); }
.context-item .compare-side { background: none; border: none; padding: 0; }
.question-prompt { font-size: 16px; font-weight: 600; line-height: 1.4; letter-spacing: -0.01em; margin: 0 0 var(--space-3); }

.options { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-3); }
.card-choice {
  display: block; width: 100%; min-height: 44px; text-align: left;
  background: var(--panel-2); border: 1px solid var(--hairline); color: var(--ink);
  border-radius: var(--r-md); padding: 11px 14px; font: inherit;
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.card-choice:hover:not(:disabled) { border-color: var(--hairline-2); background: var(--panel-3); }
.card-choice:active:not(:disabled) { transform: scale(0.995); }
.card-choice.selected { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
.card-choice:disabled { opacity: 0.75; }
.card-choice .opt-label { font-weight: 600; font-size: 13.5px; }
.card-choice .opt-desc { color: var(--muted); font-size: 12.5px; margin-top: 2px; line-height: 1.45; }
.card-choice.selected .opt-desc { color: var(--ink-2); }

.unsupported-widget { color: var(--warning); font-size: 12.5px; font-style: italic; }
.resolve-error { color: var(--critical); font-size: 12.5px; background: var(--critical-soft);
  border: 1px solid var(--critical-border); border-radius: var(--r-sm); padding: 8px 12px; margin: 0; }

/* multi-select: same card, plus a checkbox glyph */
.opt-check { position: relative; display: inline-block; flex: none; width: 16px; height: 16px; margin-right: 10px;
  margin-top: 1px; border: 1.5px solid var(--hairline-2); border-radius: 4px; background: var(--panel);
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease); }
.opt-check::after { content: ""; position: absolute; left: 4.5px; top: 1px; width: 4px; height: 8px;
  border: solid var(--accent-ink); border-width: 0 2px 2px 0; transform: rotate(45deg) scale(0);
  transition: transform var(--dur) var(--ease); }
.choice-multi.selected .opt-check { background: var(--accent); border-color: var(--accent); }
.choice-multi.selected .opt-check::after { transform: rotate(45deg) scale(1); }
.choice-multi { display: flex; align-items: flex-start; }
.opt-main { display: inline-flex; flex-direction: column; flex: 1; min-width: 0; }

.opt-preview { display: block; margin-top: var(--space-2); max-width: 100%; border-radius: var(--r-sm); }
.opt-preview-img { border: 1px solid var(--hairline); }
.opt-preview-code { background: var(--panel); border: 1px solid var(--hairline); padding: 8px 10px;
  font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }

/* choose-between-rendered-variants: each option wraps a fully
   rendered block instead of living inside a <button> -- an iframe cannot nest
   inside one. .options-variants overrides .options' single-column flex layout
   (source order after it decides the tie, same specificity) with a grid, since
   this widget's options are rendered content meant to sit side by side, not a
   list of short labels. .variant-card is the div src/ui.mjs wires by hand for
   the click + keyboard contract .card-choice gets from a real <button>;
   :focus-visible's outline (near the top of this file) already covers its
   keyboard-focus ring for free, the same way it does for every other
   focusable element here. */
.options-variants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-3); }
/* an 'html'-kind option (src/render.mjs's
   renderVariantOption stamps this modifier only on that kind -- see its own
   comment) spans every auto-fit column .options-variants currently has,
   whatever that count is at the page's current width, which is what makes
   this "one per row at full width" rather than a hardcoded column count.
   Every other option kind is untouched: no modifier class, so
   .options-variants' plain grid keeps deciding their layout exactly as it
   did before this existed. */
.variant-card--stage { grid-column: 1 / -1; }
.variant-card { display: block; cursor: pointer; background: var(--panel-2); border: 1px solid var(--hairline);
  border-radius: var(--r-md); padding: var(--space-3) var(--space-4);
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease); }
.variant-card:hover { border-color: var(--hairline-2); background: var(--panel-3); }
.variant-card.selected { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
/* the div equivalent of .card-choice:disabled above -- a plain <div> has no
   native disabled state, so src/render.mjs stamps this attribute instead (see
   renderVariantOption's own comment) and this is its only visual expression.
   An attribute selector, deliberately: QUIRKS.md's "every .class-name..." is
   the escape hatch for a state with no class of its own. */
.variant-card[aria-disabled="true"] { cursor: default; opacity: 0.75; }
.variant-label { margin-bottom: var(--space-2); }
.variant-label .opt-label { font-weight: 600; font-size: 13.5px; }
.variant-label .opt-desc { color: var(--muted); font-size: 12.5px; margin-top: 2px; line-height: 1.45; display: block; }
/* the nested block renders through the same renderBlock dispatch a compare
   side's own block does, and is stripped of its own card chrome for exactly
   the same reason .compare-side .block is (below): without this, an option
   would render as a card nested inside a card. */
.variant-card .block { border: none; background: none; padding: 0; box-shadow: none; }
.variant-card .block:hover { box-shadow: none; }
/* SECURITY, not polish: an
   option's rendered block is untrusted, agent-authored content -- exactly
   like any other block on the page, EXCEPT that here a click deciding which
   option gets picked is a decision only the reviewer may make. An 'html'
   option is a sandboxed iframe that can run the agent's own script
   (renderHtmlBlock), and that script can dispatch a click on itself with no
   human involved at all -- an autoplaying demo, an animation, a mock that
   clicks its own button, all ordinary content for /example's real mockups.
   'pointer-events: none' makes the iframe unreachable by any real pointer
   input, so a genuine, trusted click over the visible mock can never land
   inside it -- it falls through to the card underneath in the parent
   document instead (the same one a click on the option's label already
   selects), which is the ONLY thing that can ever record a pick. See
   ADR.md entry 78 for the two paths this closes and why guarding a message
   instead of deleting the channel would not have been enough. */
.choice-variant .html-stage { pointer-events: none; }
/* a SEPARATE rule from the one immediately
   above rather than folded into it -- that one is a trust boundary
   and stays exactly as written, on
   its own line, byte for byte. '.html-stage''s own floor (min-height: 320px,
   resize: vertical, further down this file) is for a STANDALONE stage only
   (a different chunk's territory); a variant option's stage
   overrides all three: 'min-height: 0' lifts the floor, 'resize: none' drops
   a drag handle 'pointer-events: none' already made ungrabbable here, and
   'overflow: hidden' is the clip -- deliberately with no added
   "there is more below" marker, the same fault this whole feature exists to
   fix, one level down; the expand control another chunk is landing is the
   way to the rest.
   'height: 320px' is NOT an arbitrary starting number -- it is the exact
   value of the floor this whole rule replaces. Measured in real Chrome
   (src/render.mjs's stageAgentScript, "WHEN this runs" comment above
   reportHeight): a stage's first accurate report is deferred (two nested
   requestAnimationFrame calls, waiting for this document's own first layout
   pass), and either that or ResizeObserver's own first delivery can in
   principle be late or, on a sufficiently old or unusual browser, never
   arrive at all. A lower placeholder here (200px shipped in an earlier cut
   of this rule, before that measurement) would leave a variant option's
   stage WORSE than the fixed floor it was meant to improve on for as long as
   -- or, in the never-arrives case, for as often as -- that gap lasts. 320px
   makes "no report yet" cost nothing next to today's behaviour; a real
   report still grows or shrinks the box exactly as intended the
   moment it lands. 'max-height' is a CSS backstop for the same cap
   handleStageHeight enforces in JS (STAGE_HEIGHT_CAP, src/ui.mjs) --
   hand-kept at the same value, the way QUIRKS.md's "Two stylesheets, one
   palette" already documents for this file's stage-side hex, since neither
   file can read a value out of the other; the clamp that actually matters
   against a hostile report is the one in JS, which runs before a value ever
   reaches this box's inline style. */
.choice-variant .html-stage { min-height: 0; height: 320px; max-height: 600px; resize: none; overflow: hidden; }

/* free text: a comfortable writing surface, not a cramped input */
.answer-textarea { width: 100%; min-height: 220px; resize: vertical; background: var(--panel-2);
  border: 1px solid var(--hairline); color: var(--ink); border-radius: var(--r-md); padding: 14px 16px;
  font: inherit; font-size: 14px; line-height: 1.65; margin-bottom: var(--space-3);
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.answer-textarea::placeholder, .note-field textarea::placeholder, .search-input::placeholder,
.comment-form input[type=text]::placeholder { color: var(--muted); }
.answer-textarea:focus, .note-field textarea:focus, .comment-form input[type=text]:focus, .search-input:focus {
  outline: none; border-color: var(--accent); box-shadow: var(--ring); }

.rank-list { list-style: none; margin: 0 0 var(--space-3); padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.rank-list li { display: flex; align-items: center; gap: var(--space-3); background: var(--panel-2);
  border: 1px solid var(--hairline); border-radius: var(--r-md); padding: 10px 14px; cursor: grab;
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease); }
.rank-list li:hover { border-color: var(--hairline-2); background: var(--panel-3); }
.rank-list li:active { cursor: grabbing; }
.rank-list li.dragging { opacity: 0.45; border-style: dashed; border-color: var(--accent); }
.rank-list .rank-index { color: var(--accent); font-size: 11.5px; font-weight: 650; font-variant-numeric: tabular-nums;
  width: 20px; height: 20px; line-height: 20px; text-align: center; border-radius: var(--r-sm); background: var(--accent-soft); }
.rank-list .rank-grip { color: var(--muted); display: inline-flex; }

.note-field { width: 100%; }
.note-field label { display: block; font-size: 10.5px; font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.1em; color: var(--muted); margin-bottom: var(--space-1); }
.note-field textarea { width: 100%; min-height: 56px; resize: vertical; background: var(--panel-2);
  border: 1px solid var(--hairline); color: var(--ink); border-radius: var(--r-md); padding: 9px 12px; font: inherit;
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }

.question-footer { display: flex; align-items: center; gap: var(--space-3); }
.btn-defer { background: var(--panel-2); border: 1px solid var(--hairline); color: var(--ink-2);
  font-size: 11.5px; font-weight: 550; border-radius: var(--r-pill); padding: 6px 14px;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease), background var(--dur) var(--ease); }
.btn-defer:hover:not(:disabled) { border-color: var(--warning); color: var(--ink); }
.btn-defer.active { background: var(--warning-soft); border-color: var(--warning-ring); color: var(--warning); }

/* the status line reads at a glance instead of being scanned as text: the dot is
   coloured from data-status, which src/render.mjs emits alongside the same word */
.answer-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--muted); }
.answer-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.answer-status[data-status="answered"] { color: var(--good); }
.answer-status[data-status="deferred"] { color: var(--warning); }

.comment-btn { display: inline-flex; align-items: center; gap: 5px; background: transparent;
  border: 1px solid var(--hairline); color: var(--muted); font-size: 10.5px; font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase; border-radius: var(--r-pill); padding: 4px 10px;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease), background var(--dur) var(--ease); }
.comment-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* the diagram's expand control. Same
   pill chrome as the comment button beside it in the kicker, written out rather
   than folded into the selector above so neither rule's exact text moves (several
   rules in this file are asserted by their text -- QUIRKS.md). It deliberately
   does NOT also carry .comment-btn: that class is what wireRoot binds the
   "open a block-level comment form" click handler to. */
.expand-btn { display: inline-flex; align-items: center; gap: 5px; background: transparent;
  border: 1px solid var(--hairline); color: var(--muted); font-size: 10.5px; font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase; border-radius: var(--r-pill); padding: 4px 10px;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease), background var(--dur) var(--ease); }
.expand-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }

.comment-list { margin-top: var(--space-3); display: flex; flex-direction: column; gap: var(--space-2); }
.comment-item { position: relative; font-size: 12.5px; color: var(--ink-2); background: var(--panel-2);
  border: 1px solid var(--hairline); border-left: 2px solid var(--hairline-2);
  border-radius: var(--r-sm); padding: 8px 12px;
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease); }
/* queued locally, not yet sent -- matches the hollow .pin-pending badge.
   Only a PENDING entry ever carries a delete control
   (a sent comment has none), so only it reserves gutter space
   for one. */
.comment-item.comment-pending { border-style: dashed; border-color: var(--accent); border-left-color: var(--accent); padding-right: 30px; }
.comment-item .comment-anchor { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; margin-right: 8px; }
.comment-item .comment-lost { color: var(--critical); }
/* the "x" on a queued comment's own
   list entry -- removes that one entry, its hollow pin, and (src/ui.mjs's
   refreshPendingCommentItems) renumbers whatever queued comments are left so
   the sequence stays contiguous. */
.comment-delete { position: absolute; top: 6px; right: 6px; background: transparent; border: none;
  color: var(--muted); font-size: 15px; line-height: 1; padding: 2px 6px; border-radius: var(--r-sm);
  cursor: pointer; }
.comment-delete:hover { color: var(--critical); background: var(--panel-3); }

/* "commenting on: <anchor>" -- src/render.mjs emits one per block, always, so it
   MUST be hidden until a comment is actually being composed on that block; without
   a rule it fell back to a visible block-level element and a six-block board
   showed six stray lines each claiming a comment was in progress. src/ui.mjs
   adds .open alongside the comment form's own .open, and removes it on submit. */
.comment-target { display: none; margin-top: var(--space-3); font-size: 10.5px; font-weight: 600;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); }
.comment-target.open { display: block; }

.comment-form { margin-top: var(--space-2); display: none; gap: var(--space-2); }
.comment-form.open { display: flex; }
.comment-form input[type=text] { flex: 1; min-width: 0; background: var(--panel-2); border: 1px solid var(--hairline);
  color: var(--ink); border-radius: var(--r-md); padding: 8px 12px; font: inherit;
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.comment-form button { background: var(--accent); color: var(--accent-ink); border: none; font-weight: 600;
  border-radius: var(--r-md); padding: 8px 16px; font: inherit; transition: filter var(--dur) var(--ease); }
.comment-form button:hover:not(:disabled) { filter: brightness(1.08); }
.comment-form button:disabled { opacity: 0.5; }

/* mermaid: client-rendered SVG from the daemon's own vendored copy; pre.mermaid holds raw source until then */
.mermaid-block pre.mermaid { background: none; border: none; overflow-x: auto; margin: 0; }
.mermaid-block pre.mermaid svg { max-width: 100%; height: auto; }
.mermaid-block .missing { color: var(--warning); font-size: 12.5px; }

/* code: a file plus a line range or section, no syntax highlighting. A reference
 * can run to hundreds of lines and previously had
 * no height cap at all, pushing everything below it off-screen -- capped at
 * ~480px (roughly 24 lines at this font-size/line-height) with overflow: auto
 * and resize: vertical. NOT quite the idiom '.html-stage' below uses, despite
 * the family resemblance and despite what the spec that ordered this said: that
 * one is FLOORED (min-height: 320px) and resizable, which is a different thing
 * from capped and resizable, and the difference is the whole reason the next
 * paragraph exists. A min-height leaves the resize handle free to move the box
 * in both directions; a max-height does not.
 * A short block's natural height never reaches the cap, so it renders untouched
 * (max-height only ever caps, never pads a shorter box out to it) -- but a
 * genuinely long one needs one more thing THIS rule alone cannot give it:
 * max-height clamps the box even against the explicit height its own resize
 * handle sets while dragging, so a capped block would otherwise be undraggable.
 * src/ui.mjs's unlockCodeCapForDrag converts the cap to a plain height, once,
 * the moment a block is confirmed to actually be capped -- see its own comment. */
.code-block pre { background: var(--panel-2); border: 1px solid var(--hairline); border-radius: var(--r-md);
  padding: 12px 14px; overflow: auto; margin: 0; max-height: 480px; resize: vertical; }
/* --code-base, not --code-ink: this is specifically a highlighted code block's
 * default/uncoloured-token text colour (see DARK's own palette comment for why
 * it is a distinct token from the one .md-content's inline code still uses). */
.code-block pre code { background: none; padding: 0; font-size: 12.5px; line-height: 1.55; color: var(--code-base); }

/* ADR.md entry 63: one row per source line, each carrying its
 * own real line number in a fixed left gutter column that is never a text node --
 * a 'data-line' attribute plus a "content: attr(...)" ::before, never a <span> of
 * literal digits. Generated content is not selectable and is never included in a
 * copy, so selecting/copying a code block yields exactly 'block.text' back: no
 * line numbers, and (renderCodeBlock wraps each row in its own .code-row,
 * splitting on the real newlines already in the text rather than inserting any)
 * no injected newlines either. 'user-select: none' is redundant with that --
 * generated content was never selectable to begin with -- kept anyway as the
 * belt to the CSS's own braces.
 *
 * A row is an INLINE box, and that is load-bearing rather than incidental.
 * src/render.mjs joins the rows on the newline bytes already in the text -- that
 * is the whole mechanism behind "no injected newlines" above -- and under this
 * 'white-space: pre' every one of those newlines is a real line break in its own
 * right. Making the row 'display: block' as well means each row breaks the line AND
 * the newline between two rows forms an anonymous block that breaks it again:
 * measured in Chrome against examples/sample-board.html at a 19.38px row box
 * against a 41.77px row-top-to-row-top delta (five rows occupying 186px instead of
 * 97px), and a live selection read back over a five-line block returning 8 newlines
 * against the 4 in its text. Every gutter-numbered block rendered AND copied
 * double-spaced, next to a single-spaced markdown fence for contrast -- and AC 8 is
 * a promise about what the BROWSER copies, which is why a suite asserting
 * textContent (a different string, correct throughout) never saw it.
 *
 * The two ways out are not symmetric: dropping those separators would keep the
 * block-level row but break textContent fidelity, i.e. trade a real bug for the
 * same bug somewhere less visible. So the separators stay and the row goes inline,
 * which leaves exactly one line-break mechanism in the block. The gutter column is
 * then reserved by the ::before being an in-flow inline-block of fixed width rather
 * than an absolutely positioned box over a padding-left -- absolute positioning
 * needs the row to be a containing block, which is what forced 'position: relative'
 * (and with it the block-level display) in the first place.
 *
 * One consequence worth naming rather than discovering: the .diff-add/.diff-del
 * fill below now paints the row's inline box -- its text -- instead of the full
 * width of the <pre>'s content box. On a line wider than the block that is the more
 * honest of the two, since a block-level row is clipped at the client width and its
 * tint stopped dead at the fold with the scrolled-right remainder unfilled
 * (measured on a long added line: 3076px of scrollable width against a filled row
 * box of 992px). */
.code-row::before { content: attr(data-line); display: inline-block; width: 2.75em;
  margin-right: 0.75em; text-align: right; color: var(--muted); user-select: none; }

/* The six-hue syntax palette (ADR.md entry 63): a class per coloured token type,
 * never an inline colour, so a theme change re-colours an already-rendered block
 * with no re-post (AC 6) -- src/render.mjs's TOKEN_CLASS table is what decides
 * which of Prism's own token types earns which class. */
.tok-keyword { color: var(--code-keyword); }
.tok-string { color: var(--code-string); }
.tok-function { color: var(--code-function); }
.tok-number { color: var(--code-number); }
.tok-comment { color: var(--code-comment); font-style: italic; }

/* ADR.md entry 64: inside a diff block the six-hue
 * palette above never applies at all -- src/render.mjs's TOKEN_CLASS has no diff
 * token mapped to a tok-* name, so a diff row carries none of the rules above by
 * construction, not by overriding them here. Syntax colour instead "drops to
 * --code-ink": .code-diff (src/render.mjs's codeBody, present on <code> only for a
 * lang: 'diff' block) overrides .code-block pre code's own --code-base default by
 * specificity alone, one more class on the same selector. */
.code-block pre code.code-diff { color: var(--code-ink); }
/* A diff's own structural lines -- '---'/'+++' file headers, '@@ ... @@' hunk
 * headers -- read like a comment (present, but not a line either file contains):
 * --muted italic, per ADR 64, rather than --code-comment (the six-hue comment
 * token this block's own colour system is suppressed). Reached through
 * TOKEN_CLASS's 'coord' entry, so this applies equally to a kind: 'code' diff
 * block and a markdown 'diff' fence -- both tokenize through the same
 * highlightRows/TOKEN_CLASS path (src/render.mjs). */
.diff-meta { color: var(--muted); font-style: italic; }
/* The conventional add/remove tint, alpha 0.12 against the code block's own
 * --panel-2 (src/styles.mjs's --diff-add-fill/--diff-del-fill comments have the
 * full account of why these are their own pre-composited tokens rather than a
 * CSS-level rgba(var(--good), 0.12)). Row-level, from src/render.mjs's
 * diffCodeBody -- an added/removed row's OWN .code-row carries the fill class,
 * never a wrapper around its tok-* spans, and (per the rule above) a diff row
 * never has any tok-* spans to wrap regardless. */
.code-row.diff-add { background: var(--diff-add-fill); }
.code-row.diff-del { background: var(--diff-del-fill); }
/* A markdown diff FENCE (src/render.mjs's highlightFenceHtml, gutter: false)
 * gets the same .diff-add/.diff-del fill above but never a gutter -- it has no
 * source.lines and never did (ADR.md entry 65), so it has no real line number to
 * reserve a column for. .diff-flat suppresses the ::before number cell so a fenced
 * diff's rows sit flush left, the same as every other fenced language, instead of
 * indented for a column nothing would ever fill in. 'content: none' (not '') is
 * what does it: it stops the pseudo-element being generated at all, so its
 * inline-block width goes with it -- there is no separate padding to strip now that
 * the gutter is in flow rather than absolutely positioned over one. */
.code-row.diff-flat::before { content: none; }

/* html stage: sandboxed iframe so a hand-mocked preview never leaks into the page */
.html-stage { display: block; width: 100%; min-height: 320px; resize: vertical; overflow: auto;
  border: 1px solid var(--hairline); border-radius: var(--r-md); background: var(--stage-bg); }

/* element-level anchoring: pin-layer overlays the html-stage iframe or
 * the rendered mermaid SVG exactly, and src/ui.mjs positions numbered .anchor-pin
 * badges inside it once the element they point at is resolvable in the live DOM. */
.stage-wrap { position: relative; }
.pin-layer { position: absolute; inset: 0; pointer-events: none; }
.anchor-pin { position: absolute; transform: translate(-50%, -50%); pointer-events: auto;
  min-width: 20px; height: 20px; padding: 0 5px; border-radius: var(--r-pill); background: var(--accent);
  color: var(--accent-ink); font-size: 11px; font-weight: 700; line-height: 20px; text-align: center;
  font-variant-numeric: tabular-nums; box-shadow: 0 0 0 2px var(--panel), var(--shadow-1); cursor: default; }
.anchor-pin.pin-lost { background: var(--critical); }
/* a comment queued but not yet sent: the pin appears the moment it is queued (the
   batching is the win), drawn hollow so it never reads as an already-sent one --
   src/ui.mjs mints its number provisionally, continuing the server's sequence */
.anchor-pin.pin-pending { background: var(--panel); color: var(--accent);
  border: 1px dashed var(--accent); line-height: 18px; }

/* Both stage kinds are clickable at element level, and both read as pictures with no
   built-in cue of their own -- the comment-mode toggle is the one thing on the page
   that says so now: the kicker's own per-stage hint was deleted for repeating once
   per variant option, in the place vertical space is scarcest, saying what the toggle
   was already visible chrome to say. A mermaid node highlights under the cursor (the
   html stage's equivalent is injected into the iframe's own document by src/ui.mjs,
   since this stylesheet deliberately does not reach inside it). Neither applies in a
   standalone file: archive, where nothing is clickable. One gesture,
   toggle-gated everywhere -- a diagram node is no longer a standing exception either,
   so both rules below also require body.comment-mode, the same class setCommentMode
   (src/ui.mjs) toggles for every other anchoring rule. */
body.comment-mode:not(.readonly) .mermaid-block svg g[id*="-flowchart-"],
body.comment-mode:not(.readonly) .mermaid-block svg g[id^="flowchart-"] { cursor: pointer; }
body.comment-mode:not(.readonly) .mermaid-block svg g[id*="-flowchart-"]:hover,
body.comment-mode:not(.readonly) .mermaid-block svg g[id^="flowchart-"]:hover { outline: 2px solid var(--accent); outline-offset: 3px; }
/* a node that already carries a SENT
   comment is no longer a comment target at all while comment mode is on --
   de-affordanced (not-allowed cursor, no hover outline) rather than marked
   permanently, riding this same body.comment-mode class rather than a
   standing state.
   .cb-anchor-sent is stamped onto the live SVG node by src/ui.mjs's
   wireMermaidBlock, from board.comments -- placed after the two rules above
   so its equal-specificity override wins by source order. */
body.comment-mode:not(.readonly) .mermaid-block svg g.cb-anchor-sent { cursor: not-allowed; }
body.comment-mode:not(.readonly) .mermaid-block svg g.cb-anchor-sent:hover { outline: none; }

/* --- the diagram lens ----------
   A full-viewport <dialog> src/ui.mjs builds once, lazily, and reuses: drag pans,
   scroll zooms, fit and 1:1 reset the view. Modelled on /explain's lens
   (~/.claude/skills/explain/template.html), with two differences that are the
   whole reason this one exists -- it is opened only by the explicit .expand-btn
   (never by clicking the diagram, which keeps its comment meaning), and its
   contents are commentable.

   Every value below is a token from :root, and so, now, is the mermaid diagram
   INSIDE the lens: this comment used to say those colours were 'hardcoded' in
   src/ui.mjs, which was true when this shipped and stopped being true when
   the light theme landed -- 'mermaidThemeVariables()' reads the live computed
   value of a CSS token per mermaid variable through MERMAID_TOKEN_MAP (QUIRKS.md
   "Two stylesheets, one palette", which records the same correction; only the
   html stage's own injected stylesheet still carries a literal hex, and it is
   one value for two stage surfaces because it clears 3:1 on both, not because
   the stage is one colour). The lens clones an already-rendered SVG, so it
   inherits whatever the ACTIVE palette produced and adds no colour of its own --
   but that also means a clone taken before a theme switch is stale, which is what
   src/ui.mjs's lensRetheme exists to fix. */
.diagram-lens { width: 100vw; height: 100vh; max-width: 100vw; max-height: 100vh;
  margin: 0; padding: 0; border: none; background: var(--bg); color: var(--ink); overflow: hidden; }
.diagram-lens[open] { display: flex; flex-direction: column; }
.diagram-lens::backdrop { background: var(--bg); }
.lens-bar { display: flex; align-items: center; gap: var(--space-2); flex: none;
  padding: var(--space-2) var(--space-4); border-bottom: 1px solid var(--hairline);
  background: var(--panel); font-size: 11px; }
.lens-title { letter-spacing: 0.12em; text-transform: uppercase; font-weight: 600;
  color: var(--accent); margin-right: auto; }
.lens-hint { color: var(--muted); font-style: italic; }
.lens-pct { color: var(--ink-2); min-width: 46px; text-align: right; font-variant-numeric: tabular-nums; }
.lens-btn { background: var(--panel-2); border: 1px solid var(--hairline); color: var(--ink-2);
  border-radius: var(--r-sm); padding: 4px 11px; font: inherit; font-size: 11px; font-weight: 600;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease); }
.lens-btn:hover { border-color: var(--accent); color: var(--accent); }
/* The block's OWN comment form is moved in here while the lens is open (src/ui.mjs
   lensAdopt) rather than duplicated -- "the same comment as one
   minted inline" is then true of the markup, not just of the anchor: one <form>,
   one submit handler, one pendingComments queue. Collapses to nothing while the
   form is closed, which is its state until a node is actually clicked. */
.lens-form-host { flex: none; padding: 0 var(--space-4); background: var(--panel); }
.lens-form-host .comment-form { margin: var(--space-2) 0; }
.lens-form-host .comment-target { margin-top: var(--space-2); }
.lens-stage { flex: 1; position: relative; overflow: hidden; cursor: grab;
  touch-action: none; user-select: none; }
.lens-stage.lens-dragging { cursor: grabbing; }
/* transform-origin at the top-left is what makes src/lens.mjs's view math mean
   what it says: a canvas-local point p renders at x + s * p, with no half-size
   correction anywhere. The pins live INSIDE this transform (src/ui.mjs's
   renderLensPins) and are counter-scaled per pin, so panning and zooming move
   them for free while each stays 20px on screen. */
.lens-canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
.lens-canvas svg { display: block; max-width: none; }
body.comment-mode:not(.readonly) .lens-canvas svg g[id*="-flowchart-"],
body.comment-mode:not(.readonly) .lens-canvas svg g[id^="flowchart-"] { cursor: pointer; }
body.comment-mode:not(.readonly) .lens-canvas svg g[id*="-flowchart-"]:hover,
body.comment-mode:not(.readonly) .lens-canvas svg g[id^="flowchart-"]:hover { outline: 2px solid var(--accent); outline-offset: 3px; }
/* the clone carries whatever .cb-anchor-sent stamps wireMermaidBlock put on the
   live diagram, so a node with a sent comment is de-affordanced in the lens for
   exactly the same reason and by exactly the same mechanism as it is inline */
body.comment-mode:not(.readonly) .lens-canvas svg g.cb-anchor-sent { cursor: not-allowed; }
body.comment-mode:not(.readonly) .lens-canvas svg g.cb-anchor-sent:hover { outline: none; }

/* --- the html-stage lens --------------------
   The second lens src/ui.mjs builds, wearing the first one's chrome ('.lens-bar',
   '.lens-title', '.lens-btn' above are shared verbatim) and none of its view
   maths: what it frames is a live iframe, which scrolls and lays itself out on
   its own, not a cloned SVG on a pannable canvas. Hence no cursor: grab, no
   touch-action and no user-select here -- every one of those would fight the
   mock's own pointer input, which is the whole point.

   Three things this layout is load-bearing for, none of them decoration:
   - 'min-height: 0' on the body. A flex child's default min-height is auto, i.e.
     'never smaller than my content' -- and an iframe's content is a whole
     document, so without this the body grows past the dialog and the frame
     scrolls the PAGE instead of scrolling itself.
   - the body's padding is the lens's clickable surround. src/ui.mjs closes on a
     click landing on the dialog or on this element (the backdrop
     half); a dialog that filled the viewport edge to edge with the frame would
     leave nothing outside the stage to aim at.
   - the frame is sized in CSS, not by the stage. An iframe's intrinsic size is
     300x150 regardless of what it holds, so 'a mock with its own scrollable
     content can be scrolled here' needs a real box given from this side. */
.stage-lens { width: 100vw; height: 100vh; max-width: 100vw; max-height: 100vh;
  margin: 0; padding: 0; border: none; background: var(--bg); color: var(--ink); overflow: hidden; }
.stage-lens[open] { display: flex; flex-direction: column; }
.stage-lens::backdrop { background: var(--bg); }
.stage-lens-body { flex: 1; min-height: 0; padding: var(--space-4); background: var(--bg); }
.stage-lens-frame { display: block; width: 100%; height: 100%; border: 1px solid var(--hairline);
  border-radius: var(--r-md); background: var(--stage-bg); }
/* the pick control's slot, between the title (which
   carries 'margin-right: auto') and close. Note what is NOT in the two rules
   above, and is load-bearing for this one: neither the body nor the frame is
   positioned or given a z-index, so the framed stage stays in normal flow BELOW
   this bar and a mock has no way to paint over the one control that records an
   answer. The stage is a cross-origin iframe -- it renders only inside its own
   box -- so "outside the frame" is a structural guarantee here rather than a
   stacking-order race. */
.lens-actions { display: inline-flex; align-items: center; gap: var(--space-2); }
/* the one control in either lens that RECORDS something, so it does not wear the
   same quiet chrome as 'close' beside it: accent-filled, the same visual weight
   .btn-primary gives Send. Disabled when the pick would be refused anyway (a
   historical round, comment mode -- src/ui.mjs's stageLensPick), which has to
   look unavailable rather than merely unresponsive. */
.lens-pick { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.lens-pick:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-ink); filter: brightness(1.08); }
.lens-pick:disabled { background: var(--panel-2); border-color: var(--hairline); color: var(--muted); cursor: not-allowed; }

/* the generic comment-mode hover outline ("before
   committing the reviewer can see exactly which element will be anchored"). Set
   from JS (src/ui.mjs) on the innermost element under the cursor, never via a
   :hover rule -- that would outline every ancestor in the chain at once. The
   iframe's own copy of this same outline (src/render.mjs's stageAgentScript) is a hardcoded hex
   injected into the sandboxed document's own <style>, since this stylesheet
   deliberately does not reach in there -- see QUIRKS.md "two stylesheets, one
   palette". */
.cb-anchor-hover { outline: 2px solid var(--accent); outline-offset: 2px; cursor: pointer; }
/* applied INSTEAD OF .cb-anchor-hover
   the moment an element already carries a sent comment -- no outline, and a
   cursor that says clicking here does nothing.

   Gated on body.comment-mode, and that gate is the whole rule rather than a
   tidy-up. The class reaches this selector two ways with opposite lifetimes:
   src/ui.mjs's mouseover listener adds it transiently, and only ever while
   comment mode is on (the listener returns immediately otherwise), so the gate
   costs that path nothing; but wireMermaidBlock and the .comment-btn wiring
   STAMP it permanently, at wire time, on a diagram node and on a heading's
   anchor button. Unscoped, those permanent stamps put a not-allowed cursor on
   the READING view of a settled board -- the state most pins are in -- which
   is exactly what the spec's Decision rules out: "de-affordanced in comment
   mode only ... the reading view stays unmarked". The two mermaid-specific
   rules above already carried this gate; this generic one did not. */
body.comment-mode .cb-anchor-sent { cursor: not-allowed; }
body.comment-mode .blocks { cursor: crosshair; }

/* compare: the side-by-side stage inherited from /example */
.compare-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
.compare-side { background: var(--panel-2); border: 1px solid var(--hairline); border-radius: var(--r-md); padding: var(--space-3) var(--space-4); }
.compare-label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted);
  margin-bottom: var(--space-2); }
.compare-side .block { border: none; background: none; padding: 0; box-shadow: none; }
.compare-side .block:hover { box-shadow: none; }

/* the action bar is the one thing that must never scroll away */
.send-bar { position: sticky; bottom: 0; z-index: 20; margin-top: var(--space-6); padding: var(--space-4) 0;
  background: linear-gradient(to top, var(--bg) 55%, var(--bg-fade-80) 85%, var(--bg-fade-0));
  backdrop-filter: blur(10px);
  display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3); }
/* the scrim's whole job is telling the reviewer content
   still runs on underneath the bar -- at the round's own end (.round-end on screen,
   src/ui.mjs's setupSendBarDock) that stops being true, so the scrim goes with it and
   the bar docks flush instead, a plain opaque panel.
   No hairline of its own, and that is the point of the condition: '.docked' means
   the closing rail is on screen, and that rail IS a full-width line, drawn a
   couple of rows above. A border here put a second horizontal rule under the
   first, so the foot of a fully scrolled round read as two dividers separating
   nothing. Undocked there is no rail on screen and no border either -- the
   gradient scrim above is what separates the bar from the content running on
   underneath it. */
.send-bar.docked { background: var(--bg); backdrop-filter: none; }
/* the questions-left pill: a
   live, additive count of the open round's still-unanswered questions, floating
   centered above the send bar. Nested INSIDE .send-bar itself rather than beside
   it -- position: absolute against the bar's own sticky positioning is exactly
   "floating over the content, centered above the send bar" with no separate
   fixed-position layer or z-index of its own to reason about, and it inherits
   body.readonly .send-bar { display: none } for free (QUIRKS.md "Readonly is
   locked twice" -- here nesting buys the second mechanism at no cost, rather than
   needing one hand-written). Hidden by default -- no .visible class -- rather than
   the 'hidden' attribute (QUIRKS.md "el.hidden does nothing when a class in our
   own stylesheet sets display"): .visible is the only rule that ever turns
   display on, so there is exactly one place deciding it, in src/ui.mjs's
   updateQuestionsLeftPill. Grey, not the send guard's warning amber -- amber
   stays "you got into a state", this is the ordinary unanswered-mid-round case. */
.questions-left-pill { display: none; position: absolute; left: 50%; bottom: 100%; transform: translateX(-50%);
  margin-bottom: var(--space-3); background: var(--panel-2); color: var(--ink-2);
  border: 1px solid var(--hairline); border-radius: var(--r-pill); padding: 7px 16px;
  font: inherit; font-size: 12.5px; font-weight: 600; box-shadow: var(--shadow-2);
  cursor: pointer; white-space: nowrap;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease); }
.questions-left-pill.visible { display: inline-flex; align-items: center; }
.questions-left-pill:hover:not(:disabled) { border-color: var(--hairline-2); color: var(--ink); }
/* AC 11 (second half): the ordinary board's own half of the waiting signal --
   the page-board pill's '.round-meta' above is the other half of the same
   rule. A muted, always-visible figure beside the send bar's own status text,
   never a second colour: the open round's countdown is informational, not a
   state the reviewer needs to act on. Hidden by default, the same
   single-decider idiom '.questions-left-pill'/'.back-to-top' already use --
   src/ui.mjs turns it on only while the open round is genuinely awaited. */
.round-countdown { display: none; color: var(--muted); font-size: 12.5px; align-self: center; white-space: nowrap; }
.round-countdown.visible { display: inline; }

/* the Notice (ADR.md entry 96): a transient message the blocked click itself
   raises with comment mode on -- an option card gets none of comment mode's
   own cues (no cursor, no hover outline), so the click a reviewer actually
   tries reads as a dead option with nothing on the page to say why. Floats
   centered above the send bar, same nested-and-absolute idiom
   '.questions-left-pill' already uses (and inherits that bar's
   'body.readonly .send-bar { display: none }' the same free way). It wears
   the board's own card chrome -- panel surface, hairline border -- rather
   than an inverted or accent-tinted one: this is the page speaking, not the
   system. Built once by src/ui.mjs and kept in the DOM, toggled by
   '.visible' alone like every other single-decider control here, so a
   dismissed Notice both fades AND drops out of the accessibility tree
   (visibility's own transition delay) rather than lingering as an invisible
   node a screen reader could still reach. */
.notice { position: absolute; left: 50%; bottom: 100%; transform: translateX(-50%);
  margin-bottom: var(--space-3); display: flex; align-items: center; gap: var(--space-3);
  overflow: hidden; background: var(--panel); color: var(--ink);
  border: 1px solid var(--hairline-2); border-radius: var(--r-md);
  padding: 11px 16px; box-shadow: var(--shadow-2);
  font: inherit; font-size: 13px; white-space: nowrap; z-index: 21;
  opacity: 0; visibility: hidden;
  transition: opacity var(--dur) var(--ease), visibility 0s linear var(--dur); }
.notice.visible { opacity: 1; visibility: visible; transition: opacity var(--dur) var(--ease); }
.notice-x { border: 0; background: transparent; color: var(--muted); font: inherit;
  font-size: 17px; line-height: 1; padding: 2px 4px; cursor: pointer; }
.notice-x:hover { color: var(--ink); }
/* the thin accent bar counting the 5s down -- restarted (src/ui.mjs) every
   time the dismiss timer is, independent of '.visible' so a second blocked
   click resets the bar along with the timer it tracks. 'forwards' holds the
   animation's END state (scaleX(0)) once it completes at 5s -- without it
   the bar snaps back to its 0%-keyframe start the instant the animation
   ends, which lands squarely inside the 160ms opacity fade (--dur) still
   running the Notice itself out, so the bar visibly REFILLS while the
   message is fading. */
.notice-bar { position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; background: var(--accent); transform-origin: left; }
.notice-bar.notice-bar-run { animation: notice-countdown 5s linear forwards; }
@keyframes notice-countdown { from { transform: scaleX(1); } to { transform: scaleX(0); } }

/* the Notice and the questions-left pill float in the EXACT same spot above
   the send bar (both 'position: absolute; left: 50%; bottom: 100%;
   translateX(-50%)', by design -- the Notice wears the pill's own placement
   idiom). A raised Notice sits above the pill in paint order (it is
   appended after it, and layered above it via 'z-index'), so with unanswered
   questions and the round-pager rail off screen -- exactly when the pill is
   showing its own "N questions left" text -- a Notice would otherwise cover
   it for the whole 5s. The pill yields instead: '.notice-open' on their
   shared host ('.send-bar', toggled alongside '.notice.visible', src/ui.mjs)
   hides it for exactly as long as the Notice is up, and it reappears (in
   whatever state 'updateQuestionsLeftPill' actually left it) the moment the
   Notice is dismissed or fades. Never a sibling combinator ('~'/'+') to
   reach the pill from the Notice's own position in the markup -- QUIRKS.md:
   the DOM stand-in's selector engine (and its cascade resolver, used to
   compute effective styles in test/check-pure.mjs) support neither one, so
   a rule built on either would be real-browser-only and unable to be pinned
   here the way every other rule in this file is. */
.send-bar.notice-open .questions-left-pill { display: none; }

/* --- the round pager: the board's pages, always both controls (ADR.md entry 42,
   criterion 26) ---------------------------------------------------------------

   Two positions, per the spec's decision: the pill sits bottom-centre and the
   chevrons at the two edges. Both are position: fixed and both are siblings in
   the markup rather than dock-wraps-chevrons -- the dock's own centring
   transform would otherwise make it the containing block for anything fixed
   inside it, pinning the chevrons to the dock instead of the viewport.

   Never hidden, on any page: the pager is how a page board's reader reaches the
   question round and how a question round's reader gets back to the artifact, so
   unlike the send bar it survives body.page-board, body.sent-page and
   body.readonly (an archive's rounds are pages too). Above the send bar's own
   z-index, since on an ordinary round it sits in the bar's otherwise empty
   left/centre -- the bar's contents are right-aligned. */
/* The dock is the fixed, centred box, not the pill inside it: the caption sits
   above the numerals and shares their centre line, and stacking both in one
   fixed column is what holds that with no measured offset between them.

   Its real rendered height also drives '--round-pager-dock-h' (see
   '.page-comments' below, ADR.md entry 42): setupPagerDockHeightTracking
   (src/ui.mjs) measures this box with a ResizeObserver and writes that
   custom property, so a panel that has to clear the dock reads its actual
   height off the browser's own layout -- whatever that ends up being, one
   row, two rows, or a third row nobody has drawn yet -- instead of a number
   someone typed once and nobody re-measured.

   CSS anchor positioning ('anchor-name'/'anchor()') was tried here first and
   reverted: '.page-comments' doesn't precede this dock in tree order (it's
   nested inside the page board's own '.block.html-block', src/render.mjs),
   and moving it earlier hit a second, separate containing-block problem --
   confirmed in a real Chrome, not the DOM stand-in (QUIRKS.md, "The stand-in
   has no layout"), since getComputedStyle().bottom simply came back 'auto'. */
.round-pager-dock { position: fixed; z-index: 40; left: 50%; bottom: var(--space-4); transform: translateX(-50%);
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  max-width: min(560px, calc(100vw - 2 * var(--space-6))); }
/* ...and the floor the dock floats over has to end above it. On an open round
   the send bar holds that space: it is sticky, it is the last thing in the
   shell, and the dock sits in the bar's own empty left/centre (see the dock
   comment above). A round that has been sent hides that bar -- so the last
   question's own controls ran on to the very bottom of the document and the
   dock's caption printed straight over them.
   The reservation is the same expression '.page-comments' clears the dock with,
   token for token, so the two can never drift: the dock's own bottom offset,
   plus its measured height, plus a gap.
   An archive needs it for a wider reason than a sent round does: body.readonly
   hides the send bar on EVERY archive, open round or not, so the floor is
   missing there whether or not the last round was ever sent -- which is why
   'body.readonly' reserves on its own and not only in company with
   'body.sent-page'. It reserves unconditionally because the dock renders
   unconditionally: renderRoundPager (src/render.mjs) prints it for a
   one-round board too, so there is no archive whose last block is not under
   it. '.board-shell''s own comment near the top of this file used to pin an
   archive flush ("one rule answers both endings"); that promise was written
   before this dock existed and is rewritten with this rule, in that comment
   and in test/check-round-end.mjs together.
   One board stays carved out, and the carve-out is load-bearing:
   'body.page-board' has a fixed 100vh frame that must not grow, and its
   Tray already clears the dock by this exact formula. */
body.sent-page:not(.page-board) .board-shell,
body.readonly:not(.page-board) .board-shell {
  padding-bottom: calc(var(--space-4) + var(--round-pager-dock-h, 84px) + var(--space-3)); }
/* The one place the pager still spends a title: the round the reviewer is
   actually on, named in full. Ellipsed rather than wrapped, so an agent-supplied
   title of any length costs one line and never reflows the dock. */
.round-pager-caption { max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  font-size: 11.5px; font-weight: 600; color: var(--muted); pointer-events: none; }
.round-pager { display: flex; align-items: center; gap: 2px; max-width: 100%;
  overflow-x: auto; background: var(--panel-2); border: 1px solid var(--hairline);
  border-radius: var(--r-pill); padding: 4px; box-shadow: var(--shadow-2); }
/* "Rounds" said once, over the row, instead of on every entry. */
.round-pager-lede { flex: none; padding: 5px 10px 5px 12px; font-size: 10px; font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
.round-page { background: none; border: none; border-radius: var(--r-pill); padding: 5px 11px;
  font: inherit; font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer;
  min-width: 30px; text-align: center; white-space: nowrap;
  transition: color var(--dur) var(--ease); }
.round-page:hover { color: var(--ink); }
.round-page-current { background: var(--panel); color: var(--ink); box-shadow: var(--shadow-1); }
/* the dot on the round that still owes an answer. Its hue is the one place
   "waiting on you" is not one colour across both themes -- see '--owed-dot'
   (ADR.md entry 66) for which each takes and why. */
.round-page-owed::after { content: ''; display: inline-block; width: 6px; height: 6px; margin-left: 6px;
  border-radius: 50%; background: var(--owed-dot); vertical-align: middle; }
.round-flip { position: fixed; z-index: 40; top: 50%; transform: translateY(-50%);
  width: 32px; height: 64px; display: flex; align-items: center; justify-content: center;
  background: var(--panel-2); color: var(--ink-2); border: 1px solid var(--hairline);
  font: inherit; font-size: 20px; line-height: 1; cursor: pointer; box-shadow: var(--shadow-2);
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease); }
.round-flip:hover:not(:disabled) { color: var(--ink); border-color: var(--hairline-2); }
/* the ends of the board are dead ends, shown rather than hidden: a control that
   vanishes at the first page is one the reviewer has to find again on the next.
   (No digit after the word "round" anywhere in this stylesheet -- the index page
   embeds it, and test/check-pure.mjs reads the whole page when it checks that a
   row's round segment is a count and never an ordinal.) */
.round-flip:disabled { opacity: 0.25; cursor: default; }
.round-flip-prev { left: 0; border-left: none; border-radius: 0 var(--r-md) var(--r-md) 0; }
.round-flip-next { right: 0; border-right: none; border-radius: var(--r-md) 0 0 var(--r-md); }
.btn-send { background: var(--accent); color: var(--accent-ink); border: 1px solid transparent; border-radius: var(--r-md);
  padding: 11px 24px; font: inherit; font-size: 13.5px; font-weight: 650; box-shadow: var(--shadow-2);
  transition: filter var(--dur) var(--ease), transform var(--dur) var(--ease); }
.btn-send:hover:not(:disabled) { filter: brightness(1.08); }
.btn-send:active:not(:disabled) { transform: translateY(1px); }
/* the send guard's armed state: Send wears this
   only while armSendGuard has it armed because questions are still
   outstanding -- never for the plain Cmd+Enter arm at the end of a fully
   traversed round, which keeps the ordinary accent color and its own label
   (test/check-enter.mjs criterion 3 pins that one unchanged). */
.btn-send.warn { background: var(--warning-soft); color: var(--warning-ink); border-color: var(--warning-border-strong); box-shadow: none; }
.btn-send.warn:hover:not(:disabled) { filter: none; }
/* the second way out: returns the
   call now with whatever is filled in. Secondary weight -- Send stays the primary
   action -- but it sits in the same bar, so body.readonly hides both together. */
.btn-discuss { background: var(--panel-2); color: var(--ink-2); border: 1px solid var(--hairline);
  border-radius: var(--r-md); padding: 11px 20px; font: inherit; font-size: 13.5px; font-weight: 600;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease), background var(--dur) var(--ease); }
.btn-discuss:hover:not(:disabled) { border-color: var(--hairline-2); background: var(--panel-3); color: var(--ink); }
.btn-send:disabled, .btn-discuss:disabled { opacity: 0.5; box-shadow: none; }
.send-status { color: var(--muted); font-size: 12.5px; align-self: center; margin-right: auto; }

/* thread index (src/indexpage.mjs) — same tokens, its own layout */
.index-shell { position: relative; z-index: 1; max-width: 900px; margin: 0 auto; padding: var(--space-6) var(--space-5) 96px; }
/* align-items: center, not flex-start. The lockup used to be two stacked lines
   (title over a subtitle) and the actions row a single line, so top-aligning
   them was the only thing that put the controls level with the TITLE rather
   than floating in the middle of a taller block. With the subtitle gone both
   sides are one line each, and flex-start left the controls visibly riding
   above the title's optical centre. */
.index-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
  margin-bottom: var(--space-5); padding-bottom: var(--space-4); border-bottom: 1px solid var(--hairline); }
/* The mark leads the index title rather than taking a control over: this page has
   no back control to absorb it, and it is the one page whose h1 IS the product
   name, so the two belong in a single lockup. */
.index-head-titles { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
.index-head-titles > svg { flex: none; }
/* the gap separates the pomodoro widget from the theme toggle beside it */
.index-head-actions { flex: none; display: flex; align-items: center; gap: var(--space-3); }
/* 30px, up from 22px: the subtitle it used to sit above is gone, and the title
   takes that vertical space back rather than leaving the row short. Sized to
   fill the 36px mark's height without exceeding it, and margin: 0 with a
   line-height of 1 so the lockup's own centring has nothing to fight. */
.index-head h1 { font-size: 30px; line-height: 1; margin: 0; font-weight: 650; letter-spacing: -0.02em;
  min-width: 0; }

/* the pomodoro widget: src/pomodoro-widget.mjs
   is the markup, src/indexpage.mjs's indexScript is the behaviour -- see that
   module's own header comment for the split, the same one theme.mjs draws
   between themeToggle() and themeBootScript. */
.pomodoro-widget { display: flex; align-items: center; gap: var(--space-2); position: relative; }
/* the tomato stands in for the word "Pomodoro" -- same muted weight the status
   text beside it carries, so the pair reads as one label, not an icon plus a
   sentence */
.pomodoro-icon { color: var(--muted); flex: none; }
/* Invisible to the flex box -- src/pomodoro-widget.mjs
   wraps TOMATO_ICON/REST_ICON in this span so indexScript's renderPomodoro has a
   stable element to swap the glyph's MARKUP into (never the 'hidden' property,
   which .pomodoro-icon's own author 'display' rule already defeats -- see that
   rule's own history). 'display: contents' drops the wrapper out of the box tree
   entirely, so the glyph inside it still lays out as a direct child of
   .pomodoro-widget, gap and alignment unchanged from having no wrapper at all. */
.pomodoro-icon-slot { display: contents; }
/* Work only: a running, unpaused work interval turns the tomato
   up to the product's own amber, off the same --warning token the tab mark and
   the "waiting on you" surfaces already spend. Idle and paused stay at
   .pomodoro-icon's plain muted weight -- no class here at all -- because a
   break dropping to muted only reads as "turned down" if the running state was
   turned up, and idle was never turned up to begin with (spec decision).
   Two classes, not a lone modifier, so this can never outrank .pomodoro-icon
   itself on specificity regardless of declaration order. */
.pomodoro-icon.pomodoro-icon-amber { color: var(--warning); }
/* user-select: none on the status text alone -- the rest of the widget is
   buttons, which never need it, and the settings panel's inputs and labels sit
   outside this element entirely and stay selectable. Denies a double-click or a
   drag across a string that repaints every second from ever landing a selection
   on it. */
.pomodoro-status { font-size: 11.5px; color: var(--ink-2); font-variant-numeric: tabular-nums; white-space: nowrap;
  user-select: none; }
/* Break/long break only: the status text drops to the SAME muted
   weight the rest glyph sits at, so the pair reads as one quiet state rather
   than a glyph that went quiet beside text that didn't. Two classes, same
   specificity reasoning as .pomodoro-icon-amber above. */
.pomodoro-status.pomodoro-status-rest { color: var(--muted); }

/* The start/pause/resume control: a real switch, knob left for off, knob right
   for on -- NOT the .mode-toggle pill it used to borrow. Two reasons it could
   not stay that pill: it carried no state a reader could see except the word on
   it, and it tried to hide itself with the 'hidden' property against
   .mode-toggle's own 'display: inline-flex', which is an author rule and so
   beats the UA sheet's '[hidden] { display: none }' outright -- it never hid,
   and showed as an empty pill with nothing to act on. This control is never
   hidden; when idle it starts a pomodoro.

   40x22 is deliberately under the 44px touch minimum, matching every other
   control in this header (.mode-toggle is ~28px tall): this page is a local
   desktop dashboard driven by a mouse, and sizing one control for touch while
   its neighbours stay small would look broken without helping anyone. If the
   header ever gets a real narrow-viewport treatment, that is where the 44px
   target belongs, applied to the whole row at once. */
.pomodoro-switch { display: inline-flex; align-items: center; justify-content: flex-start;
  width: 40px; height: 22px; flex: none; padding: 2px; box-sizing: border-box;
  background: var(--panel-2); border: 1px solid var(--hairline); border-radius: var(--r-pill);
  cursor: pointer;
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease); }
.pomodoro-switch:hover { border-color: var(--hairline-2); }
.pomodoro-switch-knob { width: 16px; height: 16px; border-radius: 50%; background: var(--muted);
  /* transform, never 'margin-left'/'left' -- the animation rules
     name animating layout properties as the anti-pattern; a transform stays off
     the layout path entirely. */
  transform: translateX(0);
  transition: transform var(--dur) var(--ease), background var(--dur) var(--ease); }
.pomodoro-switch[aria-checked="true"] { background: var(--accent-soft); border-color: var(--accent); }
/* 18px = the track's inner width (40 - 2*2 padding - 2*1 border = 34) minus the
   16px knob. Knob flush left when off, flush right when on. */
.pomodoro-switch[aria-checked="true"] .pomodoro-switch-knob { background: var(--accent); transform: translateX(18px); }
@media (prefers-reduced-motion: reduce) {
  .pomodoro-switch-knob { transition: none; }
}

/* Restart/Forward: one
   segmented pill between the status text and the switch, round two's picked
   variant -- panel background, hairline
   border, height matched to the 22px switch beside it so the row stays one
   visual line. overflow: hidden is what lets the two buttons' square corners
   sit flush against the pill's own rounded ends. */
.pomodoro-ctl-group { display: inline-flex; align-items: stretch; height: 22px; box-sizing: border-box;
  background: var(--panel-2); border: 1px solid var(--hairline); border-radius: var(--r-pill); overflow: hidden; }
/* Icon-only, no background/border of its own -- the group above carries both,
   and a second border here would double the hairline at the pill's own edge.
   Hover only brightens the icon (background stays the group's own panel-2 --
   a hover background here would be a no-op on top of it). */
.pomodoro-ctl { background: none; border: none; display: inline-flex; align-items: center;
  justify-content: center; width: 24px; color: var(--ink-2);
  transition: color var(--dur) var(--ease); }
.pomodoro-ctl:hover { color: var(--ink); }
/* The hairline divider between the two controls -- an adjacent-sibling border,
   not a third element, so there is nothing between them a screen reader could
   stumble on. */
.pomodoro-ctl + .pomodoro-ctl { border-left: 1px solid var(--hairline); }

/* The settings control is the cogwheel, not the words "Pomodoro settings":
   the header row is a row of controls, and a text link among them read as
   prose. Icon-only, so it carries an aria-label and a title (accessibility
   priority 1: an icon-only button without a label is the named
   anti-pattern) -- see src/pomodoro-widget.mjs for both.
   'display: flex' is what removes the native disclosure triangle in Firefox;
   'list-style: none' and the ::-webkit-details-marker rule cover the rest. */
.pomodoro-settings-summary { list-style: none; cursor: pointer; color: var(--muted);
  display: flex; align-items: center; justify-content: center; width: 26px; height: 26px;
  border-radius: var(--r-sm); transition: color var(--dur) var(--ease), background var(--dur) var(--ease); }
.pomodoro-settings-summary::-webkit-details-marker { display: none; }
.pomodoro-settings-summary::marker { content: ''; }
.pomodoro-settings-summary:hover { color: var(--ink); background: var(--panel-2); }
.pomodoro-settings[open] .pomodoro-settings-summary { color: var(--ink); background: var(--panel-2); }
/* a native <details>/<summary> needs no JS to open or close -- the spec's own
   "lazy correct answer" for a settings panel collapsed by default. Popover
   positioning, not inline: opening it must not shove the countdown/theme
   controls sideways in the same header row. */
.pomodoro-settings-form { position: absolute; right: 0; top: 100%; margin-top: 6px; z-index: 5;
  display: flex; flex-direction: column; gap: var(--space-2); min-width: 220px;
  background: var(--panel); border: 1px solid var(--hairline); border-radius: var(--r-md);
  padding: var(--space-3); box-shadow: var(--shadow-2); }
.pomodoro-field { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2);
  font-size: 11.5px; color: var(--ink-2); }
.pomodoro-field-check { justify-content: flex-start; }
.pomodoro-field input[type="number"] { width: 60px; background: var(--panel-2); border: 1px solid var(--hairline);
  color: var(--ink); border-radius: var(--r-sm); padding: 4px 6px; font: inherit; }
/* The three cue pickers -- same field row, a <select> instead
   of a number input. Wider than the duration inputs above: a cue's value
   ("Submarine", "None") needs more than 60px, and unlike a duration there is
   no natural max-width to hold it to, so this caps rather than fixes it. */
.pomodoro-field select { max-width: 120px; background: var(--panel-2); border: 1px solid var(--hairline);
  color: var(--ink); border-radius: var(--r-sm); padding: 4px 6px; font: inherit; }
/* Each section's own hairline + caption -- no fold, no tab, everything the
   panel can do is visible the moment it opens (the spec's own placement
   decision). Caption styled like .note-field label's own small-caps treatment,
   not a second, competing type scale. Four sections now, not one plus Cues:
   Pomodoro, Banners, Cues, Store (ADR 71). The first carries no hairline above
   it -- there is nothing above it to separate it from. */
.pomodoro-settings-divider { border: none; border-top: 1px solid var(--hairline); margin: 2px 0; }
.pomodoro-settings-caption { font-size: 10.5px; font-weight: 600; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--muted); }
.pomodoro-settings-actions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
.pomodoro-btn { background: var(--panel-2); border: 1px solid var(--hairline); color: var(--ink-2);
  font-size: 11px; font-weight: 600; border-radius: var(--r-pill); padding: 6px 12px; font: inherit;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease); }
.pomodoro-btn:hover:not(:disabled) { border-color: var(--hairline-2); color: var(--ink); }
/* Save is the panel's one primary action, so it wears the accent the way
   .search-btn and .btn-send already do, rather than sitting at the same visual
   weight as Reset beside it. Same filter: brightness(1.08) hover those two use,
   so all three primaries behave identically. */
.pomodoro-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 650; }
.pomodoro-btn-primary:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-ink); filter: brightness(1.08); }
/* the reset button's armed ("Really reset?") state -- indexScript toggles this
   class alongside the label swap, on the same element the two-step confirm
   already relabels, never a second control. */
.pomodoro-reset-btn.armed { border-color: var(--critical); color: var(--critical); }
/* The Store section's Delete: the same critical treatment .pomodoro-reset-btn
   only reaches once ARMED, worn permanently instead -- this control deletes on
   one click and has no armed state to save it for (ADR 71). The colour is what
   carries the warning the second click used to. */
.pomodoro-btn-danger { border-color: var(--critical); color: var(--critical); }
.pomodoro-btn-danger:hover:not(:disabled) { border-color: var(--critical); color: var(--critical); filter: brightness(1.12); }
/* Where the prune says what it did -- or, when no window was named, that it
   refused. Muted and small: it sits beside a button in the actions row, and
   it is empty until something has happened. */
.pomodoro-settings-status { font-size: 11px; color: var(--muted); align-self: center; }

.search-form { display: flex; gap: var(--space-2); margin: 0 0 var(--space-5); }
.search-input { flex: 1; min-width: 0; background: var(--panel-2); border: 1px solid var(--hairline); color: var(--ink);
  border-radius: var(--r-md); padding: 10px 14px; font: inherit;
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.search-btn { background: var(--accent); color: var(--accent-ink); border: none; border-radius: var(--r-md);
  padding: 10px 20px; font: inherit; font-weight: 650; transition: filter var(--dur) var(--ease); }
.search-btn:hover { filter: brightness(1.08); }

/* No '.search-results' / '.result-*' rules any more: the box filters the thread
   list in place (src/indexpage.mjs filterThreads) instead of rendering a second
   set of block-level result cards beneath it, so the rows a query produces are
   .thread-item rows and are already styled below. */

.empty-state { color: var(--muted); font-size: 13px; background: var(--panel); border: 1px dashed var(--hairline);
  border-radius: var(--r-md); padding: var(--space-5); text-align: center; }

.thread-list { display: flex; flex-direction: column; gap: var(--space-3); }
.thread-item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
  background: var(--panel); border: 1px solid var(--hairline); border-radius: var(--r-lg);
  padding: var(--space-4) var(--space-5); text-decoration: none; color: var(--ink); box-shadow: var(--shadow-1);
  transition: border-color var(--dur) var(--ease), transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.thread-item:hover { border-color: var(--hairline-2); transform: translateY(-1px); box-shadow: var(--shadow-2); }
/* a thread with an open round is the one thing on this page asking for something */
.thread-item.live { border-color: var(--warning-line); background:
  linear-gradient(to right, var(--warning-soft), transparent 45%), var(--panel); }
.thread-item.live:hover { border-color: var(--warning); }
.thread-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
/* headline: the board title (or, title-less, the project folder name) — was .thread-cwd,
   which held the full path as the bold headline; the path is now demoted to .thread-path */
.thread-title { display: flex; align-items: center; gap: var(--space-2); font-size: 14.5px; font-weight: 600; letter-spacing: -0.01em; }
.thread-path { color: var(--muted); font-size: 12.5px; }
.thread-meta { color: var(--muted); font-size: 12px; }
.thread-status { display: flex; align-items: center; gap: var(--space-3); flex: none; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warning); display: inline-block;
  box-shadow: 0 0 0 0 var(--warning-ring); animation: cb-pulse 2.4s var(--ease) infinite; }
@keyframes cb-pulse {
  70% { box-shadow: 0 0 0 7px var(--warning-fade); }
  100% { box-shadow: 0 0 0 0 var(--warning-fade); }
}
/* Only ever rendered when count > 0 -- there is no zero state
   to style, so the amber "waiting on you" treatment is the whole rule. */
.rounds-left-badge { font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums;
  color: var(--warning); background: var(--warning-soft); border: 1px solid var(--warning-border-strong);
  border-radius: var(--r-pill); padding: 4px 12px; }

/* --- the page board: one rendered artifact, filling the viewport -------------
   A board whose blocks are one html block and nothing else (src/render.mjs's
   isPageBoard -- ADR.md entry 33, inferred from the board's shape, never
   declared) renders the artifact edge to edge instead of in a porthole. Nothing
   here is a new component: every rule below overrides a value the ordinary board
   already sets, on one class src/render.mjs puts on <body> and src/ui.mjs takes
   off again the moment a live round makes this an ordinary board.

   Two geometry decisions carry the whole layout:

   - the header FLOATS (position: fixed) rather than sticking. A sticky header is
     in flow, so it would push a 100vh frame down by its own height and put the
     artifact's last 80px below the fold with nothing able to scroll to it. Fixed
     takes it out of flow, so the frame starts at the top of the viewport and the
     document's height is exactly the frame's (ADR.md entry 40, whose condensing
     behaviour lands on top of this).
   - the frame is a CONSTANT 100vh and scrolls its own content; the page itself
     does not scroll at all. Not min-height, not a height
     grown from what the artifact reports: the rendered templates use
     position: sticky and their own full-viewport <dialog>, and both of those
     mean something only against a viewport-sized box. 'resize: none' drops the
     drag handle for the same reason -- a frame the reviewer can resize is a
     frame whose height changes while it is read.

   The comment surface is what has to move (.page-comments, emitted only by the
   page-board branch of renderHtmlBlock): a form under a 100vh frame is a form
   below the fold on a page that cannot scroll, so it floats over the artifact
   instead, bottom centre. Its panel only appears when it holds something -- the
   form is display: none until a click inside the stage opens it, and a page
   board's comment list is empty until a later round's submit -- so an empty
   surround is never painted over the artifact. */
body.page-board { overflow: hidden; }
/* The artifact keeps the whole viewport, and the header floats over it -- ADR.md
   entry 40's overlay, unchanged. A band reserved at the top instead (tried, and
   reverted at the reviewer's call) fixed the overlap by charging the artifact
   for it permanently: 80px of every page, at every scroll position, spent on
   chrome that is only 40px tall and only in the way at the very top. The air
   the pill needs is air ABOVE the pill (its margin-top, further down), not a
   strip taken out of somebody's rendered page. */
body.page-board .board-shell { max-width: none; padding: 0; }
body.page-board .board-head { position: fixed; top: 0; left: 0; right: 0;
  margin-bottom: 0; padding: var(--space-3) var(--space-5); }
body.page-board .blocks { gap: 0; }
body.page-board .round { gap: 0; }
/* no card: the artifact is the surface, so the block's panel, hairline, radius,
   padding and hover lift all go (the hover selector is repeated rather than
   trusted to inherit -- '.round-open .block:hover' outranks a plain
   'body.page-board .block'). */
body.page-board .block, body.page-board .block:hover {
  background: none; border: none; border-radius: 0; padding: 0; box-shadow: none; }
body.page-board .html-stage { height: 100vh; min-height: 0; border: none; border-radius: 0; resize: none; }
/* ADR.md entry 35: a page board is not sendable. Same idiom, same guarantee as
   'body.readonly .send-bar' above -- the bar stays in the markup because a round
   arriving over SSE turns this board into an ordinary one in place (src/ui.mjs's
   applyRoundPush drops the class), and a queued comment needs it to leave on. */
body.page-board .send-bar { display: none; }
/* the archive's banner is in flow above the header, which would push the frame
   off the bottom of a viewport that cannot scroll -- floated into the corner the
   Tray and the header both leave free. */
body.page-board .readonly-banner { position: fixed; left: var(--space-5); bottom: var(--space-5);
  z-index: 30; margin-bottom: 0; max-width: 320px; }
/* Raised clear of the round pager, which owns the bottom-centre strip on every
   page (ADR.md entry 42). A send control now sits inside this panel at every
   comment count (ADR.md entry 45), so it can no longer be sized by
   a number picked to fit today's content -- '2379f12' already broke exactly
   that once, when the pager grew a caption row above its pill and this
   panel's hardcoded '44px' (sized for the old single-row pill) started
   sitting under it instead of above it.

   '--round-pager-dock-h' is not a second copy of a number: it is written by
   setupPagerDockHeightTracking (src/ui.mjs), a ResizeObserver on the real
   '.round-pager-dock' element, so this 'bottom' tracks whatever that box's
   ACTUAL rendered height is -- at any viewport height, at every comment
   count, and through any future change to the dock's own shape -- with no
   value here to fall out of step. The var()'s own fallback (84px) is a
   deliberately generous floor for the window before that observer's first
   callback lands (a real Chrome does not guarantee that callback is
   synchronous with layout -- see setupPagerDockHeightTracking's own comment)
   and for a browser without ResizeObserver at all -- wrong by construction
   (it cannot know the dock's real height either) but wrong on the safe
   side, clear of even a three-line dock.

   Verified against a real Chrome, which this repo's own DOM stand-in cannot
   substitute for (QUIRKS.md, "The stand-in has no layout"): 'bottom' resolves
   to exactly var(--space-4) + the dock's measured height + var(--space-3),
   not the fallback, and with the Tray given real content (growing content,
   overflowing off the TOP of the viewport) its measured rect bottom stayed
   exactly var(--space-3) above the dock's measured rect top in every case,
   because the Tray grows upward, away from the dock -- the clearance is a
   property of the DOCK's height alone, not the Tray's. */
/* The Tray (variant A, CONTEXT.md) used to grow upward with NO ceiling --
   fine while it only ever held the compose form and a short list, wrong the
   moment a send control landed inside it (ADR.md entry 45, AC 4) that has to
   stay reachable at every comment count: unbounded growth pushed the Tray's own
   top edge off the top of a short viewport well before the list got long,
   taking the send control with it. The working ceiling is on
   '.comment-list-wrap' (its own rule below): the list shows the three NEWEST
   queued comments and scrolls for the rest -- src/ui.mjs keeps it pinned to
   the bottom on every queue, so the visible three are always the latest --
   which holds the whole Tray to a few rows over the artifact at any queue
   length. The 45px in that calc is the measured single-line '.comment-item'
   box (12.5px type over two rows, 8px padding, 1px borders); a comment long
   enough to wrap is taller and shows fewer whole entries, and the partial
   entry at the fold is itself the cue that there is more to scroll.
   'max-height' on the Tray itself stays as the backstop, not the mechanism:
   'min()' keeps the older, dock-relative ceiling alongside the 40vh one --
   tied to the SAME '--round-pager-dock-h' the 'bottom' value already reads (no
   second number to keep in sync) -- for a viewport short enough
   that even a three-item Tray's top edge would climb into the
   condensed header pill's own headroom (ADR.md entry 40): "the rest of the
   viewport, minus the Tray's own bottom offset, minus 72px of headroom for
   the pill plus its own gap". Whichever number is smallest wins, so the Tray's
   top edge can never rise above any of the limits, at ANY comment count.
   '.comment-list-wrap' is what actually absorbs the
   overflow: 'flex: 1 1 auto; min-height: 0' lets it shrink below its content's
   natural height and scroll internally, while the compose form and
   '.page-send-bar' below it stay at their own natural size, never scrolled,
   always the last thing on screen no matter how long the list above them
   grows -- '.page-send-bar' says so explicitly ('flex: none' below); the
   compose form says nothing at all and gets the identical floor for free, a
   flex item's automatic minimum size defaulting to its own content height
   whenever nothing opts it into shrinking below that (src/render.mjs's own
   comment on renderPageCommentPanel has the fuller version of this). The hint
   rides inside '.page-send-bar' now rather than sitting beside it (see its
   own rule below), so it is carried by the bar's 'flex: none' rather than
   carrying one of its own. 'overflow: hidden' on the Tray itself is a
   backstop, not the mechanism: the inner scroll is what is meant to absorb
   every case, this
   only guards against a measurement this comment did not anticipate. */
.page-comments { position: fixed; z-index: 30; left: 50%;
  bottom: calc(var(--space-4) + var(--round-pager-dock-h, 84px) + var(--space-3));
  max-height: min(40vh, calc(100vh - (var(--space-4) + var(--round-pager-dock-h, 84px) + var(--space-3)) - 72px));
  transform: translateX(-50%); width: min(640px, calc(100vw - 2 * var(--space-6)));
  display: flex; flex-direction: column; overflow: hidden; }
.page-comments:has(.comment-form.open), .page-comments:has(.comment-item), .page-comments:has(.page-send-bar) {
  background: var(--panel); border: 1px solid var(--hairline); border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
  /* one inset on all four sides, the same --space-3 the condensed header pill
     insets its controls by HORIZONTALLY: these are the two surfaces that float
     over the artifact, at the top and the bottom of the same viewport, and they
     now share a radius (--r-lg), a shadow and a side inset. The old
     8px/16px/16px asymmetry read as a different object at the other end of the
     screen. The pill's own vertical inset is 6px rather than 12px, and that is
     not a mismatch to fix: the pill holds one row of controls and is sized by
     them (see its padding-block), while the Tray stacks a list, a form and a
     bar, which need the room. */
  padding: var(--space-3); }
/* The scrollable half: only the LIST of already-left comments grows without
   bound and scrolls internally once it outgrows its own max-height -- three
   single-line items, their two gaps and the list's own top margin (see the
   Tray comment above for where 45px comes from and why a wrapped comment
   shows fewer whole entries) -- the hint, the compose form and the send
   control are none of them inside this box, so they never scroll out of
   reach regardless of how many comments are queued. */
.comment-list-wrap { flex: 1 1 auto; min-height: 0; overflow-y: auto;
  max-height: calc(3 * 45px + 2 * var(--space-2) + var(--space-3)); }
/* The click-to-comment gesture's own hint, back in exactly
   one place -- the awaited page board's empty Tray, because comment
   mode already starts ON there (src/ui.mjs), so the mode toggle itself is no
   longer what reveals the gesture the way it is everywhere else.

   It rides the send bar's own row, at its left end: one short line does not
   need a row to itself, and the Tray floats over somebody's artifact, so every
   row it does not spend is artifact the reader can still see. 'margin-right:
   auto' is what keeps the two buttons at the right end of that row without the
   bar having to change its own 'justify-content' -- and when the hint goes at
   the first queued comment (src/ui.mjs sets display:none), the buttons stay
   exactly where they were. */
.page-comment-hint { flex: 0 1 auto; min-width: 0; font-size: 11.5px; font-style: italic;
  color: var(--muted); margin: 0 auto 0 0; }
/* AC 4: one send control at every comment count, Discuss beside it -- the same
   '.btn-send'/'.btn-discuss' chrome the ordinary send bar uses, just inside
   the Tray instead. 'flex: none' keeps it out of '.comment-list-wrap''s own
   scroll region (see this file's own comment on '.page-comments' above).

   'flex-wrap' is the narrow-window floor: the hint plus both buttons fit the
   640px Tray comfortably, but on a phone-width board there is no row wide
   enough for all three, and wrapping puts the hint back on a line of its own
   rather than squeezing the controls. */
.page-send-bar { flex: none; display: flex; align-items: center; justify-content: flex-end;
  flex-wrap: wrap; gap: var(--space-3); padding-top: var(--space-2); }
/* AC 12: a wait that dies mid-read reverts the page to read-only WITHOUT
   throwing away anything already on screen -- src/ui.mjs adds this class the
   moment it learns the round's deadline has passed (a periodic client-side
   check, and an immediate nudge over the 'awaitExpired' SSE event) and removes
   none of the Tray's existing '.comment-item' entries when it does. Locked
   twice, same discipline QUIRKS.md's "Readonly is locked twice" already
   documents for body.readonly: this hides the compose surface, and src/ui.mjs
   additionally disables the same elements' 'disabled' attribute, since a CSS
   rule alone leaves a control that looks gone but is not. */
.page-comments.expired .comment-form,
.page-comments.expired .page-comment-hint { display: none; }
/* The send bar itself STAYS, holding one frozen control that names where the
   comments went (badge.mjs's PAGE_SEND_EXPIRED_LABEL). Everything that could
   still start something goes: Discuss opens a second route to an agent that is
   no longer there. The button is disabled in the same sweep that adds this
   class, so this rule is presentation for a control that is already inert --
   not the lock itself. */
.page-comments.expired .page-discuss-btn { display: none; }
.page-comments.expired .page-send-btn { opacity: 1; cursor: default; font-style: italic;
  background: transparent; border-color: var(--border); color: var(--muted); }

/* --- chrome that gets out of the way (ADR.md entry 40) -----------------------
   Reading the artifact condenses the header into a single pill, centred at the
   top and floating over the page; scrolling back up expands it again.

   The condense has NO THRESHOLD. It is driven continuously by '--stage-p', a
   0-to-1 progress src/ui.mjs writes on <body> from the scroll offset a page
   board's stage reports -- the parent cannot see inside an opaque-origin frame,
   so the offset arrives over the stage channel (src/render.mjs's
   stageAgentScript) rather than being observed here. Every rule below is a
   'calc()' on that one number, so the pill forms and un-forms under the
   reader's own finger and there is no snap point to sit on. The threshold this
   replaced (one boolean flipped at 24px) made a reader parked on the boundary
   flap the whole header on and off, which is the failure a dead zone would only
   have narrowed rather than removed.

   'stage-scrolled' survives as a plain "is it off zero" flag, since the
   back-to-top control still needs a discrete 'display' switch (below) and a
   'display' cannot be interpolated at all.

   Everything condensing does is a change of BOX, never of flow: the header is
   already 'position: fixed' above, so the frame under it stays a constant 100vh
   through the whole cycle and a long artifact can never reflow mid-read. That
   is the entire reason entry 40 chose an overlay over a header that pushes.

   What condenses is the header's IDENTITY TEXT, not its controls. The title and
   the thread/id line go; the mark, the comment-mode toggle, the theme control
   and the state label stay (ADR.md entry 42 -- the round badge that used to
   round out this row is gone, the header names no round at rest or condensed),
   so the pill is never decorative -- entry 40's
   "the pill keeps the comment-mode toggle, so the mode is switched mid-read
   rather than being suspended by the scroll". Collapsing those two elements
   rather than moving any control means there is still exactly ONE
   #comment-mode-toggle in the document, condensed or not: a second copy could
   disagree with the first about .active/aria-pressed, and src/ui.mjs's
   setCommentMode writes to one element by design.

   How the box moves without interpolating an intrinsic width. The header stays
   full-bleed at every progress and the pill is drawn by a ::before behind it,
   inset from both edges by a percentage of the header's OWN width -- so
   'inset-inline: calc(50% - var(--pill-half))' at p=1 is a centred band exactly
   one pill wide. The content converges on that same band through a matching
   'padding-inline', so chrome and controls arrive together. Percentages and
   plain lengths interpolate everywhere; 'width: 100vw' to 'width: fit-content'
   does not, which is what made an animation here look impossible before.

   --pill-half is measured once by src/ui.mjs (half the width of the controls
   that survive the condense) rather than hardcoded, because the state label
   (the read-only slot, ADR.md entry 46) changes width at runtime -- appearing,
   going, and swapping between a countdown and 'read-only'.

   --head-clear (see :root) is untouched on purpose. It is a scroll-margin for
   anchor jumps down a scrolling DOCUMENT, and a page board's document does not
   scroll at all ('body.page-board { overflow: hidden }' above) -- the artifact
   scrolls inside the frame. Nothing on a page board reads the token, so the
   header's height changing here cannot make it wrong. */
/* Both defaults live on <body> rather than on the header, because <body> is
   where src/ui.mjs writes them and because .back-to-top (further down, a
   sibling of the header) reads --stage-p too: declared on the header, it would
   shadow the written value there and be invisible everywhere else. */
body.page-board { --stage-p: 0; --pill-half: 120px; }
body.page-board .board-head {
  /* two terms: the header's own edge padding easing from --space-5 to the
     pill's tighter --space-3, plus the inset that walks the content into the
     centred band. --pill-half is measured to match this exact arithmetic
     (src/ui.mjs measurePillHalf), so the controls land with --space-3 of air
     inside the band and no more. */
  padding-inline: calc(var(--space-5) + var(--stage-p) * (var(--space-3) - var(--space-5))
    + var(--stage-p) * (50% - var(--pill-half)));
  /* 6px is not a token on purpose: the condensed pill is the one box on the
     surface whose height is set by nothing but its own contents (see the mark
     and badge rules below), and --space-2 left it 4px taller than the tallest
     thing in it. Every other progress reads --space-3 as before.
     "Its own contents" is literal, so the pill has no one height: measured in
     Chrome, 34.4px on a page board that is not awaited (the badge's 22.4px label is
     the tallest thing there) and 39.8px on an awaited one, where the
     comment-mode chip is 27.8px. Both are the box the controls ask for, which
     is the whole point -- what it is no longer is 64.4px, the figure it was
     held at by an identity block nobody can see. */
  padding-block: calc(var(--space-3) + var(--stage-p) * (6px - var(--space-3)));
  /* the air the floating pill sits in, and the only vertical space this header
     ever costs the artifact: it arrives with the pill (nothing at p=0, where
     the header is a full-bleed wash flush to the top edge) and it is --space-4
     rather than --space-3 so the pill reads as floating over the page rather
     than clipped to its top edge. */
  margin-top: calc(var(--stage-p) * var(--space-4));
  gap: calc(var(--space-4) + var(--stage-p) * (var(--space-3) - var(--space-4)));
  /* the expanded wash and the pill chrome are two layers, faded past each other.
     A gradient does not interpolate into a flat panel colour, and a
     'border-bottom' does not interpolate into a full pill border, so neither
     tries to: each lives on its own pseudo-element at its own opacity. */
  background: none; border-bottom: none;
  /* the blur belongs to the wash, not to the header's own box: left here it
     would frost a full-viewport band across the top of the artifact while the
     visible chrome is a 240px pill */
  backdrop-filter: none;
}
body.page-board .board-head::before,
body.page-board .board-head::after {
  content: ""; position: absolute; z-index: -1; pointer-events: none;
}
/* the pill: a centred band that grows out of the full-bleed header */
body.page-board .board-head::before {
  inset-block: 0; inset-inline: calc(var(--stage-p) * (50% - var(--pill-half)));
  background: var(--panel); border: 1px solid var(--hairline);
  /* --r-lg, not --r-pill: a stadium's lobes are half the box tall, so at the
     64.4px this used to draw they were taller than every label inside them and
     the chrome read as rounder than its own contents. Height was the cause and
     the radius the symptom -- the rules below hand the box back to its controls
     (34.4px to 39.8px, see the padding-block comment above), and a 14px corner
     on either is a corner rather than a capsule. */
  border-radius: var(--r-lg); box-shadow: var(--shadow-2);
  opacity: var(--stage-p);
}
/* the expanded header's own wash, leaving as the pill arrives. No border-bottom
   here either, for '.board-head''s own reason above: the wash IS the edge, and
   over an artifact the contradiction is sharper still -- the hairline drew a
   full-width rule across somebody's rendered page while the gradient underneath
   it was busy saying the page runs on. */
body.page-board .board-head::after {
  inset: 0;
  background: linear-gradient(to bottom, var(--bg) 62%, var(--bg-fade-0));
  backdrop-filter: blur(10px);
  opacity: calc(1 - var(--stage-p));
}
/* the identity text collapses horizontally, which is what lets the header's
   content reach the pill's width without anything being clipped mid-word. Ink
   leaves faster than the box does (the 1.8 multiplier): text at 10% opacity
   squeezed into 30px reads as a rendering fault, where an empty box reads as a
   box.

   It collapses VERTICALLY too, and that is not symmetry for its own sake:
   measured in Chrome, a title plus thread/id line is 52.4px tall, and a box at
   'max-width: 0' with 'opacity: 0' still contributes every one of those pixels
   to the header's height. That invisible column, not the mark or any control,
   was what made the condensed pill 64px tall around 22px of ink -- the reason
   the shape read as far rounder than its contents. The ceiling is a plain
   length so it interpolates, and the size of that ceiling is the whole trade:
   too low and the box starts clipping while the text is still legible, too high
   and the collapse is a jump at the very end of the ramp. 160px against a
   measured 52.4px identity block starts the squeeze around p=0.67, comfortably
   after the ink reaches zero (0.556 on the 1.8 multiplier above), and still
   leaves a block half again as tall as this one collapsing unseen. */
body.page-board .board-head-ident {
  min-width: 0; overflow: hidden;
  max-width: calc((1 - var(--stage-p)) * 60vw);
  max-height: calc((1 - var(--stage-p)) * 160px);
  opacity: calc(1 - var(--stage-p) * 1.8);
}
/* ...and the gap that held the collapsed block off the mark goes with it. At
   p=1 the identity block is a zero-by-zero box, so this gap was 12px of dead
   air between the mark and everything else -- the pill's contents sat visibly
   left-of-centre inside their own band. It also makes src/ui.mjs's
   measurePillHalf true: that function sums brand + the HEADER's gap + actions,
   which is the whole content width only once this inner gap is gone. */
body.page-board .board-head-title { gap: calc((1 - var(--stage-p)) * var(--space-3)); }
body.page-board .board-head h1,
body.page-board .board-head .meta { white-space: nowrap; }

/* What sets the condensed pill's height. Nothing here is a page-board layout
   rule in the sense of the ones above -- it is the two surviving controls
   being sized for a floating pill instead of a full-width header.

   The mark was the whole 46px: 30px of tile with 8px of air over and under it,
   wrapped around labels whose own line box is 18px. It is 22px at EVERY
   progress on a page board rather than easing 30 -> 22 with the ramp, because
   src/ui.mjs's measurePillHalf reads '.back-to-index'.offsetWidth and its
   ResizeObserver watches '.board-head-actions' alone: a width that moved with
   the ramp would leave --pill-half measured for a mark the header has already
   left, i.e. a band a few px wider than the contents it is drawn to fit.
   Observing the brand as well would re-measure on every scroll frame, forcing a
   layout inside the ramp and feeding --pill-half's own padding-inline change
   back into the observer that caused it. The cost of the constant is 8px of
   mark in the EXPANDED page-board header, where the mark is not what sets the
   height anyway (the title and thread/id lines are). */
body.page-board .back-to-index { width: 22px; height: 22px; }
/* markSvg writes width/height ATTRIBUTES, which a flex item keeps at its
   intrinsic size unless the box is told otherwise -- without this the 30px
   drawing simply overflows the 22px slot. */
body.page-board .back-to-index svg { width: 100%; height: 100%; }
/* ADR.md entry 42 deleted the round badge that used to sit here between the
   theme control and the state label -- with it gone, the theme control's own
   .mode-toggle chrome (shared with the comment-mode toggle, unchanged by this
   file) is the last thing before the state label, and a hairline is what
   separates them: without a rule between two controls of different kinds the
   pill reads as a label that happens to have a word after it, not one toolbar. */
body.page-board .round-meta { border-left: 1px solid var(--hairline); padding-left: var(--space-3); }
/* ...but not while the slot is empty, which on an awaited page board is exactly
   how it first paints: src/render.mjs leaves the countdown out at render time
   on purpose (a wall-clock figure only src/ui.mjs may compute) and fills it at
   hydrate. Without this the pill opens carrying a divider with nothing after it
   and 24px of air holding the space. */
body.page-board .round-meta:empty { display: none; }

/* --- ADR.md entry 40: an ordinary board condenses
   too, into the same pill above -- the two board types stop reading as two
   designs. Every rule below reads the identical '--stage-p'/'--pill-half' the
   page-board rules above do (written here by refreshDocumentScrollChrome,
   src/ui.mjs, off this document's own scroll instead of a stage's postMessage
   -- an ordinary board scrolls itself rather than a fixed-height stage frame,
   so only the PROGRESS SOURCE differs), and keeps the expanded header's own
   control order and colour tokens (mark, comment toggle, theme, state label --
   ADR.md entry 42 already left that the order everywhere else).

   AC 8 is why the MECHANISM differs from the page board's, not just the
   source: '.board-head' here is 'position: sticky', still IN normal flow --
   an ordinary board has no fixed 100vh frame to float over, it is the
   document itself that scrolls. A sticky element's own box stays part of flow
   at every scroll offset (only its rendering position changes once stuck), so
   letting the ramp touch anything that changes THAT box's height --
   'padding-block', 'margin-top', or the '.board-head-ident' 'max-height' the
   page-board rules collapse -- would push the column underneath it up or down
   on every scroll frame, exactly what AC 8 forbids. None of those three
   properties appear anywhere below: '.board-head-ident' still narrows to
   nothing and fades out (AC 7's "same contents"), but keeps its own height,
   so '.board-head' itself measures the same at every progress by
   construction -- nothing to reserve because nothing was ever going to move.
   test/check-header-condense.mjs pins that as a fact about the stylesheet
   (no rule in this block sets a height-affecting property off '--stage-p'),
   the only way to prove it without a layout engine -- QUIRKS.md, "the
   stand-in has no layout".

   That leaves the pill's own chrome (::before) unable to reuse 'inset-block:
   0' the way the page board's does -- spanning a box that never shrinks would
   draw a tall rounded panel, not a pill. Its height is a plain constant
   instead, and hardcoding it here is harmless in a way hardcoding
   '.board-head''s OWN height would not have been: a ::before is
   'position: absolute', out of flow inside a positioned ancestor (sticky
   qualifies), so it contributes nothing to the box whose constancy AC 8
   actually depends on. */
body:not(.page-board) { --stage-p: 0; --pill-half: 120px; }
/* '+ var(--space-3)', where the page-board rule below has none: that rule's
   OWN padding-inline eases between two edge tokens ('--space-5' at rest,
   '--space-3' condensed) BEFORE adding the '(50% - --pill-half)' inset term,
   and it is that easing, not the inset term, which supplies the pill's inner
   air on a page board -- '--pill-half' is measured as half the CONTENT plus
   '--space-3' (src/ui.mjs's measurePillHalf: 'half = (brand + gap +
   actions) / 2 + pad'), and the page board's own '::before' (inset-inline:
   50% - pill-half alone, no added term) is exactly '2 * pill-half' wide, i.e.
   content-width plus that same '--space-3' on both edges.
   An ordinary board has no edge-token easing to borrow that air from -- rest
   state here is a flush '0' inline padding, not '--space-5' -- so without
   this term 'padding-inline' at full condense pulls the CONTENT in to
   exactly '2 * pill-half' too, matching the panel's own width bolt for bolt,
   and 'justify-content: space-between' (unchanged, above) has nowhere to put
   the pill-half's baked-in slack except the one gap it controls: between the
   mark and the actions row -- without this term that slack shows up as a gap
   dead centre in the pill instead of air on both edges. Adding '--space-3'
   here shrinks the padding by that same amount, which shrinks the CONTENT'S
   width to exactly
   'brand + gap + actions' (pill-half's own '- pad' term, undone) -- tight
   against itself, with the panel's unchanged extra width now free to sit as
   symmetric air on both edges instead of a gap in the middle. */
body:not(.page-board) .board-head {
  padding-inline: calc(var(--stage-p) * (50% - var(--pill-half) + var(--space-3)));
  /* the wash moves to its own layer below so it can fade against the pill,
     same split the page-board rules above use and for the same reason. */
  background: none; border-bottom: none; backdrop-filter: none;
}
body:not(.page-board) .board-head::before,
body:not(.page-board) .board-head::after {
  content: ""; position: absolute; z-index: -1; pointer-events: none;
}
/* the pill itself: a fixed-height band (mark's own 30px plus 6px of air each
   side, the same 6px the page-board pill's own padding-block ramp ends at),
   centred on the CONTROL ROW -- not on '.board-head''s own box, which is a
   different centre. The header's own padding is NOT symmetric ('padding:
   var(--space-4) 0 var(--space-3)', 16px over 12px), and 'align-items: center'
   centres each flex child on the CONTENT box (the padding box minus that
   padding), not on the border box -- so the true centreline sits
   '(--space-4 - --space-3) / 2' below the border box's own 50%, which a plain
   'top: 50%' would miss. 'top'/'bottom' set together (no 'height', no
   'transform') fixes both at once: their SUM is fixed at 42px regardless of
   the box's own height (so the panel is still exactly 42px tall), and
   shifting both by the same '(--space-4 - --space-3) / 2' moves the
   centreline to match the content box's, using the identical tokens
   '.board-head''s own padding already declares -- so a future change to
   either token keeps this in step without a second number to maintain. A
   'transform: translateY()' can't do this instead: a computed-style read
   (what 'resolveComputedProperty' does -- it never runs layout) reports 'top'
   and 'bottom' from before any transform paints, so nothing here could assert
   the corrected position without dropping the transform entirely. */
body:not(.page-board) .board-head::before {
  top: calc(50% + (var(--space-4) - var(--space-3)) / 2 - 21px);
  bottom: calc(50% - (var(--space-4) - var(--space-3)) / 2 - 21px);
  inset-inline: calc(var(--stage-p) * (50% - var(--pill-half)));
  background: var(--panel); border: 1px solid var(--hairline);
  border-radius: var(--r-lg); box-shadow: var(--shadow-2);
  opacity: var(--stage-p);
}
/* Full-bleed, not 'inset: 0' -- the page-board rule above uses 'inset: 0'
   safely because THAT header is itself full-bleed (position: fixed over a
   100vh frame with no document scrollbar at all, body.page-board sets
   overflow: hidden). This one is 'position: sticky' inside the 1120px
   column, so 'inset: 0' would size the wash to the COLUMN, not the
   viewport -- a ~1072px rounded blur-plus-gradient rectangle sitting on the
   page's own plain background outside it, with a visible seam at both edges
   against the page background beyond the column.
   'width: 100vw' (the usual full-bleed escape) is unsafe here specifically:
   'vw' units include the scrollbar's own width, and an ordinary board is
   exactly the one surface that always has a real document scrollbar (a page
   board never does, which is why its own full-bleed geometry never had to
   consider this) -- '100vw' overflows the actual visible width by the
   scrollbar's, trading the seam for a horizontal scrollbar instead. Measured
   instead: '--doc-w' is 'document.documentElement.clientWidth'
   (measureDocWidth, src/ui.mjs), which excludes the scrollbar by
   definition, the same 'measure it, don't guess it' idiom '--pill-half' and
   '--round-pager-dock-h' already use in this file. 'left: 50%; transform:
   translateX(-50%)' centres a box of that measured width on '.board-head''s
   OWN horizontal centre, which is also the viewport's -- '.board-shell'
   ('margin: 0 auto') centres the column at every width, including one
   narrower than 1120px where the column simply fills 100% and the centring
   is a no-op -- so this lands flush with both real edges at any viewport,
   with no vw-based overflow risk. */
body:not(.page-board) .board-head::after {
  top: 0; bottom: 0;
  left: 50%; transform: translateX(-50%);
  width: var(--doc-w, 100%);
  background: linear-gradient(to bottom, var(--bg) 62%, var(--bg-fade-0));
  backdrop-filter: blur(10px);
  opacity: calc(1 - var(--stage-p));
}
/* collapses by width and fades by opacity, same as the page-board rules
   above -- deliberately NOT by 'max-height' (see this block's own comment). */
body:not(.page-board) .board-head-ident {
  min-width: 0; overflow: hidden;
  max-width: calc((1 - var(--stage-p)) * 60vw);
  opacity: calc(1 - var(--stage-p) * 1.8);
}
body:not(.page-board) .board-head-title { gap: calc((1 - var(--stage-p)) * var(--space-3)); }
body:not(.page-board) .board-head h1,
body:not(.page-board) .board-head .meta { white-space: nowrap; }
/* the same divider the page-board rules draw between the theme control and
   the state label, once the state label is showing here too (see the base
   '.round-meta' rule above). */
body:not(.page-board) .round-meta { border-left: 1px solid var(--hairline); padding-left: var(--space-3); }
body:not(.page-board) .round-meta:empty { display: none; }

/* the back-to-top control (ADR.md entry 40), in the questions-left pill's own
   shape -- one visual object for "a pill floating at the bottom of the board",
   deliberately not a second treatment. Two differences it has to have: it is
   'position: fixed' rather than absolute inside .send-bar (the bar is
   display:none on a page board AND in an archive, and this control has to
   outlive both), and it sits bottom-RIGHT, because bottom-CENTRE belongs to the
   round pager and two controls stacked on one point is a collision, not a
   layout.

   Hidden by default and turned on by '.visible' alone, the same single-decider
   idiom as .questions-left-pill -- and for the same reason (QUIRKS.md: the
   'hidden' attribute does nothing against our own stylesheet's display).

   '.visible' is the only part of this control that is still a boolean, and it
   has to be: 'display' has no interpolable midpoint, so something must flip it.
   It flips the instant the artifact leaves zero, and the control is at zero
   opacity there -- the FADE is driven by '--stage-p' like the header pill, over
   the last 40% of the ramp, so the two arrive as one gesture with the
   back-to-top trailing. Ordering it that way is deliberate: the reader is told
   "you are reading" first and offered the way back second. */
.back-to-top { display: none; position: fixed; z-index: 30;
  right: var(--space-5); bottom: var(--space-5);
  background: var(--panel-2); color: var(--ink-2);
  border: 1px solid var(--hairline); border-radius: var(--r-pill); padding: 7px 16px;
  font: inherit; font-size: 12.5px; font-weight: 600; box-shadow: var(--shadow-2);
  cursor: pointer; white-space: nowrap;
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease); }
.back-to-top.visible { display: inline-flex; align-items: center; }
body.page-board .back-to-top.visible {
  opacity: clamp(0, (var(--stage-p) - 0.6) * 2.5, 1);
  transform: translateY(calc((1 - var(--stage-p)) * var(--space-2))); }
.back-to-top:hover:not(:disabled) { border-color: var(--hairline-2); color: var(--ink); }

/* --- responsive: the board is a laptop surface first, but it has to survive a
   phone-width window without a horizontal scrollbar or a two-column squeeze --- */
@media (max-width: 860px) {
  .question-block { grid-template-columns: minmax(0, 1fr); }
  .compare-grid { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
  /* the header stacks here and grows from 81.4px to 115.4px (measured in
     Chrome), so every scroll-to-anchor target has to clear that much more --
     see --head-clear's own comment in :root */
  :root { --head-clear: 124px; }
  .board-shell, .index-shell { padding-left: var(--space-4); padding-right: var(--space-4); }
  .board-head { flex-direction: column; align-items: flex-start; gap: var(--space-2); }
  .block { padding: var(--space-4); border-radius: var(--r-md); }
  .send-bar { flex-wrap: wrap; }
  .send-status { width: 100%; margin-bottom: var(--space-2); }
  .btn-send, .btn-discuss { flex: 1; }
  .search-form { flex-wrap: wrap; }
  /* The condense is suppressed below this
     breakpoint rather than taught a second, column-aware shape. '--pill-half'
     is measured as one SINGLE-ROW sum (brand + columnGap + actions,
     src/ui.mjs's measurePillHalf) and describes nothing once the rule two
     lines up turns '.board-head' into two stacked rows: the fixed-height band
     it draws no longer matches either row, with the mark sitting above it and
     the state label below it, neither one inside it. A pill that does not
     fit its own contents is worse than no pill, and nothing about AC 7 requires one
     at every viewport -- the header just stays exactly as it always has
     below this breakpoint, at every scroll offset.
     '!important' is load-bearing: refreshDocumentScrollChrome (src/ui.mjs)
     writes '--stage-p' as an INLINE style on <body>, which outranks a plain
     stylesheet rule regardless of specificity -- only '!important' on the
     stylesheet side can still win, and every rule this ticket added reads
     '--stage-p' rather than testing 'stage-scrolled' directly, so pinning the
     ONE variable neutralises all of them at once with no second override to
     keep in sync. AC 8 is untouched by this: it was never about whether the
     header condenses, only about whether condensing (whenever it happens)
     moves anything -- and below this breakpoint it now simply never
     happens.
     'body', not 'body:not(.page-board)': a page board reaches the pill by the
     same arithmetic off the same single-row '--pill-half', and the
     'flex-direction: column' rule above is not scoped away from it either, so
     it breaks here in exactly the way an ordinary board did. The first pass
     scoped this rule to ordinary boards only because its ticket was about
     them, not because a page board was found to survive. */
  body { --stage-p: 0 !important; }
}
