/* ============================================================================
   THE PALETTE, AND THE WHOLE THEME MECHANISM (2026-08-05).

   Every colour in this sheet is a variable, and a theme is ONE var-set swap. Nothing
   below this block names a colour directly except the scanned-page canvas, which is
   white paper in both themes because it IS paper. ThemePaletteFenceTest holds that
   line mechanically, and ThemeContrastFenceTest measures every text/background pair
   declared in theme-contrast-pairs.tsv against its floor in BOTH themes — a palette
   edit that dims a word past legibility fails the build rather than the reader.

   THREE BLOCKS, IN THIS ORDER, AND THE ORDER IS LOAD-BEARING:

     1. :root — the dark set, and the app's default. A reader whose system says nothing
        (or an engine too old to be asked) lands here.
     2. the prefers-color-scheme block, guarded by :not on a stored dark choice — so the
        system preference applies only while the reader has not decided for themselves,
        and a stored dark stamp falls back through to block 1 rather than needing a
        fourth copy of the dark values.
     3. the stored light choice, last, so it outranks the system preference at equal
        specificity.

   The two light blocks are byte-identical on purpose and the fence asserts it; the
   duplication is the price of a no-JavaScript default that is honest, and the fence is
   what stops the two copies drifting.

   WHY --accent AND --accent-fill ARE TWO TOKENS. On paper, link text has to be dark
   enough to read against the page while a filled button has to be light enough for its
   near-black ink — one gold cannot be both, and the pair of them is measured from both
   ends. In the dark set they hold the same value, so no gold on that theme moved.

   WHY --field-line AND --control-line ARE TWO TOKENS. An empty text box is identified by
   its boundary and by nothing else: its fill barely differs from the surface behind it,
   and there is no label inside it to read. A filled pill — a chip, a tappable row — is
   identified by that fill plus the chip-styled word printed on it, both of which are
   measured above. So the FIELD boundary carries a real 3:1 obligation and the filled
   pill's outline is refinement. Splitting them is what let the field boundary be lifted
   to clear that floor in the dark theme (it measured 1.45:1 against its own fill and had
   done since long before this work) without repainting every pill on the page along with
   it.

   WHAT ACTUALLY MOVED IN THE DARK THEME, stated in full because "one deliberate change"
   was claimed here once and was not true. THREE outlines changed value there, all of them
   boundaries and none of them text or fill: (1) --field-line lifted #3a4150 -> #67738e,
   the shortfall above; (2) the recents/shelf pill, which named #2c313a longhand and now
   reads --control-line (#3a4150) — a token swap that happens to be a real repaint, and
   kept because it is the semantically right token and the contrast improves 1.39 -> 1.77;
   (3) the SECONDARY BUTTON, moved off --control-line onto --field-line by the audit fold
   below. Everything else in the dark set holds its shipped value.

   --when-dark / --when-light are not colours: they are the display values that pick
   which of a theme-paired element's two variants is shown (the header control's label,
   the footer mark). One mechanism for the palette and for the swapped artwork.

   EACH BLOCK ALSO DECLARES color-scheme, WHICH IS NOT A COLOUR AND IS NOT A TOKEN. It is
   what makes the browser's OWN chrome — scrollbars, select popups, date pickers, autofill
   shading, the canvas behind a rubber-band overscroll — follow the PAGE rather than the
   operating system. Without it a reader who taps "Switch to paper" on a dark-set phone
   gets a paper page with dark scrollbars and a dark autofill wash over the light form
   fields, which is precisely the half-swapped look the stamp exists to prevent. The dark
   block says dark and both light blocks say light, so a STORED choice carries the UA
   chrome with it in either direction (a stored dark stamp falls through to block 1 and
   takes `dark` with it), while a reader who has chosen nothing still gets their system's
   answer through the media query.
   ============================================================================ */
:root {
    color-scheme: dark;
    --bg: #14161a; --panel: #1d2026; --panel2: #24282f;
    --text: #e8e6e3; --ink: #e8e6e3; --muted: #9aa0a8; --muted-dim: #7f858c;
    --accent: #d4a24c; --accent-fill: #d4a24c; --accent-ink: #1a1408;
    --good: #6fbf73; --warn: #e0b050; --bad: #d97a6c; --info: #7aa7d9;
    --line: #2c313a; --line2: #3a4150; --line3: #2a303a;
    --results-fill: #1d2026; --results-line: #2c313a;
    --control-line: #3a4150; --field-line: #67738e;
    --good-fill: #22321f; --warn-fill: #33261d; --bad-fill: #3a1f2a;
    --info-fill: #1e2a38; --amber-fill: #33301d; --clean-fill: #1f2e1d;
    --line-good: #2f4a2b; --line-warn: #4a3a24; --line-bad: #52303c;
    --line-info: #2f4258; --line-amber: #4a4526;
    --badge-rulebook-bg: #2c3b2a; --badge-community-bg: #322030;
    --badge-community-fg: #c79ac2; --badge-unknown-bg: #2c313a;
    --retraction-bg: #2a1a1d; --hole-bg: #241a1d;
    --chip-derived-bg: #17324a; --chip-directive-bg: #4a1420;
    --chip-count-bg: #2a2438; --chip-count-fg: #b9a9e0;
    --chip-teal-bg: #1d3a30; --chip-teal-fg: #7fd6b0;
    --chip-regen-bg: #2a3040; --chip-regen-fg: #9db4d6;
    --chip-suppressed-bg: #2e2a20; --chip-suppressed-fg: #d6c79d;
    --phase-inventory-ink: #6fb3ac; --phase-setup-ink: #d894ae;
    --complexity-light-fill: #1f2c22; --complexity-light-ink: #8fbf8a; --complexity-light-edge: #2e4634;
    --complexity-medium-fill: #1e2733; --complexity-medium-ink: #82abd6; --complexity-medium-edge: #31435c;
    --complexity-heavy-fill: #33201d; --complexity-heavy-ink: #cf8a76; --complexity-heavy-edge: #543029;
    --when-dark: inline-block; --when-light: none;
}
@media (prefers-color-scheme: light) {
    :root:not([data-theme="dark"]) {
    color-scheme: light;
    --bg: #f2eee6; --panel: #fbf9f3; --panel2: #f4efe5;
    --text: #46473f; --ink: #1a1b18; --muted: #6c6657; --muted-dim: #6e6859;
    --accent: #7d5a15; --accent-fill: #b07f1c; --accent-ink: #1a1408;
    --good: #2f6135; --warn: #6e5209; --bad: #9c3d2a; --info: #2f5a86;
    --line: #dcd4c4; --line2: #cfc6b4; --line3: #e6e0d2;
    --results-fill: #ece5d8; --results-line: #d7cdb8;
    --control-line: #8a7f66; --field-line: #8a7f66;
    --good-fill: #e3ecdd; --warn-fill: #f5ead3; --bad-fill: #f6e1dc;
    --info-fill: #e0e9f3; --amber-fill: #f2ecd6; --clean-fill: #e6f0e0;
    --line-good: #bfd4b8; --line-warn: #dcc79a; --line-bad: #dfb8ae;
    --line-info: #b9cadd; --line-amber: #d8ce9e;
    --badge-rulebook-bg: #dfead8; --badge-community-bg: #efe2ee;
    --badge-community-fg: #6b3e67; --badge-unknown-bg: #f0ebe0;
    --retraction-bg: #f8e4e0; --hole-bg: #f8e7e2;
    --chip-derived-bg: #dde8f2; --chip-directive-bg: #f7dcd8;
    --chip-count-bg: #e7e1f2; --chip-count-fg: #4a3b78;
    --chip-teal-bg: #dcede5; --chip-teal-fg: #1f5c46;
    --chip-regen-bg: #e1e7f1; --chip-regen-fg: #37507a;
    --chip-suppressed-bg: #efe9d9; --chip-suppressed-fg: #5f5122;
    --phase-inventory-ink: #245f58; --phase-setup-ink: #8f2f57;
    --complexity-light-fill: #e3ecdd; --complexity-light-ink: #2f5c39; --complexity-light-edge: #a9c0a5;
    --complexity-medium-fill: #e0e9f3; --complexity-medium-ink: #2f5a86; --complexity-medium-edge: #9fb6ce;
    --complexity-heavy-fill: #f2e0d7; --complexity-heavy-ink: #8a442e; --complexity-heavy-edge: #c3a292;
    --when-dark: none; --when-light: inline-block;
    }
}
:root[data-theme="light"] {
    color-scheme: light;
    --bg: #f2eee6; --panel: #fbf9f3; --panel2: #f4efe5;
    --text: #46473f; --ink: #1a1b18; --muted: #6c6657; --muted-dim: #6e6859;
    --accent: #7d5a15; --accent-fill: #b07f1c; --accent-ink: #1a1408;
    --good: #2f6135; --warn: #6e5209; --bad: #9c3d2a; --info: #2f5a86;
    --line: #dcd4c4; --line2: #cfc6b4; --line3: #e6e0d2;
    --results-fill: #ece5d8; --results-line: #d7cdb8;
    --control-line: #8a7f66; --field-line: #8a7f66;
    --good-fill: #e3ecdd; --warn-fill: #f5ead3; --bad-fill: #f6e1dc;
    --info-fill: #e0e9f3; --amber-fill: #f2ecd6; --clean-fill: #e6f0e0;
    --line-good: #bfd4b8; --line-warn: #dcc79a; --line-bad: #dfb8ae;
    --line-info: #b9cadd; --line-amber: #d8ce9e;
    --badge-rulebook-bg: #dfead8; --badge-community-bg: #efe2ee;
    --badge-community-fg: #6b3e67; --badge-unknown-bg: #f0ebe0;
    --retraction-bg: #f8e4e0; --hole-bg: #f8e7e2;
    --chip-derived-bg: #dde8f2; --chip-directive-bg: #f7dcd8;
    --chip-count-bg: #e7e1f2; --chip-count-fg: #4a3b78;
    --chip-teal-bg: #dcede5; --chip-teal-fg: #1f5c46;
    --chip-regen-bg: #e1e7f1; --chip-regen-fg: #37507a;
    --chip-suppressed-bg: #efe9d9; --chip-suppressed-fg: #5f5122;
    --phase-inventory-ink: #245f58; --phase-setup-ink: #8f2f57;
    --complexity-light-fill: #e3ecdd; --complexity-light-ink: #2f5c39; --complexity-light-edge: #a9c0a5;
    --complexity-medium-fill: #e0e9f3; --complexity-medium-ink: #2f5a86; --complexity-medium-edge: #9fb6ce;
    --complexity-heavy-fill: #f2e0d7; --complexity-heavy-ink: #8a442e; --complexity-heavy-edge: #c3a292;
    --when-dark: none; --when-light: inline-block;
}
* { box-sizing: border-box; }
body {
    margin: 0; padding: 0 0 4rem;
    background: var(--bg); color: var(--text);
    font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 44rem; margin: 0 auto; padding: 1rem; }
a { color: var(--accent); text-decoration: none; }
/* Headings take --ink rather than the body's --text. In the dark set the two hold the
   same value, so no heading changes colour there; on paper the near-black heading against
   the softer body grey is what gives the page its printed-sheet hierarchy. */
h1, h2, h3 { color: var(--ink); }
/* THE COMPANION'S HELLO (2026-08-15, the operator's cohesion pass): the shelf's h1 is a
   greeting now, so it renders like one — a touch smaller than a looming title, no
   shout, with its promise line in body ink directly under it at a conversational
   size. The pair reads as one breath: hello, promise, action. */
/* margin-top DECLARED 2026-08-21 (operator: "reduce the padding at the top by ~10px or
   so (between the black horizontal line, and where it says: 'Hi — I'm rulesage.'"). It
   was the browser's h1 default, .67em = 16.08px, under main's 1rem — 32.08px measured
   at 414px. .38rem = 6.08px makes it 22.08px, exactly 10.00px tighter, on the one
   declaration that owns it rather than on main's padding, which every page shares. The
   rule rides the CLASS, and the class is worn twice, so the ask ANSWER page's heading
   (shelf-ask.html:60) moved up the same 10px — a second surface the ask did not name. */
h1.greeting { font-size: 1.5rem; margin-top: .38rem; margin-bottom: 0.25rem; }
.greeting-sub { color: var(--ink); font-size: 1.05rem; margin-top: 0;
                margin-bottom: 1.1rem; max-width: 34em; }
/* The header row wraps (2026-08-05): the theme control added ~130px beside the brand and
   the tagline, and on a 320px phone the three of them no longer fit on one line. Wrapping
   drops the control to a second row there instead of squeezing the tagline to nothing. */
header.site {
    padding: .9rem 1rem; border-bottom: 1px solid var(--line);
    display: flex; flex-wrap: wrap; align-items: baseline; gap: .6rem;
}
header.site .brand { font-weight: 700; letter-spacing: .04em; color: var(--accent); }
header.site .tag { color: var(--muted); font-size: .8rem; }
/* THE THEME CONTROL. Past the 44px touch minimum (the feedback-band standard) and
   labelled in WORDS — an icon alone would leave a reader guessing which way it goes.
   Its two labels are both authored in the markup and the var-set shows exactly one, so
   the visible words and the button's accessible name are the same words and no script
   ever composes a sentence.

   THE DECLARATION PAIR ON EACH LABEL IS THE NO-var() FALLBACK, and it defaults to the
   LIGHT label on purpose. An engine with no custom-property support ignores the second
   declaration and keeps the first — and that engine has no theme at all: every colour on
   this page comes from a variable, so it resolves none of them and paints the browser's
   own white page with its own black text. The reader is therefore looking at a light
   screen, and the honest offer is "Switch to dark". (It used to default the other way, on
   the reasoning that such an engine "is on the dark default" — it is not; the dark default
   is a var-set it cannot read.) Same rule, same reason, for the footer mark below. */
header.site .theme-toggle {
    margin-left: auto; align-self: center; min-height: 44px;
    padding: .5rem .9rem; border-radius: 999px;
    border: 1px solid var(--control-line); background: var(--panel2);
    color: var(--text); font: inherit; font-size: .82rem; cursor: pointer;
}
.theme-when-dark { display: none; display: var(--when-dark); }
.theme-when-light { display: inline-block; display: var(--when-light); }
/* THE ANNOUNCEMENT BANNER (2026-08-12) — the warm, gentle notice strip the header fragment
   emits ABOVE itself when a deployment turns it on (rulesage.announcement.*; see the
   `announcement` fragment). A full-width strip, so it reads as a property of the page rather
   than a card dropped onto it, with the same 1rem horizontal padding as the header below it.

   NO NEW PALETTE TOKEN, DELIBERATELY, so ThemeContrastFenceTest covers it unchanged: it
   borrows the card's own --panel ground, the gold --accent for its leading bar and bell (the
   same left-bar idiom .citation / .draft / .recall already use), the near-black --ink for the
   message and --muted for the detail beneath it. Every one of those pairs — ink-on-panel,
   muted-on-panel, accent-on-panel — is already measured in theme-contrast-pairs.tsv. Warm
   because the accent is gold; gentle because the ground is the paper every card sits on, not
   a state colour that would read as an alarm. */
.announcement {
    display: flex; align-items: baseline; gap: .55rem;
    padding: .7rem 1rem; background: var(--panel);
    border-bottom: 1px solid var(--line); border-left: 4px solid var(--accent);
}
.announcement .announcement-icon { flex: 0 0 auto; }
.announcement .announcement-body { min-width: 0; }
.announcement .announcement-message { margin: 0; color: var(--ink); font-weight: 700; }
.announcement .announcement-detail { margin: .15rem 0 0; font-size: .9rem; }
.card {
    background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
    padding: 1rem; margin: .8rem 0;
}
/* A RESULTS PANEL SITS UNDER THE PAGE (2026-08-07, the operator: while a ruling is building the
   panel "blends in with the bg"). ONLY here — the paper palette is his and stays exactly as
   it is everywhere else. The card fill is deliberately lighter than the page across the rest
   of the site; on the one surface a reader stares at while waiting, that lightness costs the
   panel its edge, so this rule alone steps it the other way. Dark theme is untouched: its
   cards already sit above a darker page and read as raised. */
.card.results { background: var(--results-fill); border-color: var(--results-line); }
/* THE VERIFYING PANEL'S FIRST LINE, AT THE CARD REGISTER (2026-08-21, operator: "there's
   a decent gap ... between 'Verifying…' and the top of the panel, and I only want a SMALL
   amount of padding there"). It was never padding, which is why no padding declaration
   owned it: these two cards open on an <h1>, and an h1 carries the browser's .67em top
   margin — 21.44px at the 2em default — on top of .card's 1rem. Measured 38.44px;
   zeroed, 17px, which is .card { padding: 1rem } plus the border, the register every
   other card's first line already sits at. BOTH ids: stream.js swaps #stream-card for
   #stream-waiting between books, so one id alone puts the same panel's first line back
   at 38.44px mid-read. Left at the browser default on purpose — the non-streaming status
   cards, ANSWERING at ask-status.html:140, QUEUED at :207, FAILED at :442, which this ask
   did not name. Two ids rather than .card.results > h1, which would take all three. */
#stream-card > h1, #stream-waiting > h1 { margin-top: 0; }
.muted { color: var(--muted); }
/* The working dots (operator ruling, 2026-08-02 v2): the heading text sits still and only
   the ellipsis breathes, each dot in sequence — the thinking-indicator idiom — so
   the finalize window (draft complete, gate + persist still running) never reads
   as a hang. Opacity-only, subtle (rest .25 -> swell 1), staggered 200ms; the dots
   are aria-hidden (screen readers hear the plain heading) and the whole cue turns
   off for reduced-motion readers. */
.working-dots { letter-spacing: .06em; margin-left: .05em; }
.working-dots span { display: inline-block; opacity: .25;
    animation: dot-breathe 1.4s ease-in-out infinite; }
.working-dots span:nth-child(2) { animation-delay: .2s; }
.working-dots span:nth-child(3) { animation-delay: .4s; }
@keyframes dot-breathe { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .working-dots span { animation: none; opacity: .7; } }
/* ============================================================================
   MEEPLE MODE (2026-08-18) — the per-reader whimsy toggle and the three pieces of
   chrome it buys. Restraint by default, spectacle by consent.

   THE BOUNDARY, AND IT IS THE WHOLE FEATURE: chrome only, NEVER answers. Not one
   rule below touches a word, a number, a citation or a receipt. With the mode on
   and off, every ruling, every checklist, every wrench and every authored sentence
   in this app renders byte-identically — MeepleBoundaryFenceTest reads the tree for
   that, in the floor, in every lane, and the trust and operator surfaces never see
   so much as the light switch.

   EVERY RULE IS UNDER body.meeple, WITHOUT EXCEPTION, and on two of the three that
   is redundant — the shimmer's own element and the footer's pawn only exist in the
   served bytes when the mode is on, so the class adds nothing there. The redundancy
   is deliberate and it is the cheap half of a belt: markup gating and style gating
   are two independent statements of the same boundary, and a later edit that
   loosens one still cannot leak the other. It also makes the sheet greppable — the
   answer to "what changes for a meeple reader" is every rule with that class on it.

   WHY THIS SECTION SITS ABOVE THE SAGE'S RATHER THAN BELOW IT. The sage's fences
   slice its stylesheet from `.sage { margin` to the LAST reduced-motion block in the
   sheet, so a second such block written after them would swallow theirs and the
   uniqueness and specificity arms would start reading the wrong bytes. Placed here,
   this section carries its own reduced-motion block and the sage's slice is
   untouched. The one rule below that has to OUT-RANK a sage rule does it on
   specificity (body.meeple adds an element-and-class), which order cannot affect.

   EVERY COLOUR IS A TOKEN AND NO PAIR IS NEW. The glints are --accent, the same gold
   the fairy dust already uses; the pawn is --muted-dim resting and --accent lit; the
   focus ring is --info. All three pairs are already published in
   theme-contrast-pairs.tsv — muted-dim/bg (the build stamp's own row), accent/bg
   (link text on the page) and info/bg (the ring) — and the tsv's closing note names
   them in the same order. This section adds no row to that table and no colour to
   this sheet.
   ============================================================================ */
/* THE CONTROL: a pawn at the end of the build stamp, the quietest line on the page.
   It rests at the stamp's own dim grey and lights to the gold every other control in
   this app uses, so "on" is a state a reader can see rather than remember. No
   underline: it is a glyph, and an underlined pawn reads as a typo. */
footer.site .meeple-toggle { position: relative; margin-left: .6em;
    text-decoration: none; color: var(--muted-dim); font-size: .9rem;
    line-height: 1; vertical-align: baseline; }
footer.site .meeple-toggle:hover { color: var(--accent); }
footer.site .meeple-toggle:focus-visible { outline: 2px solid var(--info);
    outline-offset: 2px; border-radius: 4px; }
body.meeple footer.site .meeple-toggle { color: var(--accent); }
/* THE GLYPH IS ~14px AND THE TARGET IS 44px, the same not-a-contradiction the two
   doors resolve the same way: the pawn has to read as part of the build stamp rather
   than as a button bolted under it, and the feedback band's 44px minimum has to be
   hittable with a thumb on the iPad this footer is read on. So an invisible child
   carries the target, overhanging the glyph on every side. It can steal nothing —
   the stamp beside it is not interactive, and the footer's own bottom padding is
   wider than the overhang. */
footer.site .meeple-toggle::after {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: 44px; height: 44px; transform: translate(-50%, -50%); }
/* THE SCREEN EFFECT — the front page's un-chosen door dissolving at viewport scale
   instead of at its own. Fixed rather than absolute, so it is the SCREEN that
   shimmers and not the block the doors sit in; pointer-events off, so a decoration
   can never eat a tap; aria-hidden in the markup, so a reader who never sees it
   loses nothing at all.

   SPARSE, AND THAT IS THE RULING. Fourteen specks across a whole viewport for ~1.2s
   is restraint even in spectacle: the operator's word for the register is "something
   chill", and a screenful of gold would be the one thing the whole delight arc has
   refused twice. The glints are the SAME 3px dot in the SAME gold as the doors' own
   fairy dust — this is the same gesture, drawn larger, not a second one. */
body.meeple .meeple-shimmer { position: fixed; left: 0; top: 0; right: 0; bottom: 0;
    pointer-events: none; z-index: 4; }
body.meeple .meeple-shimmer span { position: absolute; width: 3px; height: 3px;
    border-radius: 50%; background: var(--accent); opacity: 0; }
body.meeple .meeple-shimmer span:nth-child(1) { left: 6%; top: 74%; animation-delay: .02s; }
body.meeple .meeple-shimmer span:nth-child(2) { left: 14%; top: 38%; animation-delay: .21s; }
body.meeple .meeple-shimmer span:nth-child(3) { left: 21%; top: 88%; animation-delay: .09s; }
body.meeple .meeple-shimmer span:nth-child(4) { left: 29%; top: 17%; animation-delay: .33s; }
body.meeple .meeple-shimmer span:nth-child(5) { left: 36%; top: 61%; animation-delay: .15s; }
body.meeple .meeple-shimmer span:nth-child(6) { left: 44%; top: 29%; animation-delay: .27s; }
body.meeple .meeple-shimmer span:nth-child(7) { left: 51%; top: 83%; animation-delay: .05s; }
body.meeple .meeple-shimmer span:nth-child(8) { left: 58%; top: 46%; animation-delay: .30s; }
body.meeple .meeple-shimmer span:nth-child(9) { left: 65%; top: 12%; animation-delay: .18s; }
body.meeple .meeple-shimmer span:nth-child(10) { left: 72%; top: 69%; animation-delay: .12s; }
body.meeple .meeple-shimmer span:nth-child(11) { left: 79%; top: 34%; animation-delay: .36s; }
body.meeple .meeple-shimmer span:nth-child(12) { left: 86%; top: 91%; animation-delay: .24s; }
body.meeple .meeple-shimmer span:nth-child(13) { left: 92%; top: 53%; animation-delay: .07s; }
body.meeple .meeple-shimmer span:nth-child(14) { left: 97%; top: 22%; animation-delay: .39s; }
/* ⚠ LONGHANDS, NEVER THE `animation` SHORTHAND, for exactly the reason the fairy
   dust's own trigger carries: the shorthand resets every animation-* it does not
   mention — animation-delay back to 0s among them — and these triggers out-rank the
   :nth-child rules that hold the stagger. Written short, all fourteen fire together
   and a chill drift becomes a flashbulb.

   ONE RULE NOW (2026-08-18, the radio rebuild): the trigger is a checked radio and
   the general sibling combinator — no :has() anywhere in the doors any more, so the
   engines that used to be denied the shimmer now get it too. */
body.meeple .sage-door-radio:checked ~ .meeple-shimmer span {
    animation-name: meeple-shimmer; animation-duration: 1.2s;
    animation-timing-function: ease-out; animation-fill-mode: forwards; }
/* TWO RULES NOW (2026-08-20, the control split). A voice's door is a <button>, so it
   checks nothing and the trigger above cannot reach it — a meeple reader would have
   silently lost the upgrade on exactly the doors this arc is adding. The script marks
   the door row and this is the twin, with EVERY NUMBER THE SAME: same keyframe, same
   1.2s, same easing, same fill. Restraint by default, spectacle by consent, on both
   kinds of door. */
body.meeple .sage-doors.sage-dusting .meeple-shimmer span {
    animation-name: meeple-shimmer; animation-duration: 1.2s;
    animation-timing-function: ease-out; animation-fill-mode: forwards; }
@keyframes meeple-shimmer {
    0% { opacity: 0; transform: translateY(0) scale(.5); }
    30% { opacity: .45; }
    100% { opacity: 0; transform: translateY(-3.5vh) scale(1); }
}
/* THE FULLER BLOOM on a correct guess — the one meeple rule that has to WIN rather
   than merely exist. The sage's own bloom trigger is (0,6,1); this is the same
   selector with body.meeple in front, which is (0,6,2), so it takes the keyframe
   whatever order the sheet is read in. Fuller means exactly that: a shade brighter at
   the peak, a little further up, a little longer. It is still six specks. */
body.meeple .sage-guess .sage-radio-joke:checked ~ .sage-verdict-joke.right .sage-dust span,
body.meeple .sage-guess .sage-radio-wisdom:checked ~ .sage-verdict-wisdom.right .sage-dust span {
    animation-name: meeple-bloom; animation-duration: 1.4s;
    animation-timing-function: ease-out; animation-fill-mode: forwards; }
@keyframes meeple-bloom {
    0% { opacity: 0; transform: translateY(0) scale(.6); }
    35% { opacity: .9; }
    100% { opacity: 0; transform: translateY(-1.9em) scale(1.35); }
}
/* REDUCED MOTION, HANDLED THE ONLY WAY IT CAN BE: every selector that starts one of
   these animations, repeated verbatim. A media query adds no specificity, so a
   shorter spelling here would lose to the trigger it is trying to stop and the
   promise would be prose. Each :has() keeps its own rule, for the reason above.
   Nothing is lost: the door still closes, the verdict still reads, the mode is still
   on — what goes is only the movement. */
@media (prefers-reduced-motion: reduce) {
    body.meeple .sage-door-radio:checked ~ .meeple-shimmer span,
    body.meeple .sage-doors.sage-dusting .meeple-shimmer span {
        animation-name: none; opacity: 0; }
    body.meeple .sage-guess .sage-radio-joke:checked ~ .sage-verdict-joke.right .sage-dust span,
    body.meeple .sage-guess .sage-radio-wisdom:checked ~ .sage-verdict-wisdom.right .sage-dust span {
        animation-name: none; opacity: 0; }
}
/* ============================================================================
   THE SAGE'S CHROME (2026-08-18) — the drawn entry and its tag, the waiting
   page's guess game, and the front page's two doors.

   THE DELIVERY CONTRACT, and every rule below is written to it: the server picks
   the entries DURING render, the delivered page is COMPLETE, and there is ZERO
   JavaScript on any of it. The disclosure is the browser's own <details>; the
   quiz is two radio inputs and their labels; the effects are keyframes. Nothing
   here fetches, and nothing here is composed after the page lands.

   EVERY COLOUR IS A TOKEN AND EVERY PAIR IS MEASURED. The gold is --accent (the
   glints and the correct verdict) and the buzzer is --bad, both on
   --results-fill inside the waiting card and on --bg on the front page and the
   404 — four rows in theme-contrast-pairs.tsv, so a palette edit that dims either
   past legibility fails the build rather than the reader.

   REDUCED MOTION IS HANDLED ONCE, AT THE BOTTOM, for everything in this section:
   zero animation, every verdict and every entry still perfectly readable. The
   effects are garnish, and no fact rides on any of them.
   ============================================================================ */
.sage { margin: .9rem 0 .2rem; }
.sage-lead { color: var(--muted); font-size: .78rem; text-transform: uppercase;
    letter-spacing: .06em; margin: 0 0 .3rem; }
/* The entry itself, IN FULL — never clamped, never behind a click. A reader on a
   status page is captive for the length of a generation, which is exactly the
   wait this is here to fill. */
.sage-entry { margin: 0; max-width: 34em; }
.sage-tag { color: var(--muted); font-size: .82rem; margin: .35rem 0 0; }

/* THE GUESS GAME — pure CSS, and honest by construction: BOTH verdicts are
   composed by the server, which knows which book it opened, and :checked reveals
   only the one the reader picked. Nothing on the client decides anything.

   The radios are visually hidden but NOT display:none, so they stay focusable and
   the labels stay their accessible names; the labels wear the ask-chip's own
   chrome (999px, --panel2 on --control-line) because that is this app's word for
   "tappable", and past the 44px touch minimum the feedback band set. */
.sage-guess { position: relative; margin: .5rem 0 0; }
.sage-radio { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0;
    opacity: 0; }
.sage-guess-btn { display: inline-block; margin: 0 .4rem .2rem 0; min-height: 44px;
    padding: .55rem .9rem; border-radius: 999px;
    border: 1px solid var(--control-line); background: var(--panel2);
    color: var(--text); font: inherit; font-size: .88rem; cursor: pointer; }
/* The same gold edge the doors take, for the same reason and no more than that: these two
   already carry the pill, the fill and the 44px target, so the affordance was never the
   gap here — what was missing was the acknowledgement a pointer expects back. */
.sage-guess-btn:hover { border-color: var(--accent); }
.sage-radio:focus-visible + .sage-guess-btn {
    outline: 2px solid var(--info); outline-offset: 2px; }
.sage-verdict { display: none; position: relative; margin: .45rem 0 0;
    padding: .3rem .5rem; border-radius: 8px; }
/* The pairing is spelled out per guess rather than as one :checked rule, and that
   is the whole honesty of it: a rule matching ANY checked radio would reveal the
   other guess's verdict too, and the reader would be shown both answers. */
.sage-guess .sage-radio-joke:checked ~ .sage-verdict-joke,
.sage-guess .sage-radio-wisdom:checked ~ .sage-verdict-wisdom { display: block; }
.sage-verdict.right { color: var(--accent); }
.sage-verdict.wrong { color: var(--bad); }
/* THE BUZZER: one soft outward pulse in the muted red, arcade energy without the
   arcade — no shake, no repeat, gone in half a second. */
.sage-guess .sage-radio-joke:checked ~ .sage-verdict-joke.wrong,
.sage-guess .sage-radio-wisdom:checked ~ .sage-verdict-wisdom.wrong {
    animation-name: sage-buzz; animation-duration: .55s;
    animation-timing-function: ease-out; animation-iteration-count: 1; }
@keyframes sage-buzz {
    0% { box-shadow: 0 0 0 0 var(--bad); }
    40% { box-shadow: 0 0 0 3px var(--bad); }
    100% { box-shadow: 0 0 0 3px transparent; }
}
/* THE FAIRY DUST — tiny glints in the muted gold, low opacity, drifting gently upward as
   they fade. TWO USERS, AND NEITHER OF THEM IS "three to five", which is what this line
   said until 2026-08-21: the guess box marks a correct answer with the SIX spans authored
   below (:nth-child(1) through (6)), and the sweep under the doors is server-derived at
   SageDust.COUNT = 36 motes. ~1s, ease-out, no burst and no bounce:
   the operator's word for the register is CHILL. Purely decorative and
   aria-hidden; a reader who never sees it loses nothing, because the verdict is a plain
   fact of the markup underneath and the door it was chosen against is STILL STANDING —
   no door has disappeared since 2026-08-20 (⧖ D4: no door ever disappears) — so the dust
   is decoration over a page that is already complete without it. */
.sage-dust { position: absolute; left: 0; top: 0; width: 8em; height: 1.6em;
    pointer-events: none; }
.sage-dust span { position: absolute; width: 3px; height: 3px; border-radius: 50%;
    background: var(--accent); opacity: 0; }
/* The doors' motes carry their own size inline; this is only the floor a size-less span
   would land on, and it exists so a stray span is a speck rather than a 3px dot. */
.sage-dust-line span { width: 1px; height: 1px; }
.sage-dust span:nth-child(1) { left: .4em; top: 1em; animation-delay: 0s; }
.sage-dust span:nth-child(2) { left: 1.7em; top: 1.2em; animation-delay: .12s; }
.sage-dust span:nth-child(3) { left: 3.1em; top: .9em; animation-delay: .06s; }
.sage-dust span:nth-child(4) { left: 4.4em; top: 1.15em; animation-delay: .2s; }
.sage-dust span:nth-child(5) { left: 5.6em; top: 1em; animation-delay: .15s; }
.sage-dust span:nth-child(6) { left: 6.8em; top: 1.2em; animation-delay: .26s; }
/* THE LINE-DRAWING SWEEP (operator-ruled 2026-08-20 off the v0.228.0 preview: "ever so
   more prominent... extend out longer like it's drawing the line with magic"). The
   DOORS' dust only — the guess page's verdict bloom keeps its own compact geometry.
   The motes across 16em (SageDust.COUNT — thirty-six at this writing, and the count lives
   THERE), the whole sweep done in 1.1s (operator-tuned LIVE off the preview through six cuts, 2026-08-20 — length, count, arc, stars, then tempo twice; the final beat: dust finishes just as the words do, text speed untouched), delays cascading strictly left-to-right, so the sparkle
   travels SIXTEEN EM, ALWAYS — from the spoken line's own origin, never the width that
   line is about to occupy (arc-end ledger R1-F6: four sibling comments were corrected on
   2026-08-20 and this fifth one was missed). The dust leads, the words follow. Still the CHILL register: same stagger mechanism (longhands, never the
   `animation` shorthand — it resets animation-delay and turns the sweep into the
   BURST that was ruled out), just a longer road and a touch more light. */
/* ⚠ THE SWEEP IS ANCHORED TO THE SPOKEN LINE, NOT TO THE DOOR ROW (2026-08-20; the
   operator on three devices: "tracks text correctly on desktop, but is off on ipad").

   WHY IT DRIFTED, exactly. .sage-dust is position:absolute and its containing block is
   .sage-doors, which is position:relative — so `top: 0` anchored the trail to the top-left
   of the DOOR ROW. On a desktop width the pills fit on ONE line and the row's top was
   within a line of the text, so it looked right. At 768-1024px the pills wrap to two or
   three rows, the row's top is one or two pill-heights ABOVE the spoken line, and the dust
   played over the doors instead of over the words. (It got worse the day no door
   disappeared: the row never collapses now, so the old anchor is wrong on every width.)

   THE RULE, AS IT ACTUALLY SHIPS: the origin is the SPOKEN LINE's own content box — that
   half landed and it is what fixed the iPad — and the arc is SIXTEEN EM, ALWAYS, on every
   device. Every particle sits at a PERCENTAGE of the box, so one width scales the trail;
   max-width does the cap with no CSS math function, which the oldest engine in the floor
   does not have. ⚠ This paragraph read "min(its width, the tuned 16em)" until 2026-08-20
   and the line's width was never measured — see SageDust.ARC_EM for the whole of it, and
   ⧖ D14 for what a real first-line measurement would take (OPER-5).

   WITH THE SCRIPT BLOCKED the fallbacks put the box at the container's BOTTOM, which is
   the bottom of the revealed entry — the only reveal that happens without a script is a
   classic's, and its stream is the last thing in the row. Not the first line, but on the
   line rather than a pill-row away from it. */
.sage-dust-line {
    left: var(--sage-dust-x, 0px);
    top: var(--sage-dust-y, auto);
    bottom: var(--sage-dust-b, 0px);
    width: var(--sage-dust-w, 16em);
    max-width: 16em; }
/* ⚠ THE PARTICLES ARE SERVER-RENDERED FROM SageDust, NOT LISTED HERE (2026-08-20; the
   operator: "the dots that come off the text are too big — more particles that are way
   smaller"). Ten hand-written :nth-child rules became thirty-six particles, and thirty-six
   rules would put the tuning in a stylesheet where "make them smaller" is thirty-six edits.
   Each mote carries its own left / top / size / delay inline, derived from ONE constant
   block in Java — the same way the classics' per-word delays already are — so the next
   tuning round is a one-line diff. Everything static about a mote is still static; the one
   trigger rule and the one keyframe are still the whole engine. */
@keyframes sage-glint {
    0% { opacity: 0; transform: translate(0, 0) scale(.6); }
    35% { opacity: .68; }
    100% { opacity: 0; transform: translate(.5em, -1.6em) scale(1.05); }
}
/* The correct guess blooms — the same glints, a shade brighter at the peak (.7 against
   the sweep's .68), per the register ruling. No raw count comparison here any more: this
   box keeps its own compact spans below, while a door's dust is SageDust.COUNT derived
   motes across 16em, and that number lives THERE so it cannot rot in a stylesheet. */
/* ⚠ LONGHANDS, NEVER THE `animation` SHORTHAND, AND THE STAGGER IS WHY. The shorthand
   resets every animation-* it does not mention — including animation-delay, back to 0s —
   and this selector outranks the :nth-child rules that hold the delays. Written short,
   all six glints fire at once and the effect reads as a BURST, which is the one register
   the operator ruled out ("chill", "no burst, no bounce"). */
.sage-guess .sage-radio-joke:checked ~ .sage-verdict-joke.right .sage-dust span,
.sage-guess .sage-radio-wisdom:checked ~ .sage-verdict-wisdom.right .sage-dust span {
    animation-name: sage-bloom; animation-duration: 1.1s;
    animation-timing-function: ease-out; animation-fill-mode: forwards; }
@keyframes sage-bloom {
    0% { opacity: 0; transform: translateY(0) scale(.6); }
    35% { opacity: .7; }
    100% { opacity: 0; transform: translateY(-1.4em) scale(1.1); }
}

/* THE TWO DOORS (front page only — the CLASSICS are the shelf greeting's alone. Their
   DUST stopped being, on 2026-08-21: the operator asked for the glimmer on the game
   page's helpers tray too, so the sweep rides both sage surfaces now).

   REBUILT ON RADIOS (2026-08-18). The <details>+:has() build's documented
   degradation — "the first door simply stays put" — turned out to describe the
   operator's own phone, and the same live read ruled the choreography itself
   funky (the pop, the slide). The mechanism now is two hidden radios and the
   general sibling combinator: CSS old enough to vote, both directions on every
   engine that has ever shipped, and NO :has() remains in this section.

   ⚠ NO DOOR EVER DISAPPEARS (operator-ruled 2026-08-20, and this paragraph was
   two presses behind it until audit B7). Both pills STAND before and after a
   press, exactly like the utility pills beside them, and every door is open court. The
   radio stays because it is the JS-off half — a checked radio is what reveals a
   classic's entry with no script at all — not because a choice is one-way; the
   paragraph here used to argue the opposite ("the one-way door got STRONGER in
   the move ... they can't go back") and that reasoning is retired, kept only as
   the thing this comment is correcting.

   THE CHOREOGRAPHY: on press, nothing leaves the flow — the retired dissolve
   took both pills out at once, and the ruling that replaced it is that a reader
   never loses a door they might want next. The dust sweeps over the SPOKEN LINE
   (the box is absolutely positioned and the script anchors it to that line's own
   box on every press), and the entry STREAMS IN word by word beneath the standing
   row — the draft stream's own register, because this line is coming from
   rulesage too. Nothing moves but opacity. The flex row survives from the first
   build for the same 390px reason: two pills wrap between, never mid-sentence. */
/* ⚠ THE ROW GAP IS .85rem AND THE ARITHMETIC IS WHY, not taste. At 390px six pills wrap
   to two or three rows. (THIS RULE NOW GOVERNS TWO ROWS OF DIFFERENT SIZES: the front
   page's five pills since the 2026-08-24 room ruling — the operator took a door off that
   row because of this very wrap, "there's just not enough room" — and the game page tray's
   three since the 2026-08-25 placement. Both wrap later than these figures were measured
   at, and the gap stays for both: the arithmetic is about two ADJACENT rows whenever a
   width produces two, and a narrow enough screen still does. The tray declares no geometry
   of its own precisely so it inherits this; SageVoiceContractFenceTest holds that.)
   A pill is 31.28px tall and its invisible ::after tap target is
   44px, so the target overhangs 6.36px above and below; with a 7.2px row gap two adjacent
   rows' targets OVERLAPPED by 5.52px, both ::after boxes are position:absolute with no
   z-index, and the later door in DOM order won the hit. A mis-tap now costs the reader
   their answer, because pressing a door closes the panel they were reading. 13.6px of row
   gap is the first value that clears the 12.72px of combined reach. The COLUMN gap is
   unchanged at .45rem — the pills were never too close side by side.

   ⚠ EVERY FIGURE IN THIS PARAGRAPH IS AT A 16px ROOT, and only there. `::after`'s 44px is
   the one ABSOLUTE length in the block — gap, padding and font-size are all rem — so a
   user-set root BELOW ~15px narrows the clearance (at a 12px root the pill falls to
   ~23.96px, combined reach rises to ~20.04px and the row gap falls to 10.2px, and the
   adjacent-row overlap this .85rem was chosen to clear comes back), while a root above it
   widens the clearance. Browser page-zoom is safe: it scales both. Putting both quantities
   in ONE unit (a px row gap) was proposed at the arc end and RULED NOT BUILT, in this round
   or any other — freezing the gap against the reader's root size runs the accessibility
   trade the wrong way, and rem is the side that scales with the type. */
.sage-doors { position: relative; display: flex; flex-wrap: wrap;
    align-items: baseline; gap: .85rem .45rem; margin: 0 0 1.1rem; }
/* THE DOOR HAS TO LOOK LIKE A DOOR (operator, reading v0.205.0 live: "can you make it
   clearer the joke or wisdom are links / buttons?"). The first cut painted the two
   summaries gold and left them otherwise shaped like the sentence they end — and a door
   nobody notices is a wall. They wear the house's own word for "tappable" now: the
   ask-chip pill (999px, --panel2 inside --control-line), at the CHIP register (.82rem)
   rather than the guess buttons' larger one, because these two sit inside the greeting
   rather than under a card — the shape is what has to read, not the size.
   EVERY PAIR HERE IS ALREADY MEASURED in theme-contrast-pairs.tsv and no token is new:
   the gold word on --panel2 (text, 4.5) and on --bg beyond it; the hairline and the fill
   as published decor; --accent as the hover boundary (the base-book row's own ui pair);
   --info as the focus ring. So the pill costs the contrast table nothing but a comment. */
.sage-door-pill {
    display: block; position: relative; cursor: pointer; color: var(--accent);
    padding: .3rem .7rem; border-radius: 999px;
    border: 1px solid var(--control-line); background: var(--panel2);
    font-size: .82rem; line-height: 1.5; }
/* THE PILL IS 31px TALL AND THE TARGET IS 44px, WHICH IS NOT A CONTRADICTION — it is the
   only way to have both things this door needs. The chip register is what makes it read as
   one line of the greeting rather than a toolbar bolted under it; the feedback band's 44px
   minimum is what makes it hittable with a thumb on the iPad this page is actually read on,
   and it is the floor the guess buttons and the expansion rows already keep. So the pill
   keeps its size and an invisible child carries the target, overhanging ~6px above and
   below. It can steal nothing, and there are THREE guards now rather than the two this
   paragraph used to name. ABOVE: the paragraph is not interactive. BELOW THE LAST FLEX
   LINE: this block's own 1.1rem bottom margin is 17.6px against 6.36px of reach. BELOW ANY
   OTHER LINE: since the reversal an ANSWER PANEL lands INSIDE this container beneath the
   pill row (.sage-door-stream.sage-speaks — flex: 1 1 100%, margin-top .15rem), so the
   guard there is the container's ROW GAP — 13.6px between flex lines plus the panel's own
   2.4px, 16px against the same 6.36px. That third case is the load-bearing one, because
   the fate answer's LINK lives in that panel. And each ::after is bounded by its own
   summary's edges, so the two doors cannot reach into one another across the gap. */
.sage-door-pill::after {
    content: ""; position: absolute; left: 0; right: 0; top: 50%;
    height: 44px; transform: translateY(-50%); }
/* HOVER MOVES THE BOUNDARY, NOT THE FILL, and that is a light/dark decision rather than a
   taste one: --control-line and --field-line hold the SAME value in the warm-paper theme,
   so a hairline step between them is a no-op on paper, and the two panel fills differ by
   about 1.1:1 in both. The gold edge is the one shift that is plainly visible in both
   themes — and it is the colour the word inside is already wearing. */
.sage-door-pill:hover { border-color: var(--accent); }
/* A VOICE'S DOOR IS A <button> AND A CLASSIC'S IS A <label> (the control split,
   2026-08-20) — so the pill above has to survive being both. A button brings its own
   user-agent font, its own centring and, on iOS, its own rounded chrome; every one of
   those would make the utility doors visibly a different species from the two beside
   them, which is the one thing the split was not allowed to cost. `font: inherit`
   takes the page's family, size and weight, and the two appearance resets take the
   platform's. Nothing here is a new value: the pill's own declarations still decide
   what the door LOOKS like. */
button.sage-door-pill { font: inherit; font-size: .82rem; line-height: 1.5;
    text-align: left; -webkit-appearance: none; appearance: none; }
/* The ring rides the LABEL when its own radio holds focus — per-door pairs, because a
   single any-radio rule would ring both pills at once and point at neither. */
/* ⚠ ONE LINE PER CLASSIC DOOR, AND THAT IS ALL IT IS NOW. This used to say "a new voice
   adds its own", and it does not any more: a voice is a button, so it takes focus
   itself and the rule below serves every voice this app will ever have. What is left
   here is the two classics' problem, which does not generalise and is not an oversight
   — a single `.sage-door-radio:focus-visible ~ .sage-door-pill` would ring BOTH pills
   at once and point at neither, which is worse for the reader this rule exists for than
   having no ring at all. The wisdom selector stays LAST — the surface fence reads this
   rule by that exact opening string. */
.sage-door-radio-joke:focus-visible ~ .sage-door-pill-joke,
.sage-door-radio-wisdom:focus-visible ~ .sage-door-pill-wisdom {
    outline: 2px solid var(--info); outline-offset: 2px; }
/* FOCUS PARITY ACROSS THE SPLIT — the same 2px --info at the same offset the classics
   get, on the control that holds focus directly. This is the half of the split a reader
   cannot check by looking at a closed page, and it is the half a keyboard reader meets
   first. */
button.sage-door-pill:focus-visible {
    outline: 2px solid var(--info); outline-offset: 2px; }
/* ⚠ THE ROLLED GAME'S LINK, and the underline is the whole point: WCAG 2.1 §1.4.1, Level
   A. The script builds this anchor at runtime; until 2026-08-20 the class had no rule, so
   it fell to the base anchor colour at 1.86:1 dark / 1.50:1 light against the sentence
   around it. The cue is deliberately not a colour. SageSurfaceFenceTest carries the
   argument and the arm (SURF-1). */
.sage-pick { text-decoration: underline; text-underline-offset: .15em; }
.sage-pick:focus-visible {
    outline: 2px solid var(--info); outline-offset: 2px; }
/* ⚠ THE INSTANT VANISH IS RETIRED (operator, reading live 2026-08-20: "the joke or
   wisdom make the buttons disappear — we decided to NEVER make the buttons disappear").
   One rule collapsed EVERY pill for EITHER radio, and with it went the two-geometry law
   that made the classics one-way while the utility doors stayed open. EVERY door
   is open-court now: both classic pills stand before and after a press, exactly like the
   ones beside them, and a table can take any tool in any order without a reload.

   WHAT THE RETIREMENT SETTLES, recorded so the reasoning is not lost with the rule:
   the collapse's nine declarations (max-width, padding, border-width, font-size and the
   rest) each closed one part of the ~24px empty lozenge and the ~20px dead strip a
   partial collapse leaves; and `visibility: hidden` was audit M1's cure for the half only
   a keyboard reader met — three focusable buttons nobody could see. BOTH problems are
   MOOT rather than solved: nothing collapses, so nothing is a lozenge, and nothing leaves
   the tab order because nothing leaves the page. The dust that carried the farewell is
   now the REVEAL's own gesture and plays for every door on the row.

   ⚠ AND THE ONE-PANEL-AT-A-TIME RULE MOVED WITH IT — see .sage-hushed below. */
/* ...AND ONE ANSWER AT A TIME, IN BOTH DIRECTIONS (the mirror of audit finding S1).

   S1's rule was a checked classic radio hiding any following .sage-door-stream that also
   wore .sage-speaks — a chosen classic silencing an open voice, in the one direction the
   script could not reach, because a classic carried no hook. TWO things changed that. The classics now
   carry a hook (their radio, granted deliberately for the re-press), so the script can
   close a voice itself. And every door is open-court, so a voice pressed AFTER a
   classic must SHOW — which S1, left standing, would have silently prevented: its (0,4,0)
   beats the voice reveal's (0,3,0) while the classic's radio is still checked, and the
   reader would have got a blank row from a door that looked fine. That is why S1 is
   replaced rather than kept: it is not orthogonal any more.

   What replaces it is the same shape in the opposite class, and the script drives both
   halves symmetrically: opening any door hushes every other, and a hushed CLASSIC wears
   this. (0,4,0) beats the classics' own reveal (0,3,0), so a hushed stream stays shut
   while its radio is still checked — which is the state CSS alone cannot leave, since a
   radio cannot be un-checked and this file may never write one. It starts no animation,
   so there is nothing for the reduced-motion block to repeat. */
.sage-door-radio:checked ~ .sage-door-stream.sage-hushed { display: none; }
/* THE STREAM — hidden until chosen, then a full-width flex item on the row beneath the
   doors, which every one of them still stands on.

   ⚠ THE GAP COMPENSATION IS RETIRED AND THE NUMBER IS NOW THE VOICE PANEL'S (2026-08-20).
   This rule used to carry margin-top: -.45rem, pulling back the container's row gap
   because the classics' pill line COLLAPSED to zero height and the entry had to land where
   the pills had been. Nothing collapses any more, so the premise is gone: a classic's
   entry lands under a standing row exactly as a voice's answer does, and it takes the same
   .15rem of breath. That is the whole of the operator's "one panel geometry" ask — the two
   rules are now identical, and a fence holds them so.

   (The number is deliberately NOT the -.7rem an earlier draft computed. That was .15 minus
   the new .85rem row gap, correct while the pill line still collapsed; with the row
   standing it would have tucked the classics' entry 13.6px tighter to its door than every
   other answer on the page.)

   TWO RULES, ONE NUMBER, and the duplication is required rather than sloppy: the surface
   fence resolves a rule by its exact opening selector and asserts uniqueness, and two of
   its arms each need THEIR selector last in its own block. Both cannot be last in one.

   Each word arrives on its delay as a plain fade: no translate, no slide — the entry
   composes in place, the way the draft does on the waiting page. */
/* position:relative IS the trail's coordinate system: words and motes resolve against
   this box. It moves no layout. */
.sage-door-stream { display: none; position: relative; }
.sage-door-radio-joke:checked ~ .sage-door-stream-joke,
.sage-door-radio-wisdom:checked ~ .sage-door-stream-wisdom {
    display: block; flex: 1 1 100%; max-width: 42em; margin-top: .15rem; }
.sage-word { opacity: 0;
    animation-name: sage-word-in; animation-duration: .26s;
    animation-timing-function: ease-out; animation-fill-mode: forwards; }
@keyframes sage-word-in {
    0% { opacity: 0; }
    100% { opacity: 1; }
}
/* The dust fires from EITHER radio, on every engine, over the entry the reveal just put
   on screen (the .sage-dust box is absolutely positioned, and the script anchors it to
   the spoken line's own box on every press). This IS the pop the reader sees. It used to
   read "at the exact corner the pills held ... the pills themselves are already gone";
   nothing is gone now — no door ever disappears — so the sweep is the ARRIVAL's gesture
   rather than a farewell, and it plays over the words.

   ⚠ THIS RULE IS NOT THE WHOLE ENGINE FOR A CLASSIC, and believing it was cost the
   classics their sweep (audit A1/B4). It only ever fires the animation when the computed
   animation-name CHANGES — so it plays on the first press of a page where nothing else
   has spoken, and on no press after that: the twin rule below sets the same name, so a
   row that has heard a voice is already glinting sage-glint and `:checked` newly matching
   moves nothing. The script therefore re-lights a classic's FIRST press the same way it
   re-lights a voice, and only the first. */
.sage-door-radio:checked ~ .sage-dust span {
    animation-name: sage-glint; animation-duration: .7s;
    animation-timing-function: ease-out; animation-fill-mode: forwards; }
/* ...AND THE SAME DUST FOR A DOOR THAT IS NOT A RADIO (2026-08-20). A voice's
   button checks nothing, so the rule above cannot reach it; the
   script marks the row instead and this rule is the twin. EVERY NUMBER IS THE SAME
   NUMBER — the same keyframe, the same .7s, the same easing, the same fill — because
   the glint register was tuned on a live page (#274 cut it at ten glints across 16em;
   dust v2 re-cut it to SageDust.COUNT — thirty-six — across that same 16em on 2026-08-20,
   and "every number is the same number" is exactly what carried the re-cut into BOTH
   rules) and a second spelling of a tuned gesture is a second gesture. The script
   re-lights it on every ask by replacing the glint element with a clone of itself, which
   is what makes an already-marked row play again. */
.sage-doors.sage-dusting .sage-dust span {
    animation-name: sage-glint; animation-duration: .7s;
    animation-timing-function: ease-out; animation-fill-mode: forwards; }

/* THE FIVE GENIES (2026-08-21) — five keyframes, their classes, one mote, and the
   stand-down that stops the old sweep playing under a trail (it wins by sitting later at
   equal specificity; no script can un-fire a :checked rule). The script builds every mote,
   so none of this is markup. Written tight, and argued where prose is free:
   sage-speaking-v2.js, and the parser-level block beside sageDust. */
.sage-doors.sage-trailed .sage-dust span { animation-name: none; }
.sage-mote{position:absolute;border-radius:50%;background:var(--accent);opacity:0;
    animation-duration:700ms;animation-timing-function:ease-out;
    animation-fill-mode:forwards}
.sage-mote-glow{filter:blur(2.5px) opacity(.3)}
.sage-k-drift{animation-name:sage-drift}
.sage-k-settle{animation-name:sage-settle}
.sage-k-nib{animation-name:sage-nib}
.sage-k-loop{animation-name:sage-loop}
.sage-k-pulse{animation-name:sage-pulse}
@keyframes sage-drift{0%{opacity:0;transform:translate(0,0) scale(.6)}35%{opacity:.68}100%{opacity:0;transform:translate(var(--sage-dx,.5em),var(--sage-dy,-1.6em)) scale(1.05)}}
@keyframes sage-settle{0%{opacity:0;transform:translate(var(--sage-dx,.5em),var(--sage-dy,-1.6em)) scale(1.15)}40%{opacity:.7}100%{opacity:0;transform:translate(0,0) scale(.45)}}
@keyframes sage-nib{0%{opacity:0;transform:translate(0,0) scale(.5)}25%{opacity:.92;transform:translate(.1em,-.15em) scale(1.3)}100%{opacity:0;transform:translate(.35em,-.5em) scale(.7)}}
@keyframes sage-loop{0%{opacity:0;transform:translate(0,0) scale(.6)}18%{opacity:.78;transform:translate(calc(var(--sage-mx,1)*.55em),-.85em) scale(1.05)}42%{opacity:.7;transform:translate(calc(var(--sage-mx,1)*1.25em),-.15em) scale(.95)}68%{opacity:.5;transform:translate(calc(var(--sage-mx,1)*.65em),.6em) scale(.85)}100%{opacity:0;transform:translate(calc(var(--sage-mx,1)*-.15em),-.35em) scale(.5)}}
@keyframes sage-pulse{0%{opacity:0;transform:translate(0,0) scale(.4)}35%{opacity:.6;transform:translate(0,-.15em) scale(1.15)}100%{opacity:0;transform:translate(0,-.6em) scale(1.85)}}

/* THE PRESENCE (2026-08-20) — "rulesage, speaking". The doors did not change; what
   changed is that they now stand inside a character. The heading and the invitation
   are the whole of it, and neither is a control: the row below them is.

   NO NEW COLOUR PAIR. The heading is --accent on --bg and the invitation is --muted
   on --bg, both already in theme-contrast-pairs.tsv and both already worn by text
   on this very page. A presence that cost the contrast table a row would be a
   presence somebody had to re-measure to keep. */
.sage-speaking { margin: 1.2rem 0 0; }
.sage-speaking-heading { color: var(--accent); font-size: .82rem;
    letter-spacing: .02em; margin: 0 0 .15rem; }
.sage-speaking-invite { color: var(--muted); font-size: .82rem; margin: 0 0 .55rem; }

/* A CLIENT-DRAWN VOICE'S PANEL — the same room the classics' entries land in, opened
   by a class the script sets rather than by a per-door :checked rule.

   WHY A CLASS AND NOT THE COMBINATOR THE CLASSICS USE. A `:checked ~` reveal has to
   name its door, so every voice that ever lands would cost this sheet another
   selector — and CSS cannot say "the panel belonging to the radio that is checked".
   The script is already listening to that radio (it has to be: it draws the line),
   so it marks the one panel and this ONE rule serves every voice this app will ever
   have. The classics keep their own two rules, untouched, because they have no
   script and must not acquire one.

   ⚠ THE GEOMETRY IS THE CLASSICS' GEOMETRY NOW, and that is the operator's ask #2
   answered by DELETION rather than by tuning. It used to differ by one declaration —
   this .15rem against the classics' -.45rem gap compensation — because a classic's
   stream landed where its pills had collapsed. No door collapses any more, so both
   panels land under a standing row and both take the same breath. The two rules are
   byte-identical in their declarations and a fence arm holds them equal; they stay TWO
   rules only because the surface fence needs two different selectors each last in its
   own block. */
.sage-door-stream.sage-speaks { display: block; flex: 1 1 100%; max-width: 42em;
    margin-top: .15rem; }
/* 42em IS THE COLUMN (main is 44rem less 1rem of padding a side). All THREE caps moved
   and had to — the panel's two ARE the container. */
.sage-spoken { margin: 0; max-width: 42em; }
/* ⚠ TWO RULES LEFT THIS BLOCK ON 2026-08-20 AND THE PANEL IS THE SPOKEN LINE ALONE NOW
   (operator-ruled 2026-08-20). The rule paragraph beneath every settling answer and the
   whisper-button riding the end of it are both retired: pressing the door again IS the
   draw-again, on every door, so the affordance that said so was a second home for one
   gesture — and it was also the only thing making a voice's panel taller than a classic's,
   which is the delta the operator read as "more indentation / different padding". One panel
   geometry, arrived at by DELETION rather than by tuning. */
/* NO RULE FOR .sage-hint, DELIBERATELY. The hidden description is hidden by the `hidden`
   ATTRIBUTE alone — a display:none belt beside it would be a second place its visibility
   could be edited, and unlike the bank below there is no stylesheet-only fallback worth
   having: a description that leaked into the layout would be one line of prose under the
   doors, which is precisely the visible hint the design put out of scope. */
/* The shipped bank. It already carries the `hidden` attribute — which is the half that
   matters, because it takes two dozen lines out of the accessibility tree — and this is
   the belt beside it. */
.sage-bank { display: none; }

/* GAME HELPERS (2026-08-20) — the sage's compact rendering, beside a game's ask box.
   The browser's own <details> carries the open/close, so this section is four
   declarations and no mechanism: the summary reads at the section-label register the
   rest of that page uses, the doors inside are the SAME pill (`.sage-door-pill`, from
   the rules above — no second definition of the door look exists), and the panel is
   the same `.sage-door-stream`.

   ⚠ THE DUST IS HERE SINCE 2026-08-21, reversing this block's own "NO DUST HERE … the
   doors-and-dust signature is the front page's alone": the operator asked for the glimmer
   on this tray, so it draws the same sageDust fragment. THIS SECTION DECLARES NOTHING FOR
   IT — every dust rule above is keyed on .sage-doors or .sage-dust, both of which this
   row already wore, so the trigger, the keyframe, the meeple twin and the whole
   reduced-motion block reached it unedited; the gameHelpers fragment carries the ruling in
   full. NO NEW COLOUR PAIR either — the summary is --muted on --panel, the card's own
   pair. */
.sage-helpers { margin: .8rem 0 0; }
.sage-helpers-summary { cursor: pointer; color: var(--muted); font-size: .78rem;
    text-transform: uppercase; letter-spacing: .06em; }
.sage-helpers-summary:focus-visible { outline: 2px solid var(--info);
    outline-offset: 2px; }
.sage-helper-doors { margin: .6rem 0 0; }

/* REDUCED MOTION: zero animation — for the doors' sweep, the wait page's bloom and
   its buzzer alike. Every verdict and every entry still appears plainly; what is
   removed is only the movement. (It used to say "instant vanish ... the un-chosen door
   still goes" — the dissolve is retired, so there is no vanish left to make instant.) */
/* ⚠ REDUCED MOTION MUST OUT-SPECIFY THE TRIGGER, NOT MERELY FOLLOW IT. A media query
   adds NO specificity, so `.sage-dust span { animation: none }` (0,1,1) loses to
   `… .sage-verdict-joke.right .sage-dust span` (0,6,1) and the bloom plays anyway — and
   `opacity: 0` does not save it either, because a running animation's keyframes outrank
   an ordinary declaration. So every rule below repeats the FULL trigger selector. The
   duplication is the mechanism; a shorter spelling here is a reduced-motion promise this
   app does not keep. (The doors carry no :has() any more — the radio rebuild.) */
@media (prefers-reduced-motion: reduce) {
    .sage-dust span,
    .sage-door-radio:checked ~ .sage-dust span,
    .sage-doors.sage-dusting .sage-dust span,
    .sage-guess .sage-radio-joke:checked ~ .sage-verdict-joke.right .sage-dust span,
    .sage-guess .sage-radio-wisdom:checked ~ .sage-verdict-wisdom.right .sage-dust span {
        animation-name: none; opacity: 0; }
    .sage-guess .sage-radio-joke:checked ~ .sage-verdict-joke.wrong,
    .sage-guess .sage-radio-wisdom:checked ~ .sage-verdict-wisdom.wrong {
        animation-name: none; }
    /* The stream arrives all at once: same words, same place, zero motion. Same
       specificity as the base trigger; later in the sheet, so the media block
       wins. (No pill moves at all any more — nothing there to disable.) */
    /* ...AND ALL FIVE GENIES, whole selectors repeated (a media query adds none of its
       own). The engine builds no motes under stillness; this is the belt. */
    .sage-k-drift, .sage-k-settle, .sage-k-nib, .sage-k-loop,
    .sage-k-pulse { animation-name: none; opacity: 0; }
    .sage-word { animation-name: none; opacity: 1; }
    /* ...AND THE DIGNIFIED FADE THAT REPLACES THE STAGGER. The rule above stops
       twenty-four words flickering in one at a time; this one gives the sentence back
       a single quiet arrival, as ONE piece, with nothing moving. A fade is not motion
       — nothing travels, nothing scales — so it is the substitute rather than another
       thing to switch off, and a spoken line that simply blinked into existence would
       be the only abrupt thing on a page built for stillness.

       IT LIVES ONLY IN THIS BLOCK, deliberately: with motion allowed, the per-word
       stagger IS the arrival and a second fade over the top of it would be two
       gestures for one moment. And it restarts on every ask because the script builds
       a FRESH element each time — a rule on a surviving node would play once and
       never again. */
    .sage-said { opacity: 0; animation-name: sage-word-in;
        animation-duration: .32s; animation-timing-function: ease-out;
        animation-fill-mode: forwards; }
}
.small { font-size: .82rem; }
.health { font-size: .82rem; color: var(--muted); margin-top: .6rem; }
.health .dot { display: inline-block; width: .6em; height: .6em; border-radius: 50%;
    margin-right: .35em; vertical-align: baseline; }
.health .dot.up { background: var(--good); }
.health .dot.busy { background: var(--warn); }
.health .dot.down { background: var(--bad); }
ul.shelf { list-style: none; margin: 0; padding: 0; }
ul.shelf li { border-bottom: 1px solid var(--line); }
ul.shelf li:last-child { border-bottom: 0; }
ul.shelf a { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem;
    padding: .7rem .3rem; }
/* THE ROW IS ITSELF A FLEX LINE so the one-tap link can hold the row's RIGHT EDGE while the
   title, chip and players·time receipt keep the left (operator ruling, 2026-08-15, made
   against the rendered shelf — the right side of every row was empty).

   A DIRECT-CHILD selector, and that is the load-bearing part: the nested expansion rows
   under a box are `ul.shelf-nested li`, not `ul.shelf > li`, so they keep the ordinary block
   layout they have today and only a top-level row becomes a flex line.

   THE TWO SIBLINGS THAT MUST KEEP THEIR OWN LINES SAY SO. Once the row is a flex container
   every direct child would otherwise join the line — including the shelf answer's receipt
   paragraph and the nested expansion list, both of which belong under the row rather than
   beside it. flex-basis:100% is what puts them back on their own line. */
ul.shelf > li { display: flex; flex-wrap: wrap; align-items: baseline; }
ul.shelf > li > a:not(.teach-link) { flex: 1 1 12rem; min-width: 0; }
ul.shelf > li > .shelf-receipt,
ul.shelf > li > ul.shelf-nested { flex-basis: 100%; }
ul.shelf .group { color: var(--muted); font-size: .78rem; font-family: ui-monospace, monospace; }
/* THE ROW RECEIPT (2026-08-06) — the values a chip above could have excluded this row on, so
   a reader can check a count by reading the list. Muted and small: it is evidence for the
   number above, not a second title. It sits INSIDE the row's flex line and wraps with it. */
ul.shelf .facts { color: var(--muted); font-size: .78rem; }
ul.shelf .score { margin-left: auto; color: var(--muted); font-size: .72rem;
    font-family: ui-monospace, monospace; }
/* THE ONE-TAP DEEPLINK on a shelf row (2026-08-15). It needs a rule of its own ONLY because
   `ul.shelf a` above turns every descendant anchor into the row's full-width flex line with
   the row's padding — without this it would render as a second, empty-looking shelf row
   under each game.

   IT KEEPS THE ACCENT COLOUR, and that is a correction. The first draft made it muted at
   .78rem — byte-identical to the `.facts` receipt one rule up — which meant the app's own
   visual grammar (muted = a fact, accent = something you can press) said "caption" while the
   markup said "link". Small is how it stays quiet; grey is how it stops being an offer at
   all, and a link distinguished from the text around it by nothing whatsoever also fails
   WCAG 1.4.1. Small and accent is the honest combination, and it matches the same
   affordance's second home on the game page.

   THE PADDING IS THE TAP TARGET, and it matters more than the type size here: the reader is
   a non-gamer with a thumb on an iPad, and beside this sits the row anchor's own live hit
   area. Too little padding and a thumb aimed a few pixels wide silently opens the game page
   instead.

   margin-left:auto IS THE RIGHT-ALIGNMENT — the same idiom `.score` one rule up already uses
   to hold the right edge inside the row anchor, now applied one level out. On a narrow
   screen the row wraps and this lands right-aligned on its own line rather than crushing the
   title, which is the behaviour an iPad in portrait should get. */
ul.shelf a.teach-link { display: inline-block; margin-left: auto; text-align: right;
    padding: .35rem .3rem .55rem; color: var(--accent); font-size: .78rem;
    white-space: nowrap; }
/* THE SHELF-ASK BOX WEARS THE FINDER'S CHROME AND NOT ITS CLASS, and that is a JS
   constraint rather than a style choice. Two scripts on the home page bind
   `form.find input[name="q"]` with querySelector, which takes the FIRST match in document
   order — so a second element carrying class `find` above the finder would silently steal
   the live shelf filter and the instant filter. Sharing the declarations here keeps the two
   boxes identical to the eye while leaving exactly one `form.find` on the page. */
form.find, form.ask-shelf { display: flex; gap: .5rem; }
form.find input, form.ask-shelf input {
    flex: 1; padding: .6rem; border-radius: 8px; border: 1px solid var(--field-line);
    background: var(--panel2); color: var(--text); font: inherit;
}
form.find button, form.ask-shelf button {
    padding: .6rem 1rem; border: 0; border-radius: 8px;
    background: var(--accent-fill); color: var(--accent-ink); font-weight: 700;
}
/* A shelf answer's second line: the page the word was found on, under the game's own row.
   It reuses .facts for its muted tone (the row receipt beside it) and only needs to sit
   below the row's flex line rather than inside it. */
ul.shelf .shelf-receipt { display: block; margin: 0 0 .6rem; padding: 0 .3rem; }
.recents { display: flex; flex-wrap: wrap; gap: .4rem; margin: .2rem 0 .2rem; }
.recents a {
    display: inline-block; padding: .3rem .6rem; border-radius: 999px;
    background: var(--panel2); border: 1px solid var(--control-line); font-size: .82rem;
}
.recents a .when { color: var(--muted); font-size: .72rem; margin-left: .35rem; }
.section-label { color: var(--muted); font-size: .78rem; text-transform: uppercase;
    letter-spacing: .06em; margin: .8rem 0 .3rem; }
/* THE SHELF FILTER ROW (2026-08-03 as a complexity strip; generalised 2026-08-06; compacted
   2026-08-07; COLLAPSED TO THREE DROPDOWNS the same day, the operator: "all these inputs should fit
   on 1 row").

   WHAT THE STRIP COST, MEASURED — recorded here because the tool that produced the numbers
   has been retired along with the strip it measured. A browser laid the SERVED bytes out at
   390px: a chip carrying a value and a count was ~52px wide, and eight of them
   plus a spelled-out escape and .5rem gaps needed 687px of a 358px content box. Taking the
   count off the chip bounded it by the 44px touch minimum instead — 352px of a 358px box,
   which fits only with no gap at all — and that was ONE row of three. Three rows built that
   way is a wall of chrome above a list of games, on the iPad this page is read on, in
   portrait, where the first game row has to stay visible.

   WHAT REPLACED IT, MEASURED THE SAME WAY, IN EVERY FILTER STATE: one row at 390px, the
   controls spanning 358px of the 358px box, the document never scrolling sideways, and the
   whole filter block 44px tall where three chip rows were a wall.

   A <select> ENDS THE ARITHMETIC. Eight choices cost the width of one, the phone renders a
   native picker every reader has used a thousand times, and the choices that did not fit
   come back with their counts attached: "4 players (32)" is a line of prose where a 44px
   square was two ambiguous numerals. The row is flex with three equal columns and NOWRAP —
   the probe's question is now "is this one line", and the answer is structural rather than
   arithmetical.

   NO SCRIPT REQUIRED. It is a plain GET form: Show submits it and the URL that comes back is
   the same shareable one the chips produced. The instant filter hides Show and narrows the
   rendered rows on change; with scripting off the button stays and the form is complete.

   THE TOUCH TARGET IS THE FLOOR, IN PX. min-height is 44px and in px rather than rem on
   purpose: this is a measurement of a thumb, not of the type, so it must not move when a
   reader changes their font size. min-width: 0 on the selects is what lets three equal
   columns actually shrink — a flex item's default min-width is its content, so without it
   the widest option's text sets the row's width and pushes the whole form past the
   viewport, which is the wrap this control exists to end. */
.shelf-filters {
    display: flex; flex-wrap: nowrap; align-items: center; gap: .35rem;
    margin: .2rem 0 .5rem;
}
/* iOS Safari paints its OWN control over a select unless you opt out — which is why these
   read as raw browser widgets on a phone while every other control on the page wears the
   site's chrome. appearance:none surrenders the native arrow too, so we draw one: a chevron
   as a data-URI SVG in a muted grey that carries on paper and on dark alike, with the right
   padding reserved so a long option never runs under it. The rest is borrowed wholesale
   from the ask box's textarea and the search input, so the row reads as part of the page
   rather than as three things the browser supplied. */
.shelf-filter {
    flex: 1 1 0; min-width: 0; min-height: 44px;
    -webkit-appearance: none; -moz-appearance: none; appearance: none;
    padding: .5rem 1.6rem .5rem .6rem; border-radius: 8px;
    border: 1px solid var(--field-line);
    background-color: var(--panel2); color: var(--text);
    font: inherit; font-size: .84rem; line-height: 1.2;
    background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238a8578' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right .6rem center;
}
.shelf-filter:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; }
/* Show is the no-script path and is hidden the moment the instant filter runs, so it is
   sized to be reachable rather than to be prominent. */
.shelf-filter-go {
    flex: 0 0 auto; min-height: 44px; padding: .25rem .6rem;
    font-size: .82rem; white-space: nowrap;
}
/* Clear is offered as soon as ANY row is applied. The escape option is inside a closed
   picker — two taps and a scroll away — so without this there is nothing on the page that
   undoes a filter. It is rendered hidden rather than absent so the instant filter can reveal
   a link the server wrote the href for. */
.shelf-filter-clear {
    flex: 0 0 auto; display: inline-flex; align-items: center;
    min-height: 44px; padding: 0 .25rem; font-size: .8rem; white-space: nowrap;
}
.shelf-filter-clear[hidden] { display: none; }
/* THE RESULT LINE — how many boxes are showing, next to the boxes. Body ink rather than
   muted: it is the number the reader came for, not a footnote about it. The absence and
   disclosure lines beneath it take .muted .small from the element, because those explain
   what is NOT showing and must not compete with the answer. */
.result-line { margin: .1rem 0 .4rem; font-size: .92rem; }
.notice-line { margin: .1rem 0 .4rem; }
/* VISUALLY HIDDEN, still spoken. Carries the units a chip's bare numbers leave out — "6
   players, 31 boxes" rather than "6 31". Deliberately real text in the document rather than
   an aria-label, which would REPLACE the visible words and risk a WCAG 2.5.3 label-in-name
   mismatch against the number a reader can see. The clip-rect and the inset() are both
   present on purpose: old iOS Safari, which this page already goes out of its way to
   support, honours the first and not the second. */
.vh {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
    overflow: hidden; white-space: nowrap; clip: rect(0 0 0 0); clip-path: inset(50%);
}
/* THE COMPLEXITY CHIP — the one word, wherever it renders (2026-08-05, the operator's placement
   ruling: beside every game's name on the shelf, before its corpus group, and on the game
   page). ONE set of classes and one fragment feed both surfaces, so a reader who learns a
   colour on the shelf learns something true on the page they tap through to.

   ITS OWN FAMILY, NOT THE OUTCOME SET. The .outcome.tone-* classes are a pinned bijection
   with one axis of meaning — which bucket a ruling was counted into — and this reads on a
   different axis entirely. Borrowing a tone would make one colour mean two things one page
   apart, which is the exact failure the outcome set was built to end.

   WHY THE PREFIX IS heft- — HISTORY, as of 2026-08-06. When this family was minted, the
   filter row above spelled .weight-chip / .weight-chips, and a fence whose rule is "this
   prefix appears in one file" cannot hold a prefix two honest, unrelated controls already
   spell. That row is now three .shelf-filter dropdowns, so weight- is free again — and heft-
   stays anyway: it belongs to these three rules and nothing else, which is what lets
   WeightChipFenceTest read a stray mention as the bug it usually is, and renaming a served
   class token for a nicer name is churn with real risk and no reader-visible gain.

   MUTED, DELIBERATELY. These sit inside a list of game titles, three or four to a screen; a
   saturated pill row would read as a warning strip. Each colour is a desaturated cousin of a
   palette variable rather than the variable itself — greens for the easy end, amber through
   the middle, rust at the heavy end — dark backgrounds in the same register as the outcome
   chips so the two families look related without being interchangeable. */
.complexity { display: inline-block; padding: .1rem .5rem; border-radius: 999px;
    font-size: .74rem; font-weight: 600; letter-spacing: .02em;
    border: 1px solid transparent; }
.complexity.heft-light { background: var(--complexity-light-fill); color: var(--complexity-light-ink);
    border-color: var(--complexity-light-edge); }
/* Medium is BLUE — the ski-slope convention (green circle, blue square, black diamond):
   blue-as-intermediate is already in every reader's head, and a cool hue cannot blend
   with the warm title text, which every warm middle shade did (operator ruling, 2026-08-05, four
   shelf reads). Back in the outlined family style with its siblings. */
.complexity.heft-medium { background: var(--complexity-medium-fill); color: var(--complexity-medium-ink);
    border-color: var(--complexity-medium-edge); }
.complexity.heft-heavy { background: var(--complexity-heavy-fill); color: var(--complexity-heavy-ink);
    border-color: var(--complexity-heavy-edge); }
/* ⚠⚠ THE KIND CHIPS (PR-T3, 2026-08-17) — PROVISIONAL PAINT, PENDING THE OPERATOR'S EYEBALL.
   The operator ruled per-facet colours for the game page (§11a.5, verbatim: "GENRE, FORM, MECHANIC
   each get their own chip color; FORM listed first (usually one), then the 1-2 genre chips,
   then mechanics"). The ROLES are ruled; these three particular shades are the lane's read
   of "the closest existing tokens" and are expected to be retuned by eye once chips are on
   a real page. Retune the three rules below; nothing else has to move.

   NOT ONE NEW PALETTE TOKEN, DELIBERATELY — the .announcement block's discipline, and it
   buys the same thing: every pair below is already measured in theme-contrast-pairs.tsv, in
   both themes, so this family arrives inside the contrast fence rather than beside it.

   THE THREE ROLES, and why each wears what it wears:
     * FORM is the NEUTRAL/STEEL family (--panel2 under --muted). It is a fact about the box,
       not a flavour — cards, dice, tiles — so it takes the quietest treatment on the page
       and reads as a caption rather than a claim.
     * GENRE is the WARM GOLD/AMBER family (--amber-fill under --accent, this app's own
       brand gold). It is the "what kind of night is this" signal, at most two per box, and
       it is the one a reader's eye should land on first.
     * MECHANIC is the COOL family. It sits BELOW the ask card (§6's chip budget), far from
       the head, and cool separates it from everything warm above it.

   ⚠ AND THE MECHANIC PAIR WEARS A TOKEN NAMED FOR SOMETHING ELSE — said out loud rather
   than left for the next reader to trip over. Every cool token in this palette was already
   spoken for by something a reader can see on THIS page (--info is the excluded chip and the
   official badge; the medium complexity word is the blue in the head), and one colour
   meaning two things one page apart is the exact failure the outcome family was built to
   end. The carried-verbatim pair is the only cool pair whose one rule renders on an
   ops-gated surface (/review) and never on a public page, so borrowing it collides with
   nothing a reader will ever hold in one glance. If the operator blesses the teal, the follow-up is
   a straight rename of the token pair in the three palette blocks, the two rules that read
   it, and its row in the contrast file — mechanical, and worth doing rather than leaving a
   name that lies.

   THE PREFIX IS kind- AND IT APPEARS ONLY HERE. TagFacetChipFenceTest is the third family to
   ride ChipClassFence, so: every facet constant needs a rule below or the build fails, a
   rule no constant claims fails too, and no other template may spell the prefix at all —
   the whole class token comes from TagFacet.cssClass or it is not guarded. */
/* THE PILL ROW, and the quiet labels that name its groups (2026-08-17 — the operator's
   ruling over a live page: "move the medium down to the same row you added wargame to, and
   have some way of labeling the chip types").

   IT WAS .kinds AND IT IS NOT ONE FAMILY ANY MORE. The head row now carries kind chips AND
   the complexity chip — two vocabularies, two colour families, one line — so a class named
   for one of them was a name that lies about half its contents. .pill-row says what it is:
   a wrapping row of pills with an optional label in front of each group. The mechanics row
   below the ask card uses the same shape and carries one group.

   THE LABELS ARE WHY THE ROW CAN HOLD TWO FAMILIES AT ALL. Colour alone distinguished them
   while each row was one vocabulary; put side by side, a steel pill and a blue pill are two
   facts about the box with nothing saying which axis either sits on. The label is the axis's
   name, in the quietest register the page has (muted, at the chips' own size, the voice the
   provenance line uses) — it names the group without competing with the words inside it.

   THE SEPARATOR RULE IS A SIBLING SELECTOR, not a second class, so that a group which
   renders FIRST is never given a gap to separate it from nothing. A box with no approved
   kinds shows "complexity · Medium" hard against the left margin like any first group; the
   extra space appears only where there is a group above it to be separated from. */
.pill-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: .35rem;
    margin: .45rem 0 0; }
.pill-row .group-label { color: var(--muted); font-size: .74rem; letter-spacing: .02em; }
.pill-row .group-label ~ .group-label { margin-left: .5rem; }
.kind { display: inline-block; padding: .1rem .5rem; border-radius: 999px;
    font-size: .74rem; font-weight: 600; letter-spacing: .02em;
    border: 1px solid transparent; }
.kind.kind-form { background: var(--panel2); color: var(--muted);
    border-color: var(--control-line); }
.kind.kind-genre { background: var(--amber-fill); color: var(--accent);
    border-color: var(--line-amber); }
/* THE TEAL PAIR IS NAMED FOR ITS COLOUR, NOT FOR EITHER OF ITS JOBS (renamed 2026-08-18).
   It was --chip-carried-*, minted for /review's CARRIED chip, and the name was honest right
   up until the 2026-08-17 pill row painted MECHANIC kinds with it on a public game page.
   After that it described one of its two callers and quietly misled anyone reading the other
   — a mechanic is not "carried", and a token whose name belongs to one consumer is the token
   somebody edits for that consumer's sake and repaints the other by accident.
   The house already had the answer: --amber-fill is the palette's other shared pair (the
   human-waved chip AND the GENRE kind above), and it is named for the hue precisely because
   no single semantic covers both. So this one is teal, and both consumers stand on equal
   footing. Its measured pairs are in theme-contrast-pairs.tsv under the new name. */
.kind.kind-mechanic { background: var(--chip-teal-bg); color: var(--chip-teal-fg);
    border-color: var(--control-line); }
/* THE IDENTITY HEAD (2026-08-05 declutter). The game's name, and — until 2026-08-17 — the
   derived complexity word beside it at the far end of the row.

   THE WORD LEFT THIS ROW and joined the pill row under the facts line, where it sits beside
   the kind chips under a label of its own (the operator's ruling, quoted in full at the pill-row
   block above). The rule that pushed it to the far end went with it rather than being left
   behind: a selector nothing can match is paint the next reader cannot tell from paint that
   matters, and this one would have read as a live placement decision.

   THE FLEX ROW STAYS. The title is its only child today, and the row is what lets a second
   element land beside it on a wide screen and stack under it on an iPad in portrait — the
   property this block was written for, and the one a plain heading would have to re-earn. */
.page-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: .6rem; }
.page-head h1 { margin-bottom: 0; }
/* THE FACTS LINE — what a table decides on. Body ink rather than muted, and only slightly
   smaller than body copy: this is the line someone reads to answer "can we play this
   tonight", so it is not secondary to anything on the page except the name above it. */
.bgg-facts { margin: .35rem 0 0; font-size: .92rem; }
/* THE LANGUAGE-DEPENDENCE LINE — BGG's own words for how much in-game text this box needs.
   Muted small (the classes ride the element, not this rule), like the figures line under it:
   the facts line above answers "can we play this tonight" and this qualifies it for a table
   that does not read the box's language. Its own rule exists only for the spacing — one line
   of BGG's prose set apart from the chips above and the numbers below. */
.bgg-language { margin: .35rem 0 0; }
/* THE FIGURES LINE — the community's numbers, and the way out to where they came from. It
   takes the muted small treatment (the classes ride the element, not this rule) because a
   reader who wants these numbers is looking for them, while a reader deciding what to play
   has already been served by the line above.

   A FLEX ROW SO THE LINK TAKES THE FAR END, the same shape as the identity head above it:
   figures from the left, the route out from the right. It wraps like every other row here,
   and on a narrow screen the link drops to its own line still holding the right edge. The
   row gap is what separates the two on that wrapped line; the column gap never applies,
   since margin-left:auto has already put the whole remaining width between them. */
.bgg-figures { display: flex; flex-wrap: wrap; align-items: baseline; gap: .15rem .6rem;
    margin: .2rem 0 0; }
.bgg-figures .bgg-out { margin-left: auto; white-space: nowrap; }
/* THE REFERENCE CARD's link row. One middot-joined line of everything a table might want
   to open about this game, so the affordances stop competing with the ask box for the top
   of the page. Wraps like every other row here; no colour of its own. */
.about-links { margin: 0; }
.about-links .sep { color: var(--muted); }
/* THE COMPONENT-LIST TRIGGER. It has to be a BUTTON — it opens a dialog and navigates
   nowhere — and it has to READ as one more entry on this middot-joined line, so it takes the
   link colour and drops every scrap of button chrome. font:inherit is load-bearing rather
   than tidy: a button does not inherit the page's font by default, and left alone it would
   be the one item on the row set in the platform's control face. --accent on --panel is the
   measured link pair this line already uses. */
.about-links button.linkish { padding: 0; border: 0; background: none; font: inherit;
    color: var(--accent); cursor: pointer; }
/* THE COMPONENT MODAL (2026-08-10). The card's own chrome — same fill, same hairline, same
   radius — because it IS a card, drawn over the page instead of in it.

   THE CLOSED STATE IS WRITTEN OUT, and that is not a duplicate of the UA sheet. An engine
   with no <dialog> support treats it as an unknown element and renders its CONTENTS inline,
   so without this rule the whole component list would sit open on the page forever there.
   With it, that engine gets a closed list the script can still open by hand (the open
   attribute), rendered in flow inside the reference card rather than over the page — not
   modal, still correct. Both states are declared so neither depends on a UA rule this app
   cannot see.

   THE BACKDROP IS THE PAGE'S OWN GROUND, dimmed — WHERE IT RESOLVES. It takes --bg and an
   opacity rather than a colour of its own, so it needs no new token and gives the contrast
   table nothing to measure. On an engine that inherits custom properties into ::backdrop it
   darkens a dark page and lightens a paper one; on an older one ::backdrop inherits from
   nothing, var(--bg) is invalid there, and the rule falls back to transparent — the dialog
   still opens and reads, the page behind it simply is not dimmed. Stated rather than cured:
   the cure would be a literal rgba(), which is a colour outside the palette that the
   hex-only tokenization fence structurally cannot see. */
dialog.components { background: var(--panel); color: var(--text);
    border: 1px solid var(--line); border-radius: 10px; padding: 1rem;
    width: calc(100% - 2rem); max-width: 24rem; }
dialog.components:not([open]) { display: none; }
dialog.components[open] { display: block; }
dialog.components::backdrop { background: var(--bg); opacity: .82; }
.components-list { list-style: none; margin: .4rem 0 .9rem; padding: 0; }
.components-list li { border-bottom: 1px solid var(--line); }
.components-list li:last-child { border-bottom: 0; }
/* The whole row is the tap target (the panel's 44px minimum), label-wrapping the input so
   a thumb on an iPad hits the row rather than the 13px box. */
.components-list label { display: flex; align-items: center; gap: .6rem; min-height: 44px;
    padding: .4rem .2rem; cursor: pointer; }
/* Ticked reads as DONE: struck through and stepped back to the muted ink, which is the same
   measured pair every quiet line in a card already uses. CSS reads :checked directly, so
   nothing is scripted and the two states cannot disagree with the checkbox. */
.components-list input:checked + span { text-decoration: line-through; color: var(--muted); }
.components-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem;
    margin: 0; }
/* The way across to the base box on an expansion page — pushed to the far end so it reads
   as a way out rather than as a third control. */
.components-parent { margin-left: auto; font-size: .85rem; }
/* The measurement section's spacing. The SECTION itself always renders — it closes with
   the corpus health line, which is a fact about the app and true on every game page. What
   is conditional is its HEADING and the figures it heads: those are absent together on a
   never-asked edition, because a header over nothing is the empty scaffolding the absence
   law exists to prevent. (game.html carries the gate; this rule is layout only.) */
.measurement { margin-top: 1.2rem; }
form.ask textarea {
    width: 100%; min-height: 4.5rem; padding: .6rem; border-radius: 8px;
    border: 1px solid var(--field-line); background: var(--panel2); color: var(--text);
    font: inherit; resize: vertical;
}
form.ask button {
    margin-top: .6rem; width: 100%; padding: .7rem; border: 0; border-radius: 8px;
    background: var(--accent-fill); color: var(--accent-ink); font-weight: 700;
    font-size: 1rem;
}
/* QoL ask chips (game home): tappable starter questions that FILL the ask textarea
   (ask-chips-v1.js) — never submit. Pill chrome mirrors the .recents strip; the padding
   is touch-sized for phones, and the row wraps on narrow screens. */
.ask-chips, .sort-chips { display: flex; flex-wrap: wrap; gap: .5rem; margin: .8rem 0 .2rem; }
.ask-chip {
    padding: .55rem .9rem; border-radius: 999px; border: 1px solid var(--control-line);
    background: var(--panel2); color: var(--text); font: inherit; font-size: .88rem;
    cursor: pointer;
}
/* THE KINDS-I-KNOW MENU (the empty kind answer's door). They wear the ask-chip pill because
   that is this app's word for "tappable", past the feedback band's 44px minimum — but they
   are LINKS and are painted like one: a tap goes to the search rather than filling a box,
   which is what "tap one" promises, and the gold word is how every other door on this site
   says so (.sage-door-pill's own note: a door nobody notices is a wall).

   NO NEW TOKEN AND NO NEW PAIR — every one is already in theme-contrast-pairs.tsv: --accent
   on --panel2 (link text in a tile, 4.5), --control-line on --panel2 (published decor),
   --panel2 on --panel (a tile against a card), and --accent on --panel2 again at 3.0 as the
   hover boundary, which is the base-book row's own ui pair.

   The ROW shares .ask-chips' declaration above rather than restating it — one substrate, one
   spacing. inline-FLEX rather than inline-block so the word sits centred in a pill the
   feedback band's 44px minimum makes taller than its own text; an inline-block would have
   left the label riding high and the two chip rows on this page visibly mismatched. */
.sort-chip {
    display: inline-flex; align-items: center; min-height: 44px;
    padding: .55rem .9rem; border-radius: 999px;
    border: 1px solid var(--control-line); background: var(--panel2);
    color: var(--accent); font-size: .88rem; text-decoration: none;
}
.sort-chip:hover { border-color: var(--accent); }
/* The keyboard half of the same control (SURF-10, 2026-08-20): :hover was the only
   affordance this chip had. Same ring as everything else here. */
.sort-chip:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; }
/* --- Ask SCOPE (V47, expansion phase 1). One quiet line saying which books an ask reads,
   rendered in three places from the server's own resolved/persisted set. The group name
   beside each title is deliberate: a scope decision with no visible path back to the value
   it moves is the bare figure the house forbids.

   THE SEPARATION BETWEEN A BOOK AND ITS GROUP IS THIS RULE'S JOB, AND ONLY THIS RULE'S
   (2026-08-06). The two spans are emitted adjacent with no whitespace between the tags —
   which is how they have to be emitted, because Thymeleaf serves the template's own
   whitespace and any gap written between those tags is one re-indentation away from
   becoming a newline plus a run of indentation inside the rendered line. Without a margin
   here the title and the group ran together as one word wherever that pair renders; the
   reader who reported it typed the two straight into a sentence, which is exactly what the
   page had been showing them. The qualifier that replaces a group name on an unresolved row
   rides the same class for the same reason, so one declaration separates both variants and
   neither carries a second separator of its own.

   IT IS KEYED ON THE BOOK, NOT ON THE LINE (audit fold, 2026-08-06), because the same
   title-then-group pair is emitted by the per-book head of a layered ruling as well as by
   the scope line, and by the client-side mirror of that head. Keyed on the line, the layer
   head carried the identical defect one selector away with nothing reaching it at all. The
   book wrapper is the narrowest thing every emission of that pair has in common, so it is
   what the rule hangs off — and there is exactly one declaration to change if the spacing
   is ever re-judged. The sibling rule below stays keyed on the LINE deliberately: a scope
   line is a sentence, where a title broken across a wrap reads as two books, while a layer
   head is a heading with a chip beside it and must be free to wrap. --- */
.scope-line { margin: .3rem 0 .6rem; }
.scope-line .scope-book { white-space: nowrap; }
.scope-book .scope-group { color: var(--muted); font-size: .78rem; margin-left: .35em; }
.scope-line .scope-note { display: block; margin-top: .2rem; }
/* --- Expansion toggles (V47). Plain checkboxes that are correct with JavaScript off; the
   external ES5 file only remembers them. Each row is a full-width tap target well past the
   44px feedback-band minimum, so a checkbox on an iPad is hit by the LABEL, not by the 13px
   box. A disabled row keeps the same shape and drops the pointer, so the panel reads as one
   list rather than two. --- */
.expansions { margin: .8rem 0 .2rem; }
.expansions legend { padding: 0; margin-bottom: .4rem;
    color: var(--muted); font-size: .82rem; }
.expansions fieldset { border: 0; margin: 0; padding: 0; }
.expansion-row {
    display: flex; align-items: flex-start; gap: .6rem; min-height: 44px;
    padding: .55rem .7rem; margin-bottom: .4rem; border-radius: 10px;
    border: 1px solid var(--control-line); background: var(--panel2); cursor: pointer;
}
.expansion-row input[type=checkbox] { width: 1.15rem; height: 1.15rem; margin-top: .18rem;
    flex: 0 0 auto; accent-color: var(--accent); }
.expansion-row .expansion-text { display: flex; flex-direction: column; gap: .1rem; }
.expansion-row .expansion-name { font-weight: 600; }
.expansion-row .expansion-meta { color: var(--muted); font-size: .78rem; }
.expansion-row.off { cursor: default; opacity: .72; }
.expansion-row:focus-within { outline: 2px solid var(--info); outline-offset: 2px; }
/* The BASE BOOK's row leads the panel and is marked as the box itself, not one more
   add-on — unticking it is the expansion-only ask. Stated with a rule rather than order
   alone so the difference survives a reader who skims. */
.expansion-row.base-row { border-color: var(--accent); }
/* Declared here rather than trusted to the UA sheet: the script reveals the base row's
   "tick an expansion first" line by removing this attribute, and old-iOS Safari is exactly
   the engine where leaning on a default is how a hidden sentence ships visible. */
[hidden] { display: none !important; }
/* --- Nested expansions on the shelf (V47): a box's expansions sit under it, indented,
   never as shelf entries of their own. --- */
ul.shelf ul.shelf-nested { list-style: none; margin: 0; padding: 0 0 0 1.1rem; }
ul.shelf ul.shelf-nested .title::before { content: "↳ "; color: var(--muted); }
/* --- The same relation on a SEARCH row (V47, operator-ruled 2026-08-04). Results are ranked,
   not nested, so the row carries the relation as a pill that NAMES the box it belongs to —
   never a bare glyph, which would leave "part of what?" unanswered. It borrows the nested
   shelf's ↳ so the two surfaces read as one idea. --- */
ul.shelf .expansion-of {
    color: var(--muted); font-size: .72rem; white-space: nowrap;
    border: 1px solid var(--line2); border-radius: 999px; padding: .05rem .45rem;
}
ul.shelf .expansion-of::before { content: "↳ "; }
/* --- Reader feedback band (ruling page, 2026-08-02). iPad-first: the three options are
   full-height tap targets that share the row and wrap (2+1 at ~360px, 3 across from ~380px
   up), each well past the 44px touch minimum. NO JavaScript — the radio input carries the choice and its LABEL
   is the target, so `:checked + label` does the whole selected state in CSS. The input is
   clipped rather than display:none so it stays focusable and announced (keyboard + screen
   reader get a real radio group); :focus-visible paints the outline on the label instead. --- */
.feedback-fieldset { position: relative; border: 0; margin: 0 0 .7rem; padding: 0; }
.feedback-fieldset legend { padding: 0; margin-bottom: .5rem;
    color: var(--muted); font-size: .82rem; }
.feedback-band { display: flex; gap: .5rem; flex-wrap: wrap; }
.feedback-band input[type=radio] {
    position: absolute; width: 1px; height: 1px; opacity: 0; margin: 0; pointer-events: none;
}
.feedback-band label {
    flex: 1 1 7rem; min-height: 3rem; display: flex; align-items: center;
    justify-content: center; text-align: center; padding: .8rem .6rem;
    border: 1px solid var(--control-line); border-radius: 10px; background: var(--panel2);
    color: var(--text); font-weight: 600; cursor: pointer;
}
.feedback-band input[type=radio]:checked + label {
    background: var(--accent-fill); border-color: var(--accent-fill);
    color: var(--accent-ink);
}
.feedback-band input[type=radio]:focus-visible + label {
    outline: 2px solid var(--info); outline-offset: 2px;
}
.feedback textarea {
    width: 100%; min-height: 3.5rem; margin-top: .25rem; padding: .6rem;
    border-radius: 8px; border: 1px solid var(--field-line); background: var(--panel2);
    color: var(--text); font: inherit; resize: vertical;
}
.feedback button {
    margin-top: .6rem; width: 100%; padding: .7rem; border: 0; border-radius: 8px;
    background: var(--accent-fill); color: var(--accent-ink); font-weight: 700;
    font-size: 1rem;
}
/* The inline "fix this and resend" line — a nudge on the reader's own form, deliberately
   not a .banner.warn alarm (nothing failed; the note was too long or nothing was picked). */
.feedback-problem { margin: 0 0 .6rem; color: var(--warn); font-size: .86rem; }
.feedback-thanks { margin: 0; }
/* The request-a-game form (2026-08-02) reuses the feedback card's textarea + button chrome
   and adds the single-line name field the band never needed. Scoped to .request-form on
   purpose: a bare `.feedback label` would tie with `.feedback-band label` on specificity
   and, being later in the sheet, would win — turning the band's flex tap targets into
   blocks. Same panel treatment as the textarea, so both fields read as one form. */
.request-form label { display: block; margin-top: .6rem; }
.request-form input[type=text] {
    width: 100%; margin-top: .25rem; padding: .6rem; border-radius: 8px;
    border: 1px solid var(--field-line); background: var(--panel2); color: var(--text);
    font: inherit;
}
/* OUTCOME CHIPS. Exactly four tones, one per answerability bucket, named for the palette
   variables they use rather than for outcomes — the mapping lives in ChipTone /
   AnswerabilityBucket and OutcomeChipFenceTest proves a rule exists here for every tone, so
   a chip can never render unstyled. The predecessors (.answered/.refused/.down/.partial/
   .error/.staged) were an outcome-named set written by hand in a template ternary: it had
   six classes for four buckets, one of them unreachable, and it coloured a refusal like an
   abstain. A reader who learns a colour on one surface must learn something true on the
   next, which is only possible while the colours and the counts share one mapping. */
.outcome { display: inline-block; padding: .15rem .55rem; border-radius: 999px;
    font-size: .78rem; font-weight: 600; }
.outcome.tone-good { background: var(--good-fill); color: var(--good); }
.outcome.tone-warn { background: var(--warn-fill); color: var(--warn); }
.outcome.tone-bad { background: var(--bad-fill); color: var(--bad); }
/* The excluded-ask chip rides info-blue, matching .banner.staged: an infrastructure state
   is never styled like a hard refusal, and never greyed out either — it is a real state a
   reader is meant to see and count separately.
   NOTHING IN THIS SHEET'S COMMENTS MAY NAME A SCENARIO IN THE WORDS A TEST FIXTURE WOULD
   USE. This stylesheet is inlined into the head of EVERY page, so a phrase written here is
   served on all of them — a draft of this very comment named a failure mode in the same
   words a rulings-list fixture used as its query, and an absence assertion failed against
   prose no reader would ever see. Style, not scenarios. */
.outcome.tone-info { background: var(--info-fill); color: var(--info); }
/* The tag beside a chip: which counted figure this ruling moved. Quiet on purpose — the
   chip carries the colour, this carries the accounting, and it says so in words rather
   than repeating the chip's own label back at the reader. */
.bucket-tag { margin-left: .35rem; }
/* RECALL. Its own family, carrying no colour from the outcome set above: that set is a
   pinned bijection with one axis of meaning, and this panel reads on another. Quiet panel
   chrome throughout — the panel is offered material, not a verdict, and styling it as
   loudly as a ruling would be a claim the words deliberately do not make. The small tags
   are plain pills with no per-state colours at all, because nothing about them varies. */
.card.recall { border-left: 3px solid var(--line2); }
.recall-head { margin: 0; }
.recall-note { margin: .35rem 0 .6rem; color: var(--muted); font-size: .84rem; }
.recall-row { border-top: 1px solid var(--line); padding: .5rem 0 .1rem; }
.recall-query { margin: 0; }
.recall-meta { margin: .2rem 0 .35rem; }
.recall-chip { display: inline-block; padding: .05rem .45rem; margin-right: .35rem;
    border-radius: 999px; background: var(--panel2); color: var(--muted);
    font-size: .74rem; }
/* pre-line, not pre-wrap: the model's newlines (numbered-list lines especially) display
   as real line breaks on the phone, while stray runs of spaces the answerer sometimes
   emits mid-sentence collapse instead of forcing odd wraps. Verbatim rulebook QUOTES keep
   pre-wrap (the blockquote rule below) — there, space runs are part of the quote.
   line-height rides the body's 1.55. */
.answer { white-space: pre-line; }
/* THE LIST RENDERING — engaged whenever the answer really is a list
   (AnswerOutline.hasListStructure: something one level in, or two numbered items, or two
   bullets), which is what pre-line cannot express. A sub-bullet lands flush against its
   parent, and a plain nine-step setup answer reads as a wall whose wrapped steps fall back
   under their own numbers (the operator's asks, 2026-08-09 and 2026-08-10 — the second one
   found on a live ruling that had NO sub-items and therefore never engaged). Here each line
   is its own block, so the indent is a margin rather than leading spaces pre-line would
   collapse anyway, and white-space returns to normal because the elements now carry the
   breaks the newlines used to.

   NO NEW COLOUR TOKEN and no font change: this rule moves lines, nothing else, so the
   contrast table covers this branch exactly as it covers the paragraph. `.answer` stays on
   the container so the superscript marker rule below still applies to both renderings. */
.answer.answer-shaped { white-space: normal; }
.answer-shaped .ans-line { margin: 0; }
/* One level in, and only one — the indent is em-based so it tracks the reader's own text
   size on the iPads this audience actually plays on. 1.1em, NOT independent: it equals the
   .ans-num gutter below, so a sub-bullet's marker sits exactly on its parent's text column
   — the classical nesting geometry — and a sub-bullet's own wrap column lands at 1.95em
   (this margin + .ans-bullet's .85em gutter). Dialed down from 1.4em by the operator's eye
   (2026-08-10: the wrapped line of a sub-bullet read "a bit too far" in). If the .ans-num
   gutter ever moves again, move this with it. */
.answer-shaped .ans-sub { margin-left: 1.1em; }
/* THE HANGING INDENT — a list item that wraps keeps its later lines clear of its own
   number instead of dropping them back underneath it (the operator's ask, 2026-08-10: "the
   entire numbered line wraps WITH an indentation, and then the next numbered line starts
   back, non-indented").

   ONE FIXED GUTTER FOR THE WHOLE LIST, never a per-item one. Every continuation lands on
   the SAME column whatever its item is numbered, and the numbers sit left-aligned among
   themselves at the margin to its left — so the reader's eye tracks one wrap column down
   the list rather than a ragged one (the operator's geometry, 2026-08-10: "the text always
   aligns, and only the numbers align with themselves on the left").

   Mechanically it is one constant written twice with opposite signs: padding-left holds the
   gutter open on EVERY line of the block, and a negative text-indent of the same width pulls
   the FIRST line — the one carrying the number — back out to the margin. Nothing here is a
   list element and nothing renumbers: the "3." a reader sees is the answerer's own three
   characters, moved, never generated (the shape-never-markup line AnswerOutline holds).

   WHAT THIS CANNOT DO, said plainly so nobody re-derives it from a screenshot. The number
   and its space are INSIDE the line's text, not in a marker box, so the first line's text
   begins wherever that number happens to end and no value of the gutter moves it. Measured
   on the installed faces (advances read off the TTFs, 2026-08-10): "3. " ends at ~1.11em
   and "10. " at ~1.67em. A first line therefore starts a little LEFT of the wrap column —
   further left for a single digit — which reads as a slightly deep hang, never as a wrap
   falling back under a number. Putting the first line's text on the column too needs the
   marker in a fixed-width box of its own, which means splitting the line's first run in the
   server: a markup change, and the operator's call, not a stylesheet's.

   WHY 1.9em. It is the smallest round width that clears "10." (1.39em Arial-metric, 1.41em
   Noto, 1.59em DejaVu) with a full space to spare on every one of them, so a two-digit step
   can never have its own wrap tuck under its digits — the exact defect this rule exists to
   remove. Three digits are the classifier's ceiling and simply overrun: a "100." pushes its
   OWN first line right while every continuation still lands on the column, so the worst case
   is one ragged line rather than a broken list. 0.85em does the same job for a bullet's "* "
   or "- ": it clears the widest measured marker advance (0.81em) by a hair, and — unlike the
   1em it replaced (operator's eye, 2026-08-10, second dial) — it puts the wrap column ON the
   first line's own text start instead of 0.19em right of it, which read as the wrapped line
   indenting too far. The clearance margin is thin BY CHOICE: the marker advances are measured
   off the installed faces, and a future face with a fatter "* " shows up as a tucked wrap —
   re-measure and re-dial, don't pad speculatively. It rides ON TOP of the level indent above,
   so a sub-bullet holds its own wrap column at 1.95em. em throughout, for the same reason .ans-sub is
   em-based: the geometry tracks the reader's own text size rather than a designer's screen.
   Spacing only — no colour, no font, no weight — so the contrast table covers this branch
   exactly as it did before. */
.answer-shaped .ans-num { padding-left: 1.1em; text-indent: -1.1em; }
.answer-shaped .ans-bullet { padding-left: .85em; text-indent: -.85em; }
/* A blank line the answerer wrote becomes air, never an empty element: an empty block would
   be a gap the reader cannot tell from a rendering fault.

   SAID OUT LOUD SO IT IS NOT LATER FILED AS A BUG: this air is SMALLER than the same blank
   line under pre-line, which spends a whole 1.55 line-height on it. That was true from the
   day the shaped branch shipped, but it reached only nested lists; since the engagement
   widened (2026-08-10) it is what most list answers do, so the paragraph gaps in a stepped
   setup answer are deliberately tighter than they were. Change the value, not the branch,
   if the operator wants them back. */
.answer-shaped .ans-spaced { margin-top: .7rem; }
/* PLAY ON (2026-08-20) — the answered ruling's closing line. GEOMETRY ONLY: the colour
   and the size come from `muted small`, the register this page's own asides already
   use, so no new colour pair reaches the contrast table. All it buys is a breath
   between the citations and whatever comes next, and a line that does not have to be
   read as part of the answer above it. */
.play-on { margin: .9rem 0 .2rem; }

/* S3c live-stream draft: visibly PROVISIONAL until the gate rules. */
.stream-note { color: var(--muted); margin: .2rem 0 .4rem; }
.draft-label { color: var(--warn); font-size: .74rem; text-transform: uppercase;
    letter-spacing: .05em; margin: .2rem 0 .3rem; }
.draft { color: var(--muted); font-style: italic;
    border-left: 3px solid var(--warn); padding-left: .7rem; }
.draft.retracted { text-decoration: line-through; opacity: .55; }
.retraction { color: var(--bad); border-left: 3px solid var(--bad);
    padding: .5rem .7rem; margin: .6rem 0; background: var(--retraction-bg);
    border-radius: 6px; }
.citation, .passage {
    background: var(--panel2); border-left: 3px solid var(--accent);
    border-radius: 6px; padding: .6rem .8rem; margin: .6rem 0;
}
.citation blockquote, .passage blockquote {
    margin: .4rem 0 0; padding: 0; white-space: pre-wrap; color: var(--text);
}
.badge {
    display: inline-block; padding: .05rem .45rem; border-radius: 4px;
    font-size: .72rem; font-weight: 700; letter-spacing: .03em;
    vertical-align: middle; margin-right: .4rem;
}
.badge.tier-rulebook { background: var(--badge-rulebook-bg); color: var(--good); }
.badge.tier-official { background: var(--info-fill); color: var(--info); }
.badge.tier-designer { background: var(--amber-fill); color: var(--warn); }
.badge.tier-community { background: var(--badge-community-bg);
    color: var(--badge-community-fg); }
.badge.tier-unknown { background: var(--badge-unknown-bg); color: var(--muted); }
.cite-meta { color: var(--muted); font-size: .8rem; }
/* THE CITATION MARKER — the [n] a reader meets in the prose and the same [n] on the entry
   it names. ONE class for both ends deliberately: they are the same fact seen from two
   sides, and two classes would be two chances for them to stop looking like each other.
   Gold and underlined, like every other link here, so colour is never the only signal — and
   both surfaces are already measured pairs at the 4.5 text floor (--accent on --panel inside
   the answer card, --accent on --panel2 inside an entry). The smaller, lighter forms below
   move no token, so the contrast fence still covers them unchanged. */
.cite-mark { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
/* IN THE PROSE it is an academic superscript: small, raised and LIGHT — a footnote
   reference, not the bold chip it used to be (the operator's ask, 2026-08-09). The size is
   in em so it tracks whatever paragraph it sits in, and line-height: 0 keeps the raised box
   from opening up the pre-line layout the answerer's own newlines depend on. Horizontal
   padding is the TAP AREA an older iPad still needs; vertical padding would fight the raised
   box and overlap the line above, so the width alone does that work. */
.answer .cite-mark { font-size: .72em; vertical-align: super; line-height: 0;
    font-weight: 400; padding: 0 .25rem; }
/* ON THE ENTRY the number sits on the baseline at the head of its reference line — the same
   quiet gold, a touch smaller than the meta beside it, and never raised: a footnote list
   numbers its entries on the line, only the in-text reference is a superscript. */
.citation .cite-mark { margin-right: .35rem; font-size: .82rem; font-weight: 600; }
details.wrench { margin-top: 1.2rem; border-top: 1px dashed var(--line2); padding-top: .7rem; }
details.wrench summary { cursor: pointer; color: var(--muted); font-size: .85rem; }
details.wrench dl { font-size: .8rem; }
details.wrench dt { color: var(--muted); margin-top: .5rem; }
details.wrench dd { margin: 0; font-family: ui-monospace, monospace;
    word-break: break-all; white-space: pre-wrap; }
/* The live-seat line when the running child disagrees with the deploy pin. ONE row, not a
   band (the band was axed 2026-08-02) — and the colour is the SECOND signal, never the
   only one: the line itself spells the divergence out in words and names which field it
   was, so the warning survives a monochrome screen and a colour-blind reader.
   NOTE this is a CSS comment inside a served <style> block, so it reaches the response
   body: it must not spell the row's own alarm words, or every page would carry them. */
details.wrench dd.seat-drift { color: var(--bad); font-weight: 700; }
/* THE SAME MARK ON THE OPERATOR MODERATION PAGES, where the classifier seat's since-boot line
   wears it when the seat has been asked several times and cleared nothing (arc-end patch).
   The selector above is scoped to `details.wrench dd`, so the ops pages' <p class="muted
   small seat-drift"> matched NOTHING and a struggling seat rendered byte-identically to a
   healthy one — which is the exact class of silence that whole feature was built to break.
   The scoping is kept (a bare `.seat-drift` would inherit the wrench's monospace context
   into places it does not belong), so the ops surface gets its own rule and its own
   specificity rather than the wrench's rule getting widened.

   NOTE, for the same reason its sibling above carries one: this CSS comment ships inside a
   served <style> block on EVERY page. It said the family's own constant in capitals, which
   put that token in the public /review body and turned three sweep-banner tests red —
   RotSurfaceIT, SweepHealthLineIT and RotComposedLoopIT each assert that the PUBLIC-POSTURE
   /review page cannot name it. (Public POSTURE, not unauthenticated: those tests fetch it
   with operator credentials, and the fence is about which surface may carry an ops-derived
   token, not about who is holding a password.) Describe the surface in prose here; never
   spell a family constant.

   A STEADY COLOUR CHANGE AND NO MOTION — the house signal rule. No animation, no blink, no
   badge that moves; the mark is a colour and a weight, and the SECOND signal is that the
   line beside it already spells out attempted/cleared/held-or-failed and the last-attempt
   time, so the reading survives a monochrome screen and a colour-blind operator. The
   .muted class this sits beside sets the quiet grey these lines normally take, so the
   colour has to be stated here to win.
   NOTE this is a CSS comment inside a served <style> block, so it reaches the response
   body: it must not spell the row's own alarm words. */
p.seat-drift, p.seat-drift span { color: var(--bad); font-weight: 700; }
/* The per-ask provenance lens. */
.lens { margin-top: .9rem; }
.lens-note { margin: .2rem 0; font-size: .76rem; color: var(--muted); }
.lens-unavailable { margin: .3rem 0; font-size: .78rem; color: var(--warn); }
/* table-layout: fixed + wrapping is LOAD-BEARING, not polish. Every cell here is fed
   corpus text (heading paths, document titles) that the quote gate guarantees arrives
   adversarial, and the §6.1 boundary bounds it at 8,000 characters — an auto-layout table
   sizes its columns to the longest unbroken run, so one hostile heading would stretch the
   table and scroll the whole page sideways on a phone. Fixed columns + break-anywhere keep
   a hostile string inside its own cell. */
.lens-rows { width: 100%; table-layout: fixed; border-collapse: collapse;
    font-size: .76rem; margin: .3rem 0 .6rem; }
.lens-rows th { text-align: left; color: var(--muted); font-weight: 600;
    border-bottom: 1px solid var(--line2); padding: .2rem .4rem .2rem 0; }
.lens-rows td { vertical-align: top; padding: .25rem .4rem .25rem 0;
    border-bottom: 1px solid var(--line3); }
.lens-rows th, .lens-rows td { overflow-wrap: anywhere; word-break: break-word; }
.lens-cards { margin: .2rem 0 .6rem; padding-left: 1.1rem; font-size: .78rem; }
/* The game page's answerability line — the page's existing quiet provenance idiom, given a
   name so a test can assert on the RENDERED element rather than on words that also appear in
   this page's HTML comments (the lesson GameBggStatsWebIT records). */
.answerability { margin: .2rem 0 .8rem; }
/* The per-game rulings list. table-layout: fixed + break-anywhere is LOAD-BEARING here for
   the same reason it is on .lens-rows above, one source further out: every question in this
   column is READER-typed text, so an auto-layout table would size its columns to whatever
   the longest unbroken run someone chose to submit is, and scroll the whole page sideways on
   a phone. The widths keep the question column dominant — it is what the page is for. */
.ruling-rows { width: 100%; table-layout: fixed; border-collapse: collapse;
    font-size: .85rem; margin: .4rem 0 .8rem; }
.ruling-rows th { text-align: left; color: var(--muted); font-weight: 600;
    border-bottom: 1px solid var(--line2); padding: .3rem .5rem .3rem 0; }
.ruling-rows td { vertical-align: top; padding: .35rem .5rem .35rem 0;
    border-bottom: 1px solid var(--line3); }
.ruling-rows th, .ruling-rows td { overflow-wrap: anywhere; word-break: break-word; }
.ruling-rows th:nth-child(2), .ruling-rows td:nth-child(2) { width: 30%; }
.ruling-rows th:nth-child(3), .ruling-rows td:nth-child(3) { width: 22%; }
/* The game a question was asked about — the shelf-wide history's one addition to the row.
   Its own line under the question rather than a fourth column: the question column is what
   these pages are for, and a fourth column takes its width from exactly that text. Quiet,
   because it is the row's context and not its subject. */
.row-game { display: block; font-size: .78rem; margin-top: .1rem; }
.row-game a { color: var(--muted); }
/* The pager under the shelf-wide history. Plain GET links — a page of the history is a URL
   somebody can share — with the page count between them, so "older" is never a step into an
   unknown number of remaining pages. */
.pager { display: flex; align-items: baseline; gap: .8rem; margin: .6rem 0 .8rem; }
.pager .of { color: var(--muted); font-size: .82rem; }
.lens blockquote { margin: .2rem 0; padding-left: .6rem;
    border-left: 2px solid var(--line2); white-space: pre-wrap; }
.backlink { display: inline-block; margin-bottom: .4rem; font-size: .85rem; }
/* THE WAY BACK FROM A SHELF ANSWER (2026-08-16) — the `backToShelf` fragment's chrome.

   A REAL TAP TARGET, NOT A QUIET LINE. `.backlink` above is right where it is used: a small
   marker on a page a reader reached deliberately and can leave whenever. The shelf answer is
   the opposite case — the reader is at the END of something, on a phone or an iPad, and the
   way home is the most likely next tap on the page. So this one takes the header control's
   own shape: pill chrome, and min-height in PX at the 44px touch minimum the shelf filters
   and the feedback band are already built to (in px rather than rem for the same reason they
   are — a reader who has scaled their text has not changed the size of their finger).

   NO NEW PALETTE TOKEN, DELIBERATELY, so ThemeContrastFenceTest covers it unchanged. It
   borrows the header theme control's own ground (--panel2), takes the gold link colour every
   anchor on this site already has, and paints its focus ring in --info like .shelf-filter
   does.

   THE OUTLINE IS --field-line, NOT --control-line, AND THAT IS THE SECONDARY-BUTTON CASE.
   A filled chip's outline is decor because the chip carries a LABEL in chip styling — small,
   bold, letter-spaced — that identifies it as a control on its own (theme-contrast-pairs.tsv
   spells this out). This link's label is body copy: not bold, not letter-spaced, nothing
   about the words saying "control". So the outline is doing the real identifying work here,
   exactly as it does on the secondary button, and it owes the 3:1 boundary floor a filled
   chip's does not. --control-line measured only 1.77:1 against the page in the dark default;
   --field-line clears it (3.81 dark / 3.42 light) and is already a measured `ui` row.

   EVERY COLOUR PAIR THIS RULE PUTS ON SCREEN IS IN theme-contrast-pairs.tsv: accent-on-panel2
   (the gold label, 4.5 text), field-line against panel2 and against the page (the outline,
   3.0 ui), info-on-page (the focus ring, 3.0 ui), and panel2-against-the-page (the fill,
   decor — added with this rule).

   inline-flex rather than inline-block: the arrow and the words are two spans emitted with
   no whitespace between them, so the `gap` is what separates them on screen, and
   align-items keeps the glyph on the words' centre line rather than their baseline. */
.back-to-shelf {
    display: inline-flex; align-items: center; gap: .4rem;
    min-height: 44px; padding: .5rem .9rem; margin-bottom: .6rem;
    border-radius: 999px; border: 1px solid var(--field-line);
    background: var(--panel2); font-size: .9rem;
}
.back-to-shelf:focus-visible { outline: 2px solid var(--info); outline-offset: 2px; }
.btn { display: inline-block; padding: .6rem 1rem; border: 0; border-radius: 8px;
    background: var(--accent-fill); color: var(--accent-ink); font-weight: 700;
    font: inherit;
    cursor: pointer; }
/* THE SECONDARY BUTTON TAKES THE FIELD BOUNDARY, NOT THE PILL ONE (audit fold,
   2026-08-05). It is the one member of the filled-control family whose label is styled
   like body copy rather than as a chip: nothing about the words says "this is a control",
   so the outline is doing real identification work here and has to clear the 3:1 floor
   like a text field's does. It measured 1.59:1 in dark on --control-line, and it renders
   on PUBLIC pages (the strategy empty state, both add-result outcomes). Pills, chips and
   tappable rows keep --control-line: their fill and their chip-styled label already carry
   them, and both of those are measured as text. */
.btn.secondary { background: var(--panel2); color: var(--text);
    border: 1px solid var(--field-line); }
/* THE CONSENT PAYLOAD BLOCK (2026-08-16). The offer card says "This is everything that
   would leave" and then prints the exact message list, so these declarations are not
   cosmetic — they are what makes that sentence true on a real screen.

   white-space: pre-wrap on the <pre> is the load-bearing one. The system turn is a single
   816-character line with no newlines in it, and a <pre>'s UA default of white-space: pre
   does not wrap: inside this app's 44rem main column that renders as one line running off
   the side of an iPad, which is a payload the reader cannot read and therefore a promise
   the card cannot keep. overflow-wrap breaks a long unbroken token rather than pushing the
   page sideways; max-height + overflow-y makes it scrollable rather than endless, which is
   the treatment the copy names. */
.overflow-payload { margin: .6rem 0; padding: .6rem .8rem;
    border-left: 3px solid var(--field-line); background: var(--panel2);
    max-height: 22rem; overflow-y: auto; }
.overflow-payload pre { margin: 0 0 .8rem; font-size: .82rem; line-height: 1.45;
    white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
.overflow-payload p { margin: 0 0 .2rem; text-transform: uppercase;
    letter-spacing: .04em; font-size: .7rem; }
.add-link { margin-left: auto; font-size: .82rem; }
form.add label { display: block; margin: .5rem 0 .2rem; }
form.add input[type=text], form.add input[type=number], form.add select,
form.add input[type=file] {
    width: 100%; padding: .55rem; border-radius: 8px; border: 1px solid var(--field-line);
    background: var(--panel2); color: var(--text); font: inherit;
}
.field-note { color: var(--muted); font-size: .78rem; margin: .15rem 0 .5rem; }
fieldset.roles { border: 1px solid var(--line); border-radius: 8px; margin: .6rem 0;
    padding: .5rem .8rem; }
fieldset.roles legend { color: var(--muted); font-size: .74rem; text-transform: uppercase;
    letter-spacing: .05em; padding: 0 .3rem; }
.radio { display: block; padding: .3rem 0; }
.radio input { margin-right: .5rem; }
.match { border-bottom: 1px solid var(--line); padding: .5rem .2rem; font-size: .9rem; }
.match:last-child { border-bottom: 0; }
.match .bid { color: var(--muted); font-family: ui-monospace, monospace; font-size: .76rem; }
.banner { border-radius: 8px; padding: .7rem .9rem; margin: .8rem 0; font-size: .9rem; }
.banner.staged { background: var(--info-fill); border: 1px solid var(--line-info);
    color: var(--info); }
/* The corpus group named inside the staged banner (2026-08-06). On a staged game the scope
   line — which carries the group beside every book on every other surface — is not rendered
   at all, because there is no ask box for it to be a promise about; so the banner is the one
   place left that can say WHICH group the reader is waiting on, and it says it there rather
   than nowhere. Monospace for the same reason the shelf sets a group in monospace: it is an
   identifier a reader may have to type or match, and set in the banner's prose it would read
   as a word in the sentence. It takes the banner's own colour and adds none of its own. */
.banner.staged .staged-group { font-family: ui-monospace, monospace; font-size: .92em; }
.banner.errata { background: var(--warn-fill); border: 1px solid var(--line-warn);
    color: var(--warn); }
/* banner v2 WEAK tier: deliberately quieter than .banner.errata — muted panel chrome,
   no warn colour, smaller text. A nudge ("errata retrieved but not cited"), not the
   STRONG contested alarm. */
.banner.errata-weak { background: var(--panel2); border: 1px solid var(--line);
    color: var(--muted); font-size: .82rem; }
.banner.warn { background: var(--bad-fill); border: 1px solid var(--line-bad);
    color: var(--bad); }
/* DL-P3-4: the POSITIVE sweep-health tier — a healthy documentation-rot sweep is VISIBLE (green),
   the counterpart to the .banner.warn degraded alarm. Carries full text (timestamp + counts), never
   a bare colour — the healthy state must not be silent (the anti-silent-serving fix). */
.banner.clean { background: var(--clean-fill); border: 1px solid var(--line-good);
    color: var(--good); }
/* Layout only. This family carries NO colour of its own: it rides the .clean / .override
   modifiers above, which already carry the two strengths it needs and which the artifact's
   own page uses for the same pair. A copied set of hex triples would be a second palette to
   keep in step with the first. What it stays clear of is the .outcome.* set — a pinned
   bijection with a different axis of meaning. */
.banner.checklist-band .checklist-band-head { margin: 0; }
.banner.checklist-band .checklist-band-link { margin: .5rem 0 0; }
.banner.checklist-band .checklist-band-drift { margin: .4rem 0 0; font-size: .84rem; }
/* THE COMPLETENESS CAVEAT (task #14, Track 1) — the .drift caveat's sibling and styled as one:
   layout only (margin + smaller text), NO colour of its own, so it rides the band's existing
   text/background pair and introduces no new contrast pair or hex (ThemeContrastFenceTest /
   ThemePaletteFenceTest untouched — the same escape hatch the band's other lines take). */
.banner.checklist-band .checklist-band-completeness { margin: .4rem 0 0; font-size: .84rem; }
.banner.checklist-band .checklist-band-provenance { margin: .35rem 0 0; color: var(--muted);
    font-size: .78rem; }
/* THE LEAD MODIFIER (r.checklistLeads()): on an abstained/refused ruling the band is the
   hero, not a footnote, so it gains a heavier left edge and a little more room. Emphasis
   only, and from the SAME border colour the band already carries (.clean/.override set it) —
   border-WIDTH and spacing carry no colour of their own, so no new contrast pair is
   introduced and ThemeContrastFenceTest / ThemePaletteFenceTest are untouched. */
.banner.checklist-band.lead { border-left-width: 4px; padding: .9rem 1rem; margin: 1rem 0; }
.banner.checklist-band.lead .checklist-band-head { font-size: 1.02rem; }
.banner .refs { margin: .5rem 0 0; padding: 0; list-style: none; }
.banner .refs li { padding: .2rem 0; }
.steps { list-style: none; margin: .6rem 0; padding: 0; font-size: .88rem; }
.steps li { padding: .25rem 0 .25rem 1.4rem; position: relative; color: var(--muted); }
.steps li.done { color: var(--good); }
.steps li.done::before { content: "\2713"; position: absolute; left: 0; color: var(--good); }
.steps li.here { color: var(--text); font-weight: 600; }
.steps li.here::before { content: "\25B6"; position: absolute; left: 0; color: var(--accent); }
.pageimg { max-width: 100%; height: auto; display: block; border: 1px solid var(--line);
    border-radius: 8px; background: #fff; }
.page-cite a, .cite-meta a { color: var(--accent); text-decoration: underline;
    text-underline-offset: 2px; }

/* --- Baked setup checklist: DERIVED chrome (plan D-F). Deliberately NOT the .citation
   quote chrome. Baked prose is synthesized, gate+judge-verified text — a THIRD visual
   identity that must never read as a verbatim quote. The verbatim quote itself lives only
   inside the per-step <details>, where quote chrome (a blockquote) IS appropriate. --- */
/* THE PHASE BREAK. A checklist runs its component inventory straight into its setup steps,
   and until 2026-08-09 both halves wore the same grey label and one continuous run of
   numbers — so the seam between "what should be in the box" and "what to do with it" was
   invisible. Three parts, all driven from ChecklistView.PhaseGroup: the label (unchanged),
   a colour per phase so the two headings are told apart at a glance, and a hairline above
   every phase AFTER the first. A single-phase checklist gets no rule and looks as it did.
   The colours are palette tokens measured against --bg in both themes; the rule reuses the
   app's existing hairline rather than inventing a divider component. */
.phase-head { color: var(--muted); font-size: .78rem; text-transform: uppercase;
    letter-spacing: .06em; margin: 1.1rem 0 .3rem; }
.phase-head.phase-inventory { color: var(--phase-inventory-ink); }
.phase-head.phase-setup { color: var(--phase-setup-ink); }
.phase-head.phase-divided { border-top: 1px solid var(--line); margin-top: 1.6rem;
    padding-top: 1.1rem; }
.baked-step { background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
    padding: .7rem .9rem; margin: .5rem 0; }
.baked-step.hole { border-style: dashed; border-color: var(--line-bad);
    background: var(--hole-bg); }
.baked-step .step-head { display: flex; align-items: baseline; gap: .5rem;
    flex-wrap: wrap; }
.baked-step .pos { color: var(--muted); font-family: ui-monospace, monospace;
    font-size: .8rem; }
.step-action { margin: .35rem 0 .2rem; }         /* plain prose — NEVER a blockquote */
.qty { font-weight: 700; color: var(--text); }   /* the bold quantity, from the int column */
.component { color: var(--text); }
.per-player { color: var(--info); font-size: .74rem; font-weight: 600; margin-left: .3rem; }
.chip { display: inline-block; padding: .05rem .45rem; border-radius: 4px;
    font-size: .68rem; font-weight: 700; letter-spacing: .04em; vertical-align: middle; }
.chip-derived { background: var(--chip-derived-bg); color: var(--info); }  /* verified DERIVED step */
.chip-human { background: var(--amber-fill); color: var(--warn); }  /* human-waved (weaker) */
.chip-hole { background: var(--bad-fill); color: var(--bad); }  /* honest hole */
.chip-directive { background: var(--chip-directive-bg); color: var(--bad); letter-spacing: normal;
    border: 1px solid var(--bad); }  /* V24: directive-lint hold (suspected hostile directive) */
.chip-count { background: var(--chip-count-bg); color: var(--chip-count-fg);
    letter-spacing: normal; }  /* Cutline B: count-conditional tag */
.chip-carried { background: var(--chip-teal-bg); color: var(--chip-teal-fg);
    letter-spacing: normal; }  /* B5: carried verbatim (verdict carried). The CLASS keeps its
    meaning and the TOKEN keeps its colour — the pair is shared with the MECHANIC kind chip
    now, so it is named for the teal rather than for this chip (see .kind-mechanic). */
.chip-regen { background: var(--chip-regen-bg); color: var(--chip-regen-fg);
    letter-spacing: normal; }  /* B5: regenerated by this repair */
.chip-suppressed { background: var(--chip-suppressed-bg); color: var(--chip-suppressed-fg);
    letter-spacing: normal; }  /* A5c: cross-phase duplicate held behind its survivor */
.provenance { color: var(--muted); font-size: .74rem; margin-top: .2rem; }
.provenance a { text-decoration: underline; text-underline-offset: 2px; }
/* B2-PR3: the RAW documentation-rot detail (which stored quotes no longer verify / the fetch
   error). Monospace + pre-wrap so the per-quote lines stay legible; escaped th:text is the §6.1
   boundary (the detail embeds hostile corpus fragments), this class never enables markup. */
.rot-detail { display: block; white-space: pre-wrap; color: var(--muted); font-size: .78rem;
    margin-top: .4rem; font-family: ui-monospace, monospace; word-break: break-word; }
details.claims { margin-top: .35rem; }
details.claims > summary { cursor: pointer; color: var(--muted); font-size: .78rem; }
.claim { border-left: 3px solid var(--line2); padding: .3rem .6rem; margin: .4rem 0;
    font-size: .84rem; }
.claim blockquote { margin: .3rem 0 0; padding: 0; white-space: pre-wrap;
    color: var(--text); }
.claim .claim-meta { color: var(--muted); font-size: .76rem; }
.verdict { display: inline-block; padding: .02rem .4rem; border-radius: 4px;
    font-size: .68rem; font-weight: 700; }
.verdict.pass { background: var(--good-fill); color: var(--good); }
.verdict.fail { background: var(--bad-fill); color: var(--bad); }
.verdict.uncertain { background: var(--amber-fill); color: var(--warn); }
.gate-ok { color: var(--good); font-weight: 600; }
.gate-fail { color: var(--bad); font-weight: 600; }
.banner.disclaimer { background: var(--info-fill); border: 1px solid var(--line-info);
    color: var(--info); }
.banner.override { background: var(--amber-fill); border: 1px solid var(--line-amber);
    color: var(--warn); }
.queue-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem;
    border-bottom: 1px solid var(--line); padding: .6rem .2rem; }
.queue-item:last-child { border-bottom: 0; }
.queue-item .counts { margin-left: auto; color: var(--muted); font-size: .78rem;
    font-family: ui-monospace, monospace; }
/* B2-PR3: the review queue's DRIFTED section — a served checklist the rot sweep marked. A block
   row (head line + the raw rot detail beneath it), so the multi-line detail wraps full width
   rather than fighting the flex head. */
.drift-item { border-bottom: 1px solid var(--line); padding: .6rem .2rem; }
.drift-item:last-child { border-bottom: 0; }
.drift-item .drift-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; }
.drift-item .drift-head form { margin-left: auto; }
table.rates { font-size: .82rem; border-collapse: collapse; margin: .5rem 0; }
table.rates th, table.rates td { text-align: left; padding: .2rem .8rem .2rem 0; }
table.rates th { color: var(--muted); font-weight: 600; }
.review-actions { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1rem;
    align-items: center; }
.review-actions form { display: inline; }
/* THE ACTION BAR (D1) — what the operator can do on this review page, stated at the TOP with
   the consequence beside each action and the reason beside each unavailable one. One action
   per row rather than a flex strip: every slot carries a full sentence, and a wrapping strip
   of five buttons pairs each sentence with whichever button happened to reflow beside it.
   NO NEW PALETTE TOKEN — the inert state is the existing muted ink on the existing tile fill
   inside the existing field boundary, all three already measured in the contrast table, plus
   a little opacity. Deliberately NOT sticky this PR (ruled): five actions with a sentence
   each would own a third of an iPad viewport on every scroll of a twenty-step checklist. */
.action-bar { border: 1px solid var(--line); border-radius: 8px; background: var(--panel);
    padding: .8rem .9rem; margin: .9rem 0; }
.action-bar .bar-headline { font-weight: 700; margin: 0; }
.action-bar .bar-situation { color: var(--muted); font-size: .9rem; margin: .3rem 0 0; }
.action-bar .bar-action { border-top: 1px solid var(--line2); margin-top: .6rem;
    padding-top: .6rem; }
.action-bar .bar-consequence { color: var(--muted); font-size: .85rem; margin: .35rem 0 0; }
/* The inert control keeps the button's SHAPE and loses its affordance, with the reason on the
   line under it — never a control that is simply not there. A missing Discard button with no
   line saying why is half of the incident this bar exists to answer. */
.action-bar .btn[disabled] { background: var(--panel2); color: var(--muted);
    border: 1px solid var(--field-line); font-weight: 400; opacity: .75;
    cursor: not-allowed; }
/* The two-step commit, for the two arms that SPEND GPU TIME. A plain HTML details element:
   no script (the policy admits none anywhere) and no CSS trickery — the summary is the first
   tap, the real POST sits inside it, and no single mis-tap can start a bake. The marker
   triangle is dropped because the control is styled as a button; the ellipsis in the label
   is what says "this opens something". */
.action-bar details.two-step > summary { display: inline-block; list-style: none; }
.action-bar details.two-step > summary::-webkit-details-marker { display: none; }
.action-bar .two-step-body { margin-top: .5rem; }
/* THE STRUCTURE-LABEL CONTROLS (V76 / bake-arc PR-1a) — the optional half of a decision,
   collapsed so the clean one-click path is untouched. NO NEW PALETTE TOKEN: it reuses the
   two-step disclosure's own chrome and the existing field boundary, because it IS the same
   affordance (a plain details element, no script) doing a quieter job — this one does not
   gate a submit, it just keeps two rarely-used selects out of the way. It renders both
   inside the bar and inside the reject form further down the page, so unlike the rules
   above it is scoped to its own class rather than to .action-bar. */
.structure-label { margin: .5rem 0 .6rem; }
/* ONE FRAGMENT, ONE LOOK. The two-step chrome above is scoped to .action-bar, so the copy
   of this fragment that renders inside the reject form — which is not in the bar — kept the
   browser's default disclosure triangle and block layout while the bar's two copies did
   not. Same control, three homes, one appearance. */
.structure-label > summary, .structure-label details > summary { cursor: pointer;
    display: inline-block; list-style: none; }
.structure-label details > summary::-webkit-details-marker { display: none; }
.structure-label .two-step-body { margin-top: .5rem; }
.structure-label label { display: block; margin-top: .5rem; }
.structure-label select { display: block; margin-top: .2rem; max-width: 100%;
    background: var(--panel2); color: var(--ink);
    border: 1px solid var(--field-line); border-radius: 6px; padding: .35rem .4rem; }
/* OPERATOR NOTES (V61): the append-only "for the record" trail on a checklist's review page,
   shown in every status. Quiet panel chrome built ENTIRELY from existing tokens — no new
   palette. The left accent rule marks the section as the operator's own margin notes without
   introducing a colour, and the body keeps the operator's own line breaks (the first note is
   a banner-placement number-table that would collapse to noise on a single line). */
.checklist-notes { margin-top: 1.5rem; }
.checklist-notes .notes-heading { font-size: 1.05rem; margin: 0 0 .5rem; }
.note-list { list-style: none; margin: 0 0 1rem; padding: 0; }
.note-list .note { border: 1px solid var(--line); border-left: 3px solid var(--accent);
    border-radius: 8px; background: var(--panel); padding: .6rem .8rem; margin-bottom: .6rem; }
.note-list .note-body { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.note-list .note-meta { margin: .35rem 0 0; }
.add-note label { display: block; margin: 0 0 .1rem; }
footer.site {
    max-width: 44rem; margin: 2.5rem auto 0; padding: 1rem;
    border-top: 1px solid var(--line); color: var(--muted); font-size: .82rem;
}
footer.site p { margin: .25rem 0; }
footer.site .dot { display: inline-block; width: .6em; height: .6em;
    border-radius: 50%; background: var(--good); margin-right: .45em;
    vertical-align: baseline; }
footer.site .wordmark { font-weight: 700; color: var(--text); }
footer.site .wordmark:hover { text-decoration: underline; text-underline-offset: 2px; }
footer.site .wordmark .arrow { color: var(--accent); }
/* The build stamp (2026-08-02): quieter than the rest of the footer — monospace so the
   sha reads as an identifier, and dimmer so it never competes with the copy above it.
   It is the last line on the page on purpose: always available, never in the way.

   THE DIMMING IS A COLOUR NOW, NOT AN OPACITY (2026-08-05). A .75 opacity over the page
   blends toward the background, and the blended result measured below the legibility
   floor in the dark theme — a real, shipped shortfall that the contrast pass caught and
   that no palette swap would have fixed, because the blend was invisible to it. A token
   is measurable, so it is measured; the line looks the same and now provably reads.

   HOW MUCH DIMMER, PER THEME. In dark it is a distinctly quieter grey (--muted-dim 4.86:1
   against the page, --muted 6.87:1). On paper the two were the same value until the audit
   pass darkened --muted to clear the 4.5 floor over --bad-fill, so the dim token is now a
   hair quieter there rather than equal (4.79:1 against the page, against --muted's 4.94)
   — a difference the eye barely reads, and both sides of it clear the floor. */
footer.site .buildstamp { margin-top: .5rem; font-size: .72rem; color: var(--muted-dim);
    font-family: ui-monospace, monospace; }
/* THE ATTRIBUTION MARK. Sized well past the terms' legibility floor and never below it,
   with the two supplied variants swapped by the same var pair that swaps the palette —
   so the correct one shows in both system preferences with no JavaScript at all.

   THE no-var() FALLBACK DEFAULTS TO THE LIGHT CUT, and here that is a terms question
   rather than a taste one. An engine that cannot resolve a custom property also cannot
   resolve the palette, so it paints this page on the browser's own white — and the DARK
   cut is the reversed artwork, whose lettering is fill="#FFFFFF". Defaulting to it would
   put a white mark on a white page: invisible, which is the one thing the legibility
   clause forbids. Same pair, same reason, as the header control's labels. */
footer.site .bgg-credit { margin-top: .8rem; }
.bgg-mark { width: 160px; height: auto; max-width: 100%; }
.bgg-mark-dark { display: none; display: var(--when-dark); }
.bgg-mark-light { display: inline-block; display: var(--when-light); }
