/* GENERATED by design/ship.py from library/library.css -- DO NOT EDIT. That file is the master; run `python3 design/export.py --shell` and this one is rewritten. */
/* THIS FILE IS A MASTER, NOT A BENCH. design/library/ mirrors library/ and
   `design/export.py --shell` builds clean/library/library.css from it
   (design/LOOP.md). The Library's bench is design/reader/bench-library.html;
   this file ships the numbers it settled on and carries none of its dials, so
   there is nothing here to cut and the loop's own word for that is one
   marker: */
  :root {
    --bg: #fcfcfb;
  /* ---- THE RADIUS SCALE (Osca, 30 Aug: "all boxes across all pages are just
     the wrong shape, wrong curve, not modern enough").
     Four files had grown ten different radii between them -- 3, 4, 6, 7, 8,
     10, 12, 14, 50% and 999px -- assigned per element as each was written, so
     no two boxes on a screen agreed and none of them agreed with the next
     screen. These four steps are the whole vocabulary now, and they are one
     step larger at every level than what they replace, which is most of what
     "modern" means here. A box picks the step for its ROLE, not its size:
       --r-0  a mark on running text (the word, the sentence highlight)
       --r-1  a control you press (button, input, select, small chip)
       --r-2  a surface that holds things (card, section, panel, header button)
       --r-3  a surface that floats over another (sheet, popup, menu)
     Anything outside these four is a deliberate exception and says why. ---- */
  --r-0: 5px;
  --r-1: 11px;
  --r-2: 15px;
  --r-3: 22px;
  --r-pill: 999px;
  /* The chrome's font, not the book's -- see reader.html's own note. */
  --ui-font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable Text",
             "Segoe UI", Inter, system-ui, "Helvetica Neue", Arial, sans-serif;
 --fg: #0b0b0b; --fg-dim: #52514f; --accent: #832f2b;
    --border: #e4e3e0; --panel-bg: #ffffff; --link: #832f2b; --control-bg: #f5f5f3;
    --ok: #3f7a45; --bad: #a03328;
  }
  [data-theme="dark"] {
    --bg: #131316; --fg: #e8e7e6; --fg-dim: #98979b; --accent: #c9c4bb;
    --border: #2c2c32; --panel-bg: #1a1a1f; --link: #c9c4bb; --control-bg: #1a1a1f;
    --ok: #7fc08a; --bad: #d98a7a;
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --bg: #131316; --fg: #e8e7e6; --fg-dim: #98979b; --accent: #c9c4bb;
      --border: #2c2c32; --panel-bg: #1a1a1f; --link: #c9c4bb; --control-bg: #1a1a1f;
      --ok: #7fc08a; --bad: #d98a7a;
    }
  }
  /* ---- THE PAPER'S WARMTH, the same one number the reading page takes
     (reader/reader.css's own block, and reader/settings.js writes it). The
     shelf is paper too: warming the book you are reading and leaving the room
     you chose it from cold would be the seam a person notices every time they
     switch tabs. Mixed from the real colour, so warmth 0 IS the shelf that was
     always there, exactly. The CHROME does not warm and should not -- chrome
     belongs to the window, paper to the book. */
  :root { --warm: 0; --paper-warm: #f4e6cb; --ink-warm: #2a2016; }
  [data-theme="dark"] { --paper-warm: #1b1610; --ink-warm: #efe6d5; }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) { --paper-warm: #1b1610; --ink-warm: #efe6d5; }
  }
  /* ---- AND THE WARMTH IS BEHIND @supports, which is not decoration
     (6 Sep, measured in a real WebKit).  A custom property takes ANY token
     stream: `--bg: color-mix(...)` parses in every engine ever written, so it
     overrides the plain `--bg: #fcfcfb` above whether or not the engine can
     compute it.  An engine without `color-mix` -- Safari before 16.2, which is
     the phone -- then fails at computed-value time instead, `background:
     var(--bg)` becomes `unset`, and the page paints on whatever is behind the
     webview with NO console error of any kind.  Measured, WebKitGTK 2.36
     (Safari 15) against this file before the guard: `body` background
     `rgba(0, 0, 0, 0)`, `--bg` still the literal `color-mix(...)` string; with
     the guard, `rgb(252, 252, 251)`, which is the plain value four lines up
     doing its job.  `@supports` is the only construct that stops the override
     from being seen at all, because the alternative -- a second declaration
     after it -- is dropped by the same "any tokens are valid" rule. ---- */
  @supports (color: color-mix(in oklab, #000, #fff 50%)) {
    :root {
      --bg: color-mix(in oklab, #fcfcfb, var(--paper-warm) calc(var(--warm) * 100%));
      --fg: color-mix(in oklab, #0b0b0b, var(--ink-warm) calc(var(--warm) * 100%));
    }
    [data-theme="dark"] {
      --bg: color-mix(in oklab, #131316, var(--paper-warm) calc(var(--warm) * 100%));
      --fg: color-mix(in oklab, #e8e7e6, var(--ink-warm) calc(var(--warm) * 100%));
    }
    @media (prefers-color-scheme: dark) {
      :root:not([data-theme="light"]) {
        --bg: color-mix(in oklab, #131316, var(--paper-warm) calc(var(--warm) * 100%));
        --fg: color-mix(in oklab, #e8e7e6, var(--ink-warm) calc(var(--warm) * 100%));
      }
    }
  }

  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
    font-family: var(--ui-font); height: 100%; }
  body { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
  header { padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
  /* THE STRIP IS THE HEADER, and only in the app (desktop/README.md
     `## Status`'s desktop-headless-chrome entry §6). Written out because the
     UA's own `[hidden] { display: none }` loses to the `display: flex` on the
     line above. In a plain browser and on the phone nothing sets it. */
  header[hidden] { display: none !important; }
  .headerspace { flex: 1; }
  /* A hairline box around every icon in the bar is the single most dated
     thing on these pages: five outlined squares in a row read as a toolbar
     from 2012. The button is the icon; the surface only appears under the
     pointer, which is also the only moment it means anything. */
  header button, header .headerLink {
    min-height: 40px; min-width: 40px;
    border-radius: var(--r-1); border: 1px solid transparent;
    background: transparent; color: var(--fg-dim);
    font-size: 1rem; cursor: pointer;
    display: inline-flex; align-items: center; justify-content: center;
    text-decoration: none;
    transition: background 0.14s, color 0.14s;
  }
  header button:hover, header .headerLink:hover {
    background: var(--control-bg); color: var(--fg);
  }
  header button:active, header .headerLink:active { transform: scale(0.96); }
  header button svg { display: block; }
  header button:focus-visible, header .headerLink:focus-visible {
    outline: 2px solid var(--accent); outline-offset: 1px;
  }
  /* THE PHONE'S DOOR TO SETTINGS (library.html `#settingsDoor`): nothing on
     the Mac, where Settings is a window off Cmd-, and the menu (30 Aug, the
     gear left the header). THE PHONE WHOLE block at the foot of this file
     shows it under the phone's media query, and nowhere else. */
  #settingsDoor { display: none; font-size: 1.15rem; }
  /* The way out of the Library (Osca, 28 Aug: "Open library means I pretty much
     have to close the app"). Top-left in BOTH layouts, before the sources toggle. */

  /* ---- the shelf = the library rail. SHARED CONTRACT with studio/studio.html:
     every rule from .rail to .railrow-status below must stay byte-identical
     in both files (reader/tests/test_library_rail.py). The bench may only
     ADD (.railmore); this page adds only .ingest-* to a row still being
     ingested (further down). Spec: PROMPTS/library-sidebar.md.
     Moved here from reader.html on 28 Aug (READER_FIRST.md: the reader's
     sidebar is this book's chapters; the Library page holds the shelf). ---- */
  .rail {
    width:236px; flex:0 0 236px; border-right:1px solid var(--border);
    background:var(--panel-bg); overflow:auto; padding:14px 0;
  }
  .rail.hidden { display:none; }
  .rail h2 {
    margin:0 0 8px; padding:0 16px; font-size:0.72rem; font-weight:600;
    letter-spacing:0.09em; text-transform:uppercase; color:var(--fg-dim);
  }
  .railrow {
    display:block; width:100%; text-align:left; font:inherit; color:inherit;
    background:transparent; border:none; border-left:2px solid transparent;
    padding:9px 16px; cursor:pointer;
  }
  .railrow:hover { background:var(--control-bg); }
  .railrow[aria-selected="true"] { border-left-color:var(--accent); background:var(--control-bg); }
  .railrow-title { display:block; font-weight:600; font-size:0.9rem;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .railrow-by { display:block; color:var(--fg-dim); font-size:0.8rem;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .railrow-status { display:block; margin-top:3px; color:var(--fg-dim); font-size:0.76rem; }
  .railrow-status b { color:var(--ok); font-weight:600; }

  /* library-page layout: the shelf is the main pane, so it grows */
  .shell { flex: 1 1 auto; display: flex; min-height: 0; }
  #shelf.rail { container: shelf / inline-size;
    flex: 1 1 auto; width: auto; border-right: none; background: var(--bg);
    padding: 22px 34px; }
  /* a device row ("On this device", the phone's own books) is still a row:
     it is not in the picture, and nothing here turns it into a tile */
  #shelf .railrow:not(.tile) { max-width: 560px; }
  .empty { padding: 16px; color: var(--fg-dim); font-size: 0.9rem; }

  /* THE NO-PACK LINE (settings.js::tileLineHTML, G-LANG). Its own class since
     13 Sep -- it wore `.railrow-by` and so was placed in the byline's cell at
     the 1 ¶ rung and drawn on top of it. Same type, its own row. */
  .railrow-lang { display:block; color:var(--fg-dim); font-size:0.8rem;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .railrow-lang[hidden] { display:none; }
  /* ...MOVED OUT OF THE SHARED BLOCK, 14 Sep, and not one byte of it changed.
     It was written between `.railrow-by` and `.railrow-status`, which is
     INSIDE `.rail` .. `.railrow-status b` -- studio's half of a contract this
     page may not restyle, asserted byte for byte by
     `library/tests/test_library_rail.py::test_rail_css_byte_identical`. That
     test has been red since 13 Sep for this reason alone (measured: the whole
     of the drift is these six lines). Like the verdict below it, the no-pack
     line is drawn on TILES and on nothing else -- `tileLineHTML` is called
     from `tileHTML`, never from `railRows` -- so this is where it always
     belonged. Same rule, same order relative to everything that reads it. */

  /* THE VERDICT, ON THE TILE (P1; drawn 14 Sep). One word, the parser's own,
     and only where the book is not clean -- `library.html::verdictTileHTML`
     says why `clean` is silent here and spoken on the book page.

     ITS OWN ROW, LEARNED FROM THE LINE ABOVE IT. G-SMALL, 13 Sep: the no-pack
     line wore `.railrow-by` and was placed in the byline's cell at the 1 ¶
     rung and drawn on top of it. So this is its own class, and -- see the
     rungs below -- it is not drawn at 1 ¶ at all, where the pane says the same
     thing in full: no new grid row anywhere, and every rectangle G-SMALL
     measured stands unchanged.

     AND IT IS OUTSIDE THE SHARED BLOCK. `.rail` .. `.railrow-status b` is
     studio's half of a contract this page may not restyle
     (`library/tests/test_library_rail.py::test_rail_css_byte_identical`, which
     caught this rule sitting inside it). The word is drawn on TILES and on
     nothing else -- `verdictTileHTML` is called from `tileHTML`, never from
     `railRows` -- so it belongs here, with every other rule that turns a row
     into a tile. */
  .railrow-verdict { display:block; font: 600 0.66rem/1.4 var(--ui-font);
    letter-spacing:0.14em; color:var(--fg-dim);
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .railrow-verdict[data-verdict="damaged"],
  .railrow-verdict[data-verdict="unrecoverable"] { color:var(--warn, #b4622a); }

  /* ---- THE SHELF IS COVER TILES (PROMPTS/mock/library.html, 1 Sep).
     The shared `.rail` block above is studio's half of a contract this page
     may not restyle (reader/tests/test_library_rail.py), so every rule that
     turns a row into a tile is HERE, after it, and scoped to `.tile`. The
     row keeps its `.railrow` class and its `data-slug`, which is why
     selection, Enter, double-click, drag, the ⋯ menu and the ingest veil all
     go on working without a single handler being re-keyed. ---- */
  /* EVERY TILE THE SAME SIZE, EVERY PART ON THE SAME LINE (Osca, 1 Sep:
     "and all aligned, the same size"). The tile is a three-row grid --
     120px cover, a 34px title block that is exactly two 17px lines whether
     the title fills them or not, and an 18px line under it -- so a
     one-word title and a title that wraps twice put their covers, their
     captions and their grey lines on the same three baselines across the
     whole shelf. It is the mock's rule, row for row; the old `margin-bottom`
     under the cover is now the grid's `row-gap`. */
  /* ---- FOUR BOOKS ACROSS (Osca, 5 Sep: "go there get the new library ...
     that's the new library"). `reader/design/library.html`'s grid, value for
     value: four equal columns in a column 1120px wide or 88vw, whichever is
     smaller, its 2.6rem/1.6rem gaps and its two step-downs. A tile is no
     longer 120px of anything -- it is a quarter of that column, and the cover
     fills it, which is roughly three times the size it was.

     THREE THINGS OF THE BENCH'S DID NOT COME ACROSS, each for a reason
     already written down on this page:
       * its `.head` -- "THE LIBRARY", the count, a dark button. This page has
         no <h1> ON PURPOSE (see library.html): in the app the strip is the
         title bar and says Library. The bench needs a head because the bench
         has no strip. The theme lives in Settings.
       * its `<img src="../../books/<slug>/cover.jpg">`. THE TYPESET COVER IS
         THE COVER here (Osca, 1 Sep, and fifteen lines of why, above). The
         bench draws the jacket because the bench has no cover engine; this
         page has one hundred covers. §6 asks whether that should change.
       * its `location.href = "shell.html#book=" + slug`. Opening a book is a
         new crown, and a reader tab that navigates itself anywhere near the
         Library is closed by `track_url`. No click handler is touched here.

     THE THREE BASELINES SURVIVE (Osca, 1 Sep: "and all aligned, the same
     size"). The bench's tile is a flex column, so a two-line title there
     pushes its grey line down and the shelf loses its rows. This one stays a
     grid: the cover row is `auto` because `aspect-ratio` now drives it, the
     title block is exactly two lines of its own leading whether it fills them
     or not, and the line under it is one. Same rule as before, expressed in
     the type's own units instead of in 34px and 18px. ---- */
  /* TWO OF THE BENCH'S NUMBERS ARE VIEWPORT NUMBERS, AND THIS PAGE HAS
     SIDEBARS. `88vw` and `@media (max-width: 920px)` are right on a page whose
     only column is the window, which is what the bench is. Here Sources opens
     on the left and the phone shelf on the right, either or both, and the
     shelf is what is left over -- so a shelf 700px wide inside a 1400px window
     would take 88% of the WINDOW and hang off its own column, and would still
     be drawing four across because the WINDOW is over 920. Both are therefore
     measured against the shelf itself: `92%` of its column, and `@container`
     in place of `@media`. Same proportions, same two step-downs, asked of the
     right box.

     BIGGER, AND FURTHER APART (Osca, 5 Sep: "make the books slight bigger,
     more spaced", then "I want the books to be spaced more horizontally").
     1120 wide with 1.6rem/2.6rem gaps became 1410 with 4.8rem across and
     3.2rem down -- 77px between two books against 51px between two rows,
     half again as much air sideways as down. He has asked four times and
     every one of them was sideways ("more spaced", "spaced more
     horizontally", "slighter further apart left to right ... centred as they
     are but spread out a bit more sideways", "just a tad more"), so the row
     gap has not moved since the first and the column gap is the only dial
     that has been turned since. 1.6 -> 2.2 -> 3.4 -> 4.2 -> 4.8rem.

     THE FIXED COLUMN THAT CARRIED THOSE GAPS IS GONE (Osca, 6 Sep: "the
     bench wins"). It said: "the column is 1410 ... two columns want 665 and
     one wants 300", because more air between four books in a fixed box can
     only come out of the books. The bench answered that by making the BOOK
     the fixed thing and letting the shelf grow into the margin, which is the
     block below; the gaps and the step-downs above are unchanged by it, and
     294 is now `--bookw` rather than a quarter of 1410. */
  /* ---- THE BOOK IS THE FIXED THING AND THE SHELF GROWS AROUND IT
     (design/reader/library.html, 6 Sep; Osca that day: "the bench wins").

     Osca on the bench: "I increase spacing here, the books just get smaller --
     I don't want them smaller. There are huge margins in the page, I want to
     move them further apart. The margins are just made up, and it won't let
     me space past them."

     He is right, and this is the bench's fix, ported. The grid was
     `width:min(1410px,92%)` with `1fr` columns: a fixed box, so every rem of
     gap came OUT OF THE COVERS and the shelf could never cross the margin.
     Now the columns are `--bookw` wide and the grid is `fit-content`, centred,
     free to widen up to the shelf. Opening the gap moves the books apart and
     takes the room from the empty page either side, which is what the margin
     was. The covers only give way when the shelf actually reaches the page
     edge -- `minmax(0, --bookw)` lets the tracks shrink then, as before.

     4 AT ITS DEFAULT IS STILL THE APP TO THE PIXEL, which is the whole reason
     the model could change at all: 4 x 294 + 3 x 4.8rem = 1406, which is what
     `min(1410px, 92%)` with `1fr` columns was giving. The hand-set numbers of
     the 5th -- 294px and the step-downs at 1290 and 620 -- are unmoved; they
     are now written as `--bookw` and `--cols` instead of as a width and a
     repeat count. The GAP is not the 5th's any more: 3.2rem down and 4.8rem
     across became 4.30 and 6.45 on 10 Sep, Osca's own dial (THE SPACING,
     below), and this base rule carries 4's pair so the first paint is it.

     AND IT STAYS CENTRED ("centred as they are but spread out a bit more
     sideways"). `margin: 4px auto 0` is what centres it and it has not been
     touched; the column grows about its own middle. */
  /* THE TWO NUMBERS SIT ON THE GRID, NOT ON THE SHELF, and that is not a
     style choice -- IT IS THE ONLY PLACE THEY WORK. `#shelf` IS the container
     (`container: shelf / inline-size`, above), and an element cannot answer a
     container query about itself: a `@container shelf (...)` rule that names
     `#shelf` matches nothing, ever. The bench draws them on its `.sheet`,
     which is its container, so the bench's own two step-downs are dead and
     nobody could see it because the numbers only differ below 1290. Measured
     here at 1280 before the move: `--cols` computed 4 with the query in force
     (CLAUDE.md, "where prose and code disagree, the code is right" -- the
     rule the bench MEANT is the one ported, and library/STATUS.md §6 asks the
     bench for the same one-line fix). `.shelfgrid` is a descendant of the
     container, so on it they work. */
  .shelfgrid { --cols: 4; --bookw: 294px;
    display: grid;
    grid-template-columns: repeat(var(--cols), minmax(0, var(--bookw)));
    gap: 4.30rem 6.45rem; width: fit-content; max-width: 100%; margin: 4px auto 0;
    padding-bottom: 3.5rem; }
  /* A BOOK IS THE SAME SIZE AT EVERY STEP. Dropping from four columns to two
     must not make each book BIGGER as the window gets smaller -- that reads as
     the shelf lurching rather than reflowing. `--bookw` is the fixed thing and
     only `--cols` steps, which is that rule expressed once instead of three
     times. The two widths are the app's own, 1290 and 620, and they belong to
     the app's own zoom and to no other: at 8 up the whole point is that the
     columns do not step, and at 2 and 1 the tile is a different shape. */
  @container shelf (max-width: 1290px) { #shelf[data-zoom="4"] .shelfgrid { --cols: 2; } }
  @container shelf (max-width: 620px) { #shelf[data-zoom="4"] .shelfgrid { --cols: 1; } }

  /* ---- THE SORT BAR, AND THE BANDS IT SORTS (design/reader/bench-library.html).
     Osca: "I need a small drop down in library, to change how the books are
     ordered. Alphabetically by title, and by author and most recent. Put newly
     added in its own section too."

     THE BENCH'S THREE DIALS ARE NOT HERE, and that is the foot's own rule
     said again: the bench sets where the bar stands across the page, how big
     it is and whether it is at the top of the shelf or down in the foot, and
     the app SHIPS the numbers that came out of it -- at the top of the
     shelf, 12px, and AT THE RIGHT since 10 Sep (Osca's dial: the log's
     `.sortbar{ justify-content:flex-end; }`; it was left). A dial in the app
     would be a second place to set one number, which is the drift the loop
     exists to end. */
  .sortbar { --sortsize: 12px;
    display: flex; align-items: center; gap: 9px;
    width: 94%; margin: 0 auto; padding: 1.1rem 0 0.2rem;
    justify-content: flex-end; }
  .sortbar label { font: 600 calc(var(--sortsize) - 1px)/1 var(--ui-font);
    letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-dim); }
  .sortbar select { font: var(--sortsize)/1.2 var(--ui-font); color: var(--fg);
    background: var(--control-bg); border: 1px solid var(--border);
    border-radius: var(--r-pill); padding: 0.42em 0.8em; cursor: pointer;
    -webkit-appearance: none; appearance: none;
    background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
      linear-gradient(135deg, currentColor 50%, transparent 50%);
    background-position: calc(100% - 14px) 55%, calc(100% - 9px) 55%;
    background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
    padding-right: 2em; }
  .sortbar select:hover { border-color: var(--fg); }

  /* A BAND IS A HEADING AND A SHELF, sized to the shelf so the heading lines
     up with the first book's left edge instead of floating in the margin.
     `.shelfgrid` is `width: fit-content` centred on `margin: 4px auto`, so a
     band has to be the same or the two are centred on different boxes. */
  .band { width: fit-content; max-width: 100%; margin: 0 auto; }
  .band[hidden] { display: none; }
  .bandhead { margin: 0; padding: 1.4rem 0 0; font: 600 12px/1.4 var(--ui-font);
    letter-spacing: 0.13em; text-transform: uppercase; color: var(--fg-dim); }

  /* ====================================================================
     THE LADDER -- SIX ZOOMS, AND ONE DIMENSION: how much of the pane a
     single book is given. Out to in (design/reader/bench-library.html):

      16   the whole library on one screen. Covers only, and they do not step
           down until the shelf is under 1100px, where they go to eight rather
           than shrink past reading (Osca, 6 Sep: "a new view in library on 16
           book rows, larger than 8 -- keep all others, just add a 16 row view")
       8   the whole shelf at a glance. Covers only -- the caption IS the
           cover, and the title/byline rows are taken away rather than shrunk,
           because a two-line title at 150px is a smear
       4   THE APP, and the one the shelf opens on
       2   two books, the covers given the room the caption was using
     1 ¶   ONE BOOK AS ITS OWN PAGE (10 Sep): the cover, and beside it the
           book's text -- title, byline, the facts, the blurb, its languages,
           its voices, its chapters. `1t` in the attribute, the bench's name
       1   one jacket, and the shelf snaps book to book

     One attribute on `#shelf` and every difference between the six is CSS off
     it. Nothing is re-rendered when it changes -- the tiles are already whole
     -- so zooming keeps the book under the cursor rather than jumping to the
     top of the shelf. THERE ARE NO BUTTONS FOR IT (Osca, 10 Sep): the foot
     that carried 16/8/4/2/1 is gone, and the pinch -- cmd/ctrl + wheel, which
     is what a trackpad pinch arrives as -- is the whole of the control.

     1 ¶ LANDED WHEN ITS FACTS BECAME REAL. It stayed off the page while the
     bench could only DRAW its pane (grey bars, labelled outlines) -- a picture
     of a promise. The book record now carries description, publisher, date,
     identifier and subjects (`c0ad5df`, 8 Sep), so the pane is filled from
     `GET /book` and the outlines are gone: a real fact replaces the drawing,
     and a fact the record does not have is not drawn at all. 2 ¶ is still the
     bench's alone.

     The gaps are Osca's own dial, one block, after the rungs: THE SPACING,
     below. ==================================================== */

  /* ---------------- 16 UP: sixteen across, covers only ------------------- */
  /* 75px: 16 x 75 + 15 x 0.90rem (14.4) = 1416, the same width 8's row takes
     (8 x 150 + 7 x 1.90rem = 1413) and 4's (1406) -- so the shelf keeps its
     edge and the covers halve, which is the whole of the step out. The row
     gap keeps 8's own ratio to the column gap (1.70 / 1.90 = .895), so the
     pane's character survives the step. Under 1100px sixteen 75px covers
     would have to shrink past reading, so the row goes to eight -- the same
     width 8 up steps to four at -- and to four under 620. */
  #shelf[data-zoom="16"] .shelfgrid { --cols: 16; --bookw: 75px; }
  #shelf[data-zoom="16"] .railrow.tile { grid-template-rows: auto; row-gap: 0; }
  #shelf[data-zoom="16"] .railrow.tile:hover,
  #shelf[data-zoom="16"] .railrow.tile:focus-visible { transform: translateY(-2px); }
  /* a 75px board with an 11px corner reads as a lozenge, not a book */
  #shelf[data-zoom="16"] .tilecov { border-radius: 3px; }
  #shelf[data-zoom="16"] .railrow.tile .railrow-title,
  #shelf[data-zoom="16"] .railrow.tile .railrow-lang,
  #shelf[data-zoom="16"] .railrow.tile .railrow-verdict,
  #shelf[data-zoom="16"] .railrow.tile .railrow-by { display: none; }
  @container shelf (max-width: 1100px) { #shelf[data-zoom="16"] .shelfgrid { --cols: 8; } }
  @container shelf (max-width: 620px) { #shelf[data-zoom="16"] .shelfgrid { --cols: 4; } }

  /* ---------------- 8 UP: covers only, and they do not step down --------- */
  #shelf[data-zoom="8"] .shelfgrid { --cols: 8; --bookw: 150px; }
  #shelf[data-zoom="8"] .railrow.tile { grid-template-rows: auto; row-gap: 0.45rem; }
  #shelf[data-zoom="8"] .railrow.tile .railrow-title,
  #shelf[data-zoom="8"] .railrow.tile .railrow-lang,
  #shelf[data-zoom="8"] .railrow.tile .railrow-verdict,
  #shelf[data-zoom="8"] .railrow.tile .railrow-by { display: none; }
  @container shelf (max-width: 1100px) { #shelf[data-zoom="8"] .shelfgrid { --cols: 4; } }

  /* ---------------- 2 UP: the covers alone, and they take the caption's room */
  /* 446, not half of 1410: the cover has to land clearly between 4's tile and
     1's jacket, or the step in does not read as one */
  #shelf[data-zoom="2"] .shelfgrid { --cols: 2; --bookw: 446px; }
  #shelf[data-zoom="2"] .railrow.tile { grid-template-rows: auto; row-gap: 0; }
  #shelf[data-zoom="2"] .railrow.tile .railrow-title,
  #shelf[data-zoom="2"] .railrow.tile .railrow-lang,
  #shelf[data-zoom="2"] .railrow.tile .railrow-verdict,
  #shelf[data-zoom="2"] .railrow.tile .railrow-by { display: none; }

  /* ---------------- 1 UP ¶ : one book is the page, its text beside it ---- */
  /* The bench's 1 ¶, value for value: 1180px for the whole tile, the cover in
     42% of it at min(72vh, 700px) and pushed to the gutter, the text in the
     rest, and the sheet snapping book to book like 1. Under 820px of shelf
     the two stack.

     EVERY TILE RULE HERE IS FENCED OFF THE PHONE (`html:not([data-phone])`).
     The zoom is remembered in the library record and rides to the phone, and
     the phone's block (THE PHONE, WHOLE, below) restores the app's own tile
     for whichever step it remembers -- but it names the six things the OTHER
     rungs change, and this rung changes more (columns, placement, the pane).
     Fencing them here is the same guarantee without editing the phone's
     contract: on the phone a remembered 1 ¶ is the phone's one size. */
  #shelf[data-zoom="1t"] { scroll-snap-type: y proximity; }
  #shelf[data-zoom="1t"] .shelfgrid { --cols: 1; --bookw: 1180px; }
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile {
    scroll-snap-align: center; min-height: calc(100vh - 3.4rem);
    grid-template-columns: minmax(0, 42%) minmax(0, 1fr);
    grid-template-rows: auto auto auto minmax(0, 1fr);
    column-gap: 3.4rem; row-gap: 0.75rem; align-content: center;
    padding: 2.6rem 0; }
  /* nothing lifts when it is the page */
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile:hover,
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile:focus-visible { transform: none; }
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .tilecov {
    grid-column: 1; grid-row: 1 / -1; align-self: center;
    width: auto; height: min(72vh, 700px); margin-left: auto; }
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .railrow-title {
    grid-column: 2; grid-row: 1; align-self: end;
    font-size: 2.5rem; line-height: 1.12; height: auto; -webkit-line-clamp: 4; }
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .railrow-by {
    grid-column: 2; grid-row: 2; font-size: 0.78rem; }
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .railrow-lang {
    grid-column: 2; grid-row: 3; font-size: 0.78rem; }
  /* not at 1 ¶: the pane below says the word AND the sentence (`.tile-verdictslot`),
     and the same word twice on one screen is furniture. No grid row is taken,
     so the rungs G-SMALL measured are untouched. */
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .railrow-verdict,
  html[data-phone] #shelf[data-zoom="1t"] .railrow.tile .railrow-verdict { display: none; }
  html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .tile-pane {
    grid-column: 2; grid-row: 4; display: grid;
    gap: 1.15rem; align-content: start; padding-top: 1.1rem; }
  @container shelf (max-width: 820px) {
    html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile {
      grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto auto auto;
      justify-items: start; }
    html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .tilecov {
      grid-column: 1; grid-row: 1; height: 38vh; margin: 0; }
    html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .railrow-title {
      grid-column: 1; grid-row: 2; font-size: 1.8rem; }
    html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .railrow-by { grid-column: 1; grid-row: 3; }
    html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .railrow-lang { grid-column: 1; grid-row: 4; }
    html:not([data-phone]) #shelf[data-zoom="1t"] .railrow.tile .tile-pane { grid-column: 1; grid-row: 5; width: 100%; }
    html:not([data-phone]) #shelf[data-zoom="1t"] .tile-slotrow { grid-template-columns: minmax(0, 1fr); } }

  /* THE PANE'S PARTS. The pane is in every tile and is shown at 1 ¶ alone --
     `display: none` makes no grid row, so every other rung is laid out
     exactly as it was before it existed. Its parts are the bench's: the facts
     line (`.meta` there), and the named regions (`.slot`) -- WITHOUT the
     dashed outline, because the outline was the drawing and what stands here
     now is the thing it was drawing. The name stays, as the region's head. */
  .tile-pane { display: none; }
  .tile-facts { margin: 0; font: 0.71rem/1.4 var(--ui-font); letter-spacing: 0.03em;
    text-transform: uppercase; color: var(--fg-dim);
    display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
  .tile-facts i { font-style: normal; opacity: 0.4; }
  .tile-details { font: 0.78rem/1.45 var(--ui-font); color: var(--fg-dim);
    display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .tile-details i { font-style: normal; opacity: 0.4; }
  .tile-slot { display: flex; flex-direction: column; gap: 0.5rem; min-width: 0; }
  .tile-slot > b { font: 600 0.6rem/1 var(--ui-font); letter-spacing: 0.13em;
    text-transform: uppercase; color: var(--fg-dim); }
  .tile-slot > span { display: block; font: 0.92rem/1.5 var(--ui-font); color: var(--fg);
    white-space: normal; overflow-wrap: anywhere; }
  .tile-slot.tile-blurb > span { display: -webkit-box; -webkit-box-orient: vertical;
    -webkit-line-clamp: 8; overflow: hidden; }
  .tile-slotrow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.9rem; }
  /* THE VERDICT ON THE BOOK PAGE. A slot like every other one, so it inherits
     the pane's own type and gap and adds no layout of its own; `clean` is the
     head alone (there is no sentence for a clean book -- `facts.verdict_line`
     returns "" for one) and takes the dim ink the other heads take. */
  .tile-verdictslot[data-verdict="damaged"] > b,
  .tile-verdictslot[data-verdict="unusual"] > b,
  .tile-verdictslot[data-verdict="unrecoverable"] > b { color: var(--warn, #b4622a); }
  .tile-chapters > span > span { display: block; white-space: nowrap; overflow: hidden;
    text-overflow: ellipsis; }
  .tile-chapters .tile-more { color: var(--fg-dim); }
  /* the read button, the shape the bench drew for it -- and a real door:
     one press opens the book, which is what a tile's double-click does */
  .tile-read { justify-self: start; align-self: start; cursor: pointer;
    font: 600 0.74rem/1 var(--ui-font); letter-spacing: 0.06em; text-transform: uppercase;
    color: var(--panel-bg, #fff); background: var(--fg); border-radius: var(--r-pill);
    padding: 0.85rem 1.7rem; }

  /* ---------------- 1 UP: the jacket, and nothing else -------------------- */
  /* one column, so the gap is the space between one book and the next as the
     sheet snaps down them, and the ratio is 1 */
  #shelf[data-zoom="1"] { scroll-snap-type: y proximity; }
  #shelf[data-zoom="1"] .shelfgrid { --cols: 1; --bookw: 820px; }
  #shelf[data-zoom="1"] .railrow.tile {
    scroll-snap-align: center; min-height: calc(100vh - 3.4rem);
    grid-template-rows: auto; row-gap: 0;
    justify-items: center; align-content: center; padding: 2rem 0; }
  #shelf[data-zoom="1"] .railrow.tile:hover,
  #shelf[data-zoom="1"] .railrow.tile:focus-visible { transform: none; }
  #shelf[data-zoom="1"] .railrow.tile .tilecov { width: auto; height: min(88vh, 1000px); }
  #shelf[data-zoom="1"] .railrow.tile .railrow-title,
  #shelf[data-zoom="1"] .railrow.tile .railrow-lang,
  #shelf[data-zoom="1"] .railrow.tile .railrow-by { display: none; }

  /* ---- THE SPACING, AS OSCA DIALLED IT (10 Sep, G-LIB2). The spacing log of
     design/reader/bench-library.html, pasted as it prints: every pane in
     ladder order, `gap: <row> <column>`, the bench's `.sheet[data-zoom]`
     written as this page's `#shelf[data-zoom]` (the bench's selector would
     match nothing here). What he moved: 4, from the 5th's 3.2/4.8 to
     4.30/6.45. 16's 0.81 is the log's own rounding of 0.90 x .895, where this
     sheet had written 0.80. 2 ¶ has no rung on this page, so its line matches
     nothing today; it is his block, whole, and it is ready for the day it does.
     The sort bar's two numbers from the same block are on `.sortbar` above:
     `justify-content: flex-end`, `--sortsize: 12px`. */
  #shelf[data-zoom="16"] .shelfgrid { gap: 0.81rem 0.90rem; }
  #shelf[data-zoom="8"]  .shelfgrid { gap: 1.70rem 1.90rem; }
  #shelf[data-zoom="4"]  .shelfgrid { gap: 4.30rem 6.45rem; }
  #shelf[data-zoom="2t"] .shelfgrid { gap: 3.40rem 4.00rem; }
  #shelf[data-zoom="2"]  .shelfgrid { gap: 2.80rem 3.20rem; }
  #shelf[data-zoom="1t"] .shelfgrid { gap: 0.00rem 0.00rem; }
  #shelf[data-zoom="1"]  .shelfgrid { gap: 0.00rem 0.00rem; }

  /* THE FOOT IS GONE (Osca, 10 Sep: "Remove the 16/8/4/2/1 zoom-ladder
     buttons from the page -- pinch already drives data-zoom and stays"). It
     stood here as `.shelffoot` and `.zoom`, the bench's five buttons under
     the shelf. The bench keeps its own controls; the page has the pinch. */
  #shelf .railrow.tile {
    width: auto; max-width: none; padding: 0;
    display: grid; grid-template-rows: auto 2.5em 1.4em; row-gap: 0.75rem;
    border-left: 0; background: transparent; text-align: left;
    transition: transform 0.15s ease;
  }
  #shelf .railrow.tile:hover { background: transparent; }
  /* the lift is the bench's, and it is the only thing hover moves */
  #shelf .railrow.tile:hover, #shelf .railrow.tile:focus-visible {
    transform: translateY(-3px); }
  /* AN OPEN BOOK'S ✕ (Osca, 10 Sep, PROMPTS/phone-chrome.md: *"In the
     library, each open book carries a small ✕ at its top-right -- press it to
     close."*). library.html draws it only on a book that is open (OPEN BOOKS
     COME FIRST), so it is a mark of that state as much as a control, and on a
     book that is not open there is no node at all. Small to the eye and a
     thumb's worth to the finger: the disc is 22px, the element that takes the
     press is 36px, both in the cover's corner (the tile is `.railrow`,
     `position: relative`). Inverted ink, so it reads on a pale typeset cover
     and on a photograph alike, in both themes. */
  #shelf .railrow.tile .tile-close {
    position: absolute; top: 0; right: 0; z-index: 2;
    width: 36px; height: 36px; display: grid; place-items: center;
    font: 700 11px/1 var(--ui-font); color: var(--bg);
    cursor: pointer; -webkit-tap-highlight-color: transparent; }
  #shelf .railrow.tile .tile-close::before {
    content: ""; position: absolute; z-index: -1; width: 22px; height: 22px;
    border-radius: 50%; background: var(--fg); opacity: 0.78;
    box-shadow: 0 1px 3px rgba(0,0,0,0.25); }
  #shelf .railrow.tile .tile-close:hover::before { opacity: 1; }
  #shelf .railrow.tile .tile-close:focus-visible { outline: none; }
  #shelf .railrow.tile .tile-close:focus-visible::before {
    opacity: 1; outline: 2px solid var(--fg); outline-offset: 2px; }
  /* THE COVER IS NOW FLUID, AND THAT IS THE WHOLE OF WHAT CHANGED IN IT.
     Every number below this was tuned against an 88x120 box; at a quarter of
     1120px the box is ~262px wide, so an 11.5px title would be a caption on a
     poster. So the box is `width:100%` with its ASPECT KEPT AT 88/120 -- not
     the bench's 2/3 -- and every length inside it is now `cqw`, a percentage
     of the cover's own width. Keeping the aspect is the point: a uniform
     scale means all twenty pairings by five layouts arrive at three times the
     size with their proportions untouched, and not one of the hundred had to
     be looked at. Each `cqw` below is its old px x 100/88; nothing was
     redesigned, and if the grid changes width again nothing here needs to
     change at all. */
  .tilecov {
    display: block; position: relative; overflow: hidden;
    width: 100%; height: auto; aspect-ratio: 88 / 120; margin: 0;
    container: cover / inline-size;
    border-radius: 6px; border: 1px solid var(--border);
    box-shadow: 0 1px 2px rgba(0,0,0,0.06);
    background: var(--paper); color: var(--ink);
    font-family: Charter, Georgia, "Times New Roman", serif;
    transition: box-shadow 0.15s ease, border-color 0.15s ease;
  }
  /* THE JACKET IS MOUNTED, NOT CROPPED (Osca, 5 Sep: "the covers are fine,
     but they're not centred or framed -- they're not STRAIGHT and don't fit
     the border of the cover, some images will be cropped, so they need a
     small frame to sit straight, in the middle of the book").

     It was `object-fit: cover`, which fills the tile by cutting whatever does
     not fit -- and almost nothing fits, because `parser/cover.py` writes
     whatever shape the source carried and the tile is a fixed 88:120. So the
     Shakespeare portrait lost its head and its feet, the Singapore photograph
     lost its edges, and every one of them sat a little off its own centre.

     `contain` instead: the whole jacket, straight, in the middle, with the
     mount showing around it. The inset is in `cqw` like everything else in
     this box, so the frame is a frame at 88px and still a frame at 294.

     AND THE TYPE UNDERNEATH IS TAKEN AWAY when a jacket is there. Under
     `cover` it could not be seen, so it did not matter; under `contain` it
     would show in the margins, and a book's title set in type peeking out
     from behind its own jacket is the one thing this arrangement must not
     do. The rule that does it is two declarations below. */
  .covimg { position: absolute; inset: 4.545cqw; display: block;
    width: auto; height: auto; max-width: calc(100% - 9.09cqw);
    max-height: calc(100% - 9.09cqw); margin: auto;
    object-fit: contain; }
  /* `:has`, not a class set from the image's `load`. A class needs an event,
     and an image that is already in the cache when the listener attaches
     never fires one -- so the type would show through the frame exactly on
     the second visit and never on the first, which is the worst kind of bug
     to own. `:has(.covimg)` asks the only question that matters, is there a
     picture in this box, and it answers it again the moment the error
     listener REMOVES a picture that would not load. Nothing to keep in sync. */
  .tilecov:has(.covimg) { background: var(--panel-bg, #fff); }
  .tilecov:has(.covimg) .ini, .tilecov:has(.covimg) .t,
  .tilecov:has(.covimg) .a, .tilecov:has(.covimg) .fw { display: none; }

  /* ---- THE COVER IS THE BOOK'S OWN PICTURE WHEN IT HAS ONE, AND TYPESET
     WHEN IT DOES NOT (Osca, 5 Sep: "pictures from the books were used ... Use
     them -- for every book without one, generate one"). This reverses the
     1 Sep rule and restores the contract `parser/cover.py` states in its own
     first paragraph. The typeset box below is drawn for EVERY book and the
     JPEG is laid over it by `.covimg`, so the generated cover is not a
     fallback that has to be selected -- it is simply what is there, finished,
     underneath. Nothing asks whether a book has a cover; the image either
     arrives or removes itself.

     TWO DIALS, HASHED SEPARATELY (Osca, 1 Sep, on the first build of this:
     "mostly beige; use multiple colours; 4-5 different layouts so books look
     different, as they are; and all aligned, the same size").
       * TWENTY PAPER/INK PAIRINGS, `.p1`-`.p20`, pairing = hash(title) mod 20;
       * FIVE LAYOUTS, `.l1`-`.l5`, layout = hash(author + title) mod 5.
     Twenty by five is a hundred covers, and the two hashes are taken over
     different strings so two books that collide on one rarely collide on the
     other. **A tile never takes the pairing of the tile before it on the same
     shelf** -- it steps to the next one -- which is a render-order rule and
     therefore not in `coverPairing()`; see `assignPairings()`.

     The rules below are the mock's, declaration for declaration. `.verse` is
     gone: the layout is the hash's, not the book's form. `form` is still read
     off book.json (it is one key of a fetch that has to happen anyway for
     `first_words`) and is no longer what centres a title. ---- */
  .tilecov .t { position: absolute; left: 10.227cqw; right: 10.227cqw; font-size: 13.068cqw; line-height: 1.2; }
  .tilecov .a { position: absolute; left: 10.227cqw; right: 10.227cqw; font-size: 9.091cqw;
    letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.75;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .tilecov .fw { position: absolute; left: 10.227cqw; right: 10.227cqw; bottom: 9.091cqw; font-size: 6.818cqw;
    opacity: 0.45; font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .tilecov .ini { position: absolute; font-weight: 400; line-height: 1;
    color: var(--ink); opacity: 0.09; letter-spacing: -0.04em; }
  /* L1 -- ranged left, initial bleeding top-right */
  .l1 .t { top: 15.909cqw; } .l1 .a { bottom: 25cqw; }
  .l1 .ini { right: -6.818cqw; top: -15.909cqw; font-size: 104.545cqw; }
  /* L2 -- centred, initial behind the middle */
  .l2 .t, .l2 .a { text-align: center; } .l2 .t { top: 25cqw; } .l2 .a { bottom: 25cqw; }
  .l2 .ini { left: 50%; transform: translateX(-50%); top: 34.091cqw; font-size: 79.545cqw; }
  /* L3 -- a band: the title reversed out across the middle, no initial */
  .l3 .t { left: 0; right: 0; top: 38.636cqw; text-align: center; padding: 9.091cqw 10.227cqw;
    background: var(--ink); color: var(--paper); font-size: 11.932cqw; }
  .l3 .a { bottom: 15.909cqw; text-align: center; }
  .l3 .ini { display: none; }
  /* L4 -- the initial IS the cover: huge, solid, the title small at the foot */
  .l4 .ini { left: 50%; transform: translateX(-50%); top: 6.818cqw; font-size: 134.091cqw; opacity: 0.85; }
  .l4 .t { bottom: 27.273cqw; font-size: 10.795cqw; text-align: center; }
  .l4 .a { bottom: 12.5cqw; text-align: center; font-size: 7.386cqw; }
  .l4 .fw { display: none; }
  /* L5 -- spine: the title runs up the left edge, the author small top-right */
  .l5 .t { left: 13.636cqw; top: auto; bottom: 11.364cqw; width: 113.636cqw; transform: rotate(-90deg);
    transform-origin: left bottom; white-space: nowrap; overflow: hidden;
    text-overflow: ellipsis; font-size: 13.636cqw; }
  .l5 .a { left: auto; right: 10.227cqw; top: 11.364cqw; width: 54.545cqw; text-align: right; font-size: 7.955cqw; }
  .l5 .ini { right: -4.545cqw; bottom: -20.455cqw; font-size: 90.909cqw; }
  .l5 .fw { left: 38.636cqw; }
  /* THE ONE THING THE FIVE LAYOUTS DO NOT SAY, and the plainest answer to it
     (mock/README rule 5). The mock draws each layout with a title that fits;
     none of its rules says what a title does when it does not. Unclamped,
     `The Collected Poems of Wallace Stevens` wraps to three lines inside L4
     and lands across the initial, and `The Singapore Story: Memoirs of Lee
     Kuan Yew, Vol. 1` runs down into the author in L1 -- which is a broken
     tile, not a variant. So each layout's title is clamped to the lines its
     own arrangement leaves room for and ellipsises past that. Nothing else is
     added and no number of the mock's is changed; §6 gives Osca these five
     declarations to paste into the picture if he wants the two to agree
     literally. (L5's title is `nowrap` in the mock already.) */
  .tilecov .t { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
  .l1 .t { -webkit-line-clamp: 4; }
  .l2 .t { -webkit-line-clamp: 4; }
  .l3 .t { -webkit-line-clamp: 3; }
  .l4 .t { -webkit-line-clamp: 2; }
  /* the twenty pairings, value for value from PROMPTS/mock/library.html */
  .p1 { --paper: #efe7d6; --ink: #2a2521; }
  .p2 { --paper: #2f3a45; --ink: #e9e4d8; }
  .p3 { --paper: #7b2d26; --ink: #f2e8dc; }
  .p4 { --paper: #4f6152; --ink: #eef0e6; }
  .p5 { --paper: #c9a44a; --ink: #2b2418; }
  .p6 { --paper: #1e1e1c; --ink: #e6e1d6; }
  .p7 { --paper: #243f6b; --ink: #e9ecf3; }
  .p8 { --paper: #5a3557; --ink: #f1e8f0; }
  .p9 { --paper: #b84a3a; --ink: #fbeee6; }
  .p10 { --paper: #2f5d5a; --ink: #e6f0ee; }
  .p11 { --paper: #8a6f3c; --ink: #f6efe0; }
  .p12 { --paper: #d9c7a3; --ink: #2b2418; }
  .p13 { --paper: #3b3b6b; --ink: #ecebf5; }
  .p14 { --paper: #6b7b3b; --ink: #f1f3e4; }
  .p15 { --paper: #a3564e; --ink: #f7ebe8; }
  .p16 { --paper: #1f3a2e; --ink: #e4efe8; }
  .p17 { --paper: #e3d5c0; --ink: #3a2f24; }
  .p18 { --paper: #4a4a4a; --ink: #f0ede8; }
  .p19 { --paper: #c76b3b; --ink: #fdf1e6; }
  .p20 { --paper: #6a5a8c; --ink: #f1eef7; }
  /* the ring is the selection mark on a tile -- `.railrow[aria-selected]`'s
     own background would only tint the caption */
  #shelf .railrow.tile[aria-selected="true"] { background: transparent; }
  #shelf .railrow.tile[aria-selected="true"] .tilecov { box-shadow: 0 0 0 2px var(--fg); }
  #shelf .railrow.tile:hover .tilecov { box-shadow: 0 12px 28px rgba(0,0,0,0.13);
    border-color: var(--fg); }
  #shelf .railrow.tile[aria-selected="true"]:hover .tilecov { box-shadow: 0 0 0 2px var(--fg); }
  /* the caption's type is the mock's, to the tenth of a pixel: `.tile b` is
     12.5px/1.3 and `.tile small` is 11.5px there, and the rem sizes this page
     had (0.8rem = 12.8px, 0.74rem = 11.84px) were a third of a pixel out on
     both. Written in px for the same reason the mock writes px -- a tile is a
     fixed 120px object and its type does not scale with the root. */
  .tile .railrow-title { white-space: normal; margin: 0;
    font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
    font-size: 1rem; font-weight: 600; line-height: 1.25; height: 2.5em;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
    overflow: hidden; }
  .tile .railrow-by, .tile .railrow-lang { font-family: var(--ui-font); font-size: 0.71rem;
    letter-spacing: 0.03em; text-transform: uppercase; line-height: 1.4; margin: 0;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .tile .railrow-lang { color: var(--fg-dim); }
  /* the order mark rides the cover, not the caption */
  .railrow.tile[data-sel-order]::after { top: 6px; right: 6px; }
  .railrow.tile[data-sel-order="1"]:not(.selFirst)::after { top: 10px; right: 10px; }
  /* a book still being ingested: faded, and its line reads `parsing…` */
  #shelf .railrow.tile.loading { overflow: visible; opacity: 1; }
  #shelf .railrow.tile.loading .tilecov { opacity: 0.35; }
  #shelf .railrow.tile.loading .railrow-title,
  #shelf .railrow.tile.loading .railrow-by { color: var(--fg-dim); opacity: 1; }
  /* the veil (and its progress edge) is the COVER's, so the caption stays
     readable while the bar crosses the picture */
  /* the veil covers the cover, and the cover is no longer 88x120 -- so it is
     given the same box the same way, by the aspect it now has */
  .tile .ingest-veil { inset: auto; position: absolute; top: 0; left: 0;
    width: 100%; aspect-ratio: 88 / 120; height: auto; border-radius: 6px; }
  .tile .ingest-step { display: none; }
  .tile .ingest-veil.failed { position: absolute; top: 0; aspect-ratio: 88 / 120;
    height: auto; padding: 6px; }
  .tile .ingest-fail-title, .tile .ingest-fail-line { font-size: 0.66rem; }
  .tile .ingest-retry { font-size: 0.7rem; min-height: 22px; padding: 1px 8px; }

  /* ---- SOURCES IS THE FOLDER YOU OPENED (PROMPTS/reader-library-mock.md §3).
     The card at the top is the folder itself -- its name, where it is, and a
     ⌄ to open another; under it the files in it, and the only thing a file
     carries besides its name is a dot: grey = already on the shelf, red =
     being parsed, none = not yet. The kind and the megabytes are gone; they
     were two columns of noise beside a list of titles, and the picture draws
     neither. ---- */
  .srcbar { width: 250px; flex: 0 0 250px; border-right: 1px solid var(--border); background: var(--panel-bg);
    overflow: auto; padding: 14px 0; display: flex; flex-direction: column; }
  .srcbar h2 { margin: 0 0 8px; padding: 0 16px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.09em;
    text-transform: uppercase; color: var(--fg-dim); }
  .srcfolder { margin: 0 12px 12px; padding: 9px 10px; background: var(--bg);
    border: 1px solid var(--border); border-radius: var(--r-1);
    display: flex; align-items: center; gap: 9px; width: calc(100% - 24px);
    font: inherit; color: inherit; text-align: left; cursor: pointer; }
  .srcfolder:hover { background: var(--control-bg); }
  .srcfolder:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  .srcfolder .ic { flex: 0 0 18px; width: 18px; height: 14px; border: 1.5px solid var(--fg-dim);
    border-radius: 3px; position: relative; }
  .srcfolder .ic::before { content: ""; position: absolute; left: -1.5px; top: -5px; width: 8px; height: 5px;
    border: 1.5px solid var(--fg-dim); border-bottom: 0; border-radius: 2px 2px 0 0; background: var(--bg); }
  .srcfolder .nm { min-width: 0; }
  .srcfolder b { display: block; font-weight: 600; font-size: 0.78rem; }
  .srcfolder small { display: block; color: var(--fg-dim); font-size: 0.7rem;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .srcfolder .ch { margin-left: auto; color: var(--fg-dim); font-size: 0.7rem; }
  .src { display: flex; align-items: center; gap: 8px; padding: 5px 16px; cursor: default; user-select: none;
    color: var(--fg-dim); }
  .src:hover { background: var(--control-bg); }
  .src[aria-selected="true"] { background: var(--control-bg); color: var(--fg); }
  /* the picture's three states, and the dot is the whole of the difference */
  .src[data-state="grey"] { color: var(--fg-dim); opacity: 0.72; }
  .src[data-state="filled"] { color: var(--fg-dim); opacity: 1; }
  .src[data-state="busy"] { color: var(--fg); opacity: 1; }
  .src-dot { flex: none; margin-left: auto; width: 5px; height: 5px; border-radius: 50%; background: transparent; }
  .src[data-state="filled"] .src-dot { background: var(--fg-dim); opacity: 0.7; }
  .src[data-state="busy"] .src-dot { background: var(--accent); }
  .src-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.82rem; }
  .src-empty, .src-note { padding: 8px 16px; font-size: 0.8rem; color: var(--fg-dim); }
  .src-note { padding-top: 14px; }
  #shelf.dropping { outline: 2px dashed var(--accent); outline-offset: -8px; }
  .shelfcount { color: var(--fg-dim); font-weight: 400; }

  /* ---- selection, named shelves, merged entries (PROMPTS/library-shelf.md).
     Every rule here is AFTER the shared .rail block above and adds nothing to
     it: reader/tests/test_library_rail.py holds `.rail` .. `.railrow-status b`
     byte-identical with studio/studio.html, and a selection mark is the
     reader's own idea. `.railrow[aria-selected="true"]` is the shared rule and
     is reused as-is -- what is new is only the ORDER MARK, because two
     identically outlined rows cannot say which of them is first. ---- */
  .railrow { position: relative; }
  .railrow[data-sel-order]::after {
    content: attr(data-sel-order);
    position: absolute; top: 8px; right: 10px;
    min-width: 17px; height: 17px; padding: 0 4px; border-radius: var(--r-pill);
    background: var(--accent); color: var(--panel-bg);
    font-size: 0.66rem; font-weight: 700; line-height: 17px; text-align: center;
  }
  /* one selected row needs no number -- there is no order to tell */
  .railrow[data-sel-order="1"]:not(.selFirst)::after { content: ""; min-width: 8px; padding: 0; height: 8px; top: 12px; }
  .railrow.merged .railrow-title::before {
    content: "pair"; display: inline-block; margin-right: 6px; padding: 0 5px;
    border: 1px solid var(--border); border-radius: var(--r-0);
    font-size: 0.62rem; font-weight: 600; letter-spacing: 0.06em;
    text-transform: uppercase; color: var(--fg-dim); vertical-align: 1px;
  }
  .railrow.merged.stale .railrow-status { color: var(--bad); }
  .shelfgrp { display: block; }
  .shelfhead { display: flex; align-items: center; gap: 2px; padding: 0 8px 0 4px; }
  .shelftoggle {
    flex: 1 1 auto; min-width: 0; display: flex; align-items: baseline; gap: 7px;
    padding: 7px 8px; border: 0; border-radius: var(--r-1); background: transparent;
    font: inherit; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.09em;
    text-transform: uppercase; color: var(--fg-dim); cursor: pointer; text-align: left;
  }
  .shelftoggle:hover { background: var(--control-bg); color: var(--fg); }
  .shelfcaret { font-size: 0.85rem; line-height: 1; letter-spacing: 0;
    opacity: 0; transition: opacity 0.12s; }
  .shelfhead:hover .shelfcaret, .shelftoggle:focus-visible .shelfcaret,
  .shelfgrp[data-collapsed="1"] .shelfcaret { opacity: 1; }
  .shelfname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .shelfmenu {
    flex: 0 0 auto; min-width: 28px; min-height: 28px; border: 0; border-radius: var(--r-1);
    background: transparent; color: var(--fg-dim); font: inherit; cursor: pointer;
  }
  .shelfmenu { opacity: 0; transition: opacity 0.12s; }
  .shelfhead:hover .shelfmenu, .shelfmenu:focus-visible { opacity: 1; }
  .shelfmenu:hover { background: var(--control-bg); color: var(--fg); }
  .shelfempty { padding: 4px 16px 10px; font-size: 0.78rem; color: var(--fg-dim); }
  .shelfgrp.dropping { background: var(--control-bg); }
  .libline { padding: 6px 16px 10px; font-size: 0.74rem; color: var(--fg-dim); }

  /* ---- right-click menu ---- */
  .menuhead { padding: 6px 14px 4px; font-size: 0.68rem; font-weight: 600;
    letter-spacing: 0.09em; text-transform: uppercase; color: var(--fg-dim); }
  .menu button.off { opacity: 0.55; cursor: default; }
  .menu button .menuwhy { display: block; font-size: 0.72rem; color: var(--fg-dim);
    white-space: normal; margin-top: 1px; }
  .menu { position: fixed; z-index: 40; min-width: 200px; padding: 6px 0; border-radius: var(--r-1);
    background: var(--panel-bg); border: 1px solid var(--border); box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
  .menu[hidden] { display: none; }
  .menu button { display: block; width: 100%; text-align: left; font: inherit; font-size: 0.9rem; color: var(--fg);
    background: transparent; border: none; padding: 7px 14px; cursor: pointer; }
  .menu button:hover, .menu button:focus { background: var(--control-bg); outline: none; }
  .menu button.danger { color: var(--bad); }
  .menu hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
  /* paintContext() hides what needs studio by setting `hidden`, and three of
     those elements have a `display` of their own further down (the sources
     sheet is `display: flex` under the phone breakpoint, the toggle
     `inline-flex`, the scrim `block`) -- a rule beats the `hidden`
     attribute's UA style, so it has to be said here, once, for all three. */
  .srcbar[hidden], #sourcesToggle[hidden], #sourcesScrim[hidden] { display: none !important; }
  /* `,` collapses the Sources rail on a desktop. Gated on a width so it can
     never collide with the phone rules below, where the same rail is a sheet
     and `,` opens and closes THAT instead -- one key, the shape of the answer
     decided by the screen rather than by a second binding. */
  @media (min-width: 641px) and (min-height: 501px) {
    .srcbar.srcCollapsed { display: none; }
  }

  /* ---- the phone shelf: the Library's right sidebar (READER_FIRST.md
     "Osca's app decisions -- 30 Aug"). Its own block, after the shared .rail
     one, which it never touches. ---- */
  .phonebar { width: 250px; flex: 0 0 250px; border-left: 1px solid var(--border); background: var(--panel-bg);
    overflow: auto; padding: 14px 0 18px; display: flex; flex-direction: column; }
  .phonebar[hidden] { display: none; }
  .phonebar.dropping { outline: 2px dashed var(--accent); outline-offset: -6px; }
  .phonehead { padding: 0 16px 2px; }
  .phonehead.onphone { margin-top: 22px; }
  .phonehead h2 { margin: 0; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.09em;
    text-transform: uppercase; color: var(--fg-dim); }
  /* Sync is the dark button in the picture, and the line beside it says what
     pressing it will do -- how many books, and where they land. */
  .phonesync { display: flex; align-items: center; gap: 10px; margin: 10px 16px 18px; }
  #syncPhone { font: inherit; font-size: 0.78rem; font-weight: 600; min-height: 30px; padding: 4px 12px;
    border-radius: var(--r-1); border: 0; background: var(--fg); color: var(--bg); cursor: pointer; flex: none; }
  #syncPhone:hover:not([disabled]) { opacity: 0.88; }
  #syncPhone[disabled] { opacity: 0.35; cursor: default; }
  .phonesync small { color: var(--fg-dim); font-size: 0.72rem; min-width: 0;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .phonedrop { margin: 0 12px; padding: 16px 10px; border: 1px dashed var(--border); border-radius: var(--r-1);
    text-align: center; color: var(--fg-dim); font-size: 0.74rem; }
  .phonebar.dropping .phonedrop { border-color: var(--accent); color: var(--fg); }
  .phonerow { position: relative; display: grid; grid-template-columns: 20px 1fr; column-gap: 10px;
    align-items: center; padding: 6px 12px 6px 16px; }
  .phonerow:hover { background: var(--control-bg); }
  .phonerow.busy { opacity: 0.6; }
  /* the row's cover: the same typeset cover as the tile, at a thumbnail's
     size -- one function draws both, so a book is never two different books */
  .phonerow .tilecov { grid-column: 1; grid-row: 1 / span 2; width: 20px; height: 27px;
    margin: 0; border: 0; border-radius: 2px; box-shadow: none; aspect-ratio: auto; }
  /* no frame on a 20px thumbnail -- there is no room for one, and at that
     size a crop reads as a texture rather than as a mistake */
  .phonerow .covimg { inset: 0; max-width: 100%; max-height: 100%;
    width: 100%; height: 100%; object-fit: cover; }
  .phonerow .tilecov .t, .phonerow .tilecov .a, .phonerow .tilecov .fw { display: none; }
  .phonerow .tilecov .ini { position: absolute; inset: 0; right: auto; left: 0; top: 0;
    display: grid; place-items: center; font-size: 11px; opacity: 0.85; transform: none; }
  .phonerow-title { grid-column: 2; font-size: 0.8rem; font-weight: 600;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .phonerow-meta { grid-column: 2; grid-row: 2; font-size: 0.7rem; color: var(--fg-dim); }
  /* Remove, and Reveal, are the picture's silence about hover (mock README
     rule 5): the page at rest is exactly the picture, and the two things a
     person can do to a row are under the pointer when they want them. */
  .phonebtn { position: absolute; top: 50%; transform: translateY(-50%);
    font: inherit; font-size: 0.7rem; min-height: 24px; padding: 1px 8px; border-radius: var(--r-1);
    border: 1px solid var(--border); background: var(--bg); color: var(--fg); cursor: pointer;
    opacity: 0; transition: opacity 0.12s; }
  .phonerow:hover .phonebtn, .phonebtn:focus-visible { opacity: 1; }
  .phonebtn:hover { background: var(--control-bg); }
  .phonebtn.reveal { right: 40px; }
  .phonebtn.x { right: 10px; min-width: 26px; padding: 1px 6px; border-color: transparent; color: var(--fg-dim); }
  .phonebtn.x:hover { border-color: var(--border); color: var(--bad); }
  .phone-empty { padding: 4px 16px 8px; font-size: 0.8rem; color: var(--fg-dim); }
  .phoneline { margin-top: auto; padding: 12px 16px 0; font-size: 0.72rem; color: var(--fg-dim); }

  #note { padding: 6px 16px; font-size: 0.82rem; color: var(--fg-dim); flex: 0 0 auto; }
  #note:empty { display: none; }

  /* ---- the panels (a re-parse, and this book's parses). A sibling of
     `.shell` for `.searchbar`'s reason: `#shelf` is rewritten wholesale on
     every poll, and a report a person is halfway through reading must not be
     blown away by an unrelated repaint. */
  .panel { flex: 0 0 auto; margin: 0 16px 8px; padding: 8px 12px 10px;
    border: 1px solid var(--border); border-radius: var(--r-1); font-size: 0.82rem; }
  .panel[hidden] { display: none; }
  .panel .pnl-head { display: flex; align-items: baseline; gap: 8px; }
  .panel .pnl-head b { font-size: 0.9rem; }
  .panel .pnl-head.warn .pnl-sub { color: var(--warn, #a86b1d); }
  .panel .pnl-head.bad .pnl-sub { color: var(--bad, #b3382c); }
  .panel .pnl-sub { color: var(--fg-dim); }
  .panel .pnl-x { margin-left: auto; font: inherit; font-size: 1rem; line-height: 1;
    padding: 0 4px; border: none; background: none; color: var(--fg-dim); cursor: pointer; }
  .panel .pnl-x:hover { color: var(--fg); }
  /* INDETERMINATE ON PURPOSE: studio counts no steps for a re-parse yet, so
     the bar travels and returns rather than filling to a made-up fraction. */
  .panel .pnl-bar { height: 3px; margin: 8px 0 6px; border-radius: 2px;
    background: var(--border); overflow: hidden; position: relative; }
  .panel .pnl-bar.pulse::after { content: ""; position: absolute; inset: 0 auto 0 0;
    width: 30%; border-radius: 2px; background: var(--accent);
    animation: pnl-sweep 1.4s ease-in-out infinite; }
  @keyframes pnl-sweep { 0% { left: -30%; } 100% { left: 100%; } }
  .panel .pnl-why { margin: 4px 0 0; color: var(--fg-dim); font-size: 0.78rem; }
  .panel .pnl-line { margin: 4px 0 0; font-size: 0.75rem; color: var(--fg-dim);
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .panel .pnl-sum { margin: 6px 0 0; padding: 0; list-style: none; }
  .panel .pnl-sum li { padding: 2px 0; border-top: 1px solid var(--border); font-size: 0.8rem; }
  .panel .pnl-sum li:first-child { border-top: none; }
  /* the parses: two headed sections, never one list -- the live parse of a
     book must never read as one of that book's own kept copies. */
  .panel .prs-head { margin-top: 8px; font-size: 0.72rem; letter-spacing: 0.04em;
    text-transform: uppercase; color: var(--fg-dim); }
  .panel .prs-live li { color: var(--fg); }
  .panel .prs li { display: flex; align-items: baseline; gap: 8px; }
  .panel .prs-facts { color: var(--fg-dim); font-size: 0.78rem; }
  .panel .prs-stamp { margin-left: auto; color: var(--fg-dim); font-size: 0.72rem; }

  /* ---- THE SEARCH BAR IS NOT THIS PAGE'S ANY MORE (Osca, 5 Sep: *"the bar
     IN THE TOP -- SHOULD be the search bar ... inherit that FUNCTION +
     searching library"*). Fifty lines went with it: two boxes, a button, a
     dropdown, its rows, its notes and its file picker. The field is the
     window's (`desktop/src/tabs.html`) and the list is drawn by
     `reader/askbar.js`, which carries its own styles on every page rather
     than each page carrying a copy. What this page kept is every ANSWER --
     the search, the staging, the parse, the shelf -- and none of the
     drawing. */

  /* ---- ingest progress (PROMPTS/reader-ingest-progress.md, READER_FIRST.md
     "Library"): a book on the shelf from the moment its ingest starts. The
     whole entry is greyed over by a veil; --ingest (0..100%) is how far the
     loading bar has come, and the veil is clipped away left -> right by
     exactly that much, so the un-greyed region IS the progress. The step
     label sits under the title. Failed: the veil turns red-ish, shows the
     last log line and a Retry. All of it is appended AFTER the shared base
     row (test_library_rail.py compares only `const base`). ---- */
  #shelf .railrow.loading { position: relative; overflow: hidden; cursor: default; --ingest: 0%; }
  #shelf .railrow.loading:hover { background: transparent; }
  #shelf .railrow.loading .railrow-title, #shelf .railrow.loading .railrow-by { opacity: 0.95; }
  .ingest-step { display: block; margin-top: 3px; font-size: 0.76rem; color: var(--accent); font-weight: 600;
    letter-spacing: 0.02em; }
  .ingest-veil { position: absolute; inset: 0; pointer-events: none;
    background: color-mix(in srgb, var(--fg-dim) 22%, var(--panel-bg)); opacity: 0.82;
    backdrop-filter: grayscale(1); -webkit-backdrop-filter: grayscale(1);
    clip-path: inset(0 0 0 var(--ingest)); transition: clip-path 0.9s linear; }
  /* the bar's leading edge: a 3-px accent line at the boundary */
  .ingest-veil::before { content: ""; position: absolute; top: 0; bottom: 0; left: var(--ingest); width: 3px;
    background: var(--accent); opacity: 0.9; transition: left 0.9s linear; }
  .ingest-veil.pulse::before { animation: ingestpulse 1.6s ease-in-out infinite; }
  @keyframes ingestpulse { 0%, 100% { opacity: 0.9; } 50% { opacity: 0.35; } }
  /* failed: the veil stops moving, tints, and becomes interactive (Retry) */
  #shelf .railrow.loading.failed { --ingest: 0%; }
  .ingest-veil.failed { pointer-events: auto; opacity: 1; clip-path: none; backdrop-filter: none; -webkit-backdrop-filter: none;
    background: color-mix(in srgb, var(--bad) 14%, var(--panel-bg)); display: flex; flex-direction: column;
    justify-content: center; gap: 6px; padding: 8px 12px; transition: none; }
  .ingest-veil.failed::before { display: none; }
  .ingest-fail-title { font-size: 0.8rem; font-weight: 600; color: var(--bad); }
  .ingest-fail-line { font-size: 0.74rem; color: var(--fg-dim); font-family: ui-monospace, Menlo, monospace;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .ingest-retry { align-self: flex-start; font: inherit; font-size: 0.8rem; min-height: 28px; padding: 3px 12px;
    border-radius: var(--r-1); border: 1px solid var(--bad); color: var(--bad); background: var(--panel-bg); cursor: pointer; }
  .ingest-retry:hover { background: var(--control-bg); }
  /* the sidebar while it runs: no spinner. The shelf shows the bar, and the
     file's own dot turns red the moment studio picks it up -- which is what
     the picture draws and all it draws. */

  /* ---- on this device (PROMPTS/phone-book-import.md): the books imported
     from a bundle .zip and held in the Cache API. These ARE shelf rows -- the
     same .railrow, in their own section above the bench's shelf -- so nothing
     in the shared .rail block (byte-identical with studio's,
     test_library_rail.py) is touched; what is added is the import control, a
     per-book remove, and the line that says how much room is left. ---- */
  .devicehead { grid-column: 1 / -1; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
    padding: 0 16px 8px; }
  .devicehead h2 { margin: 0; padding: 0; flex: 1 1 auto; font-size: 0.72rem; font-weight: 600;
    letter-spacing: 0.09em; text-transform: uppercase; color: var(--fg-dim); }
  #importBtn { font: inherit; font-size: 0.85rem; min-height: 36px; padding: 4px 14px; border-radius: var(--r-1);
    border: 1px solid var(--border); background: var(--panel-bg); color: var(--fg); cursor: pointer; }
  #importBtn:hover { background: var(--control-bg); }
  #importBtn[disabled] { opacity: 0.5; cursor: default; }
  .storageline { grid-column: 1 / -1; padding: 0 16px 10px; font-size: 0.76rem; color: var(--fg-dim); }
  .railrow.device { position: relative; }
  .rmbook { position: absolute; top: 6px; right: 6px; min-width: 30px; min-height: 30px; border-radius: var(--r-1);
    border: 1px solid transparent; background: transparent; color: var(--fg-dim); font: inherit;
    font-size: 0.95rem; line-height: 1; cursor: pointer; }
  .rmbook:hover, .rmbook:focus { border-color: var(--border); background: var(--bg); color: var(--bad); outline: none; }
  .importerr { grid-column: 1 / -1; margin: 0 16px 10px; padding: 8px 12px; border-radius: var(--r-1);
    border: 1px solid var(--bad); color: var(--bad); font-size: 0.8rem; background: var(--panel-bg); }
  .importerr b { display: block; margin-bottom: 3px; }
  .importerr ul { margin: 4px 0 0; padding-left: 18px; }

  /* ---- the phone (PROMPTS/reader-mobile.md step 3): the shelf as a grid of
     covers, the sources sidebar as a sheet, the menu as a bottom sheet from a
     long-press. Same 640 px breakpoint as reader.html; nothing above this
     line changes, and the shared .rail block stays byte-identical with
     studio's (test_library_rail.py) -- these are overrides under the media
     query, not edits to it. ---- */
  :root {
    --safe-top: env(safe-area-inset-top, 0px);
    --safe-bottom: env(safe-area-inset-bottom, 0px);
    --safe-left: env(safe-area-inset-left, 0px);
    --safe-right: env(safe-area-inset-right, 0px);
  }
  body { height: 100dvh; }
  #sourcesToggle { display: none; }
  #sourcesScrim { display: none; }
  @media (max-width: 640px), (max-height: 500px) {
    header {
      padding: calc(8px + var(--safe-top)) calc(10px + var(--safe-right)) 8px calc(10px + var(--safe-left));
      gap: 6px;
    }
    header h1 { font-size: 1rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    header button, header .headerLink { min-height: 44px; min-width: 44px; border-radius: var(--r-2); }
    #sourcesToggle { display: inline-flex; }

    /* the sources sidebar as a sheet from the left */
    .srcbar, .srcbar.hidden {
      display: flex; position: fixed; z-index: 50; top: 0; bottom: 0; left: 0;
      width: min(85vw, 320px); flex: none;
      padding-top: calc(14px + var(--safe-top)); padding-bottom: var(--safe-bottom); padding-left: var(--safe-left);
      box-shadow: 0 0 24px rgba(0,0,0,0.25);
      transform: translateX(-105%); transition: transform 0.2s ease-out;
    }
    .srcbar.sheet-open { transform: translateX(0); }
    .src { min-height: 44px; padding: 8px 16px; }
    .src-name { font-size: 1rem; }
    .srcfolder { min-height: 44px; }
    #sourcesScrim {
      display: block; position: fixed; inset: 0; z-index: 49;
      background: rgba(0,0,0,0.35); opacity: 0; pointer-events: none; transition: opacity 0.2s ease-out;
    }
    #sourcesScrim.on { opacity: 1; pointer-events: auto; }

    /* the shelf's padding on the phone. It WAS "the shelf as a grid of
       covers" -- `display: grid; repeat(auto-fill, minmax(150px, 1fr))` on
       #shelf.rail itself -- until 6 Sep, when the grid became .shelfgrid
       INSIDE it and the two fought: one auto-fill cell holding the whole
       shelf, one column of 176px tiles at 393. The PHONE FIT block at the
       foot of this file says `#shelf.rail { display: block }` and owns the
       columns (--ph-cols), the tile (--ph-tile) and the gap (--ph-gap);
       the dead grid lines are gone from here. */
    #shelf.rail {
      align-content: start;
      padding: 12px calc(14px + var(--safe-right)) calc(20px + var(--safe-bottom)) calc(14px + var(--safe-left));
      -webkit-overflow-scrolling: touch;
    }
    #shelf.rail h2, #shelf .empty { padding: 0 2px; }
    #shelf .railrow {
      max-width: none; aspect-ratio: 2 / 3; min-height: 44px;
      display: flex; flex-direction: column; justify-content: flex-end; gap: 2px;
      padding: 12px 12px 12px 14px;
      border: 1px solid var(--border); border-left: 6px solid var(--accent); border-radius: var(--r-2);
      background: var(--panel-bg); box-shadow: 0 2px 8px rgba(0,0,0,0.08);
      -webkit-touch-callout: none; user-select: none; -webkit-user-select: none;
    }
    #shelf .railrow-title { white-space: normal; font-size: 1rem; line-height: 1.25;
      display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
    #shelf .railrow-by, #shelf .railrow-lang { white-space: normal; }
    #shelf .railrow-status { margin-top: 6px; }

    /* the long-press menu as a bottom sheet */
    .menu {
      left: calc(12px + var(--safe-left)) !important; right: calc(12px + var(--safe-right)) !important;
      top: auto !important; bottom: calc(12px + var(--safe-bottom)) !important;
      min-width: 0; padding: 8px; border-radius: var(--r-3); box-shadow: 0 10px 30px rgba(0,0,0,0.3);
    }
    .menu button { min-height: 48px; font-size: 1.05rem; text-align: center; border-radius: var(--r-2); }
    .menu hr { margin: 6px 0; }
    #note { padding-left: calc(16px + var(--safe-left)); padding-right: calc(16px + var(--safe-right)); }

    /* Retry is a thumb target */
    .ingest-step { margin-top: 6px; font-size: 0.86rem; }
    .ingest-veil.failed { justify-content: flex-end; padding: 12px; }
    .ingest-fail-line { white-space: normal; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
    .ingest-retry { min-height: 44px; min-width: 44px; padding: 8px 18px; font-size: 1rem; border-radius: var(--r-2); }

    /* the phone shelf is the bench's own control -- there is no bench on a
       phone, and paintContext() has already hidden it; this is the belt to
       that braces, for a narrow window on the Mac */
    .phonebar { display: none; }

    /* on this device: thumb-sized import and remove */
    .devicehead, .storageline { padding-left: calc(2px + var(--safe-left)); padding-right: calc(2px + var(--safe-right)); }
    #importBtn { min-height: 44px; font-size: 1rem; padding: 8px 18px; border-radius: var(--r-2); }
    .rmbook { min-width: 44px; min-height: 44px; font-size: 1.1rem; top: 4px; right: 4px;
      border-color: var(--border); background: var(--bg); }
    .importerr { margin-left: 2px; margin-right: 2px; font-size: 0.85rem; }
  }

/* ==================================================================
   PHONE FIT -- from design/phone/phone.html, 6 Sep 2026 (Copy CSS). THE PHONE'S NUMBERS.
   One value per property, one property per rule, no other layout code.
   The values are the same in library.css, settings.css and bar/askbar.css
   (the bar is on the reader too, where neither page sheet is loaded); the
   bench is the source and Copy CSS is how they change. `(max-height: 500px)`
   is in the query because the phone rotated is 852 wide.
   ================================================================== */
@media (max-width: 600px), (max-height: 500px) {
  :root {
    --ph-margin: 14px;
    --ph-gap: 14px;
    --ph-cols: 2;
    --ph-tile: 150px;
    --ph-type: 16px;
    --ph-head: 56px;
    --ph-card: 560px;
    --ph-tap: 44px;
  }
  html { font-size: var(--ph-type); }
  /* the header is FIXED at --ph-head under the safe area (Osca, 6 Sep), and
     its padding is what leaves --ph-tap for the buttons inside it */
  header { height: calc(var(--ph-head) + var(--safe-top, 0px)); }
  header { padding-top: calc(6px + var(--safe-top, 0px)); }
  header { padding-bottom: 6px; }
  header button, header .headerLink { min-width: var(--ph-tap); }
  header button, header .headerLink { min-height: var(--ph-tap); }
  /* the 30 Aug block above made #shelf.rail the grid; since 6 Sep the grid
     is .shelfgrid inside it -- the shelf is the column again */
  #shelf.rail { display: block; }
  #shelf.rail { padding-left: calc(var(--ph-margin) + var(--safe-left, 0px)); }
  #shelf.rail { padding-right: calc(var(--ph-margin) + var(--safe-right, 0px)); }
  /* `[data-zoom]`, not `[data-zoom="4"]` (6 Sep, THE PHONE WHOLE): the phone
     has ONE size, so whichever zoom the shelf remembers, the grid is this */
  #shelf[data-zoom] .shelfgrid { --cols: var(--ph-cols); }
  #shelf[data-zoom] .shelfgrid { --bookw: var(--ph-tile); }
  #shelf[data-zoom] .shelfgrid { gap: var(--ph-gap); }
  #shelf .railrow { min-height: var(--ph-tap); }
  #importBtn, .ingest-retry, .menu button { min-height: var(--ph-tap); }
  /* tap targets under 44 raised (Osca, 6 Sep): the shelf heading's toggle
     (29 tall). The two `.zoom button` lines that stood here went with the bar
     itself (THE PHONE WHOLE, below: "no shelf-zoom bar") */
  .shelftoggle { min-height: var(--ph-tap); }
  /* the first-run gate is settings.js's card, drawn over THIS page; its own
     rules are injected after this sheet, so these weigh one `body` more.
     (`max-width: var(--ph-card)` and the two paddings stood here until 6 Sep:
     the card FILLS the screen now -- THE PHONE WHOLE, below.) */
  body .ttstv-settings .kag-btn { min-height: var(--ph-tap); }
}
@media (max-height: 500px) and (orientation: landscape) {
  /* 3, not 4 (Osca, 6 Sep: "one size, 2 across, 3 in landscape") */
  :root { --ph-cols: 3; }
}

/* ==================================================================
   THE PHONE, WHOLE (Osca, 6 Sep). design/phone/library.html and
   design/phone/firstrun.html are the contract; this block is their text,
   byte for byte (test-phone.mjs checks). It decides what is NOT on the
   phone as much as what is. Every rule keys on `html[data-phone]` -- the
   flag library.html's <head> sets for a coarse pointer with no hover, or
   for ?phone -- INSIDE the media query: the Mac's smallest window matches a
   width test alone, and the Mac is untouched.
   ================================================================== */
@media (max-width: 600px), (max-height: 500px) {
  /* NO SHELF-ZOOM BAR: one size, 2 across, 3 in landscape; zoom by pinch,
     later. The ladder's four steps are the Mac's. */
  html[data-phone] .shelffoot { display: none; }
  /* THE DOOR TO SETTINGS. On the Mac Settings is a window (Cmd-, and the
     app's own menu) and the header has no gear; on the phone there is no
     Cmd-, and no window -- Settings is a page -- so the header carries one
     link to it. A link, so a press is its own proof. */
  html[data-phone] #settingsDoor { display: inline-flex; }
  /* NO "ON THIS MAC" NOTES ANYWHERE: the two lines that name the Mac's
     mirror are bench-only already; on the phone they are gone by rule too */
  html[data-phone] .libline, html[data-phone] .phoneline { display: none; }
  /* THE FIRST-RUN CARD FILLS THE SCREEN: the gate is the screen under the
     safe areas, the sheet is the whole of it, the three choices are rows a
     thumb can find -- the name above, the button the full width below */
  html[data-phone] body .ttstv-settings.fr-gate { padding: var(--safe-top, 0px) 0 var(--safe-bottom, 0px); align-items: stretch; }
  html[data-phone] body .ttstv-settings.fr-gate .fr-sheet { max-width: none; min-height: 100%; display: flex; flex-direction: column; justify-content: center; box-sizing: border-box; }
  html[data-phone] body .ttstv-settings.fr-gate .fr-sheet { padding-left: calc(var(--ph-margin) + var(--safe-left, 0px)); }
  html[data-phone] body .ttstv-settings.fr-gate .fr-sheet { padding-right: calc(var(--ph-margin) + var(--safe-right, 0px)); }
  html[data-phone] body .ttstv-settings.fr-gate .fr-sheet .set-row { flex-direction: column; align-items: stretch; gap: 8px; }
  html[data-phone] body .ttstv-settings.fr-gate .fr-sheet .set-c { width: 100%; justify-content: stretch; }
  html[data-phone] body .ttstv-settings.fr-gate .fr-sheet .kag-btn { width: 100%; min-height: var(--ph-tap); }
  /* ONE SIZE MEANS THE TILE, NOT ONLY THE GRID. PHONE FIT pins the GRID for
     whichever zoom the shelf remembers (`#shelf[data-zoom] .shelfgrid`), but
     the ladder sizes the TILE and the JACKET too and those stayed keyed on the
     step: `[data-zoom="1"]` gives the jacket `height: min(88vh, 1000px)` and
     the tile a 100vh minimum. A shelf that remembered 1 therefore drew a
     546x820 tile inside a 150px column on the iPhone 17 simulator (7 Sep) --
     one book across, clipped both sides, and no zoom bar on the phone to get
     out of it. So the tile is the app's own at every remembered step. */
  html[data-phone] #shelf[data-zoom] { scroll-snap-type: none; }
  html[data-phone] #shelf[data-zoom] .railrow.tile { min-height: 0; padding: 0; justify-items: normal; align-content: normal; }
  html[data-phone] #shelf[data-zoom] .railrow.tile { grid-template-rows: auto 2.5em 1.4em; row-gap: 0.75rem; }
  html[data-phone] #shelf[data-zoom] .railrow.tile .tilecov { width: 100%; height: auto; }
  html[data-phone] #shelf[data-zoom] .railrow.tile .railrow-title { display: block; }
  html[data-phone] #shelf[data-zoom] .railrow.tile .railrow-by { display: block; }
}

/* ==================================================================
   THE PHONE'S SHELF, ROUND TWO (G-COVERS, 11 Sep 2026). After THE PHONE,
   WHOLE on purpose: that block is design/phone/library.html's contract,
   byte for byte (test-phone.mjs), and it is not this lane's to re-cut --
   so what it said that is no longer true is overruled HERE, by coming
   later at the same weight, and the re-cut is asked of the phone lane
   (library/STATUS.md §6).

   WHAT IT SAID THAT STOPPED BEING TRUE: "one size, 2 across ... zoom by
   pinch, later". Osca, 11 Sep: the phone has the Mac's rungs by pinch
   (library.html, "the zoom on the PHONE"). So the phone gets a ladder of
   its own NUMBERS on the Mac's own STEPS -- the Mac's widths cannot be
   used at 393: under its own container step-downs 8 up comes out SMALLER
   than 16 up there (68 px against 75) and 4 up bigger than 2 up (294
   against 157), because each was tuned for a window and not a hand.
   Measured at 393x852 (365 px of shelf), out to in:

     16   four across, covers only            84 x 114
      8   three across, covers only           112 x 153
      4   THE APP: two across, title + byline  150 x 205 -- PHONE FIT's
          own tile and gap, untouched, and the rung the phone opens on
          (SHELF_ZOOM_DEFAULT; the default is the round's, flagged, Osca's
          to move)
      2   two across, covers only, the caption's room given to the cover
                                              176 x 239
     1 ¶  one book and its words: the cover, the title, the byline, the
          pane -- the Mac's own narrow 1 ¶ (its @container <= 820 stack)
      1   one jacket, the width of the shelf, one screen a book

   Every rule keys on `html[data-phone]` inside the phone's media query,
   the contract's own fence: the Mac is untouched.
   ================================================================== */
@media (max-width: 600px), (max-height: 500px) {
  /* NO MAC CHROME ON THE PHONE (PROMPTS/phone-chrome.md: "Top bar: nothing.
     No top bar at all, ever."). The header's three -- the Sources toggle,
     the settings door, the theme -- go with it; the phone's one bar is at
     the foot and is the reader's (lane 5 mounts it here). Import bundle…
     goes too: the phone's books arrive by Sync. The Sources rail is already
     hidden by context.js (a host with the door is not a bench); this is the
     belt to that brace. */
  html[data-phone] header { display: none; }
  html[data-phone] #importBtn { display: none; }
  html[data-phone] .srcbar, html[data-phone] #sourcesScrim { display: none !important; }
  /* with no header, the shelf clears the status bar itself -- and so does a
     line in `#note` ("Removed …"), which the header used to push down */
  html[data-phone] #shelf.rail { padding-top: calc(12px + var(--safe-top, 0px)); }
  html[data-phone] #note:not(:empty) { padding-top: calc(6px + var(--safe-top, 0px)); }

  /* A TILE IS AS TALL AS WHAT IS IN IT. The 30 Aug phone rule gives every
     `#shelf .railrow` `aspect-ratio: 2/3` -- written for the list rows, before
     there were tiles -- and a tile took it too: at 4 up a 150x225 box, the
     cover SHRUNK to 100x137 to leave the caption room inside it (measured,
     393x852), and at 1 up a 735-tall tile forced 490 wide on a 365 shelf.
     THE PHONE, WHOLE measured the jacket it meant, 149x203 at 88/120; this
     restores it by letting the tile be its cover plus its caption. The same
     rule's `justify-content: flex-end` (a flex row's) also lands on the
     tile's GRID, where it stops the one column stretching: the cover is
     `width: 100%` of a column sized by its caption (114 px at 4 up) or by
     nothing (2 px at 16, 8, 2 and 1 up). One column, the tile's width. */
  html[data-phone] #shelf .railrow.tile { aspect-ratio: auto; justify-content: stretch;
    grid-template-columns: minmax(0, 1fr); }

  /* AN OPEN BOOK'S ✕, ON THE PHONE: a 28 px disc at the cover's top-right,
     round, no box -- the element IS the disc (the Mac's is a 36 px square
     holding a 22 px disc, for a pointer). */
  html[data-phone] #shelf .railrow.tile .tile-close {
    width: 28px; height: 28px; top: 6px; right: 6px; border-radius: 50%;
    background: var(--fg); opacity: 0.82; font-size: 12px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.25); }
  html[data-phone] #shelf .railrow.tile .tile-close::before { display: none; }

  /* THE SHELF'S GESTURES ARE THE SHELF'S (13 Sep). `pan-y` leaves the one
     finger that scrolls the shelf alone and takes the PINCH off the web view,
     which is the half `gesturestart` cannot be trusted with inside a
     WKWebView: without it iOS may claim the two fingers as a page zoom before
     a line of this page's script runs, and `data-zoom` never moves. The
     viewport meta is `initial-scale=1` with no `maximum-scale`, deliberately
     (a reader must stay zoomable); this narrows the refusal to the shelf. */
  html[data-phone] #shelf { touch-action: pan-y; }

  /* ---- 16: SIXTEEN across, covers only (Osca, 14 Sep, chat 108: "I can't
     get up to a 16-column view on the phone"). It said `--cols: 4` here until
     today, and `8` said three -- so the phone's top two rungs were a fourth
     and a third of their own names, the ladder's whole top end was missing,
     and a pinch that reached `16` drew four books. The rung is its name now.

     A FIXED `--bookw` CANNOT DRAW THIS. Sixteen 100px columns want 1600px;
     the shelf is 365 (393 less PHONE FIT's two 14px margins). So these two
     rungs alone take `1fr` and the grid takes the shelf's width -- the
     covers divide what there is, which is what "sixteen across" means on a
     393px screen and is what Photos does with the same gesture. The gaps go
     with them and are dialled where every other phone gap is (THE PHONE'S
     SPACING, at the foot of this block): 2px at 16, 6px at 8.
     `width: 100%` is not decoration -- `.shelfgrid`
     is `width: fit-content`, and `fit-content` collapses `1fr` columns to
     their min-content, which is 0. */
  html[data-phone] #shelf[data-zoom="16"] .shelfgrid { --cols: 16; --bookw: 1fr; }
  html[data-phone] #shelf[data-zoom="16"] .shelfgrid { width: 100%; }
  html[data-phone] #shelf[data-zoom="16"] .railrow.tile { grid-template-rows: auto; row-gap: 0; }
  /* a 21px board with an 11px corner is a dot, not a book (the Mac's 16 makes
     the same cut at 75px) */
  html[data-phone] #shelf[data-zoom="16"] .tilecov { border-radius: 2px; }
  html[data-phone] #shelf[data-zoom="16"] .railrow.tile .railrow-title,
  html[data-phone] #shelf[data-zoom="16"] .railrow.tile .railrow-lang,
  html[data-phone] #shelf[data-zoom="16"] .railrow.tile .railrow-by { display: none; }
  /* ---- 8: EIGHT across, covers only */
  html[data-phone] #shelf[data-zoom="8"] .shelfgrid { --cols: 8; --bookw: 1fr; }
  html[data-phone] #shelf[data-zoom="8"] .shelfgrid { width: 100%; }
  html[data-phone] #shelf[data-zoom="8"] .railrow.tile { grid-template-rows: auto; row-gap: 0; }
  html[data-phone] #shelf[data-zoom="8"] .tilecov { border-radius: 3px; }
  html[data-phone] #shelf[data-zoom="8"] .railrow.tile .railrow-title,
  html[data-phone] #shelf[data-zoom="8"] .railrow.tile .railrow-lang,
  html[data-phone] #shelf[data-zoom="8"] .railrow.tile .railrow-by { display: none; }
  /* ---- 4: PHONE FIT and THE PHONE, WHOLE, as they stand */
  /* ---- 2: two across, covers only, filling the shelf */
  html[data-phone] #shelf[data-zoom="2"] .shelfgrid { --cols: 2; --bookw: 400px; }
  html[data-phone] #shelf[data-zoom="2"] .railrow.tile { grid-template-rows: auto; row-gap: 0; }
  html[data-phone] #shelf[data-zoom="2"] .railrow.tile .railrow-title,
  html[data-phone] #shelf[data-zoom="2"] .railrow.tile .railrow-lang,
  html[data-phone] #shelf[data-zoom="2"] .railrow.tile .railrow-verdict,
  html[data-phone] #shelf[data-zoom="2"] .railrow.tile .railrow-by { display: none; }
  /* ---- 1 ¶ and 1 take the whole shelf: `100cqw` of the `shelf` container,
     a length, where `100%` would resolve against a band that is
     `width: fit-content` and collapse its one book to 44 px (measured) */
  /* ---- 1 ¶: the Mac's narrow stack -- cover, title, byline, the pane */
  html[data-phone] #shelf[data-zoom="1t"] .shelfgrid { --cols: 1; --bookw: 100cqw; }
  html[data-phone] #shelf[data-zoom="1t"] .railrow.tile {
    grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto auto auto;
    justify-items: start; row-gap: 0.75rem; padding: 1.4rem 0 2.2rem; }
  html[data-phone] #shelf[data-zoom="1t"] .railrow.tile .tilecov {
    grid-column: 1; grid-row: 1; width: auto; height: 38vh; margin: 0; }
  html[data-phone] #shelf[data-zoom="1t"] .railrow.tile .railrow-title {
    grid-column: 1; grid-row: 2; font-size: 1.8rem; line-height: 1.12; height: auto; -webkit-line-clamp: 4; }
  html[data-phone] #shelf[data-zoom="1t"] .railrow.tile .railrow-by { grid-column: 1; grid-row: 3; font-size: 0.78rem; }
  html[data-phone] #shelf[data-zoom="1t"] .railrow.tile .railrow-lang { grid-column: 1; grid-row: 4; font-size: 0.78rem; }
  html[data-phone] #shelf[data-zoom="1t"] .railrow.tile .tile-pane {
    grid-column: 1; grid-row: 5; width: 100%; display: grid; gap: 1.15rem; align-content: start; padding-top: 0.6rem; }
  html[data-phone] #shelf[data-zoom="1t"] .tile-slotrow { grid-template-columns: minmax(0, 1fr); }
  /* the ✕ rides the cover's corner, not the tile's, where the cover is narrower */
  html[data-phone] #shelf[data-zoom="1t"] .railrow.tile .tile-close { right: auto; left: calc(38vh * 88 / 120 - 34px); top: calc(1.4rem + 6px); }
  /* ---- 1: one jacket, the shelf's width, one screen a book */
  html[data-phone] #shelf[data-zoom="1"] { scroll-snap-type: y proximity; }
  html[data-phone] #shelf[data-zoom="1"] .shelfgrid { --cols: 1; --bookw: 100cqw; }
  html[data-phone] #shelf[data-zoom="1"] .railrow.tile {
    grid-template-rows: auto; row-gap: 0; scroll-snap-align: center;
    min-height: calc(100dvh - var(--safe-top, 0px) - var(--safe-bottom, 0px) - 24px); align-content: center; }
  html[data-phone] #shelf[data-zoom="1"] .railrow.tile .railrow-title,
  html[data-phone] #shelf[data-zoom="1"] .railrow.tile .railrow-lang,
  html[data-phone] #shelf[data-zoom="1"] .railrow.tile .railrow-by { display: none; }
  /* the jacket is centred in a screen-high tile; the ✕ sits on its corner:
     half the tile, less half the jacket (the shelf's width, 100cqw of the
     `shelf` container, at 120/88), plus the 6 px inset */
  html[data-phone] #shelf[data-zoom="1"] .railrow.tile .tile-close { top: calc(50% - 68.182cqw + 6px); }

  /* THE PHONE'S SPACING, one block in ladder order as the Mac's is (THE
     SPACING, above: `gap: <row> <column>`, and no rung carries a gap of its
     own). 4 is PHONE FIT's --ph-gap and is not restated. Mine, measured at
     393, one place to dial them. 16 and 8 were 10 and 14 until 14 Sep, when
     those rungs became sixteen and eight columns instead of four and three
     (chat 108): 10px between sixteen columns is 150 of the shelf's 365 spent
     on nothing, so 2 and 6 -- the Mac's own ratio (0.90 / 1.90) carried down
     to a phone. */
  html[data-phone] #shelf[data-zoom="16"] .shelfgrid { gap: 2px 2px; }
  html[data-phone] #shelf[data-zoom="8"]  .shelfgrid { gap: 6px 6px; }
  html[data-phone] #shelf[data-zoom="2"]  .shelfgrid { gap: 18px 14px; }
  html[data-phone] #shelf[data-zoom="1t"] .shelfgrid { gap: 0 0; }
  html[data-phone] #shelf[data-zoom="1"]  .shelfgrid { gap: 0 0; }
}

/* ==================================================================
   THE WEB EDITION (LIBRARY-WEB, wave 9, 26 Sep). Osca, on the site in
   Chrome at 2000 px: *"there is this bar inside the webapp, WITH NOTHING ON
   IT bar the light/dark mode … LOADS of wasted space … order, import, on
   this device ALL CRAP, no clear way to open surface or settings … SPACING
   matters so much, like sitting well in the page."* Every rule keys on
   `html[data-web]`, the flag library.html's <head> sets when host.js says
   `isWeb` -- the app (the strip is its header) and the phone (its own block
   above) never carry it and are untouched, dials included.
   ================================================================== */
  /* THE BAND: 48 px, the name, three doors, theme at the far right. The
     empty version measured 61 px of nothing. */
  html[data-web] header { padding: 0 32px; min-height: 48px; gap: 0; }
  html[data-web] header button, html[data-web] header .headerLink { min-height: 32px; min-width: 32px; }
  html[data-web] #sourcesToggle { display: none; }
  .webnav { display: none; }
  html[data-web] .webnav { display: flex; align-items: center; gap: 4px; min-width: 0; }
  html[data-web] .webbrand { font: 600 19px/1 var(--serif, Georgia, serif); letter-spacing: 0.01em;
    color: var(--fg); text-decoration: none; padding: 6px 0; margin-right: 22px; }
  html[data-web] .webdoor { font: 500 13px/1 var(--ui-font); color: var(--fg-dim); text-decoration: none;
    background: transparent; border: 1px solid transparent; border-radius: var(--r-1);
    padding: 8px 10px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
    transition: background 0.14s, color 0.14s; }
  html[data-web] .webdoor:hover { background: var(--control-bg); color: var(--fg); }
  html[data-web] .webdoor[aria-current="page"] { color: var(--fg); font-weight: 600; }
  html[data-web] .webdoor:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  html[data-web] .webdoor kbd { font: 500 11px/1 var(--ui-font); color: var(--fg-dim); opacity: 0.8;
    border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px; }

  /* THE PAGE COLUMN: a content width of 1280 centred with equal gutters --
     nothing hugs the window's edge. Every direct child of the shelf is the
     column; `.shelfgrid` and `.band` keep their own `fit-content` centring
     inside it, so on a wide window the 4-across grid (4 x 294 + 3 x 32 =
     1272) sits centred in the 1280. */
  html[data-web] #shelf.rail { padding: 28px 32px 48px; }
  html[data-web] #shelf > * { max-width: 1280px; margin-left: auto; margin-right: auto; }
  html[data-web] #shelf .band, html[data-web] #shelf .shelfgrid { margin-left: auto; margin-right: auto; }
  html[data-web] #shelf .empty { padding: 64px 0 16px; text-align: center; }
  /* THE EMPTY SHELF, DRAWN (STRANGER-FIX, 28 Sep): a lead, one line in
     Frank's voice, one door -- never a developer's paragraph set left. */
  #shelf .empty .empty-lead { margin: 0 0 8px; font: 500 16px/1.4 var(--ui-font); color: var(--fg); }
  #shelf .empty .empty-line { margin: 0 auto; max-width: 36em; font: 400 14px/1.5 var(--ui-font); color: var(--fg-dim); }
  #shelf .empty .emptydoor { display: inline-block; margin-top: 16px; padding: 8px 16px; border-radius: var(--r-1);
    border: 1px solid var(--fg-dim); background: transparent; color: var(--fg); font: 500 13px/1 var(--ui-font);
    cursor: pointer; text-decoration: none; -webkit-appearance: none; appearance: none; }
  #shelf .empty .emptydoor:hover { background: var(--control-bg); }
  html[data-web] #shelf .libline { padding: 6px 0 10px; }

  /* THE SORT: one quiet word at the column's right, the five orders behind
     it; the current one is never printed. The <select> is laid over the
     word at opacity 0 -- a real drop down, the native menu with the current
     order ticked, the keyboard and the reader all still see it. */
  html[data-web] .sortbar { width: 100%; padding: 0 0 16px; min-height: 32px; }
  html[data-web] .sortbar label { display: none; }
  html[data-web] .sortctl { position: relative; display: inline-flex; align-items: center; }
  .sortface { display: none; }
  html[data-web] .sortface { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px;
    font: 500 13px/1 var(--ui-font); color: var(--fg-dim); border-radius: var(--r-1); }
  html[data-web] .sortface::after { content: ""; width: 5px; height: 5px; margin-top: -3px;
    border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); }
  html[data-web] .sortctl:hover .sortface { background: var(--control-bg); color: var(--fg); }
  html[data-web] .sortbar select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0;
    margin: 0; padding: 0; cursor: pointer; }
  html[data-web] .sortctl:has(select:focus-visible) .sortface { outline: 2px solid var(--accent); outline-offset: 1px; }

  /* THE TILE'S RHYTHM: cover, 12, title, 4, author -- three auto rows and
     two margins, in place of the app's fixed 2.5em/1.4em rows. */
  html[data-web] #shelf .railrow.tile { grid-template-rows: auto auto auto; row-gap: 0; }
  /* the title's box is its text (one line or two, clamped), not the app's
     fixed 2.5em: a one-line title is followed by its author 4 px down, not
     by a line of air */
  html[data-web] #shelf .railrow.tile .railrow-title { margin-top: 12px; line-height: 1.3; height: auto; max-height: 2.6em; }
  html[data-web] #shelf .railrow.tile .railrow-by { margin-top: 4px; line-height: 1.3; }
  /* THE GAPS: row equal to column, one number a rung. */
  html[data-web] #shelf[data-zoom="16"] .shelfgrid { gap: 12px 12px; }
  html[data-web] #shelf[data-zoom="8"]  .shelfgrid { gap: 20px 20px; }
  html[data-web] #shelf[data-zoom="4"]  .shelfgrid { gap: 32px 32px; }
  html[data-web] #shelf[data-zoom="2"]  .shelfgrid { gap: 32px 32px; }
  html[data-web] #shelf .shelfgrid { margin-top: 0; }
  /* THE COLUMN COUNT STEPS BY WHAT FITS, 4 · 3 · 2 · 1, at the widths the
     32 px gap gives (4 x 294 + 3 x 32 = 1272; 946; 620): the app's own
     4 -> 2 step at 1290 left a 1100 px window two thin columns in 240 px
     gutters. The book stays 294 wide at every step; only the count moves. */
  @container shelf (max-width: 1271px) { html[data-web] #shelf[data-zoom="4"] .shelfgrid { --cols: 3; } }
  @container shelf (max-width: 945px)  { html[data-web] #shelf[data-zoom="4"] .shelfgrid { --cols: 2; } }
  @container shelf (max-width: 619px)  { html[data-web] #shelf[data-zoom="4"] .shelfgrid { --cols: 1; } }
  html[data-web] .bandhead { padding: 0 0 12px; }
  html[data-web] #shelf .band + .shelfgrid { margin-top: 40px; }

  /* THE FOOT: the counts of what is drawn, and Import bundle… as a link. */
  .webfoot { display: none; }
  html[data-web] .webfoot { display: block; padding: 28px 0 0; font-size: 0.8rem; color: var(--fg-dim); }
  html[data-web] .importlink { color: var(--fg-dim); text-decoration: underline; text-underline-offset: 3px; }
  html[data-web] .importlink:hover { color: var(--fg); }
  html[data-web] .importlink[aria-disabled="true"] { opacity: 0.5; pointer-events: none; }
  /* a bundle being read is one loading row above the shelf, the app's own */
  html[data-web] #shelf .railrow.loading[data-importing] { max-width: 560px; margin: 0 auto 16px; }
