/* OmniTextus — the shell.
 *
 * This sheet styles the workspace: panels, tabs, and the chrome around a
 * reading pane. It deliberately does NOT style the corpus. Each resource
 * carries its own stylesheet, generated from its own format and paragraph
 * tables and served at /r/<slug>/style.css, and that is the only thing that
 * decides what the text looks like. Anything here that reached into
 * `.text` would be a second opinion about typography the resource already
 * stated — which is the same failure as a second renderer, one layer up.
 */

/* THE ROOT FONT SIZE IS PINNED HERE — decisions item 10.
 *
 * `render.BASE_CSS` opens with `html { font-size: 100% }`, and a resource's
 * stylesheet is linked AFTER this one. `html` and `html, body` are both
 * specificity (0,0,1), so that rule won on document order and **the root font
 * size of this whole application was decided by whether a book happened to be
 * open**: 15px with an empty pane, 16px with a resource loaded. Every `rem` in
 * this file moved with it — both panels 300px <-> 320px, the topbar 39px <->
 * 41.6px, the reading column 630px <-> 672px.
 *
 * `:root` is a pseudo-class, specificity (0,1,0), so it beats a bare element
 * selector regardless of which sheet loaded last. Document order is not
 * something this file should have to win.
 *
 * **16px AND NOT 15px, and the choice is the whole fix.** 15px is what
 * `html, body` below says, so it looks like the app's "own" size — but Jon has
 * only ever read with a book open, which means every `rem` in this file has
 * only ever rendered against 16px. Pinning to 15px would shrink all 335 of
 * them by 6.25% — including ten that land on rendered content, and including
 * `.missing-plate`, which `static/app.js` puts INSIDE `article.text` in place
 * of an image that will not decode. The XPS plates used to guarantee it; since
 * 2026-08-05 the server substitutes a raster for those, so this is reached only
 * by a genuine load failure. (The old count here was wrong twice over: it is
 * **264** inline references, FSBINFOGRAPHICS 169 + LBSINFOGRPHX 95 — 315 is the
 * archive-wide XPS *file* count, 169 + 146 on disk.)
 * Pinning to 16px makes every one of those a
 * no-op: nothing Jon currently looks at moves by a pixel, and the empty-pane
 * state stops being the odd one out.
 *
 * It also removes a class of bug rather than an instance. At 15px each of
 * those ten rules would have had to be restated correctly; an adversarial pass
 * found two I had already missed on the first attempt. At 16px there is
 * nothing to restate.
 *
 * What this does NOT do is change the size of corpus text, and that is
 * measured rather than assumed: every run of text is sized by an absolute `pt`
 * rule in the resource's own sheet — **1,963 length values across all 51
 * generated stylesheets and every one of them `pt`, 688 of those `font-size`**
 * — and text with no such rule inherits from `body`, which this sheet holds at
 * 15px and no resource sheet touches. The earlier claim that pinning the root
 * would shrink all 51 books by 6.7% counted only the 13 shared lines of
 * `BASE_CSS` and missed the generated half entirely.
 * (*"Every run is sized by a `pt` rule"* was true of those 51 Logos books and
 * is false of a converted one - see the `font-size: calc(var(--ws-fscale, 1)
 * * 1em)` rule on `.view.article-view > .text`, decision 224.) */
:root {
  font-size: 16px;
  --bg: #fbfaf7;
  --panel: #f2efe9;
  --line: #ddd7cc;
  --ink: #23201b;
  --dim: #6d675d;
  /* **Three tokens for the Settings panel, because `--dim` is not findable.**
   * Jon, 2026-08-09, answering `margin-notes-density`: *"the labels for the
   * settings are too subdued when in dark mode so it was hard to find them."*
   *
   * Measured in a real browser rather than read off this file (L-024 - the
   * cascade decides which declaration wins and only the runtime knows).
   * `--dim` on the panel's own `--pane` came back **6.04:1 in dark and 5.60:1
   * in light**: above AA (4.5) and below AAA (7), on 12px uppercase headings
   * that are the landmarks a reader scans for. The control EDGES were worse
   * and are half of "hard to find" - an unpressed choice button's only
   * affordance is its border, and `--line` measured **1.45:1 dark, 1.43:1
   * light** against 3:1 for a non-text control (WCAG 1.4.11).
   *
   * Role names, not place names, so the rest of the app can adopt them: a
   * LABEL you scan for, secondary PROSE under it, and a control's EDGE. The
   * floors are the standard's (7.0 text, 3.0 component) and
   * `verify_shell.check_the_settings_panel_is_legible` re-derives every ratio
   * from the running browser in BOTH schemes - so these hexes are an input to
   * a measurement, not a number in prose (L-020).
   *
   * **Deliberately scoped to the Settings panel and its button.** `--dim` is
   * used on every page in this app and has the same weakness everywhere; Jon
   * named the settings, and a palette-wide change is a bigger thing to measure
   * than a smaller one to ship. That is a stated limit, not an oversight. */
  --ink-label: #4a453c;
  --ink-sub: #5c574d;
  --line-ctl: #8d8678;
  --accent: #7a2e2e;
  --accent-soft: #f0e2e2;
  /* **TRACKED-CHANGE AUTHORS.** Two, because the app has two authors it
   * can have. Deliberately NOT `--accent`: that is already a dark red
   * used for focus and emphasis, and a change painted in it would read
   * as the app highlighting something rather than as somebody's edit.
   * Neither carries meaning alone - an insertion is underlined and a
   * deletion is struck through whatever the hue. */
  --track-mine: #1f5fa9;
  --track-other: #8a4b12;
  /* Something went wrong. **Amber and not red**, which is the obvious choice
   * and the wrong one here: `--accent` is already a dark red, and a failure
   * painted in a second red is a failure that looks like the ordinary bar to
   * anyone not comparing swatches. The colour has to differ from THIS palette,
   * not from a default one. */
  --bad: #a15c00;
  --pane: #fff;
  --topbar-h: 2.6rem;
  /* The book/chapter indicator's row. Fixed, never `min-height` - see
   * `.ws-where`, whose comment carries the whole argument.
   *
   * **It is the PANE HOST's top inset now, not part of the topbar** - Jon's
   * items 1 and 2, 2026-08-16. It stopped contributing to `--topbar-real` on
   * the same commit that made it one bar per half, and the two moves are the
   * same move: a bar that describes a half has to live in that half, and a
   * half is a box inside `#pane`. Same rem, same row, one box lower. */
  --ws-where-h: 1.35rem;
  /* THE PER-TAB MENU BAR's row - Jon's `bar-with-what-exists`, 2026-08-26.
   * *"one row with what exists ... so that row will have pop down menus on
   * it."*
   *
   * **Between the tabs and the where-am-i bar**, which is the order down the
   * screen his sentence implies and the order the rest of this chrome already
   * follows: the tabs name the book, the tab's own tools come next because
   * they act on it, the bar says where in it you are, the text is underneath.
   *
   * **A fixed height, never `min-height`** - `--ws-tabs-h`'s argument, which
   * is R3's: a row that grows with its content makes the pane's top inset
   * content-dependent, and `--topbar-real`'s static fallback is the only
   * offset a reader with JavaScript off ever gets.
   *
   * At `:root` under L-079 and not negotiable: `.ws-pview`'s `inset`
   * SHORTHAND reads it, and a shorthand containing an element-scoped `var()`
   * is invalid at computed-value time - every pane collapses to nothing, which
   * is how that lesson was learned.
   *
   * **ZERO BY DEFAULT SINCE 2026-09-19, and it is `--ws-tabtool-h`'s mechanism
   * rather than a new one.** Jon, on the composer tab: *"the menu is not in
   * the menu position - where a menu belongs there is a BLANK EMPTY GAP, and
   * there is MORE GAP before it reaches the title."* Measured on the served
   * page at 1100x900: the tab strip ends at y=86 and the composer's first row
   * began at y=139. **Of those 53 pixels, 32 were this length, reserved for a
   * bar that was `hidden`** - `syncTabbars` shows the bar only for a tab with
   * a resource or a search tab, and a composer tab is neither, so the row was
   * correctly not painted and its space was reserved anyway. The remaining
   * 21.6 are `--ws-where-h`, which is a different question and is NOT touched
   * here (see the filed row).
   *
   * **`body[data-tabbar]` IS WRITTEN BY THE ONE FUNCTION THAT ALREADY DECIDES
   * THIS** - `syncTabbars` computes `bar.hidden` per slot, so the length is
   * written beside the decision rather than re-derived from it (L-017).
   * **ANY, not EVERY**, the annotations row's own settled answer: one half
   * reading a book and the other holding an editor is what this app is FOR,
   * and the reading half needs the row. The other half gets an empty band in
   * space that is already paid for. */
  --ws-tabbar-h: 0px;
  /* **THE LIBRARY PANEL'S OWN BAR, AND ITS OWN LENGTH - not the tab menu
   * bar's.** `#library-head` read `--ws-tabbar-h` from 2026-09-04 (*"a menu
   * sized bar"*, Jon's drawing), and that length went to `0px` by default on
   * 2026-09-19 for every tab with no menu bar. So over a Decisions,
   * Milestones or composer tab the head collapsed to nothing, and its word,
   * Fill and arrows sat half above the panel's top edge, clipped - Jon,
   * 2026-10-02, from his phone: *"When I open library on mobile it is kind of
   * covered up."* The panel's bar is there whenever the panel is, so its
   * height cannot follow a row that comes and goes with the active tab. The
   * SAME value as the menu bar when that shows (2rem; 2.4rem on the phone,
   * below), and `check_the_library_bar_carries_the_word_and_shuts_the_panel`
   * compares the two rather than transcribing either. */
  --ws-libhead-h: 2rem;
  /* THE ANNOTATIONS ROW - Jon, 2026-08-26. *"that can just be a persistent
   * bar if they're on that menu item."*
   *
   * **ZERO by default, and that is the whole mechanism.** The row is part of
   * `.ws-pview`'s top inset unconditionally, so opening and closing it is one
   * custom property changing and nothing measuring anything (R3). A
   * content-sized row here would make the pane's top inset depend on what is
   * IN the row, which is the shape this project has refused five times - and
   * the reader would watch the first line of the book move as the row's
   * contents changed.
   *
   * **`0px`, not `0`.** It is summed inside `calc()` with `rem` terms, and a
   * unitless zero is valid there only by grace; `0px` is unambiguous and
   * costs nothing.
   *
   * At `:root` under L-079 with `--ws-tabbar-h`'s reason exactly: the
   * `.ws-pview` `inset` SHORTHAND reads it, and a shorthand containing an
   * element-scoped `var()` is invalid at computed-value time - every pane
   * collapses to nothing.
   *
   * **Switched on `<body>`, not on the pane, and that is what decides that
   * the row is one state for the whole screen.** The panes read this through
   * an `inset` SHORTHAND, so it has to be declared somewhere that is not
   * element-scoped (L-079) - which means both halves get the inset whichever
   * half's button was pressed. The only remaining question is whether the
   * other half shows a row in the band it has already paid for or an empty
   * gap, and showing it also keeps the two reading columns' first lines
   * level, which is exactly what a reader comparing two books is looking at.
   * `syncTabTools` is the one producer of that decision. */
  --ws-tabtool-h: 0px;
  /* "This is the old copy" (`show-it-and-say-so`, 2026-08-30). A SWITCH, never
   * content-sized (R3, for the seventh time): `0px` here and a fixed height
   * under `body[data-stale]`, so the reading pane's top inset is a constant in
   * each state and never a function of how long the sentence is. */
  --ws-stale-h: 0px;
  /* Where the slow-request bar rides: flush with the BOTTOM EDGE of the
   * topmost chrome row that is on screen. Normally that is the header, so
   * this is `--topbar-real` less the bar's own 2px - measured, and it
   * reproduces the pre-2026-09-05 box to the pixel (top 40.6 at a 42.6px
   * header). `body[data-fullscreen]` re-points it at the location bar, which
   * is the only row that mode keeps.
   *
   * **`--topbar-real`, never `--topbar-h`, for anything below the bar** - the
   * standing rule, and the phone is why: there `.topbar` is `height: auto`
   * and the two differ. The bar is `hidden` until a request is already slow,
   * so `syncTopbar` has always run by the time this is read and the static
   * fallback never decides anything here.
   *
   * At `:root` because the element is a child of `<body>` and the switch is
   * on `<body>`: a value declared on the element itself could not be
   * overridden by the mode (L-079's neighbourhood, one property over). */
  --ws-progress-top: calc(var(--topbar-real) - 2px);
  /* How wide a PINNED contents column is - Jon, 2026-08-30. Unlike the four
   * switches above this one is a real DEFAULT rather than an off-state: the
   * pin is turned on by `body[data-tcpin]`, and this is the width the column
   * takes until the reader drags it. `20rem` is `--panel-w`'s number, and the
   * same number for the same reason - it is the width at which the Contents'
   * `column-width: 7rem` gives two columns.
   *
   * **`static/app.js` writes it on `<body>` and never on the element** (L-079,
   * and `--ws-stale-h` one line up is the worked precedent): `.ws-pview`
   * resolves its own top from an `inset` SHORTHAND whose `var()`s must come
   * from something it inherits, and the pane and the list are siblings. */
  --ws-tccol-w: 20rem;
  /* The TAB STRIP's row - Jon's item 3, 2026-08-16. It moved out of
   * `.topbar-main` and into `#pane` for the same reason the indicator did one
   * item earlier: a bar that describes a half has to live in that half, and a
   * half is a box inside `#pane`. See `.tabstrip`.
   *
   * **`calc(var(--topbar-h) + 1px)`, and the +1px is not a fudge.** The strip
   * used to be a `height: var(--topbar-h)` flex row sitting ON `.topbar`'s
   * bottom border, and `.ws-tab`'s `border-bottom: none` + `margin-top: .3rem`
   * are drawn to meet that edge. Stated as the row PLUS the edge it owns, the
   * tabs keep the geometry they had to the pixel - measured 36.80px tall
   * before and after. Written as a plain `2.65rem` it would be a number that
   * happened to agree with `--topbar-h` until somebody changed one of them.
   *
   * **A fixed height, never `min-height`** - `.ws-where`'s argument, which is
   * R3's: a row that grows with its content makes the pane's top inset
   * content-dependent, and `--topbar-real`'s static fallback is the only
   * offset a reader with JavaScript off ever gets.
   *
   * Safe at `:root` under L-079 because it contains no element-scoped `var()`:
   * `--topbar-h` is declared here and nowhere else. The phone block redeclares
   * this one, also at `:root`, because the strip is a single button there. */
  --ws-tabs-h: calc(var(--topbar-h) + 1px);
  /* **The two panel buttons' widths, and the tab groups' insets, from ONE pair
   * of numbers each — Jon, 2026-08-16.** *"when opened they can read with the
   * words, and when closed, they are the smaller versions."*
   *
   * `--ws-fold-w` / `--ws-fold-w-open` size the BUTTON; `--ws-fold-l` /
   * `--ws-fold-r` inset the tab group so a tab never slides under one. They are
   * separate names and the same values on purpose: the button is one per side
   * and the inset is per group, and a split gives the left inset to slot 0 and
   * the right to slot 1 while an unsplit strip takes both.
   *
   * **A `<body>` attribute switches them, not a measurement.** `syncTopbar` →
   * `--topbar-real` is the precedent for publishing a measured length, and it is
   * the wrong tool here: the width has exactly two states per side, the button
   * and the inset read the SAME variable so they cannot disagree (L-017), and a
   * `<body>` attribute is this app's established channel for published state
   * (`data-split`, `data-disp-*`, `data-notes-place`). Nothing to measure means
   * nothing to keep in step and no trigger set to get wrong.
   *
   * `7.2rem` holds "Contents" — the longer of the two words — plus the 1.02rem
   * icon, the `.3rem` gap and `.5rem` of padding each side, at `.78rem` type.
   * Safe at `:root` under L-079: no element-scoped `var()` inside either. */
  --ws-fold-w: 2.7rem;
  --ws-fold-w-open: 7.2rem;
  --ws-fold-l: var(--ws-fold-w);
  --ws-fold-r: var(--ws-fold-w);
  /* What everything BELOW the bar must clear. Equal to the bar's own
   * height until the bar wraps - see the phone block and `syncTopbar` in
   * `static/app.js`. Deliberately a SECOND variable: `.topbar` sizes itself
   * by `--topbar-h`, so measuring the bar into that name would be a rule
   * feeding its own input.
   *
   * **It used to include the indicator's row and no longer does**, because the
   * indicator is no longer in the bar. The reasoning that put it here still
   * governs what is left: this is the ONLY offset a reader with JavaScript off
   * ever gets, and it is also the one in force on every load until
   * `syncTopbar` runs - this app's `<script src>` is not deferred, so the page
   * lays out and paints first. Measured with a row unaccounted: 24.39px of the
   * pane sat under the bar at desktop and 22.06px at phone, then jumped. That
   * is the same defect Jon reported as *"the top of Yours is cut off"*, an
   * order of magnitude smaller and just as real. The row did not stop
   * existing - it moved into `#pane`, where `.ws-pview`'s top inset accounts
   * for it, and the two together still add up to the same reading pane. */
  --topbar-real: var(--topbar-h);
  --panel-w: 20rem;
  /* **THE OVERLAY PANEL'S STACKING LEVEL, AND IT IS A VARIABLE BECAUSE ONE
     GESTURE HAS TO LOWER IT.** Two rules spell this level - the >=55rem
     unpinned rule and the <=55rem one - and a drag has to get UNDER both
     without either being restated. `body.ws-dragging-tab` redefines it, which
     no selector-specificity fight can do: those two selectors are (0,3,1) and
     a `body.ws-dragging-tab .panel` override is (0,2,1) and loses.

     It stays under `#fold-library`'s 22 - *an overlay panel must not cover
     the control that closes it* - and under `.tabstrip`'s 21. */
  --panel-overlay-z: 20;
  /* **THE READING COLUMN, stated once, because two things now draw it.**
   * `.view` IS the column; the where-am-i bar must CENTRE OVER the column
   * while living outside it, which is Jon's item 1, 2026-08-16 (*"it needs to
   * always center over the text"*). Two rules that each re-typed `42rem` and
   * `1.6rem` would be two producers of one geometry and would drift on the
   * first change (L-017) - the `--ws-split` argument, and the `--ws-what`
   * argument in the Settings panel, arriving a third time.
   *
   * **`:root`, and it was `.pane` for one commit, which was a live bug.**
   * `.pane` looked like the right home - the nearest common ancestor of
   * `.view` inside a pane and of the bars beside them - and it is not,
   * because `.view` ALSO renders outside the shell entirely: `article.html`,
   * `search.html`, `diagnostics.html`, `resource.html`, `plate.html`,
   * `plates.html`, `mine.html`, `decisions.html` and `help.html` all extend
   * `base.html`, whose `<main>` has no `.pane` ancestor. Those are the
   * JavaScript-off half of this app, and a `var()` with no fallback and no
   * definition is INVALID AT COMPUTED-VALUE TIME - so `max-width` became
   * `none` and `padding` became `0` on every one of them. Bounded only by
   * `body:not(.shell) main`'s own 44rem, and invisible to the whole board:
   * `shell` was 187/187 across the commit that broke it.
   *
   * **`--ws-gutter` is deliberately NOT here**: it multiplies `--ws-fscale`,
   * which is a per-TAB inline value on `.ws-pview`, and a custom property
   * declared at `:root` would resolve that `var()` against `:root` and freeze
   * the global scale. It is declared on the two elements that carry the right
   * scale, in one shared rule - see the margin block. That distinction is the
   * whole reason these three are safe here and that one is not: a plain
   * length can be hoisted, a value containing `var()` cannot. */
  --ws-measure: 42rem;
  --ws-vpad: 1.6rem;
  --ws-gutter-gap: 1.2rem;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #16150f;
    --panel: #1e1c16;
    --line: #35322a;
    --ink: #e7e2d6;
    --dim: #9a9284;
    /* Dark is the scheme Jon reported, and the one that needs the most: the
     * same 6.04:1 that reads as merely quiet on white reads as absent on
     * #14130e. Measured on the panel's `--pane`: label 10.29:1, sub 7.80:1,
     * edge 3.77:1. */
    --ink-label: #c9c0ac;
    --ink-sub: #b0a795;
    --line-ctl: #767061;
    --accent: #d98b8b;
    --accent-soft: #2e211f;
    --track-mine: #7fb2ea;
    --track-other: #e0a56a;
    --bad: #e0a458;
    --pane: #14130e;
  }
}

/* -------------------------------------------------------- READING THEMES
 *
 * Jon, 2026-08-27, with a screenshot of a book he could not read: *"dark text
 * on dark background ... maybe just presets, for like an old yellowish
 * background with dark font, and total black background (light text) and total
 * white background (dark text)."*
 *
 * Three themes on `.ws-pview[data-theme="..."]`, one per pane. They override
 * `--pane`, `--ink`, `--line` and `--dim` ON THE PANE, so a themed pane reads
 * on its own paper in its own ink whichever scheme the OS chose. A theme is an
 * OVERRIDE on top of `:root`, never a replacement: with no `data-theme` set
 * nothing here applies and the page is byte-identical.
 * `.ws-pview[data-theme=...]` is (0,2,0) against either `:root`'s (0,1,0), and
 * a media query adds NO specificity, so a theme beats the
 * `prefers-color-scheme: dark` block above without a media query of its own.
 *
 * **A THEMED PANE MUST PAINT ITS OWN SURFACE.** `.ws-pview` declares no
 * background - the reading surface is the HOST `.pane { background: var(--pane) }`
 * and the pane is a transparent scroller on top of it. Overriding `--pane` on
 * the pane alone would leave the host's still-unthemed value showing through
 * and the reader would get the OS paper under themed ink, which is the exact
 * defect this feature exists to remove. So each rule paints
 * `background: var(--pane)` reading its OWN overridden token. That is a second
 * painted surface, not a second producer of the page's paper: the host still
 * owns the chrome - the tab strip, the where-am-i bar and the tabtool row are
 * `data-host-furniture` SIBLINGS of the pane, not children - and a themed pane
 * owns only the reading column it covers.
 *
 * **`--pane` AND `--ink` ARE ALSO IN `archive.py`, AND THAT IS THE ONE THING
 * TO BE CAREFUL WITH.** `archive.THEME_PAPERS` / `THEME_INKS` are what the
 * stylesheet transform reasons with when it decides how far to move a colour,
 * and a band's placement depends on the ink this file paints - measured, using
 * the wrong ink collapses every coloured band to `rgb(2,2,2)`, invisible on a
 * black page. Two files holding one palette is a second producer (L-017), so
 * it is not left to agree by inspection: `verify_reading.stage40` parses this
 * sheet as SERVED and asserts the six values equal the Python ones.
 *
 * `--line` and `--dim` are declared per theme rather than inherited, because
 * both `:root` pairs were tuned against a LIGHT or a DARK paper and a theme's
 * paper is neither: a light `--line` tuned for white vanishes on cream, and a
 * dark `--dim` tuned for `#14130e` burns on pure black.
 *
 * NO `display` IS DECLARED HERE, EVER. `.ws-pview` is toggled through the
 * `hidden` property, and `[hidden] { display: none }` is a USER-AGENT rule an
 * author `display` beats regardless of specificity (L-024). This project has
 * been bitten by that twice. */
.ws-pview[data-theme="sepia"] {
  /* **A THEME IS A COMPLETE PALETTE FOR THE PANE, NOT FOUR TOKENS.** The
   * first version declared only `--pane`, `--ink`, `--line` and `--dim`, and
   * Jon found what that costs within the hour: *"I changed it to sepia, and
   * now I can't read my LSB. The title 'Decisions' in the decisions tab isn't
   * visible."* He reads in DARK MODE. The dark `:root` block above redefines
   * **twelve** tokens; a themed pane overrode four, so the other EIGHT kept
   * their dark values - light ink on a cream page, invisible. A light-mode
   * probe passes on this defect, which is why it shipped.
   *
   * So a light-paper theme takes the LIGHT inks and a dark-paper theme the
   * DARK ones, and every token the dark block touches is declared here.
   * **`verify_shell` asserts that set relationship from the SHEET ITSELF**,
   * so a thirteenth token added to the dark block turns a check red rather
   * than silently escaping a list somebody has to remember (L-076). */
  --accent: #7a2e2e;
  --accent-soft: #f0e2e2;
  --bad: #a15c00;
  --bg: rgb(244,232,205);
  --track-mine: #1f5fa9;
  --track-other: #8a4b12;
  --ink-label: #4a453c;
  --ink-sub: #5c574d;
  --line-ctl: #8d8678;
  --panel: #ece0c4;
  --pane: rgb(244,232,205);
  --ink: rgb(35,32,27);
  --line: #d2c4a3;
  --dim: #635a4e;
  background: var(--pane);
  color: var(--ink);
}
.ws-pview[data-theme="black"] {
  --accent: #d98b8b;
  --accent-soft: #2e211f;
  --bad: #e0a458;
  --bg: rgb(0,0,0);
  --track-mine: #7fb2ea;
  --track-other: #e0a56a;
  --ink-label: #c9c0ac;
  --ink-sub: #b0a795;
  --line-ctl: #767061;
  --panel: #1e1c16;
  --pane: rgb(0,0,0);
  --ink: rgb(233,228,214);
  --line: #2c2924;
  --dim: #8f877a;
  background: var(--pane);
  color: var(--ink);
}
.ws-pview[data-theme="white"] {
  --accent: #7a2e2e;
  --accent-soft: #f0e2e2;
  --bad: #a15c00;
  --bg: #ffffff;
  --track-mine: #1f5fa9;
  --track-other: #8a4b12;
  --ink-label: #4a453c;
  --ink-sub: #5c574d;
  --line-ctl: #8d8678;
  --panel: #f2efe9;
  --pane: rgb(255,255,255);
  --ink: rgb(35,32,27);
  --line: #ddd7cc;
  --dim: #6d675d;
  background: var(--pane);
  color: var(--ink);
}

* { box-sizing: border-box; }

/* ============================================================================
 * `hidden` MEANS HIDDEN — the one rule, instead of sixteen that had to be
 * remembered.
 *
 * `[hidden] { display: none }` lives in the USER-AGENT sheet, and an author
 * `display` beats a user-agent one regardless of specificity. So every element
 * in this file that declares a `display` and is also toggled with the `hidden`
 * property needed a hand-written `.thing[hidden] { display: none }` companion
 * beside it — and there are **16 such rules carrying 17 selectors**, each added
 * the day somebody was bitten. (Derived, not counted by eye, because nothing
 * re-derives a number in prose — L-020. Strip comments, then match rules whose
 * selector carries `[hidden]` and whose body declares `display: none`; two
 * earlier readings of this same sheet said "fourteen" and "13".)
 * `.ws-progress`'s comment says so in as many words: *"`.popover` and
 * `.ws-note` each already carry this line, which is exactly why nobody noticed
 * it was needed."*
 *
 * **And FOUR elements had no companion at all** — `#ws-undo-btn`,
 * `#ws-back-btn`, `#ws-fwd-btn` and `.outline-level` — working only because
 * nothing happens to declare a `display` on them, which `.ws-undo-btn`'s own
 * comment states as a standing prohibition (*"NO `display` DECLARATION HERE,
 * EVER"*). A prohibition is a keep-list with no checker. This rule retires it.
 *
 * **That is a keep-list, and the fourth entry it lost was the one Jon hit
 * (L-076).** `#ws-bookctx` carries the companion through `.ws-tabctx[hidden]`,
 * and `templates/shell.html` has a comment nine lines above the element saying
 * that companion is what makes `hidden` work — and then
 * `#ws-bookctx { display: flex }` was added for the info card's flip order.
 * An ID selector is (1,0,0) and beats the companion's (0,2,0), so from the hour
 * the card shipped `closeBookContext()` could not close the menu. Measured in
 * Chromium at 9a78095: after clicking away, after pressing Escape, and after
 * clicking one of the menu's own buttons, `hidden` was `true`, computed
 * `display` was `flex`, the box was 320x466 and `elementFromPoint` at its
 * centre answered an element inside the card. Jon had to reload the page, and
 * the Escape workaround he was given does not work either.
 *
 * `!important` — the first in this sheet — because this is not a style
 * decision being overridden. `hidden` is the platform saying *this element is
 * not relevant*, and there is no design in this app under which such an element
 * should paint: every one of the sixteen companions declares `display: none`,
 * and `render.py` never emits the attribute, so this rule contradicts nothing
 * that exists. Specificity cannot express "beats anything an author writes
 * later"; `!important` can, and a rule that has to be remembered at fifteen
 * sites is the thing being removed.
 *
 * The sixteen companions are LEFT STANDING and are now redundant. They are
 * kept because each carries the record of the incident that produced it, and
 * because sweeping them would touch sixteen unrelated features in the commit
 * that unblocks Jon. Anything NEW writing a `display` no longer needs one.
 *
 * `verify_shell.check_hiding_a_thing_takes_it_off_the_screen` is the red line:
 * it hides every element this app can hide and measures the BOX, because the
 * attribute is what was already being asserted while Jon looked at the menu. */
[hidden] { display: none !important; }

/* SBL BIBLIT, AND IT IS ONE DECLARATION BECAUSE THE OLD ONE WAS ALREADY HERE.
 *
 * Jon, 2026-09-12: *"I want this to be the standard default font for all
 * Greek/Latin/Hebrew tabs/texts. Maybe even default for the whole app, as a
 * serif font."*
 *
 * **THE HANDOVER SAID THIS APP DECLARED NO FONT AND THAT WAS FALSE** (L-454).
 * The line below already set one, as part of the `font:` SHORTHAND - which is
 * why a census grepping `font-family` reported six monospace lines and missed
 * the declaration that decides the whole application. Measured: 46 `font:`
 * shorthands in this sheet, exactly ONE naming a family, 45 of them
 * `font: inherit` control resets. And `Source Sans Pro` is not installed
 * here, so every book with no font of its own has been falling through to
 * `system-ui` - **a sans-serif nobody chose**, which is the thing Jon was
 * actually asking to be rid of.
 *
 * **THE STACK AFTER SBL BIBLIT IS LOAD-BEARING, NOT CEREMONIAL.** The font
 * covers 1,160 codepoints - Latin-1 Supplement 100%, Greek Extended 91%,
 * Hebrew 78% - but **combining diacritics only 38%**, so a pointed-Hebrew or
 * polytonic-Greek run can need a mark it does not carry. Charis SIL and
 * Gentium Plus are SIL academic faces with deep combining coverage and are
 * installed on this box; Noto Serif is near-universal on Android, DejaVu on
 * Linux desktops. Named in that order so the first face that HAS the mark
 * draws it - per-glyph fallback is what a stack is for.
 *
 * **`data-font="prev"` is the way back and it is one tap** (`lr.font`,
 * `screenFont`/`setScreenFont`, the Settings row). The orchestrator ruled on
 * 2026-09-12 that this ships with the previous look selectable rather than
 * being asked about, because Jon's own word was "maybe".
 *
 * **WHAT THIS DOES NOT DO IS TOUCH THE CORPUS**, and that is this sheet's
 * opening rule rather than a limitation. A book that states its own face
 * keeps it; this reaches the 288 of 573 stores that state none, plus the
 * app's own chrome. Jon answered `font-override-reach` -> `wait-for-the-fonts`
 * on 2026-09-12 knowing exactly that: `logos-linux` measured 96 of 508 source
 * EPUBs embedding fonts the converter discards, so most of what an override
 * would replace is about to come back as the publisher's real typeface. The
 * override is deferred until it does, on his word, and is not absent by
 * oversight. */
@font-face {
  font-family: "SBL BibLit";
  /* **TTF, UNMODIFIED, AND NOT woff2.** The licence extracted from the font's
   * own `name` table (IDs 0, 7, 13 - `sbl-site.org` answers HTTP 403 behind a
   * Cloudflare challenge) permits embedding in non-commercial web pages under
   * clause 5, and clause 4 forbids redistributing a MODIFIED version - a
   * format conversion is one. `OS/2.fsType` = 0x0008, Editable Embedding,
   * verified in the shipped file. sha256 in `static/SHA256SUMS-sbl`.
   * NON-COMMERCIAL ONLY: a commercial OmniTextus needs a paid licence from
   * SBL and Tiro Typeworks. */
  src: url("/static/SBL_BLit.ttf") format("truetype");
  font-display: swap;
}

html, body {
  margin: 0; padding: 0; height: 100%;
  background: var(--bg); color: var(--ink);
  font: 15px/1.5 "SBL BibLit", "Charis SIL", "Gentium Plus", "Noto Serif",
        "DejaVu Serif", serif;
}

/* The way back. A body attribute rather than a second `html, body` rule, so
 * the two answers to *what face is this* sit at different specificities and
 * neither depends on document order - the `:root` pin's lesson above. */
body[data-font="prev"] {
  font-family: "Source Sans Pro", system-ui, -apple-system, sans-serif;
}

/* THE PAGE GUTTER — the other half of decisions item 10, and the half that
 * actually gives the reader something back.
 *
 * `render.BASE_CSS` says `body { margin: 0 1em }`, which is right for an EPUB,
 * where `body` IS the article. Here `body` is the whole application, so it put
 * a 15px gutter down each side of the workspace — `main` 860px -> 830px at a
 * 1500px viewport — and it won over the `margin: 0` above purely on document
 * order, both being specificity (0,0,1).
 *
 * `html body` is (0,0,2): the same statement at a specificity a bare `body`
 * cannot tie, so the outcome stops depending on which stylesheet the browser
 * finished parsing last. Same lesson as the `:root` pin above and as
 * `#pane .ws-landed` further down — where a cascade decides, say it at a
 * specificity that does not need document order to hold.
 *
 * The `em` was against BODY's font-size, which is 15px in both states, so
 * pinning the root did not touch this. The two halves of item 10 are genuinely
 * independent and both are needed. */
html body { margin: 0; }

/* The shell's own leading, on the shell's own furniture. `BASE_CSS` sets
 * `body { line-height: 1.4 }` for the article, and inheritance carried it into
 * the topbar, the shelf and the outline — chrome the renderer has no business
 * sizing. Stated here it stops crossing; the PANE keeps inheriting the
 * renderer's 1.4, which is correct, because there the renderer is right. */
/* **`.tabstrip` joined this list when it moved into `#pane`, 2026-08-16.** The
 * sentence below is about the SHELL's chrome, and the strip is chrome wherever
 * it lives - but it is now a descendant of the pane, so without being named
 * here it silently inherits the renderer's 1.4 along with the reading text.
 * Invisible today (the tabs are stretched by their group's flex row and
 * measured 36.80px either way), and exactly the kind of thing that stops being
 * invisible the moment somebody derives a height from a measurement. */
/* **`.ws-tabcontents` joined this list on 2026-08-29, and a real leak is why.**
 * The contents moved INSIDE `#pane` when it went per half, so - exactly as the
 * strip did in 2026-08-16 - it stopped being a sibling of the reading text and
 * became a descendant of it, silently inheriting the renderer's 1.4 from
 * BASE_CSS's `body { line-height: 1.4 }`. Unlike the strip's case this was NOT
 * invisible: `verify_shell`'s resource-sheet reach check went red naming it
 * outright (`.ws-tc-body::lineHeight 22.5px -> 21px`), i.e. a BOOK's own
 * stylesheet was restyling the chrome that lists its chapters. That guard is
 * exactly what it is for. */
/* **`.ws-progress` joined this list on 2026-09-05, and it is the same leak in
 * the opposite direction.** The bar left `.topbar` that day (it had no box at
 * all in full-screen reading while it lived there), and the moment it became
 * a child of `<body>` its label stopped inheriting the 1.5 above and started
 * inheriting BASE_CSS's 1.4 - a BOOK's stylesheet sizing the app's own
 * failure notice. MEASURED, because it is exactly the size nobody would look
 * for: the pill went 24.8px tall to 23.6px on the same word. Naming it here
 * puts it back to the pixel. */
.topbar, .panel, .tabstrip, .ws-tabcontents,
.ws-progress { line-height: 1.5; }

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

/* ---------------------------------------------------------------- topbar */

/* **The bar is a BLOCK holding a flex row**, and its height is `--topbar-h` -
 * a CONSTANT at every width, which is the property `--topbar-real`'s static
 * fallback rests on.
 *
 * **It used to hold the indicator too, and R3 is still the reason it never
 * held it as a FLEX CHILD.** Making the indicator a flex child of this element
 * with `flex-wrap: wrap` was measured and refused - five defects, listed in
 * `templates/shell.html` beside `.topbar-main` and in
 * `research/where-am-i-refutations-2026-08-15.md` R3. The block-sibling shape
 * that answered R3 is what shipped, and it held for one Jon-item: on
 * 2026-08-16 he asked for the indicator per PANEL, and a singleton in this
 * element cannot be per-panel by any amount of styling. So it left. **R3's
 * finding survives the move and gets stronger** - the height this element
 * contributes is now `--topbar-h` alone, which is constant by construction
 * rather than by a second fixed row agreeing with it.
 *
 * **No `overflow: hidden` here, deliberately.** It is the tempting belt, and
 * `.ws-menu-list` is what it would cost: the hamburger's dropdown opens
 * `.35rem` BELOW this element and is only unclipped because nothing here
 * clips. (`.ws-progress` was the other reason and is no longer one - it left
 * this element on 2026-09-05 and is `position: fixed` now, so it is outside
 * any clip this rule could apply. It still rides the bottom border; what
 * changed is which box it is measured from.) Nothing in here can overflow
 * vertically anyway. */
.topbar {
  position: fixed; top: 0; left: 0; right: 0;
  background: var(--panel); border-bottom: 1px solid var(--line);
  z-index: 30;
}

.topbar-main {
  height: var(--topbar-h);
  display: flex; align-items: stretch; gap: .4rem;
  padding: 0 .5rem;
}

.brand {
  align-self: center; font-weight: 600; letter-spacing: .01em;
  color: var(--dim); font-size: .85rem; white-space: nowrap;
}

/* **THE PAINT LIVES HERE AND THE LAYOUT LIVES IN `#pane > .fold`, and keeping
 * them apart is the whole point.** `#pane > .fold` is (1,1,0); every state rule
 * for these buttons — `.fold:hover` (0,2,0) and the phone's
 * `.fold[aria-pressed="true"]` (0,2,0) — is lower, so any `background` or
 * `border-color` declared THERE wins unconditionally, at every width, in every
 * engine, and the state rules are not weak but DEAD (L-085).
 *
 * Measured at `316c05e`, 390px, panel OPEN: `background-color` came back
 * `rgb(242,239,233)` = `--panel` where `--accent-soft` `#f0e2e2` was asked for,
 * and `border-color` `rgb(221,215,204)` = `--line` where `--accent` `#7a2e2e`
 * was. Only `color` survived, because `#pane > .fold` happens to declare none.
 * Hover's border feedback was gone at every width for the same reason.
 *
 * `background: var(--panel)` rather than `transparent` because the button sits
 * on the tab row and needs the row's ground — which is exactly what `#pane >
 * .fold` was saying, one specificity class too high. The two `.fold` elements
 * in the app are both `#pane`'s own children (`grep -rn 'class="fold' \
 * templates/`), so moving it down changes nothing that paints today and gives
 * the state rules back their say. */
.fold {
  align-self: center; border: 1px solid var(--line); background: var(--panel);
  color: var(--dim); border-radius: 4px; padding: .2rem .5rem;
  font-size: .78rem; cursor: pointer; white-space: nowrap;
  /* `inline-flex` so the drawn panel icon and its word sit on one centred
   * line without a `vertical-align` fudge - and so that with the word hidden
   * on a phone the icon centres itself in the button's `min-width` rather
   * than sitting where a text baseline would have put it. */
  display: inline-flex; align-items: center; justify-content: center;
  gap: .3rem;
}
/* The two panel buttons say WHICH SIDE they open, because on a phone the word
 * beside them is hidden and both used to be `☰` - which put three identical
 * hamburgers in a row next to the tool menu. Drawn rather than typed, for the
 * reason the wheel and the tool menu are. */
.ws-fold-icon { display: block; width: 1.02rem; height: 1.02rem; }
.fold[aria-pressed="false"] { opacity: .55; }
.fold:hover { color: var(--ink); border-color: var(--dim); }

/* **THE TWO BUTTONS SIT ON THE TAB ROW, AT THE READING AREA'S EDGES** — Jon's
 * 2026-08-16 ask, and `templates/shell.html` carries why they are `#pane`'s own
 * children rather than the strip's.
 *
 * `#pane` is `position: relative`, so these position against it — and its edges
 * are the ones that already follow the two folding panels. With a panel open and
 * in flow (>55rem) the button therefore sits immediately beside the panel it
 * opened rather than hard against the window; with it closed the host is
 * full-width and the button is at the screen's edge, which is the state that
 * matters most.
 *
 * **`z-index: 22`, and every neighbour's number is why.** `.tabstrip` is 21 and
 * its `.ws-tabgroup` fills the whole strip box with `pointer-events: auto`
 * (the strip itself is `none`, for the sash), so a button under the group is
 * painted and unclickable. Above the strip it must also stay above `.panel`'s
 * 20 — an overlay panel below 55rem must not cover the control that closes it —
 * and below `.topbar`'s 30 and `.noscript-note`'s 40. `.pane` is
 * `position: relative; z-index: auto` and creates NO stacking context, so all of
 * these compete in the ROOT one and the numbers are directly comparable; that is
 * the trap `.tabstrip`'s own comment records.
 *
 * `height: var(--ws-tabs-h)` rather than a padding: the row is a fixed height
 * (R3) and the button filling it exactly is what keeps it a thumb target on a
 * phone without a second `min-height` to keep in step.
 *
 * **LAYOUT ONLY — no `background`, no `border-color`.** See `.fold`'s own
 * block: at (1,1,0) this rule outranks every state rule these buttons have, so
 * a paint declaration here is not a default, it is a veto. */
#pane > .fold {
  position: absolute; top: 0; z-index: 22;
  width: var(--ws-fold-w); height: var(--ws-tabs-h);
  margin: 0; border-radius: 0; align-self: auto;
  overflow: hidden;
}
#pane > #fold-library { left: 0; border-left: none; border-top: none; }
/* `#fold-outline` sat here at `right: 0` until 2026-08-29. The panel it folded
 * is gone (`tab-contents-replaces-panel` -> `replace-it`) and the control that
 * opens the contents is now a `.ws-tabbar` toggle, one per half. The LEFT rule
 * stays: the library panel is untouched. */

/* **THE WORD APPEARS WHEN THE PANEL IS OPEN.** One attribute per side on
 * `<body>`, written by `foldSetup`'s `apply` in `static/app.js` — the one place
 * that already decides open-or-closed, so this is a publication of that decision
 * and not a second copy of it.
 *
 * The button widens and the tab group's inset widens with it from the same
 * variable, so a tab can never end up under a word that just appeared. Note the
 * `--ws-fold-*` redefinitions are on `body`, not on the buttons: the group that
 * has to move is inside `#tabstrip`, a different subtree, and a custom property
 * has to be declared on a common ancestor of both readers.
 *
 * **This replaces the phone-only `.ws-fold-word { display: none }`** that bought
 * the search box its width on 2026-08-16. Jon approved that hiding (*"the words
 * ... being hidden in mobile is fine"*) and then asked for something better than
 * a width rule: the word is now a function of STATE at every width, so the
 * desktop gets the same economy the phone got and the phone gets the word back
 * exactly when it is useful. Anything keyed on the old media-query rule would be
 * a rule that can no longer fire (L-052). */
/* **THE WORD IS GONE FROM THIS BUTTON AND SO IS THE WIDENING — Jon,
 * `library-button-word` -> `word-on-the-panel`, 2026-09-02.** He said: *"the
 * 'Library' button, when it opens the library, I think it should not push over
 * into the tabs, but that library button should sit on top of the library."*
 *
 * **The number was 72px and it is now zero.** `--ws-fold-w` is 2.7rem and
 * `--ws-fold-w-open` was 7.2rem, and the tab group's inset read whichever was
 * current - so opening the library shoved every tab 4.5rem right and closing it
 * shoved them back. `--ws-fold-l` now resolves to the closed width for ever and
 * the group cannot move.
 *
 * **This is not undoing his August ask, it is where he chose to spend it.** On
 * 2026-08-16 he asked for *"when opened they can read with the words, and when
 * closed, they are the smaller versions"*; the two pull against each other, he
 * was asked which, and the word moved to `#library-head` - printed on the list
 * rather than on the button. `.ws-fold-word` and the three `body[data-panel-*]`
 * rules that switched it are DELETED rather than left standing: with the span
 * gone from `shell.html` they could never fire again, and a rule that cannot do
 * anything reads as coverage (L-052). The button's accessible name was never
 * the span - it is `aria-label="Library panel"` on the button itself.
 *
 * **`--ws-fold-w-open` is deliberately KEPT.** It is what a check compares the
 * shipped constant width against, and it is the one place the 72px this change
 * gives back is written down. */
body[data-panel-right="open"] { --ws-fold-r: var(--ws-fold-w-open); }
/* **`--ws-fold-r` IS KEPT AND IS NOW ALWAYS THE CLOSED WIDTH**, and deleting it
 * would be the mistake. `body[data-panel-right="open"]` can no longer be
 * written - `foldSetup` had the only writer and the right-hand panel is gone -
 * so this half of the pair is a rule that can never fire (L-052) and its two
 * selectors are removed above. But the tab GROUP's right inset still reads
 * `--ws-fold-r`, and it must, because the strip's own right edge is still
 * `#pane`'s: the variable resolves to the closed width for ever, which is the
 * correct constant now that nothing sits there. Do not "simplify" the inset to
 * zero without measuring the strip against `#pane`'s right edge first. */

/* The corpus search box, and it belongs HERE rather than beside the search
 * PAGE it used to sit with: it is topbar chrome, and its position in this
 * file is load-bearing. Six hundred lines further down it came AFTER the
 * phone block, so `@media (max-width: 34rem) { .topsearch { margin: 0 .2rem } }`
 * lost the tie on source order and did nothing - the phone bar was serving
 * 0.5rem margins and an 84.4px box where its author had asked for 94.0px.
 * Measured at 390px, both arms in a browser, because only a browser says
 * which rule won (L-024). A media query buys no specificity; the only thing
 * that decides two equal-specificity rules is which one is later.
 *
 * So: base first, then every media block that narrows it. The band block
 * below and the phone block further down are both after this, which is what
 * makes them mean what they say. */
.topsearch { display: flex; align-items: center; margin: 0 0.5rem; }
.topsearch-box {
  width: 14rem;
  padding: 0.25rem 0.5rem;
  border: 1px solid var(--line);
  border-radius: 0.25rem;
  background: var(--pane);
  color: var(--ink);
  font: inherit;
  font-size: 0.85rem;
}
.topsearch-box:focus { outline: 2px solid var(--accent); outline-offset: -1px; }

/* ------------------------------------------ THE BAND BETWEEN THE PHONE AND 800px
 *
 * **Between 545px and 799px every control past the search box was CLIPPED, and
 * clipped is not the same as pushed to the right.** `.topbar` is
 * `position: fixed`, and a fixed element contributes nothing to the document's
 * scrollable overflow - measured, `document.documentElement.scrollWidth ==
 * clientWidth` at every width in the band - so what hangs off the edge cannot be
 * reached by scrolling, panning or anything else. It is gone.
 *
 * Measured at 149bb78, the row's own `scrollWidth` against the viewport:
 *
 *     viewport   row      lost off the right edge
 *     545        797      Diagnostics, Decisions, Help, Settings, Outline
 *     600        797      Decisions, Help, Settings, Outline
 *     700        797      Settings, Outline
 *     780        797      Outline
 *     800        797      - (fits)
 *
 * The row is a rigid **797px** across the whole band because NOTHING in it can
 * give: `.brand`, `.fold` and `.dx-link` are all `white-space: nowrap`, the
 * links are explicitly `flex: 0 0 auto`, and `.topsearch` is a flex item whose
 * automatic minimum size is the `<input>`'s, so it held exactly 224px from 545
 * to 800 without yielding a pixel. A flex row with no elastic item does not
 * compress, it overflows.
 *
 * `.dx-link`'s own comment above already names this mechanism - *"a topbar item
 * that can be squeezed is one that pushes its neighbour off the screen ... which
 * is exactly what adding this link did to the Outline button"* - and the answer
 * it took was to make the links rigid, which moved the overflow to the END of
 * the row rather than removing it. **Rigidity is not the fix; it is what
 * guarantees a hard edge.** The row needs exactly one item that yields, and the
 * search box is the only one that is still useful at any width - the same
 * argument the phone block made at 34rem and never carried upward.
 *
 * **Two stages, and the thresholds are derived rather than chosen.**
 *
 *   - `<= 50rem`: the brand goes and the search yields. Without the brand's
 *     84px the row cannot fit at 545 no matter how small the box gets; with it
 *     hidden the fixed content is 490.5px and the box takes the remainder.
 *   - `<= 37.5rem` (below): the link words become their glyphs, which is where
 *     the yielding box would otherwise get narrower than the phone's own -
 *     110.6px at 544 when this was measured. 490.5 + 110.6 = 601.1, so 600px
 *     is the width at which words stop being affordable, and it is not a round
 *     number anybody liked.
 *
 *     **That 110.6 moved to 120.3 an hour later**, when the phone's own
 *     `margin: 0 .2rem` was found to have been dead since it was written and
 *     was revived. The threshold did NOT move with it, and the check that
 *     guards this band deliberately stopped comparing the two: coupling a
 *     desktop breakpoint to a phone measurement means every phone tweak breaks
 *     a desktop threshold, and the guard then goes red on a build nobody broke
 *     (L-069 - when a guard refuses a build you trust, suspect its idea). What
 *     it floors on instead is the `4rem` the phone block itself declares as
 *     this box's flex basis: the one place in this sheet where anybody wrote
 *     down how small the search may get.
 *
 * Both bounds are floored at 34.0625rem = 545px so the phone block below owns
 * its own regime whatever the source order does; `.topsearch`'s base rule is
 * ABOVE this one and already silently beats the phone's `margin`, which is a
 * reminder that a media query buys no specificity.
 *
 * **NOT a wrap.** R3 refused `flex-wrap` on this row for five measured reasons -
 * viewport-dependent bar height, 24.39px of text painted under the bar before
 * `syncTopbar` runs, 15.5% of the phone's landscape reading pane, `.ws-tab`
 * compressed 35.80 -> 28.69px, and a JS-off fallback that cannot be right for a
 * bar whose height depends on the viewport. Everything below `--topbar-real`
 * still depends on this row being a CONSTANT height, and it stays one.
 */
@media (min-width: 34.0625rem) and (max-width: 50rem) {
  /* Decorative, and the one item in the row that costs 84px and does nothing.
   * The phone has hidden it since the beginning for the identical reason. */
  .brand { display: none; }
  /* `0 1 14rem`, not `1 1`: the basis IS today's 224px, so at every width where
   * the row already fitted the box is unchanged to the pixel, and it never
   * grows into slack the old row did not give it either. `min-width: 0` on both
   * is what actually permits the shrink - without it the input's automatic
   * minimum size holds the form at its content width and the row overflows
   * exactly as before. */
  .topsearch { flex: 0 1 14rem; min-width: 0; }
  .topsearch-box { width: 100%; min-width: 0; }
}

/* ------------------------------------------------------------------ tabs */

/* **THE STRIP IS HOST FURNITURE IN `#pane` — Jon's item 3, 2026-08-16.**
 *
 * *"we need to tabs to align over what they present. right set of tabs, left
 * side, etc. otherwise its just hard to tell whats for what"*, and, geometric:
 * *"the tabs themselves should only go over the specific panel ... the tabs
 * should always stay with whatever panel they are in."*
 *
 * Measured at `e7927dd`, the fraction of a tab lying over the half it opens,
 * divider at 25/50/75%: the LEFT tab 1.000 every time (it is left-packed and
 * the left half starts at the same edge), the RIGHT tab **0.440 / 0.000 /
 * 0.000** — at half and beyond it was entirely over the other book. His
 * sentence, as a number.
 *
 * **It had to leave `.topbar`, and the reason is geometry not taste.**
 * `.topbar` is `position: fixed; left: 0; right: 0` and spans the VIEWPORT;
 * the halves are cut out of `#pane`, whose edges move with the two folding
 * panels. Saying "over the right half" from the topbar needs `#pane`'s box
 * republished as a variable, which is a second producer of what `--ws-split`
 * already owns (L-017). Here it is the same three-rule seam arithmetic the
 * panes, the sash, the drop zones and the two where-am-i bars already use.
 *
 * **ONE element, ONE id, two groups inside it.** `static/app.js` binds ten
 * delegated listeners to `#tabstrip` and `refreshTabs` replaces its innerHTML
 * wholesale; two sibling strips would have split all of that, and every
 * `#tabstrip ...` locator across three suites with it.
 *
 * **`z-index: 21`, and the number is the whole of "the tabs stay reachable".**
 * `.pane` is `position: relative` with `z-index: auto`, so it creates NO
 * stacking context and this competes in the ROOT one — against `.panel`'s 20
 * (a FIXED overlay at ≤55rem, which is Jon's travel laptop) and `.scrim`'s 15
 * (≤34rem, his phone). At the obvious `3` an open Library panel would cover
 * the left half's tabs, and on the phone a tap meant for the tab button would
 * land on the scrim and merely close the panel. The strip used to live in
 * `.topbar`, `z-index: 30`, above both — so anything less than 21 is a
 * REGRESSION rather than a new limitation. It stays under 30 because the
 * topbar is still the thing that must never be covered.
 *
 * It is therefore also above `.ws-drops`' 4, which costs one thing and buys
 * another: releasing a dragged tab over the other half's strip does nothing
 * (exactly as releasing it over the topbar does today), and in exchange the
 * dragged tab stays visible instead of sitting under the zones' 70% veil.
 *
 * `pointer-events: none` here with `auto` on the children is the `.ws-drops`
 * precedent and it is load-bearing: the sash runs `top: 0` THROUGH this row,
 * so the 9px seam gap between the two groups has to pass its drag down to it.
 * Delegated listeners are unaffected — a descendant with `auto` still
 * hit-tests and its events still bubble through here. */
.tabstrip {
  position: absolute; top: 0; left: 0; right: 0; z-index: 21;
  height: var(--ws-tabs-h);
  pointer-events: none;
}
/* The halves. A flex row each, so the tab list and the "+" sit side by side;
 * the SCROLL lives on the list, not on the group, so a group of forty tabs
 * scrolls under a `+` that stays put. `gap: .3rem` is what `.tabstrip` used to
 * carry and is the 4.80px the `+`-placement check measures.
 *
 * `top: 0; bottom: 0` rather than `height: 100%`, so `.tabs { height: 100% }`
 * resolves against the same box it always did.
 *
 * The background and the bottom border are on the GROUP and not on the strip,
 * which is what lets the sash's line show through the seam: the divider then
 * runs unbroken from the top of the tabs to the bottom of the text, and
 * "which tabs are for which book" stops needing to be inferred. */
/* **`padding`, and never a moved edge** — the two panel buttons sit on this row
 * at the host's edges since 2026-08-16, and a tab must not slide under one.
 *
 * Padding insets the tab LIST while leaving the group's own box, its
 * `background` and its bottom border exactly where the seam puts them. Moving
 * `left`/`right` instead would shrink the box, and item 3's measured property —
 * the fraction of a tab lying over the half it opens, 1.000 on both halves at
 * every divider position — is a claim about that box. This way that property is
 * untouched by construction rather than by re-measurement.
 *
 * An UNSPLIT strip is one group at `left: 0; right: 0`, so it takes both insets;
 * a split gives the left one to slot 0 and the right one to slot 1, because each
 * half owns exactly one screen edge. The two overrides below the seam rules do
 * that, and since 2026-09-01 they are the group's own `--slot-pl` /
 * `--slot-pr`, stamped per group by the server from the occupancy census
 * rather than zeroed by a rule that had to name a slot to do it. */
.ws-tabgroup {
  position: absolute; top: 0; bottom: 0;
  /* **ONE RULE, NAMING NO SLOT, AND NO GATE.** Two rules keyed on
     `[data-groups="2"] [data-slot="0"|"1"]` positioned the halves until
     2026-09-01, and all three of their failures at three panels were silent:
     group 1 kept `right: 0` and spanned areas 1 AND 2; group 2 matched
     nothing, fell back to the ungated `left: 0; right: 0`, and painted its
     tabs opaquely over both others; and a workspace occupying slots 0 and 2
     with nothing in 1 computed `data-groups="1"`, so NO group was positioned
     and every one of them stacked - the doubled-labels state that attribute
     exists to prevent, reachable from an ordinary split.

     `--a-*` is `syncAreas`' live answer. `--slot-l` / `--slot-r` are the
     SERVER's, stamped per group in `_tabbar.html` from the OCCUPANCY census
     (L-328 - the vocabulary is not the census), and they are what keeps the
     scripting-off promise this block's own comment is about: equal shares of
     the occupied groups, with the sash's width taken out of the seam exactly
     as `--ws-split`'s arithmetic did. At two groups the two spellings are the
     same number to the pixel, which is why this could land before `SLOTS`
     widened. At ONE group they are `0px`/`0px`, which is the unsplit base
     state, so the rule needs no gate to be right unsplit.

     CSS cannot read an attribute's value as a number, so the alternative to an
     inline property is one generated rule per slot - N**2 once the paddings
     are included. The value is per-element and the server owns it. */
  left: var(--a-l, var(--slot-l, 0px));
  right: var(--a-r, var(--slot-r, 0px));
  display: flex; align-items: stretch; gap: .3rem;
  padding-left: var(--slot-pl, var(--ws-fold-l));
  padding-right: var(--slot-pr, var(--ws-fold-r));
  pointer-events: auto;
  background: var(--panel); border-bottom: 1px solid var(--line);
}
.ws-tabgroup[hidden] { display: none; }

/* **THE SEAM IS THE GROUPS' OWN GEOMETRY NOW, and there is no gate left.**
 *
 * This strip deliberately does not key on `data-split`, and that reason is
 * unchanged: `<body>` carries no such attribute from the server (`syncSlots`
 * is its only writer, inside the first `loadActive`), so a strip keyed on it
 * would spend every load with a stored split - and the WHOLE of a
 * JavaScript-off session - with every group at `left: 0; right: 0`, the later
 * ones opaque and lying over the earlier ones. A flash of doubled tab labels
 * on every load, and permanently for a reader with scripting off.
 *
 * What changed is where the server's answer lives. `data-groups` was it, and
 * it was a COUNT that had to be 2 for anything to be positioned: it asked
 * *does slot 1 hold a tab*, which is not the same question as *how many
 * panels are there* the moment there can be three. Each group now carries its
 * own `--slot-l` / `--slot-r`, computed in `_tabbar.html` from the same
 * occupancy census that decides its `hidden`, so there is still exactly one
 * producer and it is still right before any script runs. `data-groups`
 * survives as that census's count, read by nothing in this sheet.
 *
 * `--ws-sash-w` is inherited from `.pane`, so the gap the server leaves at
 * each seam is the divider's own width. `--ws-split` is no longer read here at
 * all, which retires the last unconditional reader of a single scalar that can
 * place exactly one seam. */
/* `0 1 auto`, NOT `1 1 auto` - shrink when the tabs overflow, but never GROW.
 *
 * Jon, 2026-08-06: *"the 'plus' sign for adding a tab is not placed in an
 * intuitive area, it should be directly adjacent right to the current tab, or
 * last tab on the right."* It was not adjacent to anything: `1 1 auto` made
 * this list fill the whole strip whatever it contained - measured 868.86px at
 * one, two, three, four AND five tabs, while one tab's content is 159.91px -
 * so the "+" was parked at the strip's far right and the gap to the last tab
 * was **713.75px at one tab and 505.23px at two**.
 *
 * It looked correct at five tabs (4.80px) only because the list SATURATES
 * there, which is exactly the trap: the one configuration anybody building it
 * would have eyeballed is the one where the bug is invisible. */
.tabs { flex: 0 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; }
.tabs::-webkit-scrollbar { height: 4px; }

.tabs {
  display: flex; align-items: stretch; gap: 2px;
  list-style: none; margin: 0; padding: 0; height: 100%;
}

.ws-tab {
  display: flex; align-items: center; max-width: 15rem; min-width: 0;
  border: 1px solid var(--line); border-bottom: none;
  border-radius: 5px 5px 0 0; background: transparent;
  padding: 0 .15rem 0 .55rem; margin-top: .3rem;
}
/* **`.shown` carries the "this tab is the one you can see" look, not
 * `.active`.** They are the same tab in an unsplit workspace, so nothing
 * changes there; in a split BOTH halves' current tabs must look current,
 * because both of them are. `.active` — the tab with the reader, one in the
 * document — then adds a cue on top, and only when there is a second half to
 * distinguish it from: `body[data-split="v"]`, so an unsplit strip is
 * pixel-identical to before rather than gaining a mark that says nothing. */
.ws-tab.shown { background: var(--pane); border-color: var(--line); }
.ws-tab.shown .ws-tab-label { color: var(--ink); font-weight: 600; }
body[data-split="v"] .ws-tab.active { border-color: var(--accent); }
body[data-split="v"] .ws-tab.active .ws-tab-label { color: var(--accent); }
/* **Where the halves meet in the strip used to be a .8rem margin between two
 * adjacent tabs, and item 3 replaced it with the real thing.** That rule read
 * `.ws-tab[data-slot="0"] + .ws-tab[data-slot="1"]` and could only ever be a
 * hint, because the tabs were one row over one pane host. The halves are now
 * two positioned groups on the `--ws-split` seam, so the gap between them IS
 * the divider - with the sash's own line showing through it. Nothing is left
 * to nudge: an adjacency selector across the two groups matches nothing, so
 * the rule was deleted rather than kept as a no-op somebody would later have
 * to explain. */

/* The "move this tab to the other half" control. Sized and coloured as the
 * close button's sibling because it is one; rendered only when the move is
 * legal (`_tabs`' `can_split`), so it is never a button that answers 409. */

.ws-tab-label {
  color: var(--dim); font-size: .82rem; padding: .25rem .1rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  min-width: 0; cursor: pointer;
  /* **`-webkit-touch-callout: none`, and it is the tab context menu's missing
   * half on iOS.** This is a real `<a href>` on purpose — the tab's copyable
   * link, working with JavaScript off — and iOS Safari answers a long press on
   * one with its own callout/preview sheet. `draggable="false"` does not
   * suppress that (it suppresses the DESKTOP link drag, which is what it was
   * added for), and `preventDefault()` on `pointerdown` is forbidden in this
   * strip because it would kill click-to-activate, dblclick-to-rename and
   * middle-click-close at once. This property is the narrow instrument, and it
   * is the ONLY one: `grep -n touch-callout static/app.css` is this line.
   *
   * **Not measured on a device** — nothing here has an iOS device — so this is
   * the absence of the only property that suppresses the sheet being closed,
   * not a claim about what Safari then does. It costs nothing anywhere else:
   * every other platform ignores it. */
  -webkit-touch-callout: none;
}
.ws-tab-label:hover { text-decoration: none; }
.ws-tab.renamed .ws-tab-label { font-style: italic; }

.ws-tab-close {
  border: none; background: transparent; color: var(--dim);
  font-size: 1rem; line-height: 1; cursor: pointer;
  padding: .1rem .3rem; border-radius: 3px;
}
.ws-tab-close:hover { background: var(--accent-soft); color: var(--accent); }

/* ---- "UPDATING X" — which tab is mid-change (Jon, 2026-08-30)
 *
 * > *"tabs on which settings are being implemented (queued or loading or
 * > whatever) should be shown to be in a intermediate state, like a symbol and
 * > a message (updating X, with X being the setting that is changing). And it
 * > could reflect the same status of loading that we already have in the
 * > progress bar."*
 *
 * **THE SYMBOL IS THE VISIBLE HALF AND THE MESSAGE IS THE ACCESSIBLE ONE, and
 * that is a width decision rather than a preference.** `.ws-tab` is capped at
 * 15rem and `.ws-tab-label` is `nowrap` + `ellipsis`, so twenty-one characters
 * of "updating reading mode" inside a tab do not appear beside the book's name
 * — they eat it. The words stay in the element (which is what makes the
 * `role="status"` live region fire, and what a screen reader reads) and are
 * clipped out of the layout; `title` puts them under a pointer. The progress
 * bar already says *something is loading*; what this adds is WHICH TAB.
 *
 * **`[hidden]` FIRST AND EXPLICIT (L-024).** `display: inline-block` below is
 * an author declaration and beats the user agent's `[hidden] { display: none }`
 * regardless of specificity — the mechanism that had `.ws-settings-row`
 * painting at 71px while reporting itself hidden, and `.ws-undo-btn`'s standing
 * rule. Without this line every tab in the strip would wear a dot for ever. */
.ws-tab-busy[hidden] { display: none; }
.ws-tab-busy {
  display: inline-block; flex: 0 0 auto;
  width: .5rem; height: .5rem; margin: 0 .1rem 0 .3rem;
  border-radius: 50%; background: var(--accent);
  /* The words are present for the live region and for `title`, and out of the
   * layout. Not `font-size: 0` — that would collapse the box the dot IS. */
  overflow: hidden; text-indent: -999em; white-space: nowrap;
  animation: ws-tab-busy-pulse 1.1s ease-in-out infinite;
}
/* A dot that pulses for the length of a pane refill is motion in the reader's
 * peripheral vision on every settings press. Honour the system setting: the
 * marker still says which tab, it just stops moving. */
@media (prefers-reduced-motion: reduce) {
  .ws-tab-busy { animation: none; opacity: .9; }
}
@keyframes ws-tab-busy-pulse {
  0%, 100% { opacity: .28; }
  50%      { opacity: 1; }
}
/* The tab itself says so too, so the cue survives a reader who cannot see a
 * 8px dot. Deliberately NOT a background change: `.ws-tab.shown` already owns
 * the background and a second writer of it is how two states come to disagree
 * about which tab looks current. */
.ws-tab[data-applying] .ws-tab-label { font-style: italic; opacity: .75; }

/* The drag ghost: a floating copy of the tab that follows the pointer while a
 * drag is armed, so a picked-up tab reads as picked up (JupyterLab's cue). It
 * carries the source tab's label text, written by `tabDragSetup` in app.js, and
 * is hidden until the 6px threshold arms the drag.
 *
 * **`pointer-events: none` is LOAD-BEARING.** `zoneAt()` in app.js finds the
 * drop target with `document.elementFromPoint` at the pointer - the pointer is
 * captured by the tab for the whole drag, so the zones receive no pointer
 * events and only a hit test can find them. A ghost sitting under the pointer
 * WITHOUT `pointer-events: none` becomes that hit, `zoneAt` returns `null`, and
 * the drop silently stops working with nothing in the console.
 *
 * It reads as the tab it was lifted from: every token below is one `.ws-tab`
 * or `.ws-tab-label` already states - `font-size`, `color`, `border`,
 * `border-radius`, `background` - nothing is invented here. No `display` is
 * declared, so the global `[hidden] { display: none !important }` rule near the
 * top of this sheet is what hides it; a per-element `[hidden]` companion is not
 * added, per that rule's own comment. */
.ws-tab-ghost {
  position: fixed; top: 0; left: 0;
  pointer-events: none;
  z-index: 60;
  font-size: .82rem; color: var(--ink);
  border: 1px solid var(--line); border-bottom: none;
  border-radius: 5px 5px 0 0; background: var(--pane);
  padding: .25rem .15rem .25rem .55rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* A slight lift so it reads as picked up: the menu's shadow and a touch of
   * translucency, mirroring `.ws-tabctx`'s own `box-shadow`. */
  box-shadow: 0 6px 24px rgba(0,0,0,.28);
  opacity: .9;
}
/* **THE SAME CHIP, WEARING AN ATTRIBUTE.** A book dragged toward a shelf is
   not a tab torn off a strip, so it is a rectangle rather than a tab silhouette
   - but it is the SAME element, because there is one floating chip in this app
   and one producer of where it is. A second element would be a second thing to
   remember to hide, and `teardown` would have to know about both (L-076). */
.ws-tab-ghost[data-kind="book"] {
  border-radius: 4px; border-bottom: 1px solid var(--line);
  padding: .25rem .55rem;
}

/* **`user-select` SCOPED TO THE PANEL BODY, never to `body`.** Every
   `user-select: none` in this sheet is scoped this narrowly and says why: on
   `body` it would take the reading text with it, and a reader who cannot select
   a verse while a drag is live is a worse bug than a bit of highlighted title.
   The three `user-select` rules already here are `body.ws-dragging-tab`, the
   tab context menu and `.ws-tabctx-choice`; this is the fourth and it follows
   the same rule. */
body.ws-dragging-book { cursor: grabbing; }
body.ws-dragging-book #panel-library .panel-body {
  -webkit-user-select: none; user-select: none;
}
/* The row the reader lifted, so the chip under the pointer is not a duplicate
   they have to tell apart from the original. */
.shelf-item.ws-book-dragging { opacity: .5; }
/* **`outline`, not `border`: it paints OUTSIDE the box and shifts nothing.**
   A border on a `<details>` here would move every book under it by 2px the
   moment the pointer crossed the group, which reads as the list jumping. The
   negative offset draws it inside the group's own edge so it is not clipped by
   the panel. */
.shelfgrp.ws-shelf-drop-over {
  outline: 2px solid var(--accent); outline-offset: -2px;
  border-radius: 4px;
}
/* A group this book cannot go on - the loose group, a shelf whose rule will not
   run, or one that already holds it. Greyed rather than hidden: a target that
   disappears while the reader is aiming at it is worse than one that says no. */
.shelfgrp.ws-shelf-drop-off { opacity: .55; }

.notabs { align-self: center; color: var(--dim); font-size: .8rem; }

/* The "+" that opens a new tab. Jon, 2026-08-05 — ctrl-click already did this
 * and nothing on the screen said so.
 *
 * `flex: 0 0 auto` for the same reason the Diagnostics link carries it: a
 * topbar item that can be squeezed is one that pushes its neighbour off the
 * screen the first time the bar is tight, and this one must survive a strip of
 * forty tabs, which is precisely when a reader wants another. */
.ws-newtab-form { display: flex; align-self: stretch; flex: 0 0 auto; margin: 0; }
/* Shaped as the NEXT TAB IN THE ROW, not as a button parked beside it - Jon
 * asked for "a square that people will see as a way to add a tab".
 *
 * So it takes the tab's own geometry verbatim: `margin-top: .3rem` and
 * `border-bottom: none` bottom-anchor it to the topbar edge, and the top
 * corners round by 5px like a tab rather than 4px all round like a pill. It
 * measured 28.80 x 23.58 - 22.1% wider than tall, floating 8.52px above the
 * tab row and 12.22px shorter than it. Now 35.80 x 35.80, top and bottom
 * byte-identical to a real tab.
 *
 * `aspect-ratio: 1` with `padding: 0` is what makes it square at whatever
 * height the row happens to be, instead of a hard-coded size that stops being
 * square the moment the topbar's font changes. */
.ws-newtab {
  font: inherit; font-size: 1.05rem; line-height: 1; cursor: pointer;
  background: transparent; color: var(--dim);
  border: 1px solid var(--line); border-bottom: none;
  border-radius: 5px 5px 0 0;
  margin-top: .3rem; padding: 0; min-width: 0; aspect-ratio: 1;
}
.ws-newtab:hover { color: var(--accent); border-color: var(--accent); }
.ws-newtab:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* The phone's tab button. Hidden on the desktop, where the strip itself works;
 * the `@media (max-width: 34rem)` block below swaps which one is showing.
 * `display`, never the `hidden` attribute - `[hidden]` is a user-agent rule and
 * loses to any author `display` (L-024). */
/* `pointer-events: auto` here and on the dropdown, because `.tabstrip` is
 * `none` — see its rule. These two and `.ws-tabgroup` are the strip's only
 * direct children, so between them every interactive thing in the strip is
 * covered, and the 9px seam gap that has to pass a sash drag through is not. */
.ws-tabmenu { display: none; pointer-events: auto; }
/* Drawn since 2026-08-16, and this was the last typed glyph in the bar — `▤`
 * shipped on 2026-08-06 and three shifts of topbar work cited it as the known
 * font-stack risk while answering it everywhere else. See `_tabbar.html` for
 * the argument; the shape is `▤`'s own on purpose.
 *
 * **A BASE rule, beside its own button's, not in the phone block where its
 * `color` used to live.** `.ws-tabmenu` is `display: none` up there, so the
 * icon cannot paint outside a phone and no media query is needed to say so —
 * and a size declared inside `@media` with the base rule further down the sheet
 * is the exact shape of the three dead declarations this file shipped in
 * `cea2404` (L-085). One element, one rule, first. */
.ws-tabmenu-icon {
  display: block; flex: 0 0 auto;
  width: 1.02rem; height: 1.02rem; color: var(--dim);
}
/* On the desktop the wrapper is not a box at all, so `.tabstrip`'s flex row
 * sees the same two children it always did and the "+" geometry is untouched.
 * The phone's media query turns it into the dropdown. */
.ws-tabdrop { display: contents; pointer-events: auto; }


/* -------------------------------------------------------------- workspace */

.workspace {
  display: flex; align-items: stretch;
  /* **`--ws-swept-h` IS `0px` IN EVERY ORDINARY RUN AND THAT IS WHY IT IS
   * SAFE HERE.** The sweep notice exists only while a `slot_sweep` row is
   * outstanding, which at today's two slots cannot happen at all - nothing can
   * be out of vocabulary - so every band measurement in `verify_shell` reads
   * this as `calc(var(--topbar-real) + 0px)` and is byte-identical to what it
   * was. `SLOT_LABELS`' own argument one file over, and for the same reason:
   * the machinery lands before the widening and is judged by the board rather
   * than by reading.
   *
   * **It is MADE ROOM FOR rather than overlaid**, which is the whole reason
   * this length exists. `.ws-progress` is fixed and covers 2px of nothing;
   * this notice is a paragraph about the reader's own books and would sit on
   * top of the first line of whatever he is reading, permanently, because the
   * condition it reports persists until he acts on it.
   *
   * The `0px` fallback is the JavaScript-off path and it is the right failure
   * direction: the notice overlays rather than vanishing. `syncSwept` makes it
   * exact, and keeps it exact when the text wraps at a narrow width. */
  height: 100%;
  padding-top: calc(var(--topbar-real) + var(--ws-swept-h, 0px));
}

/* --------------------------------------- A sweep that moved his tabs, v34
 *
 * `s-2c50c5a29e2b7101`. See `templates/shell.html` for why this is a SEPARATE
 * surface from `#ws-undo-btn` and `app._swept` for what it must carry.
 *
 * **`z-index: 29` - under `.topbar`'s 30 and over the panes' 20-22.** The bar
 * must stay reachable above it (it holds the workspace switcher, which is one
 * of the two ways the panel this notice is about can come back), and the
 * notice must not be painted over by a panel that thinks it owns the top of
 * the workspace.
 *
 * **`--accent-soft` is this app's existing notice tone** and is defined in
 * every theme, so this needs no colour of its own and cannot be the one
 * element that is unreadable in dark. */
.ws-swept {
  position: fixed; left: 0; right: 0; top: var(--topbar-real);
  z-index: 29;
  background: var(--accent-soft); color: var(--ink);
  border-bottom: 1px solid var(--line);
  padding: .4rem .8rem;
  font-size: .78rem; line-height: 1.45;
}
/* Wraps rather than truncating: every part of the sentence is load-bearing -
 * what moved, where back to, and when - so there is nothing here that may be
 * cut off to keep it on one line. */
.ws-swept-row {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: .3rem .7rem;
}
.ws-swept-row + .ws-swept-row {
  margin-top: .35rem; padding-top: .35rem; border-top: 1px solid var(--line);
}
.ws-swept-text { margin: 0; flex: 1 1 20rem; }
.ws-swept-why { margin: 0; color: var(--ink-sub); font-style: italic; }
.ws-swept-form { margin: 0; }
.ws-swept-undo {
  font: inherit; cursor: pointer; white-space: nowrap;
  padding: .15rem .6rem;
  color: var(--ink); background: var(--bg);
  border: 1px solid var(--line-ctl); border-radius: 3px;
}
.ws-swept-undo:hover { border-color: var(--accent); color: var(--accent); }

/* **THE SCROLLER MOVED FROM `.panel` TO `.panel-body`, 2026-08-16**, and the
 * reason is Jon's book indicator: *"a little indicator of which book is there
 * (for bibles) because otherwise its just a list of chapters sometimes with
 * nothing else visible."*
 *
 * The answer is a sticky section header on the open book's row (below). A sticky
 * box positions against its SCROLLPORT, so with `.panel` scrolling there are two
 * things wanting `top: 0` in one port — `.panel-head`, which is opaque and
 * ~77.6px tall, and the book row. Give the row the lower `z-index` and it pins
 * out of sight behind the head; give it the higher one and it covers the head's
 * own `Expand: top 1 2 3` buttons. The third option is a `top: <head height>`
 * offset, and there is no `--panel-head-h`: the head is CONTENT-sized (a title
 * that may wrap, a counts line, a button row), so that number would have to be
 * measured and republished, with its own trigger set to keep correct.
 *
 * Taking the head out of the scroller removes the problem instead of paying for
 * it. `.panel-head` was `position: sticky; top: 0` only to FAKE being outside
 * the scroll; now it is outside it, and a sticky row inside the body pins at
 * `top: 0` with no number anywhere. The head's expand buttons also stop being
 * scrollable away, which is a small independent win.
 *
 * **`min-height: 0` on the body is load-bearing and is the whole trap.** A flex
 * item's automatic minimum size is its content, so without it `.panel-body`
 * grows to the full height of a 935-row level, `.panel`'s `overflow: hidden`
 * clips it, and the list becomes unscrollable rather than scrolling.
 *
 * One reader had to move with it: `static/app.js`'s "is the current node in
 * view" gate took `outlineBody.closest(".panel")` as the scrollport. It is the
 * ONLY place in the app that treated `.panel` as a scroller (grepped), and it
 * now asks the body it already holds. */
/* `position: relative` is the grip's containing block and nothing else's:
 * swept the sheet for every `position: absolute|fixed` rule and not one of
 * them matches a descendant of a panel (`.popover` and `.ws-settings` are
 * siblings of `.workspace`, not children of an `<aside>`). It creates no
 * stacking context at `z-index: auto`, so the overlay rule's `z-index: 20`
 * below 55rem is untouched. */
/* **THE LEFT ICON STRIP - decision 219, `menu-vscode-strip`.** The first flex
 * child of `.workspace`, so the panel and the pane keep their own sizing and the
 * strip simply takes its width off the left. `--ws-strip-w` is the one length
 * (Jon's option text priced it at "about 40 pixels of width"). Seam 1: hidden on a
 * phone and in full-screen reading; the phone's one fold button is seam 2. */
:root { --ws-strip-w: 2.5rem; }
.ws-strip {
  flex: 0 0 var(--ws-strip-w); width: var(--ws-strip-w);
  display: flex; flex-direction: column; align-items: center;
  gap: .2rem; padding: .45rem 0;
  background: var(--panel); border-right: 1px solid var(--line);
}
.ws-strip-item {
  position: relative; display: grid; place-items: center;
  width: 2rem; height: 2rem; border-radius: .35rem;
  color: var(--dim); text-decoration: none;
}
.ws-strip-item:hover, .ws-strip-item:focus-visible {
  color: var(--accent); background: var(--accent-soft);
}
.ws-strip-ico { width: 1.15rem; height: 1.15rem; }
.ws-strip-count {
  position: absolute; top: -.1rem; right: -.2rem;
  min-width: 1rem; height: 1rem; padding: 0 .22rem; border-radius: .5rem;
  font-size: .62rem; line-height: 1rem; text-align: center;
  background: var(--accent); color: var(--pane);
}
body[data-fullscreen] .ws-strip { display: none; }
/* **`--ws-strip-w` IS THE STRIP'S WIDTH WHEREVER IT TAKES ONE, AND 0 WHERE IT
 * DOES NOT** - full-screen reading here, the phone below. The floating library
 * panel starts at it, so the one length decides both and they cannot disagree. */
body[data-fullscreen] { --ws-strip-w: 0px; }
/* **SEAM 2: THE FOOT - the `?` and the gear, pushed to the bottom like VS Code's.**
 * `margin-top: auto` on the foot is the whole of the push: the strip is a flex
 * column the full height of `.workspace`, so the foot takes the free space above
 * itself and the primary items keep their own top-down order. */
.ws-strip-main, .ws-strip-foot {
  display: flex; flex-direction: column; align-items: center; gap: .2rem;
}
.ws-strip-foot { margin-top: auto; }
/* The `?`'s popover opens to the RIGHT of the strip, bottom-aligned with its
 * button, so it grows up from the foot rather than off the bottom of the screen. */
.ws-help { position: relative; }
.ws-help-btn { cursor: pointer; list-style: none; }
.ws-help-btn::-webkit-details-marker { display: none; }
.ws-help-btn::marker { content: ""; }
.ws-help[open] > .ws-help-btn { color: var(--ink); background: var(--accent-soft); }
/* **ON A PHONE THE STRIP FOLDS INTO ONE BUTTON - Jon's option text.** It is not
 * `display: none`, because the gear lives inside it and a phone reader needs
 * Settings in one press, where it always was: the strip collapses to zero width,
 * its items hide, and the gear is pinned at the bar's right edge, the slot it had
 * before decision 219. `.topbar-main`'s phone padding reserves that slot so the
 * search box never runs under it. The fold (`#ws-strip-fold`, in the bar) opens
 * the strip as a column over the page: `body[data-strip-open]` when `app.js`
 * runs, `:target` when it does not. `z-index: 32` clears the bar (30) and stays
 * under every popover (`.ws-update` 35 and up). */
@media (max-width: 34rem) {
  :root { --ws-strip-w: 0px; }
  .ws-strip { padding: 0; border-right: 0; }
  .ws-strip-main, .ws-help { display: none; }
  .ws-strip .ws-settings-btn {
    position: fixed; z-index: 32; right: .3rem;
    top: calc((var(--topbar-h) - 2.4rem) / 2);
    width: auto; height: auto; min-width: 2.7rem; min-height: 2.4rem;
    border: 1px solid var(--dim); border-radius: 4px;
    color: var(--ink); background: var(--panel);
  }
  /* The width is the worded menu's since `s-striplabels` (below); it was `auto`
   * while the strip was icons alone. */
  body[data-strip-open] .ws-strip, .ws-strip:target {
    position: fixed; z-index: 32; left: 0; bottom: 0;
    top: var(--topbar-real, var(--topbar-h));
    width: min(17rem, calc(100vw - 3rem)); padding: .45rem .35rem;
    align-items: stretch; overflow-y: auto;
    border-right: 1px solid var(--line); box-shadow: 6px 0 20px rgba(0,0,0,.25);
  }
  body[data-strip-open] .ws-help, .ws-strip:target .ws-help { display: block; }
}
/* **THE WORD BESIDE EVERY SYMBOL - Jon, 2026-10-16 (`s-striplabels`):** *"all it shows is
 * a strip on the left side with symbols I don't know what they mean ... I'm looking for the
 * about page I used to select on the menu that used to show up."* The open phone strip is a
 * MENU of rows, icon then word, in decision 219's words and order; Help, About and
 * Diagnostics are rows of their own (the fold opens the `?`, `app.js` `foldStrip`), so About
 * is one tap after the fold; the gear comes off the bar into the list as "Settings" while it
 * is open and goes back when it shuts. **Nowhere else:** `.ws-strip-label` is `display: none`
 * at every other width and state, so the desktop strip is the 40 px his option priced, unchanged.
 * Rows are 2.75rem (44 px), the touch target. With scripting off the `?` stays a row of its
 * own, worded, and opens by its own tap - the `<details>` the hamburger was. */
.ws-strip-label { display: none; }
@media (max-width: 34rem) {
  body[data-strip-open] .ws-strip-main, .ws-strip:target .ws-strip-main,
  body[data-strip-open] .ws-strip-foot, .ws-strip:target .ws-strip-foot {
    display: flex; flex-direction: column; align-items: stretch; gap: 0;
  }
  body[data-strip-open] .ws-strip-foot, .ws-strip:target .ws-strip-foot {
    margin-top: .35rem; padding-top: .35rem; border-top: 1px solid var(--line);
  }
  body[data-strip-open] .ws-strip-item, .ws-strip:target .ws-strip-item,
  body[data-strip-open] .ws-strip .ws-settings-btn, .ws-strip:target .ws-settings-btn {
    position: static; display: flex; align-items: center; gap: .75rem;
    width: auto; height: auto; min-width: 0; min-height: 2.75rem; padding: 0 .7rem;
    border: 0; border-radius: .35rem; background: transparent; color: var(--ink);
  }
  body[data-strip-open] .ws-strip-ico, .ws-strip:target .ws-strip-ico,
  body[data-strip-open] .ws-strip .ws-settings-icon, .ws-strip:target .ws-settings-icon {
    flex: 0 0 1.15rem; width: 1.15rem; height: 1.15rem; color: var(--dim);
  }
  body[data-strip-open] .ws-strip-label, .ws-strip:target .ws-strip-label {
    display: block; font-size: 1rem; line-height: 1.25; text-align: left;
  }
  .ws-strip-sub { display: block; font-size: .78rem; color: var(--dim); }
  body[data-strip-open] .ws-strip-count, .ws-strip:target .ws-strip-count {
    position: static; margin-left: auto;
  }
  body[data-strip-open] .ws-help[open] > .ws-help-btn { display: none; }
  /* `relative`, not `static`, on purpose: `verify_switcher`'s control hangs this list
   * off its own edge (`right: 100%`), and only a positioned box can be pushed. */
  body[data-strip-open] .ws-help-list, .ws-strip:target .ws-help-list {
    position: relative; left: 0; bottom: auto; z-index: auto;
    min-width: 0; max-width: none; max-height: none; overflow: visible;
    padding: 0; border: 0; box-shadow: none; background: transparent;
  }
  body[data-strip-open] .ws-help-list .dx-link, .ws-strip:target .ws-help-list .dx-link {
    display: flex; align-items: center; min-height: 2.75rem;
    padding: 0 .7rem 0 calc(.7rem + 1.15rem + .75rem); font-size: 1rem;
  }
}

.panel {
  position: relative;
  width: var(--panel-w); flex: 0 0 var(--panel-w);
  background: var(--panel); overflow: hidden;
  display: flex; flex-direction: column;
}

/* **UNPINNED, THE LIBRARY IS AN OVERLAY AT EVERY WIDTH — Jon,
 * `library-panel-overlay-when-wide` -> `you-choose-with-the-pin`, 2026-09-07:**
 * *"normally just slide it open and it slides back when I've selected or close
 * it (not if I'm searching) but if I pin it, then we'll get the shift"*.
 *
 * **The declarations are the ones the narrow block below already used**, lifted
 * here so the regime is decided by the PIN rather than by the window. Below
 * 55rem that block still applies unconditionally, so a phone is an overlay
 * whether or not the pin is on — deliberate, and stated in `libraryPinFits`:
 * a 20rem panel on a 360px screen is a modal, so there the pin governs only the
 * self-closing. This rule and that one agree wherever both apply, so the
 * cascade has nothing to settle.
 *
 * **`body:not([data-libpin])` — the UNPINNED case needs no attribute to
 * exist**, which is what makes a first-ever visit correct: nothing has been
 * stored, `libraryPinned()` is false, no attribute is written, and the reader
 * gets the behaviour Jon calls "normally".
 *
 * **`--topbar-real`, never `--topbar-h`** (§11): one is measured and the other
 * is what the bar sizes itself by, and a rule fed its own input is how the page
 * came to paint over 86px of text.
 *
 * **`z-index: 20`, under the fold button's 22, and that is load-bearing rather
 * than incidental** — *"an overlay panel must not cover the control that closes
 * it"*. It is also, at last, Jon's original sentence from 2026-09-01: with the
 * panel floating at `left: 0` and the button at `#pane`'s own left edge, the
 * Library button **sits on top of the library**. That clause was called
 * impossible above 55rem while the panel was in flow, and it is — the pin is
 * what takes it out of flow.
 *
 * Longhand edges, never the `inset` shorthand: a shorthand here would override
 * the inset that decides where the chrome stack ends (`.ws-drop`'s precedent,
 * and `.ws-pview`'s recorded trap). */
@media (min-width: 55.0625rem) {
  body:not([data-libpin]) .panel.left {
    /* `--ws-strip-w`, not 0, since decision 219: the left icon strip owns the
     * first column, and a panel floating at 0 painted over its `?` and gear
     * (found by driving it, 2026-09-26). It is 0px wherever the strip is. */
    position: fixed; left: var(--ws-strip-w);
    /* **THE WHOLE CHROME STACK, NOT `--ws-tabs-h` ALONE — and the first draft
     * of this rule got it wrong in a way the board caught in 86 checks.**
     *
     * It read `calc(var(--topbar-real) + var(--ws-tabs-h))`, copied from the
     * narrow block, where it is right because that block's regime is different.
     * Above 55rem it puts the panel's top at **85.19px** — which is exactly
     * where `.ws-tabbar` starts. Measured at 1512x900: the per-tab menu row is
     * y 85.19–117.19 at `z-index: 2`, the panel is z 20, and
     * `elementFromPoint` at the centre of the **Formatting** button answered
     * `panel-head-word`. `verify_tabmenu` died on
     * `Locator.click: Timeout 30000ms` against an element it had just been told
     * was *"visible, enabled and stable"* — the signature of an interception,
     * not of a missing control.
     *
     * **An overlay may cover the BOOK. It may never cover a CONTROL.** That is
     * the rule, and the chain below is the one producer of where the book
     * starts — the identical `calc` `.ws-pview` uses, minus `--a-t`, which is a
     * per-SLOT inset written by `syncAreas` onto host children and means
     * nothing for a panel that is `.workspace`'s own child. Every term is a
     * `:root` length that already goes to zero in the regimes where its row is
     * not painted (`--ws-tabbar-h: 0px` in full-screen reading), so this
     * follows those modes with no second rule to keep in step (L-017, L-076).
     *
     * **Scoped `min-width: 55.0625rem` — the complement of the block below**,
     * spelled the way this sheet already spells one (`34.0625rem`, line 805).
     * Below the breakpoint the panel is an overlay whatever the pin says and
     * that regime is untouched: it was green and this change has no business
     * moving it. */
    top: calc(var(--topbar-real) + var(--ws-tabs-h) + var(--ws-tabbar-h)
              + var(--ws-tabtool-h) + var(--ws-where-h) + var(--ws-stale-h));
    bottom: 0; z-index: var(--panel-overlay-z);
    box-shadow: 0 0 20px rgba(0,0,0,.25);
  }
}

/* **THE DRAG HANDLE — Jon, 2026-08-16: *"I dont see why it is not expandable
 * if i drag it out more? That should be the standard for these things."***
 *
 * Modelled on `.ws-sash` down to the numbers, because it is the same gesture on
 * a different seam: a 9px hit box (a 1px divider is unhittable with a finger),
 * a 1px pseudo-element for the line, `touch-action: none` HERE AND NOWHERE ELSE
 * so the panel still scrolls with a finger, and `cursor: col-resize`.
 *
 * **No `width` and no `flex-basis` here.** The stored width is applied as
 * `--panel-w` on the panel ELEMENT, so there is exactly one reader of the
 * number (`.panel`'s own `width`/`flex`) and nothing new can disagree with it
 * (L-017). It also means the phone rule below, which overrides `width`
 * outright, keeps winning with no branch anywhere: a width dragged on the
 * laptop is simply not consulted on a phone. */
.panel-grip {
  position: absolute; top: 0; bottom: 0; z-index: 3;
  width: var(--panel-grip-w, 9px);
  cursor: col-resize; touch-action: none;
  background: transparent; border: none; padding: 0;
}
.panel.left > .panel-grip { right: 0; }
/* `.panel.right > .panel-grip { left: 0 }` was here. There is no right-hand
 * panel since 2026-08-29; the contents is drawn per half inside `#pane` and has
 * no grip, because the drag existed to widen a 320px panel past two columns and
 * a half is already wider than any drag reached. */
.panel-grip::before {
  content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
  width: 1px; margin-left: -.5px; background: var(--line);
}
.panel-grip:hover::before, .panel-grip:focus-visible::before,
.panel-grip.panel-grip-dragging::before {
  background: var(--accent); width: 3px; margin-left: -1.5px;
}
.panel-grip:focus-visible { outline: 2px solid var(--accent);
                            outline-offset: -2px; }
.panel.left { border-right: 1px solid var(--line); }
.panel.folded { display: none; }

/* No `position: sticky` any more — the head is outside the scroller, so it
 * stays put by being a non-scrolling flex item. `background` stays: it is the
 * band's own colour, not a sticky-header necessity. */
.panel-head {
  padding: .5rem .6rem; border-bottom: 1px solid var(--line);
  background: var(--panel);
}
/* **THE LIBRARY'S OWN BAR — Jon's drawing, 2026-09-02**: *"a menu sized bar
 * with the label (Library) and arrows that can shut it, and "pin" that keeps it
 * open"*.
 *
 * **A FIXED height, never `min-height` (R3, for the sixth time in this file).**
 * *Menu sized* means the per-tab menu bar's own 2rem (2.4rem on the phone) -
 * NOT the 45px Jon estimated off a screenshot, because his screenshots measure
 * available space and do not size controls (his own correction). **It is
 * `--ws-libhead-h`, its own length, since 2026-10-02 - and NOT `--ws-tabbar-h`,
 * which this rule read until then**: that one is `0px` on every tab with no
 * menu bar, and the head collapsed to nothing over a Decisions tab on his
 * phone (the reason is written beside the length on `:root`).
 *
 * **The vertical padding is REPLACED, not added to.** `* { box-sizing:
 * border-box }` means `.panel-head`'s `.5rem` would be absorbed into the 2rem
 * rather than added to it - so the row would still be 2rem tall and the
 * controls inside it would have 1rem of it to live in. Zeroing it here is what
 * gives the button its 1.5rem back. */
#library-head {
  display: flex; align-items: center; gap: .35rem;
  height: var(--ws-libhead-h); padding: 0 .35rem 0 .6rem;
}
.panel-head-word {
  margin: 0; flex: 1; min-width: 0;
  font-size: .8rem; font-weight: 600; letter-spacing: .02em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* The two controls. `.fold`'s paint, deliberately restated rather than shared:
 * `.fold` is `#pane` furniture with an absolute position and a fixed
 * `--ws-tabs-h` height, and inheriting that here would fight this row. */
.panel-head-btn {
  flex: 0 0 auto; display: inline-flex; align-items: center;
  justify-content: center; gap: .25rem;
  height: 1.5rem; padding: 0 .4rem;
  font: inherit; font-size: .72rem; line-height: 1;
  color: var(--dim); background: none;
  border: 1px solid transparent; border-radius: .25rem;
  cursor: pointer;
}
/* **`[hidden]` IS A USER-AGENT RULE AND `display: inline-flex` ABOVE BEATS IT
 * REGARDLESS OF SPECIFICITY (L-024).** Without this line the Pin paints at its
 * full size on every laptop, with `el.hidden` reporting `true` the whole time -
 * which is exactly how `.ws-settings-row` came to be 71px tall while calling
 * itself hidden, and how the Contents columns nearly shipped permanently
 * expanded. Third instance in this file; it is not a corner case, it is what
 * happens whenever an author `display` meets `hidden`. */
.panel-head-btn[hidden] { display: none; }
.panel-head-btn:hover { color: var(--ink); border-color: var(--dim); }
.panel-head-btn[aria-pressed="true"] {
  color: var(--ink); border-color: var(--dim); background: var(--pane);
}
.panel-head-shut { padding: 0 .2rem; }
.panel-head-shut svg { width: 1rem; height: 1rem; display: block; }

.panel-body { padding: .6rem; flex: 1; min-height: 0; overflow-y: auto; }
.panel-foot {
  padding: .5rem .6rem; border-top: 1px solid var(--line);
  color: var(--dim); font-size: .72rem; margin: 0;
}

/* The pane is a HOST: one `.ws-pview` child per open tab, each its own
 * scroller, so the engine keeps every tab's scroll position for free
 * (measured: Chromium, Firefox and WebKit all restore a hidden scroller's
 * scrollTop on re-show - research/tab-preload-and-docking-2026-08-07.md §3).
 * The host must NOT scroll: two nested scrollers would fight, and the host
 * clips instead. */
.pane {
  flex: 1; min-width: 0; overflow: hidden;
  background: var(--pane); position: relative;
}

/* Every pane view fills the host and scrolls itself. Absolute, because they
 * coexist: JupyterLab's dock does exactly this (flat absolutely-positioned
 * children, repositioned rather than reparented) and it is what keeps live
 * Ranges - the highlight machinery - valid across switches.
 *
 * An UNSHOWN pane is `content-visibility: hidden`, NOT `display: none`:
 * both preserve scroll (measured), but content-visibility keeps the rendering
 * state, so a switch back is a cheap un-skip instead of a full re-layout
 * (measured 62ms vs 80ms for ten panes; `visibility: hidden` costs 300ms and
 * is avoided on purpose). The element box itself still exists and would sit
 * ON TOP of a shown pane in paint order whenever it comes later in the
 * DOM - it paints nothing (background lives on the host, contents are
 * skipped), but it would still swallow every click and wheel, so
 * `pointer-events: none` is load-bearing, not hygiene.
 *
 * `.shown`, NOT `.active` - the two were one class until 2026-08-09 and the
 * one that means "has geometry" is this one. See `paneShown` in app.js. A
 * split puts two panes on screen and only one of them has the reader; keying
 * this rule on `.active` would hide the other one outright. */
/* **The top inset is the where-am-i bar's row, and it is stated ONCE.** The
 * two split rules below used to restate `inset` in full, so a top written only
 * here would have been silently overridden the moment a split existed - two
 * halves whose first line sat under their own bar, which reads as "the bar
 * doesn't work in splits" rather than as a shorthand mistake. They set the
 * seam side as a LONGHAND now (the `.ws-drop` precedent, which never had the
 * problem), so this declaration is the only place a pane's top is decided. */
/* **The top inset is now the tab strip's row PLUS the indicator's row**, in
 * that order down the screen, which is the order a browser puts them in and
 * the order Jon asked for: the tabs name the book, the bar says where in it
 * you are, the text is underneath. Item 3, 2026-08-16. */
/* **ONE RULE, TWO CONSUMERS IN ITS SELECTOR LIST** - the margin geometry's own
 * idiom, and here it is what makes the per-half contents safe. `.ws-tabcontents`
 * is drawn OVER the reading pane and must cover it exactly; sharing the
 * declaration means the two cannot drift, and a change to the chrome stack moves
 * both at once.
 *
 * **NOT a hoisted `--ws-chrome-h` custom property, and L-079 is the reason.**
 * `--ws-tabtool-h` is `0px` at `:root` and `2rem` on `body[data-tabtool]`. A
 * `calc()` declared at `:root` substitutes `:root`'s value at computed-value
 * time, so a hoisted property would freeze the annotations row at zero and both
 * of these would sit 2rem too high exactly when that row is open. Resolved on
 * the element, as here, each `var()` picks up the inherited value and is right
 * in both states. */
.ws-pview,
.ws-tabcontents {
  position: absolute;
  /* **FOUR LONGHANDS, NEVER `inset`** - and this is now load-bearing twice
   * over. The old reason stands (a seam rule setting one side as a shorthand
   * would wipe the top). The new one is L-079: `--a-t` and its three siblings
   * are declared ON THE ELEMENT by `syncAreas`, and an element-scoped `var()`
   * inside a shorthand is invalid at computed-value time - the whole
   * declaration would be dropped and every pane would collapse.
   *
   * `--a-l/-t/-r/-b` are this element's own AREA insets, in host pixels,
   * written by `static/app.js`'s `syncAreas` from `areaBox` - the one
   * producer. **The `, 0px` fallback IS the scripting-off path and the first
   * paint**, and it is free: absent properties resolve to zero, which is
   * exactly the unsplit full-host geometry this rule had before areas
   * existed. No branch, no second rule, and a session with no JavaScript is
   * byte-identical to what it always was. */
  top: calc(var(--a-t, 0px) + var(--ws-tabs-h) + var(--ws-tabbar-h)
            + var(--ws-tabtool-h) + var(--ws-where-h) + var(--ws-stale-h));
  right: var(--a-r, 0px);
  bottom: var(--a-b, 0px);
  left: var(--a-l, 0px);
}
.ws-pview { overflow-y: auto; }

/* THE CONTENTS, ONE PER HALF - Jon, 2026-08-29,
 * `tab-contents-replaces-panel` -> `replace-it`.
 *
 * **`z-index: 1`, and it is `.ws-where`'s number for `.ws-where`'s reason**: the
 * panes are APPENDED and so come later in the DOM, so furniture declared in the
 * template needs a positive one to be drawn over them.
 *
 * **And deliberately NOT 2 or more.** The sash is `z-index: 2` spanning
 * `top: 0; bottom: 0`, so it crosses this element's box - the divider has to
 * stay draggable with a list open. `.ws-drops` at 4 stays above for the same
 * kind of reason about a tab drag. The three chrome rows above (`.ws-tabbar` and
 * `.ws-tabtool` at 2, `.ws-where` at 1) never overlap this box at all, because
 * it starts exactly where the pane does.
 *
 * `display: flex` with a column direction is what gives `.panel-body` its
 * `flex: 1; min-height: 0` the container it needs - without a definite height
 * on the scroller a 935-row level is clipped and unscrollable, which is the
 * library panel's own recorded trap. */
.ws-tabcontents {
  z-index: 1;
  display: flex; flex-direction: column;
  /* **The CONTAINER never scrolls; `.panel-body` inside it does** - the
   * Contents panel's own 2026-08-16 arrangement, kept verbatim so the sticky
   * book row still pins against the scrollport that holds the rows and the head
   * stays out of the scroll flow with no measured height to keep in step. */
  overflow: hidden;
  background: var(--panel);
  border-left: 1px solid var(--line);
  border-top: 1px solid var(--line);
}
.ws-tabcontents[hidden] { display: none; }
/* The head is empty until `/panel/outline/head` answers, and an empty
 * `.panel-head` is still a bordered 1.6rem band - a stripe under the bar with
 * nothing in it, on every book, for as long as the fetch takes. `:empty` is
 * exact here because the head holds either markup or nothing. */
.ws-tabcontents > .panel-head:empty { display: none; }
/* **`background: none` IS LOAD-BEARING, AND WITHOUT IT AN OFF-SCREEN PANE
 * PAINTS OVER THE ONE JON IS READING.**
 *
 * `content-visibility: hidden` skips an element's CONTENTS. The element itself
 * still generates a box and still paints its own background — which is exactly
 * what this rule wanted while `.ws-pview` declared no background at all: the
 * reading surface was the HOST `.pane`, and an unshown pane painted nothing.
 *
 * The reading themes (2026-08-27) gave the pane a background for the first
 * time — `.ws-pview[data-theme=…] { background: var(--pane) }`, which they need,
 * because a themed pane must paint its own surface rather than let the host's
 * unthemed paper show through. That turned every off-screen THEMED pane into a
 * full-size opaque rectangle stacked over the live one, and the winner is
 * simply whichever is last in the DOM.
 *
 * Measured 2026-08-28 on a copy of Jon's own workspace, 390x844, dark, one tab
 * on `Baptist Symbolics Volume 2` under `black`: SIX panes, TWO of them
 * off-screen and painting, the last one `sepia` — reading area **100% light
 * pixels, mean luminance 232.6**, a blank cream rectangle. With this one
 * declaration: **0 off-screen panes painting, mean 19.3, 87.1% dark** — the
 * book, legible. Jon, that morning: *"I'll see the black background white text
 * of the BSV2 and then it blanks black, and then it blinks white and stays
 * there."* That is the panes mounting one after another.
 *
 * **IT NEEDS SEVERAL TABS, WHICH IS WHY EVERY PROBE MISSED IT.** A store with
 * one tab has one pane and nothing to stack; the defect is invisible there and
 * unreachable, not merely unmeasured (L-252, third instance). It also survives
 * a private window and a cleared cache, because it is neither — which is what
 * ruled out the stale-copy theory.
 *
 * **AND THE DOM REPORTS SUCCESS THROUGHOUT.** The covering panes are
 * `pointer-events: none`, so `elementFromPoint` returns the paragraph
 * underneath; the active pane's own `data-theme`, `--ink` and computed
 * background are all correct at every sample. Only the PIXELS disagree
 * (L-121). Anything checking this must read the rendered image.
 *
 * Order matters: `.ws-pview:not(.shown)` and `.ws-pview[data-theme=…]` are both
 * (0,2,0), so this wins by coming later in the file. Do not move it above the
 * theme block. */
.ws-pview:not(.shown) {
  content-visibility: hidden;
  pointer-events: none;
  background: none;
}

/* ------------------------------------------------------- splits, stage 2
 *
 * **A slot is a set of INSETS on panes that never move.** Measured on all
 * three engines (research/reparent-vs-reinset-measure-2026-08-09.md):
 * `appendChild` into a different parent takes `scrollTop` 1200 -> 0 and
 * COLLAPSES every live `Range` inside — the DOM's removing steps reset their
 * boundaries onto the old parent — so Jon's pens, every note mark and every
 * search highlight would stop painting, while `isConnected` stays true and
 * `CSS.highlights.size` still reports them registered. Re-insetting the same
 * element keeps both. So there are no slot BOXES: the panes stay direct
 * children of `#pane` (which `#pane .ws-landed` also needs, to beat a resource
 * sheet's single-class background rules) and only their `inset` changes.
 *
 * **THE SEAM RULES ARE GONE - Lumino rung two, 2026-08-31.** Six families
 * fill a half (the pane, the contents list, the tab bar, the annotations row,
 * the where-am-i bar and the saved-copy band) and each carried its own pair of
 * `body[data-split="v"] .ws-FAMILY[data-slot="N"]` rules: twelve declarations
 * saying one thing. They are replaced by four custom properties per element -
 * `--a-l`, `--a-t`, `--a-r`, `--a-b`, the element's own area insets - so each
 * family states its box ONCE and none of them names a slot.
 *
 * **The reason is that the old shape could not say what Jon asked for.**
 * `--ws-split` is a single scalar: it can place one seam. Three panels across
 * plus a bottom half per pane is not a bigger scalar, it is a different
 * quantity, and no amount of seam rules expresses it.
 *
 * **`--ws-split` is no longer GEOMETRY anywhere: it is the ratio the reader
 * chose and the number that is stored.** Five rules still mention it - the
 * sash, the two drop zones and the two tab groups - and after rung three every
 * one of them reads it only as the FALLBACK inside
 * `var(--ws-seam, ...)`, i.e. only where no engine has published a seam, which
 * is a scripting-off session and the instant before the first layout. **Zero
 * unconditional readers.** Re-derive rather than trusting either number
 * (L-020); the one-liner is in
 * `research/lumino-rung-two-design-2026-08-31.md`. What each is about: the sash is the two-area control, the drop
 * zones are where a slot WOULD be (they are drawn as two halves even unsplit,
 * which is the affordance for making a split), and the tab groups are gated on
 * the groups' server-stamped geometry rather than `data-split` on purpose,
 * because that attribute is
 * server-rendered and a strip keyed on the client's would stack both groups
 * opaque for the whole of a scripting-off session. **Re-derive that five
 * rather than trusting it** (L-020); the one-liner is in
 * `research/lumino-rung-two-design-2026-08-31.md`.
 *
 * `static/app.js`'s `stampSlot` is the only writer of `data-slot` on a host
 * child, and it publishes the insets in the same act - the attribute and the
 * geometry cannot disagree, which they could the moment the cascade stopped
 * deriving one from the other. */
.pane { --ws-split: 50%; --ws-sash-w: 9px; }

/* ------------------------------------------- THE PINNED CONTENTS COLUMN
 *
 * **Jon, 2026-08-30:** *"The contents bar doesn't need to cover all the words
 * ... it should also be able to be pinned, and adjustable width, the words
 * being shifted over or made to fit a narrower space."*
 *
 * Unpinned, `.ws-tabcontents` is an overlay across the whole half (its `inset`
 * above). Pinned, it is a COLUMN on the left of its half and the pane starts
 * where it ends - which is the substantive half of his sentence: the words
 * move over rather than being covered.
 *
 * **LEFT, and it is a choice rather than a constraint.** The Contents toggle
 * is the leftmost control in `.ws-tabbar`, and a table of contents down the
 * left is what every reader does; flipping it is these two rules and nothing
 * else. Jon was told.
 *
 * **Every edge is a LONGHAND**, the `.ws-drop` precedent and `.ws-pview`'s own
 * recorded trap: a shorthand here would silently override the `inset` that
 * decides where the chrome stack ends, and both halves' first line would slide
 * under their own bars.
 *
 * **`static/app.js` REFUSES the pin in a split and on a phone** (`pinFits`),
 * so `body[data-tcpin]` and `body[data-split="v"]` never both hold and these
 * rules never have to argue with the seam rules about specificity.
 *
 * **FIVE SELECTOR PAIRS NAMING TWO SLOTS BECAME FOUR RULES NAMING NONE.** CSS
 * cannot compare one attribute to another, so `body[data-tcpin="<slot>"]` plus
 * `[data-slot="<slot>"]` had to be spelled out per slot - and
 * `body[data-tcpin="2"]` would have matched nothing at all. That is reachable
 * the moment the vocabulary widens and it is NOT the split case `pinFits`
 * refuses: one tab, in slot 2, unsplit, on a wide screen. `pinFits` says yes,
 * `aria-pressed` goes true, the width is stored - and nothing moves. **A
 * pressed control with no effect and no error.**
 *
 * **The APPLIED state is a valueless `data-tcpin` on the per-slot element**,
 * and that is this project's standing two-names rule rather than a new idea:
 * `data-disp-*` is published and `data-hide-*` is applied, for the same reason.
 * `<body data-tcpin="<slot>">` is untouched - it is the STATE, it is what
 * `aria-pressed` and `pinFits()` read, and it is per-half in `localStorage`.
 *
 * **A per-slot `--ws-tccol-w` of `0px` was the obvious shape and it cannot
 * express this block.** Three of the four compose (`calc(--a-l + width)`) and a
 * zero there really is the unpinned geometry. This one does not: pinned it is
 * `right: auto; width: <col>`, unpinned it is an OVERLAY spanning the whole
 * half, so `0px` would make the column vanish rather than bring the overlay
 * back. The grip's is worse - `display: block` is not a number.
 *
 * **Specificity was checked rather than assumed.** These go from (0,3,1) to
 * (0,2,0), and every base rule they must beat - `.ws-pview, .ws-tabcontents`,
 * `.ws-where`, `.ws-stale`, `.ws-tc-grip` - is (0,1,0), so they still win on
 * specificity alone and not on source order. `.ws-pview:not(.shown)` is the
 * one (0,2,0) neighbour and it declares no `left`. */
.ws-tabcontents[data-tcpin] {
  right: auto;
  width: var(--ws-tccol-w);
  border-right: 1px solid var(--line);
}
/* **COMPOSED with the area inset, never a bare value.** `pinFits` refuses the
 * pin in a split, so today `--a-l` is 0 wherever this rule applies and the
 * `calc` is arithmetically identical to the bare `var(--ws-tccol-w)` it
 * replaces. It is written this way because that refusal is an invariant in
 * `static/app.js` that this sheet would otherwise silently DEPEND on: the day
 * a pinned column is allowed inside an area that does not start at the host's
 * left edge, a bare value puts the text at the wrong end of the screen and
 * nothing here would say why. */
.ws-pview[data-tcpin] {
  left: calc(var(--a-l, 0px) + var(--ws-tccol-w));
}
/* **The two bars above the text move with it, and that is not cosmetic.** The
 * where-am-i bar is CENTRED over its half and Jon's own instruction about it
 * was *"being left justified is wrong, it needs to always center over the
 * text"* - measured to 0.1px when that landed. A pinned column that moved the
 * text and not the bar would put it out by half the column, which is the exact
 * defect that measurement exists to prevent. The saved-copy band is a sentence
 * about the text underneath it and follows for the same reason. */
.ws-where[data-tcpin],
.ws-stale[data-tcpin] {
  /* Composed, `.ws-pview`'s rule above and for its reason. */
  left: calc(var(--a-l, 0px) + var(--ws-tccol-w));
}

/* The drag grip. `.ws-tabcontents` is `position: absolute`, so it is already
 * this element's containing block - no `position: relative` is needed and none
 * is added, because adding one would change what the list's own absolutely
 * positioned descendants resolve against.
 *
 * **Hidden unless pinned, and by `display` rather than `hidden`** - it is
 * decided entirely by the cascade here, so no JavaScript has to keep a fourth
 * thing in step, and an unpinned overlay spans the half where a resize handle
 * would move nothing. */
.ws-tc-grip { display: none; }
.ws-tabcontents[data-tcpin] > .ws-tc-grip {
  display: block;
  position: absolute;
  top: 0; bottom: 0; right: 0;
  width: var(--panel-grip-w, 9px);
  cursor: col-resize;
  touch-action: none;
  z-index: 2;
  background: none; border: 0; padding: 0;
}
.ws-tc-grip::before {
  content: ""; position: absolute;
  top: 0; bottom: 0; left: 50%; width: 1px;
  background: var(--line);
}
.ws-tc-grip:hover::before, .ws-tc-grip:focus-visible::before,
.ws-tc-grip.ws-tc-grip-dragging::before {
  background: var(--accent); width: 2px;
}
.ws-tc-grip:focus-visible { outline: 2px solid var(--accent);
                            outline-offset: -2px; }

/* ------------------------------------- the per-tab menu bar, per HALF
 *
 * Jon, `tab-bar-what-first` -> `bar-with-what-exists`, 2026-08-26.
 *
 * **The where-am-i bar's geometry exactly, one row up**, and deliberately so:
 * both are per-SLOT furniture inside `#pane`, both stop at the `--ws-split`
 * seam, and stating the seam arithmetic the same way in both is what keeps
 * them from drifting apart when the sash moves. The only differences are the
 * ones that have to be: this row takes pointer events, and it paints a
 * background because it sits over the pane rather than over blank inset.
 *
 * **`pointer-events` is NOT `none` here, unlike `.tabstrip`.** That element
 * disables them so the sash underneath stays draggable across the strip's full
 * width; this bar is only ever as wide as its own half and stops at the seam,
 * so the sash is never under it. Buttons need the events.
 */
/* **The row's height, and the ONE declaration that opens it.** `syncTabTool`
 * is the sole writer of `data-tabtool` on `<body>`, so "is the annotations row
 * open" has one producer and the inset, the row and the button's pressed look
 * cannot disagree about it (L-017).
 *
 * The value matches `--ws-tabbar-h` deliberately: two chrome rows of different
 * heights stacked on the same edge read as a mistake, and the row holds the
 * same kind of control the bar above it does. */
/* **`:not([data-fullscreen])` IS ORDER-INDEPENDENCE, NOT A SECOND SWITCH.**
 * Full-screen reading zeroes this row with the two above it, and its own rule
 * is `body[data-fullscreen]` - the SAME specificity as this selector on the
 * SAME element, so which one won would be decided by which came later in the
 * file. Stating the exclusion here makes the answer a property of the two
 * selectors rather than of their order, which is what a later edit to this
 * sheet must not be able to change silently. */
body[data-tabtool]:not([data-fullscreen]) { --ws-tabtool-h: 2rem; }
/* **THE PER-TAB MENU BAR'S ROW, and it is the annotations row's rule one line
   up applied to the row above it.** `:not([data-fullscreen])` is what lets the
   full-screen block stop restating this length - see the note there. */
body[data-tabbar]:not([data-fullscreen]) { --ws-tabbar-h: 2rem; }

.ws-tabbar {
  position: absolute; z-index: 2;
  /* The area insets - `syncAreas`, one producer, `, 0px` = the whole host.
   * See `.ws-pview` for why they are longhands and why the fallback is the
   * scripting-off path. */
  top: calc(var(--a-t, 0px) + var(--ws-tabs-h));
  left: var(--a-l, 0px); right: var(--a-r, 0px);
  height: var(--ws-tabbar-h);
  /* **IT SCROLLS SIDEWAYS AND IT NEVER GROWS - Jon's own standing rule,
   * 2026-09-02:** *"Any control or anything that goes off the side should be
   * scrollable anyways, no matter what, you don't know what zoom level anyone
   * might use."*
   *
   * `.ws-tabtool` carries the same pair for the same reason and its comment
   * has the full argument; what is new here is that he stated it as a rule
   * rather than a fix.
   *
   * **AND THE ROW DOES OVERFLOW TODAY - THIS COMMENT SAID IT DID NOT, AND IT
   * WAS WRONG AT 390 (measured 2026-09-19).** `clientWidth` 390.00 against
   * `scrollWidth` **514.00**, ten of twelve controls painted, three of them
   * past the right edge (`layout` +12.14, `annot` +91.73, fullscreen
   * +118.92) - and `scrollLeft` ACTUALLY MOVES, so they are reached rather
   * than lost. At 540 it fits. The old sentence was true of a DIFFERENT
   * control set and nothing re-derived it (L-020);
   * `research/probes/tabbar_fit.py` re-derives it now.
   *
   * **Measured at 390x844 before this existed**, with four more controls in
   * the row - which is what adding the per-tab back/forward does: **168px of
   * overflow, `overflow-x: visible`, and a REAL sideways wheel moved it 0px**,
   * with the last control's right edge at 558 against the bar's 390. Clipped
   * is not scrolled; there was no gesture that would reach it.
   *
   * **The vertical half is what keeps the fixed height honest** rather than
   * merely declared (R3): a fixed-height flex row whose contents overflow
   * paints them OUTSIDE the box, over the first line of the reader's book.
   *
   * **Nothing inside this row pops out of it, so nothing is clipped by the
   * new container** - checked rather than assumed: `.ws-tabbar` opens at
   * `templates/shell.html:1034` and closes at 1122, and `#ws-tabpop` is at
   * 1590, a SIBLING. */
  /* `scrollbar-width` rides with them: the scrollbar must not eat the row.
   * This engine uses overlay scrollbars (`offsetWidth == clientWidth`,
   * measured), so it is belt for a system with classic ones, where a gutter
   * inside a 2.4rem row would crop the buttons - `.ws-tabtool`'s own reason.
   *
   * **The three are ONE declaration for the mutant's sake**, uninterrupted and
   * ending on the line below, which is what makes them a unique anchor:
   * `overflow-x: auto; overflow-y: hidden;` alone appears three times in this
   * sheet, and a control that quietly mutates the wrong rule is worse than
   * none (L-039). */
  overflow-x: auto; overflow-y: hidden;
  scrollbar-width: none;
  display: flex; align-items: stretch; gap: .1rem;
  padding: 0 .3rem;
  background: var(--pane);
  border-bottom: 1px solid var(--line);
}
.ws-tabbar[hidden] { display: none; }
/* **A KIND'S BAR WRAPS ON ITS OWN AND MUST NOT IN HERE.**
 * `.composer-menubar` carries `flex-wrap: wrap`, which was right inside the
 * document body where the row could grow. This row is a FIXED height (R3)
 * with `overflow-y: hidden`, so a second line would be clipped rather than
 * shown. The row scrolls sideways instead - Jon's own standing rule, and
 * measured: it already does so at 390 with the shell's own controls. */
.ws-tabbar > .composer-menubar { flex-wrap: nowrap; }
/* The menu buttons. `.ws-tabctx-item`'s `user-select` argument applies here
 * for the same reason - a phone press must not select the button's word on
 * its way to opening the menu - and it is scoped to these buttons rather than
 * to the bar, so nothing else in the row inherits it. */
.ws-tabbar-menu {
  border: 0; border-radius: 3px; padding: 0 .5rem;
  background: none; color: var(--ink-sub);
  font: inherit; font-size: .78rem; line-height: 1;
  cursor: pointer; white-space: nowrap;
  -webkit-user-select: none; user-select: none;
}
.ws-tabbar-menu:hover { background: var(--panel); color: var(--ink); }
.ws-tabbar-menu:focus-visible { outline: 2px solid var(--accent);
                                outline-offset: -2px; }
/* **`aria-expanded` is the pressed look, and it is the SAME attribute the
 * assistive tree reads** - not a second `.open` class that could disagree
 * with it (L-017). `openTabBarMenu` writes it and nothing else does. */
.ws-tabbar-menu[aria-expanded="true"] { background: var(--panel); color: var(--ink); }
/* **THE BOOK BUTTON** (`s-exportdoors`) opens the tab's own menu under itself.
 * Its own class rather than `.ws-tabbar-menu`'s membership (`shell.html` says
 * why), so it states the same look rather than inheriting it - the row still
 * reads as one set of controls. */
.ws-tabbar-book {
  border: 0; border-radius: 3px; padding: 0 .5rem;
  background: none; color: var(--ink-sub);
  font: inherit; font-size: .78rem; line-height: 1;
  cursor: pointer; white-space: nowrap;
  -webkit-user-select: none; user-select: none;
}
.ws-tabbar-book:hover,
.ws-tabbar-book[aria-expanded="true"] { background: var(--panel); color: var(--ink); }
.ws-tabbar-book:focus-visible { outline: 2px solid var(--accent);
                                outline-offset: -2px; }
/* **The toggle's pressed look, and it is a DIFFERENT attribute from the one
 * above it on purpose.** `aria-expanded` says "a menu is open under me" and
 * `aria-pressed` says "this control is on"; the Annotations button is a
 * toggle, so it owes the assistive tree the second one. Same painted result,
 * so the three buttons still read as one row. */
.ws-tabbar-tool[aria-pressed="true"] { background: var(--panel); color: var(--ink); }

/* ------------------------------------------ back and forward, per HALF
 *
 * Jon, 2026-09-02, correcting the spec: *"each one needs its own back /
 * forward"*, and *"they can go on the left side and be the first things."*
 *
 * **A GLYPH BUTTON IN A ROW OF WORD BUTTONS**, so it takes the same height,
 * the same hover and the same focus ring as `.ws-tabbar-menu` and only its
 * padding differs — an icon needs no side bearing. It is a separate class
 * rather than a modifier because `.ws-tabbar-menu` is a MEMBERSHIP three
 * functions quantify over (`syncTabBars`, `syncOpenTabMenus`, the
 * `aria-expanded` sweep), and a nav button is not one of those controls.
 *
 * **DISABLED IS PAINTED, NOT HIDDEN, AND THAT IS THE POINT.** In the topbar
 * these shipped `hidden` until the tab had a trail, i.e. invisible on every
 * freshly opened book — which is why Jon could not find a control he had asked
 * for twice. Dimmed and unpressable says *this exists and there is nowhere to
 * go yet*; absent says nothing at all.
 *
 * `opacity`, never `display` or `visibility`: the row is `overflow-x: auto`
 * (2026-09-02, his standing rule), and a control that appears and disappears
 * would make the row's scroll width jump under a finger that is using it. */
.ws-tabbar-nav {
  border: 0; border-radius: 3px; padding: 0 .3rem;
  background: none; color: var(--ink-sub);
  font: inherit; line-height: 1;
  display: inline-flex; align-items: center;
  cursor: pointer; flex: 0 0 auto;
  -webkit-user-select: none; user-select: none;
}
.ws-tabbar-nav:hover:not(:disabled) { background: var(--panel); color: var(--ink); }
.ws-tabbar-nav:focus-visible { outline: 2px solid var(--accent);
                               outline-offset: -2px; }
.ws-tabbar-nav:disabled { opacity: .38; cursor: default; }
.ws-tabbar-nav .ws-undo-icon { width: 1rem; height: 1rem; display: block; }

/* ------------------------------------------- the annotations row, per HALF
 *
 * Jon, 2026-08-26: *"that can just be a persistent bar if they're on that
 * menu item."*
 *
 * Its box is `--ws-tabtool-h`, which is `0px` until `<body>` carries
 * `data-tabtool` - so this rule paints nothing at all in an ordinary session
 * and the row is `hidden` besides. See `--ws-tabtool-h` for why the height is
 * switched rather than measured (R3).
 *
 * **`overflow-x: auto`, `overflow-y: hidden` - IT SCROLLS SIDEWAYS AND IT
 * NEVER GROWS.** The vertical half is what makes the fixed height honest
 * rather than merely declared: a fixed-height flex row whose contents overflow
 * paints them OUTSIDE the box, over the first line of the reader's book.
 *
 * **The horizontal half was `hidden` and that was a defect on the one device
 * Jon reads on.** With the eight pens and `note...` in the row, measured with
 * both panels shut: at 390px the contents are **205px wider than the row and
 * five controls are clipped away**, at 360px 235px and five, at 320px 275px
 * and six - and the two SWITCHES are last, so the controls he asked for were
 * the ones that vanished. Clipped is not scrolled: there was no gesture that
 * would reach them. `auto` gives the row the strip's own behaviour
 * (`.tabs { overflow-x: auto }`), and the desktop is unaffected - 0px of
 * overflow at 768 and 1512.
 *
 * `pointer-events` is left alone - this row takes presses, `.ws-tabbar`'s
 * argument, and it stops at the seam so the sash is never under it. */
.ws-tabtool {
  position: absolute; z-index: 2;
  /* The area insets - `syncAreas`, one producer, `, 0px` = the whole host.
   * See `.ws-pview` for why they are longhands and why the fallback is the
   * scripting-off path. */
  top: calc(var(--a-t, 0px) + var(--ws-tabs-h) + var(--ws-tabbar-h));
  left: var(--a-l, 0px); right: var(--a-r, 0px);
  height: var(--ws-tabtool-h);
  overflow-x: auto; overflow-y: hidden;
  /* The scrollbar must not eat the row. This engine uses overlay scrollbars
   * (`offsetWidth == clientWidth`, measured), so this is belt for a system
   * with classic ones - where a gutter inside a 2rem row would crop the
   * swatches. */
  scrollbar-width: none;
  display: flex; align-items: center; gap: .3rem;
  padding: 0 .45rem;
  background: var(--pane);
  border-bottom: 1px solid var(--line);
}
.ws-tabtool::-webkit-scrollbar { display: none; }
/* **Nothing in the row shrinks.** In a scrolling flex row `flex-shrink` would
 * squeeze the swatches into slivers before the overflow it is meant to
 * prevent ever happens - and a 6px pen is not a target. They keep their size
 * and the row scrolls instead. */
.ws-tabtool > * { flex: 0 0 auto; }
.ws-tabtool[hidden] { display: none; }
.ws-tabtool-what {
  font-size: .72rem; line-height: 1; color: var(--dim);
  white-space: nowrap; -webkit-user-select: none; user-select: none;
}
/* **A two-state toggle, so `aria-pressed` and nothing else.** These have no
 * `book` rung under them (`workspace.ANNOT_OPTIONS`), which is why they are
 * not `.ws-tabctx-choice`s: that control is one of three in a group and takes
 * its look from `aria-pressed="true"` meaning "this is the value in force",
 * where here it means "this is ON". Same attribute, different sentence - so a
 * separate class rather than a shared one that would have to mean both. */
.ws-tabtool-switch {
  border: 1px solid var(--line); border-radius: 3px; padding: .1rem .45rem;
  background: none; color: var(--dim);
  font: inherit; font-size: .72rem; line-height: 1.3;
  cursor: pointer; white-space: nowrap;
  -webkit-user-select: none; user-select: none;
}
.ws-tabtool-switch:hover { color: var(--ink); border-color: var(--dim); }
.ws-tabtool-switch:focus-visible { outline: 2px solid var(--accent);
                                   outline-offset: -2px; }
.ws-tabtool-switch[aria-pressed="true"] {
  background: var(--accent); border-color: var(--accent); color: #fff;
}
/* **The armed pens, and they are `.ws-note-pen`s in a shorter row.** Every
 * colour and every `::after` glyph comes from that class's rules, so there is
 * one description of what a pen looks like and the composer and the bar cannot
 * drift (L-017). Only the SIZE is restated, because the composer's 2.1rem
 * swatch does not fit a 2rem chrome row - and the row's height is fixed by
 * `--ws-tabtool-h` and must not grow with its contents (R3).
 *
 * `min-width` as well as `width`: `.ws-note-pen` sets a `min-width` that would
 * otherwise win against a plain `width` and push the row wide enough to clip
 * the switches at the end of it. */
.ws-tabtool-pen {
  min-width: 1.45rem; width: 1.45rem; height: 1.45rem;
  border-radius: 4px; padding: 0; font-size: .6rem;
}
/* **The armed look is an OUTLINE, not a fill**, because a filled swatch is
 * already how a yellow highlighter draws itself - so filling it to mean
 * "armed" would make two pens indistinguishable from one armed pen. An
 * outline sits outside the swatch's own colour and reads on all eight. */
.ws-tabtool-pen[aria-pressed="true"],
.ws-tabtool-note[aria-pressed="true"] {
  outline: 2px solid var(--accent); outline-offset: 1px;
}
.ws-tabtool-note {
  border: 1px solid var(--dim); border-radius: 4px; padding: .1rem .4rem;
  background: none; color: var(--ink-sub);
  font: inherit; font-size: .72rem; line-height: 1.3;
  cursor: pointer; white-space: nowrap;
  -webkit-user-select: none; user-select: none;
}
.ws-tabtool-note:hover { color: var(--ink); border-color: var(--dim); }
/* The space between what MAKES a mark and what SHOWS one. It GROWS on a wide
 * screen and never shrinks below a visible gap - the one exception to the
 * no-shrink rule above, and it is declared after it so it wins on order. */
.ws-tabtool-gap { flex: 1 1 .5rem; min-width: .35rem; }

/* ------------------------------------------ the book-only search row, v18
 *
 * Jon, 2026-08-26: *"the search menu item simple shows another row in that tab
 * for the search in that resource and a history of last 10 things searched."*
 *
 * **It carries `.ws-tabtool` as well as this class**, so the band, the fixed
 * height, the seam, the sideways scroll and the no-shrink rule all come from
 * the rules above and there is ONE description of what a row under this bar
 * is (L-017). Everything here is the CONTENTS of that row and nothing here
 * touches its box.
 */
.ws-tabsearch-form,
.ws-tabresults-form,
.ws-tabresults-body,
.ws-tabresults-within { display: flex; align-items: center; gap: .3rem; }
/* **`nowrap` is stated, not inherited.** `.search-form` - the corpus box this
 * row deliberately does not reuse - is `flex-wrap: wrap`, and a wrapped second
 * line inside a fixed-height row with `overflow-y: hidden` paints nothing at
 * all. Saying it here means a later author who does reuse that class still
 * gets a row that works. */
.ws-tabsearch-form { flex-wrap: nowrap; }
/* **The Filters row's form is the results PAGE's form, copied** (see
 * `.ws-tabresults` in `shell.html`), so it carries the page's `flex-wrap:
 * wrap` from the `.search-refine` rule - which is fatal in this band for the
 * reason directly above. Restated `nowrap` here, scoped to the row, and at a
 * higher specificity than the page rule so the copy cannot inherit a wrap. */
.ws-tabtool .ws-tabresults-form { flex-wrap: nowrap; }
/* Sized in `ch` so it holds a phrase rather than a word, and `min-width: 0` so
 * an `<input>`'s automatic minimum size cannot push the row wider than the
 * half - the row would still scroll, but the recent searches would start off
 * screen every time it opened. */
.ws-tabsearch-box,
.ws-tabresults-box {
  min-width: 0;
  border: 1px solid var(--line); border-radius: 3px;
  padding: .1rem .4rem;
  background: var(--pane); color: var(--ink);
  font: inherit; font-size: .75rem; line-height: 1.3;
}
/* **THE WIDTH IS THE ONE THING THE TWO BOXES NO LONGER SHARE, AND THAT IS THE
 * POINT — Jon, 2026-09-06:** *"I think other search boxes are also
 * unnecessarily small."* He is right about both, and they are two different
 * shapes: this box sits in an `.sx-field` inside `.ws-tabsearch-form`, a
 * direct child of the band; `.ws-tabresults-box` sits in a `<label>` inside a
 * form inside `.ws-tabresults-body`, which is itself the copied contents of
 * the results page's `.search-refine`. One `width` cannot be reasoned about
 * for both, and the note on the Go-to fix already said these rows want
 * DRIVING rather than copying.
 *
 * **MEASURED ON THE RUNNING APP BEFORE THE CHANGE**
 * (`research/probe_tabbox_widths-2026-09-06.py`, KJV1900, dev store):
 *
 *     viewport 390px   row 390 (content 519)  form 326 `0 0 auto`  BOX 107
 *     viewport 536px   row 536 (content 559)  form 366 `0 0 auto`  BOX 107
 *
 * **The box was 107px at EVERY width** — it never grew, because the form is
 * pinned by `.ws-tabtool > * { flex: 0 0 auto }` and is therefore content-sized
 * whatever room the row has. `16ch` is exactly the placeholder *"Search this
 * book"*, so he was typing into the width of his own prompt.
 *
 * **`32ch` because a book search is a PHRASE and a reference is one word.**
 * `.ws-tabgoto-box` keeps `12ch` — `"John 3:16"` is 9 characters — and this one
 * holds what `verify_tabmenu` searches for, *"Melchizedek priest"*, which is 18.
 * Driven after the change: **107px -> 214px at both 390 and 536**, and the row's
 * scroll grows by the same 106px rather than anything being lost — the recent
 * list is pushed right and stays reachable, which is the whole reason it was
 * not made to shrink.
 *
 * **`.ws-tabresults-box` IS `32ch` TOO NOW, 2026-09-06, AND THE REASON IT WAS
 * HELD BACK TURNED OUT TO BE A BUG SOMEWHERE ELSE ENTIRELY.** The note here
 * used to say its row was 1,978px wide, so widening the box would only make a
 * row that already scrolled by 1,562px on a phone worse. That row width was
 * real and it was not CSS: `templates/_search.html` carried a Jinja comment
 * quoting its own closing marker, the comment closed AT the quotation, and 228
 * characters of it were served into `.ws-tabresults-form` as a bare text node
 * - an anonymous flex item, which is why the attribution sweep that hid every
 * ELEMENT child could not find it. With that fixed the row is **665px** on a
 * 390px phone and **fits entirely** at 1400px.
 *
 * **PRICED ON THE RUNNING APP BEFORE THIS LINE CHANGED**
 * (`research/probe_resultsbox_after_leak-2026-09-06.py`, which injects the
 * candidate as a stylesheet and measures rather than assuming - L-039):
 *
 *     viewport 1400   row 1080  overflow   0 -> 0     BOX 107 -> 214
 *     viewport  390   row  390  overflow 289 -> 396   BOX 107 -> 214
 *
 * **Free where the row fits, and 107px of extra scroll where it does not** -
 * on a row whose overflow the same day's fix cut from 1,562px to 289px.
 * `32ch` and not some third value because this box and `.ws-tabsearch-box` are
 * the same kind of input - a PHRASE, not a reference - and two answers to
 * *"how wide is a search box in this band"* is how they drift (L-017).
 *
 * **WHAT THIS DOES NOT FIX, and it is Jon's to price:** the box starts
 * **529px** into the row, behind 394px of filter toggles, so on a 390px phone
 * it is not reachable without scrolling at EITHER width. Widening makes it
 * better to type in once he gets there; only the row's ORDER makes it
 * reachable. Asked as `results-row-order-on-a-phone`. */
.ws-tabsearch-box { width: 32ch; }
.ws-tabresults-box { width: 32ch; }
.ws-tabsearch-box:focus-visible,
.ws-tabresults-box:focus-visible { outline: 2px solid var(--accent);
                                   outline-offset: -1px; }
.ws-tabsearch-go,
.ws-tabresults-go {
  border: 1px solid var(--dim); border-radius: 3px; padding: .1rem .45rem;
  background: none; color: var(--ink-sub);
  font: inherit; font-size: .72rem; line-height: 1.3;
  cursor: pointer; white-space: nowrap;
  -webkit-user-select: none; user-select: none;
}
.ws-tabsearch-go:hover,
.ws-tabresults-go:hover { color: var(--ink); border-color: var(--ink-sub); }
.ws-tabsearch-go:focus-visible,
.ws-tabresults-go:focus-visible { outline: 2px solid var(--accent);
                                  outline-offset: -2px; }
/* The list. `.ws-tabtool > *` already froze these against shrinking, so a long
 * history scrolls sideways with the row rather than crushing the box. */
.ws-tabsearch-recent { display: flex; align-items: center; gap: .3rem; }
.ws-tabsearch-what {
  font-size: .72rem; line-height: 1; color: var(--dim);
  white-space: nowrap; -webkit-user-select: none; user-select: none;
}
/* **A past search is an ANCHOR and looks like a chip.** It is a real `<a href>`
 * so it works the way every other `data-open` link in this app does; the chip
 * look is because a row of underlined links reads as prose rather than as a
 * set of things to press. */
.ws-tabsearch-past {
  border: 1px solid var(--line); border-radius: 999px;
  padding: .05rem .5rem;
  color: var(--ink-sub); text-decoration: none;
  font-size: .72rem; line-height: 1.4;
  white-space: nowrap; max-width: 14ch;
  overflow: hidden; text-overflow: ellipsis;
}
.ws-tabsearch-past:hover { color: var(--ink); border-color: var(--dim); }
.ws-tabsearch-past:focus-visible { outline: 2px solid var(--accent);
                                   outline-offset: 1px; }
.ws-tabsearch-none {
  font-size: .72rem; line-height: 1; color: var(--dim); white-space: nowrap;
}

/* ------------------------------------------------ the verse jump row, v21
 *
 * Jon, `bible-search-next`, 2026-09-02: *"Go ahead and do the book filter, and
 * then also a verse jump and sort by where."*
 *
 * **It carries `.ws-tabtool` too**, so the band, the fixed height, the seam,
 * the sideways scroll and the no-shrink rule all come from the block above and
 * there is ONE description of what a row under this bar is (L-017). Everything
 * here is the CONTENTS of that row.
 *
 * **The box and the button are the search row's, to the declaration.** Two
 * rows in one band that look different read as two features; they are the
 * same act. Only the width differs - a reference is shorter than a phrase.
 */
.ws-tabgoto-form { display: flex; align-items: center; gap: .3rem;
                   flex-wrap: nowrap; }
/* **THE ONE EXEMPTION FROM "nothing in the row shrinks" — Jon, 2026-09-06:**
 * *"why is the box so small? It could be 3x longer from my view on the phone."*
 * `"John 3:16"` is 9 characters against a 12ch box.
 *
 * `.ws-tabtool > * { flex: 0 0 auto }` pins every child of the band so a
 * scrolling flex row cannot squeeze the annotation swatches into slivers — a
 * 6px pen is not a target. **That reason is about the pens and does not reach
 * this form**, which holds an input and one button, so letting it grow displaces
 * nothing. Measured before: form 187px inside a 536px row, box 146px at both 390
 * and 1400 viewport widths — the free space was simply never distributed.
 *
 * `flex: 1 1 auto` rather than `1 0 auto`, so it still SHRINKS on a narrow
 * phone: the band is `overflow-x: auto`, so a form that could only grow would
 * push the Go button off the row instead of wrapping (`flex-wrap: nowrap`
 * above, and that is load-bearing — a wrapped second line paints nothing in a
 * fixed-height row with `overflow-y: hidden`).
 *
 * Scoped to THIS form on purpose. `.ws-tabsearch-form` and
 * `.ws-tabresults-form` sit beside the recent-search chips and the toggle
 * strip, which is the displacement the band-wide rule exists to prevent —
 * Jon's *"other search boxes are also unnecessarily small"* is
 * `s-178ab6c1f3c5f954` and wants its own driving, not a copy of this. */
.ws-tabtool > .ws-tabgoto-form { flex: 1 1 auto; min-width: 0; }
/* **THE SEARCH ROW GETS THE SAME EXEMPTION AND A DIFFERENT SHRINK — DRIVEN,
 * NOT COPIED, AND THE DIFFERENCE IS THE WHOLE REASON THE NOTE ABOVE SAID TO
 * DRIVE IT (2026-09-06).**
 *
 * `1 0 auto`, **not** the Go-to row's `1 1 auto`. That row is alone in its
 * band, so shrinking costs it nothing; this one sits beside
 * `.ws-tabsearch-recent`, which is 173px and pinned. Measured at 390px the two
 * come to 499px in a 390px row — the line is ALREADY over, so `flex-shrink: 1`
 * would take ~71px off this form and make Jon's box SMALLER, which is the
 * opposite of what he asked for. Growth is what he wants and shrink is what
 * would have been shipped by copying.
 *
 * **WHAT IT BUYS, AND WHERE — DRIVEN ACROSS FIVE WIDTHS, because "it grows
 * where there is room" was a claim with no measurement behind it until the row
 * was actually given room.** The two widths the first probe reached are both
 * over budget, so neither could show the growth arm at all:
 *
 *     viewport   row  form  recent  BOX  slack
 *          390   390   433     173  214   -216
 *          900   286   473     173  214   -360
 *         1400   536   473     173  214   -110
 *         2000   836   643     173  384    +20
 *         2600  1136   943     173  684    +20
 *
 * Where the line is over, nothing moves — the box sits at its `32ch` basis and
 * the band scrolls sideways, which is Jon's own standing rule for anything
 * that runs off the side. The moment the row has slack the form takes it and
 * `.sx-field > input` passes it straight to the box.
 *
 * **`.ws-tabsearch-recent` IS DELIBERATELY LEFT PINNED.** Letting it shrink
 * would stop the row overflowing on a phone and would clip the recent searches
 * to nothing — unreachable, because a shrunk flex item clips its content
 * rather than extending the row's scroll. A convenience he did not ask to lose
 * is not the price of a wider box.
 *
 * **`.ws-tabresults-form` IS NOT HERE.** It is not a child of the band at all —
 * `.ws-tabresults-body` is, and the form is inside the copied `.search-refine`
 * — so this selector would not match it and a `>` rule written for it blind
 * would be one more rule that looks correct and owns nothing. */
.ws-tabtool > .ws-tabsearch-form { flex: 1 0 auto; min-width: 0; }
/* **IT GROWS INTO THE ROW NOW — Jon, 2026-09-06:** *"why is the box so small?
 * It could be 3x longer from my view on the phone."* It was `width: 12ch`, and
 * `"John 3:16"` is **9 characters**, so he was typing into very nearly the width
 * of his own input.
 *
 * **THE FIXED `ch` WAS NOT AN ACCIDENT AND THE REPLACEMENT KEEPS WHAT IT BOUGHT.**
 * `.ws-tabtool` is `overflow-x: auto`, so an input that cannot shrink pushes the
 * rest of the row off screen — the hazard `.ws-tabsearch-box`'s own comment
 * describes, where the recent searches start off screen every time the row opens.
 * `flex: 1 1 12ch` with `min-width: 0` keeps the OLD width as the basis and lets
 * the box both grow into space the row has and shrink when it has none, so the
 * Go button cannot be pushed out. A bigger fixed width would have fixed his phone
 * and broken a narrower one.
 *
 * **THIS ROW CAN AFFORD IT AND THE SEARCH ROWS CANNOT** — this form holds an
 * input and one button, so growth has nothing to displace. `.ws-tabsearch-box`
 * and `.ws-tabresults-box` sit beside the recent-search chips and the toggle
 * strip, which is why they are deliberately left alone here; Jon's *"other search
 * boxes are also unnecessarily small"* is `s-178ab6c1f3c5f954` and wants driving,
 * not a copy of this line. */
/* **THE GROWTH IS ON THE FORM, NOT HERE — two rules own this box and neither is
 * this one, which cost me two driven runs to find.** `.sx-field > input` sets
 * `flex: 1 1 auto` at specificity (0,1,1) against this rule's (0,1,0), so a
 * `flex` written here is simply discarded (that file comment says so in as many
 * words). And `.ws-tabtool > * { flex: 0 0 auto }` pins every child of the band
 * — deliberately, so a scrolling row cannot squeeze the pen swatches into
 * slivers — which is what actually held the form at 187px inside a 536px row.
 * `width` is the one lever this rule still owns, and it is the flex BASIS the
 * form's growth is distributed from. */
.ws-tabgoto-box {
  min-width: 0; width: 12ch;
  border: 1px solid var(--line); border-radius: 3px;
  padding: .1rem .4rem;
  background: var(--pane); color: var(--ink);
  font: inherit; font-size: .75rem; line-height: 1.3;
}
.ws-tabgoto-box:focus-visible { outline: 2px solid var(--accent);
                                outline-offset: -1px; }
.ws-tabgoto-go {
  border: 1px solid var(--dim); border-radius: 3px; padding: .1rem .45rem;
  background: none; color: var(--ink-sub);
  font: inherit; font-size: .72rem; line-height: 1.3;
  cursor: pointer; white-space: nowrap;
  -webkit-user-select: none; user-select: none;
}
.ws-tabgoto-go:hover { color: var(--ink); border-color: var(--ink-sub); }
.ws-tabgoto-go:focus-visible { outline: 2px solid var(--accent);
                               outline-offset: -2px; }
.ws-tabgoto-answer,
.ws-goto-said { display: flex; align-items: center; gap: .3rem; }
/* Where it landed. **Not `nowrap` on the caveat beside it** - see below. */
.ws-goto-place {
  font-size: .72rem; line-height: 1; color: var(--ink);
  white-space: nowrap; -webkit-user-select: none; user-select: none;
}
/* **The caveat is DIM, and it is `nowrap` for the row's own reason rather
 * than for looks.** This band is a FIXED `--ws-tabtool-h` with
 * `overflow-y: hidden`, so a sentence that wrapped to a second line would
 * paint NOTHING of that line - the trap `.ws-tabsearch-form` records one
 * block up, arriving at a run of prose instead of at a form. *"this book
 * marks verse 18, not 20"* is the whole reason the `nearest` rung has a name;
 * losing half of it would be back to landing silently somewhere the reader
 * did not ask for. The row scrolls sideways, which is how a long sentence is
 * reached. */
.ws-goto-caveat {
  font-size: .72rem; line-height: 1.3; color: var(--dim);
  white-space: nowrap;
}
.ws-goto-what {
  font-size: .72rem; line-height: 1; color: var(--dim);
  white-space: nowrap; -webkit-user-select: none; user-select: none;
}
/* **A candidate is a chip, `.ws-tabsearch-past`'s look** - a row of them is a
 * set of things to press, and it is the same gesture one row up. It is a
 * `<button>` rather than an `<a>` because it re-asks THIS row rather than
 * opening anything, which is the difference the two shapes carry. */
.ws-goto-pick {
  border: 1px solid var(--line); border-radius: 999px;
  padding: .05rem .5rem;
  background: none; color: var(--ink-sub);
  font: inherit; font-size: .72rem; line-height: 1.4;
  white-space: nowrap; max-width: 16ch;
  overflow: hidden; text-overflow: ellipsis;
  cursor: pointer;
}
.ws-goto-pick:hover { color: var(--ink); border-color: var(--dim); }
.ws-goto-pick:focus-visible { outline: 2px solid var(--accent);
                              outline-offset: 1px; }

/* ------------------------------------------- the "open in" row, 2026-09-04
 *
 * Follow-me links, Jon's `linked-panels` -> A. The chips are
 * `.ws-tabsearch-past`'s look for its reason - a row of underlined links reads
 * as prose rather than as a set of things to press - and `.ws-goto-caveat`
 * beside a chip is the verse jump's own caveat for the same rung. Everything
 * here is `nowrap`: the band is a fixed `--ws-tabtool-h` with
 * `overflow-y: hidden` and scrolls sideways, so a wrapped line paints nothing. */
.ws-tabanswering-body { display: flex; align-items: center; gap: .3rem; }
.ws-answer-what {
  font-size: .72rem; line-height: 1; color: var(--dim);
  white-space: nowrap; -webkit-user-select: none; user-select: none;
}
.ws-answer {
  border: 1px solid var(--line); border-radius: 999px;
  padding: .05rem .5rem;
  color: var(--ink-sub); text-decoration: none;
  font-size: .72rem; line-height: 1.4;
  white-space: nowrap; max-width: 22ch;
  overflow: hidden; text-overflow: ellipsis;
}
.ws-answer:hover { color: var(--ink); border-color: var(--dim); }
.ws-answer:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.ws-answer-none {
  font-size: .72rem; line-height: 1; color: var(--dim); white-space: nowrap;
}

/* ------------------------------------------ the where-am-i bar, per HALF
 *
 * **Jon, 2026-08-16, items 1 and 2 - and they are one idea.** *"The reference
 * bar you have been working on should be part of the tab, not outside of
 * them ... the LSB text on the right tab is where im scrolling, but the
 * indicator for the position is way over on the left over the KJV, which gets
 * no reference indicator bar."* And: *"being left justified is wrong, it needs
 * to always center over the text."* Centring the old SINGLETON would have
 * centred it over the SEAM, which is worse than left-justified - so item 1 was
 * never shippable without item 2.
 *
 * Measured at `9b82ab0`, bar-text centre against the reading column's own
 * glyph centre: desktop pool **-678.6px**, desktop margin **-564.9px**, split
 * left half **-470.7px**, split right half (the one holding the reader)
 * **-911.2px**, phone **-136.0px**. And scrolling the inactive half 2,000px
 * changed the bar **not at all**, which is his second sentence as a number.
 *
 * **One per SLOT, not per pane.** A slot shows exactly one pane
 * (`CLAUDE.md` § THE PANE ENGINE: *at most one `.shown` per slot*), so a
 * per-slot bar is 2 elements rather than up to `LIVE_PANES`, and it is the
 * shape item 3's tab strip will need. Server-rendered in `shell.html` like the
 * sash and the drop zones, because `static/app.js` builds no markup.
 *
 * **`hidden` by default and cleared by the client** - the sash's mechanism,
 * and it is doing three jobs at once here. It keeps a slot with no shown pane
 * from painting a stale crumb; it keeps the slot-1 bar off the single visible
 * pane when unsplit (there is no `body[data-split="off"]` rule anywhere in
 * this sheet and this is not the place to invent one); and with JavaScript OFF
 * it keeps both bars off the server-rendered `.loading` placeholder, whose
 * 1rem padding is less than this bar's 1.35rem row.
 *
 * `z-index: 1` because the panes are APPENDED and so come later in the DOM
 * (the sash's own comment, four rules up) - without it a bar would paint under
 * a pane and simply not exist. It should never overlap one; the top inset is
 * what guarantees that, and `verify_panes` measures it. The z-index is the
 * belt, so a wrong inset is a check going red rather than Jon losing the bar.
 *
 * `pointer-events: none` is load-bearing, not hygiene: the sash runs
 * `top: 0; bottom: 0` THROUGH this strip, and `.ws-drops` is found by
 * `elementFromPoint` during a tab drag. A bar that answered either lookup
 * would make the top 21.6px of the divider undraggable and could swallow a
 * drop. It is a `textContent`-only element and wants no events. */
.ws-where {
  position: absolute; z-index: 1;
  /* The area insets - `syncAreas`, one producer, `, 0px` = the whole host.
   * See `.ws-pview` for why they are longhands and why the fallback is the
   * scripting-off path. */
  top: calc(var(--a-t, 0px) + var(--ws-tabs-h) + var(--ws-tabbar-h)
            + var(--ws-tabtool-h));
  left: var(--a-l, 0px); right: var(--a-r, 0px);
  height: var(--ws-where-h);
  display: flex; align-items: center;
  pointer-events: none;
  font-size: .78rem; line-height: 1; color: var(--dim);
}
.ws-where[hidden] { display: none; }

/* --------------------------------- "FROM BEFORE THE LAST UPDATE", 2026-08-30
 *
 * Jon, `offline-after-an-update` -> `show-it-and-say-so`. The band under the
 * where-am-i bar, at the BOTTOM of the chrome stack so it sits directly on the
 * text it is describing.
 *
 * **`--ws-stale-h` is declared on `<body>` and not here**, and that is L-079:
 * `.ws-pview` reads it inside an `inset` SHORTHAND, and a `var()` scoped to an
 * element is invalid at computed-value time there - every pane would collapse.
 * So both halves take the inset whenever either is stale, and the other half
 * shows an empty strip. That is `--ws-tabtool-h`'s settled behaviour, not a
 * new compromise.
 *
 * **It takes no pointer events** - `.ws-where`'s rule and its reason: `.ws-sash`
 * spans this strip and `.ws-drops` is found by `elementFromPoint` during a tab
 * drag, so a band that answered either lookup would make the top of the divider
 * undraggable and could swallow a drop.
 *
 * The colour is the app's own warning tone rather than an alarm: this is a
 * true statement about a page that is WORKING, not an error. It must read in
 * both themes - Jon reads on a phone, in dark mode - so it is drawn from the
 * theme tokens rather than from a literal. */
.ws-stale {
  position: absolute; z-index: 1;
  /* The area insets - `syncAreas`, one producer, `, 0px` = the whole host.
   * See `.ws-pview` for why they are longhands and why the fallback is the
   * scripting-off path. */
  top: calc(var(--a-t, 0px) + var(--ws-tabs-h) + var(--ws-tabbar-h)
            + var(--ws-tabtool-h) + var(--ws-where-h));
  left: var(--a-l, 0px); right: var(--a-r, 0px);
  height: var(--ws-stale-h);
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
  pointer-events: none;
  font-size: .74rem; line-height: 1;
  color: var(--ink);
  /* `--accent-soft` is the app's existing notice tone and it is defined in all
   * three theme blocks (light, sepia, dark) - checked, because a colour whose
   * only definition is in one of them is invisible in the others, and Jon
   * reads on a phone in DARK mode. `--warn-bg`/`--edge`/`--rule` were the
   * first draft's names and none of the three exists in this sheet. */
  background: var(--accent-soft);
  border-bottom: 1px solid var(--line);
}
.ws-stale-text {
  padding: 0 .6rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ws-stale[hidden] { display: none; }
body[data-stale] { --ws-stale-h: 1.5rem; }

/* --------------------------------------------------- THE LINTEL, 2026-09-19
 *
 * Jon, with two screenshots: *"I fully expected we would have a tab standard,
 * not just look and feel, but functionally ... It looks like the composer tab
 * and reading tabs are completely unique instead of on the same chassis ...
 * thats bad engineering and there should be a level of abstraction where the
 * common elements are managed and defined so we dont need to have these niche
 * issues."*
 *
 * **HE REPORTED THE ONE HE COULD SEE AND IT WAS THE SMALLEST OF SEVEN.**
 * Measured over all 14 pane bodies: a title of its own 11 fill / 3 decline,
 * `data-title` 9/5, the escape form 9/5, a find box 2/12, a pager 4/10, a back
 * link 4/10, a menubar 1/13. Every one a common element with no common owner.
 * `research/tab-chassis-census-2026-09-19.md` carries the denominator, and
 * `research/probes/census_tab_chrome.py` re-derives it rather than restating
 * it (L-020).
 *
 * **WHAT WAS ACTUALLY WRONG: FIVE ROWS HAD AN OWNER AND THE SIXTH HAD NONE.**
 * The shell sizes `--ws-tabs-h`, `--ws-tabbar-h`, `--ws-tabtool-h`,
 * `--ws-where-h` and `--ws-stale-h` from `<body>`; `.composer-menubar` had no
 * custom property anywhere in this sheet and flowed inside the pane's own
 * scroll content. This row is where a KIND's chrome goes, so that the shell
 * owns its position and height the way it already owns the other five.
 *
 * **THE COMPOSER FILLS `.ws-tabbar` INSTEAD, SINCE 2026-09-19.** There was
 * a sixth row here, `.ws-lintel`, for exactly one shift. It sat BELOW the
 * where-band while a reading tab's menu row sits ABOVE it - the same visual
 * role on opposite sides of one strip, measured in a browser at three widths,
 * and that inconsistency is what Jon reported. The slot is GONE rather than
 * renamed: a slot is a position and a height owned by the shell while the kind
 * owns only content, so a row whose occupants are all reading-shaped is not
 * thereby a reading-only row. `data-needs` already hides what a non-reading
 * kind cannot use. */

/* ----------------------------------------- FULL-SCREEN READING, 2026-09-03
 *
 * Jon, unprompted: *"for any book / reading tab, it would be nice to have a
 * full screen reading mode, keep the bar that shows current location
 * obviously."* Then, on `/decisions`:
 *
 *   `fullscreen-what-comes-off`        -> `everything-but-the-location-bar`
 *   `fullscreen-how-you-get-in-and-out` -> `menu-row-in-location-bar-out`
 *
 * **THE MODE IS FOUR CUSTOM PROPERTIES AND NOTHING ELSE MOVES.** Every chrome
 * row above the reading pane is already a fixed length on `:root` or `<body>`
 * summed into `.ws-pview`'s top inset, so *"take the rows off"* is those
 * lengths going to zero - the mechanism `--ws-tabtool-h` has used since the
 * annotations row shipped. There is no second layout here and no measurement:
 * the pane's top is a sum, and this changes what it sums.
 *
 * **`--ws-where-h` IS DELIBERATELY ABSENT FROM THIS BLOCK.** It is the one row
 * Jon's answer keeps, and it is also the way OUT - `.ws-where-text` becomes
 * pressable below. A future edit that "finishes the job" by zeroing it would
 * take away the reader's only exit on a phone, where there is no Escape key.
 *
 * **`--ws-stale-h` IS ABSENT FOR A STRONGER REASON.** That band says *this is
 * the copy from before the last update*, and its VISIBILITY is the condition
 * Jon accepted the offline feature under (`offline-after-an-update` ->
 * `show-it-and-say-so`: *"the sentence has to be true and visible every time
 * ... an unlabelled old page is exactly what today's rule exists to
 * prevent"*). A reading mode may not turn a truthfulness notice off.
 *
 * **`--ws-tabtool-h` IS ABSENT BECAUSE `:root` ALREADY ANSWERS `0px`** and the
 * only rule that raises it now excludes this state by selector
 * (`body[data-tabtool]:not([data-fullscreen])`). Restating it here would be a
 * second producer of one length (L-017).
 *
 * **`--ws-tabbar-h` JOINED IT ON 2026-09-19 AND WAS REMOVED FROM THIS BLOCK
 * FOR THE SAME REASON.** It was `2rem` at `:root` and `0px` here; it is now
 * `0px` at `:root` and raised only by
 * `body[data-tabbar]:not([data-fullscreen])`, which excludes this state by
 * selector. The line that used to sit below `--ws-tabs-h` is gone rather than
 * kept as belt: two rules answering one length is how they come to disagree,
 * and the selector guard is the stronger of the two because it also covers
 * every state nobody has thought of.
 *
 * **`--topbar-real` IS STATED HERE EVEN THOUGH `syncTopbar` MEASURES IT.** That
 * function writes an inline value on `<html>`; a declaration on `<body>` wins
 * for `<body>` and everything under it, which is every reader of the property.
 * It is stated rather than left to the measurement because a `display: none`
 * `.topbar` is not guaranteed to produce a `ResizeObserver` callback, and
 * because the first paint must be right with no script having run.
 */
body[data-fullscreen] {
  --topbar-real: 0px;
  --ws-tabs-h: 0px;
  /* The PINNED contents column, for the hidden-controls rule below: with the
   * tab bar gone there is no Pin and no Contents, so a pinned column would be
   * a piece of chrome the reader cannot put away. Zeroing the width is what
   * `.ws-pview[data-tcpin]`, `.ws-where[data-tcpin]` and `.ws-stale[data-tcpin]`
   * all read, so the three insets collapse together and none of them needs a
   * rule of its own here. The reader's PIN is untouched, so leaving full screen
   * restores the column exactly as it was. */
  --ws-tccol-w: 0px;
  /* The slow-request bar, which is the app's ONE channel for *that did not
   * work* and had no box at all in this mode until 2026-09-05. It rides the
   * bottom edge of the topmost chrome row on screen, and here that is the
   * location bar - the one row Jon's own answer keeps. Nothing is reserved
   * and nothing moves: the bar is `position: fixed` and 2px, and its label
   * hangs below it over the text exactly as it hangs below the header
   * normally. **This is why the fix is not a new row** - a reserved band
   * would be close to overturning `everything-but-the-location-bar`. */
  --ws-progress-top: calc(var(--ws-where-h) - 2px);
}

/* **WHAT IS HIDDEN IS EVERY CONTROL ON THE THREE ROWS - AND THEREFORE ALSO
 * EVERYTHING THOSE CONTROLS ARE THE ONLY WAY TO CLOSE.** That second clause is
 * the whole of why the Library panel and the per-half contents are in this
 * list and is not scope creep: `#fold-library` is a child of `#pane` sized by
 * `--ws-tabs-h`, and Contents and Pin are `.ws-tabbar` buttons, so hiding the
 * rows without hiding what they open would strand an open panel over the text
 * with nothing on screen able to dismiss it.
 *
 * **Hidden, never CLOSED.** Not one of these writes a stored state, so leaving
 * full screen puts the reader's own panel, pin and tool row back exactly as
 * they were - which a build that pressed the buttons on entry could not do.
 *
 * **`display: none` and nothing else.** Every element here is either a fixed
 * length that is now `0px` or a flex item; a zero-height box with visible
 * overflow would paint its contents over the first line of the book, which is
 * the failure `.ws-tabbar`'s own `overflow-y: hidden` exists to prevent. */
body[data-fullscreen] .topbar,
body[data-fullscreen] .tabstrip,
body[data-fullscreen] #pane > .fold,
body[data-fullscreen] .ws-tabbar,
body[data-fullscreen] .ws-tabtool,
body[data-fullscreen] .panel,
body[data-fullscreen] .scrim,
body[data-fullscreen] .ws-tabcontents { display: none; }

/* **THE WAY OUT, AND IT IS THE ONE THING ON SCREEN** - Jon chose
 * `menu-row-in-location-bar-out` over a button that would have cost chrome:
 * *"Coming out is a press on the location bar - the one thing you said must
 * stay, so it is always there to press, on the phone as much as the laptop."*
 *
 * **`.ws-where-text`, NEVER `.ws-where`.** The bar itself is
 * `pointer-events: none` and that is load-bearing rather than hygiene: the
 * sash runs `top: 0; bottom: 0` through it and `.ws-drops` is found by
 * `elementFromPoint` during a tab drag. Both of those outrank the bar by
 * `z-index` (2 and 4 against 1), so this cannot swallow either - but the
 * narrow target is what makes that true by construction rather than by an
 * arithmetic about how wide `--ws-measure` happens to be in a half.
 *
 * **Scoped to full screen.** Outside it the bar is a `textContent`-only
 * element that wants no events, and a press on it would do nothing - an
 * affordance offering a refusal. */
body[data-fullscreen] .ws-where-text {
  pointer-events: auto; cursor: pointer;
  -webkit-user-select: none; user-select: none;
}

/* The button that goes IN, on the row Jon named. **Not `.ws-tabbar-menu`** -
 * that class is the pop-down/toggle MEMBERSHIP `syncTabBars`,
 * `syncOpenTabMenus` and the `aria-expanded` sweep all quantify over, and this
 * is neither - and **not `.ws-tabbar-nav`** either, whose click branch reads
 * `data-nav` and would send every press to `navGo`. A third class, taking the
 * nav button's own metrics so the row still reads as one set of controls.
 *
 * `margin-left: auto` puts it at the END of the row and hard against the far
 * edge: it is the only control here that is about the SCREEN rather than about
 * this tab, and the gap says so without a separator. It stays inside the
 * row's sideways scroll, so it is reachable at any zoom (Jon's standing rule,
 * 2026-09-02). */
.ws-tabbar-full {
  border: 0; border-radius: 3px; padding: 0 .3rem;
  background: none; color: var(--ink-sub);
  font: inherit; line-height: 1;
  display: inline-flex; align-items: center;
  cursor: pointer; flex: 0 0 auto; margin-left: auto;
  -webkit-user-select: none; user-select: none;
}
.ws-tabbar-full:hover { background: var(--panel); color: var(--ink); }
.ws-tabbar-full:focus-visible { outline: 2px solid var(--accent);
                                outline-offset: -2px; }
.ws-tabbar-full .ws-undo-icon { width: 1rem; height: 1rem; display: block; }

/* **Item 1 lives in this rule, and it is `.view`'s box with no height.**
 * `max-width` + `margin: 0 auto` is exactly what centres `.view` inside its
 * pane, so the text box lands on the column by construction rather than by an
 * arithmetic that has to agree with one. `text-align: center` then centres the
 * crumb inside it.
 *
 * **The padding is what makes the MARGIN mode right, and it cancels out of the
 * centring.** The centre of a box is `(left + padL + right - padR) / 2`, so
 * only the DIFFERENCE between the two paddings moves it - which is why the
 * phone's narrower `.view` padding needs no counterpart here (it is symmetric,
 * so it moves nothing) while the gutter does (it is not).
 *
 * One line, ellipsised, whatever the book is called: `LLS_LBD` trails run six
 * deep and this is the element that refuses to let that matter. A fixed row
 * height on the parent, never `min-height` - a bar that grows with its content
 * makes the pane's top inset content-dependent, which is R3 all over again. */
.ws-where-text {
  flex: 1; min-width: 0;
  max-width: var(--ws-measure); margin: 0 auto;
  padding: 0 var(--ws-vpad);
  text-align: center;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* The divider. A sibling of the panes, `z-index` above them because
 * `ensurePane` appends panes and they therefore come later in the DOM.
 *
 * `touch-action: none` HERE AND NOWHERE ELSE: on the pane it would kill
 * scrolling with a finger, which is how the phone reads. The 9px box is the
 * hit target — a 1px divider is unhittable with a finger and awkward with a
 * mouse — and the visible line is a 1px pseudo-element down its middle. */
.ws-sash {
  position: absolute; top: 0; bottom: 0; z-index: 2;
  /* **The GAP the engine actually left, not the ratio.** `--ws-seam` is
     written by `publishSeam` from `areaBox`'s own answer, so the divider is
     drawn exactly on the space between the two areas rather than 1.6px beside
     it (Lumino sizes the space remaining after the gap; the ratio does not).
     The fallback is the old arithmetic, which is what a scripting-off session
     and the instant before the first layout both get - and there it is exact,
     because with no engine there is no split. */
  left: var(--s-l, var(--ws-seam, calc(var(--ws-split) - var(--ws-sash-w) / 2)));
  width: var(--s-w, var(--ws-sash-w));
  cursor: col-resize; touch-action: none;
  background: transparent; border: none; padding: 0;
}
.ws-sash[hidden] { display: none; }
.ws-sash::before {
  content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
  width: 1px; margin-left: -.5px; background: var(--line);
}
.ws-sash:hover::before, .ws-sash:focus-visible::before,
.ws-sash.ws-sash-dragging::before { background: var(--accent); width: 3px; margin-left: -1.5px; }
.ws-sash:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* The drop zones — stage 3. Shown only while a tab drag is past its
 * threshold, and above the sash (`z-index: 4` against its 2) so a drag across
 * the divider does not fall into a 9px gap between the two targets.
 *
 * The zones follow the SAME seam the panes do when split, so the half a
 * reader aims at is the half they get; unsplit they are a plain 50/50 of the
 * host, because dropping on the right is what CREATES the split and there is
 * no seam yet to follow.
 *
 * `pointer-events: none` on the container and `auto` on the zones is not
 * hygiene: the pointer is CAPTURED by the tab during a drag, so the zone is
 * found with `elementFromPoint` rather than by hit-testing, and a container
 * that answered that lookup would mask both zones with itself. */
/* -------------------------------------------------------------------------
   THE AREA TREE — Lumino rung three.

   `#ws-areas` is a `SplitPanel` of EMPTY widgets, one per slot. It is the
   answer to *where is a half*: `areaBox` reads a `.ws-parea`'s box and
   `publishArea` mirrors it onto every family that fills that half. Nothing is
   ever put INSIDE one - panes stay direct children of `#pane` - because
   handing a painted pane to a Lumino layout collapses every live `Range` in
   it (0 of 46 of Jon's pens survive, measured), while `isConnected` stays
   true and the app's own repaint rebuilds the registry so that nothing
   reports a fault.

   **`pointer-events: none` IS THE ONLY PROTECTION FOR EVERYTHING THE SERVER
   RENDERED, AND BELT-AND-BRACES ONLY FOR THE PANES.** This comment said tree
   order covers it, and that is true of exactly one family: `.ws-pview` is
   created by `ensurePane` and is therefore a LATER sibling. Every other piece
   of host furniture - `#tabstrip`, `#ws-sash`, `#ws-drops`, both `.ws-where`,
   both `.ws-stale`, both `.ws-tabbar`, both `.ws-tabtool`, both
   `.ws-tabcontents` - is rendered by `shell.html` and is an EARLIER sibling
   than this tree. They are above it only because each carries an explicit
   `z-index` of 1 or more against this rule's `0`. So for the tab strip, the
   tool rows and the contents lists, tree order is the wrong way round and this
   declaration is all there is.

   That correction matters for M27, whose stated reason for mutating two things
   is that either alone suffices: that holds at the point it probes (the
   reading column, where a pane covers) and not elsewhere. Keep the two-part
   mutant - it is still the only way to make the reading-column claim fail -
   and do not delete this line on the strength of the ordering argument. Measured with the tree attached as the host's LAST child
   and this line absent, `elementFromPoint` in the middle of the reading column
   answers `DIV.lm-Widget` instead of `DIV.view`: the whole host under an
   invisible sheet that eats every click, selection and scroll gesture on the
   book. That is where the rule came from and it is real.

   **But in the shipped arrangement it is not reachable on its own**, because
   `buildAreas` runs FIRST in `boot()`, so the tree is the host's first child
   and every pane is a later sibling painting over it. Deleting only this
   declaration changes nothing a reader can feel; deleting only the ordering
   changes nothing either. `verify_panes`' M27 therefore mutates BOTH, and says
   so — a control that removes one of two sufficient protections reports
   success about a page that is fine for the other reason.

   Keep both anyway: the ordering is an emergent property of one call site in
   `boot()` and of nothing ever re-appending this node, and there is a real
   window on every cold load before the first pane exists in which the sheet is
   the only thing over the host. It is `.ws-drops`' own arrangement one rule
   down, and for the same reason.

   **It paints NOTHING.** Upstream gives `.lm-Widget` only `position: relative`
   and the theme colours `.lm-SplitPanel-handle`, which would draw a second
   divider on top of the sash - so the handles are transparent here while the
   sash is the control. Rung three's open design question is what replaces one
   sash when a tree has N-1 dividers; until it is answered, nothing draws two.

   **WHAT KEEPS IT UNDER THE READER IS DOM ORDER, NOT A `z-index` — and this
   comment said `z-index` until it was checked.** `.ws-pview` declares NO
   `z-index` anywhere in this sheet (re-derived by walking the rules, not
   grepped), so it sits at `auto`; `#ws-areas` takes an explicit `0`, which is
   the same painting level, and `buildAreas` attaches the tree at boot BEFORE
   any pane exists, so every pane is later in tree order and paints over it.
   The `0` is therefore a floor that says *never above the reader*, not the
   thing doing the work.

   Which is exactly why `pointer-events: none` is load-bearing on its own and
   was measured rather than reasoned about: painting order would not have
   saved a single click.
   ------------------------------------------------------------------------- */
#ws-areas {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

/* **NOTHING PAINTS THESE, AND THREE `background: none` RULES WERE WRITTEN
   HERE BEFORE THAT WAS CHECKED.** Read out of `lumino-widgets.css` rather than
   assumed: `.lm-SplitPanel-child` gets `z-index: 0`, `.lm-SplitPanel-handle`
   gets `z-index: 1`, a `cursor` and an 8px `:after` hit area - and no
   `background` anywhere in the file except a transparent one on the drag
   image. Our own `lumino-theme.css` colours `.lm-DockPanel-handle`, which is a
   different class and is not in this tree.

   So the overrides were dead, and a rule that cannot do anything reads as
   coverage (L-052). They are gone rather than kept "for safety": the day
   something DOES paint a handle, one sash drawn over one gap will stop being
   the whole story anyway, which is rung three's open design question. */

/* **THE DROP LAYER IS INSET BELOW THE CHROME, BECAUSE EVERY OTHER CHILD OF
   `#pane` ALREADY IS AND THIS WAS THE ONLY ONE THAT WAS NOT.** Measured
   2026-09-22 at 1280x720, one page and no drag: `#pane` runs 42.6-720.0,
   `#tabstrip` and its `.ws-tabgroup` run 42.6-85.2, and `.ws-pview` starts at
   106.8. With `inset: 0` a zone's box began at `#pane`'s own top, so the top
   **42.6px** of every drop zone lay under `.ws-tabgroup` - which is
   `pointer-events: auto` at `z-index: 21` over a control at 4, and paints
   opaquely, so those pixels were neither pressable nor visible. The zone
   ADVERTISED 42.6px of itself that the app could never honour.

   **SAME RULE AS THE LIBRARY OVERLAY ABOVE, SECOND OCCLUDER.** *"An overlay
   may cover the BOOK. It may never cover a CONTROL."* `verify_panes`'
   zone-reachability sweep found this one while measuring the first: it
   reported `div.ws-tabgroup` over one sample row of all three zones, with
   `unpainted` 0 - nothing is clipped, it is covered.

   **THE STACK, NOT A MAGNITUDE.** It is spelled as `.ws-pview`'s own terms
   rather than as the 64.2px they came to that day: fitting the number would
   hard-code a magnitude against a moving subject, and the strip grows a row
   whenever the chrome does. `--a-t` is deliberately NOT here - that is a
   per-AREA inset written on `.ws-pview` by `syncAreas`, and `publishZones`
   publishes only `--z-l`/`--z-w`, so this layer has no per-slot vertical
   term to read.

   **FOUR LONGHANDS, NEVER `inset`** - `.ws-pview`'s own block carries the
   reason, and it applies here the moment anyone adds an element-scoped
   property to this rule. */
.ws-drops {
  position: absolute;
  top: calc(var(--ws-tabs-h) + var(--ws-tabbar-h) + var(--ws-tabtool-h)
            + var(--ws-where-h) + var(--ws-stale-h));
  right: 0; bottom: 0; left: 0;
  z-index: 4; pointer-events: none;
}
.ws-drops[hidden] { display: none; }
.ws-drop {
  position: absolute; top: 0; bottom: 0; pointer-events: auto;
  display: flex; align-items: center; justify-content: center;
  border: 2px dashed transparent; border-radius: 6px;
  color: var(--dim); font-size: .85rem; letter-spacing: .02em;
  background: color-mix(in srgb, var(--pane) 70%, transparent);
}
/* **ONE RULE FOR EVERY ZONE, and the geometry is PUBLISHED per element.**
   This was four rules naming two slots and one unindexed seam - a shape that
   is not expressible past two panels, exactly as the family seam rules were
   not (`areaBox`'s docstring). `static/app.js` `publishZone` writes `--z-l`
   and `--z-w` on each zone; the rule below is the only consumer.

   **The FALLBACK is an equal share of the vocabulary, and choosing it is
   L-329's per-state question rather than a default anyone picked.** The zones
   are `hidden` until a drag arms and the publisher runs BEFORE `hidden` is
   cleared, so there is no state in which a zone is visible and unpublished -
   the fallback is for the one that is not supposed to happen. An equal share
   is the honest answer there because it is what the app draws when it knows
   nothing about occupancy, it tiles the host with no overlap at any N, and it
   reproduces today's unsplit geometry exactly at two. **Zero is not an option
   here**: a zone of no width has no drag target and nothing in the console,
   which is the failure L-330 is about.

   `--ws-zones` is stamped on `.ws-drops` by the same publisher, so the
   fallback divides by the number of zones there actually are rather than by a
   number written twice. */
.ws-drop {
  left: var(--z-l, calc(100% * var(--z-i, 0) / var(--ws-zones, 2)));
  width: var(--z-w, calc(100% / var(--ws-zones, 2)));
}
/* Not a valid target for THIS drag: the half the tab is already in, or either
 * half when the store would refuse the move. Dimmed and inert rather than
 * removed, so the reader can see the geometry they are dragging within. */
.ws-drop.ws-drop-off { opacity: .35; border-color: transparent; }
.ws-drop.ws-drop-over {
  border-color: var(--accent); color: var(--accent);
  background: color-mix(in srgb, var(--accent-soft) 80%, var(--pane));
}
/* While a tab is being dragged: the tab itself reads as picked up, and no
 * text selection can start anywhere.
 *
 * **AND THE OVERLAY PANEL GOES UNDER THE ZONES, BECAUSE A DROP ZONE IS A
 * CONTROL.** This sheet states the rule itself, above `.panel`'s overlay
 * block: *"An overlay may cover the BOOK. It may never cover a CONTROL."*
 * Unpinned, `.panel.left` is `position: fixed` at `--panel-overlay-z` (20)
 * and `.ws-drops` is at 4, so the library painted over - and TOOK THE PRESS
 * for - the left **320px** of every zone that starts at the host's left edge.
 *
 * **MEASURED, 2026-09-22, at 1512px with the library open**: zone 0 ran
 * `0-499.5px` and only **180px of it could be pressed - 36%**. Its own centre
 * (249.75px) resolved to an `a.shelf-item`, so `zoneAt` answered null and the
 * drop did nothing at all: no move, no zone lit, nothing in the console. The
 * zone's LABEL is centred too, so *Left half* was behind the panel as well.
 *
 * **IT IS OLDER THAN THE THREE-PANEL WIDENING AND THAT IS THE POINT.** At two
 * slots zone 0 ran `0-751.5px` with **432px (57%)** pressable - covered in
 * exactly the same way, but with its centre clear, so every probe landed on
 * the working part. The widening did not break this; it spent the margin that
 * was hiding it.
 *
 * **LOWERING THE PANEL, NOT RAISING THE ZONES.** There is no integer between
 * `.panel`'s 20 and `.tabstrip`'s 21, and the strip is deliberately above the
 * zones (`.tabstrip`'s own comment: a release over the other half's strip
 * does nothing, and the dragged tab stays visible instead of sitting under
 * the zones' veil). Lowering touches exactly the one pair at issue.
 *
 * The reader sees the library greyed under the zone tint for the length of a
 * drag, which is the affordance telling the truth: the whole host is a drop
 * surface, and it now looks like one. */
body.ws-dragging-tab { user-select: none; --panel-overlay-z: 3; }
body.ws-dragging-tab .ws-tab.ws-tab-dragging { opacity: .5; }

.loading, .hint { color: var(--dim); font-size: .85rem; padding: 1rem; }
/* **The server-rendered placeholder clears the tab strip's row, and this rule
 * only exists for a reader with JavaScript OFF.** With scripting on, the first
 * `activatePane` removes both of these; with it off nothing ever does, and an
 * absolutely-positioned strip at `top: 0` would paint over the first line of
 * whichever one is showing - `.loading` carries 1rem of padding against the
 * strip's whole row, so "Loading…" would sit behind the tabs.
 *
 * Scoped `#pane >`, because `.loading` and `.empty-state` are used by the
 * library panel and by several standalone pages and must keep their own
 * geometry there. This is the same JS-off trap L-079 was: a shell-only
 * assumption reaching a page that has no shell. */
#pane > .loading, #pane > .empty-state { margin-top: var(--ws-tabs-h); }

/* **It clears the tab strip's row, and that offset exists only for the reader
 * it is addressed to.** `<noscript>` renders exactly when scripting is off,
 * which is exactly when the strip's `<a href>` labels and its `+` form are the
 * only tab affordances there are — nothing will ever come and re-draw them.
 * This banner is `z-index: 40` against the strip's 21, and it starts where the
 * workspace starts, so without the offset it would cover most of the row it is
 * telling the reader about. The strip was in `.topbar` (z-index 30) until
 * 2026-08-16 and was never underneath it; keeping that true is the point.
 *
 * It still spans the viewport rather than the pane, so the band it leaves
 * crosses the two panels as well. That is cosmetic and deliberate: matching
 * `#pane`'s edges would mean republishing them, which is the thing `--ws-split`
 * exists to avoid (L-017). */
.noscript-note {
  position: fixed; top: calc(var(--topbar-real) + var(--ws-tabs-h));
  left: 0; right: 0; z-index: 40;
  margin: 0; padding: .6rem 1rem; background: var(--accent-soft);
  border-bottom: 1px solid var(--accent); font-size: .85rem;
}

/* ------------------------------------------------- the update banner
 *
 * A quiet strip when the server has moved on past the page in front of the
 * reader. `noteServedRevision` in `static/app.js` is the only thing that
 * un-hides it, and its comment carries the reasoning; this rule is only its
 * shape.
 *
 * **FIXED AT THE BOTTOM, AND IT INSETS NOTHING.** Every other permanent row in
 * this app owns a variable (`--topbar-h`, `--ws-tabs-h`, `--ws-where-h`,
 * `--ws-tabtool-h`) because something lays out around it, and every one of them
 * is a FIXED height for R3's reason - a row that grows with its content makes
 * the reading pane's inset content-dependent. This one has no variable at all,
 * which is stronger than a fixed one: nothing measures it, nothing insets
 * around it, so no amount of text in it can move a line of the book. It is the
 * `.noscript-note` shape without the offset, because it is not covering
 * anything a reader needs.
 *
 * **`z-index: 35`, and both neighbours are the reason.** Above `.topbar`'s 30 -
 * the highest thing in the ordinary chrome - so nothing the app paints can
 * bury the one message telling the reader their page is stale, which is the
 * failure `.ws-tabgroup` produced for the fold buttons at `z-index: 1`. Below
 * `.popover`'s 60 and `.ws-tabctx`'s 72, because those are transient and the
 * READER opened them: a menu somebody just asked for outranks a notice nobody
 * did. `.noscript-note`'s 40 is not a conflict - it renders exactly when
 * scripting is off, which is exactly when nothing can un-hide this.
 *
 * **NO `display` ON A SELECTOR THAT ALSO MATCHES THE HIDDEN ELEMENT**
 * (L-024). An author `display` beats the user agent's
 * `[hidden] { display: none }` regardless of specificity - that is how
 * `.ws-settings-row` came to paint at 71px while reporting itself hidden, and
 * how a `display` beside the Contents columns would have shown every collapsed
 * subtree in the library. This sheet's own global rule carries `!important`,
 * which would in fact win, but relying on that puts a feature behind a detail
 * of a rule three thousand lines away. `:not([hidden])` cannot match the
 * hidden element BY CONSTRUCTION, so the row lays out when it is shown and
 * there is nothing for the cascade to arbitrate when it is not.
 *
 * `max-width` plus `margin: auto` so it is a card on a laptop and near
 * full-width on a phone, with no media query to keep in step. */
.ws-update {
  position: fixed; z-index: 35;
  left: .5rem; right: .5rem; bottom: .5rem;
  max-width: 30rem; margin: 0 auto;
  align-items: center; gap: .5rem; flex-wrap: wrap;
  padding: .5rem .7rem;
  background: var(--accent-soft); color: var(--ink);
  border: 1px solid var(--accent); border-radius: 5px;
  box-shadow: 0 4px 18px rgba(0,0,0,.28);
  font-size: .85rem;
}
/* `display` belongs on a selector that cannot be the hidden one, so the row
 * lays out when shown and the global `[hidden]` rule still wins when it is
 * not. */
.ws-update:not([hidden]) { display: flex; }

.ws-update-said { flex: 1 1 12rem; }

.ws-update button {
  font: inherit; padding: .25rem .6rem; cursor: pointer;
  border: 1px solid var(--line-ctl); border-radius: 4px;
  background: var(--pane); color: var(--ink);
}
/* The action the banner exists to offer, so it reads as the primary of the
 * two. The other is a dismissal and is deliberately quieter. */
.ws-update .ws-update-go { border-color: var(--accent); font-weight: 600; }
.ws-update .ws-update-later { color: var(--dim); }

.empty-state { padding: 3rem 2rem; color: var(--dim); max-width: 32rem; }
.empty-state h1 { font-size: 1.3rem; color: var(--ink); margin: 0 0 .4rem; }

/* **A PANE THAT COULD NOT LOAD, SAID IN A PERSON'S WORDS.** The sentence is
 * `--ink` and not `.empty-state`'s `--dim`, because it is the thing the reader
 * is meant to read; the technical line under it keeps the quiet colour, which
 * is the ranking `static/app.js` builds and this only paints. */
.pane-fail-say { color: var(--ink); max-width: 34rem; }
.empty-state .detail {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .78rem; color: var(--dim); word-break: break-word;
}
/* The same control the update banner offers, for the same reason: a button
 * that does the obvious thing beats a sentence telling the reader to do it. */
.pane-fail-retry {
  font: inherit; padding: .3rem .8rem; cursor: pointer;
  border: 1px solid var(--accent); border-radius: 4px;
  background: var(--pane); color: var(--ink);
}

/* --------------------------------------------------------- library panel */

.find { margin: 0 0 .5rem; }
.find input {
  width: 100%; padding: .4rem .5rem; font-size: .85rem;
  border: 1px solid var(--line); border-radius: 4px;
  background: var(--pane); color: var(--ink);
}

.shelf-count { font-size: .78rem; color: var(--dim); margin: .3rem 0 .5rem; }
.shelf-count strong { color: var(--ink); }

.linkish {
  border: none; background: none; color: var(--accent);
  cursor: pointer; font-size: .78rem; padding: 0; text-decoration: underline;
}

.shelf-list { list-style: none; margin: 0 0 .8rem; padding: 0; }
/* **THE ROW THE BROWSER'S FILTER PUTS AWAY.** `#q` narrows the library in the
 * page now (Jon, `library-filter-keeps-shelves` -> `keep-the-shelves`), and it
 * does it by setting `hidden` on the `<li>`.
 *
 * **This rule exists so that hiding cannot come to depend on the absence of an
 * author `display`.** `[hidden] { display: none }` is a USER-AGENT rule and ANY
 * author `display` beats it regardless of specificity - which is how a settings
 * row once painted at 71px while reporting itself hidden (L-024). The `<li>`
 * has no author display today; `.shelf-item` and `.owned-item` inside it are
 * `display: flex`, so the trap is one element away. Stated here, it stays shut. */
.shelf-list > li[hidden] { display: none; }
.shelf-list .empty { color: var(--dim); font-size: .8rem; padding: .5rem 0; }

/* **ONE RULE, TWO CONSUMERS IN ITS SELECTOR LIST** — the idiom this sheet
 * already uses for the margin gutter, and for the same reason: a book on this
 * disk and a book Jon owns but has not opened are the same row to a reader, and
 * two rules that must agree are two rules that will not. `.shelf-item` is an
 * `<a>` and `.owned-item` is a `<form>`; everything below that line is shared. */
.shelf-item, .owned-item {
  display: flex; gap: .5rem; padding: .35rem .3rem; border-radius: 4px;
  color: inherit; align-items: flex-start;
}
.shelf-item:hover, .owned-item:hover {
  background: var(--accent-soft); text-decoration: none;
}
/* A form carries a user-agent margin and does not fill its `li`. */
.owned-item { margin: 0; width: 100%; }
/* The action sits at the END of the row, so the eye runs title -> state ->
 * what it can do about it. `margin-left: auto` rather than `justify-content`,
 * because the meta block must keep shrinking (it is the ellipsised one) and a
 * space-between would let the title steal the button's width first. */
.owned-get, .owned-no {
  margin-left: auto; align-self: center; flex: 0 0 auto;
  font-size: .72rem; white-space: nowrap;
}
/* Not a `.blindspot`: a book you have not opened yet is not a fault, it is the
 * library being bigger than the disk. `.sg-whole`'s own argument — dressing a
 * normal state in the red voice teaches the reader to read the red as noise. */
.owned-no { color: var(--dim); font-style: italic; }
/* The `si-meta` column must be allowed to shrink or the row cannot ellipsise:
 * a flex item's automatic minimum size is its content, which is the same
 * mechanism `.panel-body` needs `min-height: 0` for one screen up. */
.owned-item .si-meta { flex: 1 1 auto; }

.thumb {
  width: 2.1rem; height: 2.9rem; object-fit: cover; flex: 0 0 auto;
  border: 1px solid var(--line); border-radius: 2px; background: var(--bg);
}
.thumb.nocover { display: inline-block; }

.si-meta { display: flex; flex-direction: column; min-width: 0; }
.si-title {
  font-size: .82rem; font-weight: 600; line-height: 1.25;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.si-author, .si-stats {
  font-size: .72rem; color: var(--dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* The blind-spot line is not decoration — it is the reason the panel is
 * trustworthy. It never gets smaller than the values it qualifies. */
/* Shelves - Jon's own groupings of the library, one foldable group each.
 *
 * `.shelfgrp` is a `<details>`, so open/closed is the element's own state and
 * nothing here declares `display` on the body: `[hidden] { display: none }` is
 * a USER-AGENT rule that any author `display` beats regardless of specificity
 * (L-024), and this project has already been bitten by that twice - once on
 * the Contents columns and once on a settings row that read `hidden` and
 * painted at 71px. There is nothing to beat here and nothing that wants to. */
/* `.ownedgrp` shares the LOOK and deliberately not the CLASS — see
 * `_panel_library.html`. `.shelfgrp` is a drop target; this is not. */
.shelfgrp, .ownedgrp { border-top: 1px solid var(--line); }
.shelfgrp summary, .ownedgrp summary {
  cursor: pointer; font-size: .8rem; font-weight: 600;
  display: flex; justify-content: space-between; align-items: baseline;
  padding: .4rem 0;
}
.sg-n { color: var(--dim); font-weight: 400; font-size: .72rem; }
/* The entire-library shelf's own sentence - Jon's `allow-empty`, 2026-08-22.
 *
 * **Deliberately NOT `.blindspot`.** That class carries a `--accent` left rule
 * and is this panel's voice for *something here is wrong* - a shelf whose rule
 * will not run, a store that would not open. A shelf holding every book is not
 * a fault, it is the shelf working exactly as asked, and dressing the two the
 * same would teach the reader to read the red as noise (L-014). So: the dim
 * secondary voice `.sg-n` already uses, with no rule and no accent. */
/* `.sg-owned-note` shares this rule rather than restating it: it is the same
 * voice saying the same kind of thing — a plain fact about the group, in the
 * dim secondary tone, with no accent rule. */
.sg-whole, .sg-owned-note {
  font-size: .72rem; color: var(--dim); margin: 0 0 .35rem;
}
/* A shelf's own three controls, and they were too small to FIND - Jon,
 * 2026-08-19: *"I don't think I can rename the shelves yet either."* It has
 * been there since shelves shipped, beside `edit filter` and `remove shelf`.
 *
 * Measured at `8115820`, identically on the desktop and on his phone: each
 * button was **12px tall** in **11.2px** text, `rename` **38x12**. This app's
 * own thumb target is `--ws-tabs-h` — 45.59px on a phone, the size the two
 * fold buttons were given for exactly this reason — so the control he could
 * not find was **3.8x under the standard this file already sets**, on the
 * device he reads on. `.7rem` here was shrinking `.linkish`'s own `.78rem`
 * further, which is the wrong direction for the only controls in the group.
 *
 * The shrink is gone and the row is padded, so the target is the row's height
 * rather than the glyph's. `align-items: center` because the buttons are now
 * taller than their text. They stay link-styled: they sit inside a shelf and
 * three filled buttons per shelf would compete with the books, which are what
 * the panel is for. */
.sg-tools {
  margin: 0 0 .25rem; display: flex; gap: .6rem; align-items: center;
  flex-wrap: wrap;
}
.sg-tools .linkish { padding: .3rem 0; }
@media (max-width: 34rem) {
  /* The phone gets the app's own thumb target rather than a number of its
     own - `--ws-tabs-h` is what the fold buttons use, so a change there moves
     this with it instead of leaving a second copy behind (L-017). */
  .sg-tools { gap: 1rem; }
  .sg-tools .linkish {
    min-height: var(--ws-tabs-h);
    display: inline-flex; align-items: center;
  }
}
/* **The order control, Jon's `sort-per-shelf`.** It rides in `.sg-tools`,
 * which already wraps, so on a 320px panel it takes its own line rather than
 * squeezing the four buttons beside it - the topbar's 545-799px lesson, where
 * a flex row with no elastic item does not compress, it overflows.
 *
 * The `<select>` is `max-width: 100%` and `min-width: 0` for the same reason:
 * a select's automatic minimum size is its widest option, and "Library order
 * (A-Z)" would otherwise push the row wider than the panel the reader dragged.
 * The phone gets the app's own thumb target from `--ws-tabs-h`, the number
 * `.sg-tools .linkish` already uses, rather than a second copy of it. */
.sg-order { display: inline-flex; align-items: center; gap: .35rem;
            min-width: 0; max-width: 100%; }
.sg-order-label { color: var(--dim); font-size: .78rem; }
.sg-order select { font: inherit; font-size: .78rem; min-width: 0;
                   max-width: 100%; }
@media (max-width: 34rem) {
  .sg-order select { min-height: var(--ws-tabs-h); }
}

/* A caveat, not a result: it says why some books are in a heap at the end.
 * Same amber the diagnostics page uses for the third verdict. */
.sg-unordered { margin: 0 0 .3rem; color: #8a6a1e; font-size: .78rem; }
@media (prefers-color-scheme: dark) { .sg-unordered { color: #d8b45c; } }

.shelf-save-row { margin: 0 0 .5rem; }

/* **"Add these N to a shelf you already have" - Jon's second complaint.**
 *
 * A `<label>`, a `<select>` and a button on one wrapping row. It WRAPS rather
 * than compressing, because the panel is 320px by default and draggable down
 * to nothing sensible: a row of three rigid items in a narrow box is the
 * topbar's own 545-799px lesson, where a flex row with no elastic item does
 * not compress, it overflows - and here it would overflow a `.panel` that
 * clips.
 *
 * No `display` declaration on the row itself beyond the flex: the row exists
 * only while filtering, so there is no hidden state for `[hidden]` to fight
 * over (L-024). */
.shelf-add-row {
  display: flex; flex-wrap: wrap; align-items: center; gap: .35rem;
  margin: 0 0 .5rem; font-size: .78rem; color: var(--dim);
}
/* **THIS RULE IS LOAD-BEARING AND THE `display: flex` DIRECTLY ABOVE IT IS
 * WHY.** Since 2026-09-07 the server renders this row in every panel and the
 * browser reveals it (`filterLibrary`), because a row that only exists in one
 * of the server's two renderings is a row the browser cannot reveal — which
 * was the defect that sent the first build of the in-page filter back (L-441).
 *
 * `[hidden] { display: none }` is a USER-AGENT rule and ANY author `display`
 * beats it regardless of specificity (L-024), so without this line the row
 * would paint at full height on a cold panel — offering *"add these 0 to a
 * shelf"* — while `el.hidden` reported true and every DOM check agreed with
 * it. Stated beside the `display` that causes it, not in a block elsewhere,
 * because the next author to add a `display` here is the one who needs it. */
.shelf-add-row[hidden] { display: none; }
.shelf-add-row select {
  font: inherit; color: var(--ink); background: var(--pane);
  border: 1px solid var(--line); border-radius: 3px; padding: .2rem .25rem;
  /* The longest shelf name must not push the button off the row; it
     ellipsises instead, and the full name is in the option list. */
  max-width: 11rem;
}
.shelf-add-row .linkish:disabled {
  color: var(--dim); opacity: .55; cursor: default; text-decoration: none;
}
.shelf-hint { margin-top: .5rem; }
/* The loose group is not a shelf and should not read as one - it is where the
   ungrouped books are, and it must never look like something to close and
   forget. */
.loosegrp > summary .sg-name { font-weight: 500; color: var(--dim); }

.facet { border-top: 1px solid var(--line); padding: .35rem 0; }
.facet summary {
  cursor: pointer; font-size: .8rem; font-weight: 600;
  display: flex; justify-content: space-between; align-items: baseline;
}
.fcount { color: var(--dim); font-weight: 400; font-size: .72rem; }

.blindspot {
  font-size: .72rem; color: var(--dim); margin: .35rem 0 .4rem;
  padding: .3rem .4rem; background: var(--bg); border-radius: 3px;
  border-left: 2px solid var(--accent);
}
.blindspot strong { color: var(--accent); }

.values { list-style: none; margin: 0; padding: 0; }
.values li { font-size: .76rem; }
.values label {
  display: flex; align-items: baseline; gap: .3rem;
  padding: .1rem 0; cursor: pointer;
}
.values .v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
             white-space: nowrap; }
.values .v.none { font-style: italic; color: var(--dim); }
.values .n { color: var(--dim); font-variant-numeric: tabular-nums; }
.values .more, .omore {
  color: var(--dim); font-size: .72rem; font-style: italic; padding: .2rem 0;
}

/* **THE WHOLE LIST, SCROLLING, WITH A BOX TO NARROW IT** — Jon,
 * `facet-reach-where` -> `panel-now`, 2026-09-02: *"I cant scroll through my
 * authors, so i dont know why thats not available, not really done well."*
 * He could reach 12 of 519.
 *
 * `.full` and not `.values`: the short list the panel ships with is 12 rows
 * and must keep sizing itself, so only the list `/panel/library/facet` returns
 * gets a scroller. `max-height` in `rem` rather than a fraction of anything -
 * this box sits inside `.panel-body`, which is itself the panel's scroller,
 * and a percentage there resolves against a height that is already scrolling.
 *
 * **`overscroll-behavior: contain` is load-bearing on the phone Jon reads
 * on.** Without it, a flick that reaches the end of the author list carries
 * on into the panel behind it, so the reader loses their place in the list
 * they just scrolled to - the one gesture this feature exists to give them. */
.values.full {
  max-height: 15rem; overflow-y: auto; overscroll-behavior: contain;
}
.vfind { display: flex; align-items: baseline; gap: .35rem; margin: .1rem 0 .35rem; }
.vfind input {
  flex: 1; min-width: 0; padding: .2rem .35rem; font-size: .76rem;
  border: 1px solid var(--line); border-radius: 3px;
  background: var(--pane); color: var(--ink);
}
.vfind-n {
  color: var(--dim); font-size: .7rem; white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
/* **A CLASS, NOT THE `hidden` ATTRIBUTE.** `[hidden] { display: none }` is a
 * user-agent rule and any author `display` beats it whatever its specificity,
 * so a row can report itself hidden and paint at full height. One mechanism
 * here, and it is an author rule. */
.values li.vhide { display: none; }
.values .vnone {
  color: var(--dim); font-size: .72rem; font-style: italic; padding: .2rem 0;
}
.omore-btn {
  border: 1px dashed var(--line); background: transparent; color: var(--dim);
  border-radius: 3px; font-size: .72rem; font-style: italic; cursor: pointer;
  padding: .15rem .4rem; width: 100%; text-align: left;
}
.omore-btn:hover { color: var(--accent); border-color: var(--accent); }

/* --------------------------------------------------------- outline panel */

.ohead { display: flex; flex-direction: column; }
.ohead-title { font-size: .82rem; font-weight: 600; }
.ohead-counts { font-size: .72rem; color: var(--dim); }

.odepth { display: flex; align-items: center; gap: .25rem; margin-top: .4rem; }
.odepth-label { font-size: .72rem; color: var(--dim); }
.odepth-btn {
  border: 1px solid var(--line); background: transparent; color: var(--dim);
  border-radius: 3px; font-size: .72rem; padding: .1rem .35rem; cursor: pointer;
  display: flex; align-items: baseline; gap: .2rem;
}
.odepth-btn:hover { color: var(--ink); border-color: var(--dim); }
/* The count that a depth would show, on the control, before the click. */
.odepth-n { font-size: .62rem; color: var(--dim); font-variant-numeric: tabular-nums; }

.outline-level { list-style: none; margin: 0; padding: 0 0 0 .55rem; }
.panel-body > .outline-level { padding-left: 0; }

.orow { display: flex; align-items: baseline; gap: .15rem; }
.ocaret {
  border: none; background: none; color: var(--dim); cursor: pointer;
  font-size: .7rem; width: 1rem; flex: 0 0 1rem; padding: 0; text-align: left;
  transition: transform .12s;
}
.ocaret.leaf { cursor: default; }
.ocaret[aria-expanded="true"] { transform: rotate(90deg); }

.olabel {
  font-size: .78rem; color: inherit; padding: .1rem 0; flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.olabel:hover { color: var(--accent); }
.onode.on-trail > .orow > .olabel { color: var(--accent); font-weight: 600; }
.onode.current > .orow > .olabel { background: var(--accent-soft); }

/* **A BIBLE'S CONTENTS, COLOURED BY SECTION - Jon, 2026-09-25,
 * `s-7d2013d6a166086b`:** the background behind each book, and its chapters
 * when opened, by the part of the Bible it belongs to; Psalms a stronger
 * shade of Poetry and Wisdom. `data-section` comes from `bible_sections`
 * (keyed on core's book number, never a display name) and is on every Bible
 * row always; it PAINTS only under the reader's global switch on `<body>`,
 * so turning it off is a stylesheet change and no level is refetched.
 *
 * **The tint is the hue MIXED INTO `--panel`, never laid over it**, for two
 * reasons: every theme redefines `--panel`, so one mix follows light, sepia
 * and dark with no per-theme table; and the result is OPAQUE, which the
 * pinned open book below needs - a translucent tint would let chapter rows
 * show through it as they scroll under. `--osec-bg` inherits, so a chapter
 * row inside a coloured book takes the book's tint with no attribute of its
 * own; the current-place mark paints `.olabel`, not `.orow`, so it still
 * shows on top. */
.onode[data-section="law"]        { --osec: #c9a227; }
.onode[data-section="history"]    { --osec: #b5652b; }
.onode[data-section="wisdom"]     { --osec: #4f9a5a; }
.onode[data-section="psalms"]     { --osec: #4f9a5a; --osec-mix: 34%; }
.onode[data-section="major"]      { --osec: #6a4fc0; }
.onode[data-section="minor"]      { --osec: #b04fa8; }
.onode[data-section="gospels"]    { --osec: #c0392b; }
.onode[data-section="acts"]       { --osec: #d9822b; }
.onode[data-section="pauline"]    { --osec: #2f6fb5; }
.onode[data-section="general"]    { --osec: #2a9d9d; }
.onode[data-section="revelation"] { --osec: #8e2a4a; }
body[data-toc-sections="on"] .onode[data-section] {
  --osec-bg: color-mix(in srgb, var(--osec) var(--osec-mix, 18%), var(--panel));
}
.onode[data-section] .orow { background: var(--osec-bg, transparent); }

/* A COMPOSITION'S OUTLINE WEARS THE BOOK OUTLINE'S CLOTHES - 2026-09-20,
   `s-f550631c89264c6b`. Two rules, and both exist because the row is a
   BUTTON rather than an anchor.

   **A button, because there is nowhere to link to.** A book's row is an
   `<a href="/r/<slug>/a/<n>">` that works with scripting off; a heading
   inside a composition has no URL - it is a position in a document that
   lives in the editor. A `<a href="#">` would be a link that navigates the
   whole document and takes the workspace with it, which is the four-shapes
   rule (L-355) and has shipped broken here three times.

   So `.olabel` supplies the look and this supplies only the button reset:
   font-size, colour, padding, flex and the ellipsis all still come from
   `.olabel` above, which is why this rule sets none of them. It is placed
   AFTER `.olabel` deliberately - equal specificity, so source order decides,
   and nothing here should win a property `.olabel` names. */
.olabel-btn {
  border: none; background: none; cursor: pointer; font-family: inherit;
  text-align: left;
}

/* **THE INDENT IS THE HEADING'S LEVEL, and it is a custom property rather
   than nested `<ul>`s.** The book outline nests a level per `<ul>` because
   the server sends one level at a time and a node's children arrive on
   expand. A composition's heading tree is small, entirely present, and
   flat in the document - `Heading 3` after `Heading 1` with no `Heading 2`
   between them is a thing a writer can legitimately type - so nesting would
   mean inventing parents that are not there.

   `--cdepth` is 1..6 and is written by `fillComposerOutline` from
   `composerHeadingLevel`, the ONE producer. A row whose level could not be
   named carries NO `--cdepth`, and the fallback below puts it at the left
   margin rather than guessing a depth - `s-ab7a2a0365a9cec4`. */
.outline-level[data-composition] > .onode {
  padding-left: calc((var(--cdepth, 1) - 1) * .7rem);
}

.okids {
  font-size: .66rem; color: var(--dim); font-variant-numeric: tabular-nums;
  flex: 0 0 auto;
}

/* **THE OPEN BOOK'S ROW STAYS ON SCREEN — Jon, 2026-08-16:** *"this would also
 * benefit to have a little indicator of which book is there (for bibles)
 * because otherwise its just a list of chapters sometimes with nothing else
 * visible."*
 *
 * Measured: with Contents open on Legacy Standard Bible, the JEREMIAH row plus
 * about 16 CHAPTER rows fill the panel — scroll on and the book row is gone,
 * leaving an unlabelled column of chapter numbers. (Part of what he was seeing
 * was a bug, since fixed: the tab strip was painting over the panel's top
 * 45.59px, which is where the book row was. Scrolling loses it anyway.)
 *
 * **`.orow`, never the `li`, and that is what makes it a section header for
 * free.** A sticky box is clamped to its containing block. All 69 book `li`s
 * share one — the root `<ul>` — so a sticky `li` pins every book at the same
 * offset and they pile up. A sticky `.orow`'s containing block is its own
 * `li.onode`, which by construction spans the book's row PLUS its expanded
 * chapters, because `expand()` appends the children `<ul>` inside that same
 * `li`. So the row pins for exactly the run of its own chapters and releases
 * when the next book arrives. No new element, no duplicated row, and no second
 * producer of "which book" (L-017) — it is the row that was already there.
 *
 * **`.open`, not `:has()`.** Only an expanded book should pin; giving all 69 a
 * background and a full-bleed band would change the panel's look for a state
 * where nothing is scrolling under anything. `expand()` in `static/app.js` is
 * the ONE writer of "this node is open" — it is the only place `kids.hidden`
 * and the caret's `aria-expanded` are set, and `expandTrail` reaches it too —
 * so it toggles the class there. `:has(> ul.outline-level)` would say the same
 * thing and would put this feature behind `:has()` support on a phone browser
 * this project has already flagged as an unknown.
 *
 * **Top level only.** Unscoped, every depth sticks and the chapter rows pin as
 * well. `.panel-body > .outline-level` is the existing idiom for "the root
 * level" one rule above.
 *
 * **`background` is not optional** — the file's own sticky note on
 * `.ws-settings-head` says so, and without it the chapters show straight
 * through the pinned row. The negative margins bleed the band to the
 * scrollport's edges and the padding puts the content back, so nothing passes
 * beside it: `.panel-body` is inset `.6rem` and `.outline-level`'s own
 * `padding-left` is `.55rem`, which the root level zeroes — hence `.6rem` on
 * both sides rather than a different value per side.
 *
 * **`z-index: 1` is safe in both regimes and the geometry is why, not the
 * number.** Above 55rem `.panel` is `position: static` with `z-index: auto` and
 * creates NO stacking context, so this competes in the ROOT one — the trap
 * `.tabstrip` documents at `z-index: 21`. It is safe there because the panel and
 * `#pane` are disjoint in-flow boxes and nothing overlaps. Below 55rem
 * `.panel` is `position: fixed; z-index: 20`, a stacking context, and this
 * becomes local. */
/* **`top: -.6rem`, and it is measured rather than tidy.** Chromium clamps a
 * sticky box to the scrollport's CONTENT edge, so `top: 0` pinned the row at
 * `bodyTop + 9.6px` — `.panel-body`'s own padding — leaving a 9.6px band above
 * it in which chapter rows slid past the pinned book. `-.6rem` cancels exactly
 * that padding, so the band closes and the row sits flush under the head.
 *
 * It is on `top` and NOT a negative `margin-top`: a margin would pull the row up
 * against its previous sibling in normal flow as well, changing the layout of
 * every unpinned book. `top` is consulted only while the box is stuck. */
.panel-body > .outline-level > .onode.open > .orow {
  position: sticky; top: -.6rem; z-index: 1;
  /* A coloured Bible book keeps its section tint when pinned; `--osec-bg` is
   * opaque by construction (see the section block above). */
  background: var(--osec-bg, var(--panel));
  margin: 0 -.6rem; padding: .1rem .6rem;
  border-bottom: 1px solid var(--line);
}

/* **A LONG LIST OF CHAPTERS GETS COLUMNS — Jon, 2026-08-16:** *"when there are
 * so many chapters they are such a long list, then they should take up some
 * multiple columns, at least two, and if wide enough, more."*
 *
 * `column-width`, not `column-count`, and that IS his sentence: the count falls
 * out of whatever width the level has, so "at least two" and "if wide enough,
 * more" are one declaration instead of a table of breakpoints to maintain.
 *
 * **`7rem`, and the first draft's `9rem` produced ONE column on the laptop.**
 * `column-width` is a minimum, and the count is
 * `floor((W + gap) / (width + gap))` — so at the desktop panel's 299.8px level,
 * 144px gives `floor(312.6 / 156.8)` = **1.99 → 1**, missing two columns by half
 * a pixel while 390px's 315.2px level gave 2. Measured, not reasoned: 52 rows in
 * one 1,139.1px column at 1000px and two 26-row columns at 390px, from the same
 * declaration.
 *
 * 7rem = 112px is sized from the NARROWEST level this rule can reach — a 320px
 * viewport, where the panel is `min(86%, 21rem)` = 275.19px and a nested level
 * has 246.2px: `2 x 112 + 12.8 = 236.8` fits with 9.4px to spare. The columns
 * then WIDEN to fill (they are equal and share the whole box), so the label
 * always gets the full share: 116.7px at 320px and 143.5 on the laptop, against
 * `CHAPTER 34`'s measured **77.6px** of ink with the caret collapsed. A third
 * column needs 361.6px of level, which no panel reaches today — so this is
 * exactly "at least two everywhere, more if the panel is ever widened", and the
 * widening is Jon's call (`research/contents-panel-2026-08-16.md` §4).
 *
 * **Which levels, and why the server decides:** `data-cols` comes from
 * `app._level_is_columnable` — enough rows, no expandable row, short labels.
 * CSS can express none of those three. See that function for the numbers and
 * `templates/_outline.html` for the attribute's contract.
 *
 * **NO `display` DECLARATION HERE, EVER.** `expand()` collapses a level with
 * `kids.hidden = !open`, and `[hidden] { display: none }` is a USER-AGENT rule
 * that ANY author `display` beats regardless of specificity (L-024, which has
 * already cost this project a stuck progress bar). A `display: block` alongside
 * these columns would make every collapsed subtree in the library permanently
 * visible. `columns`/`column-width`/`column-count`/`column-gap` alone are safe.
 *
 * **`break-inside: avoid` on the rows is the `.search-res-list` precedent** one
 * screen down, which columns single-line `li`s the same way. It is only a hint
 * and is ignored for a box taller than the column — which is exactly why the
 * server refuses to flag a level containing an expandable row: an expanded book
 * fragments across the gutter and its chapters resume above the NEXT book, with
 * nothing on screen saying so.
 *
 * **The empty leaf caret collapses.** `.ocaret.leaf` is a 1rem placeholder that
 * aligns leaves under their expandable siblings — and in a level with no
 * expandable row there is nothing to align with, so it is 16px of a 144px
 * column spent on nothing. Scoped to a columned level, so every other list
 * keeps its alignment. */
/* **SIZED FROM THE LEVEL'S OWN LONGEST LABEL, not from a viewport** — Jon,
 * 2026-08-16, on the first build: *"there is a lot of room between the columns
 * in the outlone/contents area, and there is plenty of room for three way
 * before it actually displays three."*
 *
 * The gutter is not the room he is seeing. `column-width` is a MINIMUM: the
 * browser fits `floor((W + gap) / (width + gap))` columns and then stretches
 * each to fill, so an over-wide minimum costs a column AND leaves the slack
 * inside the ones that remain. A flat `7rem` had to cover the widest label the
 * server allows (16 chars, `Morning, June 10`) while **312 of the archive's
 * 400 columnable levels top out at 10 characters** - so a chapter list was
 * paying for a label it does not have.
 *
 * `--ocol-ch` is `app._level_longest_label`, published per level by
 * `templates/_outline.html`; `loadMore` takes the MAX when a later page
 * arrives. The fallback `16` is the widest label the gate allows, so a level
 * that somehow arrives without the property is sized as it used to be.
 *
 * **`.5rem` per character, and every term in that is measured.** Measured on
 * the canvas with `.olabel`'s own computed font (12.48px Source Sans Pro):
 * `CHAPTER 10` **76.9px**, `CHAPTER 150` 83.8, `Chapter 10` 61.7,
 * `Psalm 100` 58.9, `Introduction` 65.2, `Morning, June 10` 96.3 — the
 * 16-char worst case the gate allows. The widest of those is 7.7px per
 * character; `.5rem` = 8px covers it, and 8px × 10 + chrome fits a third
 * column into the default panel, which is the ask.
 *
 * **NOT `ch`, and that cost a revision.** `1ch` resolves against the element
 * the property is declared on — the `<ul>` — where it measures **8.34px**,
 * while `.olabel` is `font-size: .78rem` and its own `1ch` is **6.92px**. The
 * first build wrote `1.15ch` reasoning about the label's font and got the
 * list's: 98.34px per column instead of 82, still two columns at 320px, and
 * the fix delivered nothing. `rem` is right for the second reason too — the
 * label is sized in `rem`, so a reader who changes their browser's root font
 * moves both terms together, which `px` would not.
 *
 * **The `.15rem` is the row's own chrome, measured**: `li` width minus label
 * width is 2.4px, the `.orow` gap, because `.ocaret.leaf` collapses to 0 in a
 * columned level (below). Not guessed — the whole point of sizing from the
 * data is that every term in it came from the page. */
.outline-level[data-cols="1"] {
  column-width: calc(var(--ocol-ch, 16) * .5rem + .15rem);
  column-gap: .8rem;
}
.outline-level[data-cols="1"] > .onode { break-inside: avoid; }
.outline-level[data-cols="1"] > .onode > .orow > .ocaret.leaf {
  width: 0; flex-basis: 0;
}

/* ------------------------------------------------------------- the pane */

/* The measure and the horizontal padding come from `.pane` so the where-am-i
 * bar can centre on the same column (item 1) without re-typing them. Same
 * computed values as the literals they replace - `42rem` and `1.6rem` - so
 * this is a hoist and not a layout change. The two narrow-width overrides
 * below keep their own literals: they are genuinely different values, and the
 * bar does not need to match them, because a SYMMETRIC padding cannot move a
 * centre. */
.view { max-width: var(--ws-measure); margin: 0 auto;
        padding: 1.4rem var(--ws-vpad) 5rem; }
.article-view .text { margin: 1rem 0; }

.pager {
  display: flex; align-items: baseline; gap: .8rem; font-size: .78rem;
  color: var(--dim); border-bottom: 1px solid var(--line); padding-bottom: .4rem;
}
.pager.bottom { border-bottom: none; border-top: 1px solid var(--line);
                padding: .5rem 0 0; margin-top: 1.5rem; }
.pager .next { margin-left: auto; text-align: right; }
.pager-here { font-weight: 600; color: var(--ink); }
.pager a { max-width: 14rem; overflow: hidden; text-overflow: ellipsis;
           white-space: nowrap; }

/* **The ends of a continuous book.** Jon, 2026-08-09, `stream-edge-affordance`
 * = `both`: *"A quiet line - the beginning / the end of the book - so a stop
 * is never ambiguous. One sentence, no controls."*
 *
 * So: no rule, no border, no button, nothing to press. Quiet is the whole
 * specification, and a hairline here would sit a few pixels from the top
 * pager's own border on a stream head. Space and a lighter voice do the work
 * — the reader who has stopped scrolling needs to know the stop is the book
 * ending, not the app thinking, and one dimmed italic line says that without
 * becoming furniture in a view we have kept deliberately bare.
 *
 * The margin is asymmetric on purpose: the sentence belongs to the edge it
 * names, so it sits close to the boundary and holds its air on the text side.
 * `.view` already carries 1.4rem of top padding and 5rem of bottom, which is
 * what the small outer margins are trimmed against.
 *
 * Selectable, and deliberately so — it carries no `data-c`, so it contributes
 * no character positions to a note anchor whatever a drag does (checked:
 * `selectionRange` reads only positioned spans). Nothing to protect it from. */
.book-edge {
  text-align: center; font-size: .78rem; color: var(--dim);
  margin: 2.6rem 0;
}
/* The italics belong to the TITLE, not to the sentence. "The beginning of
 * King James Version." is ungrammatical; the italics are what say "this is
 * the name of a work", and italicising the whole line spends them on nothing
 * and leaves the sentence reading like a slip. Stated here rather than left
 * to the UA default, because `cite` is one of the elements a resource
 * stylesheet could reach (checked: none of the 52 do today). */
.book-edge cite { font-style: italic; }
.book-edge[data-edge="start"] { margin-top: .4rem; }
.book-edge[data-edge="end"] { margin-bottom: .4rem; }

.resource-head { display: flex; gap: 1.2rem; align-items: flex-start; }
.cover.big { width: 9rem; border: 1px solid var(--line); border-radius: 3px; }
.bib h1 { font-size: 1.35rem; margin: 0 0 .2rem; }
.bib .author { color: var(--dim); margin: 0 0 .6rem; }
.bib dl { display: grid; grid-template-columns: auto 1fr; gap: .1rem .8rem;
          font-size: .82rem; margin: .5rem 0; }
.bib dt { color: var(--dim); }
.bib dd { margin: 0; }
.rid { font-family: ui-monospace, monospace; font-size: .76rem; }
/* Decision 225 step 3: a scripture citation no Bible here can show. The link
 * is the renderer's (`.sref` - L-009, so it is NOT styled here); this names
 * only the state `openScripture` sets, and its title says why. */
.article-view a[data-sref-state="none"] { text-decoration-style: dotted;
                                          cursor: help; }
/* Decision 225 step 3: the card's one way in that is not the Contents list. */
.start-reading { margin: .7rem 0 0; font-size: .95rem; }
.start-reading a { font-weight: 600; }
.start-reading .start-at { color: var(--dim); font-size: .85rem; }
/* `s-exportdoors`: the book page's Download as ePub is a BUTTON (it fetches,
 * so a refusal can be a sentence) that reads like the links beside it. */
.res-epub { font: inherit; font-weight: 600; color: var(--accent);
            background: none; border: 0; padding: 0; cursor: pointer; }
.res-epub:disabled { color: var(--dim); cursor: default; }
.rights { font-size: .74rem; color: var(--dim); margin-top: .8rem;
          border-top: 1px solid var(--line); padding-top: .5rem; }

.reflookup { display: flex; gap: .3rem; align-items: center;
             margin-top: .8rem; flex-wrap: wrap; }
.reflookup label { font-size: .78rem; color: var(--dim); width: 100%; }
.reflookup input { padding: .3rem .4rem; font-size: .8rem; flex: 1;
                   border: 1px solid var(--line); border-radius: 3px;
                   background: var(--pane); color: var(--ink); }
.reflookup button, .popclose {
  border: 1px solid var(--line); background: var(--panel); color: var(--ink);
  border-radius: 3px; padding: .3rem .6rem; font-size: .8rem; cursor: pointer;
}

/* **THE ESCAPE FORM'S STRIP.** `base.html` draws the form once for every
 * standalone page, ABOVE the content rather than inside it, so it can no
 * longer be a float: a float there sits in `<main>`'s width rather than the
 * content's measure, and the following `.view` block overlaps it and takes
 * the click (driven - `verify_shell` timed out with `.view intercepts pointer
 * events`). The strip takes `.view`'s own measure and padding so the button
 * lands where it always did, at the right edge of the text column. */
.page-escape {
  max-width: var(--ws-measure); margin: 0 auto;
  padding: 1.4rem var(--ws-vpad) 0;
  display: flex; justify-content: flex-end;
}
/* Kept for `_search.html`, which still draws its own inside the content and
 * where the float is still correct - see `ESCAPE_HANDWRITTEN` in `app.py`. */
.to-workspace { float: right; margin: 0 0 .5rem .8rem; }
/* Inside the strip it is a flex item, so the float does nothing and the
 * margin would push it off the right edge. */
.page-escape .to-workspace { float: none; margin: 0; }
/* The content follows immediately; the strip already paid the top padding. */
.page-escape + .view { padding-top: .4rem; }
.to-workspace button {
  border: 1px solid var(--line); background: var(--panel); color: var(--accent);
  border-radius: 3px; padding: .25rem .6rem; font-size: .78rem; cursor: pointer;
}
.to-workspace button:hover { border-color: var(--accent); }

.toc { list-style: none; padding-left: 1rem; }
.toc li { font-size: .88rem; padding: .1rem 0; }
.toc-pointer { font-size: .8rem; color: var(--dim); }

/* Footnotes. The asides are in the document, which is what makes a marker
 * open its note without leaving the page; the popover is the enhancement. */
.notes { margin-top: 2rem; border-top: 1px solid var(--line); padding-top: .6rem; }
/* **In a continuous stream the footnote pool is present and not laid out.**
 * Jon, 2026-08-08, reading in scroll mode: the pool between chapters is
 * "especially annoying", and a marker pops its note up anyway, so listing
 * them again is a wall between him and the next chapter. `display: none`
 * rather than removing the section server-side, because `showNote` reads the
 * aside's `innerHTML` out of this element - and innerHTML needs no layout,
 * so the popover is unaffected (driven). Only a stream fragment: paged mode
 * and the standalone JavaScript-off page keep their pool. */
.view[data-stream] .notes { display: none; }
.notes h2, .unmapped h2 { font-size: .9rem; color: var(--dim); margin: 0 0 .5rem; }
.notes aside { font-size: .82rem; margin: .4rem 0; padding-left: .6rem;
               border-left: 2px solid var(--line); }
.notes aside:target { border-left-color: var(--accent); background: var(--accent-soft); }

/* ------------------------------------------- the reading-display toggles
 *
 * Jon, 2026-08-09: *"most bible reading apps provide options for removing the
 * chapter numbers and breaks, removing the verse numbers, removing the other
 * markings (like the footnotes or whatverr, there are numbers and letters
 * there). and removing titles since most versions provide section or chapter
 * titles that are not from the original text of scripture."*
 *
 * **These rules reach into `.text`, which the top of this file forbids, and
 * the exception is deliberate rather than an oversight.** That rule exists so
 * the shell never offers a second opinion about a resource's TYPOGRAPHY - what
 * the text looks like is the resource's own sheet's business. This is not an
 * opinion about typography; it is the reader asking for a class of run not to
 * be shown at all, which no resource sheet can express and which nothing else
 * in the app is positioned to do. `verify_shell.check_class_collisions` is
 * widened by an explicit ALLOWLIST naming exactly these five classes, so an
 * ACCIDENTAL collision still goes red - the guard was not re-baselined.
 *
 * **`data-hide-*`, and never `data-disp-*`.** Two attribute names for what
 * looks like one thing, the same split as `--topbar-real` / `--topbar-h`
 * above:
 *   `data-disp-<opt>="show|hide"` is the PUBLISHED state - on <body> (the
 *     global, always present) and on a tab li (the per-resource override,
 *     present only when there is one). The client reads these.
 *   `data-hide-<opt>` is the APPLIED state - written by `applyPaneDisplay` on
 *     the pane, present only when the resolved answer is "hide". CSS reads
 *     this, and only this.
 * One name for both would make `<body data-disp-versenum="show">` - which is
 * the GLOBAL being published - match a bare attribute selector and hide verse
 * numbers everywhere, most loudly when the setting is off.
 *
 * **HIDE THE RUN, NEVER THE BLOCK.** The `id="r-..."` milestone anchors -
 * what *"go to Genesis 1:1"* lands on - are EMPTY SIBLING SPANS immediately
 * before the run they mark:
 *     <span id="r-bible_kjv.1.1.1"></span><a epub:type="noteref">...</a>
 * Measured over all 52 resources by walking the rendered DOM of every article
 * (700k+ field elements, 1.6M+ milestone ids): **an `id="r-*"` anchor is a
 * descendant of a `.fld-*` run or a noteref exactly ZERO times.** So hiding a
 * run cannot cost one of THOSE. Hiding its BLOCK can and does -
 * `p:has(.fld-heading){display:none}` takes the LSB verse anchor's rect from
 * 1 to 0, and `scrollIntoView` on a display:none element is a measured no-op.
 * The companion rule below therefore zeroes the paragraph's MARGINS and never
 * its display; that pair is pixel-identical to hiding the paragraph (to 0.1px
 * on 5 of 5 resources x 30 articles) while keeping the anchor's rect.
 * **That rule is no longer a `:has()`** - it was one until 2026-08-20, and it
 * over-reached on every block whose heading run shares the paragraph with
 * text. It reads `data-only` from `render.py` now; see the comment on it.
 *
 * **BUT `r-*` IS NOT THE ONLY ID FAMILY, AND THE OTHER ONE IS ON THE RUN.**
 * An adversarial pass found what the sweep above did not ask about:
 * `render.py` puts the SCROLL-MARK id and `data-c` on the field span itself -
 *     <span id="c1139563" class="f5 fld-heading" data-c="1139563">
 * (LSB 58375, rendered with `marks=`). So a *character position* inside a
 * hidden run has no box, while a *reference milestone* beside it still does.
 * **Do not read the `r-*` sweep above as saying scroll targets are safe
 * generally: it says exactly what it measured, which is narrower than it
 * sounds.**
 *
 * **FIXED, and the rule lives in `static/app.js` `hasBox`/`boxedNode`** - one
 * producer of *"does this paint"* and one of *"where is it"*, asked by all
 * five sites that map a position back to the screen (`paneAnchor`,
 * `restoreScroll`, `landOnChar`, `landPane`'s fragment fallback,
 * `scrollToMark`, `markerTop`). Driven by `verify_reading.stage16`, whose
 * control lands the reader at 0 where the fix lands at 1,195.
 *
 * Two corrections to what this comment used to say. It listed **three** sites;
 * there were **four**, because `landPane`'s href-fragment fallback is a
 * `c<N>` whenever the tab was opened at a position (`app.py` builds
 * `"?c=%d#%s"`). And it said *"a `?c=` on the standalone page lands nowhere
 * for the same reason"*, which is **false**: these toggles are applied by
 * `applyPaneDisplay` onto the PANE, and that page has no pane and no script.
 * Fetched at HEAD, `/r/LLS_LGCYSTNDRDBBLSB/a/58375?c=1139563` is 111,428
 * bytes carrying 0 `data-hide`, 0 `data-disp` and 0 `<script>` tags. Nothing
 * to fix there.
 *
 * How much is exposed, over 1,656 BODY articles across all 52 resources
 * (<=40 each, evenly spaced over `[first,last]`; a uniform sample of
 * `article` would be ~98% footnote rows, L-068): **13,390 of 150,176
 * positioned spans, 8.92%**, sit inside something one of these four rules
 * hides - `LLS_KJV1900` **36.04%**, `LLS_CSB` 32.81%, `LLS_LEB` 24.57%, LSB
 * 8.97%, and 25 of 52 resources none. The biggest contributor is the
 * MARKER rule (7,001), not the headings (1,053).
 *
 * **The marker rule is scoped to `.text` because `fld-transnote` means two
 * different things.** It is on the marker glyph in the reading text AND on the
 * note's own words in its aside - KJV Genesis 1 carries 23 in the body, every
 * one inside a noteref, and 191 in the 31 asides. An unscoped
 * `.fld-transnote{display:none}` would delete the FOOTNOTES and leave the
 * marks, which is the ask exactly inverted. `.text` is the body article;
 * `section.notes` is its sibling, and the popover copies an aside's innerHTML
 * into `.popbody`, which is outside `.text` as well - so a hidden mark still
 * opens a readable note.
 *
 * The two companion selectors catch the ANCHORLESS separators: on `LLS_CSB`
 * 1,590 of 39,224 body crossref/transnote spans carry no anchor at all, and
 * hiding only the anchors leaves `sufficient meansC,t` as `sufficient means,`.
 * On ESV all 49,215 sit inside a noteref and the two extra selectors are
 * inert - correct on both, which is the point of having them.
 *
 * **`:not(.fld-bible)` IS THE WHOLE SAFETY OF THAT COMPANION RULE, and
 * without it this feature deletes scripture.** Of those 1,590 loose runs,
 * 1,587 are a single character - a real mark - and 3 are longer. One of them
 * is `LLS_CSB` article 39088:
 *     <span class="f0 fld-transnote fld-bible"> your allotted inheritance at
 *      the end of the days.” </span>
 * which is the back half of Daniel 12:13. A run that is scripture AND carries
 * the transnote bit is text, not a mark, and the two are told apart by the
 * `bible` field the renderer already emits beside it - the marks carry
 * `fld-transnote` alone. Hiding by class alone would have silently removed 52
 * characters of the book from the page, with the toggle behaving perfectly
 * everywhere a reader would think to check. A stray letter is a blemish; a
 * missing half-verse in a Bible is the worst thing this app can do.
 */
[data-hide-versenum] .fld-versenum { display: none; }
[data-hide-chapternum] .fld-chapternum { display: none; }
[data-hide-heading] .fld-heading { display: none; }
/* THE COMPANION RULE ASKS `render.py`, NOT `:has()`.
 *
 * Hiding a run leaves its BLOCK, and the block keeps its margins - so the
 * reader trades the words for a hole. Measured on LSB in `columns`: hiding
 * chapter numbers takes a heading block 34px -> 0 while `margin-top: 48px`
 * stays, making a chapter join 70.4px against an ordinary paragraph join's
 * 22.4 median. That is the gap Jon can see, and it is why his own stated
 * mitigation - *"turn off the chapter headings ... it should not even be
 * noticeable"* - did not work.
 *
 * `p:has(.fld-chapternum){margin:0}` is the obvious copy of the rule this
 * replaces AND IT IS WRONG THE MAJORITY OF THE TIME. Censused over the corpus
 * on the rendered markup: a heading is nearly always alone in its paragraph
 * (696 of 699 sampled blocks), but a chapter number is mostly INLINE with the
 * verse beside it - LSB 27 of 27 alone, KJV1900 3 of 29, LEB 0 of 27,
 * LLS_1.0.710 0 of 27. The copy would jam the first verse of a KJV chapter
 * against the last verse of the one before. **LSB is the pure case, which is
 * exactly why checking the book in front of you ratifies the wrong rule.**
 *
 * So the block says whether it is a hole. `render.py` sets `data-only` to the
 * field classes shared by every run in the block that renders anything - the
 * one producer of the markup answering a question CSS cannot ask (`:empty` is
 * false with the spans still present, and a `:has()` spelling that leans on
 * the milestone anchor being `:empty` works on the markup in front of you and
 * rots). Read with `~=`, because more than one toggle can empty one block.
 *
 * MARGINS, NEVER `display` - the reason the rule this replaces gave, and it
 * still holds: hiding the block takes the `r-*` milestone anchor's rect away
 * and `scrollIntoView` becomes a measured no-op. Paragraph styles carry no
 * padding (`render.py` `para_css` writes margins, indent and line-height
 * only), and a block whose every visible run is hidden has no line box, so
 * zeroing the margins is the whole of removing it.
 *
 * There is deliberately no `versenum` arm: **0 of 2,214** sampled blocks
 * carrying a verse number are emptied by hiding it, so a rule for it could
 * never fire and would read as coverage it does not have.
 *
 * **`.ws-pview` IS LOAD-BEARING AND THE BARE `[data-hide-*]` FORM SILENTLY
 * LOSES.** `archive._scope_sheet` prefixes every generated resource rule with
 * `.<scope> `, so the paragraph style arrives as `.res-xxx .p14 { margin-top }`
 * at specificity (0,2,0) - a TIE with a bare `[data-hide-chapternum]
 * [data-only~="chapternum"]`, and the resource sheet is injected after this
 * one, so order breaks the tie against us. Measured: the seam did not move at
 * all (70.4px, unchanged to 0.1px) with the markup, the attribute and the rule
 * all correct and served. Naming the pane's class takes it to (0,3,0).
 *
 * The rule this replaces was accidentally safe: `[data-hide-heading]
 * p:has(.fld-heading)` is (0,2,1), because `:has()` takes the specificity of
 * its argument. Nothing said so, and dropping the `p` and the `:has()` dropped
 * the win with it.
 *
 * The `display: none` rules above are NOT exposed to this, and that is a fact
 * about the properties rather than luck: `FIELD_CSS` sets type and alignment on
 * a field, never `display`, so those rules contend with nothing. Any future
 * rule here that sets a property a resource sheet also sets needs this
 * treatment. */
.ws-pview[data-hide-chapternum] [data-only~="chapternum"],
.ws-pview[data-hide-heading] [data-only~="heading"] { margin-top: 0; margin-bottom: 0; }
/* The ESCAPED colon, never `epub|type`. `render.BASE_CSS` spells it
 * `a[epub|type~="noteref"]` and that form works only in the resource sheet,
 * which declares `@namespace epub`. This sheet declares none, and in an HTML
 * document the attribute is namespace-less anyway, so the namespaced form
 * would match nothing and the toggle would silently do nothing. */
[data-hide-markers] .text a[epub\:type~="noteref"],
[data-hide-markers] .text .fld-crossref:not(.fld-bible),
[data-hide-markers] .text .fld-transnote:not(.fld-bible) { display: none; }

/* ------------------------------------------------ footnotes in the margin
 *
 * Jon, 2026-08-09, writing his own answer into the `stream-footnotes-side`
 * box rather than picking one of the options offered:
 *
 *   *"you chose originally to present footnote text at the bottom of each
 *   chapter, which broke up the flow of the text ... My assumption was that
 *   you could just as easily have put those notes on the same line that you
 *   found them, but to the side. this would obviously narrow the width of the
 *   text a little to afford room on the right. i think it should be an option
 *   and a reader could just close the library tab or the outline tab."*
 *
 * **`data-notes-margin`, never `data-notes-place`.** The same two-name split
 * as `data-disp-*`/`data-hide-*` above and as `--topbar-real`/`--topbar-h`
 * before it: `data-notes-place` is the PUBLISHED preference, on <body> (the
 * global) and on a tab li (the per-resource override), and it says `pool` or
 * `margin` at all times. `data-notes-margin="on"` is the APPLIED answer, on
 * the pane, written by `layoutMarginNotes` and ONLY after it has measured
 * that this pane has room. One name for both would put every pane in the
 * margin the instant the global was published as `margin`, including the ones
 * that had just declined for want of width - and a phone would get a gutter.
 *
 * **The asides are MOVED, not rebuilt.** They are already in the document,
 * already carry their ids, and `showNote` reads a note's `innerHTML` out of
 * this very element - so the gutter is `section.notes` repositioned, and
 * `static/app.js` goes on building no markup inside rendered content. Each
 * `aside` gets an inline `top` from the layout pass; nothing else about it is
 * client-authored.
 *
 * **`.view` is already the containing block** (`.article-view{position:
 * relative}`, set for `.ws-pen-layer`), so this needs no new positioning
 * context - and the gutter therefore scrolls with the text, which is what
 * *"on the same line that you found them"* means.
 *
 * The `[data-stream]` override is deliberate: continuous mode hides the pool
 * (Jon, 2026-08-08, the wall of notes between chapters is "especially
 * annoying"), and margin notes are what give those asides somewhere else to
 * be. It is the higher-specificity rule, and it is the one that must win.
 *
 * **`.view.article-view`, and NEVER a bare `.view`.** Every page this shell
 * renders into a pane wears `.view` - Diagnostics, Mine, the plates, the
 * shelf, a search result - and only an article has a footnote pool. Keyed on
 * `.view`, one global `margin` put a 253px empty gutter down the side of all
 * of them and took `/diagnostics` to 64.8% of its content width, and the
 * narrow-pane guard was structurally unable to refuse because it measures an
 * element only an article view has. **The CSS must name the same population
 * the layout iterates**, which is `.view.article-view` in both places now.
 *
 * **The gutter scales with the reader's font, and the room gate then does the
 * work for free.** A fixed 13rem is ~26 characters at scale 1 and ~7 at 3.6,
 * because `--ws-fscale` multiplies the RESOURCE's typography and never
 * reaches a shell length. Multiplying it here means a reader who has asked
 * for very large text gets a proportionally wider gutter, the reading column
 * narrows with it, and the existing measured refusal declines when there is
 * genuinely no room - rather than a gutter nobody can read a note in.
 *
 * **`max-width` is DERIVED, not a round number.** At 62rem a pane wider than
 * ~899px paid for the gutter out of the extra width, so margin mode made the
 * reading column *wider* than the pool - which is not wrong for a reader, but
 * it made "the text narrows a little" false above a threshold nobody had
 * written down, and Jon's own remedy (close a panel) is what moves you across
 * it. `42rem + gutter + gap` keeps the reading column at exactly its pool
 * measure wherever there is room for one, and narrows it only where there is
 * not.
 */
/* **ONE rule, two consumers - the column and the bar that must centre on it.**
 * The gutter shifts the reading column LEFT of the pane's centre by half its
 * own width, so a bar centred on the PANE is off by `(gutter + gap) / 2`
 * exactly where Jon reads LSB. Measured at `9b82ab0`: column centre 642.4
 * against a pane centre of 756.0, so **113.6px** - the one configuration in
 * which "centre the bar on its half" and "centre the bar on the text" are
 * different sentences (L-021: a pool-only check could not tell them apart).
 *
 * Written as a selector list rather than as two rules that agree, because
 * `--ws-gutter` multiplies `--ws-fscale` and must be DECLARED on an element
 * that carries the reader's per-tab scale - the pane for the column, the bar
 * for the bar (`refreshWhere` mirrors the pane's inline value onto it). One
 * declaration, resolved twice, is the only shape that is both correct and
 * single-producer. */
.ws-pview[data-notes-margin="on"] .view.article-view,
.ws-where[data-notes-margin="on"] .ws-where-text {
  --ws-gutter: calc(13rem * var(--ws-fscale, 1));
  max-width: calc(var(--ws-measure) + var(--ws-gutter) + var(--ws-gutter-gap));
  padding-right: calc(var(--ws-gutter) + var(--ws-gutter-gap) + var(--ws-vpad));
}
/* **THE SCALE REACHES TEXT THE BOOK NEVER SIZED - Jon, 2026-09-28, decision
 * 224.** `archive._scale_sheet` multiplies the book's own `pt` lengths, and
 * that was the whole mechanism while every book was a Logos book: every run
 * carried a `pt` size. A converted book mostly does not. Its text inherits
 * `html, body`'s 15px from THIS sheet, which nothing multiplied - so on John
 * Ploughman's Talk the body stayed 15px at 1.4 and 2 while the headings and
 * the gutter grew, and the reader saw the page jump without growing. Measured
 * over the archive that day: **512 books** carry text with no `pt` size
 * (506 of 507 calibre, all 6 heritage), **0** Logos books.
 *
 * `1em` is whatever the text inherits today, so scale 1 is byte-for-byte the
 * old layout and no size is transcribed here. A `pt` rule inside is absolute,
 * so it is still multiplied exactly once (KJV 18.7 -> 26.1px at 1.4, driven);
 * an `em` rule inside follows its parent, which is the point. `.notes` is the
 * book's own footnote text; the pens, marks and pagers beside them are the
 * shell's and stay put. Every length the columns and the gutter use is `rem`,
 * so none of them is multiplied twice. */
.view.article-view > .text,
.view.article-view > .notes {
  font-size: calc(var(--ws-fscale, 1) * 1em);
}
.ws-pview[data-notes-margin="on"] .view.article-view .notes {
  display: block;
  position: absolute; top: 0; right: var(--ws-vpad);
  width: var(--ws-gutter);
  margin: 0; padding: 0; border-top: none;
}
/* The heading is the pool's, and a gutter does not need one: every note in it
 * is beside the words that cite it, which is the label. */
.ws-pview[data-notes-margin="on"] .view.article-view .notes h2 { display: none; }
.ws-pview[data-notes-margin="on"] .view.article-view .notes aside {
  position: absolute; left: 0; right: 0; top: 0; margin: 0;
}

/* ------------------------------------------------------- footnotes OFF
 *
 * Jon, 2026-08-09, in the same paragraph that chose `visible-only`: *"i would
 * add one more easier one, the ability to turn off footnotes should be right
 * there with the ability to put them below or to the side."*
 *
 * `data-notes-off` is the pane's APPLIED answer, written by
 * `applyPaneNotesPlace` before the layout runs — see the four-name block in
 * `static/app.js`. Presence-only, because unlike the margin it can never be
 * refused: there is always room for nothing.
 *
 * **`display: none`, and the popover is unaffected — driven, not assumed.**
 * `showNote` reads the aside's `innerHTML`, and innerHTML needs no layout, so
 * a marker still opens its note. That is the same mechanism the continuous
 * stream has relied on since 2026-08-08, one rule up at `.view[data-stream]`.
 *
 * **In continuous mode this changes nothing, because the stream rule already
 * hides the pool.** `below` and `off` differ only in PAGES. The Settings panel
 * says so in words rather than leaving a reader to discover that one of three
 * choices is sometimes inert (L-002 — a visible gap, never a silent one).
 *
 * `.view.article-view`, never a bare `.view`: only an article has a pool, and
 * the rule above it paid for that lesson across 463 plate pages. */
.ws-pview[data-notes-off] .view.article-view .notes { display: none; }

/* --------------------------------------------- SIDEWAYS COLUMNS (reading)
 *
 * Jon, 2026-08-18, `multicolumn-which-shape` -> `sideways`:
 *
 *   *"not just two columns, but as many columns as will fit side by side! i
 *   think this could be a toggle, so on my ultrawide screen i could see many
 *   columns of text as will fit, and, basically that changes the scroll to
 *   left/right i think."*
 *
 * And `multicolumn-when-wide` -> `switch-per-book`: **a Settings row on the
 * existing ladder, NOT automatic on width.** So there is deliberately no
 * container query here, even though one was driven and proved inert
 * (`research/multicolumn-body-price-2026-08-18.md` §7) - the count follows the
 * pane because `column-width` is a MINIMUM, which needs no query at all.
 *
 * `data-reading` is the pane's APPLIED answer, written by `applyPaneReading`;
 * the PUBLISHED state stays `data-scroll-mode` / `data-tab-scroll-mode` on
 * `<body>` and the tab li. Two names per preference, never one - a published
 * global would otherwise match a bare attribute selector and apply itself from
 * `<body>` down (see the four-name block in `static/app.js`).
 *
 * **THE MECHANISM IS `column-fill: auto` PLUS A DEFINITE HEIGHT, and that is
 * what turns the overflow INLINE.** With `column-fill: balance` (the default)
 * the columns even up and the block grows DOWNWARD, which is the shape Jon
 * rejected. With `auto` and a definite height each column fills to the bottom
 * and the next one starts to its RIGHT, so `.text` overflows in the inline
 * axis and `.ws-pview` becomes a horizontal scroller. Measured before this was
 * built: `scrollWidth 3462` against `clientWidth 1461` on one LSB fragment.
 *
 * **THE DEFINITE HEIGHT MUST COME FROM POSITIONING. FLEX AND GRID BOTH FAIL,
 * AND THEY FAIL IDENTICALLY AND SILENTLY.** This was built first as a flex
 * column with `.text { flex: 1 1 auto; min-height: 0 }` - the obvious shape,
 * and the one the Contents panel's `min-height: 0` lesson points at. Driven on
 * LSB Genesis 1 at 1512x982, four constructions in one page:
 *
 *   flex `1 1 auto`   .text height   0px    ->  55 columns, scrollWidth 45,293
 *   flex `1 1 0%`     .text height   0px    ->  55 columns, scrollWidth 45,293
 *   grid `minmax(0,1fr)` .text height 0px   ->  55 columns, scrollWidth 45,293
 *   **absolute top/bottom**  .text height 798px  ->  **4 columns, 1,334**
 *
 * Byte-identical failures for the first three. A `column-fill: auto` multicol
 * whose height is indefinite makes every column zero-tall and starts a new one
 * per line, so the chapter came out as fifty-five column-widths of sideways
 * scroll - and NOTHING LOOKED BROKEN: the declarations all computed correctly
 * (`column-fill: auto`, `column-width: 384px`), the pane really did scroll
 * sideways, and only the `.text` box being **0** said so. The likely mechanism
 * is that flex-grow and `1fr` both size the item FROM its content contribution,
 * and such a multicol contributes zero - so there is nothing to grow. That
 * explanation is inference; the four numbers are the measurement.
 *
 * So all three children are positioned against `.view`, which is the only
 * construction that hands `.text` a height BEFORE its own sizing runs. The
 * pagers go with it: leaving them in flow makes `.text`'s inset a guess about
 * where they ended up, and `--ws-pager-h` states the row instead - measured at
 * 25-26px, set to 2.6rem so a wrapped label has slack.
 *
 * **The column width MULTIPLIES `--ws-fscale`**, declared on the element that
 * carries the reader's per-tab scale - exactly `--ws-gutter`'s contract above.
 * A fixed `rem` width would hold ~45 characters at scale 1 and a handful at
 * Jon's 2.0, i.e. the control that makes text bigger would make each column
 * useless. `24rem` is a CHOICE and stated as one: `--ws-measure`'s 42rem is a
 * single-column measure, and because `column-width` is a MINIMUM the browser
 * stretches what fits - so a 42rem minimum on a 1512 pane yields two columns
 * ~740px wide, WIDER than the 621px he reads today and past a comfortable
 * measure. 24rem lands nearer it and delivers his *"as many as will fit"*.
 *
 * **NO `column-count`, ever.** The count must fall out of the width, which is
 * the Contents panel's settled precedent and is what makes an ultrawide differ
 * from a phone with no query and no JavaScript.
 */
.ws-pview[data-reading="columns"] {
  /* The reading axis is now horizontal. `overflow-y: hidden` is what forces
   * the overflow inline: with it `visible`, the block axis would take the
   * spill and nothing would move sideways. */
  overflow-x: auto; overflow-y: hidden;
}
.ws-pview[data-reading="columns"] > .view {
  /* The measure is what CAPS a single column; a columned pane wants the whole
   * width and lets `column-width` decide how it is divided. */
  max-width: none;
  /* The containing block for all three children below. Its own padding is
   * zeroed because nothing is in flow any more — every edge is stated as an
   * inset, so there is one place each one is decided rather than two that have
   * to agree. */
  position: relative;
  height: 100%; box-sizing: border-box;
  padding: 0;
  /* **NOT A SLOT HEIGHT.** The machine-readable half of that claim is the
   * next line, and `verify_chassis` reads exactly this spelling:
   *
   *     not-a-slot-height: --ws-pager-h
   *
   * The prose below is the reason.** `verify_chassis` ranges over every `--ws-*-h` in this sheet and
   * requires each one to be declared on `:root` or on `<body>`; this one is
   * declared on an ELEMENT and is exempt, because it is not chrome. The
   * exemption is written HERE, beside the declaration, rather than as a name
   * in the checker — a keep-list there would make the next author responsible
   * for remembering to edit it, and forgetting is silent (L-076).
   *
   * **WHY IT IS NOT CHROME.** A slot height sizes a row of the tab chassis:
   * one per tab, owned by the shell, the same question for every kind. This
   * is per-PANE and per-MODE — `[data-reading="columns"]` only — and it sizes
   * the pager inside the DOCUMENT's own layout. Calling it a slot would put a
   * document-layout value into the chassis vocabulary, which is the
   * overloading that got `head` rejected as a slot name.
   *
   * **AND IT IS SAFE WHERE PLAYBOOK §11 WARNS, WHICH IS WHY IT IS EXEMPTED
   * RATHER THAN MOVED.** That rule is about a custom property read through an
   * `inset` SHORTHAND resolving against the wrong element. It IS read that
   * way — `.ws-pview[data-reading="columns"] > .view > .text` below sets
   * `inset: var(--ws-pager-h) var(--ws-vpad)` — but the reader is a
   * DESCENDANT of the element declaring it, so it inherits. The hazard is a
   * reader that is NOT a descendant; if one is ever added, this exemption is
   * wrong and this comment is where to find that out. */
  --ws-pager-h: 2.6rem;
}
.ws-pview[data-reading="columns"] > .view > .pager.top {
  position: absolute; top: 0; left: var(--ws-vpad); right: var(--ws-vpad);
}
.ws-pview[data-reading="columns"] > .view > .pager.bottom {
  position: absolute; bottom: 0; left: var(--ws-vpad); right: var(--ws-vpad);
  margin: 0;
}
.ws-pview[data-reading="columns"] > .view > .text {
  /* `--ws-vpad` on the inline edges, not 0: an absolutely positioned child
   * resolves against the PADDING box, so `left: 0` would run the first column
   * hard against the pane edge. This restores the side breathing room the
   * zeroed padding above gave up. */
  position: absolute; inset: var(--ws-pager-h) var(--ws-vpad);
  margin: 0;
  --ws-colw: 24rem;
  column-width: calc(var(--ws-colw) * var(--ws-fscale, 1));
  column-gap: calc(var(--ws-vpad) * 2);
  column-fill: auto;
  column-rule: 1px solid var(--line);
}
/* **WHERE A CHAPTER STARTS - Jon, `columns-seam-or-rewrap` -> `try-it-and-see`,
 * 2026-08-26.** One rule, and it is the whole feature.
 *
 * Sideways reading pours a RUN of chapters into ONE multicol flow, which is
 * what makes a chapter join invisible - his own ask, 2026-08-19: *"it should
 * not even be noticeable that I'm flowing between chapters as I go."* The cost
 * is that inserting a chapter at the FRONT of a flow moves every line break
 * after it, so scrolling back re-wraps the lines around the reader - against
 * his 2026-08-24: *"It should look the same scrolling forward or backward."*
 * `break-before: column` makes a prepend add a WHOLE NUMBER of columns, so
 * nothing downstream reflows; the price is a blank at the end of the column
 * before each chapter. He was given both, measured, and chose to have the
 * switch.
 *
 * **`+ .chap`, never `.chap` alone, and it is load-bearing rather than tidy.**
 * The first chapter of the flow must not break: `mountChapterInto` inserts a
 * prepended chapter BEFORE the current first `.chap`, so the adjacency keeps
 * naming "every chapter after the one that opens the flow" as the run grows at
 * either end, with nothing to recompute.
 *
 * **Two attribute names, never one** - `data-chapter-start` is the PUBLISHED
 * preference on `<body>` and on a tab li, and `data-chapbreak` is the APPLIED
 * answer written by `applyPaneChapterStart` on the pane. A single name would
 * let the published global match a bare attribute selector and break the
 * chapters of every pane, including panes of books that override it - the same
 * split `data-disp-*`/`data-hide-*` and `data-notes-place`/`data-notes-margin`
 * already make.
 *
 * **`[data-reading="columns"]` is in the selector and it is not decoration.**
 * `.chap` wrappers exist only in a `columns` run (`article_context` sets
 * `chapters` for nothing else), so the rule already could not fire elsewhere -
 * but a fragmentation break is meaningless outside a fragmentation context and
 * saying so in the selector is what lets the pane carry the applied attribute
 * in every mode without the cascade having to care which one it is in. */
.ws-pview[data-reading="columns"][data-chapbreak] > .view > .text > .chap + .chap {
  break-before: column;
}

/* **The pool is off in columns mode, and it is the SAME decision continuous
 * already made** - two rules up, `.view[data-stream] .notes`. A pool is a
 * block BELOW the text, so in a flex column it competes for the pane's height
 * against the words: KJV1900's median chapter carries 57 notes and LSB's 41,
 * which would leave the reader a strip. `showNote` reads the aside's
 * `innerHTML` and innerHTML needs no layout, so **every marker still opens its
 * note** - the mechanism this file already relies on for the stream and for
 * `data-notes-off`.
 *
 * So all three `notes_place` values paint the same page here, exactly as
 * `below` and `off` already do in continuous, and **the Settings panel says so
 * rather than leaving a reader to discover that a choice is inert** (L-002).
 * The margin gutter is refused at its own producer instead of here - see
 * `layoutMarginNotes`, verdict `columns` - because a gutter is a measurement
 * and this is a cascade. */
.ws-pview[data-reading="columns"] > .view > .notes { display: none; }

/* **AND THE TWO LIST SECTIONS, for a reason the pool did not have: left alone
 * they are painted INTERLEAVED WITH THE READING TEXT.**
 *
 * The block above positions exactly three children of `.view` — both pagers
 * and `.text` — and hides `.notes`. `.ws-pens` and `.ws-marks` are named
 * nowhere in it, so they stayed `position: static` in a `height: 100%` box
 * whose only other in-flow content is nothing at all. Measured on LSB Genesis
 * 1 at 1512x950, the page Jon has nine highlights on:
 *
 *     .text      absolute   x 26  y  42   821 x 760
 *     .ws-pens   static     x  0  y   0   872 x 1477   <- overlaps .text 821x760
 *     .ws-marks  static     x  0  y 1509  872 x 455
 *
 * and the pane reports `maxScrollY` **1153** in a mode whose whole premise is
 * that it does not scroll vertically, under an `overflow-y: hidden` that makes
 * every pixel of it unreachable. **This was not a clipping bug, it was a
 * legibility one**: `elementFromPoint` at the heading's own centre answers the
 * `h2`, and a screenshot shows "Your Logos highlights on this page", nine pen
 * rows and their ANCHORED captions drawn straight through the first column's
 * sentences. The column is unreadable.
 *
 * **The blast radius is small and it is almost entirely Jon's**, which is why
 * nobody reported it: a section renders only on a page that HAS one, and the
 * whole 52-resource library holds **9** such pages — counted, not recalled:
 *
 *     LLS:LGCYSTNDRDBBLSB   6   58375, 58387, 59174, 59232, 59233, 59416
 *     LLS:1.0.60            1   330
 *     LLS:KJV1900           1   68136
 *     LLS:ESVAUDIO          1   1
 *
 * Six of the nine are LSB, which is the book he reads in this mode.
 *
 * **Hidden rather than placed, and that is the same decision `.notes` got two
 * rules up.** There is nowhere to put them: `.text` is absolutely positioned
 * across the whole pane between the pagers, and a sibling block cannot join a
 * multi-column flow it is not inside. The highlights themselves are UNAFFECTED
 * — they are painted into `.ws-pen-layer` from character ranges, not from
 * these rows — so what a reader loses is the LIST, exactly as with the
 * footnote pool, and the Settings panel says so (L-002). */
.ws-pview[data-reading="columns"] > .view > .ws-pens,
.ws-pview[data-reading="columns"] > .view > .ws-marks { display: none; }

/* **A hidden mark must keep a BOX, and only here.**
 *
 * The only per-note position in the reading text is the noteref itself, so
 * "hide the marks" and "notes in the margin" collide: `display: none` gives
 * the marker **0 client rects** and there is no line left to align a note to.
 * Measured in Chromium 140 on KJV1900 67096 at 1200x900, one spelling at a
 * time, `textH` being the whole `.text` block's height - the honest test of
 * "did this cost any space":
 *
 *   shown (baseline)                        1 rect,  8px wide, textH 1324.80
 *   display: none                           0 rects,     -     textH 1302.41
 *   font-size: 0; line-height: 0            1 rect,  8px wide, textH 1324.80
 *   visibility: hidden                      1 rect,  8px wide, textH 1324.80
 *   inline-block; width: 0; overflow:hidden 1 rect,  0px wide, textH 1302.41
 *
 * **The obvious spelling is the wrong one.** `font-size: 0` does not collapse
 * the mark - the glyph lives in a `<sup><span class="f15">` whose size comes
 * from the RESOURCE sheet, so zeroing the anchor's font size never reaches
 * the element that sets it - and `visibility: hidden` keeps the whole 8px
 * too, which a reader sees as a stray gap. Only the last is pixel-identical
 * to removing the element AND still reports a rect, which is what the
 * alignment needs. (The paragraph's rect COUNT differs between the last two -
 * a zero-width inline-block splits a line's rect in two - so a rect count is
 * not a reflow test; the block height is.)
 *
 * Scoped to margin mode on purpose: the simpler rule is what a reader gets
 * when nothing needs the box, and a property nothing uses is a property no
 * check can honestly assert. `verify_reading`'s `_disp_visible` counts client
 * rects and stays correct for stage 13 for exactly this reason - stage 13
 * never turns the margin on - while the margin stage measures WIDTH, which is
 * the better question anyway.
 */
.ws-pview[data-notes-margin="on"][data-hide-markers]
  .article-view .text a[epub\:type~="noteref"] {
  display: inline-block; width: 0; height: 1em; overflow: hidden;
}

/* A plate that cannot be displayed says so. Same principle as the unmapped
 * ledger below: a visible gap, never a silent one. */
.missing-plate {
  border: 1px dashed var(--accent); border-radius: 4px;
  background: var(--accent-soft); color: var(--dim);
  padding: .5rem .7rem; margin: .6rem 0; font-size: .78rem;
}

/* The unmapped ledger. Visible in the app, not only in a build log. */
.unmapped { margin-top: 2rem; border: 1px solid var(--accent);
            border-radius: 4px; padding: .6rem .8rem; background: var(--accent-soft); }
.unmapped ul { margin: .3rem 0 0; padding-left: 1.2rem; font-size: .8rem; }

/* Where a reference landed. The id is the one render.py emits for that
 * reference, so the highlight marks the verse and not an approximation. */
.ref-hit { outline: 2px solid var(--accent); outline-offset: 3px;
           border-radius: 2px; }

/* Where a search hit or a sub-article contents entry landed — decisions item 2.
 *
 * TWO selectors for one thing, because there are two ways to arrive and only
 * one of them involves this app's JavaScript:
 *
 *  - `:target` is the browser's own. Following `/r/SLUG/a/N?c=1234#c1234` into a
 *    plain page with no shell around it, the browser scrolls to `id="c1234"`
 *    and matches `:target` on it, so the words are marked with no script at
 *    all. Restricted to a `span` because that is the element `render.py` puts
 *    a scroll id on; it is deliberately styling renderer output, which is what
 *    "here is the thing you asked for" requires.
 *  - `.ws-landed` is the workspace's. The shell never changes the address —
 *    tabs live in `workspace.db` so a reload restores the workspace, not one
 *    article — so `:target` can never match in there, and `landOnChar` adds the
 *    class instead.
 *
 * A background rather than an outline: the target is a run of text mid
 * paragraph, and an outline round a wrapping inline box draws a rectangle
 * across the lines between its ends.
 *
 * **`#pane` on the first selector is load-bearing, not tidiness.** A resource's
 * own stylesheet is linked AFTER this one and every rule in it is a single
 * class (`.f13`), so a bare `.ws-landed` ties on specificity and loses on
 * document order wherever a format sets `background-color` — 48,977 runs in
 * this library carry `has-background-color`. The id lifts it clear. The
 * `:target` selectors already win, because a pseudo-class plus two elements
 * outweighs one class; they are written differently on purpose and this is
 * the note saying so. L-024: the cascade decides and the source cannot tell
 * you — a browser measured both.
 *
 * **`[data-c]` on the `:target` halves is load-bearing too.** `render.py`
 * emits `<span id="r-bible_kjv.1.1.1"></span>` — an EMPTY span — as the target
 * of a reference lookup, and a bare `span:target` painted a background and a
 * box-shadow on it: a pink bar highlighting no text at all, on the one page a
 * reference link lands on. A positioned span is the only kind with words in
 * it. (The `unmappable-text` branch emits a mark id with no `data-c`, so a
 * target there goes unpainted — 0 occurrences library-wide, and a missing
 * highlight is the recoverable direction.) */
#pane .ws-landed, #pane span[data-c]:target, main span[data-c]:target {
  background: var(--accent-soft);
  box-shadow: 0 0 0 2px var(--accent-soft);
  border-radius: 2px;
}

/* A scroll target the renderer could not place. L-002: said, not swallowed. */
.landing-missed {
  border: 1px dashed var(--accent); border-radius: 4px;
  background: var(--accent-soft); color: var(--dim);
  padding: .5rem .7rem; margin: .6rem 0; font-size: .82rem;
}

.popover {
  position: fixed; z-index: 60; max-width: 26rem; max-height: 50vh;
  overflow-y: auto; background: var(--pane); color: var(--ink);
  border: 1px solid var(--dim); border-radius: 5px;
  box-shadow: 0 6px 24px rgba(0,0,0,.28); padding: .7rem 1rem .7rem .8rem;
  font-size: .84rem;
}
.popover[hidden] { display: none; }
.popclose { position: absolute; top: .2rem; right: .3rem; padding: 0 .35rem;
            line-height: 1.3; }
.popbody aside { border: none; padding: 0; margin: 0; }

/* ------------------------------------------------- the tab context menu
 *
 * Jon, 2026-08-17: a menu on long press, like Logos and JupyterLab. Opened by
 * `contextmenu` (desktop right-click, Android long press) and by a press timer
 * (iOS), placed from the tab's rect by `openTabContext`.
 *
 * **z-index 72, above everything this app draws**, and the reason is
 * `.ws-settings`' own at 71: it shows only because it was JUST opened by a
 * deliberate gesture, which is the one moment it outranks whatever else is on
 * screen. Below 22 it would be covered by the fold buttons and the tab strip
 * it opens FROM; below 30 by the topbar; below 40 by the JavaScript-off note.
 *
 * `position: fixed` with no `top`/`left` here on purpose - both are written by
 * `openTabContext` from the tab's rect, and a value here would be the state it
 * paints in for the first frame after `hidden` comes off.
 */
.ws-tabctx {
  position: fixed; z-index: 72;
  min-width: 13rem; max-width: min(20rem, calc(100vw - 1rem));
  /* It can be taller than a phone once the reader has a reopen row, and the
   * `.ws-tabdrop`'s 60vh is the precedent one control over. */
  max-height: 70vh; overflow-y: auto;
  background: var(--pane); color: var(--ink);
  border: 1px solid var(--dim); border-radius: 5px;
  box-shadow: 0 6px 24px rgba(0,0,0,.28);
  padding: .3rem; font-size: .84rem;
}
.ws-tabctx[hidden] { display: none; }
/* Which tab this menu is about. It is the only thing here that is not an
 * action, so it does not get the row treatment - a heading that highlights on
 * hover reads as a button that does nothing. */
.ws-tabctx-name {
  margin: 0; padding: .25rem .55rem .35rem; font-weight: 600;
  color: var(--ink-sub); font-size: .78rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ws-tabctx-item {
  display: block; width: 100%; text-align: left;
  padding: .34rem .55rem; border: 0; border-radius: 3px;
  background: none; color: inherit; font: inherit; cursor: pointer;
  /* A phone press must not select the row's words on its way to opening this
   * - and this rule is SCOPED to the menu rather than put on `.ws-tab`, which
   * would take the tab's own label with it. Every `user-select: none` in this
   * sheet is scoped that narrowly for the same reason:
   * `body.ws-dragging-tab`, this rule, and `.ws-tabctx-choice`. */
  -webkit-user-select: none; user-select: none;
}
.ws-tabctx-item[hidden] { display: none; }
.ws-tabctx-item:hover:not(:disabled) { background: var(--panel); }
.ws-tabctx-item:focus-visible { outline: 2px solid var(--accent);
                                outline-offset: -2px; }
/* Disabled rather than hidden when the action would close nothing, so the menu
 * keeps its shape between openings - a row that comes and goes is harder to
 * aim at than one that greys. */
.ws-tabctx-item:disabled { color: var(--ink-sub); opacity: .55; cursor: default; }
/* Why the row above it cannot act (`s-exportbutton`'s *Download as ePub*). A
 * sentence, not a row: no hover, and it wraps rather than ellipsing, because
 * the whole of it is the point. No `display` here - `[hidden]` is the global
 * rule's to honour. */
.ws-tabctx-why {
  margin: -.1rem 0 .25rem; padding: 0 .55rem .2rem;
  color: var(--ink-sub); font-size: .76rem; line-height: 1.3;
}
/* THE BOOK-INFO CARD, inside the book menu and above its two open options.
 *
 * **Its OWN scroll cap, not the menu's.** `.ws-tabctx` is already
 * `max-height: 70vh; overflow-y: auto`, so a long summary would scroll the
 * WHOLE menu and push "Open in a new tab" below the fold - reachable, but
 * below the thing the reader actually pressed for. Capping the card instead
 * keeps both buttons on screen at every length, and bounds the menu's height
 * so `placeFloatingMenu`'s flip-and-clamp has something finite to work with.
 *
 * **`:empty` and never `[hidden]`.** The block is emptied while a request is
 * in flight and filled when it lands, so the state that must take no space is
 * "no children" - and this way the card cannot flash an empty bordered box on
 * every press. Nothing else in this sheet sets `display` on it, so there is no
 * author-beats-user-agent trap here (L-024).
 */
.ws-bookctx-info {
  max-height: 40vh; overflow-y: auto;
  margin: 0 .1rem .25rem; padding: .3rem .45rem;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  font-size: .78rem; line-height: 1.35;
}
.ws-bookctx-info:empty { display: none; }

/* THE SHELF TOGGLES, between the two open options and the info card.
 *
 * **`:empty` collapses the box**, which is what lets it be an unconditional
 * mount point in `shell.html`: a shell whose shelf store is missing, or a menu
 * whose fragment has not landed yet, shows exactly the menu it showed before
 * this feature existed. `.ws-bookctx-info` earns its own `:empty` rule one
 * line up for the same reason.
 *
 * The box takes the padding so the divider spans the menu's full width; the
 * buttons keep `.ws-tabctx-item`'s own padding. */
.ws-bookctx-shelves-box:empty { display: none; }
.ws-bookctx-shelves-box {
  border-top: 1px solid var(--line);
  margin-top: .25rem; padding-top: .25rem;
}
.bs-shelf { display: flex; align-items: center; gap: .35rem; }
/* **A fixed-width tick column, so the names line up whether or not the book is
   on that shelf.** Ragged left edges in a list of two or three shelves read as
   two different kinds of row rather than one row in two states. `1ch` is the
   glyph's own width in this stack. */
.bs-tick { flex: 0 0 1ch; color: var(--accent); font-weight: 600; }
.bs-name { flex: 1 1 auto; min-width: 0;
           overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The pressed state is carried by `aria-pressed` and drawn from it, so the
   thing a screen reader is told and the thing the eye is told have one
   producer. */
.bs-shelf[aria-pressed="true"] .bs-name { color: var(--ink); }
.bs-shelf[aria-pressed="false"] .bs-name { color: var(--ink-sub); }
.bs-fault, .bs-none {
  margin: .3rem .55rem; font-size: .76rem; color: var(--ink-sub);
}
/* **THE ACTIONS STAY NEAREST THE CURSOR, WHICHEVER WAY THE MENU OPENED** -
 * Jon, 2026-08-19. Opening downward the menu's top edge is at the pointer, so
 * DOM order already puts the two open options there. Flipped, the BOTTOM edge
 * is at the pointer and DOM order is exactly backwards - which is the case
 * that happens at the bottom of a phone screen, i.e. the one that matters.
 *
 * `order` on a flex column, so nothing is moved in the DOM: a reordered node
 * would have to be moved back, and `loadBookInfo` re-places the menu AFTER the
 * card lands, so the DOM would be rearranged under a reader who has already
 * started reaching for a button. The flex context is scoped to `#ws-bookctx`
 * rather than added to `.ws-tabctx`, which the tab menu shares and which is a
 * plain block today.
 *
 * The info keeps its borders in both directions because it is a divider
 * between the actions and the card either way round. */
#ws-bookctx { display: flex; flex-direction: column; }
#ws-bookctx[data-flip="up"] > .ws-bookctx-info { order: -1; }
/* The title row, not the title: the close button moved into a head row with
 * it, and `order` only sees DIRECT children of the flex container. Left
 * naming `.ws-tabctx-name` this rule silently stops matching and a flipped
 * menu puts its title in the middle. */
#ws-bookctx[data-flip="up"] > .ws-bookctx-head { order: -2; }
/* The title row and its close control - Jon asked for a visible way out.
 *
 * **STICKY, and that is the whole reason it is a row.** `.ws-tabctx` scrolls
 * (`overflow-y: auto`, `max-height: 70vh`) and the info card is most of a phone
 * screen, so a control placed once at the top is gone the moment the reader
 * scrolls the card - which is precisely the menu that most needs one. Sticky
 * inside the scroller keeps it against the top edge instead.
 *
 * The background is opaque because the card scrolls UNDER it.
 *
 * `-.3rem` insets cancel `.ws-tabctx`'s own `.3rem` padding so the row spans
 * the menu's full width and the sticky edge sits at the very top; the padding
 * is put back inside the row. */
.ws-bookctx-head {
  position: sticky; top: -.3rem; z-index: 1;
  display: flex; align-items: flex-start; gap: .3rem;
  margin: -.3rem -.3rem 0; padding: .3rem;
  background: var(--pane);
}
.ws-bookctx-head > .ws-tabctx-name { flex: 1 1 auto; min-width: 0; margin: 0; }
/* A thumb target on the phone, where Escape does not exist. `line-height: 1`
 * on a bare glyph so the box is the size this rule says and not the font's. */
.ws-bookctx-close {
  flex: 0 0 auto;
  width: 1.7rem; height: 1.7rem; padding: 0;
  display: flex; align-items: center; justify-content: center;
  font: inherit; font-size: 1.1rem; line-height: 1;
  color: var(--ink-sub); background: none;
  border: 1px solid transparent; border-radius: 4px; cursor: pointer;
}
.ws-bookctx-close:hover { color: var(--ink); border-color: var(--dim); }
.ws-bookctx-close:focus-visible { outline: 2px solid var(--accent);
                                  outline-offset: 1px; }
@media (max-width: 34rem) {
  .ws-bookctx-close { width: 2.1rem; height: 2.1rem; font-size: 1.25rem; }
}
.ws-bookctx-info[data-state="fault"] { color: var(--ink-sub); }
/* Label above value rather than beside it: the menu is 20rem at its widest and
 * a two-column grid gives the summary about eleven characters a line on a
 * phone. */
.bi-rows { margin: 0; }
.bi-k {
  margin: .3rem 0 0; color: var(--ink-sub);
  font-size: .72rem; text-transform: uppercase; letter-spacing: .03em;
}
.bi-rows > .bi-k:first-child { margin-top: 0; }
.bi-v { margin: 0; color: var(--ink); overflow-wrap: anywhere; }
.bi-counts { margin: .45rem 0 0; color: var(--ink); font-weight: 600; }
/* What this device is holding of this book - Jon's states 1, 2 and 3. Written
 * by `fillBookSaved`, never by the server, because only the browser can read
 * its own store.
 *
 * **DELIBERATELY NO `display` HERE.** The row ships `hidden` and is unhidden
 * once it has text; `[hidden] { display: none }` is a USER-AGENT rule and any
 * author `display` beats it regardless of specificity, so a `display` on this
 * selector would paint an empty line on every card and report itself hidden
 * while doing it (L-024). Nothing below sets one, and nothing should. */
.bi-saved { margin: .2rem 0 0; color: var(--ink); }
/* What this book has no record of, said rather than left blank (L-002). Muted,
 * because it is a fact about the catalogue and not about the reader. */
.bi-missing { margin: .2rem 0 0; color: var(--ink-sub); font-style: italic; }
.bi-fault { margin: 0; color: var(--ink-sub); }
.ws-tabctx-rule { border: 0; border-top: 1px solid var(--line);
                  margin: .3rem .2rem; }
.ws-tabctx-rule[hidden] { display: none; }
/* A `.ws-tabctx-head` rule was here until 2026-08-31: the heading over the
 * per-tab preference rows inside the context menu. Those rows moved out of
 * that menu and the class is emitted nowhere, so the rule went with the
 * markup rather than being left standing (L-052). The rows themselves are
 * unmoved - the drop-down carries `ws-tabctx` as well as its own class
 * precisely so every rule below still reaches them. */
.ws-tabctx-row {
  display: flex; align-items: center; gap: .4rem;
  padding: .18rem .55rem;
}
.ws-tabctx-row[hidden] { display: none; }
/* **`--ink-label` / `--ink-sub` / `--line-ctl`, NEVER `--dim` and `--line`, and
 * this control shipped with the wrong pair for four hours.** The tokens exist
 * because of a complaint of Jon's on 2026-08-09 and their own comment records
 * the numbers: `--dim` on `--pane` is **5.60:1 light / 6.04:1 dark** against
 * this project's 7.0 floor for text, and `--line` as a button's only affordance
 * is **1.43:1 / 1.45:1** against 3.0 for a non-text control.
 *
 * **The sharp part is that `verify_shell`'s contrast check could not see it.**
 * Its own CONTROL is the substitution `("--line-ctl", "--line")` — the mutant
 * it uses to prove it can go red is what this rule shipped for real, one
 * selector family over — and its population is spelled `.ws-settings*`, so a
 * new control family is outside its scope by construction. Found by an
 * adversarial pass, not by the board. */

/* The label column. **`0 0 auto`, NOT a fixed width**, so the menu grows to
 * the longest label rather than truncating it: at a fixed 5.2em the four
 * display rows rendered as "Verse nu…", "Chapter …" and "Section ti…" on a
 * 390px phone - measured in a screenshot, not reasoned about - and "Chapter …"
 * is ambiguous in a menu that also has a chapter-shaped action. The menu's own
 * `max-width` still bounds the whole box, and the ellipsis stays as the safety
 * net for a label longer than this set's. */
.ws-tabctx-what { flex: 0 0 auto; max-width: 9em;
                  color: var(--ink-label); font-size: .78rem;
                  white-space: nowrap; overflow: hidden;
                  text-overflow: ellipsis; }
/* The `[hidden]` companion is REQUIRED wherever this block writes an author
 * `display`: `[hidden] { display: none }` is a USER-AGENT rule and any author
 * `display` beats it regardless of specificity (L-024). Every element type in
 * the menu that can be hidden has one — item, row, rule, head — and this one is
 * here for the forward case: nothing hides a `.ws-tabctx-ctl` today, and the
 * day something does, the absence would be silent. */
.ws-tabctx-ctl { display: flex; gap: .18rem; flex: 1 1 auto; }
.ws-tabctx-ctl[hidden] { display: none; }
.ws-tabctx-choice {
  flex: 1 1 auto; padding: .2rem .3rem; font: inherit; font-size: .74rem;
  border: 1px solid var(--line-ctl); border-radius: 3px;
  background: var(--panel); color: var(--ink); cursor: pointer;
  -webkit-user-select: none; user-select: none;
}
.ws-tabctx-choice:hover { border-color: var(--accent); }
.ws-tabctx-choice[aria-pressed="true"] {
  background: var(--accent); border-color: var(--accent); color: var(--pane);
}
/* "book" - follow the rung below. Distinguished from a VALUE, because
 * pressing it is a clear rather than a choice, and a reader who cannot see
 * the difference cannot tell an override from an inheritance. */
.ws-tabctx-inherit { font-style: italic; flex: 0 0 auto; }
/* The touch target the phone needs. `--ws-tabs-h` is the tab row's own height
 * and is the number this app already treats as thumb-sized; reusing it means
 * there is no second answer to that question in the sheet. */
@media (max-width: 34rem) {
  .ws-tabctx-item { padding: .55rem .6rem; min-height: var(--ws-tabs-h); }
  /* **The CHOICE buttons need the thumb target too, and the first version of
   * this rule gave it only to the action rows** — 8 of the menu's 27 controls,
   * with the 19 smallest getting nothing. They are the ones a reader aims at
   * most carefully (three or four to a row), so they are the ones that must
   * not be 20px tall on a phone. Two thirds of `--ws-tabs-h` rather than all
   * of it: three of these stack across one row, and a full-height row would
   * make the settings section taller than the phone. */
  .ws-tabctx-choice { min-height: calc(var(--ws-tabs-h) * 0.66);
                      padding: .3rem .35rem; font-size: .82rem; }
  .ws-tabctx-row { padding: .3rem .55rem; }
  .ws-tabctx { font-size: .9rem; }
}

/* **THE DROP-DOWN'S OWN OVERRIDES, AND THEY MUST COME AFTER EVERY
 * `.ws-tabctx` RULE ABOVE.** `#ws-tabpop` carries BOTH classes so it inherits
 * the whole menu look, and both selectors are a single class - (0,1,0) - so
 * the cascade is decided by ORDER alone. Written before them (which is where
 * these two lines started life) `min-width` and `padding` are silenced by
 * `.ws-tabctx`'s own, and NOTHING about the page looks wrong: the menu still
 * opens, still styled, just narrower than it was told to be. Measured that
 * way - the Notes group, which is one row, rendered **214.4px** against the
 * 240px this asks for.
 *
 * It is wider than the context menu because a one-row group under a wide
 * button reads as a sliver rather than as a menu. */
.ws-tabpop { min-width: 15rem; padding: .35rem .2rem; }

/* ----------------------------------------------------- standalone pages */

body:not(.shell) main { max-width: 44rem; margin: 0 auto; padding: 1rem 1.5rem 4rem; }
.bar { display: flex; gap: .6rem; align-items: baseline; padding: .5rem 1.5rem;
       border-bottom: 1px solid var(--line); background: var(--panel); }
.bar .home { font-weight: 600; }
.crumb { color: var(--dim); font-size: .85rem; }

.shelf { list-style: none; padding: 0; display: grid;
         grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: .8rem; }
.book a { display: flex; gap: .6rem; color: inherit; }
.book .cover { width: 3rem; border: 1px solid var(--line); }
.book .meta { display: flex; flex-direction: column; }
.btitle { font-weight: 600; font-size: .9rem; }
.book .author, .book .stats { font-size: .75rem; color: var(--dim); }

.errorpage { padding: 2rem 0; }
.errorpage .code { font-size: 3rem; color: var(--accent); margin: 0; }
.errorpage .detail { background: var(--panel); padding: .6rem; border-radius: 4px;
                     white-space: pre-wrap; font-size: .82rem; }
.errorpage .advice { color: var(--dim); }

/* The travel laptop is the narrow case. Panels overlay rather than squeeze
 * the text, because a 20rem panel on a small screen leaves nothing to read. */
@media (max-width: 55rem) {
  .panel {
    /* **`+ --ws-tabs-h`, and it is the row item 3 took away from this rule
     * without editing it.** Until 2026-08-16 the tab strip was the phone
     * topbar's second row, so `--topbar-real` MEASURED it and this `top`
     * cleared it for free. Item 3 moved the strip into `#pane` with
     * `z-index: 21` — deliberately above this rule's 20, so an open Library
     * panel cannot hide the tabs — and the bar went 82.58 -> 39.39px. The
     * reading pane was given the row back (`.ws-pview`'s inset, one file up);
     * an overlaid panel was not, and the strip then sat ON its first row.
     *
     * Measured at `2e34a4f`, outline open: 360x800 overlap **309.59 x 45.59**,
     * 800x900 **320 x 42.59** — and `elementFromPoint` at each buried
     * element's own centre answered `.ws-tabmenu` / `.ws-tabgroup` on **7 of
     * 8**, so they were unreachable and not merely dim. What it buried was the
     * panel's HEADER ("Legacy Standard Bible", "69 at the top - 1,263 in all")
     * and the first two book rows — **ISAIAH and JEREMIAH** on Jon's own
     * store. He reported the symptom without the cause: *"in the Outline area
     * ... its just a list of chapters sometimes with nothing else visible."*
     * The "nothing else" was one row off the top of the screen.
     *
     * It clears the STRIP and not the where-am-i bar, which is `z-index: 1`
     * against this 20 — an overlay panel is meant to cover the bar, and does.
     * `.noscript-note` (z 40) already carried this same `calc` from the day
     * the strip moved; this rule is the one that did not get it, which is why
     * the guard below asserts the PROPERTY over every overlaid panel rather
     * than the spelling of either rule (L-020, L-076). */
    position: fixed; top: calc(var(--topbar-real) + var(--ws-tabs-h));
    bottom: 0; z-index: var(--panel-overlay-z);
    box-shadow: 0 0 20px rgba(0,0,0,.25);
  }
  .panel.left { left: var(--ws-strip-w); }
  .view { padding: 1rem .9rem 4rem; }
}

/* The phone. Jon reads on one, and on one the two panels covered the text and
 * the controls that dismiss them were 12px of border. Three changes, all about
 * the thumb rather than the eye:
 *
 *   - the fold buttons become real tap targets. 2.4rem is a shade under the
 *     44px both platforms' guidelines ask for, and they are the widest thing
 *     in the bar because they are the control used most;
 *   - the brand disappears and the panels take the full width. A 20rem panel
 *     on a 360px screen is not a panel, it is a modal that lies about it;
 *   - a backdrop appears behind an open panel, so tapping the text dismisses
 *     it. Requiring a return trip to the exact button that opened it is the
 *     thing that makes a phone UI feel stuck.
 *
 * The panels also start CLOSED here - see `foldSetup` in app.js. That is a
 * default, not a lock: the state is still remembered per panel once touched.
 */
@media (max-width: 34rem) {
  .brand { display: none; }
  /* The right padding is the gear's slot: it is pinned there by the strip's
   * phone block (decision 219), no longer a flex item in this row. */
  .topbar-main { gap: .3rem; padding: 0 3.3rem 0 .3rem; }
  /* Type only. The PADDING is `.ws-where-text`'s and comes from `--ws-vpad`,
   * because it has to keep agreeing with the column - see item 1. */
  .ws-where { font-size: .74rem; }
  /* **`min-width` and `min-height` are GONE, and dropping them is the point.**
   * They sized these two as items of the topbar's flex row; since 2026-08-16
   * the buttons are `#pane`'s own absolutely-positioned children on the tab
   * row, sized by `--ws-fold-w` and `--ws-tabs-h`. A `min-width: 3.1rem`
   * (49.6px) against an inset of `--ws-fold-w` (43.2px) would push 6.4px of
   * button over the first tab — the button and the group's inset must read the
   * same number, which is the whole reason there is only one (L-017).
   *
   * The thumb-target reason those two existed has not gone away and is
   * satisfied by the row itself: `--ws-tabs-h` is 45.59px on a phone against
   * the 2.4rem = 38.4px they asked for, and the button fills the row's height.
   * `verify_shell`'s phone check floors on the measured box, not on these
   * declarations. */
  .fold {
    font-size: .95rem; border-color: var(--dim); color: var(--ink);
  }
  .fold[aria-pressed="true"] {
    background: var(--accent-soft); border-color: var(--accent);
    color: var(--accent);
  }
  /* Not 100%. A full-width panel covers the scrim completely, so "tap the page
   * to dismiss" has nothing left to tap - the affordance exists in the DOM and
   * not on the screen. Leaving a strip of the page showing is what makes the
   * gesture discoverable, and it also tells you the text is still back there.
   * The topbar sits above the panel either way, so the fold button that opened
   * it stays reachable as the deliberate way out. */
  .panel { width: min(86%, 21rem); flex-basis: auto; }
  /* **NO GRIP ON A PHONE, and the rule above is why.** It overrides `width`
   * outright, so `--panel-w` decides nothing here and a drag would move a
   * handle that changes no layout. `display: none` rather than `hidden`,
   * because the element is server-rendered and its listener is bound at load —
   * this is the one place open-or-closed is decided for it, and `pointerdown`
   * on a `display: none` box cannot happen. */
  .panel-grip { display: none; }
  /* `flex: 1 1 4rem` and not the default: the form used to size itself
   * to the box's 14rem and refuse to give any of it back. */
  .topsearch { margin: 0 .2rem; flex: 1 1 4rem; min-width: 0; }
  .topsearch-box { width: 100%; min-width: 0; min-height: 2.1rem; }
  /* A thumb target, exactly the size the four glyphs it replaced were given -
   * they were `min-width: 2.7rem; min-height: 2.4rem` for this reason and the
   * reason did not go away with them.
   *
   * **`border-color` and `color` are NOT here, and that is not an oversight.**
   * They belong to this rule by intent and would be DEAD in it: the base
   * `.ws-menu-btn` rule is further down the sheet and sets both, and a media
   * query buys no specificity. They live beside that base rule instead.
   * Caught by `verify_shell.check_no_declaration_is_overridden_into_silence`
   * on its first outing, in a sheet whose three previous instances of this
   * exact defect had been fixed an hour earlier by the person who then wrote
   * two more. */
  .ws-strip-fold { min-width: 2.7rem; min-height: 2.4rem; }
  /* **The word rule left this block on 2026-08-16.** It hid the word at phone
   * widths to buy `.topsearch` its width; the word is now a function of
   * whether the panel is OPEN, at every width (see `.ws-fold-word` and the two
   * `body[data-panel-*]` rules near the base `.fold`). A media-query copy here
   * could no longer decide anything — a rule that cannot fire reads as
   * coverage (L-052) — and the search box keeps the width it gained, because
   * both buttons left the bar entirely. */

  /* ---------------------------------------------------------- tabs on a phone
   *
   * Jon, 2026-08-06: *"i could not even see the tabs since they were squished.
   * I suggest they get represented some other way, maybe even a 'Tab' button
   * that also has the name of the current tab, and it is on its own row."*
   *
   * He is right and it was worse than cramped: measured at 390px, the strip was
   * pinned to `min-width: 4rem` = 64px and individual tabs rendered **13-14px
   * wide**. There is no honest way to fit a tab strip across a phone beside a
   * search box and two panel buttons, so it stops trying.
   *
   * The topbar wraps and the strip takes the whole second row, exactly as he
   * described. In that row the strip is a single button naming the current tab;
   * the list is a dropdown under it. */
  /* **The bar stops being 2.6rem tall here, and four rules still assumed it
   * was.** `.topbar` is `position: fixed`, so it paints over the workspace
   * rather than pushing it down, and `.workspace`, `.panel`, `.noscript-note`
   * and `.scrim` each offset themselves by the bar's height. Measured at
   * 390x780: the wrapped bar is **128.16px** and the workspace was padded by
   * **41.59px**, so the bar covered the top **86.56px of every view**.
   *
   * Jon found it on `/mine` - *"the top of Yours is cut off"* - because that
   * page's first element is a 45px `<h1>` sitting at 61.69-106.69, entirely
   * inside the covered band, so the heading was not merely clipped but gone.
   * An article view hides the same 86.56px and looks less obviously wrong.
   *
   * The value is a FALLBACK for a reader with JavaScript off, and it is
   * deliberately generous: `syncTopbar` measures the real bar and overwrites
   * it, and erring high costs a gap while erring low hides words.
   *
   * **It was 8.5rem until 2026-08-16 and 5.75rem is the same generosity
   * against a shorter bar.** Item 3 took the tab strip out of `.topbar-main`,
   * and the strip was the `flex: 1 0 100%` item forcing the taller wrap:
   * measured at 390, 360 and 320px, the bar goes **128.16px -> 82.58px**. It
   * still wraps, so this is still a fallback for something that varies -
   * 5.75rem is 92px against a measured 82.58, the same ~11% of headroom 8.5rem
   * (136px) gave 128.16. Leaving 8.5rem would have been the safe DIRECTION and
   * a 53px band of nothing at the top of every phone page.
   *
   * **It used to gain the indicator's row here too, and no longer does** -
   * the indicator moved into `#pane` on 2026-08-16 (Jon's items 1 and 2), so
   * the row it needs is `.ws-pview`'s top inset and not this offset. Still
   * generous, still overwritten.
   *
   * KEEP THIS ON ONE LINE AT TWO SPACES OF INDENT. `verify_shell`'s
   * JavaScript-off control reconstructs the pre-fix state by deleting this
   * rule with `r"  :root \{ --topbar-real: [^}]*\}\n"` and its `n_css == 1`
   * tripwire is what keeps that control honest; re-indenting or splitting the
   * rule takes the control out rather than the rule. The VALUE is a wildcard
   * and may change freely. */
  /* **2.75rem since the hamburger, and 5.75rem was measured waste.** The four
   * tool links going behind one control took the LAST thing forcing this bar
   * onto a second row: measured at 300, 320, 360, 390, 430, 500 and 544px it
   * is now a flat **39.39px** and wraps at none of them. Against that, 5.75rem
   * = 92px left a reader with JavaScript off looking at **52.61px of empty
   * gap** between the bar and the first line of text, at every phone width.
   * 2.75rem = 44px keeps ~4.6px of headroom over the 39.39 and scales with the
   * root font, because every term that decides the bar's height (`.fold`'s
   * `min-height: 2.4rem` plus a 1px border) is in rem too.
   *
   * `check_the_static_fallback_fits_the_bar` measures this against the real
   * bar in a JavaScript-OFF browser, which is the only place the value is ever
   * used, and bounds it in BOTH directions - too small hides words, too large
   * is the gap this paragraph is about. Nothing checked it before today, which
   * is why 8.5rem and then 5.75rem each outlived the bar they were tuned for. */
  :root { --topbar-real: 2.75rem; }
  /* The wrap belongs to the flex ROW, not to the bar. On `.topbar` it also
   * rewrapped the bar's own controls - measured, Settings and the Outline fold
   * moved onto a line of their own at 700px - and it let a second flex line
   * eat the space `align-content` was stretching, compressing `.ws-tab` from
   * 35.80 to 28.69px so the tabs no longer met the bar's bottom edge. */
  .topbar-main { flex-wrap: wrap; height: auto; }
  /* **The strip is one button here, and it is no longer in this bar at all.**
   * It moved into `#pane` on 2026-08-16 (Jon's item 3) and took the second row
   * with it: measured at 390, 360 and 320px, deleting it from `.topbar-main`
   * takes the bar from **128.16px to 82.58px**, because `flex: 1 0 100%` was
   * what forced that row. The bar still wraps — the remaining controls need
   * two lines on a phone — so `--topbar-real` is still a measurement and this
   * block's static fallback still has a job.
   *
   * **The phone never splits** (`splitFits()` is false below this very query),
   * so there is no second half for a second group to sit over. The two groups
   * stop being positioned and simply stack inside the dropdown, which is also
   * what keeps them from lying on each other: the server's occupancy census is a fact
   * about the STORE and stays true on a phone holding a laptop's split. */
  .tabstrip { padding: 0 .3rem; }
  /* One row, sized to the button rather than to the desktop's tab row. Still a
   * FIXED height, never `min-height` - `--ws-where-h`'s argument, which is
   * R3's: the pane's top inset must not depend on content. Measured: the
   * button is 35.19px and this leaves 4px above and 5.6px below it. */
  :root { --ws-tabs-h: 2.85rem; }
  /* **The menu bar's row is TALLER on the phone, not shorter**, and it is the
   * one number here set by the hand rather than by the type. At the desktop's
   * 2rem the buttons measure 31px, and this project's own floor for a thumb
   * target is the 38.4px the phone `.fold` rule asked for - so the controls
   * Jon most needs to reach on the device he most needs to reach them on would
   * have been the smallest in the app. 2.4rem gives 38.4px of button.
   *
   * Measured, 390x844: three buttons at 75.6 / 53.4 / 48.6px fill 186px of a
   * 390px row, so the extra height costs nothing in width and the row does not
   * scroll. Still FIXED, never `min-height` - `--ws-tabs-h`'s argument two
   * lines up, which is R3's.
   *
   * **THE SELECTOR IS `body[data-tabbar]…` AND NOT `:root`, SINCE 2026-09-19,
   * AND WITHOUT IT THIS RULE WAS EXACTLY INVERTED ON THE PHONE.** When
   * `--ws-tabbar-h` became `0px`-by-default that day, the raise landed at top
   * level as `body[data-tabbar]:not([data-fullscreen])` - specificity (0,2,1).
   * **A media query adds no specificity**, so a `:root` (0,1,0) declaration
   * inside this block could no longer win: the phone got the DESKTOP's 2rem
   * whenever the bar was SHOWN, losing the 38.4px thumb target this rule was
   * hand-set for, and kept 2.4rem whenever it was HIDDEN, which is the
   * reserved-for-nothing band Jon complained about - bigger on the phone than
   * the desktop case that prompted the fix.
   *
   * Measured by a non-author on the real stylesheet, four cells, before and
   * after, positive control first:
   *
   *     before   desktop book 32.00  composer 32.00 | phone book 38.39  composer 38.39
   *     after    desktop book 32.00  composer  0.00 | phone book 32.00  composer 38.39
   *     with this line               composer  0.00 | phone book 38.39  composer  0.00
   *
   * **A BREAKPOINT IS A POPULATION, AND A GEOMETRY FIX MEASURED AT ONE WIDTH
   * IS MEASURED OVER ONE MEMBER OF IT.** Both of Jon's faults live at both
   * widths and the second one was invisible at the 1100x900 the fix was driven
   * at. → L-528 */
  body[data-tabbar]:not([data-fullscreen]) { --ws-tabbar-h: 2.4rem; }
  /* The library bar's phone value, the menu bar's 2.4rem for the menu bar's
   * reason (a thumb target). `:root` and not a raise selector, because this
   * length is never raised: it is the same at every tab, so the base rule it
   * overrides is a `:root` one too, and `verify_chassis` asks exactly that. */
  :root { --ws-libhead-h: 2.4rem; }
  /* **THE LINTEL'S PHONE VALUE, ON THE SAME SELECTOR AS ITS RAISE.** Not
   * `:root`: a media query adds no specificity, so a `:root` declaration here
   * could not beat `body[data-lintel]:not([data-fullscreen])` outside one, and
   * the phone would keep the desktop's 2rem wherever the row SHOWS while
   * keeping this value wherever it is HIDDEN - which is precisely how
   * `--ws-tabbar-h` inverted both of Jon's faults on the phone (L-528).
   * `verify_chassis` asserts the shape over every `--ws-*-h` here and its
   * control is the pre-fix stylesheet, kept as a fixture.
   *
   * 2.4rem for the reason the line above it is 2.4rem: 38.4px is this
   * project's own floor for a thumb target, and a row a KIND puts its own
   * controls in is a row Jon presses. */
  /* Not a positioned half: the seam is meaningless with one pane. `display`
   * stays `flex` so the dropdown's own children lay out as they did, and
   * `[hidden]`'s (0,2,0) still beats this (0,1,0) - an empty group stays
   * gone. */
  .ws-tabgroup {
    position: static; flex-direction: column; gap: 0;
    background: transparent; border-bottom: none;
  }
  /* Two groups in one dropdown need a line between them, and only when both
   * are really there - `+` skips a hidden first group by itself. */
  .ws-tabgroup + .ws-tabgroup { border-top: 1px solid var(--line); }
  /* **The two panel buttons flank this row on a phone too**, and here the
   * groups are `position: static` inside a hidden dropdown, so their padding
   * does nothing — the inset has to be on the button that IS the strip.
   * `margin` rather than the strip's own padding, because `.tabstrip` is the
   * positioning context for `.ws-tabdrop`, which must stay full width. */
  .ws-tabmenu {
    display: flex; align-items: center; gap: .45rem;
    /* **`calc`, not `auto`, and `auto` was measured to be wrong.** A `<button>`
     * shrink-wraps its content at `width: auto` even as a block-level flex
     * container - seen at 390px, where it ran 50..145px of a 390px row instead
     * of filling it. It cannot be `100%` either, because that resolves against
     * `.tabstrip`'s content box and the two margins would then overflow it. The
     * expression names both variables, so it cannot drift from the margins
     * beside it.
     *
     * The insets are here rather than as padding on `.tabstrip` because
     * `.ws-tabdrop` is positioned against that same box: padding would narrow
     * the dropdown, which is a full-width list of tabs and wants to stay one. */
    width: calc(100% - var(--ws-fold-l) - var(--ws-fold-r));
    margin-left: var(--ws-fold-l); margin-right: var(--ws-fold-r);
    font: inherit; font-size: .85rem; text-align: left; cursor: pointer;
    background: var(--pane); color: var(--ink);
    border: 1px solid var(--line); border-radius: 5px;
    padding: .4rem .6rem; min-height: 2.2rem;
  }
  .ws-tabmenu-now {
    flex: 1 1 auto; min-width: 0; overflow: hidden;
    text-overflow: ellipsis; white-space: nowrap;
  }
  .ws-tabmenu-count {
    flex: 0 0 auto; color: var(--dim); font-size: .75rem;
    border: 1px solid var(--line); border-radius: 999px; padding: 0 .4rem;
  }
  .ws-tabmenu[aria-expanded="true"] { border-color: var(--accent); }

  /* The list drops down OVER the text rather than pushing it - a phone has no
   * vertical room to spare, and a reader choosing a tab is not reading. One
   * positioned wrapper holds both the list and the "+", so they cannot stack
   * on each other. */
  .ws-tabdrop {
    display: none;
    position: absolute; top: 100%; left: 0; right: 0; z-index: 20;
    max-height: 60vh; overflow-y: auto;
    background: var(--pane); border: 1px solid var(--line);
    border-radius: 5px; box-shadow: 0 6px 18px rgba(0,0,0,.28);
  }
  .tabstrip.ws-tabs-open .ws-tabdrop { display: block; }
  .tabstrip .tabs { display: flex; flex-direction: column; gap: 0; }
  /* A tab in the dropdown is a full-width row, not a tab shape. */
  .tabstrip.ws-tabs-open .ws-tab {
    max-width: none; margin: 0; border: none; border-radius: 0;
    border-bottom: 1px solid var(--line); padding: 0 .2rem 0 .6rem;
  }
  .tabstrip.ws-tabs-open .ws-tab:last-child { border-bottom: none; }
  .tabstrip.ws-tabs-open .ws-tab.active { background: var(--accent-soft); }
  .tabstrip.ws-tabs-open .ws-tab-label {
    flex: 1 1 auto; padding: .55rem .1rem; font-size: .88rem;
  }
  .tabstrip.ws-tabs-open .ws-tab-close { padding: .5rem .55rem; font-size: 1.1rem; }
  /* `+` moves inside the open list, as a row of its own, because on a phone it
   * has nowhere to sit beside a full-width button. */
  /* The "+" is the last row of the dropdown - on a phone it has nowhere to sit
   * beside a full-width button, and "add a tab" belongs with "choose a tab". */
  .tabstrip .ws-newtab-form { display: block; }
  .tabstrip .ws-newtab {
    width: 100%; aspect-ratio: auto; margin: 0;
    border: none; border-top: 1px solid var(--line);
    border-radius: 0; padding: .55rem 0; min-height: 2.2rem;
    background: transparent;
  }
  .notabs { display: none; }

  .view { padding: .8rem .7rem 4rem; }
  /* Tapping the page behind an open panel closes it.
   *
   * **Same `+ --ws-tabs-h` as `.panel`, and here it is purely what the reader
   * sees.** This is `z-index: 15` against the strip's 21, so a tap in the
   * strip's row already reached the strip and never this — the offset changes
   * no behaviour. What it changes is the paint: on a phone the strip's visible
   * furniture is one `.ws-tabmenu` button inset from both edges, so with the
   * scrim starting at the strip's TOP its 35% black showed through in the
   * slivers either side of that button, as two dark notches in the tab row.
   * They arrived with item 3 for the same reason the panel's overlap did —
   * `--topbar-real` used to include this row — and they are the cosmetic half
   * of one mechanism, so they are fixed with it rather than left to be
   * rediscovered as their own bug. Keeping the two rules identical is also
   * what lets the guard state ONE property over both. */
  .scrim {
    position: fixed;
    inset: calc(var(--topbar-real) + var(--ws-tabs-h)) 0 0 0; z-index: 15;
    background: rgba(0,0,0,.35);
  }
}
.scrim { display: none; }
@media (max-width: 34rem) { .scrim.on { display: block; } }

/* --------------------------------------------------------------------------
   Phase 3 - the plates, first class.

   Every class here is prefixed `plate-`. That is not decoration: `render.py`
   owns the class namespace of rendered content and the shell is what moves
   (LESSONS L-009). `.tab` cost 218,936 tab stops in the corpus a bordered flex
   box. `verify_shell.py::check_class_collisions()` sweeps the renderer's real
   vocabulary across all 51 and fails on any intersection with this file.
   -------------------------------------------------------------------------- */

.plate-grid {
  list-style: none;
  margin: 1rem 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
  gap: 1rem;
}

.plate-tile {
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0.5rem;
  background: var(--panel, #fbf9f4);
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.plate-tile > a {
  text-decoration: none;
  color: inherit;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  min-width: 0;
}

.plate-thumb {
  width: 100%;
  height: 8rem;
  object-fit: contain;
  background: #fff;
  border: 1px solid var(--line);
}

.plate-nothumb {
  height: 8rem;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--dim, #7a7264);
  font-size: 0.8rem;
  border: 1px dashed var(--line);
}

.plate-caption {
  font-size: 0.85rem;
  line-height: 1.3;
  overflow-wrap: anywhere;
}

.plate-badges {
  margin-top: 0.4rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

.plate-badges > span,
.plate-strip .plate-fmt {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 0.05rem 0.35rem;
  border-radius: 2px;
  background: var(--line);
  color: var(--ink, #2b2721);
}

/* **SCOPED TO `.plate-badges >`, and without that these four paint nothing.**
 * The shared rule above is `.plate-badges > span` — one class AND one type,
 * (0,1,1) — while a bare `.plate-fmt` is (0,1,0), so its neutral tan won every
 * badge on the grid regardless of source order (L-085). Measured at `316c05e`
 * in a real browser: `LLS_FSBINFOGRAPHICS` 60 of 60 `.plate-fmt` and
 * `LLS_ESVAUDIO` 60 `.plate-audio` + 60 `.plate-ext` ALL at
 * `rgb(216,210,196)` = `#d8d2c4` with `rgb(35,32,27)` text.
 *
 * The one that mattered is `.plate-ext`. It says **"not in the archive"** — a
 * plate the catalogue names and the backup does not hold, on **1,597 of 4,268**
 * catalogue entries — and it was painting the same tan as an ordinary `JPG`
 * format tag. This app is the behavioural confirmation that the backup is
 * complete; that badge is the sentence where it says otherwise.
 *
 * `.plate-strip .plate-fmt` is repeated because the shared rule names it at
 * (0,2,0) too, and there source order is what decides. */
.plate-badges > .plate-fmt,
.plate-strip .plate-fmt { background: #2f6f4f; color: #fff; }
.plate-badges > .plate-video { background: #6f4f2f; color: #fff; }
.plate-badges > .plate-audio { background: #4f3f6f; color: #fff; }
.plate-timings ul { list-style: none; padding: 0; }
.plate-timings li { padding: 0.15rem 0; }
.plate-badges > .plate-ext { background: #8a3d3d; color: #fff; }
.plate-nodisplay { opacity: 0.85; }

/* One plate, at full size. The PDF gets real height because it IS the plate -
   a 315-strong set of print-ready vector documents is the highest-quality
   thing in the archive and a thumbnail-sized viewer wastes it. */
.plate-pdf {
  width: 100%;
  height: min(80vh, 900px);
  border: 1px solid var(--line);
  background: #fff;
}

.plate-full {
  max-width: 100%;
  height: auto;
  background: #fff;
  border: 1px solid var(--line);
}

.plate-gap {
  border: 1px dashed var(--line);
  border-radius: 3px;
  padding: 1rem;
  background: var(--panel, #fbf9f4);
}

.plate-gap p { margin: 0.6rem 0 0; }
.plate-src code { font-size: 0.75rem; overflow-wrap: anywhere; }

.plate-crumb { font-size: 0.85rem; color: var(--dim, #7a7264); }
.plate-of { margin-left: 0.5rem; }
.plate-renditions { font-size: 0.8rem; overflow-wrap: anywhere; }
.plate-shown { font-weight: 600; }

.plate-captions { margin: 1rem 0; }

/* NOT display:flex, and the reason is L-009 wearing a different hat. A rendered
   caption is a bare run of <p class="pN"> siblings with no wrapper of its own -
   render.py emits document structure, not a container - so flex on .plate-cap
   made every paragraph a flex ITEM and laid a four-line numbered legend out as
   four side-by-side columns in the wrong reading order, on 463 plate pages.
   A shell class does not have to COLLIDE with a renderer class to break
   renderer output; it only has to be its parent. Layout that assumes a single
   child does not belong above content whose shape the renderer owns. */
.plate-cap { margin: 0.5rem 0; }
.plate-cap > .plate-role { display: block; margin-bottom: 0.15rem; }
.plate-cap > p { margin: 0.2rem 0; }
.plate-role {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--dim, #7a7264);
}
.plate-cap-title { font-size: 1.05rem; }
.plate-cap-copyright, .plate-cap-credit { font-size: 0.85rem; color: var(--dim, #7a7264); }

.plate-where ul, .plate-refs { list-style: none; padding: 0; }
.plate-refs li { padding: 0.15rem 0; }
.plate-times { color: var(--dim, #7a7264); font-size: 0.8rem; }
.plate-nowhere, .plate-noorphans, .plate-empty { color: var(--dim, #7a7264); }

.plate-pager { display: flex; gap: 1rem; margin: 1rem 0; }

/* The orphan census. It is on the gallery page and not on a page of its own,
   because "a user can see what the tool does not know" only holds if they see
   it where they would notice it missing. */
.plate-orphans {
  margin-top: 2rem;
  border-top: 1px solid var(--line);
  padding-top: 1rem;
}
.plate-orphan-kinds { list-style: none; padding: 0; }
.plate-orphan-kinds > li { margin: 0.6rem 0; }
.plate-why { color: var(--dim, #7a7264); }
.plate-orphan-list {
  list-style: none;
  padding: 0.4rem 0 0 1rem;
  max-height: 18rem;
  overflow-y: auto;
  font-size: 0.8rem;
}
.plate-size { color: var(--dim, #7a7264); margin-left: 0.5rem; }
.plate-more { color: var(--dim, #7a7264); }

/* The strip under an article: the plates that page references. */
.plate-inarticle {
  margin-top: 2rem;
  border-top: 1px solid var(--line);
  padding-top: 1rem;
}
.plate-strip {
  list-style: none;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}
.plate-strip > li { width: 10rem; }
.plate-strip a {
  text-decoration: none;
  color: inherit;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}
.plate-strip .plate-thumb { height: 6rem; }
.plate-dropped { font-size: 0.8rem; color: #8a3d3d; }

/* --------------------------------------------------------------- search
 *
 * Phase 4. Everything below styles the *result list*, which is plain text the
 * server escaped — never rendered corpus markup. That is the whole reason the
 * results are plain: per-resource stylesheets are generated from each
 * resource's own format tables, so `.f14` means one thing in the KJV and
 * something else in the Lexham Bible Dictionary, and a result list holds rows
 * from several resources at once.
 *
 * L-009 applies twice over. No class here may collide with one `render.py`
 * emits (`tab`, `xmedia`, `xres`, `f<N>`, `p<N>`, `fld-<name>`), and none may
 * be the *parent* of renderer output with layout that assumes a single child —
 * `.plate-cap { display: flex }` collided with nothing and still mangled 463
 * captions by making each of a caption's `<p>` siblings a flex item. Nothing
 * under `.search-view` ever contains renderer output, which is what makes
 * flex safe here and was not safe there.
 */

/* THE SEARCH BOX'S OWN BUTTONS - Jon, 2026-09-01: *"there could be some little
 * buttons like on VSCode."* `_search_syntax.html`'s `search_toggles` is the one
 * producer of the markup; this is the one producer of where it sits.
 *
 * **INSIDE THE FIELD, NOT BESIDE IT, AND THAT IS THE TOPBAR'S ARITHMETIC
 * RATHER THAN A STYLE CHOICE.** `.topsearch` is the ONLY elastic item in
 * `.topbar-main` - every other child is `flex: 0 0 auto` - and `.topbar` is
 * `position: fixed`, so anything that does not fit that row is UNREACHABLE
 * rather than scrolled off it. A rigid sibling would therefore be taken out of
 * the one box that yields, at every width, and the band block's whole
 * derivation is written around that box being the thing that gives. Absolutely
 * positioned inside the field, the toggles cost the ROW nothing at all: they
 * are not children of `.topbar-main`, `.topsearch`'s own box does not move,
 * and what they consume is the field's text width, which is bounded here in
 * one place.
 *
 * **`.sx-field` is the containing block and each caller wraps its own input in
 * one.** The alternative - positioning against the form - is wrong in two of
 * the three: `.search-form` is `flex-wrap: wrap` and holds a submit button and
 * a scope caption, and `.ws-tabsearch-form` holds a Search button, so `right:
 * 0` would land the toggles on top of a button rather than in the field.
 *
 * **THE FIELD CARRIES THE BORDER AND THE TOGGLES ARE A REAL FLEX ITEM - THEY
 * ARE NOT LAID OVER THE TEXT.** The first build absolutely positioned the
 * strip and reserved room for it with `padding-right`, which is two numbers
 * that have to agree; measured in a browser at five widths they did not, by
 * **1.6px** - the strip began just inside the text area at every one. That gap
 * is a transcribed census (L-035): it moves with the font, with the reader's
 * zoom, with a fourth control, and with whatever "Aa" measures in whatever
 * face the device has. Making the field the visual box and the strip an
 * ordinary flex item removes the arithmetic rather than correcting it - there
 * is nothing left that could overlap, at any size, by construction.
 *
 * The input therefore gives up its own border, background and focus ring to
 * the field. `.sx-field > input` is (0,1,1) against each box's own single
 * class, so it wins on specificity without an `!important` and without
 * touching the three rules that size those boxes. */
.sx-field {
  display: flex; align-items: center; min-width: 0; flex: 1 1 auto;
  /* **`flex-wrap: wrap`, 2026-09-03, and it is what stops the results page
   * panning sideways on a phone.** The toggle strip below is rigid by design
   * (the input yields), and v25's page-size `<select>` made it 394px wide -
   * wider than a 320 or 390px screen with the input already at zero, so the
   * strip ran past the field and the DOCUMENT scrolled sideways at 320, 390,
   * 430 and 600px (measured on the pre-change build; `verify_shell`'s
   * *"makes no width scroll sideways"* line went red the first time that
   * suite ran after v25 landed). Wrapping puts the strip under the query box
   * only when the two no longer fit on one line; at every width where they
   * did, nothing moves. */
  /* **SCOPED TO `.search-form` BELOW, NOT DECLARED HERE - one red run to
   * learn.** `.sx-field` is SHARED with the topbar's search box, and the topbar
   * is a FIXED `--topbar-h` row (R3): declared here, the bar's field wrapped at
   * phone widths, the bar grew 42.6 -> 57.4px, and two `verify_shell` checks
   * went red (controls 436px over the text; the scripting-off fallback 13px
   * under the bar). The results page is in normal flow and may grow a line;
   * the bar may not. */
  border: 1px solid var(--line); border-radius: 4px;
  background: var(--pane);
  /* The strip's own breathing room, so a face never touches the border. */
  padding-right: .15rem;
}
.sx-field > input {
  border: 0; background: transparent; min-width: 0; flex: 1 1 auto;
}
/* **The ring moves to the field, and the input's own is suppressed** - two
 * rings, one inside the other, is what you get otherwise. `:focus-within`
 * lights it for the toggles too, which is what a keyboard reader needs to see
 * when tabbing into the strip. */
.sx-field > input:focus, .sx-field > input:focus-visible { outline: none; }
.sx-field:focus-within { outline: 2px solid var(--accent); outline-offset: -1px; }

.sx-togs {
  display: flex; align-items: center; gap: .1rem;
  /* Rigid: the input is the thing that yields, exactly as `.topsearch` is the
   * thing that yields in the bar one level up. */
  flex: 0 0 auto;
  /* **AND IT SCROLLS SIDEWAYS ONCE IT IS WIDER THAN THE FIELD ITSELF** -
   * Jon's standing rule, 2026-09-02: *"Any control or anything that goes off
   * the side should be scrollable anyways, no matter what, you don't know
   * what zoom level anyone might use."* With `.sx-field` wrapping (above)
   * this strip gets a whole line of its own on a phone, and at 320px that
   * line is still 74px short of the strip's 394 - so the row scrolls, the
   * `.ws-tabbar` pair's own shape, rather than pushing the page. The book
   * select is FIRST in the strip, so at `scrollLeft` 0 it is on screen.
   * `overflow-y: hidden` keeps the height fixed. */
   * **Both declarations live in the `.search-form`-scoped rule directly
   * below, for the reason on `.sx-field`: the topbar reads this rule too.** */
}
/* **THE RESULTS PAGE'S STRIP IS NOT IN ITS QUERY FIELD ANY MORE - Jon,
 * `results-menu-move-or-copy` -> `move-to-menu`, 2026-09-03.** `.search-form`
 * holds the box alone now; the strip, the order, the page size and the
 * search-within box are rendered into `.search-refine` beneath it, which is
 * `hidden` inside a tab (the Filters row copies it) and visible on the
 * standalone page. So the `.search-form .sx-field { flex-wrap: wrap }` pair
 * that used to sit here had nothing left to wrap and is deleted rather than
 * kept as coverage (L-052); what it bought - a phone that does not pan
 * sideways over a 394px strip - is bought below on the block that now holds
 * the strip, by the same mechanism (Jon's standing rule: anything off the
 * side scrolls).
 *
 * **NO author `display` on `.search-refine` itself.** It is `hidden` inside
 * every tab, and `[hidden]` is a user-agent rule that any author `display`
 * beats regardless of specificity (L-024) - the flex goes on the FORM inside
 * it. The belt below says the same thing a second way. */
.search-refine { margin: .35rem 0 .1rem; min-width: 0; }
.search-refine[hidden] { display: none; }
/* On the page the form may take a second line - it is in normal flow and a
 * line is what a page has; inside the Filters row the same form is `nowrap`
 * (below), because that band is a fixed `--ws-tabtool-h` with
 * `overflow-y: hidden` and a wrapped line there paints nothing. */
.search-refine .ws-tabresults-form { flex-wrap: wrap; row-gap: .25rem; }
.search-refine .sx-togs {
  max-width: 100%; overflow-x: auto; overflow-y: hidden;
  scrollbar-width: none;
}
/* **THE SEARCH-WITHIN LABEL GETS THE SAME CAP, AND FOR THE SAME REASON — and
 * it is here because widening the box to `32ch` broke Jon's rule on 2026-09-06.**
 * `check_a_control_past_the_edge_of_the_tab_row_can_be_reached` caught it on
 * the board: *"and it makes no width scroll sideways — sideways pan at [320]"*.
 * Reproduced and ATTRIBUTED rather than tuned
 * (`<scratch>/repro_pan.py`): the `<label>` is 69px of *"Search within"* plus a
 * 214px box, **288px with its right edge at 323 on a 320px document.** The box
 * never shrank, because the line it wraps onto is the FORM's and the form is
 * not itself capped — so nothing on that line was ever over-constrained and
 * flex had no reason to shrink anything.
 *
 * `max-width: 100%` caps the label at the line it is on, which makes the box's
 * existing `flex-shrink: 1` and `min-width: 0` do the work they were always
 * there for. **Priced on the running app before this rule was written:**
 *
 *     320px   pan 3 -> 0    label 288 -> 250    box 214 -> 176
 *     390px   pan 0 -> 0    label 288 -> 288    box 214 -> 214
 *     430 / 600 / 1400      unchanged at every one
 *
 * So the box keeps its full width everywhere there is room and gives up only
 * what a 320px page cannot hold — never a magic number, and never the box's
 * declared width, which stays `32ch` (PLAYBOOK §4: write the mechanism, do not
 * tune the threshold).
 *
 * **SCOPED TO `.search-refine`, NOT THE ROW.** The Filters row's copy lives
 * under `.ws-tabtool` and cannot match this selector. That row is DELIBERATELY
 * a scrolling one — `.ws-tabtool > * { flex: 0 0 auto }` exists so a 6px pen
 * swatch is never squeezed — and capping it there would shrink the box straight
 * back to the size Jon complained about. Driven, not assumed: the row's box is
 * unchanged at every width with this rule live. */
.search-refine .ws-tabresults-within { max-width: 100%; }
.sx-tog { position: relative; display: inline-flex; cursor: pointer; }

/* **VISUALLY HIDDEN AND STILL FOCUSABLE.** Never `display: none` and never
 * `hidden`: both take the control off the keyboard entirely, and this is the
 * only affordance for two operators. It keeps a 1px box so a focus ring has
 * something to sit on. */
.sx-tog > input {
  position: absolute; inset: 0; width: 100%; height: 100%;
  margin: 0; opacity: 0; cursor: pointer;
}
.sx-tog-face {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.3rem; height: 1.3rem; padding: 0 .15rem;
  border: 1px solid transparent; border-radius: 3px;
  font-size: .78rem; line-height: 1; font-weight: 600;
  color: var(--dim); background: transparent;
  user-select: none;
}
.sx-tog:hover .sx-tog-face { color: var(--ink); border-color: var(--line); }
/* Every colour is a token, so the dark block at the top of this file flips all
 * four with no rule of its own here (the theme rule this sheet states). */
.sx-tog > input:checked + .sx-tog-face {
  color: var(--accent); background: var(--accent-soft);
  border-color: var(--accent);
}
.sx-tog > input:focus-visible + .sx-tog-face {
  outline: 2px solid var(--accent); outline-offset: 1px;
}

/* **THE BOOK FILTER - v21, and its WIDTH IS THIS SHEET'S RATHER THAN THE
 * OPTION TEXT'S.** `.sx-togs` is `flex: 0 0 auto` and the input is the thing
 * that yields, so a `<select>` sized by its longest option takes the field
 * away from the words being typed: `LLS_46.50.8` publishes
 * `The Gospel according to Matthew`, and `LLS_LGCYSTNDRDBBLSB` publishes
 * `DEUTERONOMY`. Capped and ellipsised, so the control's footprint is a
 * constant of the sheet and cannot move when a resource with longer book
 * names is retrieved (L-035: never let a census set a geometry).
 *
 * **ONE element carries the cap.** The label shrink-wraps the select rather
 * than restating its width - two numbers that must agree is exactly what
 * L-367 charged for, at 1.6px a width.
 *
 * It is drawn as one of the faces: same height, same border idiom, same
 * tokens, so the dark block at the top of this file flips it with no rule of
 * its own. `.sx-book-on` is the SERVER'S resolved answer, never the raw
 * parameter - the same arrangement `case_on` and `script_on` have - so the lit
 * state and the narrowing cannot disagree (L-017). `:has()` is deliberately
 * not used to derive it: this app is read on Firefox Android and Samsung
 * Internet and that selector's support there has never been verified here. */
/* **AND THE TEXT/NOTES SELECT SHARES ALL OF IT (v23), never a second copy.**
 * Everything the paragraph above says is true of that control too - it is the
 * same idiom, it needs the same dark-mode reasoning, and it is on the same row
 * on the same phone. Sharing the selector means the option-list colours, the
 * dark block at the top of this file and BOTH narrow-screen steps reach it with
 * no second set of numbers for anyone to keep equal (L-017, L-367). */
.sx-book,
.sx-part,
.sx-order,
/* v25. It JOINS the existing rule rather than getting one of its own, which is
 * what makes the dark-mode pair below true of it by construction rather than
 * by a second declaration somebody has to remember to keep in step (L-017). */
.sx-per {
  max-width: 8rem;
  height: 1.3rem; padding: 0 .1rem;
  border: 1px solid transparent; border-radius: 3px;
  font: inherit; font-size: .72rem; line-height: 1;
  /* **BOTH COLOURS ARE STATED, and `transparent` would be a dark-mode bug the
   * page cannot show you.** A `<select>`'s OPTION LIST is drawn by the browser
   * from the element's own `color` and `background-color`, outside the
   * document - so it cannot be screenshotted, and a light-mode probe passes on
   * it either way. Left transparent, the popup falls back to the UA's white
   * while the text keeps a token meant for a dark pane: in dark mode
   * `--dim` is `#9a9284` on white, and any rule that later moved this to
   * `--ink` (`#e7e2d6`) would make the list of 66 books invisible. Jon reads
   * on a phone, in dark. Both are tokens, so the dark block at the top of this
   * file flips the pair together and neither can be left behind. */
  color: var(--dim); background: var(--pane);
  cursor: pointer;
}
.sx-tog-book:hover .sx-book,
.sx-tog-order:hover .sx-order,
.sx-tog-per:hover .sx-per,
.sx-tog-part:hover .sx-part { color: var(--ink); border-color: var(--line); }
.sx-book:focus-visible,
.sx-order:focus-visible,
.sx-per:focus-visible,
.sx-part:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.sx-book.sx-book-on,
.sx-part.sx-part-on {
  color: var(--accent); background: var(--accent-soft);
  border-color: var(--accent);
}

/* **ON A PHONE THE BOOK NAME GIVES WAY TO THE WORDS BEING TYPED.** Measured
 * at 390px in dark - Jon's own screen - the 8rem cap left the query box
 * **118px** of a 355px field, i.e. the dropdown had taken more than half of
 * it. The faces are ~20px each and the select is the only control here that
 * can reasonably yield, so it is the one that does. At 5.5rem the box gets
 * ~40px back. `PHONE` is 34rem, the same breakpoint the rest of this sheet
 * turns on; the 22rem block below narrows it again, and the two are
 * independent steps rather than a pair anybody has to keep equal (L-367).
 *
 * A truncated name is not a lost one: the reader chose it, the full list is in
 * the popup, and `title` on the label carries it. Hiding the control instead
 * would take away, on the one device Jon reads on, the thing he asked for. */
@media (max-width: 34rem) {
  .sx-book { max-width: 5.5rem; }
  /* **THE TEXT/NOTES SELECT YIELDS HARDER THAN THE BOOK ONE, and that is a
   * measurement rather than symmetry.** At 390px in dark - Jon's own screen -
   * two controls at 5.5rem left the query box **68.5px** of a 355px field,
   * which is BELOW the 118px this block's own paragraph above records as the
   * state that made the book cap a defect in the first place. Its three
   * options are told apart by their first word ("Text and...", "Reading...",
   * "Footnote..."), while a book name is only itself - so this is the control
   * that can lose characters without losing meaning, and it is the one that
   * does. Hiding it instead is what the paragraph above refuses. */
  .sx-part { max-width: 3.6rem; }
  /* **THE ORDER CONTROL MAKES A THIRD SELECT, AND THREE DO NOT FIT BESIDE THE
   * WORDS BEING TYPED - SO ON THE RESULTS PAGE THE STRIP TAKES A ROW OF ITS
   * OWN.** Measured at 390px with the control added and the field still on one
   * line: `book` 88 + `part` 56 + `order` 83 + three faces left the query box
   * **19px** of a 320px field. Yielding harder was not an available answer -
   * even at the `part` cap all three come to 202px - and hiding one is what
   * this block's paragraphs above refuse twice.
   *
   * **`.search-form` ONLY, and that scope is load-bearing rather than tidy.**
   * The identical `.sx-field` sits inside `.ws-tabsearch-form`, which is a
   * FIXED `--ws-tabtool-h` band with `overflow-y: hidden`: a wrapped second
   * line there paints nothing at all, which is the trap `.ws-tabsearch-form`
   * was given a class of its own to avoid. The results page is a page and can
   * take the row.
   *
   * **It also hands back the width Jon was told about on 2026-09-03** - his
   * query box was 98.9px of a 355px field with two selects on the row, and he
   * was invited to ask for it back. He gets the whole field.
   *
   * The border stays around both lines, so it reads as one field with its
   * controls under it rather than as two unrelated boxes. */
  /* **THE THREE `.search-form` RULES THAT CLOSED THIS BLOCK ARE GONE
   * (2026-09-03).** They wrapped the strip under the query box on a phone;
   * the strip is not in that field any more - it is on the tab's Filters row
   * (`.search-refine`, and see that rule) - so all three matched nothing and
   * are deleted rather than left reading as coverage (L-052). The paragraph
   * above is kept as the measurement of WHY three selects cannot share a
   * field with the words being typed, which is the argument for the move. */
}

/* **THE NARROWEST SCREENS GET SMALLER FACES, NOT FEWER.** The strip cannot
 * overlap the text at any width now, but it can still crowd it: the phone
 * gives `.topsearch` `flex: 1 1 4rem`, so 64px is the declared floor for that
 * whole field. The faces shrink instead of the controls being hidden - hiding
 * them would take away, on the one device Jon reads on, the two controls he
 * asked for three times. */
@media (max-width: 22rem) {
  .sx-tog-face { min-width: 1.05rem; height: 1.15rem; font-size: .7rem; }
  /* The book filter shrinks with them rather than being hidden, for the
   * paragraph above's reason: this is the control Jon asked for. */
  .sx-book { max-width: 5rem; height: 1.15rem; font-size: .66rem; }
  .sx-part { max-width: 3.2rem; height: 1.15rem; font-size: .66rem; }
  /* The order control shrinks with them for the same reason. Its two labels
   * differ at the SECOND character, so it can lose the tail and still be
   * read - which is exactly the property `part_select`'s own measurement
   * chose its one-word labels for. */
  .sx-order { max-width: 4.6rem; height: 1.15rem; font-size: .66rem; }
}

.search-view { max-width: 52rem; }
.search-form { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.search-box {
  flex: 1 1 18rem;
  padding: 0.4rem 0.6rem;
  border: 1px solid var(--line);
  border-radius: 0.25rem;
  background: var(--pane);
  color: var(--ink);
  font: inherit;
}
.search-scope { font-size: 0.85rem; color: var(--dim); }
.search-count { color: var(--dim); margin: 1rem 0 0.5rem; }
.search-why { display: block; font-size: 0.8rem; margin-top: 0.2rem; }
.search-note {
  border-left: 3px solid var(--accent);
  padding: 0.5rem 0.75rem;
  background: var(--accent-soft);
}

.search-hits { padding-left: 1.6rem; }
.search-hit { margin: 0 0 1.1rem; }
.search-hit-link { text-decoration: none; color: inherit; }
.search-hit-title { font-weight: 600; color: var(--accent); }
.search-hit-link:hover .search-hit-title { text-decoration: underline; }
/* **The crumb's separators and the verse, 2026-09-02.** The label is now
   `JOSHUA · CHAPTER 11 · v4` rather than `CHAPTER 11`, and the book is the
   part Jon said was missing - so NOTHING here dims a crumb segment. Only the
   punctuation between them recedes, which is what lets three segments read as
   one place instead of three words. Every colour is a token, so dark mode is
   right by construction (the rule this block already follows).
   `--dim` at 0.75 weight is the same treatment `.search-field` gets. */
.search-hit-sep { color: var(--dim); font-weight: 400; }
.search-hit-verse { font-weight: 400; }
/* `note on` before the place a footnote is cited from. Lower case and small,
   because it qualifies the place rather than being part of it - and 27% of
   hits on a Bible are footnotes, so this is a common row and must not shout. */
.search-hit-kind {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-weight: 500;
  color: var(--dim);
}
.search-hit-where { font-size: 0.8rem; color: var(--dim); margin-left: 0.5rem; }
.search-hit-res { color: var(--dim); }
.search-snip { margin: 0.2rem 0 0; line-height: 1.45; }
.search-field {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--dim);
  border: 1px solid var(--line);
  border-radius: 0.2rem;
  padding: 0 0.25rem;
  margin-right: 0.35rem;
}
.search-noanchor { font-size: 0.75rem; color: var(--dim); }

/* **The `both-forms-labelled` label** (Jon, 2026-08-31): this hit holds a
 * different spelling of a Hebrew or Greek word than the one he typed.
 *
 * It borrows `.search-field`'s pill rather than `.search-noanchor`'s plain dim
 * text, because the two say different kinds of thing: `.search-noanchor`
 * explains what a LINK will do, and this explains why a RESULT is in the list
 * at all. It is also named in the prose above the results, where a pill is
 * what the sentence points at.
 *
 * **Every colour is a token, so dark mode is right by construction rather than
 * by a probe** - Jon reads on a phone, in dark. No literal anywhere in here. */
.search-loose {
  font-size: 0.7rem;
  color: var(--dim);
  border: 1px solid var(--line);
  border-radius: 0.2rem;
  padding: 0 0.25rem;
  white-space: nowrap;
}
.search-widened .search-loose { margin: 0 0.1rem; }

/* The one piece of markup in a snippet. `ws-` prefixed because the shell is
 * what moves when a name is contested.
 *
 * **The rule is unchanged; its reason is not.** This used to say the renderer's
 * namespace "is shared with logos-linux's epub.py", and since decisions item 11
 * `render.py` is this project's outright — logos-linux is informed of changes,
 * not asked. The renderer still owns the namespace, for a reason that was
 * always the stronger one: its class names are GENERATED FROM THE CORPUS
 * (`f14`, `p3`, `fld-*` come out of each resource's own format tables, and
 * `fld-` is open-ended because a resource may invent a field name), so they
 * cannot be renamed to avoid a collision. The shell's names can. */
mark.ws-hit {
  background: var(--accent-soft);
  color: inherit;
  font-weight: 600;
  border-radius: 0.15rem;
  padding: 0 0.1em;
}

.search-pager { display: flex; gap: 1rem; margin-top: 1rem; }
.search-byres { margin-top: 2rem; border-top: 1px solid var(--line); padding-top: 1rem; }
.search-res-list { list-style: none; padding: 0; columns: 2; }
.search-res-list > li { break-inside: avoid; margin: 0.2rem 0; }
.search-res-n { color: var(--dim); font-size: 0.85rem; }
.search-about { margin-top: 2rem; border-top: 1px solid var(--line); padding-top: 1rem; }
/* **THE SEARCH SYNTAX, and `.search-help` is gone rather than left standing.**
   That class had exactly one rule and, after `_search_syntax.html` became the
   one producer, no markup at all - a rule that cannot match anything reads as
   coverage in a review (L-052). Repointed, same `code` treatment.

   **Tokens only, no literal colours**, so the dark theme Jon actually reads in
   comes for free from the `prefers-color-scheme` block that redefines them -
   a light-mode probe passes on a dark-mode defect, and this is chrome he will
   only ever see at night. */
.search-syntax {
  margin: 0.75rem 0 0;
  font-size: 0.9rem;
}
.search-syntax > summary {
  cursor: pointer;
  color: var(--dim);
  /* The row is a real control: give it a target a thumb can hit without
     making it look like a button. */
  padding: 0.25rem 0;
}
.search-syntax[open] > summary { color: var(--ink); }
.search-syntax-list {
  /* `dt` and `dd` side by side. `max-content` keeps the operator column just
     wide enough for the longest one (`title:word`) instead of guessing a rem
     value that the next operator breaks. */
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.2rem 0.9rem;
  margin: 0.4rem 0 0;
}
.search-syntax-list dt { color: var(--ink); }
.search-syntax-list dd { margin: 0; color: var(--dim); }
.search-syntax-limits {
  margin: 0.6rem 0 0;
  color: var(--dim);
}
.search-syntax code {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 0.2rem;
  padding: 0 0.25rem;
}
.search-nobody { margin-top: 0.75rem; }
.search-nobody ul { font-size: 0.9rem; }

/* --------------------------------------------------------------------------
   Phase 5 - /diagnostics: what this tool does not know.

   Every class here is prefixed `dx-`. L-009: the renderer owns the class
   namespace of rendered content and the shell is what moves. Nothing on this
   page IS rendered content - it is counts and prose about the corpus, never
   corpus markup - so the parent-of-rendered-output hazard that flattened 463
   plate captions cannot arise here. The prefix is still not optional: the
   collision guard sweeps this sheet against every class the renderer emits
   across all 51 resources, and the `fld-<name>` family is open-ended because
   a resource may invent a field name tomorrow.

   The tables are wrapped in their own horizontal scroll rather than shrinking
   the page, because a seven-column table of counts on a 360px phone has to go
   somewhere and squeezing it makes every number unreadable at once.
   -------------------------------------------------------------------------- */

.dx-link {
  align-self: center; white-space: nowrap; font-size: .85rem;
  color: var(--dim); padding: 0 .4rem;
  /* Never shrinks and never grows. A topbar item that can be squeezed is one
   * that pushes its neighbour off the screen the first time the bar is tight,
   * which is exactly what adding this link did to the Outline button. */
  flex: 0 0 auto;
}
.dx-link:hover { color: var(--accent); }

/* ------------------------------------------------- the tool menu (hamburger)
 *
 * Jon, 2026-08-16: *"the hamburger will just have all those things, and that
 * is the mine, diagnostics, decisions, and the help. The library and the
 * outline menus, those things need to be always present"* — and the reason he
 * gave for wanting it at all: *"I want the search bar to be more easily
 * visible, especially in Mobile."*
 *
 * A `<details>`, so `[open]` IS the state and there is no `aria-expanded` to
 * keep in step and no script to open it. `.ws-menu-list` is absolutely
 * positioned so opening it does not change the bar's height — `--topbar-real`
 * and every offset under it depend on that height being constant (R3), and a
 * menu that pushed the row taller would move the whole page down on a tap.
 *
 * It is not clipped, and that is not luck: `.topbar` deliberately carries no
 * `overflow: hidden`, and since 2026-09-05 this list is the only remaining
 * reason it must not. `.topbar` is `z-index: 30` and creates the stacking
 * context, so the list only has to beat its siblings inside it - but note
 * that `.ws-progress` left that context on the same day and is `z-index: 31`
 * in the ROOT one, so a failure label now paints over an open list where it
 * used to paint under it. That is deliberate; the reasoning is on
 * `.ws-progress`. */
/* **THE FOLD, in the hamburger's slot - decision 219.** Shown only on a phone,
 * where the strip is folded away (the phone block below turns it on); at every
 * other width the strip itself is on screen and this would be a second door. */
.ws-strip-fold {
  display: none; align-self: center; flex: 0 0 auto;
  align-items: center; justify-content: center;
  border: 1px solid var(--line-ctl); background: transparent;
  color: var(--ink-label); border-radius: 4px; padding: .2rem .5rem;
  cursor: pointer;
}
.ws-strip-fold:hover { color: var(--ink); border-color: var(--dim); }
.ws-strip-fold[aria-expanded="true"] { color: var(--ink); border-color: var(--accent); }
/* Drawn, not typed, for the wheel's reason. `▤` was the last typed glyph
 * in this bar and it is drawn too since 2026-08-16 - see `.ws-tabmenu-icon`,
 * which is now the place that argument lives. */
.ws-menu-icon { display: block; width: 1.05rem; height: 1.05rem; }
/* **RIGHT-ANCHORED, and `left: 0` was hanging off the phone before the
 * switcher was ever added to it.** Measured at `8e6f9f8`, menu open, with the
 * workspace rows FORCED OFF so the reading is about today's four links and
 * nothing else: the list's right edge is **478.4px on a 390px viewport — 88.4px
 * unreachable**, and the same 88.4 at 360 and 320. *Help* is the item it takes.
 *
 * **Unreachable, not off to the right**, and that distinction is this bar's own
 * lesson: `.topbar` is `position: fixed`, and a fixed element contributes
 * nothing to the document's scrollable overflow — `documentElement.scrollWidth
 * == clientWidth` at every width measured, exactly as it did for the 545–799px
 * band. Nothing scrolls to it and nothing indicates it is there.
 *
 * `right: 0` is correct by construction rather than by tuning: this menu hangs
 * from a button in the right half of the bar at every width (brand, search,
 * menu, wheel), so anchoring to the edge it opens toward cannot leave the
 * viewport on that side. `max-width` bounds the other end, so a 60-character
 * workspace name ellipsises instead of pushing the list off the left.
 *
 * After: 141px over → **0 at 320, 360, 390**; the desktop is unmoved except in
 * which edge is fixed. */
/* **`max-height` + `overflow-y`, and the ROW COUNT IS UNBOUNDED, which is what
 * makes them necessary rather than tidy.** The workspace roster grows with
 * every workspace Jon names, and this list is absolutely positioned inside a
 * `position: fixed` bar — so anything past the viewport's bottom edge is
 * unreachable for the same reason anything past its right edge was: a fixed
 * subtree contributes nothing to document scroll overflow. Without these two,
 * four workspaces put the "New workspace…" input off the bottom of a phone in
 * LANDSCAPE (390px of height, and the ≤34rem block does not apply at 844px
 * wide) — so he could neither create a fifth nor press the last row.
 *
 * The bound is the viewport minus the bar the list hangs from, so it tracks
 * `--topbar-real` rather than restating a height.
 *
 * **THE WIDTH BOUND IS A `min()` OF TWO THINGS, AND THE VIEWPORT TERM IS NOT
 * THE ONE THAT USUALLY BINDS.** This box is `right: 0` against `.ws-menu` —
 * and `.ws-menu` is NOT pinned to the viewport's right edge. `.topbar-main`
 * lays out brand, search, menu, wheel from the left, and once `.topsearch`
 * stops growing the button settles at a roughly fixed x. Measured
 * 2026-08-17 with a 39-character workspace name, the list's right edge sits at
 * **275.6px at 320, 315.6 at 360, 345.6 at 390 and 289.2 at BOTH 545 and 800**
 * — so the space available to its left is that number, not `100vw`.
 *
 * A viewport-only bound is therefore not binding where it matters: at 545px
 * `100vw - 3.5rem` permits 489px, the content wanted 303, and the left edge
 * landed at **−13.8px** — off the screen, and `documentElement.scrollWidth ==
 * clientWidth` at every width, so unreachable rather than scrollable-to. The
 * 17.5rem (280px) term is what actually holds it: 280 is under every measured
 * right edge, and the `100vw` term takes over below ~336px where the viewport
 * is the tighter of the two. After: **x ≥ 9.2px at all seven widths.**
 *
 * Both terms are literals rather than a `--ws-menu-inset` variable, on
 * purpose: a variable kept in step with the padding, the gap and the button by
 * hand would be a second producer of a number the cascade already decides, and
 * the guard is `verify_switcher`'s arm D driving a name at
 * `.ws-space-name`'s own cap. The rule's first version claimed a 60-character
 * name would "ellipsise instead of pushing the list off the left" while the
 * arm's longest fixture name was 20 characters — a claim its own oracle could
 * not falsify (L-021), and it was false. */
/* **DECISION 219 MOVED THIS LIST.** The paragraphs above were written for the
 * hamburger's dropdown, which hung `right: 0` under a button in the bar; the
 * hamburger is gone and this is the strip's `?` list, opening to the RIGHT of
 * the strip's foot. Their measurements are history, kept for the reasoning. */
.ws-help-list {
  position: absolute; left: calc(100% + .45rem); bottom: 0;
  z-index: 33;
  min-width: 11rem;
  max-width: min(calc(100vw - 3.5rem), 17.5rem);
  max-height: calc(100vh - var(--topbar-real) - 1rem);
  overflow-y: auto;
  padding: .25rem;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: 5px; box-shadow: 0 6px 20px rgba(0,0,0,.25);
}
/* A row, not a bar item: inside the menu there is room for the word, which is
 * why the `.dx-link-short` glyphs went with the move. */
.ws-help-list .dx-link, .ws-settings-go .dx-link {
  display: block; padding: .4rem .7rem; border-radius: 3px;
  color: var(--ink); font-size: .9rem;
}
.ws-help-list .dx-link:hover { background: var(--pane); color: var(--accent); }
.ws-settings-go .dx-link:hover { background: var(--panel); color: var(--accent); }
/* The block the gear's popover gained: a thin rule under it, so the links and
 * the workspaces read as a place to GO and the rows below as things to SET. */
.ws-settings-go {
  margin: 0 0 .6rem; padding-bottom: .5rem; border-bottom: 1px solid var(--line);
}

/* ------------------------------------------- named workspaces, the switcher
 *
 * Jon chose `switcher-in-hamburger` on /decisions. Every control in here is a
 * plain `<form method="post">` answering 303 to `/`, so this block styles
 * BUTTONS and an INPUT to read as menu rows — there is no JavaScript in this
 * feature at all and there is nothing here to keep in step with any.
 *
 * **`min-width` on `.ws-menu-list` is 11rem and a name may be 60 characters**
 * (`workspace.MAX_WORKSPACE_NAME`), so the name cell ellipsises. Without it
 * the bound would be a claim about the store dressed up as a claim about
 * layout: the menu is absolutely positioned precisely so opening it cannot
 * change the bar's height (R3), and a long name would instead make it wider
 * than the screen on a phone. `max-width` bounds that end too. */
.ws-space-sep {
  height: 1px; margin: .35rem .3rem; background: var(--line);
}
.ws-space-head {
  padding: .1rem .7rem .3rem; font-size: .72rem; letter-spacing: .04em;
  text-transform: uppercase; color: var(--ink-label);
}
.ws-space-row { display: flex; align-items: stretch; gap: .15rem; }
.ws-space-row > form { display: flex; min-width: 0; }
.ws-space-row > form:first-child { flex: 1 1 auto; min-width: 0; }
.ws-space-load {
  display: flex; align-items: baseline; gap: .5rem; width: 100%;
  padding: .4rem .7rem; border: 0; border-radius: 3px;
  background: transparent; color: var(--ink); font: inherit;
  font-size: .9rem; text-align: left; cursor: pointer;
}
.ws-space-load:hover { background: var(--pane); color: var(--accent); }
.ws-space-name {
  flex: 1 1 auto; min-width: 0; max-width: 16rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ws-space-n {
  flex: 0 0 auto; font-size: .74rem; color: var(--ink-label);
  font-variant-numeric: tabular-nums;
}
/* The one you are in. `disabled` is the state — a control whose only outcome
 * is a page reload should not be pressable — so it is styled through the
 * attribute rather than through a second class that could disagree with it. */
.ws-space-load[disabled] {
  cursor: default; color: var(--accent); font-weight: 600;
}
.ws-space-load[disabled]:hover { background: transparent; }
.ws-space-del {
  flex: 0 0 auto; padding: 0 .45rem; border: 0; border-radius: 3px;
  background: transparent; color: var(--ink-label); font: inherit;
  line-height: 1; cursor: pointer;
}
/* `--bad`, the sheet's own "something is wrong here" token, rather than a
 * literal or a `var(--warn, …)` fallback naming a property that does not
 * exist - a fallback that always fires reads as a token and is a constant. */
.ws-space-del:hover { background: var(--pane); color: var(--bad); }
.ws-space-new { display: flex; gap: .2rem; margin-top: .3rem; }
.ws-space-new input {
  flex: 1 1 auto; min-width: 0; padding: .3rem .5rem;
  border: 1px solid var(--line-ctl); border-radius: 3px;
  background: var(--bg); color: var(--ink); font: inherit; font-size: .85rem;
}
.ws-space-new button {
  flex: 0 0 auto; padding: .3rem .6rem;
  border: 1px solid var(--line-ctl); border-radius: 3px;
  background: transparent; color: var(--ink-label); font: inherit;
  font-size: .85rem; cursor: pointer;
}
.ws-space-new button:hover { color: var(--accent); border-color: var(--accent); }

/* The phone's treatment of the button, AFTER the base rule so it applies at
 * all - see the note in the phone block above. `.fold` gets the identical
 * pair for the identical reason: on a small screen a topbar control drawn in
 * `--dim` on `--panel` is a target you have to hunt for. */
@media (max-width: 34rem) {
  .ws-strip-fold { display: flex; border-color: var(--dim); color: var(--ink); }
}

/* ---------------------------------------------------------------- the help page

   Jon, 2026-08-06, asked for a help button and content. Deliberately plain: it
   is a column of prose, so it leans on `base.html`'s 44rem measure and `.view`
   in a tab, and adds only what prose cannot do for itself.

   Everything is `help-` prefixed, including the container - a bare `.help`
   would be a class `render.py` could one day emit into the same document, and
   `verify_shell.check_class_collisions` exists because that has happened
   before with `.tab` (L-009). `kbd` is scoped INSIDE `.help-page` for the same
   reason one level down: a bare element selector cannot collide in the
   collision guard's eyes, because the guard matches class names and an element
   selector contains none (L-022) - so it would style any <kbd> the renderer
   emits and nothing would ever tell us.
   -------------------------------------------------------------------------- */

.help-page h2 {
  margin: 1.8rem 0 .5rem; font-size: 1.05rem;
  border-bottom: 1px solid var(--line); padding-bottom: .25rem;
}
.help-page p, .help-page li { line-height: 1.55; }
.help-page ul { padding-left: 1.1rem; }
.help-page li { margin: .35rem 0; }
.help-lede { font-size: 1.05rem; color: var(--dim); }
/* The asides that correct a likely wrong belief rather than adding a step. */
.help-plain {
  border-left: 2px solid var(--line); padding-left: .7rem; color: var(--dim);
}
.help-page kbd {
  font: inherit; font-size: .85em; padding: .05rem .3rem;
  border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 3px;
  background: var(--pane); white-space: nowrap;
}
.help-keys { border-collapse: collapse; margin: .6rem 0; width: 100%; }
.help-keys th, .help-keys td {
  text-align: left; vertical-align: top; padding: .4rem .6rem .4rem 0;
  border-top: 1px solid var(--line); font-weight: normal;
}
.help-keys th { white-space: nowrap; padding-right: 1rem; color: var(--ink); }
.help-keys td { color: var(--dim); }

/* This page is tables of counts, not a column of prose, so it overrides the
 * 42rem reading measure `.view` sets and the 44rem `main` gives a standalone
 * page. Without the second one the media table was cut off mid-column at
 * 1200px - the two rightmost columns existed, scrolled out of sight, on a
 * screen with 500px going spare. Prose inside it keeps a readable measure of
 * its own; only the tables get the width. */
.dx-view { max-width: 72rem; }
body.dx-page main { max-width: 74rem; }
.dx-lede { color: var(--dim); max-width: 44rem; }

/* Scoped to this page. `.dx-view` holds no renderer output - it is counts and
 * prose about the corpus, never corpus markup - so a bare element selector
 * here cannot reach anything `render.py` emits. */
.dx-view code {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: .92em;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: .2rem;
  padding: 0 .25rem;
  /* The unmapped ledger prints keys straight out of the corpus, and a corpus
   * key has no length limit: `GoToURL("logos4:Places;id=RedSea;map=RouteOf...`
   * is one unbreakable token 353px wide, which pushed the page 8px sideways on
   * a 390px phone. The page reports what it could not map, so the width of
   * what it reports is not something this stylesheet gets to assume. Same fix
   * and same reason as `.plate-src code`. */
  overflow-wrap: anywhere;
}


/* -------------------------------------------------------- the glance block
 * The numbers Jon asked for, above the essay. FLEX-WRAP and nothing that can
 * refuse to wrap: at 390px the phone block below removes `.dx-view`'s
 * max-width entirely, so a fixed-track grid, a `nowrap`, or a table here
 * pushes the whole document sideways - and `verify_shell`'s check that
 * catches that names no element, it prints a pixel count.
 *
 * `.dx-stat.dx-*` and not `.dx-check.dx-*`: those exist one rule down and are
 * scoped to `.dx-check`, so the state colours have to be restated for the new
 * element rather than inherited. Same three values, same three colours. */
/* `.dx-sec.dx-glance`, NOT `.dx-glance`. Both selectors tie at (0,1,0) and
 * `.dx-sec { margin: 2.2rem 0 }` is 28 lines LATER in this sheet, so the bare
 * form lost on source order and this declaration did nothing at all - the
 * element carries both classes. `verify_shell`'s override guard compares
 * IDENTICAL selector texts, so it is blind to this by construction (L-186);
 * found by attacking the shipped commit. */
.dx-sec.dx-glance { margin: 1rem 0 2rem; }
.dx-glance-verdict { font-size: 1.05rem; margin: 0 0 .7rem; }
.dx-glance-list {
  list-style: none; padding: 0; margin: 0;
  display: flex; flex-wrap: wrap; gap: .5rem .9rem;
}
.dx-stat {
  border-left: 3px solid var(--line);
  padding: .35rem 0 .35rem .7rem;
  min-width: 0; flex: 1 1 13rem;
}
.dx-stat.dx-ok { border-left-color: #4a7c4a; }
.dx-stat.dx-warn { border-left-color: #a8842c; }
.dx-stat.dx-bad { border-left-color: var(--accent); }
.dx-statval {
  display: block; font-size: 1.3rem; font-weight: 600;
  font-variant-numeric: tabular-nums;
}
/* `overflow-wrap`, kept after the tile that motivated it was removed. Every
 * label here is now a fixed English phrase, so nothing in this block can
 * currently produce an unbreakable token - but at 390px the phone rule takes
 * `.dx-view`'s max-width away entirely, one long word is all it takes, and the
 * check that would catch it prints a pixel count rather than naming an
 * element. One declaration against a whole class of regression, and it is the
 * same fix and the same reason as `.dx-view code` above. */
.dx-statwhat {
  display: block; color: var(--dim); font-size: .88rem;
  overflow-wrap: anywhere;
}

.dx-sec { margin: 2.2rem 0; }
.dx-sec > h2 {
  font-size: 1.1rem; margin: 0 0 .5rem; padding-bottom: .3rem;
  border-bottom: 1px solid var(--line);
}
.dx-note { color: var(--dim); max-width: 46rem; margin: .5rem 0; }
.dx-fine { font-size: .88rem; }

/* The three verify lines. Colour is never the only carrier - each line also
 * states its own result in words, because a reader who cannot distinguish the
 * green from the red still has to be able to read the page. */
.dx-checks { list-style: none; padding: 0; margin: 0; }
.dx-check {
  border-left: 3px solid var(--line); padding: .5rem 0 .5rem .8rem;
  margin: .6rem 0;
}
.dx-check.dx-ok { border-left-color: #4a7c4a; }
.dx-check.dx-warn { border-left-color: #a8842c; }
.dx-check.dx-bad { border-left-color: var(--accent); }
.dx-what { display: block; font-weight: 600; }
.dx-val { display: block; font-variant-numeric: tabular-nums; }
.dx-why { display: block; color: var(--dim); font-size: .9rem; margin-top: .25rem; }
.dx-fail { color: var(--accent); font-size: .9rem; }

.dx-ok-line { color: #3f6b3f; }
/* **`overflow-wrap` HERE IS NOT COSMETIC, and board 208 is the measurement.**
 * This class renders a FAULT - and a fault's text is the one string on the page
 * nobody controls the shape of. With the plug-in's tool unreachable it carried
 * `library.py is not at /media/…/tools/logos-decrypt/library.py`, 95 characters
 * with no space in them, and `check_diagnostics_does_not_scroll_sideways` went
 * red with 27px of horizontal overflow on a 390px phone.
 *
 * **The root-cause fix does NOT close this and that is the point.** Repointing
 * the tool makes the fault disappear, so the check passes because its input is
 * gone rather than because the layout got stronger - the next long path brings
 * it straight back (L-052: a fix that removes the last input capable of failing
 * a correct assertion leaves that assertion green over an unfixed fragility).
 * A page whose JOB is to display faults must display one without breaking the
 * layout it is displayed in. Same declaration and same reason as `.dx-statwhat`
 * and `.dx-view code` above. */
.dx-bad-line { color: var(--accent); font-weight: 600; overflow-wrap: anywhere; }
/* A CAVEAT is a third thing, and it needs a third colour. `dx-ok-line` would
   read as "all clear" and `dx-bad-line` as "something is wrong"; a ledger that
   has not looked in a book you fetched this morning is neither. Amber, the
   same #a8842c `.dx-check.dx-warn` already uses for its rail. */
.dx-warn-line { color: #8a6a1e; font-weight: 600; max-width: 46rem; }
@media (prefers-color-scheme: dark) {
  .dx-ok-line { color: #8fc08f; }
  .dx-warn-line { color: #d8b45c; }
}
.dx-empty {
  background: var(--panel); border: 1px solid var(--line);
  border-radius: .25rem; padding: .7rem .8rem; max-width: 46rem;
}
.dx-hot { color: var(--accent); font-weight: 600; }

.dx-table {
  border-collapse: collapse; width: 100%; margin: .6rem 0;
  font-size: .9rem; display: block; overflow-x: auto;
}
.dx-table th, .dx-table td {
  border-bottom: 1px solid var(--line); padding: .3rem .5rem;
  text-align: left; vertical-align: top; white-space: nowrap;
}
.dx-table th { color: var(--dim); font-weight: 600; font-size: .85rem; }
.dx-table .dx-num { text-align: right; font-variant-numeric: tabular-nums; }
.dx-row-bad td { background: var(--accent-soft); }
.dx-rid { color: var(--dim); font-size: .8rem; margin-left: .4rem; }

.dx-limits { list-style: none; padding: 0; }
.dx-limit {
  border-left: 3px solid var(--line); padding: .1rem 0 .1rem .8rem;
  margin: 1.1rem 0; max-width: 46rem;
}
.dx-limit > h3 { font-size: .98rem; margin: .2rem 0; }
.dx-limit > p { margin: .3rem 0; }
.dx-fix { color: var(--dim); font-size: .9rem; }
.dx-fixlabel { font-weight: 600; }

.dx-cat { margin: .5rem 0; }
.dx-cat > summary { cursor: pointer; padding: .2rem 0; }
.dx-catsum { color: var(--dim); font-size: .88rem; margin-left: .4rem; }

.dx-orphans, .dx-reach, .dx-list { font-size: .92rem; }


/* **The words-to-glyphs collapse lived here and went with the hamburger,
 * 2026-08-16.** On a 390px bar the word "Diagnostics" was 70px that the fold
 * buttons and the search box needed more, so each of the four tool links wore
 * a glyph below 37.5rem instead of its label - and this morning that rule was
 * widened from 34rem to cover the 545-799px band too.
 *
 * Jon then asked for those same four to live behind one control, and a menu
 * ROW has room for a word. So the collapse has nothing left to collapse and is
 * DELETED rather than left standing over an empty population: a correct change
 * can empty what a rule watches (L-052), and a rule that cannot do anything is
 * worse than no rule at all, because it reads as coverage. The
 * `.dx-link-short` spans went from `shell.html` in the same commit. What that
 * did to the arithmetic is in the band block beside `.topsearch`. */

/* The phone, the diagnostics PAGE. The verify lines and the limits already read
 * as a single column; the tables are the only thing that needs saying, and they
 * scroll inside themselves rather than pushing the page sideways -
 * `verify_shell.py` checks for exactly that at 390px, because a rule is untested
 * until something renders it at the width it targets (L-008). */
@media (max-width: 34rem) {
  .dx-view { max-width: none; }
  .dx-table { font-size: .82rem; }
  .dx-check, .dx-limit { padding-left: .6rem; }
}

/* The button that starts the full check. Jon's report: the page told him to
 * run a shell command on a machine he was not sitting at. A form, not a
 * fetch() — this page works with JavaScript off like every other view here,
 * and the server redirects back to it. */
.dx-run { margin: .5rem 0 .2rem; display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.dx-run button {
  background: var(--pane); color: var(--ink);
  border: 1px solid var(--dim); border-radius: .25rem;
  padding: .35rem .8rem; font: inherit; font-size: .85rem; cursor: pointer;
  min-height: 2.2rem;
}
.dx-run button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.dx-run button:disabled { opacity: .55; cursor: default; }
.dx-runnote { color: var(--dim); font-size: .82rem; }

/* ------------------------------------------------------- the note composer */
/* `ws-` prefixed, like every other shell class: `render.py` owns the class
 * namespace of rendered content and the shell is the thing that moves (L-009).
 * Nothing here is a bare element selector reaching into the pane, and nothing
 * here lays out a child whose shape the renderer owns - the quote is plain
 * text this file put there, not corpus markup. */
/* **The CONTAINER is what is positioned now, not each composer.** Composers
 * stack (Jon, 2026-08-06 - a second selection used to wipe the first one's
 * text), so the fixed corner belongs to the stack and each card is an ordinary
 * block inside it. `column-reverse` puts the NEWEST nearest the corner, which
 * is where his eye already is; older drafts stack away from it.
 *
 * `:empty { display: none }` matters: with no composers open this is a fixed
 * element sitting over the bottom-right of the reading pane, and without it a
 * click there would land on the container instead of the text. */
.ws-notes {
  position: fixed; z-index: 70; right: 1.2rem; bottom: 1.2rem;
  width: min(26rem, calc(100vw - 2.4rem));
  max-height: 84vh; overflow-y: auto;
  display: flex; flex-direction: column-reverse; gap: .55rem;
}
.ws-notes:empty { display: none; }

.ws-note {
  width: 100%; max-height: 60vh; overflow-y: auto;
  background: var(--pane); color: var(--ink);
  border: 1px solid var(--dim); border-radius: 5px;
  box-shadow: 0 6px 24px rgba(0,0,0,.28); padding: .8rem 1rem;
  font-size: .86rem;
  position: relative;          /* the close button is absolute within it */
}
.ws-note[hidden] { display: none; }

/* **Collapsed-first** - Jon's item 5, 2026-08-07: the card opens pens-only and
 * expands to a full note on a tap. The wrapper is hidden, never the textarea
 * alone, so the where-line, quote and Save disappear as one unit; the pens and
 * the `.ws-note-said` status live outside the wrapper in the template and stay
 * readable while collapsed. `.ws-note-more` hides once EXPANDED - its job is
 * done, and a dead "note…" button under a live textarea would promise a second
 * expansion that cannot happen. */
.ws-note-collapsed .ws-note-full { display: none; }
.ws-note:not(.ws-note-collapsed) .ws-note-more { display: none; }

/* The desktop's near-cursor card. `static/app.js` adds this class and sets
 * `left`/`top` (style coordinates, not markup); phone cards NEVER get it -
 * they keep the bottom-sheet layout below. Width and shadow repeat the stack
 * card's so floating changes where the card sits, not what it is. z-index 71:
 * one above the `.ws-notes` stack, so a card being dragged rides over any
 * cards still parked in the corner. */
.ws-note-floating {
  position: fixed;
  width: min(26rem, calc(100vw - 2.4rem));
  z-index: 71;
  box-shadow: 0 6px 24px rgba(0,0,0,.28);
}

/* The drag grip. On the desktop it is a real handle (`grab`, and app.js drags
 * the card by it); on the phone the same element is the sheet's grab-bar
 * decoration - it replaced the old `.ws-note::before` bar so there is exactly
 * one bar either way. `touch-action: none` keeps a browser pan gesture from
 * eating the pointer events the drag needs; the grip is the ONLY drag surface,
 * so text selection in the quote and textarea is untouched. */
.ws-note-grip { cursor: grab; touch-action: none; padding: .1rem 0 .45rem; }
.ws-note-grip:active { cursor: grabbing; }
.ws-note-grip::before {
  content: ""; display: block; width: 2.5rem; height: 4px; margin: 0 auto;
  background: var(--line); border-radius: 2px;
}

.ws-note-more, .ws-note-rec {
  display: block; width: 100%; text-align: left;
  font: inherit; font-size: .8rem; color: var(--dim);
  background: none; border: 1px dashed var(--line); border-radius: 3px;
  padding: .3rem .55rem; margin-top: .45rem; cursor: pointer;
}
.ws-note-more:hover, .ws-note-rec:hover { color: var(--ink); border-color: var(--accent); }

.ws-note-where { margin: 0 1.4rem .35rem 0; font-size: .76rem; color: var(--dim); }
.ws-note-quote {
  margin: 0 0 .6rem; padding: .35rem .6rem; border-left: 3px solid var(--accent);
  background: var(--panel); font-style: italic; overflow-wrap: anywhere;
  max-height: 7rem; overflow-y: auto;
}
.ws-note-label { display: block; font-size: .76rem; color: var(--dim); margin-bottom: .2rem; }
.ws-note-body {
  width: 100%; box-sizing: border-box; font: inherit; padding: .4rem .5rem;
  background: var(--pane); color: var(--ink);
  border: 1px solid var(--line); border-radius: 3px; resize: vertical;
}
.ws-note-actions { display: flex; align-items: center; gap: .6rem; margin-top: .5rem; }
.ws-note-said { font-size: .78rem; color: var(--dim); }

/* **The phone's composer rules live HERE, after EVERY base `.ws-note*` rule,
 * and that placement is load-bearing.** They were first written into the big
 * `max-width: 34rem` block higher up the file - and did nothing: both rules
 * are a bare `.ws-note`, so specificity ties and SOURCE ORDER decides, and
 * that block precedes the base rule by six hundred lines. Measured at 390px:
 * the sheet came out 351.6px wide with a 5px radius, i.e. the desktop card.
 * Only a browser says which rule won (L-024).
 *
 * **That move was made once and stopped one rule short, 2026-08-16.** It went
 * above `.ws-note-body`'s base rule rather than below it, so
 * `font-size: 1rem` here lost to `font: inherit` there - and `font` is a
 * shorthand, so it resets the size even though nothing in the sheet mentions
 * one. Measured at 390px: Jon's note composer served a **12.16px** textarea
 * where its author asked for 16px, which is also the size below which iOS
 * Safari zooms the page on focus. Fixing a placement for the selectors you
 * are looking at is how the next one survives (L-011); the whole block is now
 * below the whole base group, and `verify_shell` has a guard that would have
 * caught both. */
@media (max-width: 34rem) {
/* ------------------------------------------------- the composer, as a sheet
 *
 * Jon, 2026-08-06: *"i think for the mobile format, it should slide up from
 * the bottom."*
 *
 * On the desktop this is a card floating bottom-right, which is right there
 * and wrong on a phone: `min(26rem, 100vw - 2.4rem)` leaves a card with a
 * gutter round it, and the thing a thumb wants is the full width of the
 * screen with its controls at the bottom edge.
 *
 * **It was already `position: fixed`, so it has always survived a scroll** -
 * the other half of his note ("should not dissapear if the user clicks and
 * scrolls some more") is behaviour that was already correct, and nothing
 * hides it but Save, the ×, or Escape.
 *
 * The slide is an `animation`, not a `transition`, and that is forced rather
 * than chosen: the composer is shown by clearing `hidden`, so it goes from
 * `display: none` to displayed, and a transition has no start state to run
 * from. An animation on appearance does. `[hidden]` still wins because
 * `.ws-note[hidden] { display: none }` is an author rule (L-024). */
.ws-notes {
  left: 0; right: 0; bottom: 0; width: auto;
  max-height: 86vh; gap: 0;
}
.ws-note {
  max-height: 76vh; padding: .5rem .9rem 1rem;
  border-left: none; border-right: none; border-bottom: none;
  border-radius: 14px 14px 0 0;
  box-shadow: 0 -6px 24px rgba(0,0,0,.3);
  animation: ws-sheet-up .18s ease-out;
}
/* Stacked sheets on a phone: only the newest gets the full grab-bar
 * treatment, and the ones behind it are trimmed so three drafts cannot eat
 * the whole screen. They are still scrollable and still his to close. */
.ws-notes > .ws-note:not(:last-child) { max-height: 22vh; opacity: .92; }
/* The grab bar every phone sheet has is `.ws-note-grip` now - the template's
 * real element, shared with the desktop where it actually drags. On the phone
 * it stays decoration (dragging is desktop-only, Jon's scoping), and the grip
 * keeps the default cursor here because a `grab` cursor on a touch screen is
 * a promise nobody can see anyway. A collapsed sheet is simply a SHORTER
 * sheet: the collapsed/expanded split is the base rules above, and item 4's
 * scroll-clear measures the sheet rather than assuming a height, so both
 * states inherit the sliding layout unchanged. */
.ws-note-grip { padding: .1rem 0 .6rem; }
.ws-note-body { min-height: 4.5rem; font-size: 1rem; }
.ws-note-save { min-height: 2.4rem; padding: 0 1.2rem; }
.ws-note .popclose { top: .55rem; right: .6rem; }
}

/* ------------------------------------------------------ the word-study panel
 *
 * Jon taps a word and what the publisher says it means appears here. The
 * markup is in `shell.html` and `app.js` fills its text nodes; this file only
 * decides where it sits and what it looks like.
 *
 * **Bottom LEFT, and the composer's bottom right is the reason.** Both are
 * fixed overlays over the reading pane and both can be open at once — he can
 * be part-way through typing a note (a composer he has touched is WORK and is
 * not evicted) and still tap a word. Sharing a corner would mean one covering
 * the other at the moment he is using both. The two corners are far enough
 * apart at every width above the phone breakpoint, where the 26rem composer
 * and this 24rem panel cannot both reach the middle.
 *
 * **Every colour is a variable, so dark mode needs no second rule here** —
 * `--pane`, `--ink`, `--line`, `--dim` and `--accent` are all redefined in the
 * `prefers-color-scheme: dark` block at the top of this file. The one control
 * in the panel is a `button`, and a button inherits NEITHER colour nor font
 * from the page: `.popclose` sets both explicitly (see `.reflookup button,
 * .popclose` above) and this panel reuses that class rather than inventing a
 * second close affordance that would have to learn the same lesson again. */
/* **THE PANEL NO LONGER SCROLLS - ITS BODY DOES**, and that is what makes
 * Jon's *"be able to scroll the entire entry"* work with controls that stay
 * reachable. `copy`, `more` and `×` are `position: absolute` against this box;
 * with the scroll on the panel they scrolled away with the text, so on a long
 * lexicon entry the way to close the sheet was off the top of it. A flex
 * column with the scroll on `.ws-word-body` holds them still.
 *
 * `min-height: 0` on the body is the load-bearing half: a flex item's
 * automatic minimum size is its CONTENT, so without it the body refuses to
 * shrink, the panel grows past its own `max-height`, and nothing scrolls at
 * all - the same rule `.panel-body` carries one feature over. */
.ws-word {
  position: fixed; z-index: 69; left: 1.2rem; bottom: 1.2rem;
  width: min(24rem, calc(100vw - 2.4rem));
  max-height: 50vh;
  display: flex; flex-direction: column; overflow: hidden;
  background: var(--pane); color: var(--ink);
  border: 1px solid var(--dim); border-radius: 5px;
  box-shadow: 0 6px 24px rgba(0,0,0,.28);
  padding: .8rem 1rem; font-size: .86rem;
}
/* `hidden` is the resting state and an author rule has to win it: `display`
 * on `.ws-word` above would otherwise beat the UA sheet's `[hidden]` and the
 * panel would be permanently on screen (L-024 — the same trap `.ws-note`
 * documents). */
.ws-word[hidden] { display: none; }

/* Jon's 2026-08-25 item: the panel opens beside the word he tapped rather than
 * in the viewport's bottom-left corner. `placeWordNearTap` writes `top`, and
 * the resting rule above sets `bottom` - both together would STRETCH the panel
 * between them, so the floating state has to release the corner it came from.
 * A class rather than an inline `bottom`, so `unfloatWord` restores the sheet
 * by removing one thing. */
.ws-word-floating { bottom: auto; }

/* A lexicon entry per line, laid out by the text node rather than by markup:
 * `app.js` joins the entries with newlines because it authors no elements, so
 * the newlines have to survive. `pre-line` honours them and still collapses
 * runs of spaces, which is what a gloss list wants. */
.ws-word-body {
  /* Top margin clears the control row (more + copy + ×), which is absolutely
   * positioned and would otherwise sit on the first line of the entry. */
  margin: 1.4rem 1.4rem 0 0;
  overflow-wrap: anywhere; line-height: 1.5;
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  /* A sheet the reader is scrolling must not hand the gesture to the book
   * underneath when it reaches its end - on a phone that is the page jumping
   * away while they read a definition. */
  overscroll-behavior: contain;
}

/* `white-space: pre-line` on the TEXT boxes and not on their parent: the two
 * that hold newline-joined lines want it, and the hit rows are real elements
 * whose own line breaks come from the cascade. Putting it on `.ws-word-body`
 * would make the whitespace between cloned rows in the template significant. */
.ws-word-def, .ws-word-notes { white-space: pre-line; }
.ws-word-notes:not(:empty) { margin-top: .5rem; color: var(--dim); }

/* ONE DICTIONARY HIT. The book's name is the link (see `#ws-word-hit-tpl`),
 * the entry is the book's own prose, and the note underneath is why there is
 * less of it than there is in the book. */
.ws-word-hit { margin-top: .7rem; padding-top: .5rem;
               border-top: 1px solid var(--line); }
.ws-word-jump { font-weight: 600; text-decoration: none;
                color: var(--accent); }
.ws-word-jump:hover, .ws-word-jump:focus-visible { text-decoration: underline; }
/* A row whose hit named no article carries no `href` - `wordHitRow` unwires
 * it rather than pointing it nowhere - so it must stop looking clickable. */
.ws-word-jump:not([href]) { color: var(--ink); }
.ws-word-hit-how { font-weight: 400; color: var(--dim); }
.ws-word-entry { white-space: pre-line; margin-top: .25rem; }
/* **THE SENTENCE UNDER AN ENTRY IS A LINK - Jon, 2026-08-25, testing it the
 * hour it shipped**: *"That part that says open the book for the rest needs to
 * be a link. I was tapping it ... then I realized I need to scroll back up and
 * hit the book title."* On a long entry the title has scrolled off the top of
 * the sheet, so the one sentence telling him where the rest is was the one
 * thing he could not press.
 *
 * **`:empty` on the ANCHOR, never on its box.** The box always holds that
 * anchor now, so it can never be `:empty` itself - a rule keyed on the box
 * would leave a dim empty row under every complete entry. */
.ws-word-more-link { display: block; margin-top: .25rem; font-size: .8rem;
                     font-weight: 400; color: var(--dim);
                     text-decoration: underline; }
.ws-word-more-link:empty { display: none; }
.ws-word-more-link:hover, .ws-word-more-link:focus-visible {
  color: var(--accent); }
/* Unwired - `wordHitRow` strips the href when the hit names no article - so it
 * must stop reading as something to press. */
.ws-word-more-link:not([href]) { text-decoration: none; }

/* Jon's copy button, beside the × (which sits at the panel's top-right
 * corner via `.popclose`). Wide enough to hold its three states — "copy",
 * "copied", "copy failed" — without the panel edge jumping as the label
 * changes. */
.ws-word-copy {
  position: absolute; top: .45rem; right: 2.1rem;
  min-width: 4.6rem; padding: .15rem .5rem;
  font-size: .72rem; color: var(--dim);
  background: transparent; border: 1px solid var(--line);
  border-radius: 4px; cursor: pointer;
}
.ws-word-copy:hover { color: var(--accent); border-color: var(--accent); }
.ws-word-copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* The `more` / `less` control, beside `copy`. **Off the screen above 34rem**:
 * see `#ws-word-more` in `shell.html` - on a desktop the panel is already up
 * to half the viewport and is placed beside the tapped word, so there is no
 * second size for it to reach and a control that cannot do anything reads as
 * coverage (L-052). */
.ws-word-more {
  position: absolute; top: .45rem; right: 7rem;
  display: none; min-width: 3.2rem; padding: .15rem .5rem;
  font-size: .72rem; color: var(--dim);
  background: transparent; border: 1px solid var(--line);
  border-radius: 4px; cursor: pointer;
}
.ws-word-more:hover { color: var(--accent); border-color: var(--accent); }
.ws-word-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

@media (max-width: 34rem) {
  /* A phone has no second corner: the composer already takes the full width
   * as a sheet, so this takes it too and sits BEHIND it (`z-index` 69 against
   * the composer's 70). Both open at once is the rarer case and the composer
   * is the one he is typing in.
   *
   * **A QUARTER OF THE SCREEN, AND HALF WHEN HE ASKS - Jon's own geometry**,
   * 2026-08-25: *"the pop up from the bottom should fill a quarter of the
   * page, and be able to pop up to half the screen when selecting to see
   * more"*. `max-height` rather than `height`, so a two-line answer is a
   * two-line sheet instead of a quarter-screen box mostly full of nothing -
   * and with the entry text in it, an answer that has anything to say reaches
   * the quarter and the sheet fills it. */
  .ws-word {
    left: 0; right: 0; bottom: 0; width: auto;
    max-height: 25vh;
    border-left: none; border-right: none; border-bottom: none;
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -6px 24px rgba(0,0,0,.3);
  }
  .ws-word.ws-word-tall { max-height: 50vh; }
  .ws-word-more { display: block; }
}

/* --------------------------------------------- the Settings popover (6a §4)
 *
 * ws- classes throughout (L-009), markup in shell.html, OUTSIDE #pane. It
 * hangs under the topbar near its own button; on the phone it becomes a
 * bottom sheet like `.ws-word`, because that is where a thumb is.
 */

/* The button that opens it is part of "hard to find them" — it is the
 * affordance, and it sat at 5.53:1 text on a 1.33:1 border in dark.
 * `--ink-label`, not `--ink-sub`, because it sits on `--panel` rather than
 * `--pane` and the sub tone only reaches 6.25:1 there in light. */
/* **UNDO — Jon's placement, 2026-08-18: between the hamburger and the wheel.**
 *
 * It borrows `.ws-settings-btn`'s box exactly rather than inventing one, so
 * the topbar's own arithmetic gets a control it already knows the shape of:
 * the band between 545 and 800px fits by half a pixel at its tightest and
 * `verify_shell` measures every control's right edge at eleven widths.
 *
 * **NO `display` DECLARATION HERE, EVER.** The button is `hidden` whenever
 * there is nothing to undo, and `[hidden] { display: none }` is a USER-AGENT
 * rule that any author `display` beats regardless of specificity (L-024) -
 * which is how `.ws-settings-row` came to paint at 71px while reporting itself
 * hidden. Hidden is also what keeps this control free: it costs the bar no
 * width at all until the reader has actually deleted something. */
.ws-undo-btn {
  align-self: center; border: 1px solid var(--line-ctl); background: transparent;
  color: var(--ink-label); border-radius: 4px; padding: .2rem .5rem;
  font-size: .78rem; cursor: pointer; white-space: nowrap;
}
.ws-undo-btn:hover { color: var(--ink); border-color: var(--dim); }
.ws-undo-icon { display: block; width: 1.05rem; height: 1.05rem; }

/* **A STRIP ITEM SINCE DECISION 219** - it sits at the strip's foot, so it is
 * drawn like the icons above it rather than like the bar's bordered buttons; on
 * a phone the strip's own block pins it back into the bar with a border. */
.ws-settings-btn {
  display: grid; place-items: center; width: 2rem; height: 2rem;
  border: 0; border-radius: .35rem; background: transparent;
  color: var(--dim); padding: 0; cursor: pointer;
}
.ws-settings-btn:hover { color: var(--accent); background: var(--accent-soft); }
/* The wheel Jon asked for, 2026-08-16. `block` so the button's line box does
 * not add a descender's worth of height under it, and `1.05rem` so the button
 * keeps the width the topbar's arithmetic was measured with - the letters "Aa"
 * made it 33.3px and this makes it 33.6px, which matters because the band
 * between 545 and 800px fits by 0.5px at its tightest and `verify_shell`
 * measures every control's right edge at eleven widths.
 *
 * No `fill` and no `color` here: the SVG strokes `currentColor`, so the hover
 * and open rules either side of this one keep working with no knowledge that
 * the label stopped being text. */
.ws-settings-icon { display: block; width: 1.05rem; height: 1.05rem; }
/* Open: the BORDER carries the accent, the text does not. `--accent` on
 * `--panel` measures **6.50:1** in dark — under the 7.0 text floor and over
 * the 3.0 edge floor, so the same colour is legible as an edge and not as a
 * label. Found by the check below rather than by reading this file: the
 * measurement is taken with the popover OPEN, which is the only state this
 * rule applies in. */
.ws-settings-btn[aria-expanded="true"] { color: var(--ink);
                                         background: var(--accent-soft); }
/* **WITH SCRIPTING OFF THE GEAR OPENS THIS BY `:target`** (`shell.html`), so the
 * Workspaces forms under it still switch, create and delete. The id and the
 * pseudo-class out-rank the global `[hidden]` rule, which is `!important` too;
 * with scripting on `app.js` never writes the hash, so this never applies. */
#ws-settings:target { display: block !important; }

.ws-settings {
  /* Above the composer's 70: this only shows because it was JUST opened,
   * which is the one moment it outranks whatever else is on screen. */
  position: fixed; z-index: 71;
  top: calc(var(--topbar-real, var(--topbar-h)) + .4rem); right: .5rem;
  width: min(24rem, calc(100vw - 1rem));
  /* The label column, and the sub-line's indent under it, from ONE value -
   * they have to agree and nothing re-derives two hand-typed lengths. */
  --ws-what: 7.4em;
  max-height: 60vh; overflow-y: auto;
  background: var(--pane); color: var(--ink);
  border: 1px solid var(--dim); border-radius: 5px;
  box-shadow: 0 6px 24px rgba(0,0,0,.28);
  padding: .7rem .9rem .8rem; font-size: .84rem;
}
/* **On a real screen it takes the whole height, and it can be dragged wider.**
 * Jon, 2026-08-16: *"when i open the settings on desktop, it only opens a panel
 * that goes halfway down on the right, like an overlay dialog, i think it ought
 * to use the entire vertical space, no reason not to. the width seems fine,
 * though, the user should be able to drag it wider if they want."*
 *
 * `bottom` rather than a taller `max-height`: with `top` and `bottom` both set
 * the box is sized by the viewport, so it stays right at every window height
 * with nothing to re-derive. `max-height: none` is required to undo the 60vh
 * above it, which would otherwise still bind.
 *
 * **Scoped to the desktop deliberately**, because the phone is the one part of
 * this he said he LIKES: *"when i open setting on mobile, i like how it fills
 * just the bottom half, that looks good"*. 55rem is the same breakpoint the
 * panels use to stop overlaying, so a window either has room for both or for
 * neither.
 *
 * `resize: horizontal` needs a non-visible `overflow`, which the box already
 * has. It grows LEFTWARD, because the box is anchored by `right` — which is
 * the direction with room, and is why this is not `resize: both` (vertical is
 * now the viewport's job). */
@media (min-width: 55.0625rem) {
  .ws-settings {
    bottom: .5rem; max-height: none;
    resize: horizontal; min-width: 18rem; max-width: calc(100vw - 1rem);
  }
}

/* An author rule has to win `hidden` back from any `display` above — L-024,
 * the `.ws-word`/`.ws-note` trap, stated here before it can fire. */
.ws-settings[hidden] { display: none; }

/* Scoped to this panel's close button, never to `.popclose` — that class is
 * shared with the note, word-study and reference popovers, and widening a
 * measured fix into three surfaces nobody measured is how a palette drifts. */
.ws-settings-close { border-color: var(--line-ctl); }
/* **The head stays put while the rows scroll.** Jon, 2026-08-16: *"they should
 * stay put so i can X out when ready and not scroll back."* The box is the
 * scroller, so `position: sticky` on a child of it is the mechanism — but it
 * only works on ONE element, and the title and the close button were siblings
 * among the rows, so they are wrapped now.
 *
 * The negative margins bleed the head to the box's edges and the padding puts
 * it back, so the strip spans the full width and nothing shows through beside
 * it as the rows pass underneath. `background` is not optional for a sticky
 * header: without it the scrolled content is visible through it.
 *
 * `position: static` on the close button, overriding `.popclose`'s `absolute`
 * — inside a flex row it belongs in the flow, and an absolute child of a
 * sticky parent would keep the old behaviour. */
.ws-settings-head {
  position: sticky; top: 0; z-index: 3;
  display: flex; align-items: center; justify-content: space-between;
  gap: .5rem;
  margin: -.7rem -.9rem .5rem; padding: .45rem .9rem;
  background: var(--pane); border-bottom: 1px solid var(--line);
}
.ws-settings .ws-settings-close { position: static; }
.ws-settings-title { margin: 0; font-size: .9rem; }
/* The group headings are the landmarks a reader scans, so they take the
 * strongest of the three tones rather than the weakest. */
.ws-settings-heading { margin: 0 0 .3rem; font-size: .78rem;
                       color: var(--ink-label); font-weight: 600;
                       text-transform: uppercase; letter-spacing: .04em; }
.ws-settings-row { display: flex; align-items: center; gap: .5rem;
                   padding: .25rem 0; flex-wrap: wrap; }
/* **`display: flex` above BEATS the user agent's `[hidden] { display: none }`,
   regardless of specificity, so this line is what makes `hidden` mean
   anything on one of these rows (L-024).** Without it the Waiting row painted
   at 71px with `hidden` set the whole time — a permanent "Waiting –" on a
   feature that is quiet almost always, which is exactly what the row's own
   markup comment says must not happen. It was written, driven and found in one
   sitting: the first version of the check asserted `hidden === false` alone and
   went green against a row nobody could have missed on the screen (L-067 —
   `hasBox` is the producer of "does this paint", and an attribute is not).
   CLAUDE.md records the same mechanism about the Contents columns; this is the
   second time it has bitten, so it is stated at the ROW rather than at one
   user of it. */
.ws-settings-row[hidden] { display: none; }
.ws-settings-what { flex: 0 0 var(--ws-what); }
.ws-settings-ctl { display: inline-flex; align-items: center; gap: .15rem; }
.ws-settings-step {
  width: 1.7rem; padding: .15rem 0; text-align: center;
  border: 1px solid var(--line-ctl); background: transparent; color: var(--ink);
  border-radius: 4px; cursor: pointer; font-size: .9rem; line-height: 1.2;
}
.ws-settings-step:hover:not(:disabled) { border-color: var(--accent);
                                         color: var(--accent); }
.ws-settings-step:disabled, .ws-settings-reset:disabled { opacity: .4;
                                                          cursor: default; }
.ws-settings-value { min-width: 3.4em; text-align: center;
                     font-variant-numeric: tabular-nums; }
/* THE SCOPE SWITCH — Jon, 2026-08-09: *"the settings should have a checkmark
 * for "global" or "everywhere" and otherwise just be the option for the book,
 * not a completely seperate thing further down. i think this saves space too.
 * seems intuitive."*
 *
 * `margin-left: auto` moved here from `.ws-settings-reset`: the box is on
 * every row and the reset is on one, so the box is what pushes the right-hand
 * group over. The reset then sits after it, which is also the order the two
 * are read in — *everywhere*, then *reset everywhere*.
 *
 * `accent-color` rather than a hand-drawn box: it repaints the native control
 * in the app's own red and keeps every behaviour a real checkbox has. */
.ws-settings-scope {
  margin-left: auto; display: inline-flex; align-items: center; gap: .3rem;
  font-size: .72rem; color: var(--ink-sub); cursor: pointer;
  white-space: nowrap;
}
.ws-settings-scope:has(.ws-settings-box:disabled) { cursor: default;
                                                    opacity: .55; }
.ws-settings-box { accent-color: var(--accent); cursor: inherit; margin: 0; }
.ws-settings-reset {
  padding: .15rem .5rem; font-size: .72rem;
  color: var(--ink-sub); background: transparent;
  border: 1px solid var(--line-ctl); border-radius: 4px; cursor: pointer;
}
/* An author rule has to win `hidden` back from any `display` above — L-024's
 * `.ws-progress` trap, stated before it can fire rather than after. Nothing
 * sets `display` on this button today; the rule is what keeps that true. */
.ws-settings-reset[hidden] { display: none; }
.ws-settings-reset:hover:not(:disabled) { color: var(--accent);
                                          border-color: var(--accent); }
/* 6b's reading-mode choices: a pressed pair, state carried on aria-pressed
 * (the accessible attribute IS the styling hook, so the two cannot drift). */
.ws-settings-choice {
  padding: .15rem .55rem; text-align: center;
  border: 1px solid var(--line-ctl); background: transparent; color: var(--ink);
  border-radius: 4px; cursor: pointer; font-size: .78rem; line-height: 1.2;
}
.ws-settings-choice:hover:not(:disabled) { border-color: var(--accent);
                                           color: var(--accent); }
.ws-settings-choice[aria-pressed="true"] {
  background: var(--accent); border-color: var(--accent);
  color: var(--pane); font-weight: 600;
}
.ws-settings-choice:disabled { opacity: .4; cursor: default; }
.ws-settings-note { margin: .5rem 0 0; font-size: .74rem;
                    color: var(--ink-sub); }
/* The display rows carry a caveat under the controls rather than beside them:
 * the row is already `flex-wrap: wrap`, so a full-width child breaks the line
 * on its own without a second layout rule. Each note is a measurement (how
 * many of the 52 books have this at all, what the KJV loses with chapter
 * numbers off), which is why it sits on every row and not only the odd one. */
.ws-settings-sub { flex: 1 0 100%; margin: .1rem 0 .2rem;
                   padding-left: calc(var(--ws-what) + .5rem);
                   font-size: .72rem; color: var(--ink-sub); line-height: 1.35; }
.ws-settings-sub:empty { display: none; }
.ws-settings-subhead { margin-top: .7rem; }

@media (max-width: 34rem) {
  .ws-settings {
    top: auto; left: 0; right: 0; bottom: 0; width: auto;
    max-height: 50vh;
    border-left: none; border-right: none; border-bottom: none;
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -6px 24px rgba(0,0,0,.3);
  }
}

/* ------------------------------------------------ saved notes on a page
 *
 * The list is a SIBLING of `article.text`, never a wrapper, and nothing here
 * is a layout rule above renderer output. L-009's second half: `.plate-cap
 * { display: flex }` collided with no class at all and still broke 463 pages,
 * because a rendered caption is a bare run of `<p class="pN">` siblings with
 * no wrapper of its own and every paragraph became a flex item. The `blockquote`
 * below holds plain text this file's server put there, not corpus markup.
 */
.ws-marks { margin: 2rem 0 0; padding-top: .8rem; border-top: 1px solid var(--line); }
.ws-marks h2 { font-size: .95rem; color: var(--dim); margin: 0 0 .6rem; font-weight: 600; }
.ws-marks-unchecked { font-size: .84rem; color: var(--accent); margin: 0; }
.ws-mark-list { list-style: none; margin: 0; padding: 0; }
.ws-mark {
  margin: 0 0 .7rem; padding: .5rem .7rem; cursor: pointer;
  border: 1px solid var(--line); border-radius: 4px; background: var(--panel);
}
.ws-mark:hover { border-color: var(--dim); }
.ws-mark-quote {
  margin: 0 0 .35rem; padding: .2rem .55rem; font-style: italic;
  border-left: 3px solid var(--accent); overflow-wrap: anywhere;
}
.ws-mark-body { margin: 0 0 .35rem; font-size: .9rem; overflow-wrap: anywhere; }
.ws-mark-where { margin: 0; font-size: .76rem; color: var(--dim); }
.ws-mark-badge {
  display: inline-block; padding: 0 .35rem; margin-right: .3rem;
  border: 1px solid var(--line); border-radius: 3px; font-size: .72rem;
  text-transform: uppercase; letter-spacing: .04em;
}
/* A note whose quote is gone marks nothing in the text, so it says so loudly
 * here instead. A missing highlight is recoverable; a wrong one is not. */
/* Every verdict that marks nothing looks the same, because they mean the same
 * thing to a reader: the archive would not confirm this, so believe the words
 * and not the position.
 *
 * **THIS IS A KEEP-LIST OF NAMES AND IT IS THE KIND L-076 IS ABOUT** - the
 * membership is exactly "every verdict outside `app.PLACEABLE`", which nothing
 * here derives, so v27's `unpositioned` had to be remembered onto both
 * selectors or a note would have rendered as an ordinary anchored one with a
 * badge saying it had no position. Adding a verdict? Add it here, or say why
 * it paints like a placed note. `verify_notes` asserts the class on the one
 * verdict it drives (`ws-mark-lost`) and on no other, so this list is
 * otherwise unguarded. */
.ws-mark-lost, .ws-mark-ambiguous, .ws-mark-error, .ws-mark-unverifiable,
.ws-mark-unpositioned {
  border-color: var(--accent);
}
.ws-mark-lost .ws-mark-badge, .ws-mark-ambiguous .ws-mark-badge,
.ws-mark-error .ws-mark-badge, .ws-mark-unverifiable .ws-mark-badge,
.ws-mark-unpositioned .ws-mark-badge {
  border-color: var(--accent); color: var(--accent);
}
.ws-mark-delete {
  float: right; font: inherit; font-size: .74rem; cursor: pointer;
  background: transparent; color: var(--dim);
  border: 1px solid var(--line); border-radius: 3px; padding: 0 .4rem;
}
.ws-mark-delete:hover { color: var(--accent); border-color: var(--accent); }

/* The anchored words themselves. Painted through the CSS Custom Highlight API
 * from live Ranges, so no element is inserted into the rendered text and no
 * text node is split — which is also why `selectionRange`'s span-length
 * arithmetic keeps working with notes on the page. Browsers without the API
 * simply show no colour; the list and the scroll still work. */
::highlight(ws-note) { background: var(--accent-soft); text-decoration: underline solid var(--accent); }

/* ------------------------------------------------- Jon's own Logos highlights
 *
 * `markupStyle` is the pen, and `color` is NOT — it reads `"yellow"` on all
 * nine of Jon's highlights regardless of the mark, so a sheet keyed on it
 * paints nine identical yellow blocks over three fills, two underlines and four
 * marks that are not fills at all.
 *
 * **These colours are this app's approximations and the page says so.** The
 * real definitions come from `highlightingapi.logos.com` behind OAuth 1.0a and
 * are on no machine here — `logos-linux` searched the desktop install, the
 * mobile APK's 244 assemblies and a phone pull, and the string `Highlighter`
 * appears in none of them. So the names below are a reading of what the pen is
 * called, not a copy of what Logos draws.
 *
 * **What ::highlight() can and cannot do here is measured, not assumed.**
 * Chromium 140.0.7339.16, full-page pixel diff on a standalone page:
 *
 *     PAINTS      background-color, color, text-decoration (line / wavy /
 *                 thickness / line-through), text-shadow
 *     NO EFFECT   border, outline, box-shadow, padding, -webkit-text-stroke
 *
 * A first probe reported text-decoration as dead too, and it was wrong: the
 * screenshot clip was 30px tall and the underline lands at the BOTTOM of the
 * range's rect, outside it. The corrected probe diffs the whole page and
 * reports the rows that changed, so a mark drawn outside the crop cannot read
 * as a mark not drawn.
 *
 * So fills and underlines are painted here, and brackets, boxes and margin
 * marks CANNOT be — `border` and `box-shadow` are both dead in this pseudo.
 * Those are drawn into `.ws-pen-layer` below from `Range.getClientRects()`.
 */
:root {
  --pen-yellow: rgba(255, 233, 120, .80);
  --pen-green:  rgba(150, 224, 140, .70);
  --pen-red:    #a92b2b;
  --pen-orange: #cf7300;
  --pen-mark:   #7a2e2e;
}
@media (prefers-color-scheme: dark) {
  :root {
    --pen-yellow: rgba(150, 126, 26, .55);
    --pen-green:  rgba(60, 128, 60, .55);
    --pen-red:    #e06a6a;
    --pen-orange: #e59a3a;
    --pen-mark:   #d98a8a;
  }
}
::highlight(ws-pen-yellowhighlighter)  { background: var(--pen-yellow); }
::highlight(ws-pen-greenhighlighter)   { background: var(--pen-green); }
::highlight(ws-pen-redunderline)       { text-decoration: underline solid var(--pen-red);
                                         text-decoration-thickness: 2px; }
::highlight(ws-pen-orangewavyunderline){ text-decoration: underline wavy var(--pen-orange); }
/* A Logos NOTE that anchors text carries no pen at all. It is marked the same
 * way this app marks its own notes, because to a reader it is the same thing —
 * words somebody attached a comment to. */
::highlight(ws-pen-note) { background: var(--accent-soft);
                           text-decoration: underline dotted var(--accent); }

/* The overlay the other pens are drawn into.
 *
 * A SIBLING of `article.text` and empty in the HTML; `app.js` fills it with
 * absolutely-positioned boxes computed from live Ranges. Nothing is inserted
 * into rendered text and no text node is split, so `selectionRange`'s
 * span-length arithmetic keeps working and `render.py` keeps owning the shape
 * of everything inside `article.text` (L-009).
 *
 * `pointer-events: none` is load-bearing, not tidiness: without it the layer
 * sits over the reading text and swallows the drag that makes a selection,
 * which would break note-taking on every page that has a bracket on it.
 *
 * `.article-view` is made a containing block for it. That is safe here and it
 * was checked rather than assumed: `render.BASE_CSS` has no `position` rule at
 * all, so no rendered element is positioned against an ancestor, and
 * `position: relative` with `z-index: auto` creates no stacking context and
 * moves nothing. */
.article-view { position: relative; }
.ws-pen-layer { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
.ws-pen-shape { position: absolute; pointer-events: none; }

/* Brackets — two half-boxes, one at each end of the marked text. */
.ws-pen-shape-bracket-open,
.ws-pen-shape-bracket-close {
  border-top: 2px solid var(--pen-mark);
  border-bottom: 2px solid var(--pen-mark);
}
.ws-pen-shape-bracket-open  { border-left: 2px solid var(--pen-mark); }
.ws-pen-shape-bracket-close { border-right: 2px solid var(--pen-mark); }

/* DoubleBox — one box per line the mark covers, so a mark that wraps is drawn
 * as the reader sees it rather than as one rectangle over the whole paragraph. */
.ws-pen-shape-box { border: 3px double var(--pen-mark); border-radius: 2px; }

/* Margin marks. `!` and the pointer sit in the reading column's own gutter, so
 * they never overlap a glyph. */
.ws-pen-shape-margin {
  font-size: .95rem; font-weight: 700; line-height: 1;
  color: var(--pen-mark); text-align: center;
}

/* A pen this app has not met. Drawn VISIBLY and labelled — a mark rendered as
 * nothing is indistinguishable from a highlight that failed to load, and the
 * definitions are not on this machine, so a new pen is information (L-002). */
.ws-pen-shape-unknown { outline: 2px dotted var(--pen-mark); outline-offset: 1px; }

/* --------------------------------------------------- the Logos highlight list
 *
 * A sibling of `article.text`, like `.ws-marks`, and for the same reason.
 * Separate from `.ws-marks` because it is a different store with a different
 * provenance: that one is what was written in this app and can be deleted from
 * it, this one is Jon's own Logos content and is only ever read. */
.ws-pens { margin: 2rem 0 0; padding-top: .8rem; border-top: 1px solid var(--line); }
.ws-pens h2 { font-size: .95rem; color: var(--dim); margin: 0 0 .6rem; font-weight: 600; }
.ws-pens-unchecked { font-size: .84rem; color: var(--accent); margin: 0; }
.ws-pen-list { list-style: none; margin: 0; padding: 0; }
.ws-pen {
  margin: 0 0 .7rem; padding: .5rem .7rem; cursor: pointer;
  border: 1px solid var(--line); border-radius: 4px; background: var(--panel);
}
.ws-pen:hover { border-color: var(--dim); }
.ws-pen-head { margin: 0 0 .35rem; font-size: .8rem; }
.ws-pen-name { font-weight: 600; margin-right: .4rem; }
.ws-pen-kind { color: var(--dim); }
.ws-pen-swatch {
  display: inline-block; width: 1.6rem; height: .85rem; margin-right: .4rem;
  vertical-align: -.1rem; border: 1px solid var(--line); border-radius: 2px;
}
.ws-pen-swatch[data-pen="YellowHighlighter"] { background: var(--pen-yellow); }
.ws-pen-swatch[data-pen="GreenHighlighter"]  { background: var(--pen-green); }
.ws-pen-swatch[data-pen="RedUnderline"] {
  background: transparent; border: 0; border-bottom: 2px solid var(--pen-red); border-radius: 0; }
.ws-pen-swatch[data-pen="OrangeWavyUnderline"] {
  background: transparent; border: 0; border-bottom: 2px wavy var(--pen-orange);
  text-decoration: underline wavy var(--pen-orange); border-radius: 0; }
.ws-pen-swatch[data-pen="Brackets"] {
  background: transparent; border-left: 2px solid var(--pen-mark);
  border-right: 2px solid var(--pen-mark); border-top: 0; border-bottom: 0; }
.ws-pen-swatch[data-pen="DoubleBox"] { background: transparent; border: 3px double var(--pen-mark); }
/* A margin pen has no fill and no rule to show, so its swatch shows the GLYPH
 * it actually draws. Before this it was `background: transparent; border: 0`,
 * i.e. a 1.6rem of nothing next to two pens that look like something. */
.ws-pen-swatch[data-how="margin"] {
  background: transparent; border: 0; color: var(--pen-mark);
  font-weight: 700; font-size: .8rem; text-align: center; line-height: .85rem;
}
.ws-pen-swatch[data-pen="Exclamation"]::before { content: "!"; }
.ws-pen-swatch[data-pen="LeftMarginPointer"]::before { content: "\25B6"; }
.ws-pen-swatch[data-how="unknown"]::before { content: "?"; }
.ws-pen-swatch[data-how="unknown"] { background: transparent; border: 2px dotted var(--pen-mark); }
.ws-pen-quote {
  margin: 0 0 .35rem; padding: .2rem .55rem; font-style: italic;
  border-left: 3px solid var(--pen-mark); overflow-wrap: anywhere;
}
/* Whose words a quote is. Never decorative: for a `stale` mark this page shows
 * two readings of one range and they can differ. */
.ws-pen-attrib { margin: -.2rem 0 .4rem .55rem; font-size: .72rem; color: var(--dim); }
.ws-pen-quote-theirs { border-left-color: var(--dim); }
.ws-pen-body { margin: 0 0 .35rem; font-size: .9rem; overflow-wrap: anywhere; }
.ws-pen-body p { margin: 0 0 .3rem; }
.ws-uc-cite { border-bottom: 1px dotted var(--dim); }
.ws-pen-where, .ws-pen-agree { margin: 0; font-size: .76rem; color: var(--dim); }
.ws-pen-agree { margin-top: .25rem; }
.ws-pen-unmapped { margin: 0 0 .35rem; font-size: .76rem; color: var(--accent); }
.ws-pen-note { font-size: .74rem; color: var(--dim); margin: .4rem 0 0; }
.ws-pen-badge {
  display: inline-block; padding: 0 .35rem; margin-right: .3rem;
  border: 1px solid var(--line); border-radius: 3px; font-size: .72rem;
  text-transform: uppercase; letter-spacing: .04em;
}
/* Every verdict that draws nothing looks the same, because they mean the same
 * thing to a reader: this could not be confirmed, so believe the words and not
 * the position. Same rule as `.ws-mark-lost` above. */
.ws-pen-stale, .ws-pen-split, .ws-pen-empty, .ws-pen-unknown,
.ws-pen-unversioned, .ws-pen-oversized, .ws-pen-outside,
.ws-pen-malformed { border-color: var(--accent); }
.ws-pen-stale .ws-pen-badge, .ws-pen-split .ws-pen-badge,
.ws-pen-empty .ws-pen-badge, .ws-pen-unknown .ws-pen-badge,
.ws-pen-unversioned .ws-pen-badge, .ws-pen-oversized .ws-pen-badge,
.ws-pen-outside .ws-pen-badge, .ws-pen-malformed .ws-pen-badge {
  border-color: var(--accent); color: var(--accent);
}
.ws-pen-agree-differs { color: var(--accent); }
.ws-pen-badge-unknown { border-color: var(--accent); color: var(--accent); }

/* --------------------------------------------------------- slow-request bar
 *
 * Jon, 2026-08-05, choosing to let a search take a couple of seconds: *"as
 * long as their reader is able to see there is something happening, and the
 * progress of it... a little progress bar or something and not an undefinable
 * spinning circle."*
 *
 * A hairline across the bottom edge of the topbar. It is deliberately not a
 * block that appears and reflows the bar - a control that changes the layout
 * every time the app is busy is worse than the wait it is reporting.
 *
 * `--ws-progress` is set by `app.js`; the native <progress> underneath it is
 * kept for screen readers and hidden from sight, because a styled <progress>
 * cannot be transitioned smoothly across browsers.
 */
/* **`position: fixed`, and it is a CHILD OF `<body>` rather than of the
 * header** - see `templates/shell.html` for the measurement that moved it.
 * `display: none` on an ancestor takes the whole subtree out of rendering, so
 * while this lived in `.topbar` the app had no way at all to report a failure
 * in full-screen reading.
 *
 * **`z-index: 31` preserves every relationship it had, and one changes.** It
 * was `z-index: auto` inside `.topbar`'s stacking context (30), so it painted
 * over the header - its whole job, it rides the border - and under
 * `.ws-update` (35), `.noscript-note` (40), `.popover` (60) and `.ws-tabctx`
 * (72). 31 keeps all five. What moves is the ONE positioned descendant the
 * header has, `.ws-menu-list` (5, inside that context): an open hamburger
 * used to paint over the failure label and no longer does. That is the right
 * way round - `.ws-update`'s *"a menu somebody just asked for outranks a
 * notice nobody did"* is about a banner that sits there indefinitely, and
 * this label exists because the reader's own press just failed. The 2px bar
 * itself never reached the list anyway: it stops at the header's bottom edge
 * and the list opens `.35rem` below it. */
.ws-progress {
  position: fixed; left: 0; right: 0; top: var(--ws-progress-top);
  height: 2px; z-index: 31;
  display: flex; align-items: center; pointer-events: none;
}
/* **Without this the `hidden` attribute does nothing at all.** `[hidden]
 * { display: none }` is a USER-AGENT rule and `.ws-progress { display: flex }`
 * is an author rule, and author beats user-agent whatever the specificity - so
 * `progressBox.hidden = true` left a full-width red bar across the topbar for
 * the rest of the session, and a later fast request could not clear it because
 * `progressStop` early-returns on `if (progressBox.hidden) return`. Found in a
 * browser; no reading of either stylesheet would have shown it. `.popover` and
 * `.ws-note` each already carry this line, which is exactly why nobody noticed
 * it was needed. */
.ws-progress[hidden] { display: none; }
.ws-progress::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0;
  width: var(--ws-progress, 0%); background: var(--accent);
  transition: width .12s linear;
}
/* A request that did not happen. The fill still reaches the end - the request
 * IS over - so the end state cannot be what distinguishes it, and this is the
 * colour that does, alongside a label that says so in words. Before this, a
 * failure ran the success animation and the reader outside the reading pane
 * was told nothing at all. */
.ws-progress.ws-failed::before { background: var(--bad); }
/* The label's failure styling lives with the rest of the label, further down -
 * it used to be a second rule for the same selector here, which is two places
 * deciding one colour and exactly the shape this project keeps paying for. */
.ws-progress-native {
  position: absolute; width: 1px; height: 1px; opacity: 0;
}
/* Sits under the bar rather than in it, so a growing label cannot move the bar
 * it is describing. Only ever non-empty once the budget is spent.
 *
 * **Centred, and on a background that is actually a different colour.** Jon,
 * 2026-08-06: *"when there is a text status on it (like the request failed -
 * 404) then that is over on the right, just under the bar and also escapes my
 * attention. i think it would make more sense to have that centered under the
 * bar and with a distinct background to the words."*
 *
 * He is describing two separate reasons it was missable and both were real. It
 * was pinned to `right: .6rem`, which is the far corner of a wide topbar and
 * nowhere near where anyone is looking; and its background was `--panel`, which
 * is the topbar's own colour, so the "distinct background" was distinct from
 * nothing. A failure notice that only a person already looking for it can find
 * is not a notice.
 *
 * `translateX(-50%)` off `left: 50%` rather than a flex centring, because this
 * is absolutely positioned inside a 2px-tall bar; there is no line box to
 * centre it in. */
.ws-progress-label {
  position: absolute; left: 50%; transform: translateX(-50%); top: .45rem;
  font-size: .75rem; color: var(--ink); white-space: nowrap;
  background: var(--pane); padding: .15rem .55rem; border-radius: 4px;
  border: 1px solid var(--line);
  box-shadow: 0 2px 8px rgba(0,0,0,.18);
  max-width: 80vw; overflow: hidden; text-overflow: ellipsis;
}
/* A failure is the case he was actually looking at, so it gets the strongest
 * treatment the palette allows: the amber fill carried up into the label, with
 * the text reversed out of it. `--bad` is amber rather than red on purpose -
 * `--accent` is already a dark red, so a second red reads as the ordinary bar
 * (see the variable's own note). */
.ws-progress.ws-failed .ws-progress-label {
  color: var(--pane); background: var(--bad); border-color: var(--bad);
}
.ws-progress-label:empty { display: none; }

/* The phone composer's slide-up. Defined outside the media query because a
 * keyframes rule is global; only the `.ws-note` animation inside the query
 * references it. */
@keyframes ws-sheet-up {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}

/* ------------------------------------------------------------------ /mine
 *
 * Jon's own content, across the library. Jon, 2026-08-06: *"i should have
 * some way of seeing those other resources, like notes, documents, favorites,
 * etc."*
 *
 * Classes are `mine-` prefixed. `render.py` owns the class namespace of
 * rendered content and the shell is what moves (L-009); the collision guard
 * sweeps all 51 resources and would fail on any token the renderer emits.
 *
 * Nothing here sets `display: flex` on a PARENT of rendered content. A note
 * body and a clipping are `render_note_body` output - `<p>` siblings with no
 * wrapper of their own - and a flex parent turns those into columns, which is
 * exactly how four-line plate captions once laid out side by side in the wrong
 * reading order on 463 pages (L-009's second half).
 */
.mine-page { padding: 0 0 3rem; }
.mine-lede { font-size: 1.05rem; }
.mine-ro, .mine-src { color: var(--dim); font-size: .88rem; }
.mine-src { margin: -.4rem 0 1rem; }

.mine-page h2 {
  margin: 2.2rem 0 .3rem; padding-top: .8rem;
  border-top: 1px solid var(--line);
}
.mine-folder { margin: 1.2rem 0 .3rem; font-size: 1rem; }

.mine-list { list-style: none; margin: 0; padding: 0; }
/* Ink is summarised a LINE PER PAGE, never a card per stroke, so it is quieter
 * than a note: it is a pointer to where a mark is, not the mark itself. */
.mine-ink { list-style: none; margin: 0 0 .5rem; padding: 0; }
.mine-inkline { color: var(--dim); font-size: .88rem; }
.mine-item {
  margin: 0 0 1rem; padding: .7rem .9rem;
  border: 1px solid var(--line); border-radius: 4px; background: var(--panel);
}
/* The flat variant is for one-line entries - favourites, history, notebooks -
 * where a bordered card per row is noise rather than structure. */
.mine-flat .mine-item {
  margin: 0; padding: .35rem .5rem; border: none; border-radius: 0;
  background: none; border-bottom: 1px solid var(--line);
}
.mine-body p { margin: .3rem 0; }
.mine-cliptitle { font-weight: 600; }
.mine-quote {
  margin: .4rem 0; padding: .3rem 0 .3rem .8rem;
  border-left: 3px solid var(--accent); color: var(--ink);
  font-style: italic;
}
.mine-quote p { margin: .2rem 0; }
.mine-where { margin: .35rem 0 0; font-size: .9rem; }
.mine-when { color: var(--dim); font-size: .8rem; margin: .2rem 0 0; }
.mine-flat .mine-when { margin-left: .5rem; }
.mine-ref {
  display: inline-block; margin-left: .4rem; padding: 0 .35rem;
  border: 1px solid var(--line); border-radius: 3px;
  font-size: .8rem; color: var(--dim);
}
.mine-doc { margin-left: .4rem; color: var(--dim); font-size: .85rem; }
.mine-pen {
  display: inline-block; padding: 0 .35rem; border-radius: 3px;
  background: var(--accent-soft); color: var(--accent); font-size: .8rem;
}
/* An unrecognised pen is drawn VISIBLY and labelled, never skipped - the pen
 * definitions are not on this machine, so a pen this app has not met is new
 * information rather than an error to swallow (L-002). */
.mine-pen-unknown { background: var(--bad); color: var(--pane); }
.mine-nolink { color: var(--dim); font-size: .8rem; }
.mine-histname { color: var(--ink); }

.mine-table { border-collapse: collapse; width: 100%; margin: .5rem 0; }
.mine-table th, .mine-table td {
  text-align: left; padding: .3rem .5rem; border-bottom: 1px solid var(--line);
}
.mine-table th { font-size: .8rem; color: var(--dim); font-weight: 600; }
.mine-num { text-align: right; font-variant-numeric: tabular-nums; }
.mine-total td { font-weight: 600; border-top: 2px solid var(--line); }

/* The three honest states, and they are deliberately not one style. An empty
 * section and an unpublished one are different claims (L-014), and the page
 * must not let them look alike. */
.mine-empty { color: var(--dim); font-style: italic; }
.mine-absent, .mine-unmeasured, .mine-off, .mine-unmapped {
  padding: .5rem .7rem; border-left: 3px solid var(--bad);
  background: var(--accent-soft);
}
.mine-caveat {
  padding: .5rem .7rem; border-left: 3px solid var(--line);
  color: var(--dim); font-size: .9rem;
}
.mine-ok { color: var(--dim); font-size: .9rem; }
.mine-corrob { font-size: .82rem; margin: .35rem 0 0; }
.mine-corrob.mine-off { font-size: .82rem; }
.mine-why { font-family: ui-monospace, monospace; font-size: .82rem; color: var(--dim); }
.mine-probe, .mine-built { color: var(--dim); font-size: .85rem; }
.mine-loose { color: var(--dim); }

/* --------------------------------------------------------------- decisions
 *
 * Jon, 2026-08-06: *"I can just submit and write into text boxes or something
 * for decisions that you need."*
 *
 * `dec-` and not `dx-`: Diagnostics owns that prefix, and two pages sharing one
 * family is how a rule written for one silently restyles the other. Every token
 * here is swept against the renderer's own class namespace by
 * `verify_shell.check_class_collisions` (L-009), which is what makes a new
 * prefix safe to invent rather than a thing to worry about.
 */
.dec-page { padding: 0 0 3rem; }
.dec-lede { font-size: 1.05rem; }
.dec-ro, .dec-note { color: var(--dim); font-size: .88rem; }
.dec-absent {
  border-left: 3px solid var(--bad); padding: .5rem .8rem;
  background: var(--accent-soft);
}
.dec-page h2 {
  margin: 2.2rem 0 .3rem; padding-top: .8rem;
  border-top: 1px solid var(--line);
}
.dec-item {
  border: 1px solid var(--line); border-radius: 6px;
  padding: .9rem 1rem; margin: 1rem 0; background: var(--pane);
}
.dec-item[data-state="settled"] { opacity: .82; }
.dec-title { margin: 0 0 .5rem; font-size: 1.05rem; }
.dec-body { margin: .5rem 0; }
.dec-aside, .dec-reply { margin: .4rem 0; font-size: .92rem; }
.dec-who {
  color: var(--dim); font-size: .78rem; text-transform: uppercase;
  letter-spacing: .04em; margin-right: .4rem;
}
.dec-chose {
  display: inline-block; border: 1px solid var(--accent); color: var(--accent);
  border-radius: 999px; padding: 0 .45rem; margin-right: .4rem;
  font-size: .82rem; font-weight: 600;
}
.dec-options { list-style: none; margin: .8rem 0; padding: 0; }
/* A whole row is the target, not the 13px radio. This is read on a phone. */
.dec-options label {
  display: block; border: 1px solid var(--line); border-radius: 5px;
  padding: .55rem .7rem; margin: .35rem 0; cursor: pointer;
  min-height: 2.4rem;
}
.dec-options label:hover { border-color: var(--accent); }
.dec-opt-label { font-weight: 700; margin: 0 .45rem 0 .3rem; }
.dec-opt-detail { display: block; color: var(--dim); font-size: .88rem;
                  margin-top: .25rem; }
.dec-rec {
  display: inline-block; margin-left: .4rem; padding: 0 .4rem;
  border-radius: 999px; background: var(--accent-soft); color: var(--accent);
  font-size: .75rem; font-weight: 600;
}
.dec-write { display: block; margin: .8rem 0 .5rem; }
.dec-write span { display: block; color: var(--dim); font-size: .88rem;
                  margin-bottom: .25rem; }
.dec-write textarea {
  width: 100%; box-sizing: border-box; font: inherit; font-size: .95rem;
  padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 5px;
  background: var(--pane); color: var(--ink); resize: vertical;
}
.dec-submit {
  font: inherit; font-size: .95rem; cursor: pointer; min-height: 2.4rem;
  padding: .45rem 1.1rem; border-radius: 5px;
  border: 1px solid var(--accent); background: var(--accent-soft);
  color: var(--accent); font-weight: 600;
}
.dec-submit[disabled] { opacity: .6; cursor: default; }

/* ------------------------------------------------------------------ login */
/* The password gate (`templates/login.html`, `auth.py`). Prefixed `ws-` like
   every other shell class: the renderer owns the unprefixed namespace and
   `verify_shell.check_class_collisions` fails on any intersection (L-009).
   Sized for a phone first — he reads on one, and this is the page he will meet
   most often on it. */
.ws-login { max-width: 22rem; margin: 3rem auto; }
.ws-login h1 { margin: 0 0 1rem; font-size: 1.4rem; }
.ws-login-form { display: flex; flex-direction: column; gap: 0.5rem; }
.ws-login-form label { font-size: 0.9rem; opacity: 0.8; }
/* 16px minimum, or iOS zooms the whole page in on focus and he lands on a
   viewport he then has to pinch back out of. */
.ws-login-form input { font-size: 16px; padding: 0.6rem; border-radius: 6px;
                       border: 1px solid var(--line); width: 100%;
                       box-sizing: border-box; }
.ws-login-form button { font-size: 1rem; padding: 0.6rem 1rem; margin-top: 0.5rem;
                        border-radius: 6px; cursor: pointer; }
.ws-login-problem { margin: 0 0 1rem; padding: 0.6rem 0.8rem; border-radius: 6px;
                    background: #b3261e; color: #fff; }
.ws-login-note { margin-top: 1.5rem; font-size: 0.85rem; opacity: 0.7; }

/* Who asked a decision — `asked_by`, rendered because Jon asked to be able to
   tell logos-linux's questions from this project's ("ensure i can tell the
   difference", 2026-08-06). The column existed and was queried; nothing
   rendered it, so the page could not answer that question at all. */
.dec-asked-by { margin: -.2rem 0 .5rem; font-size: .78rem; color: var(--dim); }

/* Notes written in this reader (`#mine-written-here`), as distinct from the
   Logos-account notes below them. Jon, 2026-08-06: he could not find them
   because /mine never read workspace.db at all. */
.mine-book { margin: 1rem 0 .3rem; font-size: .95rem; }
.mine-own .mine-quote { margin: 0 0 .3rem; padding-left: .6rem;
                        border-left: 3px solid var(--line); font-style: italic; }
.mine-own .mine-body { margin: 0 0 .3rem; }

/* Why the standalone article page shows no colour. Jon, 2026-08-06: "all of
   the highlighting isn't working" - it was the JavaScript-off view, where
   ::highlight() cannot be populated, looking like a broken feature rather
   than a view that does not do this. */
.ws-pens-flat { margin: .3rem 0 .6rem; padding: .5rem .7rem; font-size: .84rem;
                border-left: 3px solid var(--dim); color: var(--dim); }

/* ------------------------------------------------------- the pen picker */
/* Jon, 2026-08-06 (`highlight-pens`): all eight. The swatches key off
   `data-pen`, the exact `markupStyle` the wire carries, so there is no second
   spelling to keep in step (L-017). The four the browser cannot paint natively
   are shown as what they draw - brackets, a box, a glyph - rather than as a
   colour they do not have. */
.ws-note-pens { display: flex; flex-wrap: wrap; gap: .35rem; margin: .5rem 0 .2rem; }
/* **`color` and `font-family` are set explicitly, and that is the whole of
 * Jon's dark-mode bug.** A `<button>` does not inherit either from its parent -
 * it takes the user agent's, which is near-black - so the four glyph pens were
 * dark grey on the dark pane and he could not see them at all:
 *
 *   "when using dark mode, some of them are not visible, especially the latter
 *    ones that are brackets or outline of the word. So I put it into light mode
 *    so I can see what those were supposed to be."  (2026-08-07)
 *
 * This was my CSS from the night before and I never looked at it in dark.
 *
 * **`border-bottom: 3px wavy` was also invalid and silently dropped.** `wavy`
 * is a `text-decoration-style`, not a border style, so the orange pen's swatch
 * showed nothing whatever the scheme. Every swatch now draws a sample of what
 * the pen actually does to text, which is also more use than a colour chip:
 * the two fills show a fill, the two underlines show their own underline, and
 * the four overlay pens show their glyph. */
.ws-note-pen { min-width: 2.1rem; height: 2.1rem; border-radius: 5px;
               cursor: pointer; border: 1px solid var(--dim);
               background: var(--pane); color: var(--ink);
               font: inherit; font-size: .82rem; line-height: 1; padding: 0 .25rem; }
.ws-note-pen::after { content: "Ab"; }
.ws-note-pen:hover { outline: 2px solid var(--accent); }
.ws-note-pen[data-pen="YellowHighlighter"]  { background: var(--pen-yellow); color: #202020; }
.ws-note-pen[data-pen="GreenHighlighter"]   { background: var(--pen-green); color: #202020; }
.ws-note-pen[data-pen="RedUnderline"]::after {
  text-decoration: underline solid var(--pen-red); text-decoration-thickness: 2px; }
.ws-note-pen[data-pen="OrangeWavyUnderline"]::after {
  text-decoration: underline wavy var(--pen-orange); }
.ws-note-pen[data-pen="Brackets"]::after    { content: "[Ab]"; }
.ws-note-pen[data-pen="DoubleBox"]::after   { content: "▣"; }
.ws-note-pen[data-pen="Exclamation"]::after { content: "!"; }
.ws-note-pen[data-pen="LeftMarginPointer"]::after { content: "▶"; }

/* --------------------------------------------------------------------------
   ON-DEMAND RETRIEVAL — /retrieve, 2026-08-22

   A standalone page under `base.html`'s 44rem measure, so there is no shell
   chrome to fit around and no dark/light variant to keep in step: these read
   the same custom properties every other standalone page does.

   **Nothing here is `display`-toggled.** The template emits a branch or does
   not, so there is no hidden-but-painted state to get wrong (L-024), and no
   rule below can outrank `[hidden]`.
   -------------------------------------------------------------------------- */
.rx-offer, .rx-any {
  display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
  margin: 1rem 0; padding: .85rem 1rem;
  border: 1px solid var(--line); border-radius: 8px;
  background: transparent;
}
.rx-offer-what { display: flex; flex-direction: column; flex: 1 1 14rem; }
.rx-offer-title { font-weight: 600; }
.rx-offer-by, .rx-offer-note { font-size: .88rem; opacity: .78; }
.rx-any label { flex: 1 1 100%; font-size: .88rem; opacity: .78; }
.rx-any input {
  flex: 1 1 12rem; min-width: 0; padding: .45rem .6rem;
  font: inherit; font-size: 1rem;   /* 1rem: below ~16px iOS Safari zooms on focus */
  border: 1px solid var(--line); border-radius: 6px;
}
.rx-go {
  padding: .5rem 1rem; font: inherit; font-size: 1rem; cursor: pointer;
  border: 1px solid var(--line); border-radius: 6px;
}
.rx-state { margin: 1rem 0; font-size: 1.05rem; }
.rx-phase { font-variant: small-caps; letter-spacing: .04em; }
.rx-step { opacity: .75; margin-left: .5rem; font-size: .9rem; }
.rx-done { font-size: 1.15rem; }
.rx-fault { border-left: 3px solid currentColor; padding-left: .75rem; }
.rx-quiet, .rx-alt, .rx-src, .rx-frac { font-size: .9rem; opacity: .8; }
/* **The reader's own words back at them, and the tool's words under ours.**
 * Both inherit `color` rather than naming one, which is the whole of being
 * right in dark mode here: `base.html` sets the page colours from the same
 * tokens and a literal would be correct in exactly one theme. `.rx-raw` is
 * quieter than the instruction above it on purpose - it is kept because it is
 * the only sentence that stays true when the cause is something this app has
 * not learned to name, not because it is what the reader should read first. */
.rx-asked { margin-top: 1rem; }
.rx-raw { font-size: .9rem; opacity: .7; margin-top: -.4rem;
          padding-left: .75rem; }
.rx-them { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
           font-size: .9em; }

/* Get a book from any source plug-in - decision 229. The watching page reuses
 * `/retrieve`'s `rx-` sentences; these are only what it adds: the phase bar
 * and the queue. `<progress>` is native, so the bar needs no script. */
.acq-bar { width: 100%; max-width: 28rem; height: .6rem; }
.acq-queue, .acq-recent { padding-left: 1.2rem; }
.acq-q-what { font-weight: 600; }
.acq-q-state { margin-left: .5rem; font-size: .9rem; opacity: .8; }

/* ---------------------------------------------------------------------------
 * The development milestones page - Jon's own steering page, 2026-08-23.
 *
 * **A LIST OF ROWS WITH CONTROLS, NOT A COLUMN OF PROSE**, so it takes the
 * `dx-page` treatment: `main`'s 44rem measure is a reading width and this page
 * has a title, a state and two arrows on one line. Narrower than diagnostics,
 * because these ARE sentences and 74rem of one is unreadable.
 *
 * **Every token here is one the sheet already defines**, so dark mode needs no
 * block of its own - the palette is redefined once at `:root` and everything
 * below follows it.
 *
 * **`.ms-badge` deliberately sets NO background.** Every state variant would
 * override it, and a declaration that is always overridden reads as coverage
 * while doing nothing (L-085).
 *
 * **DO NOT TRUST THAT CHECKER TO CATCH THAT FOR YOU.**
 * `verify_shell.check_no_declaration_is_overridden_into_silence` is exact
 * about ONE shape - *"identical selector text means identical specificity"* -
 * so it finds a rule beaten by a later rule with the SAME selector and is
 * blind to a rule beaten by a DIFFERENT, more specific one. This block shipped
 * with exactly that hole: `.ms-danger { color: var(--bad) }` at (0,1,0) lost
 * to `.ms-more button { color: var(--ink) }` at (0,1,1), so the one
 * destructive control on the page painted identically to *Save it* and
 * *Rename it*, and nothing in this repo could have said so. Being later in the
 * file does not help; specificity is decided first. `.ms-danger` carries the
 * matching element now.
 * ------------------------------------------------------------------------- */
body.ms-page main { max-width: 56rem; }

.ms-page h1 { font-size: 1.5rem; margin: 0 0 .3rem; }
.ms-page .lede { color: var(--dim); max-width: 44rem; margin: 0 0 1.4rem; }
.ms-page .lede strong { color: var(--ink); }

.ms-fault {
  margin: .4rem 0; padding: .45rem .7rem;
  border-left: 3px solid var(--bad);
  background: var(--panel); color: var(--ink); font-size: .92rem;
}
.ms-why { color: var(--ink-sub); }

.ms-m {
  margin: 0 0 1.6rem; padding: .8rem 0 .2rem;
  border-top: 1px solid var(--line);
}
.ms-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
.ms-title { font-size: 1.15rem; margin: 0; flex: 1 1 14rem; }
.ms-body { margin: .35rem 0; color: var(--ink-sub); font-size: .95rem; }

.ms-badge {
  flex: 0 0 auto; padding: .05rem .4rem; border-radius: 3px;
  font-size: .72rem; text-transform: uppercase; letter-spacing: .04em;
  border: 1px solid var(--line-ctl);
}
.ms-badge-open { color: var(--ink-label); }
.ms-badge-active { color: var(--accent); border-color: var(--accent); }
.ms-badge-doing { color: var(--accent); border-color: var(--accent); }
.ms-badge-done { color: var(--dim); border-color: var(--line); }
.ms-badge-parked { color: var(--dim); border-color: var(--line); }
.ms-badge-blocked { color: var(--bad); border-color: var(--bad); }
.ms-badge-question { color: var(--accent); border-color: var(--accent);
                     background: var(--accent-soft); }

.ms-count { flex: 0 0 auto; font-size: .8rem; color: var(--dim); }
.ms-q { color: var(--accent); }

.ms-move { flex: 0 0 auto; display: flex; gap: .15rem; }
.ms-move button {
  padding: 0 .4rem; border: 1px solid var(--line-ctl); border-radius: 3px;
  background: var(--bg); color: var(--ink-label); font: inherit;
  line-height: 1.5; cursor: pointer;
}
.ms-move button:hover { color: var(--accent); border-color: var(--accent); }

.ms-states { display: flex; gap: .25rem; flex-wrap: wrap; margin: .4rem 0; }
.ms-set {
  padding: .12rem .5rem; border: 1px solid var(--line-ctl); border-radius: 3px;
  background: var(--bg); color: var(--ink-label);
  font: inherit; font-size: .78rem; cursor: pointer;
}
.ms-set:hover { color: var(--accent); border-color: var(--accent); }
/* The state a row is already in. `disabled` and not merely styled, so a press
 * that would be a no-op cannot reach the server at all - `ws-space-load`'s
 * rule for the workspace you are already in. */
.ms-set[disabled] {
  cursor: default; color: var(--accent); border-color: var(--accent);
  background: var(--accent-soft); font-weight: 600;
}

.ms-guide { list-style: none; margin: .45rem 0; padding: 0; }
.ms-guide li {
  margin: .25rem 0; padding: .3rem .6rem; border-left: 3px solid var(--line);
  background: var(--panel); font-size: .92rem;
}
.ms-guide li.from-jon { border-left-color: var(--accent); }
.ms-who {
  display: block; font-size: .72rem; text-transform: uppercase;
  letter-spacing: .04em; color: var(--ink-label);
}
.ms-said { display: block; color: var(--ink); }

.ms-more { margin: .4rem 0; }
/* No `list-style` here. Nothing in this sheet hides a `summary` marker
 * GLOBALLY, so restating it would be a declaration that changes nothing, which
 * reads as coverage while doing nothing (L-085).
 *
 * The rules that DO hide one are `.shelfgrp summary`, `.facet summary` and
 * `.ws-help-btn` - the strip's `?`, which is a `<summary>` and takes all three
 * of `list-style`, `::-webkit-details-marker` and `::marker`. An earlier
 * version of this comment named `.loosegrp` instead, which styles a `<span>`
 * INSIDE a summary and hides no marker at all: the conclusion was right and
 * the evidence offered for it was wrong, which is the half that gets copied
 * (L-007). */
.ms-more > summary {
  cursor: pointer; font-size: .82rem; color: var(--dim);
}
.ms-more > summary:hover { color: var(--accent); }
.ms-more form { margin: .5rem 0; display: flex; flex-wrap: wrap;
                gap: .35rem; align-items: flex-end; }
.ms-more label { display: flex; flex-direction: column; gap: .15rem;
                 flex: 1 1 16rem; font-size: .8rem; color: var(--ink-label); }
.ms-more input[type="text"], .ms-more textarea {
  padding: .35rem .5rem; border: 1px solid var(--line-ctl); border-radius: 3px;
  background: var(--bg); color: var(--ink); font: inherit; font-size: .9rem;
}
.ms-more button {
  flex: 0 0 auto; padding: .35rem .8rem;
  border: 1px solid var(--line-ctl); border-radius: 3px;
  background: var(--bg); color: var(--ink); font: inherit; cursor: pointer;
}
.ms-more button:hover { border-color: var(--accent); color: var(--accent); }
/* `.ms-more button.ms-danger` — (0,2,1), which beats `.ms-more button`'s
 * (0,1,1). See the header: the class alone lost, silently, and the sheet
 * checker cannot see a cross-selector kill. */
.ms-more button.ms-danger { color: var(--bad); border-color: var(--bad); }
.ms-more button.ms-danger:hover {
  border-color: var(--bad); color: var(--bg); background: var(--bad);
}

.ms-tasks { list-style: none; margin: .5rem 0 0; padding: 0; }
/* Finished work, folded away rather than deleted. Jon, 2026-08-23: *"If a
 * milestone is answered or completed, it should be removed perhaps and made a
 * part of history."* 23 of his 49 tasks were done, and every one of them was
 * still in the list he steers from. `<details>` keeps it one tap away and
 * needs no JavaScript, which this page deliberately has none of. */
.ms-finished { margin: .5rem 0 0; }
.ms-finished > summary {
  cursor: pointer; color: var(--dim); font-size: .9rem; padding: .25rem 0;
}
.ms-finished[open] > summary { margin-bottom: .25rem; }
/* A task's body, notes and edit forms, on demand (`s-milestonespage`, cuts
 * 2+3) - the row's own fold, so it reads like the `.ms-more` summary it
 * replaces rather than like a heading. */
.ms-t-detail { margin: .2rem 0 0; }
.ms-t-detail > summary {
  cursor: pointer; font-size: .82rem; color: var(--dim);
}
.ms-t-detail > summary:hover { color: var(--accent); }
.ms-t-detail-more { margin: .25rem 0; font-size: .82rem; }
/* A SHUT milestone's fold (cut 4) holds only this link until it is opened;
 * the same size as a task's, since it does the same job one level up. */
.ms-fold-more { margin: .25rem 0; font-size: .82rem; }
/* Dimmed, never hidden: `display` here would fight the user-agent rule that
 * `<details>` already uses to fold, which is L-024's mechanism one element
 * over. Opacity says "finished" and leaves the row readable. */
.ms-tasks-done > .ms-t { opacity: .72; }
.ms-t {
  margin: 0 0 .5rem; padding: .4rem 0 .4rem .8rem;
  border-left: 2px solid var(--line);
}
/* An unrecognised state must LOOK unrecognised. Without this the class was
 * emitted and styled exactly like a known one, and the only signal was the
 * fault paragraph beside it (L-002: say the true thing where the reader is
 * looking). */
.ms-m.ms-unknown, .ms-t.ms-unknown { border-left: 3px solid var(--bad); }
.ms-m.ms-unknown { border-top-color: var(--bad); }
/* The milestone-level state hooks. They were emitted with no rule at all,
 * which is a class that decides nothing. */
.ms-m.ms-done, .ms-m.ms-parked { opacity: .72; }
.ms-t.ms-done { opacity: .68; }
.ms-t.ms-question { border-left-color: var(--accent); }
.ms-t.ms-blocked { border-left-color: var(--bad); }
.ms-t-head { display: flex; align-items: baseline; gap: .5rem;
             flex-wrap: wrap; }
.ms-t-title { flex: 1 1 12rem; }

.ms-new { margin-top: 1.5rem; }
.ms-foot { margin-top: 2rem; font-size: .85rem; color: var(--dim); }

/* The per-tab font stepper's readout. `.ws-tabctx-choice` already paints the
 * two steppers and the `book` button; this is only the number between them,
 * and it is an `<output>` rather than a `<span>` so a screen reader announces
 * the change (`aria-live`) exactly as the Settings popover's does. */
.ws-tabctx-out {
  min-width: 2.6rem; text-align: center; font-size: .82rem;
  font-variant-numeric: tabular-nums; color: var(--ink-label);
}

/* ---------------------------------------------------------------------------
 * INTEGRATIONS - Logos, Calibre, Zotero. Jon, 2026-08-26.
 *
 * `ms-page`'s measure and its fault bar, because this is the same shape of
 * page: sections of prose with a row of controls in each. Nothing here paints
 * a control that `.ms-*` already paints; the two families are separate because
 * this page's rows are a RADIO-like choice and the milestones page's are state
 * transitions, and giving one class two jobs is how a later edit to one page
 * silently moves the other.
 *
 * **NO BARE ELEMENT SELECTOR PAINTS A MODE BUTTON**, which is `.ms-danger`'s
 * lesson one page over. `.ix-sync button` is (0,1,1) and `.ix-mode.current` is
 * (0,2,0), so the state class does win here - but the milestones page shipped
 * the losing arrangement of exactly this pair, and its one destructive control
 * painted identically to Save. Writing every rule with its own class removes
 * the arithmetic instead of getting it right, and nothing in this repo can
 * tell you when a later edit gets it wrong.
 * ------------------------------------------------------------------------- */
body.ix-page main { max-width: 56rem; }

.ix-page h1 { font-size: 1.5rem; margin: 0 0 .3rem; }
.ix-page .lede { color: var(--dim); max-width: 44rem; margin: 0 0 1.2rem; }
.ix-page .lede strong { color: var(--ink); }

.ix-fault {
  margin: .5rem 0; padding: .45rem .7rem;
  border-left: 3px solid var(--bad);
  background: var(--panel); color: var(--ink); font-size: .92rem;
}

/* The standing statement that nothing syncs yet. Deliberately NOT painted as a
 * fault: nothing is wrong, and a red bar would teach the reader to ignore the
 * red bars that mean something. */
.ix-notyet {
  margin: .5rem 0 1.4rem; padding: .5rem .7rem;
  border-left: 3px solid var(--line-ctl);
  background: var(--panel); color: var(--ink-sub); font-size: .92rem;
  max-width: 46rem;
}
.ix-notyet strong { color: var(--ink); }

.ix-svc {
  margin: 0 0 1.8rem; padding: .9rem 0 .2rem;
  border-top: 1px solid var(--line);
}
.ix-svc > h2 { font-size: 1.15rem; margin: 0 0 .35rem; }
.ix-blurb { margin: .3rem 0; color: var(--ink-sub); font-size: .95rem;
  max-width: 46rem; }
.ix-count { margin: .35rem 0; font-size: .95rem;
  font-variant-numeric: tabular-nums; max-width: 46rem; }
.ix-cred { margin: .35rem 0 .8rem; color: var(--ink-sub); font-size: .92rem;
  max-width: 46rem; }
.ix-credwhat { color: var(--ink-label); font-weight: 600; }

.ix-sync {
  display: flex; align-items: center; gap: .4rem; flex-wrap: wrap;
  margin: .5rem 0 .3rem;
}
.ix-synclabel { color: var(--ink-label); font-size: .88rem;
  flex: 0 0 auto; margin-right: .2rem; }
.ix-mode {
  padding: .25rem .6rem; border-radius: 3px; font-size: .88rem;
  border: 1px solid var(--line-ctl); background: var(--bg);
  color: var(--ink); cursor: pointer;
}
.ix-mode:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
/* The one in force: it is where you are, so it is disabled and reads as a
 * readout rather than an offer. */
.ix-mode.current {
  border-color: var(--accent); color: var(--accent);
  background: var(--accent-soft); cursor: default; font-weight: 600;
}
.ix-means { margin: .1rem 0 0; color: var(--dim); font-size: .85rem; }

.ix-other { list-style: none; padding: 0; margin: .4rem 0;
  font-variant-numeric: tabular-nums; }
.ix-other li { margin: .15rem 0; font-size: .92rem; }

/* A book's PDF, drawn by the browser's own viewer on the STANDALONE page - the
 * scripting-off fallback (`pdf.html`). In a tab, pdf.js draws it (`.pdf-view`).
 *
 * A tall fixed height rather than `height: 100%`: this page is `base.html`,
 * which is ordinary document flow with no full-height chain to inherit from,
 * so a percentage resolves against an auto-height parent and collapses to
 * nothing. `vh` needs no chain. */
.pdf-object {
  display: block; width: 100%; height: 82vh; min-height: 24rem;
  border: 1px solid var(--line); border-radius: 4px; background: var(--bg);
}
.pdf-meta { color: var(--dim); font-size: .78rem; }

/* PDF.js renders the PDF into `.pdf-view`, one per `pdf` tab
 * (`s-booksintabs`); the `<object>` above is only on the scripting-off page. */
.pdf-view { margin: .4rem 0 0; }

/* One page. A positioned wrapper so the text layer's absolute spans resolve
 * against this box and not the whole host; the canvas sizes the wrapper and
 * the text layer is layered over it at the same CSS dimensions. The border
 * matches `.pdf-object`'s chrome so the drawn page reads as the same object. */
.pdf-page { position: relative; margin: .35rem 0;
  border: 1px solid var(--line); border-radius: 4px; background: var(--bg); }
.pdf-page canvas { display: block; }

/* The text layer. PDF.js sets this container's width/height inline — its
 * `setLayerDimensions` calc turns the unscaled page size into the rendered
 * one through `--total-scale-factor`, which `pdfview.js` sets per page — and
 * each span's `left`/`top` as PERCENTAGES of it plus a `--font-height`
 * custom property. Everything else is here. */
/* **THE INK LAYER, AND IT IS INERT BY DEFAULT.** It covers the whole page box
   so a stroke can run anywhere on it, which means it sits over `.pdf-text`
   - the layer a reader SELECTS to make a highlight. `pointer-events: none` is
   therefore the resting state and not a nicety: without it the overlay
   swallows every drag before the text layer sees one, and selecting a quote
   stops working with nothing going red. `static/pdfink.js` sets
   `data-ink-tool` on the `.pdf-view` it draws over - PER TAB since
   `s-booksintabs`, so a pen in one PDF tab never arms another - and only a
   drawing tool turns this on. */
/* The pen bar. STICKY, because a quiz PDF is many pages and the tool has to be
   in reach on the last one; above the ink layer (z-index 2) so a drawing tool
   never makes its own bar unpressable. */
.pdf-ink-bar { position: sticky; top: 0; z-index: 5; display: flex;
               flex-wrap: wrap; align-items: center; gap: .35rem;
               margin: .5rem 0; padding: .35rem .45rem; font-size: .85rem;
               border: 1px solid var(--line); border-radius: 4px;
               background: var(--bg); }
.pdf-ink-bar button { font: inherit; padding: .25rem .6rem; cursor: pointer;
                      border: 1px solid var(--line); border-radius: 3px;
                      background: var(--bg); color: var(--ink); }
.pdf-ink-bar button[aria-pressed="true"] { border-color: var(--ink);
                                           font-weight: 600; }
.pdf-ink-bar button:disabled { opacity: .45; cursor: default; }
/* A TOUCH TARGET on a phone or any touch screen - 44px, cut 1's ruling. Cut 2's
   judge (A10, 2026-10-02) measured the Voice memo button 30.4px tall at 400px
   wide; it is pressed twice per memo. Desktop keeps the compact bar. */
@media (pointer: coarse), (max-width: 600px) {
  .pdf-ink-bar button { min-height: 44px; }
}
.pdf-ink-status { flex: 1 1 12rem; color: #c46b1b; }
/* A mark the store has NOT kept. Dashed and faded so it cannot be mistaken for
   a saved one; the bar's status line says the same thing in words. */
.pdf-ink .pdf-ink-unsaved { stroke-dasharray: 2 2; opacity: .5; }

.pdf-ink { position: absolute; inset: 0; width: 100%; height: 100%;
               pointer-events: none; z-index: 2; }
.pdf-view[data-ink-tool="pen"] .pdf-ink,
.pdf-view[data-ink-tool="highlighter"] .pdf-ink,
.pdf-view[data-ink-tool="eraser"] .pdf-ink,
.pdf-view[data-ink-tool="memo"] .pdf-ink {
  /* `touch-action: none` or the browser pans the page instead of drawing -
     and a finger is a legitimate pen here, so this cannot be left to a
     `pointerType` test. */
  pointer-events: auto; touch-action: none;
}
.pdf-view[data-ink-tool="pen"] .pdf-ink { cursor: crosshair; }
.pdf-view[data-ink-tool="highlighter"] .pdf-ink { cursor: cell; }
.pdf-view[data-ink-tool="eraser"] .pdf-ink { cursor: not-allowed; }
.pdf-view[data-ink-tool="memo"] .pdf-ink { cursor: copy; }
/* A VOICE MEMO's marker takes a tap in EVERY tool, Read included, so it can be
   played while the layer under it lets the text be selected (cut 2). */
.pdf-ink .pdf-ink-memo { pointer-events: auto; cursor: pointer; }
.pdf-ink .pdf-ink-recording circle:first-child { opacity: 1;
  stroke: #ffffff; stroke-width: 2; }

.pdf-text {
  position: absolute; inset: 0;
  overflow: hidden;          /* clip to the page, as a viewer does */
  line-height: 1; text-align: initial; transform-origin: 0 0;
  letter-spacing: normal; word-spacing: normal;
  /* The pixel-snapping increments PDF.js's calc rounds to. The `web/` viewer
   * bundle would supply them; only the core is vendored, so they live here. */
  --scale-round-x: 1px; --scale-round-y: 1px;
  z-index: 1;               /* above the canvas, so a selection reaches text */
  /* pdf.js's viewer machinery for the browser's minimum font size: text is
   * sized up by it and scaled back down, so a tiny span still matches the
   * canvas. `--total-scale-factor` is set per page by `static/pdfview.js`. */
  --min-font-size: 1;
  --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
  --min-font-size-inv: calc(1 / var(--min-font-size));
}

/* Transparent but selectable. The glyphs are already painted on the canvas,
 * so the text here must be invisible — but present, so a drag selects it and
 * the browser's own selection highlight shows over the page. */
.pdf-text span, .pdf-text br {
  position: absolute; color: transparent; white-space: pre;
  cursor: text; user-select: text; transform-origin: 0% 0%;
}
/* **A SPAN IS AS BIG AS THE WORDS IT LIES OVER - `s-pdftexthighlight`.** The
 * vendored `TextLayer` writes each span's size as CUSTOM PROPERTIES and leaves
 * the rule to the viewer's CSS, which we did not vendor: `--font-height` in
 * UNSCALED page units, `--scale-x` (the PDF's width over the browser font's)
 * and `--rotate`. This sheet applied `--font-height` as pixels and the other
 * two not at all, so on a page drawn smaller than its own size every span was
 * too big and the wrong width. Measured on WR11 in a 404px pane (scale 0.66):
 * a selection of "Writing" highlighted 55.8pt for a 40.4pt word. These are
 * pdf.js's own rules (`web/text_layer_builder.css`). */
.pdf-text > :not(.markedContent),
.pdf-text .markedContent span:not(.markedContent) {
  z-index: 1;
  font-size: calc(var(--text-scale-factor) * var(--font-height));
  transform: rotate(var(--rotate, 0deg)) scaleX(var(--scale-x, 1))
             scale(var(--min-font-size-inv));
}
/* **A TAGGED PDF'S TEXT WAS ALL IN THE PAGE'S TOP-LEFT CORNER -
 * `s-pdftexthighlight`.** Jon, 2026-10-04: *"I could not select any text."* A
 * tagged PDF (WR11, every Word export) wraps its text in `span.markedContent`,
 * and the span rule made that wrapper an absolute box of NO size - so each
 * span's `left`/`top`, which pdf.js writes as a PERCENT of the page, resolved
 * against nothing and every span sat at 0,0: no text under any word he could
 * see. Measured on WR11 page 1: "Writing" drawn 107px below the top of the page,
 * its span at 0. `display: contents` is pdf.js's own viewer rule today: the
 * wrapper makes no box, so the spans place against `.pdf-text`, the page. */
.pdf-text .markedContent { display: contents; }
.pdf-text br::selection { background: transparent; }
/* **THE END-OF-CONTENT BOX**, pdf.js's viewer CSS (`.textLayer .endOfContent`)
 * under our names; `static/pdfview.js` says why and moves it. The spans sit
 * ABOVE it, so it only answers a pointer over a gap between them, and only
 * while a selection is being made in this layer (`.pdf-text-selecting`);
 * otherwise it is parked below the page. */
.pdf-text .pdf-text-end {
  display: block; position: absolute; inset: 100% 0 0; z-index: 0;
  cursor: default; -webkit-user-select: none; user-select: none;
  transform: none;
}
.pdf-text.pdf-text-selecting .pdf-text-end { top: 0; }

/* **A BOOK'S PDF IN A TAB - `s-booksintabs`.** Jon, 2026-10-04: *"Any book
   opened in any way needs to be in a tab."* The pane is a `.view`, whose
   max-width is the READING measure - right for text, wrong for a page drawn
   at its own proportions with tables on it - so the PDF takes the pane's whole
   width and pdf.js scales each page to it. */
.view.pdf-book { max-width: none; }
.pdf-head { margin: .2rem 0 0; }
.pdf-book .pdf-ink-status { margin: .3rem 0 0; font-size: .82rem; }
.pdf-book .pdf-ink-status:empty { display: none; }
/* **THE PEN BAR, LIFTED INTO THE TAB'S ANNOTATIONS ROW** (`syncTabInkRow`) -
   *"the annotations will be in the annotations menu item bar like usual."* The
   row is the band: a fixed `--ws-tabtool-h`, `overflow-x: auto`, every child
   pinned at its own size. So the bar sheds the sticky box it wore on the old
   page and becomes one unwrapped run of buttons that scrolls sideways with the
   row, never a second line the band would crop (R3). */
.ws-tabtool > .pdf-ink-bar {
  position: static; flex-wrap: nowrap; gap: .3rem; margin: 0; padding: 0;
  border: 0; border-radius: 0; background: none; font-size: .78rem;
}
.ws-tabtool > .pdf-ink-bar button { padding: .1rem .5rem; line-height: 1.3;
                                    white-space: nowrap; }
/* **AND THE 44px TOUCH TARGET SURVIVES THE MOVE**: on a touch screen the band
   is taller while the PDF's pen row is the open one - a FIXED height on
   `<body>`, never a `min-height` (R3) - so the buttons keep cut 1's ruling
   rather than being cropped to a 2rem strip. */
@media (pointer: coarse), (max-width: 600px) {
  body[data-tabtool="ink"]:not([data-fullscreen]) { --ws-tabtool-h: 3rem; }
}

/* ---------------------------------------------------------------------------
   THE DICTIONARY PICKER - `templates/_dictionaries.html`, dropped into the
   Settings row by `loadDictionaries`.

   **`flex: 1 0 100%` and NOT a `display` declaration.** The host lives inside
   `.ws-settings-row`, which is `display: flex; flex-wrap: wrap`, so a full-row
   basis is what puts the groups on their own line - the same thing
   `.ws-settings-sub` does one selector up. Declaring `display` on anything in
   this block would beat the user agent's `[hidden] { display: none }`
   regardless of specificity, which is how the Waiting row came to paint at
   71px while reporting itself hidden (L-024). Nothing here is ever hidden by
   attribute today, and not declaring `display` is what keeps that true if one
   ever is.
   --------------------------------------------------------------------------- */
.ws-dict-host { flex: 1 0 100%; margin: .2rem 0 0; }
.ws-dict-lang { margin: .45rem 0 .6rem; }
.ws-dict-title { font-size: .95rem; font-weight: 600; margin: 0 0 .2rem; }
.ws-dict-list { list-style: none; margin: 0; padding: 0; }
.ws-dict-item { margin: .12rem 0; }
.ws-dict-item label { display: flex; align-items: baseline; gap: .4rem;
                      cursor: pointer; }
.ws-dict-book { flex: 1 1 auto; }
/* The measurement, deliberately quieter than the book's name: it is what lets
   Jon tell a real lexicon from a book that merely mentions the language, and
   it is not the thing he is choosing BY. */
.ws-dict-rate { flex: 0 0 auto; font-size: .8rem; opacity: .7;
                white-space: nowrap; }
.ws-dict-note, .ws-dict-none, .ws-dict-chosen {
  font-size: .82rem; opacity: .75; margin: .15rem 0; }
/* The picker for a language nothing can rank. A native `<select>` rather than
   a list of every book: five measured tests fail to tell a dictionary from a
   Bible for English and Latin, so the whole library is on offer - 572 rows -
   and a native control is a wheel on the phone instead of a wall of
   checkboxes. It ADDS; the chosen books come back as ordinary `.ws-dictbox`
   rows above it, which is why `static/app.js` still builds no markup here. */
.ws-dict-add-row { display: flex; align-items: baseline; gap: .4rem;
                   margin: .3rem 0 .1rem; }
.ws-dict-add-label { flex: 0 0 auto; font-size: .82rem; opacity: .75; }
/* `min-width: 0` because a flex item's automatic minimum size is its content,
   and the longest title in this library is 242 characters - without it the
   select pushes the settings panel wider than the screen. */
.ws-dict-add { flex: 1 1 auto; min-width: 0; font: inherit; font-size: .85rem; }

/* ------------------------------------------------------------------ /about
 *
 * Jon, 2026-08-28: *"even an about screen that has a version number things
 * like that."* The page is a standalone `base.html` document, so it inherits
 * that file's measure and typography and needs only what is genuinely its own.
 *
 * NO `display` ON ANYTHING TOGGLED WITH `hidden` (L-024): `#ab-verdict` and
 * `#ab-controls` are both server-rendered `hidden` and un-hidden by the page's
 * own script, and an author `display` here would beat the user agent's
 * `[hidden] { display: none }` regardless of specificity and paint them both
 * on a page with scripting off. This project has lost a control to that
 * twice. */
.about-list { margin: 0 0 1.4rem; }
.about-list dt { font-weight: 600; margin-top: .7rem; }
.about-list dd { margin: .15rem 0 0; display: flex; flex-wrap: wrap;
                 gap: .5rem; align-items: baseline; }
.about-verdict { border-left: 3px solid var(--line-ctl); padding: .5rem .8rem;
                 margin: 1rem 0 1.4rem; background: var(--panel); }
.about-verdict-warn { border-left-color: var(--bad); }
.about-verdict-ok { border-left-color: var(--line-ctl); }
.ab-btn { font: inherit; padding: .35rem .9rem; cursor: pointer;
          border: 1px solid var(--line-ctl); background: var(--panel);
          color: var(--ink); border-radius: 3px; }
.ab-btn:hover { border-color: var(--ink-sub); }
.ab-btn-warn { border-color: var(--bad); color: var(--bad); }

/* ------------------------------- "SAVED ON THIS DEVICE", `/offline`, 2026-08-30
 *
 * Jon widened `offline-after-an-update` in his own words: *"users should also
 * be ablt to see what books (or parts / percentages of books) are cached."*
 *
 * A standalone page under `base.html`, so it reuses that vocabulary
 * (`dx-lede`, `crumb`) and adds only what is genuinely new: a table whose
 * numeric columns line up and whose rows the client un-hides.
 *
 * **Theme tokens throughout, never a literal.** Jon reads on a phone in DARK
 * mode, and a light-mode probe passes on a dark-mode defect. */
.off-summary { font-size: 1.05rem; margin: 0 0 1rem; }
.off-none { color: var(--dim); }
.off-warn {
  border-left: 3px solid var(--bad);
  padding: .5rem .8rem; margin: 0 0 1rem;
  background: var(--panel); color: var(--ink);
}
.off-table {
  border-collapse: collapse; width: 100%; max-width: 44rem;
  margin: 0 0 1.2rem;
}
.off-table th, .off-table td {
  text-align: left; padding: .3rem .6rem;
  border-bottom: 1px solid var(--line);
}
/* The two count columns. `text-align: right` alone would leave them ragged
 * against a proportional font, which is what makes a column of numbers hard
 * to compare at a glance. */
.off-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.off-table th { color: var(--dim); font-weight: 600; font-size: .85rem; }
.off-title a { color: var(--ink); text-decoration: none; }
.off-title a:hover { color: var(--accent); text-decoration: underline; }
/* `[hidden]` is a USER-AGENT rule and ANY author `display` beats it whatever
 * the specificity (L-024) - which has now bitten this repo three times, most
 * recently a Settings row that reported itself hidden and painted at 71px.
 * The table and its rows are both rendered hidden and un-hidden by the page's
 * own script, so both need this. */
.off-table[hidden] { display: none; }
.off-table tbody tr[hidden] { display: none; }
.off-foot { font-size: .85rem; }

/* --- Typed in English letters: which Hebrew or Greek word did you mean -----
   `hebrew:shalom` answers with WORDS TO PICK FROM rather than with results,
   so it is a list of choices and not a result list. Every colour is a token,
   so dark mode is right by construction rather than by a probe - Jon reads on
   a phone, in the dark, and a light-mode check passes on a dark-mode defect. */
.translit {
  margin: 0.9rem 0 1.2rem;
}
.translit-list {
  list-style: none;
  margin: 0.6rem 0 0;
  padding: 0;
  display: grid;
  /* `max-content` twice, so the word and its transliteration line up down the
     list and the book title takes whatever is left. Guessing rem widths here
     breaks on the first long headword: the longest across the four lexicons is
     23 characters (`בְּאֵרֹת בְּנֵי־יַעֲקָן`, LLS_FBHOTLEX), measured - an
     earlier draft of this comment said 80, which is `translit._MAX_HEADWORD`,
     a regex bound rather than anything in the data. */
  grid-template-columns: max-content max-content 1fr;
  gap: 0.35rem 1rem;
  align-items: baseline;
}
.translit-item {
  display: contents;
}
.translit-word {
  /* The pick, and the largest thing in the row: it is what the reader came
     for and what they will tap on a phone. Sized in em of the reading scale
     rather than px so it follows the font control. */
  font-size: 1.35em;
  line-height: 1.5;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid var(--line);
}
.translit-word:hover,
.translit-word:focus { color: var(--accent, var(--ink)); }
.translit-latin {
  color: var(--ink-sub);
  font-style: italic;
  white-space: nowrap;
}
.translit-books {
  color: var(--dim);
  font-size: 0.9em;
}
.translit-books a { color: inherit; }
/* The loose tier is a GUESS and must not read like the exact one (L-002). */
.translit-loose { font-style: italic; }
.translit-homograph {
  /* The lexicon lists several words spelled the same; the number is the
     lexicon's own way of telling them apart, and it must not read as part of
     the word. */
  color: var(--dim);
  font-size: 0.8em;
  margin-left: 0.25rem;
}
.translit-scope { font-style: italic; }

/* ======================================================== THE LIBRARY TAB ===
 *
 * `templates/_library.html` — the whole library as one table with a column and
 * a box per field. Jon, on `s-cb6ad3cd4a840481`: *"different filters of
 * different columns"*.
 *
 * **Every selector here is prefixed `lib-`, `lc-` or `lh-`, and none of those
 * is a class `render.py` emits** (L-009 — `verify_shell` asserts the property
 * rather than trusting this sentence). Bare element selectors are scoped under
 * `.lib-table`, which holds no renderer output: it is metadata about books,
 * never a book's own markup. */

/* The page measure, `dx-page`'s reason: `base.html` gives a standalone page
 * 44rem because that is a column of prose, and this is six columns of table. */
body.lib-page main { max-width: 76rem; }

.lib { max-width: 74rem; }

.lib-find { margin: 0 0 .6rem; }
.lib-q {
  width: 100%;
  box-sizing: border-box;
  padding: .35rem .5rem;
  font: inherit;
  color: var(--ink);
  background: var(--pane);
  border: 1px solid var(--line-ctl);
  border-radius: 3px;
}
.lib-adv { margin-top: .45rem; }
.lib-adv > summary {
  cursor: pointer;
  color: var(--ink-label);
  font-size: .88rem;
}
/* A grid rather than a row, so five boxes wrap instead of running off the side
 * — Jon's standing rule is that anything off the side scrolls, and a control
 * strip that scrolls sideways is one he cannot see the end of. */
.lib-adv-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: .4rem;
  margin: .45rem 0;
}
.lib-fbox { display: flex; flex-direction: column; gap: .15rem; }
.lib-flabel { font-size: .78rem; color: var(--ink-label); }
.lib-f {
  width: 100%;
  box-sizing: border-box;
  padding: .3rem .4rem;
  font: inherit;
  font-size: .9rem;
  color: var(--ink);
  background: var(--pane);
  border: 1px solid var(--line-ctl);
  border-radius: 3px;
}
.lib-adv-note { margin: .2rem 0 0; font-size: .8rem; color: var(--dim); }

.lib-count { margin: .4rem 0; color: var(--dim); }
.lib-count strong { color: var(--ink); }

/* **THE `[hidden]` RULE, STATED BESIDE THE `display` THAT WOULD BEAT IT**
 * (L-024). `[hidden] { display: none }` is a USER-AGENT rule and ANY author
 * `display` beats it regardless of specificity — which is exactly how
 * `.shelf-add-row` came to paint *"add these 0 to a shelf"* while reporting
 * itself hidden. Four things in this file are toggled by `hidden` — the two
 * sentences below, the empty-result line, and every `<tr>` — so each one that
 * carries an author `display` says so and restores the rule explicitly.
 *
 * The two paragraphs get no author `display` at all, so the user-agent rule
 * holds for them and nothing more is needed; they are listed here so that the
 * next author adding a `display` to one has already been told. */
.lib-aside, .lib-none, .lib-plain { margin: .4rem 0; }
.lib-none { color: var(--dim); font-style: italic; }

/* **WHERE HIS BOOKS CAME FROM** — Jon, 2026-09-14. Two lists, and the
 * separation is the contract's rather than a layout choice: a book's `source`
 * is which SYSTEM it came from and an account is an installed plug-in, and the
 * kit forbids joining them. See `sources.py`.
 *
 * **NO AUTHOR `display` ON ANYTHING TOGGLED BY `hidden` HERE** — nothing in
 * this block is hidden by attribute, and `app.css`'s global
 * `[hidden] { display: none !important }` already covers anything that grows
 * one later (L-470). A `.thing[hidden]` companion re-grows the keep-list that
 * rule exists to retire. */
.lib-sources { margin: .6rem 0 .8rem; }
.lib-src-h {
  font-size: .82rem; font-weight: 600; color: var(--dim);
  margin: .6rem 0 .3rem; text-transform: none; letter-spacing: 0;
}
.lib-src-list, .lib-acct-list, .lib-acct-bad {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: .35rem;
}
.lib-acct-bad { display: block; }
.lib-src, .lib-acct {
  /* A card per source. `flex-wrap` above rather than a grid: the number of
   * sources is whatever Jon's works say it is, so nothing here may assume a
   * column count. */
  border: 1px solid var(--line); border-radius: 3px; background: var(--pane);
  padding: .35rem .5rem; min-width: 11rem;
  display: flex; flex-direction: column; gap: .1rem;
}
.lib-src-name, .lib-acct-name {
  font-size: .82rem; font-weight: 600; color: var(--ink);
}
.lib-src-n { font-size: .78rem; color: var(--dim); }
.lib-src-n strong { color: var(--ink); }
.lib-src-split, .lib-acct-state { font-size: .72rem; color: var(--dim); }
.lib-acct-on { color: var(--accent); }
.lib-acct-none { font-size: .78rem; color: var(--dim); margin: .2rem 0; }
/* The blindspot sentences inside a card sit flush rather than indenting the
 * card's own text — same rule, narrower margin. */
.lib-acct .blindspot { margin: .25rem 0 0; }
.lib-refresh { margin: .3rem 0 0; }
.lib-refresh button { font-size: .75rem; padding: .18rem .45rem; }
/* The search box on a source's card (`_sources_search_box.html`): one row,
 * the field taking what the card has left, at the refresh button's scale. */
.lib-search { display: flex; gap: .3rem; margin: .3rem 0 0; max-width: 32rem; }
.lib-search input[type="search"] { flex: 1 1 auto; min-width: 0;
  font-size: .8rem; padding: .18rem .35rem; }
.lib-search button { font-size: .75rem; padding: .18rem .45rem; }
/* The answer lands here, inside the card that asked. **No `:has()`** - this
 * sheet's own rule, stated at `.lib-page`: a layout must not rest on selector
 * support in whatever browser the travel laptop is running. The result is
 * allowed to be as wide as the card and scrolls inside itself instead. */
.lib-acct-out { margin: .35rem 0 0; max-width: 32rem; }
/* Jon's 246: a folder per account, closed until opened. The list scrolls inside
 * the folder rather than lengthening the library page. */
.lib-acct-folder { margin: .3rem 0 0; max-width: 32rem; }
.lib-acct-folder > summary { font-size: .78rem; cursor: pointer; }
.lib-acct-folder .srcres-list { max-height: 24rem; overflow: auto; }
/* GET EVERY NEW BOOK (`s-kindlegetall`): the count on the button, the age and
 * the progress under it at the folder's scale. Its lists scroll inside the
 * card rather than lengthening the library page, the folder's rule. */
.lib-every { margin: .3rem 0 0; max-width: 32rem; }
.lib-every-go { margin: 0; }
.lib-every-btn { font-size: .75rem; padding: .18rem .45rem; }
.lib-every-said, .lib-every-going, .lib-every-h { font-size: .78rem; margin: .25rem 0 0; }
.lib-every-age { font-size: .72rem; margin: .2rem 0 0; }
.lib-every-list { list-style: none; margin: .2rem 0 0; padding: 0; max-height: 16rem;
                  overflow-y: auto; font-size: .76rem; }
.lib-every-list li { padding: .12rem 0; border-top: 1px solid var(--line); }
.lib-every-landed > summary, .lib-every-cannot-box > summary {
  font-size: .78rem; cursor: pointer; margin: .25rem 0 0; }
/* At a thumb's size on a phone - the card's other controls' 44px rule
 * (`s-sourcetaptargets`), in a block of its own so that rule's text, which a
 * suite's mutant anchors on, stays as it was. */
@media (pointer: coarse), (max-width: 600px) {
  .lib-every-btn, .lib-every-landed > summary, .lib-every-cannot-box > summary {
    min-height: 44px; }
}

/* What one Refresh found. Shared by the pane and by the plain page, which is
 * the same fragment - `_sources_result.html`. */
.srcres-h { font-size: .85rem; margin: 0 0 .25rem; }
.srcres-count { font-size: .8rem; margin: .25rem 0; color: var(--dim); }
.srcres-count strong { color: var(--ink); }
.srcres-none, .srcres-back { font-size: .8rem; margin: .3rem 0; }
/* WAS HIS ACCOUNT ASKED — the paragraph above the count.
 *
 * **EVERY ONE OF THESE IS A `.blindspot`, `asked` INCLUDED, AND THAT IS A
 * CORRECTION.** This comment first said `.srcres-ask-asked` was "deliberately
 * NOT a `.blindspot`" because it was "the one sentence backed by an act this
 * process performed and watched". It is not: a two-line script printing
 * `{"ok":true,"state":"unchanged"}` earns that state (measured 2026-09-15 by
 * a non-author review, reproduced here), and every fact in the paragraph is
 * read out of an envelope the plug-in wrote. `.blindspot` is this sheet's mark
 * for *here is something this app cannot see*, and what it cannot see is the
 * inside of somebody else's process — which is the whole point of that
 * boundary. So the mark belongs on all six.
 *
 * What still differs is the ANSWER, not the warrant, and that is carried by
 * the words rather than by contrast. `verify_sources` asserts all six are
 * marked. */
.srcres-ask { font-size: .8rem; margin: .3rem 0; }
.srcres-list { list-style: none; margin: .3rem 0; padding: 0; max-height: 22rem;
               overflow-y: auto; }
.srcres-row {
  display: flex; flex-wrap: wrap; gap: .3rem; align-items: baseline;
  padding: .2rem 0; border-top: 1px solid var(--line);
}
.srcres-title { font-size: .8rem; color: var(--ink); }
.srcres-state { font-size: .72rem; color: var(--dim); }
/* The row's press (decision 229): an inline form, so the button sits on the
 * row's line rather than opening a block under it. */
.srcres-get { display: inline; margin: 0 0 0 .4rem; }
.srcres-get-go, .srcres-open { font-size: .78rem; }
.srcres-tag {
  font-size: .66rem; text-transform: uppercase; letter-spacing: .04em;
  border: 1px solid var(--accent); color: var(--accent);
  border-radius: 2px; padding: 0 .2rem;
}
.srcres-note { flex-basis: 100%; }
/* A full-text hit's words, on their own line under the row's title. */
.srcres-snip { flex-basis: 100%; font-size: .74rem; color: var(--dim); }
/* THE SOURCE CARD'S CONTROLS AT A THUMB'S SIZE on a phone or any touch screen -
   44px, A10's rule and cut 1's ruling (s-sourcetaptargets). The search-box judge
   (C13, 2026-10-02) measured them at 400px wide: the search input and Search
   24.75px, Get this book 14px, Look for new books 23.75px. Jon reads on his
   phone. Desktop keeps the compact card. `open it` is an inline link, so it
   needs a box before a height can apply. */
@media (pointer: coarse), (max-width: 600px) {
  .lib-refresh button, .lib-search input[type="search"], .lib-search button,
  .srcres-get-go, .srcres-open { min-height: 44px; }
  .srcres-open { display: inline-flex; align-items: center; }
}

.lib-scroll {
  /* Anything off the side scrolls, and it scrolls INSIDE its own box — a
   * document that pans takes the tab strip and the panels with it. */
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: 3px;
  background: var(--pane);
}
.lib-table {
  border-collapse: collapse;
  width: 100%;
  font-size: .88rem;
}
.lib-table th,
.lib-table td {
  text-align: left;
  vertical-align: top;
  padding: .3rem .5rem;
  border-bottom: 1px solid var(--line);
}
.lib-table th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--panel);
  color: var(--ink-label);
  font-weight: 600;
  white-space: nowrap;
}
/* `display: table-row` restored explicitly, so `hidden` on a row keeps working
 * if anything above ever gives `tr` an author display. This IS the author
 * display for these rows, which is why the `[hidden]` rule is repeated here
 * rather than left to the user agent (L-024). */
.lib-table tbody tr { display: table-row; }
.lib-table tbody tr[hidden] { display: none; }
.lib-table tbody tr:hover { background: var(--accent-soft); }
.lc-title a { color: var(--ink); text-decoration: none; }
.lc-title a:hover,
.lc-title a:focus { color: var(--accent); text-decoration: underline; }
/* **THE *Edit* LINK ON EVERY ROW — decision 200.** Quiet until the row is
 * under the pointer, because it is on 3,247 rows and the reader is here to
 * read titles: `opacity` rather than `display`, so it keeps its box and the
 * titles beside it do not reflow as the pointer moves down the table.
 *
 * **NEVER INVISIBLE TO A KEYBOARD OR A TOUCH.** `:focus-within` on the row and
 * `:focus` on the link both bring it back, and the whole rule is inside a
 * `hover: hover` query — a phone reports no hover, so there the link is simply
 * always there. A control that is drawn and cannot be reached is worse than
 * one that is absent (the plain page's own rule, stated in `_library.html`). */
.lc-edit {
  margin-left: .5rem;
  font-size: .85em;
  white-space: nowrap;
  color: var(--dim);
  text-decoration: none;
}
.lc-edit:hover,
.lc-edit:focus { color: var(--accent); text-decoration: underline; }
@media (hover: hover) {
  .lc-edit { opacity: 0; transition: opacity .12s ease; }
  .lib-table tbody tr:hover .lc-edit,
  .lib-table tbody tr:focus-within .lc-edit,
  .lc-edit:focus { opacity: 1; }
}
.lc-authors, .lc-publishers { color: var(--ink-sub); }
.lc-date { color: var(--dim); white-space: nowrap; }
.lc-subjects { color: var(--dim); font-size: .92em; }
.lc-where { color: var(--dim); font-size: .92em; white-space: nowrap; }
/* The unopened half is dimmer than the books that are here, because "not
 * opened yet" is a real difference the reader is entitled to see at a glance —
 * it is not hidden and it is not styled as a fault (L-014: absent, unreadable
 * and empty are three sentences, and this is none of them; it is *not here
 * yet*). */
.lib-there .lc-title { color: var(--ink-sub); }

@media (max-width: 44rem) {
  /* Subject is the widest column and the least useful on a phone; the box that
   * searches it is still there, so nothing becomes unreachable — only
   * undrawn. */
  .lh-subjects, .lc-subjects { display: none; }
}

/* The door from the panel into the Library tab — "the tools a guy has readily
 * available ... and theres like a second step he has to take (search) to find
 * anything else" (Jon, `facet-reach-where` -> `panel-now`). Panel type, so it
 * does not compete with the find box directly under it. */
.lib-door {
  margin: 0 0 .4rem;
  font-size: .72rem;
  color: var(--dim);
  line-height: 1.35;
}
.lib-door a { color: var(--accent); font-weight: 600; }

/* ADD A BOOK - `s-addabook`, `_addabook.html`. The door is the LABEL: a real
 * file input sits inside it, so a click or a tap on the words opens the
 * device's picker with no script. The input is out of sight but not out of the
 * document - `display: none` would take it out of the label's activation on
 * some phone browsers, and the label is the whole control. At least 44px tall
 * where a thumb presses it, the source card's rule. */
.addbook { margin: .2rem 0 .6rem; }
.addbook-pick { display: inline-flex; align-items: center; min-height: 2.75rem;
  padding: 0 .8rem; border: 1px solid var(--accent); border-radius: .4rem;
  color: var(--accent); font-weight: 600; cursor: pointer; position: relative; }
.addbook-pick:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.addbook-file { position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; cursor: pointer; }
.addbook[data-busy="1"] .addbook-pick { opacity: .6; cursor: progress; }
.addbook-kinds { font-size: .85rem; opacity: .75; margin-left: .4rem; }
/* The workspace answers the choice by itself (`static/app.js`, `addBook`), so
 * *Add it* is for the plain page only - drawn there, where nothing else would
 * send the file. */
body.shell .addbook-go { display: none; }
.addbook-out:empty { display: none; }
.addbook-said p { margin: .4rem 0; }
.addbook-bar { width: 8rem; vertical-align: middle; margin-left: .4rem; }

/* Which facet rows the panel draws — the switches live in the Library tab.
 * See `applyPanelChoice` in `static/app.js`. */
.lib-panelprefs { margin-top: .5rem; }
.lib-panelprefs > summary {
  cursor: pointer;
  color: var(--ink-label);
  font-size: .88rem;
}
.lib-pp-note { margin: .35rem 0; font-size: .8rem; color: var(--dim); }
.lib-pp-grid {
  display: flex;
  flex-wrap: wrap;
  gap: .3rem .9rem;
  margin: .35rem 0 .2rem;
}
.lib-pp-box { display: flex; align-items: center; gap: .3rem; font-size: .9rem; }

/* **`[hidden]` ON A FACET ROW, STATED BECAUSE THE RULE IS EASY TO LOSE**
 * (L-024). `[hidden] { display: none }` is a USER-AGENT rule and ANY author
 * `display` beats it regardless of specificity — `.shelf-add-row` is
 * `display: flex` and painted *"add these 0 to a shelf"* while reporting
 * itself hidden. `.facet` carries no author `display` today, so the user-agent
 * rule holds; this line is here so that the next author who gives it one has
 * already been told, and so that hiding a facet cannot quietly stop working.
 * The switches are in the Library tab; `applyPanelChoice` is what sets the
 * attribute. */
.facet[hidden] { display: none; }

/* ------------------------------------------------------------ the composer
   Jon, 2026-09-12: *"it would be nice if i clicked the new tab, it showed
   some options there, to open an editor perhaps, or something."*

   **NO CLASS HERE MAY COLLIDE WITH ONE `render.py` EMITS** (L-009) -
   `verify_shell` asserts that, and every name below is prefixed `composer-`
   or `empty-`, neither of which the renderer uses. */
.empty-options { list-style: none; padding: 0; margin: 1.5rem 0 0; }
.empty-options li { margin: 0 0 1rem; }
.empty-options a { font-weight: 600; }
.empty-options span { display: block; opacity: 0.75; font-size: 0.9em; }

.composer { display: flex; flex-direction: column; height: 100%; }
.composer-head { display: flex; align-items: center; gap: 0.5rem;
                 margin-bottom: 0.5rem; }
/* **THE TITLE READS AS A FIELD BEFORE HE TOUCHES IT.** It used to carry a
   TRANSPARENT bottom border and a transparent background, with the underline
   appearing only on `:focus` - i.e. only once he had already committed to
   typing in it. So it painted as a slightly larger line of text above more
   text, with nothing marking it as a field and nothing marking where it ends.
   Jon found exactly that: *"There's nothing that shows that what I first typed
   was indeed the title."*

   The wrapper takes the `flex: 1 1 auto` the input used to carry, and is
   itself a flex row so the input still fills the space beside the word. */
.composer-title-label { flex: 1 1 auto; display: flex; align-items: baseline;
                        gap: 0.4rem; min-width: 0; font-size: 0.78rem;
                        color: var(--dim); }
.composer-title { flex: 1 1 auto; min-width: 0; font-size: 1.2em;
                  font-weight: 600; border: 0;
                  border-bottom: 1px solid var(--line);
                  background: transparent; color: var(--ink);
                  padding: 0.2rem 0; }
/* Focus still has to be distinguishable from merely being a field, so it keeps
   the stronger `currentColor` it always had. */
.composer-title:focus { outline: none; border-bottom-color: currentColor; }
.composer-saved { flex: 0 0 auto; font-size: 0.85em; opacity: 0.7; }
/* **THE WRITING MENU. Jon, 2026-09-13:** *"there is no menu bar or anything."*
   The row is server-rendered EMPTY and `fillComposerMenu` puts one control in
   it per command the editor actually publishes.

   **`:empty` IS THE WHOLE OF ITS HIDING RULE, and it is a PROPERTY rather than
   a flag** (L-024). Scripting off, a mount that failed, an older pinned
   `extensions.js` with no `buildCommands`, or simply an editor with nothing to
   offer all leave the element childless - and all four want the same answer:
   no row, no border, no gap above the text. A `hidden` attribute set by script
   would need every one of those paths to remember to set it, and the one that
   forgot would leave an empty grey strip over his page with no way to know
   why. Nothing here needs to be told; the row is its own condition.

   No `display` is declared for the filled case: the rule below only applies
   once there are children, so the flex box is exactly what a populated row
   gets. */
/* **IN THE TOOL BAND SINCE 2026-09-19 (`s-42246cca75dae1c1`), AND THE `wrap`
 * THAT USED TO BE HERE WOULD HAVE MADE CONTROLS SILENTLY CEASE TO EXIST.**
 * `.ws-tabtool` is a fixed `--ws-tabtool-h` with `overflow-y: hidden`, so a
 * second flex line paints NOTHING while leaving every node in the DOM - which
 * means a `querySelectorAll` census passes on exactly that failure. Measured
 * before the move: this strip was 74.27px at 1400px and **113.45px at 390px**,
 * about three wrapped rows on the phone Jon reads from, against a 32px band.
 *
 * `nowrap` is therefore not tidying, it is the whole of what makes the strip
 * survivable in this row - and the row already scrolls sideways
 * (`overflow-x: auto`, with `flex: 0 0 auto` on its children so nothing
 * shrinks into a sliver first), which is Jon's *"it should scroll sideways when
 * needed"* answered by construction rather than by new code.
 *
 * The vertical margin, the padding and the rule under it all go: they were the
 * strip's own separation from the text when it sat in the pane, and the band
 * draws its own `border-bottom`. Keeping them would spend the band's fixed
 * height on space (R3 - it must not grow with its contents). */
.composer-menu { display: flex; flex-wrap: nowrap; align-items: center;
                 gap: 0.25rem; }
.composer-menu:empty { display: none; }
/* **ONLY THE SIZE IS RESTATED, AND ONLY INSIDE THE BAND** - `.ws-tabtool-pen`'s
 * rule and its reason, one control over: every colour, face and state comes
 * from the `.composer-menu-*` classes above, so there is ONE description of
 * what a formatting button looks like and the pane and the band cannot drift
 * (L-017).
 *
 * **MEASURED, NOT CHOSEN.** The buttons are 35.2x35.2 and the style `<select>`
 * 92.8x35.2 (2026-09-19, 1400px); the band is a fixed 2rem = 32px with
 * `overflow-y: hidden`, so at their own size they are clipped top and bottom.
 * This is the composer's 2.1rem swatch meeting the same 2rem row, and it gets
 * the same answer: the control adapts to the band, never the band to the
 * control (R3).
 *
 * **`min-height` AS WELL AS `height` ON THE SELECT** - a `<select>` takes a
 * UA-supplied intrinsic height that a plain `height` does not always win
 * against, which is `.ws-tabtool-pen`'s `min-width` lesson in the other axis. */
/* **`min-height` IS THE ONE THAT MATTERS AND IT IS A DELIBERATE DECISION BEING
 * OVERRIDDEN, NOT AN ACCIDENT OF THE CASCADE.** `.composer-menu-btn` carries
 * `min-height: 2.2rem` with the reason written beside it: *"Jon reads on a
 * phone as well as the laptop, and this row is pressed with a thumb there.
 * 2.2rem is the same floor the tab-row controls take."* A first pass here set
 * `height: 1.6rem` and measured **25.6 x 35.2** - the width took and the height
 * did not, because `min-height` beats `height`. That is the floor working.
 *
 * **2.2rem IS 35.2px AND THE BAND IS 2rem. THE TOUCH TARGET DOES NOT FIT THE
 * ROW, AND NO CSS RESOLVES THAT - IT IS A TRADE.** Shrinking spends Jon's thumb
 * floor on the phone he reads from; growing the band spends the OTHER half's
 * reading space, because `--ws-tabtool-h` is one length both panes take as an
 * inset (L-079). It is shrunk here, and the trade went to HIM with both numbers
 * rather than being settled quietly in a stylesheet.
 *
 * **`.ws-tabtool-pen` IS THE PRECEDENT AND IT WENT THE SAME WAY** - the
 * composer's 2.1rem swatch sits at 1.45rem in this band. A chrome row already
 * holds smaller controls than the pane does; that is what a chrome row is. */
/* **THIS SHRINK IS A RULING OF JON'S, NOT AN OVERSIGHT - DO NOT REVERT IT
 * WITHOUT ASKING HIM AGAIN.** `formatting-row-keep-both`, 2026-09-19: he was
 * shown that the band is 2rem, that `.composer-menu-btn` carries
 * `min-height: 2.2rem` with a comment saying 35px was deliberate because he
 * presses these with a thumb on the phone, and that fitting the strip into the
 * row therefore costs him that thumb target. He was offered a bigger button
 * (option B) and a taller row on the phone only (option D). **He chose to keep
 * it as built.**
 *
 * **THE REASON THIS COMMENT EXISTS IS THAT THE CODE CANNOT SHOW YOU A TRADE.**
 * You arrive here, see a thumb target shrunk to 1.7rem, and `app.css:737` four
 * hundred lines up tells you a button filling its row exactly is what keeps it
 * a thumb target on a phone. Everything visible says this is a bug. The
 * evidence that it is not lives in a decision row, and a handoff naming it
 * gets swept - so the slug is written HERE, where the next reader lands. */
.ws-tabtool .composer-menu-btn {
  min-height: 1.7rem; height: 1.7rem; min-width: 1.7rem; padding: 0 .3rem;
}
.ws-tabtool .composer-menu-btn[data-face="icon"] {
  min-width: 1.7rem; width: 1.7rem; padding: 0;
}
.ws-tabtool .composer-menu-style {
  height: 1.7rem; min-height: 1.7rem; padding: 0 .2rem; max-width: 8rem;
}
/* The two review checkboxes and their labels ride the same row; their labels
 * are prose and must not wrap for the row's own reason. */
.ws-tabtool .composer-menu-switch { white-space: nowrap; }
.composer-menu-btn { font: inherit; font-size: 0.85em; line-height: 1.2;
                     padding: 0.25rem 0.55rem; border: 1px solid var(--line-ctl);
                     border-radius: 0.25rem; background: transparent;
                     color: inherit; cursor: pointer;
                     /* Jon reads on a phone as well as the laptop, and this row
                        is pressed with a thumb there. 2.2rem is the same floor
                        the tab-row controls take. */
                     min-height: 2.2rem; }
/* **THE ICON FACE.** Jon: *"Why can't it look like those buttons on
   LibreOffice?"* A square-ish button with one glyph, so the row reads as a
   toolbar rather than as a sentence of words. `min-width` matches the touch
   floor the label buttons already carry - he presses this with a thumb on the
   phone - and a button with NO icon entry keeps its words and its natural
   width, which is what keeps the row derived rather than curated. */
.composer-menu-btn[data-face="icon"] { min-width: 2.2rem; padding: 0.25rem;
                                       justify-content: center; }
.composer-menu-glyph { display: inline-block; line-height: 1;
                       font-size: 1.05em; }
/* **THE PARAGRAPH-STYLE DROPDOWN.** LibreOffice gives paragraph style one box
   showing the style you are standing in, not seven buttons, and Jon asked for
   LibreOffice's faces. It wears the same border, radius and touch floor as the
   buttons beside it so the row still reads as one toolbar.

   **`font: inherit` and an explicit `color`/`background`, because a bare
   `<select>` is painted by the PLATFORM.** Left alone it arrives in the user
   agent's own font at the user agent's own size, light-on-light in this app's
   dark theme - the one control on the row that ignores the palette. It is the
   same reason every other control here states both halves.

   No fixed width: the box is as wide as "Heading 1" needs, and `max-width`
   keeps a longer option from pushing the lists off the row on a phone. */
.composer-menu-style { font: inherit; font-size: 0.85em; line-height: 1.2;
                       padding: 0.25rem 0.4rem;
                       border: 1px solid var(--line-ctl);
                       border-radius: 0.25rem;
                       background: transparent; color: inherit;
                       cursor: pointer; min-height: 2.2rem;
                       max-width: 10rem; }
.composer-menu-style:hover { background: var(--panel); }
.composer-menu-style:focus-visible { outline: 2px solid var(--accent);
                                     outline-offset: 1px; }
/* The dropped-open list is painted by the platform, which does not inherit the
   button's transparent background - so the options need the panel's own
   colours or they render on whatever the UA picks. */
.composer-menu-style option { background: var(--panel); color: var(--ink); }

/* ------------------------------------------------ SEEING A TRACKED CHANGE
   `s-db6c152240d30627`. Jon, 2026-09-13: *"certainly not a way to view
   comments or tracked changes."*

   **THE ENGINE WAS ALREADY EMITTING ALL OF THIS AND NOTHING STYLED IT.**
   `schema-track.js` writes `span.insertion`, `span.deletion`,
   `span.approved-insertion` and `span.format-change`, each with the user, the
   name and the date on it - and this stylesheet matched NONE of them, so every
   tracked change rendered as ordinary text. The record was kept and shown to
   nobody.

   **TWO AUTHOR COLOURS, AND A FALLBACK THAT IS NOT ONE OF THEM.** The app has
   exactly two authors it can have - Jon and the assistant - and the engine
   writes `user-<n>`. A third would otherwise inherit whatever the last rule
   said, so the base rule carries its own colour and the numbered ones override
   it; a colour nobody assigned is then visibly not one of the two rather than
   silently impersonating one (L-014).

   **NEVER COLOUR ALONE.** An underline for an insertion and a strike for a
   deletion carry the meaning without it, because a reader who cannot separate
   these two hues would otherwise see one undifferentiated mess - and because
   which colour means whom is a thing you have to be told, while a strikethrough
   is not. */
.composer-surface .ProseMirror .insertion,
.composer-surface .ProseMirror .deletion,
.composer-surface .ProseMirror .format-change {
  color: var(--track-other); cursor: pointer; }
.composer-surface .ProseMirror .insertion { text-decoration: underline;
                                            text-underline-offset: 0.15em; }
/* **THE TEXT IS STILL THERE, which is the whole idea of a tracked deletion.**
   A pending deletion has not happened yet - rejecting it is what keeps the
   words and accepting it is what removes them - so it must read as *proposed
   for removal*, never as gone. */
.composer-surface .ProseMirror .deletion { text-decoration: line-through; }
.composer-surface .ProseMirror .format-change {
  border-bottom: 1px dotted currentColor; }
/* **AN APPROVED INSERTION IS ORDINARY TEXT.** It was settled; the document
   simply says this now. Stated as a rule rather than left to the absence of
   one, because the class is still in the markup and the next person to write a
   `[class*="insertion"]` selector would catch it. */
.composer-surface .ProseMirror .approved-insertion { text-decoration: none;
                                                     color: inherit; }
/* **THE AUTHOR'S OWN UNDERLINE, AND IT HAD NO RULE AT ALL UNTIL 2026-09-14.**
   Jon: *"and, underline still doesnt work"*. It was not the button and not the
   served bundle - both were fine. `schema-track.js:694` renders the mark as
   `<span class="underline">`, which has NO user-agent default, so pressing U
   put the mark in the document and the screen did not move. **`<strong>` and
   `<em>` hid this for the other two**: they paint off the UA sheet, so bold
   and italic looked built while underline looked broken, and all three were
   the same amount of built.

   **MEASURED, not read off the source** (L-466): driven in Chromium on
   `48d7257+dirty`, a real press of U produced
   `<span class="underline">underline me</span>` with
   `getComputedStyle(...).textDecorationLine === "none"`, and a census of every
   served stylesheet returned ZERO selectors matching `.underline`.

   **SCOPED TO `.composer`, NOT GLOBAL.** `render.py` spells the corpus's own
   underline as an inline `text-decoration:underline` (its `FORMAT_FLAG_CSS`),
   never as a class, so there is no collision today - and a bare `.underline`
   rule would be a shell class claiming a word the renderer might want
   tomorrow, which is exactly L-009's shape.

   **IT DOES NOT BORROW THE INSERTION'S OFFSET.** A tracked insertion is
   underlined too, three rules up, and it is `--track-other` coloured - so the
   two are told apart by colour and by the review bar, as they are in every
   editor that has both. An author underline is `currentColor` by saying
   nothing, which is what makes it ordinary text with a line under it. */
.composer .underline { text-decoration: underline; }
.composer-surface .ProseMirror .user-0,
.composer-surface .ProseMirror .user-1 { color: var(--track-mine); }
.composer-surface .ProseMirror .user-2 { color: var(--track-other); }

/* **"Show changes" OFF: the marks go quiet, the words do not move.** Only the
   decoration is dropped - no `display`, no `content`, nothing that reflows -
   so turning it off and on again cannot shift a line under his cursor while he
   is reading. The marks are still in the document and still counted; he asked
   to read it clean, not to throw anything away. */
.composer-surface[data-marks="off"] .ProseMirror .insertion,
.composer-surface[data-marks="off"] .ProseMirror .deletion,
.composer-surface[data-marks="off"] .ProseMirror .format-change {
  color: inherit; text-decoration: none; border-bottom: 0; cursor: auto; }

/* ---------------------------------------------- A COMMENT YOU CAN SEE - v30
   `s-6802522e92e2d1f3`. Jon, on his first use of the composer: *"I still don't
   see any comments though."*

   **THE MARK WAS ALWAYS THERE AND HAD NO RULE.** `schema-track.js`'s comment
   mark renders `span.comment[data-id]`, and this sheet had NO `.comment`
   selector at all - so a commented span was byte-for-byte ordinary text on the
   screen. That is the whole of his complaint: not a missing feature, a feature
   with no appearance.

   **`--accent-soft` RATHER THAN A NEW TOKEN, AND THAT IS A CHOICE.** A comment
   IS the app marking something for attention, which is what that token is for
   - unlike a tracked change, which is somebody's EDIT and is why
   `--track-mine`/`--track-other` exist and why the block above says an edit
   painted in the accent would read wrongly. It also keeps the three theme
   blocks' token sets untouched, which `verify_shell` asserts from the sheet
   itself.

   **THE UNDERLINE CARRIES THE MEANING, NOT THE TINT** - the same rule the
   tracked-change block states: a background alone is invisible to anyone who
   cannot see that hue, and `currentColor` is correct in both themes with no
   second palette. */
.composer-surface .ProseMirror .comment {
  background: var(--accent-soft);
  border-bottom: 1px dashed currentColor;
  cursor: pointer; }

/* **"Show comments" OFF: exactly `data-marks="off"`'s rule, for its reason.**
   Only the decoration goes - no `display`, no `content`, nothing that reflows
   - so turning it off and on again cannot shift a line under his cursor while
   he is reading. The marks and their bodies are untouched; he asked to read it
   clean, not to throw anything away. */
.composer-surface[data-comments="off"] .ProseMirror .comment {
  background: transparent; border-bottom: 0; cursor: auto; }

/* The bar itself. Same border, radius and touch floor as the writing row, so
   the two read as one piece of chrome rather than as two toolbars. */
.composer-review { display: flex; flex-wrap: wrap; align-items: center;
                   gap: 0.5rem; margin-bottom: 0.5rem; padding-bottom: 0.4rem;
                   border-bottom: 1px solid var(--line);
                   font-size: 0.85em; }
.composer-review-count { font-weight: 600; }
.composer-review-show { display: inline-flex; align-items: center;
                        gap: 0.3rem; cursor: pointer; }
.composer-review-btn { font: inherit; font-size: 1em; line-height: 1.2;
                       padding: 0.2rem 0.55rem;
                       border: 1px solid var(--line-ctl);
                       border-radius: 0.25rem; background: transparent;
                       color: inherit; cursor: pointer; min-height: 2.2rem; }
.composer-review-btn:hover:not(:disabled) { background: var(--panel); }
.composer-review-btn:focus-visible { outline: 2px solid var(--accent);
                                     outline-offset: 1px; }
.composer-review-btn:disabled { opacity: 0.45; cursor: default; }
/* The panel that opens on one change. In the flow rather than floating: a
   `position: fixed` box has to be placed from a measurement, and a measurement
   taken before the caret settles is how a panel ends up covering the very
   words it is describing. */
.composer-review-panel { display: flex; flex-wrap: wrap; align-items: center;
                         gap: 0.5rem; margin: 0.5rem 0;
                         padding: 0.5rem 0.6rem;
                         border: 1px solid var(--line-ctl);
                         border-radius: 0.3rem; background: var(--panel);
                         font-size: 0.85em; }
.composer-review-says { flex: 1 1 12rem; }

/* ------------------------------------- READING A COMMENT BACK - v30, part two
   `s-6802522e92e2d1f3`. Jon: *"I still don't see any comments though."* Part
   one gave the mark an appearance; this is the panel that opens on one.

   **THE SAME PIECE OF CHROME AS THE REVIEW PANEL, DELIBERATELY.** It answers
   the same shape of question about the same passage - *what does this mark
   mean and what do you want to do about it* - so it wears the same border,
   radius, ground and type size. Two boxes that behave alike and look different
   is the reader learning two things where there is one.

   **IN THE FLOW, NOT FLOATING**, which is the review panel's own rule and its
   measurement: a `position: fixed` box is placed from a measurement, and a
   measurement taken before the caret settles is how a panel ends up covering
   the very words it is describing.

   **NO `[hidden]` COMPANION HERE OR ON ANYTHING INSIDE IT** - `app.css`'s
   global `[hidden] { display: none !important }` already does it, and its own
   comment says anything new writing a `display` no longer needs one. A
   seventeenth companion is how the keep-list that rule retired grows back
   (L-470). */
.composer-comments-panel { display: flex; flex-wrap: wrap; align-items: center;
                           gap: 0.5rem; margin: 0.5rem 0;
                           padding: 0.5rem 0.6rem;
                           border: 1px solid var(--line-ctl);
                           border-radius: 0.3rem; background: var(--panel);
                           font-size: 0.85em; }
/* The byline takes the row to itself, because the box below it is the thing
   he came to read and a name sharing a line with a text box reads as its
   label. `100%` on a wrapping flex row is the one-per-line spelling. */
.composer-comments-says { flex: 1 1 100%; }
/* **`font: inherit`, or a textarea arrives in the browser's monospace default**
   - his words are prose and should look like the prose above them. The minimum
   height is the same touch floor the buttons carry. */
.composer-comments-body { flex: 1 1 100%; font: inherit; font-size: 1em;
                          line-height: 1.4; padding: 0.35rem 0.45rem;
                          border: 1px solid var(--line-ctl);
                          border-radius: 0.25rem;
                          background: var(--bg); color: inherit;
                          min-height: 3.4rem; resize: vertical; }
.composer-comments-body:focus-visible { outline: 2px solid var(--accent);
                                        outline-offset: 1px; }

/* ------------------------------ WHERE A DOCUMENT IS SAVED, AND THE WAY BACK
   `s-b5a048e62e0be4c6`. Jon: *"And now I wonder, when I close it, where will
   it be found again?"* The line sits under the title row and above the
   toolbar, because it answers a question he has before he starts typing
   rather than while he is writing - and it is quiet for the same reason: it
   should be findable when wondered about and invisible the rest of the time. */
/* **IT IS BEHIND A DISCLOSURE NOW - Jon, 2026-09-19.** *"there are LLM
   artifacts where it is talking to me on the tab, which is a dumb distraction,
   'saved in this machine, etc, last saved, etc.'"* His own remedy was that the
   material belongs behind File -> Info / Details, *"like any other normal
   editor/word processor"*.

   **THE SUBTRACTION IS TAKEN AND THE MENU ITEM IS NOT**, deliberately: a
   survey of what sits on which menu in real editors is running, and adding an
   item ahead of it would be guessing at the answer it exists to produce. A
   `<details>` is the same shape `.composer-share` two rules down already uses,
   it needs no script - which `base.html` requires of the standalone document
   page, since it carries no `<script>` at all - and File -> Info can be wired
   to open it in one line once the terrain is in front of somebody.

   **NOTHING IS DELETED.** The answer to *where is this saved* is still one
   press away, and `s-b5a048e62e0be4c6` is the row where Jon asked for it:
   *"And now I wonder, when I close it, where will it be found again?"* He
   asked for the fact to EXIST, not to narrate itself at him while he writes. */
.composer-info { margin: 0 0 0.5rem; font-size: 0.85em; }
/* **DOCKED BELOW THE WRITING SURFACE - `s-d4c4e45bdaf50762`, 2026-09-19.**
   Jon, twice: Details *"is folded but still does not belong on the main tab -
   it should be behind a menu, as discussed"*, and the whole block above his
   text is *"a giant area that cannot be folded"*.

   **FOLDING WAS NOT ENOUGH AND THE MEASUREMENT IS WHY.** A FOLDED
   `.composer-info` still cost 23.91 px at 1100 and `.composer-share` 35.19 px
   at every width, and the three gaps between the title and the toolbar were
   three different margin-bottoms in a flex column that cannot collapse them
   (8.00 + 8.00 + 9.59). `static/app.js` re-parents both below
   `.composer-surface` and hides them, which is worth 76.68 px at 1100 and
   75.41 px at 390 - see `research/composer-gap-accounting-2026-09-19.md`.

   **THE SELECTOR IS THE ELEMENT'S OWN DECLARATION, NOT A LIST OF NAMES**
   (L-076): `dockComposerDetails` sets `data-docked`, so a third disclosure
   docked there is styled with no edit here. It wins over `.composer-info` and
   `.composer-share` above on specificity ((0,2,1) against (0,1,0)) rather
   than on order.

   **IT BORROWS `.composer-stages-panel`'s FACE**, because it is the same
   thing: a panel a menu row opens, docked at the bottom of the pane, `hidden`
   at rest. Three panels already look like this and a fourth appearance would
   be a second answer to what a docked panel is (L-017).

   **`max-height` AND `overflow-y`, BECAUSE THE SHARE PANEL IS NOT SMALL.** It
   holds four forms and one row per link; with `flex: 0 0 auto` and no cap, a
   long list would push the writing surface toward zero. The surface is
   `flex: 1 1 auto; min-height: 0` and yields, so the cap is what keeps his
   text on the screen while the panel is open. */
.composer > details[data-docked] { flex: 0 0 auto; margin: 0.5rem 0 0;
                                   padding: 0.5rem 0.6rem;
                                   border: 1px solid var(--line-ctl);
                                   border-radius: 0.3rem;
                                   background: var(--panel);
                                   max-height: 60%; overflow-y: auto; }
/* `.composer-share-open`'s rule and its reason: a `<summary>` draws a
   disclosure marker only while its `display` is `list-item`, and an
   `inline-flex` summary silently loses the one thing on the element that says
   *this opens*. So the chevron is put back by hand and turned by the sheet. */
.composer-info-open { cursor: pointer; display: inline-flex;
                      align-items: center; gap: 0.35rem;
                      padding: 0.15rem 0.4rem; border-radius: 0.25rem;
                      color: var(--ink-sub); list-style: none; }
.composer-info-open::-webkit-details-marker { display: none; }
.composer-info-open:hover { background: var(--panel); }
.composer-info-open:focus-visible { outline: 2px solid var(--accent);
                                    outline-offset: 1px; }
.composer-info-chev { display: inline-block; transition: transform .12s ease;
                      color: var(--dim); }
.composer-info[open] > .composer-info-open .composer-info-chev {
  transform: rotate(90deg); }
.composer-where { margin: 0.3rem 0 0.2rem; font-size: 0.94em;
                  color: var(--ink-sub);
                  display: flex; flex-wrap: wrap; align-items: baseline;
                  gap: 0.25rem 0.75rem; }
.composer-facts { color: var(--dim); }
.composer-where a { color: inherit; }

/* ------------------------------------------------------- THE SHARE PANEL
   `s-89ac665cc32626d9`, stage 3. Jon: *"I should be able to click the share
   tab and create a link with a role and optionally a password."*

   **A `<details>` IN THE FLOW, CLOSED BY DEFAULT.** It sits between the
   *where is this saved* line and the writing toolbar because it belongs to the
   same family as the first - facts about the document's place in the world,
   answered before he starts typing rather than while he is writing. Closed, it
   costs one row; it must not push the words down for a reader who is not
   sharing anything.

   **NOTHING HERE WRITES A `[hidden]` COMPANION.** `app.css`'s global rule
   already hides `[hidden]` with `!important` for every element on the page,
   and a new companion re-grows the keep-list that rule exists to retire
   (L-470). The fault line and the password hint are toggled through the
   attribute alone. */
.composer-share { margin: 0 0 0.6rem; font-size: 0.85em; }
/* **IT HAS TO READ AS A CONTROL - Jon, 2026-09-14: *"i see no options to
   share."*** Driven on the served revision, the control was there and visible
   with nothing in front of it; he did not recognise it. It wore
   `color: var(--ink-sub)`, 0.1rem of side padding and no border, which is the
   description of a caption.

   **AND `display: inline-flex` HAD TAKEN THE DISCLOSURE MARKER AWAY.** A
   `<summary>` draws its triangle only while its `display` is `list-item`;
   laying the count out beside the word cost the element the one glyph that
   said *this opens*, and nothing reported it. The chevron is in the markup now
   and turns here.

   **IT BORROWS `.composer-menu-btn`'s FACE rather than inventing one** - the
   same border, radius and touch floor as the writing row two elements down, so
   it reads as a control of this app rather than as a control of some other
   one. It is NOT that class: that name means *derived from the command
   registry* and this is a disclosure (the same distinction `.composer-stages-open`
   is written out for). */
.composer-share-open { cursor: pointer; display: inline-flex;
                       align-items: center; gap: 0.35rem;
                       padding: 0.25rem 0.55rem; color: inherit;
                       font-size: 0.85em; line-height: 1.2;
                       border: 1px solid var(--line-ctl);
                       border-radius: 0.25rem; min-height: 2.2rem;
                       box-sizing: border-box; width: fit-content;
                       /* A thumb press must not select the word on its way to
                          opening this - `.ws-tabctx-item`'s rule, scoped. */
                       user-select: none; -webkit-user-select: none; }
.composer-share-open:hover { background: var(--panel); }
.composer-share-open:focus-visible { outline: 2px solid var(--accent);
                                     outline-offset: 1px; }
/* **TURNED, NOT SWAPPED FOR A SECOND GLYPH.** One character in the markup and
   one rotation here is one producer of *which way it points* (L-017); a pair
   of glyphs swapped by a rule is two, and they drift. `display: inline-block`
   is load-bearing - `transform` does nothing to an inline box. */
.composer-share-chev { display: inline-block; transition: transform .12s ease;
                       color: var(--dim); }
.composer-share[open] > .composer-share-open .composer-share-chev {
  transform: rotate(90deg); }
/* The count rides the summary so the closed panel still says whether anybody
   has this document - a disclosure that hides its own answer is one more thing
   to open before you know you did not need to. Zero is a real answer and is
   shown as one. */
.composer-share-count { font-variant-numeric: tabular-nums; min-width: 1.2rem;
                        padding: 0 0.3rem; border-radius: 0.6rem;
                        background: var(--panel); color: var(--dim);
                        text-align: center; }
/* **THE POSITION, NOT A WARNING.** The links do not open anything yet and the
   panel says so once. It is quiet rather than red: nothing is wrong, and a
   caution stripe over a true sentence teaches a reader to stop reading them. */
.composer-share-shut { margin: 0.4rem 0 0.6rem; padding: 0.5rem 0.7rem;
                       border-left: 3px solid var(--line-ctl);
                       background: var(--panel); color: var(--ink-sub);
                       max-width: 46rem; }
/* The server's own sentence when it refused. Loud, because it is the one thing
   on this panel that means something did NOT happen. */
.composer-share-fault { margin: 0.4rem 0; padding: 0.45rem 0.7rem;
                        border-left: 3px solid var(--bad);
                        background: var(--panel); color: var(--ink); }
.composer-share-none { margin: 0.4rem 0; color: var(--dim); }
.composer-share-list { list-style: none; padding-left: 0; margin: 0.4rem 0; }
.composer-share-row { padding: 0.5rem 0; border-top: 1px solid var(--line);
                      display: flex; flex-wrap: wrap; align-items: flex-end;
                      gap: 0.4rem 0.6rem; }
/* A link he has turned off is DIMMED and still fully operable - his *"turn off
   that link so it doesn't work at some point"* is reversible by construction,
   so the row that turns it back on must not be hidden or disabled. */
.composer-share-row.off { opacity: 0.62; }
.composer-share-says { flex: 1 1 100%; margin: 0 0 0.2rem;
                       display: flex; flex-wrap: wrap; align-items: baseline;
                       gap: 0.25rem 0.5rem; }
.composer-share-name { font-weight: 600; }
.composer-share-can { color: var(--ink-sub); }
.composer-share-flag { font-size: 0.85em; padding: 0.05rem 0.4rem;
                       border: 1px solid var(--line-ctl);
                       border-radius: 0.7rem; color: var(--ink-sub); }
/* **THE ONE FLAG THAT IS NOT NEUTRAL**, and it is still not an error: a link
   that can change the words and carries no password is a state Jon chose, his
   sentence being that the requirement is a default and *"doesn't always have
   to be that way"*. Marked so he can see it at a glance, never refused. */
.composer-share-flag.open { border-color: var(--bad);
                            color: var(--bad); }
.composer-share-flag.unknown { border-style: dashed; }
.composer-share-field { display: flex; flex-direction: column; gap: 0.15rem;
                        flex: 1 1 10rem; color: var(--ink-sub); }
.composer-share-field input, .composer-share-field select {
    font: inherit; font-size: 1em; line-height: 1.3; padding: 0.25rem 0.4rem;
    min-height: 2.2rem; border: 1px solid var(--line-ctl);
    border-radius: 0.25rem; background: transparent; color: var(--ink); }
.composer-share-field select option { background: var(--panel);
                                      color: var(--ink); }
.composer-share-clear { display: inline-flex; align-items: center;
                        gap: 0.3rem; cursor: pointer; color: var(--ink-sub); }
.composer-share-alike, .composer-share-expect { flex: 1 1 100%;
                        margin: 0.15rem 0 0.35rem; color: var(--dim); }
.composer-share-expect { color: var(--bad); }
/* Every button on the panel is the review bar's button, because they are the
   same kind of thing one control over and two spellings of one control is how
   they come to look like two features. */
.composer-share-save, .composer-share-toggle, .composer-share-del,
.composer-share-make { font: inherit; font-size: 1em; line-height: 1.2;
                       padding: 0.2rem 0.55rem;
                       border: 1px solid var(--line-ctl);
                       border-radius: 0.25rem; background: transparent;
                       color: inherit; cursor: pointer; min-height: 2.2rem; }
.composer-share-save:hover:not(:disabled),
.composer-share-toggle:hover:not(:disabled),
.composer-share-del:hover:not(:disabled),
.composer-share-make:hover:not(:disabled) { background: var(--panel); }
.composer-share-save:focus-visible, .composer-share-toggle:focus-visible,
.composer-share-del:focus-visible, .composer-share-make:focus-visible {
    outline: 2px solid var(--accent); outline-offset: 1px; }
.composer-share-save:disabled, .composer-share-toggle:disabled,
.composer-share-del:disabled, .composer-share-make:disabled {
    opacity: 0.45; cursor: default; }
/* **DELETION IS THE RARE CONTROL AND LOOKS LIKE IT.** The switch beside it is
   the ordinary one - reversible, and it keeps the name a tracked change's
   byline is taken from. This is deliberately the quieter of the two until it
   is reached for. */
.composer-share-del { border-style: dashed; color: var(--ink-sub); }
.composer-share-h { font-size: 1em; margin: 0.6rem 0 0.3rem; }
.share-new, .share-edit { display: flex; flex-wrap: wrap; align-items: flex-end;
                          gap: 0.4rem 0.6rem; flex: 1 1 18rem; }
.share-switch, .share-del { display: inline-block; }

/* The list itself, on its own page and in a tab. The dates are the answer to
   his question, so they are on the row rather than behind a hover: two drafts
   both called "Untitled" are told apart by nothing else. */
.doc-where { font-size: 0.9em; color: var(--ink-sub); }
.doc-list { list-style: none; padding-left: 0; }
.doc-list li { padding: 0.4rem 0; border-bottom: 1px solid var(--line); }
.doc-list a { font-weight: 600; }
.doc-when { display: block; font-size: 0.8em; color: var(--dim); }
/* The group separator. Not a control: no `data-cmd`, `aria-hidden`, and it is
   deliberately invisible to every census that counts buttons. */
.composer-menu-sep { display: inline-block; width: 1px; align-self: stretch;
                     margin: 0.1rem 0.25rem; background: var(--line); }
/* THE TRACK CHANGES SWITCH. Jon's word, answering `tracked-changes-record-mine`:
   *"I think of it as track changes and I don't think of it as recording."*

   **`.composer-review-show`'s rule verbatim in shape, because it IS the same
   kind of control** - a label wrapping a checkbox, sitting in a toolbar row -
   and two switches on one page that lay out differently for no reason is the
   CSS half of L-017. It is a separate selector only because the two rows have
   different padding contexts.

   **NO `[hidden]` COMPANION, and that is a rule rather than an omission.**
   `app.css:490` does it globally with `!important`, and it exists to RETIRE
   the sixteen companions this sheet had accumulated - a seventeenth was added
   and removed on 2026-09-13 when its control came back green (L-470). This
   declares a `display`, which is exactly the case that comment covers. */
/* **TWO NAMES, ONE DECLARATION, and the split is load-bearing.** The menu row
   carries two switches now - Track changes and Show comments - and they share
   a shape but NOT the emphasis below. A second copy of these six properties
   would be the CSS half of L-017; reusing `.composer-menu-track` for both
   would bold *"Show comments"* whenever recording was on, which says something
   untrue about a control that has nothing to do with recording. So the layout
   is the pair and the emphasis is the one. */
.composer-menu-track,
.composer-menu-switch { display: inline-flex; align-items: center;
                       gap: 0.3rem; cursor: pointer; white-space: nowrap;
                       font-size: 0.92em; color: var(--ink-sub); }
/* ON is not a subtle state: it changes what typing DOES, so it is not left to
   a checkbox glyph a phone may draw at 12px.
   **NAMES `.composer-menu-track` ALONE**, for the reason just above. */
.composer-menu[data-tracking="on"] .composer-menu-track { color: var(--ink);
                                                          font-weight: 600; }
.composer-menu-btn:hover:not(:disabled) { background: var(--panel); }
.composer-menu-btn:focus-visible { outline: 2px solid var(--accent);
                                   outline-offset: 1px; }
/* **DISABLED IS DIMMED AND STILL DRAWN, never removed.** A control that comes
   and goes as the cursor moves is a row whose shape changes under the reader's
   hand; a dim one says *this exists and does not apply here*, which is the
   sentence that is actually true (L-014). */
.composer-menu-btn:disabled { opacity: 0.45; cursor: default; }

/* **THE TEXT SCROLLS, NOT THE WHOLE COMPOSER.** Jon, 2026-09-13: *"Why does it
   scroll off the page while I am writing? And I have to scroll up in order to
   do something."*

   Measured before the fix: after typing 45 lines the menu row's top was at
   **-1114.8px** against a 700px viewport, `.ws-pview.active` had a
   `scrollTop` of 1286, and the surface's own `overflow-y` was `visible`. So
   the surface never scrolled at all - the ProseMirror grew from 192px to
   1710px inside a 439px box, overflowed it, and the PANE scrolled instead,
   carrying the title and the whole menu off the top with it.

   Making the surface the scroller is the structural fix rather than pinning
   the row with `position: sticky`: the composer is already a height-bounded
   flex column, so once its one growing child stops overflowing, the head and
   the menu simply stay where they are. It also hands the caret back to the
   browser, which keeps the caret inside a scroller natively and needed no
   code here at all.

   **`min-height: 0` IS THE LOAD-BEARING HALF AND IS NOT DECORATION.** A flex
   item's default `min-height` is `auto`, which refuses to shrink below its
   content - so `overflow-y: auto` alone would leave the box as tall as the
   text and scroll nothing. The `12rem` floor moves to `max()` so the surface
   is still comfortably tall in a short pane without reintroducing that. */
.composer-surface { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.composer-surface .ProseMirror { outline: none; min-height: 12rem; }
/* **`white-space: pre-wrap` IS PROSEMIRROR'S OWN REQUIREMENT** - `s-trackbackspace`,
   Jon, 2026-10-05: Backspace stopped after one letter in a composition. Its
   stylesheet (`style/prosemirror.css`) was never loaded here, and the library
   says so in the console: *"ProseMirror expects the CSS white-space property to
   be set, preferably to 'pre-wrap'"*. Without it the browser re-spells a space
   left at the end of a line as a no-break space the moment the letter after it
   goes, and the editor reads that as a SECOND change - so with Track changes on,
   striking the last letter of a word also struck the space before it and
   inserted a new one, the caret landed past it, and the next press took that
   invisible insertion back instead of a letter. One press seemed to do nothing
   and the one after struck the same letter again. Measured on the app: a
   paragraph ending `hello w`, three presses - carets 7, 8, 7, 8 without this
   line and 7, 6, 5, 4 with it (track.js's backward-caret branch is the other
   half; `verify_composer` drives each without the other). */
.composer-surface .ProseMirror { white-space: pre-wrap; }

/* **THE COMPOSER'S ZOOM - Jon, 2026-09-14: *"and i cannot zoom."***
 *
   **`font-size` ON THE SURFACE, and the reason it is not `zoom` or
   `transform: scale()` is in `app.js` beside the store.** Both of those put
   ProseMirror's caret arithmetic through a coordinate space that is not the
   viewport's, and the thing he is asking for is bigger type.

   **THE LIST INDENT IS GIVEN IN `em` IN THE SAME RULE, and that is the whole
   of `docs/why/09` arriving from the other side.** There the fix was to STOP
   an indent scaling with the type, because a publisher's hanging indents are
   absolute lengths the reader never chose. Here the indent is the browser's
   own `40px` default on a document that has no typography of its own - so at
   2.0 the type doubles and the indent stands still, and a bullet list slowly
   stops looking like one. The two are the same rule read correctly: an indent
   should scale with the thing it is indenting FROM.

   **DECLARED WITH A FALLBACK OF 1, so the variable's absence is the ordinary
   size** - `syncComposerZoom` removes the property rather than writing 1, and
   this is the single place that number lives. */
.composer-surface .ProseMirror {
  font-size: calc(1em * var(--composer-zoom, 1));
}
.composer-surface .ProseMirror ul,
.composer-surface .ProseMirror ol { padding-inline-start: 2.2em; }

/* ------------------------------------------------------- THE MENU BAR
   Jon, 2026-09-14: *"why is there no actual menu on my composer?"*

   **IT SITS WHERE LIBREOFFICE'S SITS - above the toolbar, flush left, quiet.**
   A menu bar is not a row of buttons and must not read as one: no borders, no
   boxes, just words that darken under the pointer. The toolbar below keeps its
   buttons, which is what makes the two distinguishable at a glance.

   **NO `position: fixed` AND NO `z-index` RACE.** `.ws-tabctx` is fixed
   because it opens at a pointer anywhere on the screen; this one always opens
   directly under its own word, so a containing block per menu is the whole
   mechanism and the drop needs no script to place it. It still needs a stack
   above the writing surface, which `z-index` on the OPEN menu alone provides -
   scoped so a closed bar cannot sit over anything. */
.composer-menubar {
  display: flex; flex-wrap: wrap; align-items: center; gap: .1rem;
  font-size: .84rem; padding: 0;
}
.composer-menubar:empty { display: none; }
.composer-menubar-menu { position: relative; display: inline-block; }
.composer-menubar-top {
  border: 0; background: none; color: inherit; font: inherit;
  padding: .2rem .5rem; border-radius: 3px; cursor: pointer;
  /* A press on a phone must not select the word on its way to opening the
     menu - `.ws-tabctx-item`'s rule, scoped the same way. */
  user-select: none; -webkit-user-select: none;
}
.composer-menubar-top:hover { background: var(--panel); }
.composer-menubar-top:focus-visible { outline: 2px solid var(--accent);
                                      outline-offset: -2px; }
.composer-menubar-top[aria-expanded="true"] { background: var(--panel); }
.composer-menubar-drop {
  position: absolute; top: 100%; left: 0; z-index: 72;
  min-width: 13rem; max-width: min(22rem, calc(100vw - 1rem));
  max-height: 70vh; overflow-y: auto;
  background: var(--pane); color: var(--ink);
  border: 1px solid var(--dim); border-radius: 5px;
  box-shadow: 0 6px 24px rgba(0,0,0,.28);
  padding: .3rem;
}
.composer-menubar-drop[hidden] { display: none; }
/* ------------------------------------- THE MENUS ARE PORTALLED, 2026-09-19
 *
 * **MEASURED, NOT REASONED** (`research/probes/lintel_clip_probe.py`). A
 * menu drop inside this row hits TWO independent faults, and a repair
 * clearing either one alone looks identical to the reader:
 *
 *   CLIP     `position: absolute` inside the row: **3px of 384px** survives,
 *            cut by the row's own `overflow-y: hidden`.
 *   COVER    made `position: fixed` in that same row it keeps all 384px and
 *            is STILL not painted - the row carries `z-index: 2`, so it
 *            establishes a STACKING CONTEXT and a `z-index: 72` child of it
 *            is painted at the ROW's level, underneath the library panel.
 *
 * **AND `.ws-tabbar` HAS BOTH PROPERTIES VERBATIM** - `position: absolute;
 * z-index: 2` and `overflow-x: auto; overflow-y: hidden`, identical to the
 * row this measurement was taken in. Moving the composer's menus there
 * INHERITS the problem rather than retiring it, which is why the portal
 * survives the slot that motivated it. The destination already had one.
 *
 * Leaving `#pane` clears both, which is why the element MOVES rather than
 * merely changing its `position`. The host is placed by `placeFloatingMenu` -
 * already this app's one producer of where a floating menu sits.
 *
 * **THE ROW'S OWN OVERFLOW IS UNTOUCHED**, so R3 survives: the fixed height
 * stays honest and its contents still cannot paint over the first line of the
 * book. */
/* **ONE ROW, ONE PORTAL - `#composer-menupop` RETIRED INTO `#ws-tabpop`,
 * 2026-09-19.** With the composer's menus in `.ws-tabbar`, a second host for
 * *where does this row's menu go* would be a second producer of one question
 * (L-017), sitting beside the first. `#ws-tabpop` already portals this exact
 * row's own menus, already lives outside `#pane`, and is already placed by
 * `placeFloatingMenu`.
 *
 * **THE HOST SUPPLIES THE BOX AND THE DROP GOES NEUTRAL INSIDE IT.** The two
 * were near-identical already - `z-index: 72`, `min-width: 13rem`,
 * `max-height: 70vh`, the same background, border, radius, shadow and padding
 * - so the drop keeping its own would paint a menu inside a menu. It keeps
 * its contents and gives up its chrome, which is also what makes a composer's
 * menu look like every other menu on this row.
 *
 * `position: static` so the host sizes to it and `placeFloatingMenu` can read
 * an `offsetHeight`; `overflow: visible` because the HOST owns the scroll. */
#ws-tabpop > .composer-menubar-drop {
  position: static; top: auto; left: auto;
  min-width: 0; max-width: none; max-height: none; overflow: visible;
  background: none; border: 0; border-radius: 0; box-shadow: none;
  padding: 0;
}
/* **A ROW IS A GRID OF TWO, so every shortcut lines up in one column** - a
   menu whose keys are ragged is one a reader has to scan twice. The second
   column collapses to nothing on a row with no key, so a menu with none looks
   exactly as it did before keys existed. */
.composer-menubar-item {
  display: grid; grid-template-columns: 1fr auto; gap: .9rem;
  align-items: baseline;
  width: 100%; text-align: left;
  padding: .34rem .55rem; border: 0; border-radius: 3px;
  background: none; color: inherit; font: inherit; cursor: pointer;
  user-select: none; -webkit-user-select: none;
}
.composer-menubar-item:hover:not(:disabled) { background: var(--panel); }
.composer-menubar-item:focus-visible { outline: 2px solid var(--accent);
                                       outline-offset: -2px; }
/* Disabled rather than hidden, `.ws-tabctx-item`'s rule and for its reason:
   a row that comes and goes is harder to aim at than one that greys. */
.composer-menubar-item:disabled { color: var(--ink-sub); opacity: .55;
                                  cursor: default; }
.composer-menubar-key { color: var(--ink-sub); font-size: .92em;
                        font-variant-numeric: tabular-nums; }
/* **THE TICK IS A GLYPH IN A RESERVED GUTTER, never a change of colour.**
   `NEVER COLOUR ALONE` is this sheet's standing rule for the tracked-change
   marks and it is the same reader here.

   **ON THE TEXT SPAN, NOT ON THE ROW - AND THE ROW IS WHERE I PUT IT FIRST.**
   The row is `display: grid`, so a `::before` on IT becomes a grid ITEM: it
   took column 1 and pushed every label into the `auto` column, which
   right-aligned the seven paragraph styles and nothing else. Driven and seen
   in a screenshot, not reasoned about. On the text span it is ordinary inline
   content inside column 1.

   **EVERY ROW GETS THE GUTTER, not only the checkable ones**, which is what
   LibreOffice does and is the only way the labels line up in a menu that
   mixes the two. The reserved character is a FIGURE SPACE, so the width is
   the tick's and a row does not shift sideways when it is ticked. */
.composer-menubar-text::before {
  content: "\2007"; margin-inline-end: .35rem;
}
.composer-menubar-item[aria-checked="true"] .composer-menubar-text::before {
  content: "\2713";
}
.composer-menubar-rule { border: 0; border-top: 1px solid var(--dim);
                         margin: .25rem .2rem; }

/* **SHOWN UNTIL THE EDITOR SAYS IT MOUNTED, and that direction is the point.**
   The sentence is in the markup and the stylesheet takes it away once
   `data-mounted="1"` is set - so a mount that FAILS, or a browser with
   scripting off, leaves the explanation standing. Written the other way round
   (hidden by default, revealed by script) the failure case is a blank box with
   nothing to read, which is the one outcome worse than a missing editor
   (L-014, L-021). */
.composer-noscript { font-size: 0.85em; opacity: 0.75; }
.composer:has(.composer-surface[data-mounted="1"]) .composer-noscript {
  display: none;
}

/* THE SIGN-IN THE APP HOSTS — Jon, decision 185, 2026-09-13: *"i want to be
 * able to just use the app, not be on a terminal somewhere."*
 *
 * One block per plug-in that declares the two verbs, inside `#ix-signin`.
 * It reuses this page's existing class NAMES — `.ix-save`, `.ix-forget`,
 * `.ix-field` — rather than minting a parallel set. Measured 2026-09-13:
 * the first two carry no rules in this sheet at all and render as plain
 * buttons, which is what the vault rows above already look like; reusing the
 * names is so that the day either one is styled, this section is styled with
 * it rather than drifting into a second appearance for the same control.
 *
 * **No `[hidden]` companion, and that is a rule rather than an omission.**
 * `app.css:490` already does it globally with `!important`; writing another
 * one here would re-grow the keep-list that rule exists to retire (L-470). */
.ix-signin {
  margin: .7rem 0; padding: .55rem .7rem;
  border: 1px solid var(--line); border-radius: 4px;
}
.ix-signin + .ix-signin { margin-top: .5rem; }
.ix-signin-who { margin: 0 0 .35rem; font-size: .95rem; color: var(--ink); }
.ix-signin-who code { font-size: .88em; }

/* Where the code went and how long it lasts. This is the page's OWN sentence,
 * built from the store's typed fields — never its `prompt`, which is
 * pre-formatted English hard-coding a count `code_length` already carries. */
.ix-signin-sent { margin: .3rem 0 .5rem; font-size: .93rem;
                  color: var(--ink-sub); max-width: 44rem; }
.ix-signin-sent strong { color: var(--ink); }

/* The code box. `flex` with `wrap` so the row folds to one column on a phone
 * rather than pushing the page sideways — this is read on one. */
#ix-signin .ix-field {
  display: flex; flex-wrap: wrap; gap: .4rem; align-items: baseline;
  margin: .3rem 0;
}
#ix-signin .ix-field form {
  display: flex; flex-wrap: wrap; gap: .4rem; align-items: baseline;
}
#ix-signin .ix-flabel { color: var(--ink-label); font-size: .88rem;
                        font-weight: 600; }
/* SIGNING IN WHERE THE STORE NEEDS A BROWSER - Jon, decision 186.
 *
 * **IT REUSES `.ix-signin`'S BOX RATHER THAN MINTING A SECOND ONE**, because
 * it IS the same thing to a reader: one store, one dance, one block. What this
 * adds is only what the other path has no equivalent of - the picture of a
 * challenge - plus the two `#ix-signin` selectors repeated for the new section
 * id, since those were written against an id rather than a class.
 *
 * `.ix-bsignin` carries no rules of its own today and is here as the hook the
 * script reads (`data-plugin` hangs off it). Named rather than left implicit
 * so that the day it needs a rule, there is one place for it.
 *
 * **No `[hidden]` companion** - `app.css:490` already does it globally with
 * `!important`, and a second one here would re-grow the keep-list that rule
 * exists to retire (L-470). */
#ix-browser-signin .ix-field {
  display: flex; flex-wrap: wrap; gap: .4rem; align-items: baseline;
  margin: .3rem 0;
}
#ix-browser-signin .ix-field form {
  display: flex; flex-wrap: wrap; gap: .4rem; align-items: baseline;
}
#ix-browser-signin .ix-flabel { color: var(--ink-label); font-size: .88rem;
                                font-weight: 600; }
#ix-browser-signin input[type="password"],
#ix-browser-signin input[type="email"] {
  font: inherit; padding: .2rem .35rem;
  border: 1px solid var(--line); border-radius: 3px;
  background: var(--bg); color: var(--ink);
}
#ix-browser-signin input[type="password"] { width: 12ch; }
/* The address box holds an email, so it is wider than the code box - but still
 * capped, so a phone does not get a full-bleed field that reads as belonging
 * to the whole page. `max-width` rather than `width` so it shrinks first. */
#ix-browser-signin input[type="email"] { width: 24ch; max-width: 100%; }

/* **SIGNED IN.** The state Jon looked for and could not find on 2026-09-14.
 * It is deliberately NOT `.ix-fault` and NOT `.ix-notyet`: both of those are
 * headed with something being wrong or unfinished, and this is the one line on
 * the section that says a thing WORKED.
 *
 * **The heading does the work, not the colour** - the same thing this sheet
 * already says about `.ix-kept` one section up. A reader on a monochrome
 * screen, or one who does not read green as success, gets the word
 * *Signed in.* in bold either way. */
.ix-connected {
  margin: .4rem 0 .3rem; padding: .4rem .6rem;
  font-size: .93rem; color: var(--ink-sub); max-width: 44rem;
  border-left: 3px solid var(--line); border-radius: 0 3px 3px 0;
}
.ix-connected strong { color: var(--ink); }

/* **THE CHALLENGE PICTURE.** Fetched server-side with this app's token and
 * re-served by `/integrations/browser-signin/<plugin>/screenshot` - the
 * browser is never sent at the harness, because a token in a page is a token
 * in a page.
 *
 * Capped and `height: auto` so a tall screenshot cannot push the rest of the
 * section off a phone; `block` so it does not sit on a text baseline. */
.ix-bshot {
  display: block; margin: .5rem 0; max-width: 100%; height: auto;
  border: 1px solid var(--line); border-radius: 3px;
}

#ix-signin input[type="password"] {
  /* Wide enough for a long code and no wider: a full-width box on a phone
   * reads as a password field for the whole page rather than for this one
   * step. `ch` so it tracks the font rather than a guess in pixels. */
  width: 12ch; font: inherit; padding: .2rem .35rem;
  border: 1px solid var(--line); border-radius: 3px;
  background: var(--bg); color: var(--ink);
}

/* **A STATEMENT OF WHAT HAPPENED, AND DELIBERATELY NOT `.ix-fault`.** The
 * fault block is headed "That did not take."; this one carries things that
 * DID take — an accepted code whose credential core could not keep yet, and
 * one day a completed sign-in.
 *
 * **The distinction is the HEADING, not the colour, and that is said out loud
 * because the colour looks like it is doing the work and is not.** This sheet
 * has no success token: `--accent` is a dark red (#7a2e2e light, #d98b8b
 * dark) and is what `.ix-mode:hover` already uses for emphasis. So this rule
 * borrows the page's emphasis and the WORDS carry the difference. Minting a
 * green here would be a new colour in a sheet that has none, for one block. */
.ix-note {
  margin: .6rem 0; padding: .5rem .7rem; font-size: .9rem;
  border: 1px solid var(--line); border-left: 4px solid var(--accent);
  background: var(--panel, transparent); color: var(--ink);
  max-width: 44rem;
}

/* ------------------------- WHAT THE STORE SAID ABOUT A WORK IT IS HOLDING
   `CONTRACT.md`'s `held_note`, 2026-09-14. The line above it is the app's own
   sentence about the row - one of five, chosen from a closed vocabulary. This
   is the SOURCE's prose about the same hold, and Jon's case is why the field
   exists: a book paid for twice and delivered never, which no vocabulary was
   ever going to carry.

   **IT WRAPS, AND THAT IS THE WHOLE RULE HERE.** `.si-stats` beside it is
   `white-space: nowrap` with an ellipsis, which is right for a fixed phrase
   from a five-word vocabulary and is exactly wrong for a sentence somebody
   else wrote: the field exists because *"the most actionable thing it knows
   stays invisible"*, and truncating it at the row's width is a second way to
   make it invisible. So the three properties that would clip it are undone
   explicitly rather than left to inherit.

   **AND IT IS NOT FAINTER THAN THE APP'S OWN LINE.** `--ink-sub` rather than
   `--dim`: this carries the part he can act on, and greying it below the
   sentence it qualifies would rank the app's vocabulary above the store's
   words. Italic marks it as a quotation from another program; the contrast
   does not. */
.ownedlist .si-held { display: block; font-size: .72rem; font-style: italic;
                      color: var(--ink-sub); white-space: normal;
                      overflow: visible; text-overflow: clip; }

/* The answers on a comment - L2. One block per answer: who said it, then what
   they said. Indented from the body above so a conversation reads as one, and
   WRAPPING for the reason the held-note line wraps: these are sentences
   somebody typed, and a row that clips them to the panel's width is a reply he
   cannot read. */
.composer-comments-answers { flex: 1 1 100%; display: flex;
                             flex-direction: column; gap: 0.35rem;
                             padding-left: 0.6rem;
                             border-left: 2px solid var(--line); }
.composer-comments-said { display: flex; flex-direction: column; }
.composer-comments-who { font-weight: 600; }
.composer-comments-what { white-space: pre-wrap; }

/* ----------------------- A COMMENT AN AI AGENT LEFT, AND HE CAN SEE IT IS
   `s-555f91ce0ac3a6eb`. Jon, on his first use of the composer: *"It would be
   neat if comments were added there in response and I could see it was from
   an AI agent author, and test out that, maybe as a comment or something."*

   **THIS IS THE SECOND HALF OF THE MARK AND NOT THE WHOLE OF IT.** The byline
   itself SAYS *"(an AI agent)"* - `composerAuthorName` is the producer - so
   these rules are the *at a glance* half of the row's own sentence
   (*"distinguishable at a glance from a comment a person left"*) and nothing
   depends on them being seen. A reader with no colour, or reading the text of
   the byline, still gets the whole answer (L-002).

   **`data-author` IS WRITTEN BY `paint` IN THE SAME COMMIT AS THESE RULES**,
   which is the difference between this block and `.composer-surface
   [data-comments="off"]` a few screens up - that one has no writer anywhere
   and cannot fire. A stylesheet half without its writer is L-485 one layer
   down, and shipping both together is the only thing that prevents it.

   **THE ACCENT BAR RATHER THAN A BACKGROUND.** `--accent` is the one token
   that is defined in both themes and legible in both; a tinted panel would
   have to be chosen twice and would fight the `--panel` this box already
   sits on. The bar reads as *somebody else wrote this* at any width, and it
   costs the text no contrast at all. */
.composer-comments-panel[data-author="agent"] { border-left: 3px solid
                                                var(--accent); }
.composer-comments-panel[data-author="agent"] .composer-comments-says
  { color: var(--accent); }
/* An answer sits inside the panel, so it takes the bar on its own row - a
   thread where the agent commented and Jon replied shows two authors of two
   kinds, and only the agent's rows are marked. */
.composer-comments-said[data-author="agent"] { padding-left: 0.45rem;
                                               border-left: 3px solid
                                               var(--accent); }
.composer-comments-said[data-author="agent"] .composer-comments-who
  { color: var(--accent); }

/* ------------------------------------ NAMED STAGES - `s-8b877b613ff6bd1d`
   Freeze this draft under a name, list them, and compare two. The panel sits
   IN THE FLOW under the writing row, like the comments panel and for its
   reason: a box placed from a rect taken before the caret settles lands in
   the wrong place, and `position: fixed` makes that the only possible bug.

   **NO NEW `[hidden]` COMPANION** - `app.css`'s global
   `[hidden] { display: none !important }` already does it, and a companion
   here would re-grow the keep-list that rule was written to retire (L-470). */
/* **ITS OWN FACE, BECAUSE IT IS NOT A COMMAND.** `.composer-menu-btn` is the
   registry-derived buttons' class and the suite's census collects exactly
   that selector, so this control - which opens a panel rather than running a
   command - wears its own name and borrows the row's metrics rather than its
   identity. */
.composer-stages-open { font: inherit; font-size: 0.92em;
                        padding: 0.2rem 0.5rem; cursor: pointer;
                        white-space: nowrap; color: var(--ink-sub);
                        background: transparent;
                        border: 1px solid var(--line-ctl);
                        border-radius: 0.25rem; }
.composer-stages-open:hover { color: var(--ink); }
.composer-stages-open[aria-expanded="true"] { color: var(--ink);
                                              font-weight: 600; }
.composer-stages-open:focus-visible { outline: 2px solid var(--accent);
                                      outline-offset: 1px; }

.composer-stages-panel { display: flex; flex-wrap: wrap; align-items: center;
                         gap: 0.5rem; margin: 0.5rem 0;
                         padding: 0.5rem 0.6rem;
                         border: 1px solid var(--line-ctl);
                         border-radius: 0.3rem; background: var(--panel);
                         font-size: 0.85em; }
.composer-stages-naming,
.composer-stages-picking { flex: 1 1 100%; display: flex; gap: 0.4rem;
                           align-items: center; flex-wrap: wrap; }
/* The name box takes the room, because a stage's name is the thing he will
   read a week from now and a four-character box invites "v2". */
.composer-stages-name { flex: 1 1 12rem; font: inherit; font-size: 1em;
                        padding: 0.3rem 0.45rem;
                        border: 1px solid var(--line-ctl);
                        border-radius: 0.25rem;
                        background: var(--bg); color: inherit; }
.composer-stages-name:focus-visible { outline: 2px solid var(--accent);
                                      outline-offset: 1px; }
/* `min-width: 0` or a long stage name makes the select refuse to shrink and
   the row scrolls sideways on the phone. */
.composer-stages-from,
.composer-stages-to { flex: 1 1 8rem; min-width: 0; font: inherit;
                      font-size: 1em; padding: 0.25rem;
                      border: 1px solid var(--line-ctl);
                      border-radius: 0.25rem;
                      background: var(--bg); color: inherit; }
.composer-stages-says { flex: 1 1 100%; color: var(--ink-sub); }
.composer-stages-diff { flex: 1 1 100%; display: flex; flex-direction: column;
                        gap: 0.25rem; }
/* One change per line, and the two halves read left to right: what went, then
   what came. `pre-wrap` because the words are his and a diff that collapses
   his spacing is describing a document he did not write. */
.composer-stages-change { display: flex; flex-wrap: wrap; gap: 0.35rem;
                          white-space: pre-wrap; }
/* **THE SAME TWO FACES THE REVIEW BAR USES**, so a deletion looks like a
   deletion wherever he meets one - struck through and still readable, because
   a stage is history and nothing here is pending. */
.composer-stages-gone { text-decoration: line-through;
                        color: var(--ink-sub); }
.composer-stages-came { text-decoration: underline;
                        text-decoration-style: solid; }
/* The box that adds one. Same face as the body box above it, and shorter,
   because an answer is usually a line. */
.composer-comments-answer { flex: 1 1 100%; font: inherit; font-size: 1em;
                            line-height: 1.4; padding: 0.35rem 0.45rem;
                            border: 1px solid var(--line-ctl);
                            border-radius: 0.25rem;
                            background: var(--bg); color: inherit;
                            min-height: 2.6rem; resize: vertical; }
.composer-comments-answer:focus-visible { outline: 2px solid var(--accent);
                                          outline-offset: 1px; }

/* The Bible for verse links (decision 239), first in the dictionary panel.
   `.ws-dict-add`'s sizing for `.ws-dict-add`'s reason: a Bible's title can be
   long, and a flex item's automatic minimum is its content. */
.ws-bible-group { margin: .45rem 0 .6rem; }
.ws-bible-pick { flex: 1 1 auto; min-width: 0; font: inherit; font-size: .85rem; }

/* A voice memo on a note card (`s-audionotes`). The native player, full width
   so its controls are thumb-sized on a phone (ENTITLED C11); the length the
   recorder measured beside it, quiet like the card's other metadata. */
.ws-memo { display: flex; align-items: center; gap: .5rem; margin: .3rem 0; }
.ws-memo-play { flex: 1 1 auto; min-width: 0; max-width: 100%; height: 2.5rem; }
.ws-memo-len { flex: 0 0 auto; font-size: .8rem; opacity: .7;
               font-variant-numeric: tabular-nums; }

/* The record button stays on the EXPANDED card too (unlike "note…", whose job
   ends at expanding): it is the stop button while a memo records. Pressed, it
   says so in the accent, so a phone reader can see the microphone is live. */
.ws-note-rec[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
/* A TOUCH TARGET, idle AND recording - 44px, both platforms' guidelines. The
   judge's C11 (2026-10-02) measured this button at 29.5px tall at 400px wide
   (the shared rule's .3rem padding and .8rem font) beside a 40px player, and
   it is the button a phone reader presses twice per memo. `min-height`, so a
   larger font still grows it; "note…" beside it keeps the shared rule. */
.ws-note-rec { min-height: 44px; }

/* ------------------------------------------------ a gap in the source
 *
 * `run.extra.gap` (decision 268): `render.py` draws the stand-in exactly as
 * before - text, colour, nothing added at rest - and puts WHY in `data-gap`
 * ("illegible: page cropped - 2 letters"). Shown here as GENERATED content, so
 * a selection, a note's range and every `data-c` are what they were. Keyed on
 * the ATTRIBUTE, like `[data-only]` above, because the renderer owns the class
 * namespace and verify_shell's collision sweep would flag a class rule the day
 * the first re-landed EEBO book drew one. `:focus` is the phone's tap (the span
 * carries `tabindex="0"`), `:hover` the desktop's; `title` is the fallback.
 */
[data-gap] { position: relative; }
[data-gap]:hover { cursor: help; }
[data-gap]:hover::after, [data-gap]:focus::after {
  content: attr(data-gap);
  position: absolute; left: 0; top: 100%; z-index: 30;
  width: max-content; max-width: min(18rem, 80vw);
  padding: .2rem .45rem; border: 1px solid var(--line); border-radius: 4px;
  background: var(--panel); color: var(--ink);
  font: italic .8rem/1.35 system-ui, sans-serif; white-space: normal;
  pointer-events: none;
}
