/* T2 — "Learn Different" brand reskin. Phone-first, big eyes-free-friendly touch targets
   (>=44px), safe-area aware (iOS notch/home-indicator). Dark-only (the brand has no light
   theme) — Urbanist self-hosted below so the PWA stays fully functional offline. */
@font-face {
  font-family: 'Urbanist';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('fonts/urbanist-400.woff2') format('woff2');
}
@font-face {
  font-family: 'Urbanist';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('fonts/urbanist-700.woff2') format('woff2');
}
@font-face {
  font-family: 'Urbanist';
  font-style: normal;
  font-weight: 900;
  font-display: swap;
  src: url('fonts/urbanist-900.woff2') format('woff2');
}

:root {
  --bg: #141416;
  --surface: #1e1e22;
  --surface-elev: #26262f;
  --text: #f2f2f5;
  --muted: #9999a3;
  --accent: #ff5e5b;
  --accent-hover: #ff6e6b;
  --border: #3a3a40;
  --danger: #ff5c52;
  /* Iteration 9 (legal access redesign) — dark ink for text ON coral. White on #ff5e5b is only
     ~2.68:1 (fails WCAG AA); #141416 on coral is ~6.14:1. Used by the scoped .access-page /
     .terms-page controls below. NOT applied to the global `button.primary`: that is a
     site-wide brand decision Andrew has not made yet, so this iteration only adds the token.
     --error is the paired error text colour (always used with text, never colour-only). */
  --accent-ink: #141416;
  --error: #ff7771;
  --radius: 18px;
  --radius-card: 18px;
  --radius-helper: 12px;
  --radius-chip: 8px;
  --radius-pill: 999px;
  /* Iteration 5.2 — new tokens only for concepts the existing palette has no equivalent for
     (a faint/dim text tone, the ambient lamp glow, a darker coral for pressed states). NOT a
     copy of the v4-material prototype's own :root block — that block re-declares --bg/--muted/
     --radius at DIFFERENT values than the ones already approved and used app-wide (T2 reskin),
     so copying it verbatim would have silently shifted color/radius on every existing screen. */
  --faint: rgba(242, 242, 245, 0.42);
  --lamp: rgba(255, 208, 164, 0.05);
  --accent-deep: #e64946;
  /* Iteration 5.2 (stats screen) — a 6-step coral ramp for the listening-activity heatmap;
     s0 = no activity that day, s5 = the coral accent at full strength. */
  --s0: #26262f;
  --s1: #4a2827;
  --s2: #7a3835;
  --s3: #b8504e;
  --s4: #ff8e8c;
  --s5: #ff5e5b;
  /* Iteration 11 — library redesign additions (mockup v3). Premium hairline + inset cover
     sheen (no drop shadow, no backdrop-filter — GPU-light rule) + the four master-category
     tint tokens used by the designed placeholder covers (Task 8). */
  --hair: rgba(242, 242, 245, 0.09);
  --sheen: inset 0 1px 0 rgba(255,255,255,.07), inset 0 0 0 1px rgba(255,255,255,.02);
  --t-psy: #4b3a5a;
  --t-biz: #2f4a4a;
  --t-tech: #2c3a5e;
  --t-health: #2f4a38;
  /* Iteration 10 — a positive/success tone, the one state the palette never had a token for
     (the only green in the file was the hardcoded #1e7a4a on .lib-page .pill.done).
     admin-panel.js was already writing color: var(--text-success) on its "account created"
     message and var(--error) on its failure message; neither token existed, so both lines
     rendered with an invalid value and silently inherited the surrounding text colour. This
     defines the success one; the failure one now points at the --danger token that does exist,
     rather than minting a second name for the same red. --ok-tint / --ok-line are the same
     green at fill and hairline strength, mirroring how coral pairs with rgba(255,94,91,.12). */
  --text-success: #4bd6a0;
  --ok-tint: rgba(75, 214, 160, 0.12);
  --ok-line: rgba(75, 214, 160, 0.3);
  /* Iteration 11 v4-desktop, audit Group F — the four things the token set did not have a name
     for, plus the two scales the library page was inventing one element at a time.

     FONT STACKS. 'Merriweather' was written out sixteen times and 'Urbanist' in two different
     lengths: the html/body rule carries "Segoe UI" and Roboto, .lib-page .psum b did not, so
     the masthead numerals fell back differently from every other numeral in the app on a
     machine with no Urbanist. One name each, one stack each.

     HOVER TINT. The same interaction state was written rgba(242,242,245,.04), .05 and .06 in
     three rules a hundred lines apart, so row hover, panel hover and menu hover were three
     measurably different intensities and none could be changed at once. .05 is the middle of
     the three and the value the tree already used; --hair (.09) stays the hairline, which is a
     border, not a hover.

     TYPE. A census of every text-bearing node in .lib-page at 1440x900 returned TWELVE sizes,
     eleven of them between 10px and 17px, six inside a 2.5px band that cannot be told apart,
     then nothing at all until the 56px masthead. Four steps replace the band, and --t-head
     fills the 39px hole so the results state has a headline instead of a slightly bold label.
     Ratio is roughly 1.15 per step, which is what keeps two neighbouring steps distinguishable
     at these sizes.

     SPACING. Thirteen vertical values on one page, nine of them off any grid. A 4px scale, and
     the structural rhythm (page, masthead, toolbar, shelf, grid, results) reads from it. Values
     INSIDE a component that were measured against a target rather than chosen (the 44px touch
     floor, the 62px density panel, the 46/38px tree rows) keep their number and say why. */
  --font-sans: 'Urbanist', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-serif: 'Merriweather', Georgia, serif;
  --tint-hover: rgba(242, 242, 245, 0.05);
  --t-head: 24px;
  /* v5. Andrew asked for the shelf heading to grow while "See all" beside it stayed put, which
     is a real step the scale did not have: 15 to 24 is a 1.6x jump with nothing in it. 18px keeps
     the ~1.2 ratio going up from --t-lg and leaves --t-head a clear step above. Minted rather
     than typed inline, so the next thing that needs this size finds it. */
  --t-xl: 18px;
  --t-lg: 15px;
  --t-md: 13px;
  --t-sm: 11.5px;
  --t-xs: 10.5px;
  /* One kicker, one size, one tracking. The library block alone carried .18em, .14em and .12em
     for the same uppercase micro-label; 11px is the value two of the three already used. */
  --kicker-size: var(--t-sm);
  --kicker-track: .14em;
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 20px;
  --sp-6: 24px;
  --sp-8: 32px;
  color-scheme: dark;
}

* { box-sizing: border-box; }
html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  -webkit-tap-highlight-color: transparent;
}
body {
  padding-bottom: env(safe-area-inset-bottom);
}

#app-shell {
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  /* CRITICAL DEFECT 2 (Iteration 11 v3-mobile). This was `overflow-x: hidden`, and a computed
     `overflow-x` of hidden forces `overflow-y` to auto — which made #app-shell a scroll
     container that never actually scrolls (the page scrolls on <body>). Every `position: sticky`
     inside it therefore resolved against a box with no scrollable overflow and never stuck: the
     library toolbar, sticky since Iteration 11 v2, has never once stuck on any width.
     `clip` hides the same horizontal overflow but does NOT create a scroll container, so
     overflow-y stays visible and sticky works. Safari 16+, Chrome 90+, Firefox 81+.
     MERGE NOTE (Iteration 11 v8 into live, 2026-07-26): live carried `hidden` here and this
     branch carried `clip`, and the two are NOT interchangeable in one direction only. `clip`
     hides exactly the same horizontal overflow Iteration 25 measured, so nothing it verified
     is lost; `hidden` would silently un-stick the library toolbar again. Both were kept and
     both are re-measured after the merge, the toolbar for sticking and the sign-in button for
     sitting inside the page at 390, 1440 and 1920. */
  overflow-x: clip;
  /* Iteration 25: the containing block for .signin-slot, so the sign-in button measures its
     right edge against the SAME box #app-root centres in rather than against the viewport.
     `position: relative` with no z-index creates no stacking context and is not a containing
     block for `position: fixed`, so site-bar's fixed .ambient, the mini-player and the tab bar
     are all untouched. Orthogonal to the overflow rule above: `overflow` never creates a
     containing block for an absolutely positioned descendant, only a clip. */
  position: relative;
}
/* Redesign: #app-root used to hard-cap every screen at 640px, centered in the dark canvas —
   the "2002 column" problem (tiny covers, wasted space at any desktop width). It now scales up
   to a generous 1600px so Library/Home/Now-playing can genuinely use the screen; screens that
   want to stay narrow (login, settings, admin, the mobile categories list) opt into their own
   cap via .page-narrow / .login-wrap, so nothing gets awkwardly stretched. */
#app-root {
  flex: 1 1 auto;
  padding: max(1.25rem, env(safe-area-inset-top)) 1.25rem 6.5rem;
  max-width: 1600px;
  width: 100%;
  margin: 0 auto;
}
@media (min-width: 640px) {
  #app-root { padding-left: 2rem; padding-right: 2rem; }
}
@media (min-width: 1100px) {
  #app-root { padding-left: 3.5rem; padding-right: 3.5rem; padding-top: 2rem; }
}

/* Iteration 19, immersive play mode. app.js puts this class on <body> for the /player route
   only. #app-root's gutters and its 6.5rem bottom clearance (reserved for the tab bar and the
   mini-player, neither of which is on this route) would otherwise inset the player inside the
   app's page frame, and the player is meant to BE the viewport. site-bar's ambient layer goes
   too: the player renders its own wash from the cover art, and two stacked ambients is one
   more than the design has. */
body.is-player #app-root { padding: 0; max-width: none; }
body.is-player #global-site-bar { display: none; }

.page-narrow { max-width: 640px; margin: 0 auto; }

h1, h2, h3 { line-height: 1.2; margin: 0 0 0.6rem; font-weight: 900; }
h1 { font-size: clamp(1.6rem, 1.2rem + 1.6vw, 2.6rem); }
p { margin: 0 0 0.75rem; color: var(--muted); }
a { color: var(--accent); }
label { font-weight: 700; }

/* Andrew, 2026-07-26: "For the buttons use more letter spacing, because it is hard to read."
   Urbanist at weight 700-800 sets tight by default, and the app's buttons are short all-caps-ish
   labels on dark glass, which is the hardest case for tracking. 0.03em here is the base every
   button inherits; the two loudest surfaces (.av-btn, .primary/.gate-submit) take a little more
   below, and anything that already declares its own letter-spacing (the whole player) keeps it,
   because a rule further down the file still wins. */
button {
  font: inherit;
  font-weight: 700;
  letter-spacing: 0.03em;
  color: inherit;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.7rem 1.1rem;
  min-height: 44px;
  min-width: 44px;
  cursor: pointer;
}
button.primary, .gate-submit { letter-spacing: 0.045em; }
button.primary { background: var(--accent); color: #fff; border-color: transparent; }
button.primary:hover, button.primary:active { background: var(--accent-hover); }
button.btn-ghost { background: transparent; border-color: var(--border); color: var(--text); }
button.icon-btn {
  border: none;
  background: transparent;
  padding: 0.5rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  border-radius: 999px;
  transition: background 0.15s, color 0.15s;
}
/* Iteration 5.2 — the prototype's .tbtn hover treatment (player-prototype.html), applied to the
   already-circular transport buttons; the play/pause button (.big) keeps its own accent hover. */
button.icon-btn:not(.big):hover { background: rgba(255, 255, 255, 0.06); color: var(--text); }
button.icon-btn svg { width: 28px; height: 28px; fill: currentColor; }
button.icon-btn.big {
  width: 76px;
  height: 76px;
  border-radius: 999px;
  background: var(--accent);
  color: #fff;
}
button.icon-btn.big:hover { background: var(--accent-hover); }
button.icon-btn.big svg { width: 36px; height: 36px; }
input, textarea {
  font: inherit;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 0.7rem 0.9rem;
  width: 100%;
  min-height: 44px;
}

/* ---- a11y: visible keyboard focus on interactive elements (additive, safe) ---- */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1rem;
  margin-bottom: 1rem;
}

/* ---- library grid ---- */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 1.1rem;
}
@media (min-width: 640px) {
  .grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1.4rem; }
}
@media (min-width: 1100px) {
  .grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1.9rem; }
}
.grid-item {
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.grid-item img { width: 100%; aspect-ratio: 1; border-radius: 10px; object-fit: cover; box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
/* Title leads (the prominent line); author is secondary — brand data now supplies real
   title/author, so the redesign leans into that with real weight contrast. */
.grid-item .t { font-weight: 700; font-size: 0.95rem; line-height: 1.3; }
.grid-item .a { font-size: 0.78rem; color: var(--muted); margin-top: 0.1rem; }

/* ---- continue listening row ---- */
.row-scroll { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 0.4rem; scroll-snap-type: x proximity; }
.row-scroll .grid-item { flex: 0 0 150px; scroll-snap-align: start; }
@media (min-width: 900px) { .row-scroll .grid-item { flex: 0 0 210px; } }

/* ---- tab bar ----
   Iteration 19, the app's ONLY navigation now that the top header is gone, so it was worth
   more than a flat strip of grey glyphs. Three changes:
     1. The icon sits in its own rounded plate, and the plate fills with a coral tint when the
        tab is current. The old bar signalled "current" with colour alone on a 22px glyph.
     2. A top hairline plus a shadow lifting off the page, so the bar reads as sitting above
        the content rather than being welded to the bottom of it.
     3. --tabbar-h is the single source of the bar's height. <mini-player> parks itself
        directly above the bar and used to hardcode 3.6rem, which was already 4px short of the
        real height; both now read the same variable.
   Deliberately NO backdrop-filter: this bar is fixed and permanent, so a blur would
   recomposite on every scroll frame of a 3,602-tile library. Same GPU-light rule the ambient
   layers and the old header were both trimmed for. */
:root { --tabbar-h: 62px; }
/* `hidden` must actually hide it. app.js sets tabBar.hidden = true on /player and on the
   offline screen, but the UA stylesheet's [hidden] { display: none } is a bare attribute
   selector and LOSES to `.tab-bar { display: flex }` on specificity, so the bar stayed on
   screen both times. Latent since M4: the offline screen has been showing a nav bar it
   deliberately turns off, and the numeric probe passed because the `hidden` PROPERTY was
   correctly true. Caught by looking at the render. */
.tab-bar[hidden] { display: none; }
.tab-bar {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  display: flex;
  height: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
  padding-bottom: env(safe-area-inset-bottom);
  /* DEFECT FIX (Iteration 27). This was `rgba(20, 20, 23, 0.97)` with no backdrop-filter, so 3% of
     every heading that scrolls under it composited through and collided with the Home and Library
     labels. A 0.97 alpha with nothing blurring behind it is not frosting, it is a ghost. Found by
     LOOKING at the v6 book page's own renders at both widths, where a chapter title and a "See all"
     pill were legible through the bar and overlapping its text; the numbers were all green.
     The bar is a KERNEL surface, so this is on every scrollable page rather than one, which is the
     point: the defect was on every scrollable page too. Opaque, and the value is --bg's own
     neighbour rather than a new colour. */
  background: #141417;
  border-top: 1px solid var(--hair);
  box-shadow: 0 -10px 30px -18px rgba(0, 0, 0, 0.9);
  z-index: 20;
}
.tab-bar button {
  flex: 1;
  min-width: 0;
  background: none;
  border: none;
  border-radius: 0;
  padding: 7px 2px 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  color: var(--muted);
  cursor: pointer;
}
.tab-bar .tb-plate {
  display: flex; align-items: center; justify-content: center;
  width: 56px; height: 28px; border-radius: var(--radius-pill);
  border: 1px solid transparent;
  transition: background 0.16s ease, border-color 0.16s ease;
}
.tab-bar .tb-lab {
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.01em;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tab-bar svg { width: 21px; height: 21px; fill: currentColor; }
.tab-bar button:hover .tb-plate { background: rgba(255, 255, 255, 0.06); }
.tab-bar button.active { color: var(--accent); }
.tab-bar button.active .tb-plate {
  background: rgba(255, 94, 91, 0.15);
  border-color: rgba(255, 94, 91, 0.34);
}
.tab-bar button.active .tb-lab { font-weight: 800; }
@media (prefers-reduced-motion: reduce) {
  .tab-bar .tb-plate { transition: none; }
}

/* ---- mini player ---- */
mini-player {
  position: fixed;
  left: 50%; transform: translateX(-50%);
  width: min(1400px, calc(100% - 40px));
  /* Iteration 19, was a hardcoded 3.6rem (57.6px) against a bar that measured 61.6px, so the
     mini-player overlapped the tab bar's top hairline by 4px. Both read --tabbar-h now. */
  bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 8px);
  z-index: 15;
}
.mini-player-inner {
  display: none;
  align-items: center;
  gap: 0.7rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 0.5rem 0.7rem;
  /* Iteration 8 (ticket 09 Group F) — trimmed 20px->14px. */
  box-shadow: 0 6px 14px rgba(0,0,0,0.25);
  /* THESE TWO ARE THE FIX FOR "the player bar is sticking out of the container" (Andrew,
     2026-07-27, with a crop of the bottom-left and bottom-right corners).

     Nothing was overflowing. Measured on the deployed page at 390, 1440 and his own 2952: the
     cover and the play button sit 9px inside the box top and bottom and 12.2px inside its ends,
     and the box is exactly the host's 1400px. What juts out is `.mini-player-progress`, the 3px
     track along the bottom, and it does it in two ways at once.

     One: it was positioned against the HOST. `.mini-player-inner` was `position: static`, so the
     nearest positioned ancestor was <mini-player> itself (`position: fixed`), and `left/right/
     bottom: 0` resolved to the host's box, which is this element's BORDER box. The track
     therefore painted ON TOP of the 1px border along the bottom and both ends instead of inside
     it. `position: relative` moves its containing block here, so `0` now means the padding box
     and the border survives all the way round.

     Two, and this is the part the eye actually catches: its `border-radius: 0 0 14px 14px` cannot
     round a box 3px tall. CSS scales radii down until they fit, so a 14px corner on a 3px box is
     effectively square, and the track ran straight out to where the container's 14px curve had
     already turned in. A square end against a rounded corner is exactly "sticking out of the
     container". `overflow: hidden` clips it to this element's own rounded padding box, so the
     track ends on the curve whatever radius either rule names. Nothing else here can be clipped:
     every child already measures inside the box, and an element's own box-shadow is never clipped
     by its own overflow. */
  position: relative;
  overflow: hidden;
}
.mini-player-inner.show { display: flex; }
/* 2026-07-26, Andrew: "on the player bar at the bottom of the screen, the cover image is too small
   compared to the rest of the containers". It was 42px in a bar whose content box measures 76px,
   because the play/pause button is `.icon-btn.big` (76x76) and that button, not the cover, was
   setting the bar's height — the artwork filled 55% of a band it shares with a control twice its
   size. 76px matches the button exactly, so the two ends of the bar are the same size and the
   cover fills the band edge to edge. The bar's own height does not move: it was already 94px.
   The radius goes 8 -> 12 (--radius-helper, the token every other cover on the app uses) because
   an 8px corner on a 42px square and on a 76px square are not the same shape. */
.mini-player-inner img { width: 76px; height: 76px; border-radius: var(--radius-helper); object-fit: cover; }
.mini-player-inner .info { flex: 1; min-width: 0; }
.mini-player-inner .title { font-weight: 600; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mini-player-inner .sub { font-size: 0.72rem; color: var(--muted); }
.mini-player-progress {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 3px; background: var(--border); border-radius: 0 0 14px 14px; overflow: hidden;
}
.mini-player-progress > span { display: block; height: 100%; background: var(--accent); width: 0%; }

/* ============================================================
   ---- now playing (Iteration 19 base; visual SSOT mockup v8) ----
   Ported from mockup-player-page-redesign-v8.html. Behaviour is the
   production now-playing custom element; this block is chrome only.

   Every rule is scoped under `now-playing`, because the mockup's class names are short and
   generic on purpose (.bar, .stage, .panel, .toast) and several of them already exist
   elsewhere in this file, .bar is the stats page's meter, .ambient is site-bar's room
   background. Scoping is what lets the design port verbatim instead of being renamed
   wholesale; the one rename is .ambient -> .pl-amb, since a player-local copy of a name that
   means "the app's fixed full-viewport backdrop" would be actively misleading.

   The panels are `fixed`, not `absolute`: <now-playing> is min-height 100dvh but grows past
   the viewport on a short screen, and a bottom sheet anchored to the bottom of a taller-than-
   viewport box lands off-screen. Fixed also matches the existing .sheet implementation.

   Phone defaults below; desktop deltas live in @media (min-width: 900px).
   ============================================================ */
now-playing {
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
  overflow: hidden;
}

/* Material path glyphs (SVG), sized like the mockup's .ms utility classes. Scoped only. */
now-playing svg.ms {
  display: inline-block;
  flex: none;
  fill: currentColor;
  vertical-align: middle;
  width: 1em;
  height: 1em;
}
now-playing svg.ms.sm { width: 18px; height: 18px; }
now-playing svg.ms.md { width: 22px; height: 22px; }
now-playing svg.ms.lg { width: 26px; height: 26px; }
now-playing svg.ms.xl { width: 30px; height: 30px; }
now-playing svg.ms.xxl { width: 40px; height: 40px; }

/* 1. THE AMBIENT STAGE */
now-playing .pl-amb { position: absolute; z-index: 0; inset: -18% -18% 42%; filter: blur(56px) saturate(1.5); opacity: 0.5; }
now-playing .pl-amb img { width: 100%; height: 100%; object-fit: cover; display: block; }
now-playing .veil {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg, rgba(20,20,22,0.42) 0%, rgba(20,20,22,0.80) 44%, var(--bg) 72%);
}
now-playing .stage {
  position: relative; z-index: 2; flex: 1;
  display: flex; flex-direction: column; min-height: 0;
  /* Phone: mockup 46px top; keep safe-area so the cover never sits under the collapse control. */
  padding: max(46px, env(safe-area-inset-top) + 12px) 22px 0;
  overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
}

now-playing .collapse {
  position: absolute; top: max(12px, env(safe-area-inset-top)); left: 16px; z-index: 5;
  width: 40px; height: 40px; border-radius: 50%; padding: 0;
  display: flex; align-items: center; justify-content: center;
  color: var(--text); background: rgba(255, 255, 255, 0.09); border: 0; cursor: pointer;
  backdrop-filter: blur(6px);
}
now-playing .collapse svg { width: 24px; height: 24px; fill: currentColor; }
now-playing .collapse:active { background: rgba(255, 255, 255, 0.18); }
now-playing .backpill { display: none; }

now-playing .mid {
  display: flex; flex-direction: column; min-height: 0; flex: 1;
  justify-content: center;
}

/* 2. COVER + whole-book progress */
now-playing .coverbox { position: relative; flex: none; margin: 18px auto 0; width: fit-content; }
now-playing .coverwrap {
  position: relative; width: 300px; aspect-ratio: 1;
  border-radius: 16px; overflow: hidden; box-shadow: 0 26px 54px rgba(0, 0, 0, 0.62);
}
now-playing .coverwrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
now-playing .coverwrap::after {
  content: ""; position: absolute; inset: 0; border-radius: 16px; pointer-events: none;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
}
now-playing .bookbar {
  position: absolute; left: 0; right: 0; bottom: 0; height: 5px;
  background: rgba(0, 0, 0, 0.42); z-index: 2; transition: height 0.16s ease;
}
now-playing .bookbar i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); width: 0; }
now-playing .coverdrag {
  position: absolute; left: 0; right: 0; bottom: 0; height: 30px; z-index: 3;
  cursor: ew-resize; touch-action: none;
}
now-playing .coverbox.scrubbing .bookbar { height: 9px; }
now-playing .bookpill {
  position: absolute; left: 50%; bottom: 100%; transform: translate(-50%, 4px); margin-bottom: 10px;
  padding: 7px 13px; border-radius: var(--radius-pill); background: rgba(38, 38, 43, 0.97);
  border: 1px solid rgba(255, 255, 255, 0.13); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
  font-size: 12.5px; font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap;
  opacity: 0; pointer-events: none; z-index: 6;
  transition: opacity 0.14s ease, transform 0.14s ease;
}
now-playing .coverbox.scrubbing .bookpill { opacity: 1; transform: translate(-50%, 0); }

now-playing .booktimes {
  display: flex; align-items: baseline; justify-content: space-between; gap: 14px;
  margin-top: 10px; font-size: 11.5px; font-weight: 700; color: var(--faint);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}

now-playing .side {
  display: flex; flex-direction: column; min-width: 0;
  flex: 1 0 auto; min-height: min(100%, 420px);
}
now-playing .grow { flex: 1 1 auto; min-height: 10px; }

/* 3. CHAPTER HEAD + BOOKMARK-ONLY BAR */
now-playing .chhead {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  min-width: 0; width: 100%; margin-top: 12px; padding: 4px 2px 0;
  text-align: center; border-radius: 12px; flex-wrap: nowrap;
  background: none; border: 0; color: inherit; cursor: pointer; font: inherit;
}
now-playing .chhead:hover { background: rgba(255, 255, 255, 0.03); }
now-playing .chhead:active { background: rgba(255, 255, 255, 0.05); }
now-playing .chhead .ic-list {
  flex: none; color: var(--muted); width: 28px; height: 28px; fill: currentColor;
}
now-playing .chhead .nm {
  font-size: 20px; font-weight: 900; letter-spacing: -0.02em; line-height: 1.22;
  white-space: normal; overflow: visible; text-overflow: clip;
  min-width: 0; max-width: calc(100% - 42px); text-align: left; flex: 0 1 auto;
  color: var(--text);
}

now-playing .navbar {
  display: flex; align-items: stretch; margin-top: 10px;
  border-radius: 18px; overflow: hidden;
  background: linear-gradient(180deg, rgba(255,255,255,0.085), rgba(255,255,255,0.035));
  border: 1px solid rgba(255, 255, 255, 0.10);
  box-shadow: 0 10px 26px rgba(0,0,0,0.34), inset 0 1px 0 rgba(255,255,255,0.10);
}
now-playing .navseg {
  display: flex; align-items: center; min-width: 0; position: relative;
  background: none; border: 0; border-radius: 0; color: inherit; cursor: pointer; font: inherit;
}
now-playing .navseg:hover { background: rgba(255, 255, 255, 0.05); }
now-playing .navseg:active { background: rgba(255, 255, 255, 0.09); }
now-playing .navdiv { width: 1px; flex: none; background: rgba(255, 255, 255, 0.10); margin: 11px 0; }
now-playing .ic-list, now-playing .ic-caret {
  flex: none; color: var(--muted); fill: currentColor;
}
now-playing .ic-caret { width: 18px; height: 18px; }
now-playing .nav-bmlist { flex: 1; min-width: 0; gap: 10px; padding: 12px 14px 12px 16px; text-align: left; }
now-playing .nav-bmlist .lab { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
now-playing .nav-bmlist .kick {
  font-size: 10px; font-weight: 800; letter-spacing: 0.11em; text-transform: uppercase; color: var(--faint);
}
now-playing .nav-bmlist .nm {
  font-size: 16px; font-weight: 900; letter-spacing: -0.015em; color: var(--text);
}
now-playing .nav-bmlist .bmg { display: flex; align-items: center; gap: 8px; color: var(--text); }
now-playing .nav-bmlist .bmg svg { width: 20px; height: 20px; fill: currentColor; }
now-playing .nav-addbm {
  flex: none; flex-direction: column; justify-content: center; gap: 3px;
  padding: 10px 18px; min-width: 92px;
}
now-playing .nav-addbm .bmg {
  display: flex; align-items: center; justify-content: center; color: var(--text);
}
now-playing .nav-addbm .bmg svg { width: 22px; height: 22px; fill: currentColor; }
now-playing .nav-addbm .bml {
  font-size: 10px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; color: var(--faint);
}
now-playing .nav-addbm.saved .bmg {
  color: var(--accent); transform: scale(1.08);
  transition: transform 0.18s cubic-bezier(0.2, 1.6, 0.4, 1);
}

/* 4. CONTROLS */
now-playing .controls {
  margin-top: 0; padding-bottom: max(22px, env(safe-area-inset-bottom)); flex: none;
}
now-playing .scrub { margin: 0 2px; }
now-playing .scrub .bar {
  position: relative; height: 5px; border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, 0.14); cursor: pointer; touch-action: none;
}
now-playing .scrub .bar .fill {
  position: absolute; inset: 0 auto 0 0; border-radius: var(--radius-pill); background: var(--accent); width: 0;
}
/* 19px, not the mockup's 15px: on a phone the dot is the thing a thumb aims at, and 15px is
   under the finger before the drag even starts. The rail stays 5px so the dot still reads as
   a handle on a line rather than a second bar. */
now-playing .scrub .bar .knob {
  position: absolute; top: 50%; left: 0; width: 19px; height: 19px; border-radius: 50%;
  background: var(--accent); transform: translate(-50%, -50%); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
now-playing .scrub .hit { position: absolute; left: 0; right: 0; top: -15px; height: 35px; }
now-playing .times {
  display: flex; justify-content: space-between; margin-top: 9px;
  font-size: 11.5px; font-variant-numeric: tabular-nums; color: var(--faint); font-weight: 600;
}

now-playing .transport {
  display: flex; align-items: center; justify-content: space-between;
  margin: 18px 6px 0;
}
now-playing .tbtn {
  width: 46px; height: 46px; display: flex; align-items: center; justify-content: center;
  color: var(--text); position: relative; border-radius: 50%; flex: none;
  background: none; border: 0; cursor: pointer; padding: 0;
}
now-playing .tbtn:hover { background: rgba(255, 255, 255, 0.06); }
now-playing .tbtn:active { background: rgba(255, 255, 255, 0.08); }
now-playing .tbtn svg { width: 26px; height: 26px; fill: currentColor; }
now-playing .tbtn.edge { color: var(--muted); }
now-playing .tbtn.edge svg { width: 26px; height: 26px; }
now-playing .tbtn.skip {
  width: 52px; height: 52px; min-width: 44px; min-height: 44px;
  border-radius: 0; background: transparent; border: 0; box-shadow: none;
}
now-playing .tbtn.skip:hover,
now-playing .tbtn.skip:active { background: transparent; }
now-playing .tbtn.skip .skip-inner {
  position: relative; width: 32px; height: 32px;
  display: flex; align-items: center; justify-content: center;
}
now-playing .tbtn.skip .skip-inner svg { width: 30px; height: 30px; opacity: 0.88; }
now-playing .tbtn.skip .skip-n {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-size: 10px; font-weight: 900; font-variant-numeric: tabular-nums; letter-spacing: -0.03em;
  padding-top: 1px; color: var(--text); z-index: 1;
  text-shadow: 0 0 5px var(--bg), 0 0 3px var(--bg), 0 0 2px var(--bg);
}
now-playing .play {
  width: 74px; height: 74px; border-radius: 50%; background: var(--accent); color: #fff; flex: none;
  display: flex; align-items: center; justify-content: center; border: 0; cursor: pointer; padding: 0;
  box-shadow: 0 12px 30px rgba(255, 94, 91, 0.34);
}
now-playing .play svg,
now-playing .play .ms { width: 40px; height: 40px; fill: currentColor; color: #fff; }
now-playing #pl-play-glyph { display: flex; align-items: center; justify-content: center; line-height: 0; }
now-playing .play:hover { background: var(--accent-hover); }

now-playing .essrow {
  margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(255, 255, 255, 0.07);
  display: flex; justify-content: center; align-items: center;
}
now-playing .ess {
  display: flex; align-items: stretch; justify-content: center; gap: 48px;
}
now-playing .ess button {
  display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px 20px;
  border-radius: 14px; min-width: 72px; min-height: 44px;
  background: none; border: 0; color: inherit; cursor: pointer; font: inherit;
}
now-playing .ess button:hover { background: rgba(255, 255, 255, 0.05); }
now-playing .ess button:active { background: rgba(255, 255, 255, 0.07); }
now-playing .ess .g { height: 24px; display: flex; align-items: center; justify-content: center; color: var(--text); }
now-playing .ess .g svg { width: 22px; height: 22px; fill: currentColor; }
now-playing .ess .rate { font-size: 17px; font-weight: 900; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
now-playing .ess .lb { font-size: 11px; font-weight: 700; color: var(--faint); letter-spacing: 0.02em; }
now-playing .ess button.saved .g {
  color: var(--accent); transform: scale(1.12);
  transition: transform 0.18s cubic-bezier(0.2, 1.6, 0.4, 1);
}

/* 5. TOAST */
now-playing .pl-toast {
  position: fixed; left: 50%; bottom: max(16px, env(safe-area-inset-bottom)); z-index: 52;
  transform: translate(-50%, 14px);
  display: flex; align-items: center; gap: 9px; padding: 11px 18px 11px 14px;
  border-radius: var(--radius-pill); background: rgba(38, 38, 43, 0.96);
  border: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
  font-size: 14px; font-weight: 700; white-space: nowrap;
  opacity: 0; pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease;
}
now-playing .pl-toast.show { opacity: 1; transform: translate(-50%, 0); }
now-playing .pl-toast .tick {
  width: 19px; height: 19px; border-radius: 50%; background: var(--accent); color: #fff;
  display: flex; align-items: center; justify-content: center; flex: none;
}
now-playing .pl-toast .tick svg { width: 12px; height: 12px; fill: currentColor; }
now-playing .pl-toast .undo {
  margin-left: 6px; font-size: 13px; font-weight: 900; color: var(--accent); letter-spacing: 0.03em;
  background: none; border: 0; cursor: pointer; pointer-events: auto; padding: 4px;
}

/* 6. PANELS */
now-playing .pl-backdrop {
  position: fixed; inset: 0; z-index: 44; background: rgba(0, 0, 0, 0.55);
  opacity: 0; pointer-events: none; transition: opacity 0.2s ease;
}
now-playing .pl-backdrop.open { opacity: 1; pointer-events: auto; }
now-playing .panel {
  position: fixed; z-index: 45; background: #202025; border: 1px solid var(--border);
  left: 0; right: 0; bottom: 0;
  border-radius: 22px 22px 0 0; border-width: 1px 0 0;
  padding: 10px 20px calc(30px + env(safe-area-inset-bottom));
  transform: translateY(101%); transition: transform 0.26s cubic-bezier(0.3, 0.9, 0.3, 1);
}
now-playing .panel.open { transform: translateY(0); }
now-playing .navp {
  height: 92%; display: flex; flex-direction: column;
  padding-top: 18px; padding-bottom: 8px;
}

now-playing .grab {
  display: none !important;
}
now-playing .ptitle { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20px; }
now-playing .ptitle .t { font-size: 16px; font-weight: 900; }
now-playing .ptitle .v {
  font-size: 22px; font-weight: 900; font-variant-numeric: tabular-nums;
  color: var(--accent); letter-spacing: -0.02em;
}

/* ---- speed ---- */
now-playing .speedrow { display: flex; align-items: center; gap: 15px; margin-bottom: 22px; }
now-playing .rbtn {
  width: 44px; height: 44px; flex: none; border-radius: 50%; border: 1.5px solid var(--border);
  display: flex; align-items: center; justify-content: center; color: var(--text);
  background: none; cursor: pointer; padding: 0;
}
now-playing .rbtn svg { width: 19px; height: 19px; fill: currentColor; }
now-playing .rbtn:hover { border-color: #3d3d46; }
now-playing .rbtn:active { background: rgba(255, 255, 255, 0.08); }
now-playing .rtrack {
  flex: 1; position: relative; height: 44px; display: flex; align-items: center;
  cursor: pointer; touch-action: none;
}
now-playing .rtrack .rail {
  position: absolute; left: 0; right: 0; height: 5px; border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, 0.16);
}
now-playing .rtrack .rfill {
  position: absolute; left: 0; height: 5px; border-radius: var(--radius-pill); background: var(--accent); width: 0;
}
now-playing .rtrack .rknob {
  position: absolute; left: 0; width: 17px; height: 17px; border-radius: 50%; background: var(--accent);
  transform: translateX(-50%); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
now-playing .presets { display: flex; gap: 8px; }
now-playing .presets button {
  flex: 1; height: 44px; border-radius: 13px; background: var(--surface-elev);
  border: 1.5px solid transparent; font-size: 14px; font-weight: 800; font-variant-numeric: tabular-nums;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
  color: inherit; cursor: pointer; font-family: inherit;
}
now-playing .presets button.on { border-color: var(--accent); color: var(--accent); background: rgba(255, 94, 91, 0.12); }
now-playing .presets button i { font-style: normal; font-size: 8px; font-weight: 900; letter-spacing: 0.06em; color: var(--faint); }

/* 7. TWIN: chapters + bookmarks */
now-playing .twinhead {
  display: flex; flex-direction: column; align-items: stretch; gap: 10px;
  margin-bottom: 12px; flex: none;
}
now-playing .seg {
  position: relative; display: flex; background: var(--bg);
  border-radius: var(--radius-pill); padding: 3px;
  border: 1px solid var(--border); flex: none;
  width: 100%; max-width: 340px; margin: 0 auto;
}
now-playing .segpill { display: none !important; }
now-playing .segb {
  position: relative; z-index: 1; height: 40px; min-height: 36px; padding: 0 20px;
  font-size: 13px; font-weight: 600; color: var(--muted); white-space: nowrap; flex: 1;
  background: transparent; border: 0; border-radius: var(--radius-pill);
  cursor: pointer; font-family: inherit;
}
now-playing .segb:hover { color: var(--text); }
/* 2026-07-31: Andrew — dark ink on coral, not white. Overrides v8 lock 6.8. Matches .lib-page .seg. */
now-playing .segb.on,
now-playing .segb[aria-selected="true"] {
  background: var(--accent); color: var(--accent-ink); font-weight: 800;
}
/* twinend: three-slot grid so meta stays centred and #pl-export never sits on the seg above
   or paints over the count. Outer columns stay reserved so the count does not jump when the
   export shows (bookmarks side only). */
now-playing .twinend {
  display: grid; grid-template-columns: 34px 1fr 34px; align-items: center; column-gap: 8px;
  flex: none; width: 100%; min-height: 34px;
}
now-playing .twinmeta {
  grid-column: 2; font-size: 12px; color: var(--faint); font-weight: 700;
  font-variant-numeric: tabular-nums; text-align: center; min-width: 0;
}
now-playing .bmexport {
  grid-column: 3; justify-self: end;
  width: 34px; height: 34px; flex: none; border-radius: 10px; display: none;
  align-items: center; justify-content: center; color: var(--muted);
  background: rgba(255, 255, 255, 0.055); border: 1px solid rgba(255, 255, 255, 0.07);
  cursor: pointer; padding: 0;
}
now-playing .bmexport svg { width: 17px; height: 17px; fill: currentColor; }
now-playing .bmexport:hover { color: var(--text); }
now-playing .navp[data-side="1"] .bmexport { display: flex; }

now-playing .twinview { flex: 1; min-height: 0; overflow: hidden; margin: 0 -6px; touch-action: pan-y; }
now-playing .twintrack { display: flex; width: 200%; height: 100%; transition: transform 0.3s cubic-bezier(0.3, 0.9, 0.3, 1); }
now-playing .twinpane { width: 50%; flex: none; height: 100%; min-width: 0; padding: 0 6px; }
now-playing .plist { height: 100%; overflow-y: auto; padding-bottom: 8px; }
now-playing .plist .empty-state { padding: 1.4rem 0.6rem; }

/* ---- chapter rows ---- */
now-playing .chrow {
  display: flex; align-items: flex-start; gap: 12px; width: 100%; text-align: left;
  padding: 12px 10px; border-radius: 12px;
  background: none; border: 0; color: inherit; cursor: pointer; font-family: inherit;
}
now-playing .chrow:hover { background: rgba(255, 255, 255, 0.045); }
now-playing .chrow:active { background: rgba(255, 255, 255, 0.06); }
now-playing .chrow .n {
  width: 24px; flex: none; text-align: right; font-size: 11.5px; font-weight: 700;
  color: var(--faint); font-variant-numeric: tabular-nums; padding-top: 2px;
}
now-playing .chrow .t {
  flex: 1; min-width: 0; font-size: 14.5px; font-weight: 700; line-height: 1.3;
  white-space: normal; overflow: visible; text-overflow: clip;
}
now-playing .chrow .d {
  font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums;
  flex: none; font-weight: 600; padding-top: 2px;
}
now-playing .chrow.done .t { color: var(--faint); font-weight: 400; }
now-playing .chrow.done .n { color: var(--accent-deep); }
now-playing .chrow.now { background: rgba(255, 94, 91, 0.13); border: 1px solid rgba(255, 94, 91, 0.30); }
now-playing .chrow.now .t { font-weight: 900; }
now-playing .chrow.now .n { color: var(--accent); }

/* ---- bookmark rows: mockup v8 axis (right=edit, left=delete) ---- */
now-playing .bmwrap { position: relative; border-radius: 12px; overflow: hidden; margin-bottom: 7px; touch-action: pan-y; }
now-playing .act {
  position: absolute; top: 0; bottom: 0; display: flex; align-items: center; gap: 8px; padding: 0 17px;
  font-size: 11.5px; font-weight: 900; letter-spacing: 0.07em; text-transform: uppercase;
}
now-playing .act svg { width: 17px; height: 17px; fill: currentColor; }
now-playing .act.edt { left: 0; background: rgba(255, 255, 255, 0.10); color: var(--text); }
now-playing .act.del { right: 0; background: rgba(255, 94, 91, 0.22); color: #ff8f8c; }
now-playing .bmwrap.armed .act.del { background: rgba(255, 94, 91, 0.42); color: #fff; }
now-playing .bmwrap.armed .act.edt { background: rgba(255, 255, 255, 0.20); }

now-playing .bmrow {
  position: relative; z-index: 1; display: flex; align-items: flex-start; gap: 4px; width: 100%;
  background: var(--surface-elev); border-radius: 12px;
  transition: transform 0.22s cubic-bezier(0.3, 0.9, 0.3, 1);
}
now-playing .bmrow:hover { background: #2b2b31; }
now-playing .bmrow .bm-hit {
  flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: 10px; text-align: left;
  padding: 13px 4px 13px 12px; border-radius: 0; background: transparent; color: inherit;
  border: 0; cursor: pointer; font: inherit;
}
now-playing .bmrow .tx {
  flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px;
  white-space: normal; overflow: visible; text-overflow: clip;
}
now-playing .bmrow .tx .tt {
  display: block; font-size: 13.5px; font-weight: 700; color: var(--text); line-height: 1.3;
  white-space: normal; overflow: visible; text-overflow: clip;
}
now-playing .bmrow .tx .nt {
  display: block; font-size: 12px; color: var(--faint); font-weight: 500; line-height: 1.35;
  white-space: normal; overflow: visible; text-overflow: clip;
}
now-playing .bmrow .tx .tm {
  display: block; font-size: 12.5px; font-weight: 800; font-variant-numeric: tabular-nums;
  color: var(--muted); letter-spacing: -0.01em;
  white-space: normal; overflow: visible; text-overflow: clip;
}
now-playing .bmrow .tx .tt:empty,
now-playing .bmrow .tx .nt:empty { display: none; }
now-playing .bmrow .cp {
  flex: none; font-size: 10.5px; font-weight: 800; color: var(--faint);
  letter-spacing: 0.05em; font-variant-numeric: tabular-nums; padding-top: 3px;
}
now-playing .bmwrap.going { opacity: 0; transform: scale(0.96); transition: opacity 0.18s ease, transform 0.18s ease; }

now-playing .pen-btn {
  flex: none; box-sizing: border-box; width: 44px; height: 44px; min-width: 44px; min-height: 44px;
  padding: 0; margin: 4px 10px 4px 2px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center; align-self: flex-start;
  color: var(--muted); background: transparent; border: 0; cursor: pointer; line-height: 0;
}
now-playing .pen-btn:hover,
now-playing .pen-btn:active { background: rgba(255, 255, 255, 0.08); color: var(--text); }
now-playing .pen-btn svg { width: 20px; height: 20px; fill: currentColor; }

now-playing .swipehelp {
  margin: 2px 4px 14px; padding: 0 2px 2px; text-align: center;
  font-size: 12px; font-weight: 600; color: var(--faint); letter-spacing: 0.01em; line-height: 1.45;
  background: none; border: 0; border-radius: 0;
}
now-playing .swipehelp .sh-ico {
  display: inline-flex; align-items: center; justify-content: center;
  vertical-align: -3px; margin: 0 1px;
}
now-playing .swipehelp .sh-ico svg { width: 15px; height: 15px; fill: currentColor; }
now-playing .swipehelp .sh-ico.edit { color: var(--muted); }
now-playing .swipehelp .sh-ico.del { color: #ff8f8c; }
now-playing .swipehelp.empty {
  text-align: center; font-size: 13px; font-weight: 600; color: var(--faint); padding: 10px 8px 4px;
}

/* ---- inline bookmark editor ---- */
now-playing .bmedit {
  background: var(--surface-elev); border-radius: 12px; padding: 14px 14px 12px; margin-bottom: 7px;
  border: 1px solid rgba(255, 94, 91, 0.30);
}
now-playing .bmedit .eh { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 6px; }
now-playing .bmedit .eh .tm { font-size: 13px; font-weight: 900; font-variant-numeric: tabular-nums; }
now-playing .bmedit .eh .cp { font-size: 10.5px; font-weight: 800; color: var(--faint); letter-spacing: 0.05em; }
now-playing .bmedit label {
  display: block; font-size: 9.5px; font-weight: 900; letter-spacing: 0.10em; text-transform: uppercase;
  color: var(--faint); margin-top: 8px;
}
now-playing .bmedit input {
  width: 100%; background: none; border: 0; border-bottom: 1px solid var(--border);
  padding: 6px 0 7px; font-size: 16px; font-weight: 600; color: var(--text); outline: none;
  letter-spacing: -0.01em; font-family: inherit; border-radius: 0;
}
now-playing .bmedit input:focus { border-bottom-color: var(--accent); }
now-playing .ebtns { display: flex; gap: 8px; margin-top: 14px; }
now-playing .ebtns button {
  flex: 1; height: 44px; border-radius: 11px; font-size: 13px; font-weight: 800;
  border: 0; cursor: pointer; font-family: inherit;
}
now-playing .ebtns .ghost { background: rgba(255, 255, 255, 0.07); color: var(--muted); }
now-playing .ebtns .prime { background: var(--accent); color: #fff; }
now-playing .ebtns .prime:hover { background: var(--accent-hover); }

/* ---- desktop: cover left, controls right; nav drawer from LEFT ---- */
@media (min-width: 900px) {
  now-playing .pl-amb { inset: -12% -6% 26%; filter: blur(78px) saturate(1.55); opacity: 0.46; }
  now-playing .pl-amb img { object-fit: fill; }
  now-playing .veil {
    background: linear-gradient(180deg, rgba(20,20,22,0.38) 0%, rgba(20,20,22,0.76) 52%, var(--bg) 94%);
  }
  now-playing .stage {
    padding: 26px 34px 0;
    overflow-y: visible;
  }
  now-playing .collapse { display: none; }
  now-playing .backpill {
    position: absolute; top: 24px; left: 28px; z-index: 5; height: 44px; padding: 0 16px 0 10px;
    border-radius: var(--radius-pill); display: flex; align-items: center; gap: 5px;
    background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.09);
    font-size: 13px; font-weight: 800; color: var(--text); cursor: pointer; font-family: inherit;
    backdrop-filter: blur(6px);
  }
  now-playing .backpill svg { width: 20px; height: 20px; fill: currentColor; }
  now-playing .backpill:hover { background: rgba(255, 255, 255, 0.14); }

  now-playing .mid {
    flex-direction: row; align-items: center; justify-content: center; gap: 58px;
    width: 100%; max-width: 1060px; margin: 0 auto; padding-bottom: 20px;
  }
  now-playing .coverbox { margin: 0; }
  now-playing .coverwrap { width: 400px; }
  now-playing .side { flex: 1 1 auto; max-width: 604px; min-height: 0; }
  now-playing .booktimes { font-size: 12.5px; margin-top: 12px; }
  now-playing .chhead { margin-top: 8px; justify-content: center; }
  now-playing .chhead .nm { font-size: 24px; }
  now-playing .chhead .ic-list { width: 30px; height: 30px; }
  now-playing .navbar { margin-top: 14px; }
  now-playing .controls { margin-top: 26px; padding-bottom: 0; }
  now-playing .scrub { margin-top: 6px; }
  now-playing .transport { justify-content: center; gap: 30px; margin-left: 0; margin-right: 0; }
  now-playing .ess { gap: 72px; }
  now-playing .pl-toast { bottom: 26px; }

  /* Left-edge drawer (Andrew 2026-07-25 / 3887be1); v8 width 480px kept. */
  now-playing .navp {
    top: 0; left: 0; bottom: 0; right: auto; width: 480px; height: auto;
    border-width: 0 1px 0 0; border-radius: 0; padding: 24px 20px 14px;
    transform: translateX(-101%); transition: transform 0.28s cubic-bezier(0.3, 0.9, 0.3, 1);
  }
  now-playing .navp.open { transform: translateX(0); }

  now-playing .speedp {
    top: 50%; left: 50%; right: auto; bottom: auto; width: 430px;
    border-radius: 20px; border-width: 1px; padding: 22px 22px 26px;
    transform: translate(-50%, -46%) scale(0.97); opacity: 0; pointer-events: none;
    box-shadow: 0 30px 80px rgba(0, 0, 0, 0.62);
    transition: transform 0.2s cubic-bezier(0.3, 0.9, 0.3, 1), opacity 0.2s ease;
  }
  now-playing .speedp.open { transform: translate(-50%, -50%) scale(1); opacity: 1; pointer-events: auto; }
}

@media (prefers-reduced-motion: reduce) {
  now-playing .panel,
  now-playing .pl-backdrop,
  now-playing .pl-toast,
  now-playing .twintrack,
  now-playing .segpill,
  now-playing .bmrow,
  now-playing .bookpill,
  now-playing .bookbar { transition: none; }
}

.seek-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.4rem; }
.seek-row .time { font-variant-numeric: tabular-nums; font-size: 0.78rem; color: var(--muted); width: 3.6rem; text-align: center; }
input[type="range"] { flex: 1; accent-color: var(--accent); min-height: 32px; }
.seek-row input[type=range] { height: 28px; }
.seek-row input[type=range]::-webkit-slider-thumb { width: 20px; height: 20px; min-width: 20px; min-height: 20px; }
.seek-row input[type=range]::-moz-range-thumb { width: 20px; height: 20px; min-width: 20px; min-height: 20px; }


/* ---- sheet (chapters, bookmarks) ----
   Floating card, not an edge-to-edge sheet: inset margins on every viewport width so it
   never reads as a full-width panel, with a proper elevation shadow + backdrop blur for a
   premium feel. Still bottom-anchored on phones (thumb reach); floats up as a centred card
   from tablet width up. */
.sheet-backdrop {
  position: fixed; inset: 0; background: rgba(8, 8, 10, 0.6);
  backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
  z-index: 40;
  display: none;
}
.sheet-backdrop.open { display: block; }
.sheet {
  position: fixed; left: 50%; bottom: 0; transform: translateX(-50%);
  width: min(92vw, 460px);
  max-height: min(76vh, 620px);
  background: var(--surface);
  border: 1px solid var(--border);
  border-bottom: none;
  border-radius: 22px 22px 0 0;
  padding: 1.2rem 1.3rem calc(1.2rem + env(safe-area-inset-bottom));
  overflow-y: auto;
  box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.4);
  z-index: 41;
}
.sheet h3 {
  display: flex; justify-content: space-between; align-items: center;
  margin: 0 0 1rem; padding-bottom: 0.85rem;
  border-bottom: 1px solid var(--border);
  font-size: 1.05rem;
}
@media (min-width: 640px) {
  .sheet { bottom: 6vh; border-radius: 22px; border-bottom: 1px solid var(--border); }
}
.chapter-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 0.8rem;
  padding: 0.85rem 0.6rem;
  border-radius: 12px;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  transition: background 0.15s;
}
.chapter-item:hover { background: rgba(255, 255, 255, 0.04); }
.chapter-item:last-child { border-bottom: none; }
.chapter-item.current { background: rgba(255, 94, 91, 0.12); }
.chapter-item.current span:first-child { color: var(--accent); font-weight: 700; }
.chapter-item .ct { font-variant-numeric: tabular-nums; color: var(--muted); }

/* ---- bookmark popover ---- */
.bm-wrap { position: relative; display: inline-block; }
.bm-popover {
  position: absolute;
  bottom: calc(100% + 0.5rem);
  right: 0;
  width: 240px;
  max-width: calc(100vw - 2rem);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 0.8rem;
  /* Iteration 8 (ticket 09 Group F) — trimmed 24px->16px. */
  box-shadow: 0 8px 16px rgba(0,0,0,0.3);
  display: none;
  z-index: 30;
}
.bm-popover.open { display: block; }
.bm-popover textarea { min-height: 60px; resize: vertical; margin-bottom: 0.6rem; }

.bm-row {
  display: flex;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 0.7rem 0.2rem;
  border-bottom: 1px solid var(--border);
}
.bm-row .note { color: var(--muted); font-size: 0.85rem; }

/* ---- toasts ---- */
#abs-toast-host {
  position: fixed;
  top: max(0.8rem, env(safe-area-inset-top));
  left: 50%;
  transform: translateX(-50%);
  z-index: 60;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  align-items: center;
}
.abs-toast {
  background: rgba(20,20,26,0.92);
  color: #fff;
  padding: 0.55rem 1rem;
  border-radius: 999px;
  font-size: 0.85rem;
  opacity: 0;
  transform: translateY(-8px);
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.abs-toast.show { opacity: 1; transform: translateY(0); }

/* An anchored toast (opts.anchor) escapes the shared top-center host: JS sets left/top from
   the anchor's own getBoundingClientRect(), this just centers on that point and floats above
   it instead of stacking under the host's own top-of-screen position. */
.abs-toast-anchored {
  position: fixed;
  transform: translate(-50%, calc(-100% - 0.5rem));
}
.abs-toast-anchored.show { opacity: 1; transform: translate(-50%, calc(-100% - 0.7rem)); }

/* ---- login ---- */
.field { margin-bottom: 0.8rem; }
.field label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 0.3rem; }
.form-error { color: var(--danger); font-size: 0.85rem; min-height: 1.1rem; }
.link-btn { background: none; border: none; color: var(--accent); text-decoration: underline; padding: 0.3rem; min-width: 0; }

/* ============================================================
   Iteration 5.2 — auth gate (login-screen.js)
   Lifted from ITERATION 5/5.1 - tweak/login-prototype.html. See the real-differences notes
   at the top of login-screen.js for what changed (real login/register form instead of the
   prototype's local profile-tile picker) and what was deliberately dropped (fake shelf stat,
   "skip the password" link, hardcoded server-status footer).
   ============================================================ */
.login-wrap {
  position: relative; z-index: 1; min-height: calc(85vh - 60px);
  display: flex; align-items: center; justify-content: center;
  padding: 2rem 1rem;
}
/* Iteration 8 (ticket 09 Group C) — .gate/.resume/.col-card/.fr-gate/.cat-card all render the
   same two-tone diagonal "glass card" gradient at slightly different angles/tints; consolidated
   into one shared background formula here, each selector overriding only what differs from
   these defaults (which are .gate's own original values) instead of restating the full
   linear-gradient(). Every card's rendered output is unchanged from before this consolidation. */
.gate, .home-wrap .resume, .col-card, .fr-gate, .cat-card {
  background: linear-gradient(var(--glass-angle, 168deg), var(--glass-top, rgba(38, 38, 47, 0.92)), var(--glass-bottom, rgba(20, 20, 24, 0.97)));
}
.gate {
  width: min(420px, 100%);
  border: 1px solid rgba(255, 255, 255, 0.09);
  border-radius: 24px;
  padding: 2.75rem 2.25rem 2rem;
  text-align: center;
  /* Iteration 8 (ticket 09 Group F) — trimmed the ambient shadow radius 120px->90px (kept
     the colored glow + inset; shrink, not remove, per the catalog's own recommendation). */
  box-shadow: 0 50px 90px -40px rgba(0, 0, 0, 0.85), 0 0 90px rgba(255, 94, 91, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.06);
  /* Iteration 8 (ticket 09 Group D) — trimmed from blur(16px): backdrop-filter forces a
     recomposite of everything behind the element on every visible frame; this is the
     highest-value GPU-cost category the ticket-05 catalog found. Still visibly "glass", just
     cheaper. */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}
.gate-mark { display: inline-flex; align-items: center; gap: 10px; color: var(--text); }
.gate-mark .name { font-size: 21px; font-weight: 900; letter-spacing: -0.01em; }
.gate h1 { margin-top: 1.5rem; font-size: 1.7rem; letter-spacing: -0.02em; }
.gate h1 em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); }
.gate-sub { margin-top: 0.4rem; font-size: 0.92rem; color: var(--muted); }
.gate-form { margin-top: 1.6rem; text-align: left; }
.pwd-wrap { position: relative; }
.pwd-wrap input { padding-right: 3rem; }
.pwd-eye {
  position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
  width: 38px; height: 38px; min-width: 0; padding: 0; border: none; background: transparent;
  color: var(--muted); display: flex; align-items: center; justify-content: center;
}
.pwd-eye:hover { color: var(--text); }
.pwd-eye svg { width: 18px; height: 18px; }
.gate-submit { width: 100%; margin-top: 0.4rem; }
.gate-submit:disabled,
.gate-submit[disabled] {
  opacity: 0.45;
  cursor: not-allowed;
  background: rgba(255, 94, 91, 0.3) !important;
  box-shadow: none !important;
  pointer-events: none;
}
.gate-switch { margin-top: 1.2rem; font-size: 0.86rem; color: var(--muted); }

/* Material Design Custom Checkbox */
.field-tos {
  margin: 1.5rem 0 1.2rem;
  text-align: left;
}
.join-confirms .md-checkbox-label + .md-checkbox-label { margin-top: 0.85rem; }
.join-bound-email { overflow-wrap: anywhere; }
.md-checkbox-label {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  cursor: pointer;
  user-select: none;
  position: relative;
}
.md-checkbox-label input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
  min-height: 0;
  pointer-events: none;
}
.md-checkbox {
  width: 20px;
  height: 20px;
  min-width: 20px;
  min-height: 20px;
  border-radius: 4px;
  border: 2px solid rgba(255, 255, 255, 0.3);
  background: transparent;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  color: #fff;
  flex-shrink: 0;
  margin-top: 2px;
}
.md-checkbox-label:hover .md-checkbox {
  border-color: var(--accent);
  background: rgba(255, 94, 91, 0.1);
}
.md-checkbox-label input[type="checkbox"]:checked + .md-checkbox {
  background: var(--accent);
  border-color: var(--accent);
}
.md-checkbox-icon {
  width: 13px;
  height: 13px;
  opacity: 0;
  transform: scale(0.5);
  transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.md-checkbox-label input[type="checkbox"]:checked + .md-checkbox .md-checkbox-icon {
  opacity: 1;
  transform: scale(1);
}
.md-checkbox-label input[type="checkbox"]:focus-visible + .md-checkbox {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.md-checkbox-text {
  font-size: 0.88rem;
  line-height: 1.45;
  color: var(--text);
  opacity: 0.9;
}
.tos-error-msg {
  color: #ff5e5b;
  font-size: 0.8rem;
  margin-top: 0.5rem;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

@media (max-width: 640px) {
  .login-wrap {
    align-items: flex-start;
    padding-top: 1rem;
    min-height: auto;
  }
  .join-wrap {
    position: fixed;
    top: calc(max(1.25rem, env(safe-area-inset-top)) + 54px);
    right: 1.25rem;
    bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 44px + 1.5rem);
    left: 1.25rem;
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  .gate { padding: 2.1rem 1.4rem 1.7rem; }
  /* A filtered ancestor becomes the containing block for fixed descendants. Keep the shared
     card surface intact, but remove its nearly-invisible ambient blur on this phone-only route
     so the dock resolves against the viewport rather than the tall card. */
  .join-wrap .gate {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .join-wrap .gate-form { padding-bottom: calc(44px + 0.75rem); }
  .join-wrap .gate-submit {
    position: fixed;
    left: 50%;
    bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 0.75rem);
    z-index: 21;
    width: calc(min(420px, calc(100vw - 4.5rem)) - 2.8rem - 2px);
    margin-top: 0;
    transform: translateX(-50%);
  }
}

/* Iteration 5.2 — settings-screen.js (see the file's own header comment for what was
   deliberately not ported from settings-prototype.html). Everything else reuses the
   existing .phead/.card/.bm-row components; this is the one genuinely new bit. */
.role-badge {
  display: inline-block; margin-left: 0.4rem; padding: 0.1rem 0.55rem;
  border-radius: var(--radius-pill); background: rgba(255, 94, 91, 0.14);
  color: var(--accent); font-size: 0.72rem; font-weight: 800;
  letter-spacing: 0.04em; text-transform: uppercase; vertical-align: middle;
}

.empty-state { text-align: center; color: var(--muted); padding: 2rem 1rem; }
.list-picker { display: flex; flex-direction: column; gap: 0.5rem; }
.list-picker button { text-align: left; display: flex; justify-content: space-between; }

.loading-spinner {
  width: 28px; height: 28px;
  border: 3px solid var(--border);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  margin: 2rem auto;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ---- a11y: honor reduced-motion for the spinner, toast, and chapter/book progress bars
   (additive; disables only, doesn't change any other rule) ---- */
@media (prefers-reduced-motion: reduce) {
  .scrub-overlay,
  .chapter-bar > span,
  .abs-toast {
    transition: none;
  }
  .loading-spinner,
  .np-quick-bookmark.bm-pulse,
  .np-quick-bookmark.bm-pulse svg,
  .scrub-hint {
    animation: none;
  }
  .scrub-hint { opacity: 0.4; }
}

/* ==== T2 shared class contract (_SHARED-CONTEXT.md §6) ====
   Defined here so T3/T4/T5 only need to add markup with these class names. Additive only —
   the pre-existing .grid-item/.bm-row/etc. rules above stay untouched for back-compat with
   markup that hasn't been migrated yet. */

/* ---- cover art (grid tiles + now-playing) ---- */
.cover-wrap { position: relative; }
img.cover {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  border-radius: var(--radius-helper);
  object-fit: cover;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
  background: var(--surface-elev);
}
.cover-progress {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 3px;
  background: var(--border);
  border-radius: 0 0 var(--radius-helper) var(--radius-helper);
  overflow: hidden;
}
.cover-progress > span {
  display: block;
  height: 100%;
  width: 0%;
  background: var(--accent);
}
.cover-badge {
  position: absolute;
  top: 0.4rem; left: 0.4rem;
  background: var(--accent);
  color: #fff;
  font-size: 0.65rem;
  font-weight: 700;
  line-height: 1;
  padding: 0.3rem 0.5rem;
  border-radius: var(--radius-pill);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.np-cover-wrap img.cover { border-radius: var(--radius-card); }

/* ---- signature element: the bookmark ribbon ----
   Replaces the flat bottom progress bar + pill badge with one motif: a coral ribbon laid over
   the top of the cover whose visible length IS how far into the book you are — the same object
   a real bookmark ribbon is. Untouched books show a clean cover (no ribbon markup is rendered).
   A finished book's ribbon reads as "full" (see coverRibbon() in library-grid.js for the height
   math). Used on every cover in the app (grid tiles, continue-listening, now-playing). */
.cover-ribbon {
  position: absolute;
  top: -2px;
  right: 14%;
  width: 12%;
  min-width: 8px;
  max-width: 16px;
  background: linear-gradient(180deg, var(--accent), var(--accent-hover));
  clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 80%, 0% 100%);
  filter: drop-shadow(0 3px 5px rgba(0, 0, 0, 0.45));
}
.cover-ribbon.done { background: linear-gradient(180deg, var(--accent-hover), var(--accent)); }

/* ---- search ---- */
.search-bar {
  position: sticky;
  top: 0;
  z-index: 10;
  background: var(--bg);
  padding: 0.6rem 0 0.8rem;
}
input.search-input {
  border-radius: var(--radius-pill);
  background: var(--surface-elev);
  border: 1px solid var(--border);
}

/* ---- filter chips ---- */
.filter-bar {
  display: flex;
  gap: 0.5rem;
  overflow-x: auto;
  padding: 0.2rem 0 0.8rem;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.filter-bar::-webkit-scrollbar { display: none; }
.chip {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0 0.9rem;
  border-radius: var(--radius-pill);
  background: var(--surface-elev);
  border: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 700;
  white-space: nowrap;
  cursor: pointer;
}
.chip.active { background: var(--accent); color: #fff; border-color: transparent; }

/* ---- bookmark manager (T5, inside .sheet) ----
   Idle rows show one subtle "edit" icon-button, not two boxy buttons — delete only appears
   inside the edit form (separated left, danger-coloured) so the resting list reads clean. */
.bm-toolbar { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; }
.bm-toolbar .bm-add-btn { flex: 1; }
.bm-view-all {
  display: block; width: 100%; margin: 0.9rem 0 0;
  background: transparent; border: none; color: var(--accent); font-weight: 700;
  text-align: center; cursor: pointer; min-height: auto; padding: 0.5rem;
}
.bm-view-all:hover { color: var(--accent-hover); }

.bm-list { display: flex; flex-direction: column; }
.bm-list .bm-row {
  display: flex;
  align-items: flex-start;
  gap: 0.85rem;
  padding: 0.9rem 0.3rem;
  border-bottom: 1px solid var(--border);
}
.bm-list .bm-row:last-child { border-bottom: none; }
.bm-time {
  flex-shrink: 0;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 0.88rem;
  min-width: 3.4rem;
  padding-top: 0.15rem;
  cursor: pointer;
}
.bm-title { font-weight: 600; flex: 1; min-width: 0; }
.bm-note { color: var(--muted); font-size: 0.82rem; margin-top: 0.2rem; }
.bm-actions { display: flex; align-items: center; gap: 0.3rem; margin-left: auto; flex-shrink: 0; }
.bm-actions .icon-btn { min-width: 38px; min-height: 38px; }
.bm-actions .icon-btn svg { width: 18px; height: 18px; }

/* Edit mode: stacked card (time → fields → actions) instead of the cramped inline row. */
.bm-list .bm-row-edit { flex-direction: column; align-items: stretch; gap: 0.6rem; }
.bm-time-static {
  color: var(--muted); font-variant-numeric: tabular-nums; font-weight: 700; font-size: 0.8rem;
}
.bm-edit-fields { display: flex; flex-direction: column; gap: 0.5rem; }
.bm-edit-fields textarea { min-height: 60px; resize: vertical; }
.bm-actions-edit { width: 100%; margin-left: 0; justify-content: space-between; }
.bm-actions-edit button { min-height: 36px; padding: 0.4rem 0.85rem; font-size: 0.85rem; }
.bm-actions-confirm { display: flex; gap: 0.5rem; }
.bm-delete-btn { color: var(--danger); border-color: var(--danger); }

/* ---- home hero ---- */
.home-wrap { max-width: 1400px; margin: 0 auto; }
.home-hero { margin: 0.5rem 0 1.75rem; }
.home-eyebrow {
  font-size: 0.8rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--accent); margin: 0 0 0.3rem;
}
.home-title { font-size: clamp(1.8rem, 1.3rem + 2.2vw, 3.2rem); margin: 0; }
.home-browse { margin-top: 2.25rem; }
.home-browse-actions { display: flex; gap: 0.6rem; flex-wrap: wrap; }

/* --- category rail: styled as a library index (catalog-card typography, thin coral marker) ---
   minmax(0, 1fr) — NOT a bare 1fr — on every track: a nested auto-fill grid's min-content can
   otherwise "blow out" an ancestor grid track sized by content, overflowing the viewport on
   narrow screens (a classic CSS Grid pitfall). min-width:0 on .lib-main is the same fix
   belt-and-braces, since it's the actual grid item wrapping the nested .grid. */
.lib-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.75rem; }
@media (min-width: 900px) { .lib-layout { grid-template-columns: 260px minmax(0, 1fr); gap: 3rem; } }
.lib-main { min-width: 0; }
.lib-main h1 { margin-bottom: 1.25rem; }
category-sidebar[mode=rail] { display: none; }
@media (min-width: 900px) {
  category-sidebar[mode=rail] {
    display: block; position: sticky; top: 2rem; align-self: start;
  }
}
.cat-nav-title {
  font-size: 0.76rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted); margin: 0 0 0.75rem; padding: 0 0.6rem;
}
.cat-nav button { width: 100%; text-align: left; background: transparent; border: none; padding: 0.6rem; border-radius: 4px; display: block; font-weight: 400; min-height: 44px; }
.cat-nav .cat-head { font-weight: 700; letter-spacing: 0.01em; }
.cat-nav .sub-row { padding-left: 1.4rem; color: var(--muted); min-height: 44px; font-size: 0.92rem; }
.cat-nav .active { color: var(--accent); border-left: 3px solid var(--accent); padding-left: calc(0.6rem - 3px); }
.cat-nav .sub-row.active { padding-left: calc(1.4rem - 3px); }
.lib-cats-btn { display: inline-flex; }
@media (min-width: 900px) { .lib-cats-btn { display: none; } }

/* ============================================================
   Iteration 5.2 — shared site chrome (site-bar.js)
   Lifted from ITERATION 5/5.1 - tweak/landing-prototype-v4-material.html §header/.ambient.
   GLOBAL/unscoped: site-bar is mounted once in index.html and shown on every authenticated
   route (same persistent-element pattern as <mini-player>/#tab-bar).
   ============================================================ */

.ambient { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
/* Iteration 6.1 (perf) — was a viewport-filling background whose gradient position was
   re-set on every mousemove frame, forcing a full-surface repaint each time. Now a small,
   fixed-content layer that's only ever translated — the GPU composites it, it never repaints. */
.amb-lamp {
  /* Iteration 8 (ticket 09 Group B) — one of the two "hover-glow" candidates; shrunk the
     glow radius and peak opacity (400px/0.065 -> 300px/0.045) rather than guessing which of
     the two candidates Andrew meant and only fixing one. */
  position: absolute; top: 0; left: 0; width: 1000px; height: 1000px; margin: -500px 0 0 -500px;
  background: radial-gradient(300px circle at center, rgba(255, 208, 164, 0.045) 0%, rgba(255, 208, 164, 0.015) 40%, rgba(255, 208, 164, 0) 75%);
  transform: translate3d(var(--mx, -600px), var(--my, -600px), 0);
  will-change: transform;
}
.amb-noise {
  position: absolute; inset: 0; opacity: 0.05;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E");
}
/* Iteration 6.1 (perf) — .amb-glow/.amb-aura got their soft look from a live filter: blur()
   on layers that also animate scale forever, which forces the GPU to re-run the blur on
   every single frame, on every page, permanently. Same soft look via wider multi-stop
   gradients instead — the softness is baked into the gradient itself, so the compositor
   only ever runs cheap transform/opacity, no filter. */
.amb-glow, .amb-aura { display: none !important; }

/* Iteration 19, the floating top header (.bar/.bar-in/.wordmark/.topnav/.searchpill/.avatar/
   .readbar) and its `body.has-topbar #app-root { padding-top }` clearance are REMOVED. There is
   one navigation now, #tab-bar at the bottom. #app-root's own top padding (line ~114) was
   always underneath that 104px clearance and is the correct spacing with no header above it,
   so nothing replaces the rule, content simply starts where the page starts.
   site-bar.js still mounts, and still owns .ambient above plus the pointer-lamp loop. */

/* ---- the sign-in button, top right, every page (site-bar.js, 2026-07-26) ----
   Andrew's brief: red borders, transparent background, not too intrusive, white text. Taken
   literally — a genuinely transparent fill, a coral hairline, and a text-shadow so the label
   stays readable where it sits over the home hero's artwork. No backdrop blur: Iteration 6.1
   removed live blurs from this app's shared layers on purpose, and a plate would be the
   "intrusive" this brief rules out.

   DISPLAY, NOT JUST [hidden]: the UA's `[hidden] { display: none }` is a bare attribute
   selector and loses to any class rule that sets display — the bug that left the tab bar
   showing on the offline screen since M4. So the class hides, and `:not([hidden])` shows.

   z-index 38 sits above page content and the library's sticky toolbar (30), below the sheets
   (40/41), the mobile category drawer (55/60) and toasts (60). It is inside <site-bar>, so
   `body.is-player #global-site-bar { display: none }` already takes it off the player, and the
   print rule that hides #global-site-bar already takes it off paper.

   ITERATION 25 MOVED IT INTO THE PAGE. It shipped `position: fixed; right: 1.25/2/3.5rem`,
   which pins it to the VIEWPORT edge. That is right up to 1440, where the content column
   happens to reach the same place, and wrong above it: measured at 1920 the button's right edge
   sat 204px outside the hero panel's, floating in the margin with nothing to belong to. It also
   sat 60px ABOVE the panel at every width, in the gap its own clearance had opened. Andrew:
   "it needs to be an integral part of the page ... just at the upper right corner of the
   homepage, sitting on top of the blurred book cover hero section."

   So the button no longer positions itself. `.signin-slot` does, and it does it by REPEATING
   #app-root's own box — same max-width, same three gutters — with an inner cap of 1400px, which
   is what .home-wrap and .lib-page cap at. The button is then simply flex-end inside that, 14px
   in. No magic offsets, and nothing to re-derive when a gutter changes: the right edge is the
   page's right edge by construction, at every width.

   ABSOLUTE, NOT FIXED, and that is the point rather than an implementation detail. A fixed
   button that stays put while the page moves under it is the definition of not integral, and
   it is also how this app keeps growing overlap defects. Absolute means it scrolls away with
   the hero it sits on, so the only overlap that can ever exist is at scroll 0, which is a check
   that can actually be made exhaustive. It is on screen when every visitor arrives, on every
   page, which is the whole job. */
.signin-slot {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 38;
  max-width: 1600px;
  margin: 0 auto;
  padding: calc(max(1.25rem, env(safe-area-inset-top)) + 14px) 1.25rem 0;
  /* The strip spans the page; only the button in it is a target. */
  pointer-events: none;
}
@media (min-width: 640px) { .signin-slot { padding-left: 2rem; padding-right: 2rem; } }
@media (min-width: 1100px) { .signin-slot { padding: calc(2rem + 14px) 3.5rem 0; } }
/* 1400px is .home-wrap's and .lib-page's cap, so this lands on the page's right edge, not the
   1600px shell's. 14px in from it, so the button sits INSIDE the hero panel's rounded corner
   rather than flush against it. */
.signin-slot-in {
  max-width: 1400px;
  margin: 0 auto;
  display: flex;
  justify-content: flex-end;
  padding-right: 14px;
}
.signin-corner {
  display: none;
  pointer-events: auto;
  min-height: 44px;
  padding: 0 20px;
  border-radius: 999px;
  background: transparent;
  border: 1px solid rgba(255, 94, 91, 0.62);
  color: #ffffff;
  font-size: 14px;
  font-weight: 800;
  letter-spacing: 0.09em;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
  transition: background 160ms, border-color 160ms;
}
.signin-corner:not([hidden]) { display: inline-flex; align-items: center; justify-content: center; }
.signin-corner:hover { background: rgba(255, 94, 91, 0.14); border-color: var(--accent); }
.signin-corner:active { background: rgba(255, 94, 91, 0.22); }

/* Clearance, for the pages with nothing behind the button. Without it the control lands on
   whatever the screen draws in its own top-right corner — at 390px that is /library's "The
   library." title, measured at y 42-80 against a button at 34-78. Same mechanism as the
   `has-topbar` rule Iteration 19 removed, and only ever on while the button is shown.

   `signin-over-hero` gives it back. On the homepage the button is MEANT to overlap: there is a
   panel under it, that panel is what makes it look like part of the page, and 54px of clearance
   is exactly what pushed the panel out from under it. app.js turns the class on for the routes
   that draw such a panel (site-bar.js setSignIn), so the button's own position never moves —
   only what sits beneath it does. */
body.has-signin #app-root { padding-top: calc(max(1.25rem, env(safe-area-inset-top)) + 54px); }
@media (min-width: 1100px) { body.has-signin #app-root { padding-top: calc(2rem + 48px); } }
body.has-signin.signin-over-hero #app-root { padding-top: max(1.25rem, env(safe-area-inset-top)); }
@media (min-width: 1100px) { body.has-signin.signin-over-hero #app-root { padding-top: 2rem; } }
body.has-signin.is-player #app-root { padding-top: 0; }

/* ---- "your email is confirmed, you are signed in" (app.js, after a verification link) ----
   Prepended above the homepage on the one render that follows the redirect. Coral-edged so it
   reads as an event rather than as another section of the page. */
.verified-note {
  max-width: 720px;
  margin: 0 auto 28px;
  padding: 22px 24px;
  border-radius: 20px;
  border: 1px solid rgba(255, 94, 91, 0.42);
  background: linear-gradient(180deg, rgba(255, 94, 91, 0.10), rgba(255, 255, 255, 0.02));
  text-align: center;
}
.verified-note .vn-eyebrow {
  margin: 0 0 6px; font-size: 11px; font-weight: 800; letter-spacing: 0.22em;
  text-transform: uppercase; color: var(--accent);
}
.verified-note h2 { margin: 0 0 8px; font-size: 24px; }
.verified-note p { margin: 0 auto; max-width: 54ch; color: var(--muted); }
.verified-note .vn-close { margin-top: 16px; min-height: 44px; padding: 0 24px; }

/* ---- a verification link that did not work (access-overview-screen.js) ---- */
.av-trouble {
  max-width: 720px;
  margin: 0 auto 40px;
  padding: 20px 24px;
  border-radius: 20px;
  border: 1px solid var(--border);
  background: rgba(255, 255, 255, 0.03);
  text-align: center;
}
.av-trouble p { margin: 0 auto 14px; max-width: 56ch; color: var(--text); }

@media (max-width: 900px) {
  .amb-lamp { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .amb-glow, .amb-aura::before, .amb-aura::after { animation: none !important; }
}

/* ============================================================
   Iteration 5.2 — home/landing page content (home-screen.js)
   Scoped under .home-wrap so nothing here touches the pre-existing global img.cover (square,
   aspect-ratio:1, §6 shared class contract above) used by library-grid.js/continue-listening.js/
   mini-player.js/now-playing.js elsewhere in the app (T2 reskin, approved, untouched). The
   landing page's shelves use the prototype's portrait 2:3 book-cover treatment, but ONLY
   inside .home-wrap.
   ============================================================ */

.home-wrap .hero { margin-top: 0; perspective: 1200px; }
.home-wrap .hero-panel {
  position: relative; border-radius: 28px; overflow: hidden;
  background: #16161b;
  border: 1px solid rgba(255, 255, 255, 0.06);
  padding: 100px 48px 150px;
  /* Iteration 8 (ticket 09 Group F) — trimmed 110px->85px. */
  box-shadow: 0 50px 85px -50px rgba(0, 0, 0, 0.85);
}
/* That 150px of bottom padding exists so the resume card can overlap into it (.resume sits at
   margin-top:-70px). With no resume card to catch - an anonymous visitor, or a signed-in one
   with nothing in progress - it is just 150px of empty panel between the CTA and the first
   shelf, and on a phone that is most of what is left of the fold. */
.home-wrap .hero.no-resume .hero-panel { padding-bottom: 76px; }
/* Iteration 8 (ticket 08) — the 75-tile cloned mosaic + its wall-par/wall-drift/abs-wallpan
   drift animation + hero-wall blur/opacity are replaced by one static flattened image (see
   findings/05-gradient-shadow-catalog.md Group E and issues/08-*.md): a single <img> baked
   from the exact same 75-cover pick, already blurred/darkened at capture time so no live
   filter is computed. .hero-veil (below, unchanged) still darkens it further for text
   legibility, same as it did over the old mosaic.
   Iteration 13 (2026-07-24) rendered that image again from scratch. Ticket 08's asset was
   captured, downscaled to 750x316 and saved at JPEG **quality 10** (7.7 KB), and object-fit:
   cover then stretches it across a panel up to 1400 CSS px, ~4x on a retina desktop. Quality
   10 welds 8x8 block artifacts into the pixels, so no amount of later blurring fixes it: the
   first attempt that same day only rescaled and blurred that file, and it still read as
   pixellated, correctly. The mosaic is therefore re-rendered from the 74 FULL-RESOLUTION cover
   exports in `DEV/ITERATION 7 - performance optimisation/hero-cover-exports/originals/`,
   through the original .hero-wall geometry, so the composition is the approved one and only
   the pixels changed.
   That clean master then shipped at 2600px wide and lightly blurred, and Andrew rejected it
   too: a legible cover wall behind the headline is the wrong look however sharp it is. So the
   shipped asset is deliberately back at Iteration 8's 750x316, from the lossless master rather
   than the quality-10 re-encode, blurred 2.5x harder than that 2600px attempt. At this blur
   there is no detail left for extra resolution to carry, which is what makes 750px a choice
   now instead of damage: 21 KB, against 139 KB sharp and 7.7 KB broken.
   One more re-render after that: Iteration 8 stacked two separate .hero-wall containers to fill
   the height, and the first one's last row was a partial row, so the bottom fifth of the panel
   was bare either side of five centred tiles. The live page hid it by panning the wall; a still
   frame does not. The mosaic is one continuous wall now, so every row the panel can show is a
   full row of covers.
   Still deliberately NOT a CSS filter: blur() - that is the live filter ticket 08 removed, and
   it would cost a full-bleed GPU pass on a panel this size for a result the asset carries for
   free.
   Re-render the mosaic:  DEV/ITERATION 13 - home page redesign/evidence/rebuild-hero-mosaic.mjs
   Re-bake the shipped JPEG (blur/size only):  the blur-hero-flat.py beside it. */
.home-wrap .hero-flat {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}
.home-wrap .hero-veil {
  position: absolute; inset: 0;
  background:
    radial-gradient(ellipse 82% 74% at 50% 42%, rgba(22, 22, 27, 0.02) 0%, rgba(22, 22, 27, 0.5) 62%, rgba(19, 19, 24, 0.9) 100%),
    linear-gradient(to bottom, rgba(22, 22, 27, 0.18), rgba(20, 20, 25, 0.68) 78%, #131318 100%);
}
.home-wrap .hero-inner { position: relative; z-index: 2; max-width: 820px; margin: 0 auto; text-align: center; }
.home-wrap .eyebrow {
  display: flex; align-items: center; justify-content: center; gap: 14px;
  font-size: 11.5px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase;
  color: var(--faint); font-variant-numeric: tabular-nums;
}
.home-wrap .eyebrow::before, .home-wrap .eyebrow::after { content: ""; width: 36px; height: 1px; background: rgba(244, 244, 246, 0.22); }
/* Andrew 2026-07-25: the whole hero stack was opened up (eyebrow -> title -> subtitle -> CTA ->
   tags). Each step below was raised in one pass rather than one at a time, because the ask was
   about the rhythm of the column, not any single gap. Measured before: 24 / 0.22em / 34 / 26. */
.home-wrap .hero h1 {
  margin-top: 34px;
  font-size: clamp(42px, 6.4vw, 78px);
  font-weight: 800; letter-spacing: -0.025em; line-height: 1.03;
  text-wrap: balance;
}
/* The second line of the headline ("Join the library." / "Ready to play.") sits after a <br>,
   so at line-height 1.03 it was jammed against the line above it. inline-block rather than
   block so the shrink-to-fit width still matches the text: the hand-drawn underline below is
   positioned at 96% of this box, and a block would stretch it the full width of the h1.
   In em so the gap scales with the clamped font size instead of collapsing on a phone. */
.home-wrap .hero h1 .serif {
  font-family: var(--font-serif);
  font-style: italic; font-weight: 400; letter-spacing: -0.01em;
  position: relative; white-space: nowrap;
  display: inline-block; margin-top: 0.34em;
}
.home-wrap .hero h1 .serif svg { position: absolute; left: 2%; bottom: -14px; width: 96%; height: 16px; overflow: visible; }
/* The hero call to action. Sized as a primary Material filled button, not a chip.
   Two variants, and the difference is the whole point (Iteration 25):
     .hero-cta            anonymous, "Get access" - filled coral, the one thing to do here
     .hero-cta--quiet     signed in, "Browse the library" - transparent, coral hairline
   The signed-in one used to not exist at all, on the reasoning that the resume card below was
   the next action. That holds for a reader mid-book and for nobody else. */
.home-wrap .hero-cta {
  display: inline-flex; align-items: center; justify-content: center;
  margin-top: 52px; padding: 0 30px; min-height: 52px;
  background: var(--accent); color: #fff;
  font-size: 16px; font-weight: 800; letter-spacing: 0.01em;
  border: 0; border-radius: var(--radius-pill); text-decoration: none;
  box-shadow: 0 14px 34px -10px rgba(255, 94, 91, 0.55);
  transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.home-wrap .hero-cta:hover { background: var(--accent-deep); transform: translateY(-2px); box-shadow: 0 18px 40px -10px rgba(255, 94, 91, 0.6); }
.home-wrap .hero-cta:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
/* One step quieter than the filled pill, on every width. The transparent fill and the border
   alpha are the corner sign-in button's, so "a thing you may do" looks the same wherever it
   appears. It keeps the filled button's size and tracking: quieter, not smaller, since a
   signed-in reader still has to be able to hit it.

   Written as .hero-cta.hero-cta--quiet rather than .hero-cta--quiet ON PURPOSE. The 760px block
   below re-skins .home-wrap .hero-cta to a tonal coral for mobile, at equal specificity and
   later in the file, so a single-class variant would silently lose the fill and the border on
   exactly the width Andrew looks at first. Three classes beats it at every width, in one rule,
   instead of the same declarations copied into the media query.

   Andrew, 2026-07-27: the resting skin IS the hover skin now. He asked for the button to sit at
   the level it previously only reached under the pointer, and measured on the deployed page that
   level is two values rather than an `opacity` (computed opacity is 1 in both states): the tonal
   fill rgba(255, 94, 91, 0.14) and the border at full accent instead of alpha 0.62. Both moved up
   into this rule. What is left on :hover is the 2px lift from `.home-wrap .hero-cta:hover`.

   The :hover rule below deliberately still states these same two values. That is not dead code:
   the 760px block re-skins `.home-wrap .hero-cta:hover` to a 0.28 fill at EQUAL specificity and
   later in the file, so it beats this rule on a phone. Only the four-class hover rule beats it
   back. Emptying that rule would leave the phone brightening on tap while desktop looked right,
   which is the same trap the paragraph above exists to record. */
.home-wrap .hero-cta.hero-cta--quiet {
  background: rgba(255, 94, 91, 0.14);
  border: 1px solid var(--accent);
  color: #ffffff;
  box-shadow: none;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
}
.home-wrap .hero-cta.hero-cta--quiet:hover {
  background: rgba(255, 94, 91, 0.14);
  border-color: var(--accent);
  box-shadow: none;
}

.home-wrap .hero-tags {
  display: flex; flex-wrap: wrap; justify-content: center; gap: 8px;
  margin-top: 40px;
}
/* Real links now, one per existing category, so they need link affordances: the previous
   version was a <span> with cursor:auto that did nothing when clicked. */
.home-wrap .htag {
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.2);
  color: #fff;
  font-size: 13px; font-weight: 600;
  padding: 6px 14px; border-radius: 40px;
  text-decoration: none; cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.home-wrap .htag:hover { background: rgba(255, 255, 255, 0.18); border-color: rgba(255, 255, 255, 0.42); }
.home-wrap .htag:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.home-wrap .resume {
  --glass-angle: 165deg; --glass-top: rgba(41, 41, 49, 0.92); --glass-bottom: rgba(24, 24, 29, 0.95);
  /* Widened 720 -> 820 and repadded so the cover can grow (66 -> 96px) without crowding the
     title, and so the "% done" label has room INSIDE the card - see .ring-label below. */
  margin: -70px auto 0; width: min(820px, 100%);
  position: relative; z-index: 5;
  display: flex; align-items: center; gap: 22px; text-align: left;
  /* Iteration 8 (ticket 09 Group D) — trimmed from blur(18px), same rationale as .gate/.bar. */
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.11);
  border-radius: 22px;
  padding: 22px 26px 22px 22px;
  /* Iteration 8 (ticket 09 Group F) — trimmed 80px->60px. */
  box-shadow: 0 34px 60px -26px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.06);
  transition: transform 0.2s ease, border-color 0.2s ease;
  cursor: pointer;
}
.home-wrap .resume:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.2); }
.home-wrap .resume img.cover { width: 96px; flex: none; }
.home-wrap .resume-meta { flex: 1; min-width: 0; }
.home-wrap .resume-eyebrow {
  font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--accent); margin-bottom: 6px;
}
.home-wrap .resume-title { font-size: 17.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.home-wrap .resume-chap { font-size: 14px; color: var(--muted); font-weight: 500; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.home-wrap .ringbtn { position: relative; width: 74px; height: 74px; flex: none; border: none; cursor: pointer; background: none; border-radius: 50%; padding: 0; min-width: 0; min-height: 0; }
/* THE `width: 100%` IS LOAD-BEARING, not tidiness. `inset: 0` does not size a replaced element
   that already has a width: the <svg> carries width="74" height="74" as presentation
   attributes, which are author-origin CSS, so left:0/right:0 is over-constrained and `right` is
   simply dropped. At 74px the button and the ring happened to be the same size and it looked
   right; the 760px block below shrinks the button to 64px, and the 74px ring stayed 74px,
   anchored top-left, spilling 10px past the button on two sides. Measured at 390px: ring centre
   5px right and 5px below the play core's, which is what Andrew photographed. Sizing from the
   button means any future size lands on one number, in one place. */
.home-wrap .ringbtn svg.ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
.home-wrap .ringbtn .core {
  position: absolute; inset: 9px; border-radius: 50%;
  background: var(--accent);
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 10px 28px -6px rgba(255, 94, 91, 0.5);
  transition: background 0.15s, transform 0.15s;
}
.home-wrap .ringbtn:hover .core { background: var(--accent-deep); transform: scale(1.06); }
.home-wrap .ringbtn .core svg { margin-left: 3px; }
/* The ring button and its "% done" label are a column INSIDE the card. Previously the label
   was position:absolute; bottom:-22px on the button itself, which put it 22px below the card's
   own bottom edge - it rendered outside the frame, overlapping whatever the page drew next. It
   is normal flow now, so the card grows to contain it and it can never escape again. */
.home-wrap .resume-action { display: flex; flex-direction: column; align-items: center; gap: 9px; flex: none; }
.home-wrap .ring-label {
  font-size: 11.5px; font-weight: 700; color: var(--muted); letter-spacing: 0.04em;
  white-space: nowrap; font-variant-numeric: tabular-nums;
}

.home-wrap .section { margin-top: 72px; }
/* Andrew 2026-07-25: consecutive category shelves sat 106px apart at 1440 and 86px at 390
   (measured, last cover's bottom edge to the next heading's top), which read as a gap between
   pages rather than between shelves. Two deliberate narrowings here: only `.shelf-sec` gets it,
   so the signed-in dashboard's "Your listening" and "Continue" sections keep their original
   breathing room; and only shelf-AFTER-shelf via the sibling combinator, so the FIRST shelf
   keeps its full 72px away from the hero. The complaint was the space after a row of books. */
.home-wrap .shelf-sec + .shelf-sec { margin-top: 44px; }
.home-wrap .sec-head { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.home-wrap .sec-head h2 { font-size: 24px; font-weight: 800; letter-spacing: -0.015em; margin: 0; }
.home-wrap .sec-count { font-size: 13.5px; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.home-wrap .sec-rule { flex: 1; height: 1px; background: var(--border); margin-top: 3px; }
/* "See all" - lifted from ITERATION 11's approved mockup-v4.html (.seeall, the on-brand tonal
   pill). Replaces the old ghost "Browse all ›" text link. Two things came across with it: it is
   a real tonal surface with a border rather than bare text, and it sits immediately after the
   count instead of being pushed to the far right by .sec-rule, so the pill stays tied to the
   shelf it belongs to. Ours is an <a> where the mockup used a <button>, hence the extra
   text-decoration/cursor. */
.home-wrap .seeall {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  color: var(--text); font-weight: 700; font-size: 12px; letter-spacing: 0.01em;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-pill); padding: 0 13px; min-height: 32px;
  box-shadow: var(--sheen); white-space: nowrap;
  text-decoration: none; cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.home-wrap .seeall:hover { background: var(--surface-elev); border-color: #565660; }
.home-wrap .seeall:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.home-wrap .seeall .ar { color: var(--accent); font-weight: 800; font-size: 15px; line-height: 1; margin-top: -1px; }

/* "Your listening" strip - signed-in only. Flat Material surface cards: one number, one label,
   no chart. The depth lives at /stats; this is the glance on the way past. */
.home-wrap .stat-row { display: flex; flex-wrap: wrap; gap: 14px; }
.home-wrap .statcard {
  flex: 1 1 180px; min-width: 160px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-card); box-shadow: var(--sheen);
  padding: 20px 22px;
}
.home-wrap .statcard .sv {
  display: block; font-size: 34px; font-weight: 800; letter-spacing: -0.02em;
  line-height: 1.05; font-variant-numeric: tabular-nums;
}
.home-wrap .statcard .sv i { font-style: normal; font-size: 19px; font-weight: 700; color: var(--muted); margin-left: 3px; }
.home-wrap .statcard .sl { display: block; margin-top: 7px; font-size: 13.5px; font-weight: 600; color: var(--muted); }

/* Iteration 8 (perf/visual, ticket 09 Group A) — shrunk from 76px: the edge fade is the
   "column side-shadow" Andrew called heavy; mask-image is compositor-cheap either way, this
   is a visual trim, not a GPU fix. Still wide enough to signal there's more to scroll. */
/* display: flow-root is load-bearing, not tidiness. .xwrap has no border, no padding and no
   BFC, so .shelf-row's -8px top margin collapsed straight through it and lifted its box 8px.
   .xwrap is the containing block for the .snav arrows (top: var(--snav-top)), so the arrows
   slid 8px up off the covers while the covers themselves looked correct. Caught by measuring
   the arrow, not the cover. */
.home-wrap .xwrap { position: relative; display: flow-root; --fade: 32px; }
.home-wrap .xwrap.can-r .xrow {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - var(--fade)), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - var(--fade)), transparent);
}
.home-wrap .xwrap.can-l .xrow {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade));
  mask-image: linear-gradient(to right, transparent, #000 var(--fade));
}
.home-wrap .xwrap.can-l.can-r .xrow {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade), #000 calc(100% - var(--fade)), transparent);
  mask-image: linear-gradient(to right, transparent, #000 var(--fade), #000 calc(100% - var(--fade)), transparent);
}
.home-wrap .xrow {
  display: flex; overflow-x: auto;
  scroll-snap-type: x proximity; scroll-padding-inline: 4px;
  scrollbar-width: none;
}
.home-wrap .xrow::-webkit-scrollbar { display: none; }
/* DE-SCOPED FROM `.home-wrap .snav` ON 2026-07-27, and the reason is a rule this program has
   already written twice. Andrew: "there is no [scroll right] arrow button, so users can horizontal
   scroll book shelves", about the BOOK PAGE, whose rails scroll and had no control to scroll them
   with. Home has had this arrow since Iteration 8. Copying it would have made two implementations
   of one control, which is what Iteration 27 v8 deleted a whole backdrop over and what Iteration
   29's `.opendot` was de-scoped to avoid. So the component lives here, unscoped, and each surface
   supplies only its own geometry: where the arrows hang, and what they are centred on.

   Every declaration below is byte-identical to the `.home-wrap`-scoped version it replaces, and
   home's rendering is asserted unchanged rather than assumed: see verify-four-fixes.mjs, which
   measures home's arrows before and after. Specificity drops from (0,2,0) to (0,1,0); nothing in
   this stylesheet targets `.snav` at any weight, and the global `button` rule is (0,0,1), so the
   cascade lands where it did. The three releases (padding, min-width, min-height) are still what
   lets a 40px square hold a 40px square, per the note at the top of the book-page block. */
.snav {
  position: absolute; z-index: 6; top: var(--snav-top, 50%); transform: translateY(-50%);
  width: 40px; height: 40px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.14);
  /* Iteration 8 (ticket 09 Group D) — trimmed from blur(8px); already the cheapest of the 4
     (small, hover-only, hidden on touch), left as a minor consistency trim. */
  background: rgba(22, 22, 27, 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  color: var(--text); display: flex; align-items: center; justify-content: center;
  cursor: pointer; opacity: 0; padding: 0; min-width: 0; min-height: 0;
  transition: opacity 0.2s, background 0.15s, border-color 0.15s;
  box-shadow: 0 10px 26px -8px rgba(0, 0, 0, 0.7);
}
.snav.prev { left: -14px; }
.snav.next { right: -14px; }
.snav:hover { background: rgba(34, 34, 40, 0.95); border-color: rgba(255, 255, 255, 0.26); }
/* Hidden where there is no pointer to reveal them with, on both surfaces. A touch user scrolls a
   rail by dragging it, so the capability is not missing; the control for it is. */
@media (hover: none) { .snav { display: none; } }
.home-wrap .xwrap.can-l:hover .snav.prev { opacity: 1; }
.home-wrap .xwrap.can-r:hover .snav.next { opacity: 1; }

.home-wrap .cont-row { gap: 20px; padding: 4px 2px 24px; }
.home-wrap .contcard {
  flex: none; width: 372px; display: flex; gap: 16px; align-items: center;
  background: #1b1b20;
  border: 1px solid var(--border);
  border-radius: 20px;
  padding: 14px 18px 14px 14px; cursor: pointer; scroll-snap-align: start;
  transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.home-wrap .contcard:hover { transform: translateY(-3px); border-color: rgba(244, 244, 246, 0.16); background: #222228; }
.home-wrap .contcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.home-wrap .contcard img.cover { width: 62px; flex: none; }
.home-wrap .cont-meta { flex: 1; min-width: 0; }
.home-wrap .cont-title { font-size: 15.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.home-wrap .cont-chap { font-size: 13px; font-weight: 500; color: var(--muted); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.home-wrap .cont-bar { position: relative; margin-top: 11px; height: 4px; border-radius: 99px; background: rgba(255, 255, 255, 0.1); overflow: hidden; }
.home-wrap .cont-bar i { display: block; height: 100%; border-radius: 99px; background: var(--accent); }
.home-wrap .cont-bar::after {
  content: ""; position: absolute; inset: 0;
  background: repeating-linear-gradient(90deg, transparent 0 calc(25% - 1.5px), rgba(16, 16, 19, 0.9) calc(25% - 1.5px) 25%);
}
.home-wrap .cont-left { margin-top: 7px; font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }
.home-wrap .cont-play {
  width: 44px; height: 44px; border-radius: 50%; flex: none; border: 1.5px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.05); display: flex; align-items: center; justify-content: center;
  transition: background 0.15s, border-color 0.15s;
}
.home-wrap .contcard:hover .cont-play { background: var(--accent); border-color: var(--accent); }
.home-wrap .cont-play svg { margin-left: 2px; }

/* padding-bottom 34 -> 18 (Andrew 2026-07-25, same "too much space after a row of books" ask).
   It cannot go below ~14: .xrow is overflow-x:auto, which makes overflow-y compute to auto too,
   so the hover drop-shadow on .cover-wrap::after (bottom: -13px) needs this much room or it gets
   clipped and the row grows a phantom vertical scroll. */
/* padding-top 6 -> 14 + margin-top -8px: the same 2px hover clip as .book-page .rail. Here
   overflow-y is not even declared, it computes to `auto` because overflow-x is `auto`,
   which is the trap the note above already describes for the bottom edge. --snav-top is
   unchanged and measured unchanged: the arrows are positioned from .xwrap, which edit 3
   holds still. */
.home-wrap .shelf-row { gap: 26px; padding: 14px 2px 18px; margin-top: -8px; --snav-top: 132px; }
.home-wrap .bookcard { flex: none; width: 172px; scroll-snap-align: start; cursor: pointer; position: relative; }
.home-wrap .bookcard img.cover { transition: transform 0.22s ease; will-change: transform; }
.home-wrap .bookcard:hover img.cover { transform: translateY(-8px); }
.home-wrap .bookcard .cover-wrap::after {
  /* Iteration 8 (ticket 09 Group G) — the other "hover-glow" candidate; shrunk alongside
     .amb-lamp above rather than guessing which one Andrew meant. */
  content: ""; position: absolute; left: 8%; right: 8%; bottom: -13px; height: 16px;
  background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.4), transparent 70%);
  transition: transform 0.22s ease, opacity 0.22s ease;
  will-change: transform, opacity;
}
.home-wrap .bookcard:hover .cover-wrap::after { transform: scaleX(0.92); opacity: 0.55; }
/* The cover badge, shared with the book page's rails: one 28px dim chevron, bottom-right,
   defined once as `.opendot` beside `.book-page .tile`. This surface only says WHEN to show it.
   It replaces a 52px coral play disc, centred, which sat on a card whose handler is
   navigate('/book/:id') and so painted a transport control on a control that navigates.

   The transform is the one thing home cannot inherit. The book page rises `.tcov`, which
   CONTAINS its badge, so the chevron rides up for free; here the rise is on img.cover and the
   badge is its sibling inside `.cover-wrap`, which does not move. Measured hovered on
   production: the book page badge holds its 7px inset in both states, home's drifted the full
   8px as the cover rose out from under it, and `.cover-wrap` is overflow:visible so it would
   have hung below the artwork rather than being clipped by it. So the badge takes the rise
   itself, same 8px, same 0.22s ease, and tracks the cover exactly.

   `.cover-wrap::after` deliberately does NOT get it: that ellipse is the shadow the cover lifts
   AWAY from, and it stays planted. */
.home-wrap .bookcard:hover .opendot,
.home-wrap .bookcard:focus-visible .opendot { opacity: 1; transform: translateY(-8px); }
.home-wrap .bookcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 8px; }
/* F10. Home and Library are two tabs of one app showing the same object one swipe apart, and
   the card's title/author/duration role carried THREE specs: 15.5/13.5/12.5 here, 14/12.5/11.5
   on the library desktop, 13/11.5/10.5 on the library phone, with the duration --muted here and
   --faint there. Sizes now come from the shared type scale (--t-lg / --t-md / --t-sm), which is
   the same ladder the library card reads at this cover width, so the two surfaces agree by
   construction rather than by two people picking similar numbers.
   The intra-card GAPS stay as they are (15/4/8 here against 11/3/5 on the library): a 172px
   shelf card standing in a scrolling row wants more air under the cover than a card packed in a
   grid, and that is a layout decision, not a type one. */
.home-wrap .bk-title {
  margin-top: 15px; font-size: var(--t-lg); font-weight: 700; line-height: 1.32;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.home-wrap .bk-author { margin-top: 4px; font-size: var(--t-md); font-weight: 500; color: var(--muted); }
.home-wrap .bk-meta { margin-top: 8px; display: flex; align-items: center; gap: 12px; font-size: var(--t-sm); font-weight: 600; font-variant-numeric: tabular-nums; }
.home-wrap .bk-dur { display: flex; align-items: center; gap: 5px; color: var(--muted); }

/* Real cover art (photographic, not the prototype's CSS-mock books) — portrait book-cover
   proportions ONLY within the home screen. Overrides the base img.cover aspect-ratio just for
   .home-wrap; the base rule (§6 shared class contract) stays untouched for every other screen. */
.home-wrap img.cover {
  aspect-ratio: 1;
  /* F2. Was 4.5px. Same object, same corner, one token — see the note on .lib-page img.cover. */
  border-radius: var(--radius-helper);
  /* Iteration 8 (ticket 09 Group F) — trimmed the mid shadow 26px->18px. */
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.4),
    0 10px 18px -8px rgba(0, 0, 0, 0.55),
    inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}

.home-wrap .colophon { margin-top: 104px; text-align: center; }
.home-wrap .colophon blockquote {
  font-family: var(--font-serif); font-style: italic; font-weight: 400;
  font-size: clamp(21px, 2.6vw, 28px); line-height: 1.52; letter-spacing: -0.005em;
  color: rgba(244, 244, 246, 0.82); max-width: 640px; margin: 0 auto; text-wrap: balance;
}
.home-wrap .ql { position: relative; white-space: nowrap; }
.home-wrap .ql i {
  position: absolute; inset: -1px -5px -3px; z-index: -1; border-radius: 3px;
  background: rgba(255, 94, 91, 0.18);
  transform: scaleX(0); transform-origin: left center;
}
.home-wrap .colophon.in .ql i { animation: abs-sweeponce 1s 0.55s cubic-bezier(0.3, 0.6, 0.2, 1) forwards; }
@keyframes abs-sweeponce { to { transform: scaleX(1); } }
.home-wrap .attr { margin-top: 18px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.32em; text-transform: uppercase; color: var(--faint); }

.home-wrap footer { margin-top: 92px; }
/* Three columns since the "Your listening" one moved up the page into its own section. */
.home-wrap .foot-grid {
  display: grid; grid-template-columns: 1.5fr 0.9fr 0.9fr; gap: 44px;
  border-top: 1px solid var(--border); padding: 54px 0 64px;
}
/* The footer wordmark. It had ONE declaration, a font-size, and nothing else: `.wordmark` was
   the removed top header's class, and when Iteration 19 deleted that block the footer's copy of
   it lost everything except this line. Measured before the fix: an inline anchor at
   `rgb(255, 94, 91)` and font-weight 400, because the generic `a` rule is coral. So the brand
   name rendered as an ordinary coral link, lighter than the small print underneath it, with the
   logo mark sitting on the text baseline. Andrew, 2026-07-26: "it is off-brand, make it bold and
   white." It is the app's name, so it is now set like the app's name, and the rules it needs are
   written here rather than inherited from a block that no longer exists. */
.home-wrap .foot-brand .wordmark {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  color: #ffffff;
  text-decoration: none;
}
.home-wrap .foot-brand .wordmark .name { font-size: 18px; font-weight: 900; letter-spacing: -0.01em; color: #ffffff; }
.home-wrap .foot-brand .wordmark:hover .name { color: #ffffff; }
.home-wrap .foot-brand .wordmark:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 6px; }
.home-wrap .foot-line {
  margin-top: 16px; font-family: var(--font-serif); font-style: italic;
  font-size: 16.5px; color: rgba(244, 244, 246, 0.68); line-height: 1.55; max-width: 300px;
}
.home-wrap .foot-small { margin-top: 14px; font-size: 13px; font-weight: 500; color: var(--muted); line-height: 1.6; max-width: 300px; }
.home-wrap .fh { font-size: 10.5px; font-weight: 700; letter-spacing: 0.26em; text-transform: uppercase; color: var(--faint); margin-bottom: 14px; }
.home-wrap .fl a { display: block; color: var(--muted); text-decoration: none; font-size: 14.5px; font-weight: 600; padding: 5px 0; transition: color 0.15s; }
.home-wrap .fl a:hover { color: var(--text); }
/* The second footer bar (.foot-bottom: "Your library, your machine." + a "self-hosted" pill)
   was removed on Andrew's instruction, 2026-07-25 - with its own border-top under the brand
   grid's, the page ended in two stacked rules and read as two footers. Its .foot-note / .ver
   rules went with it; both classes were used nowhere else in web/app (grepped). The brand
   column already says the same thing in .foot-small, so nothing true was lost. The grid's
   bottom padding absorbs the space the removed bar used to hold. */

.home-wrap .reveal { opacity: 0; transform: translateY(16px); transition: opacity 0.7s cubic-bezier(0.2, 0.7, 0.2, 1), transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1); }
.home-wrap .reveal.in { opacity: 1; transform: none; }

@media (max-width: 760px) {
  .home-wrap .hero-panel { border-radius: 22px; padding: 72px 22px 132px; }
  .home-wrap .hero.no-resume .hero-panel { padding-bottom: 56px; }
  /* Andrew 2026-07-25: `width: 100%` made this a 304px coral banner across the whole hero, which
     read as a section divider rather than a button. Back to shrink-to-fit, and tonal instead of
     filled: a semi-transparent coral surface with the accent as a real border. Desktop is
     untouched and keeps the filled button - Andrew scoped this one to mobile, and the desktop
     hero is approved work. `border: 1.5px` costs no height, `* { box-sizing: border-box }`. */
  .home-wrap .hero-cta {
    margin-top: 44px; width: auto; padding: 0 32px;
    background: rgba(255, 94, 91, 0.16);
    border: 1.5px solid var(--accent);
    box-shadow: none;
  }
  .home-wrap .hero-cta:hover { background: rgba(255, 94, 91, 0.28); box-shadow: none; }
  .home-wrap .resume { margin-top: -62px; width: 100%; padding: 16px 18px 16px 16px; gap: 16px; }
  .home-wrap .resume img.cover { width: 76px; }
  .home-wrap .ringbtn { width: 64px; height: 64px; }
  .home-wrap .section { margin-top: 56px; }
  .home-wrap .sec-head { gap: 10px; }
  .home-wrap .sec-head h2 { font-size: 21px; }
  .home-wrap .sec-rule { display: none; }
  /* With .sec-rule gone the pill has nothing pushing it right, so send it there itself -
     otherwise it sits jammed against the count on a narrow screen. */
  .home-wrap .seeall { margin-left: auto; }
  /* Three tiles, one row, even at 390px. With a min-width they wrapped 2 + 1, which read as a
     layout accident rather than a set. */
  .home-wrap .stat-row { gap: 10px; }
  .home-wrap .statcard { flex: 1 1 0; min-width: 0; padding: 14px 12px; }
  .home-wrap .statcard .sv { font-size: 26px; }
  .home-wrap .statcard .sv i { font-size: 15px; }
  .home-wrap .statcard .sl { font-size: 12px; margin-top: 5px; }
  .home-wrap .eb-count { display: none; }
  .home-wrap .shelf-sec + .shelf-sec { margin-top: 34px; }
  .home-wrap .shelf-row { gap: 18px; padding-bottom: 16px; }
  .home-wrap .bookcard { width: 140px; }
  .home-wrap .contcard { width: 296px; }
  .home-wrap .colophon { margin-top: 76px; }
  .home-wrap .foot-grid { grid-template-columns: 1fr 1fr; gap: 32px; padding: 44px 0 52px; }
  .home-wrap .foot-brand { grid-column: 1 / -1; }
}
@media (prefers-reduced-motion: reduce) {
  .home-wrap .reveal { opacity: 1; transform: none; }
}

/* ============================================================
   Iteration 5.2 — library/browse page (library-grid.js)
   Lifted from ITERATION 5/5.1 - tweak/browse-prototype.html. See the real-data notes at the
   top of library-grid.js for what was deliberately NOT ported (fake ratings/scan line, the
   sort/list-view toggle, the separate progress-bar+badge motif — the app-wide bookmark ribbon
   is reused instead) and why JetBrains Mono isn't a second self-hosted webfont here.
   ============================================================ */

/* F9. `.lib-page { max-width: 1400px; margin: 0 auto }` used to live here and was cancelled
   1,000 lines later by `.lib-page { max-width: none; margin: 0 }` at equal specificity, so the
   cap has been inert since the redesign landed and both lines are gone. The remaining rules in
   this block are NOT dead and were not deleted: `.phead` / `.psum` are the app-wide page-head
   pair (queue, stats, admin, settings, collections, bookmarks, categories all render them), and
   `.lib-page .bookcard` / `.playdot` / `.bk-meta` / `.bk-dur` / `img.cover` still paint on
   /book/:id and /author/:name, whose wrappers carry `.lib-page` as a second class to reuse this
   exact tile. Grepped across web/app before deleting anything; what went is listed at each site. */
.phead { display: flex; align-items: flex-end; gap: 1.2rem; flex-wrap: wrap; margin-bottom: 0.4rem; }
.phead h1 { font-size: clamp(2rem, 1.5rem + 2vw, 3.2rem); margin: 0; }
.phead h1 em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); }
.psum {
  margin-left: auto; text-align: right; font-size: 0.76rem; font-weight: 700;
  letter-spacing: 0.05em; text-transform: uppercase; color: var(--faint);
  line-height: 1.9; font-variant-numeric: tabular-nums;
}

/* F9. `.lib-toolbar` + `.lib-toolbar .search-bar` deleted: `grep -rn "lib-toolbar" web/app`
   returns nothing outside this file. The redesign's toolbar is `.lib-page .toolbar`. */

/* the grid renders PAGE_SIZE tiles at a time (library-grid.js) — rendering all 3,600+ at
   once is what froze the tab; this button pulls in another page. */
.lib-loadmore { display: flex; justify-content: center; margin: var(--sp-6) 0 var(--sp-3); }

/* F9. `.chip .n` deleted: no element in web/app carries the class token `chip` any more (the
   library's chips are `.fchip`, the book page's are `.genre-chip`, and neither matches `.chip`).
   The `.chip` / `.chip.active` rules at the top of this file stay — they are named in the §6
   shared class contract that test/frontend-static.test.js asserts. */

/* book cards — reuses the bk-title/bk-author/bk-meta/playdot visual language the home-screen
   shelves established (home-wrap .bookcard etc.), but with sizing rules of its own: this grid
   uses CSS Grid auto-fill tracks, not a horizontal-scrolling shelf row, so a shared width/flex
   rule would fight the grid track sizing instead of reusing cleanly. */
.lib-page .bookcard { cursor: pointer; position: relative; }
.lib-page .bookcard img.cover { transition: transform 0.22s ease; will-change: transform; }
.lib-page .bookcard:hover img.cover,
.lib-page .bookcard:focus-visible img.cover { transform: translateY(-6px); }
.lib-page .bookcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: var(--radius-chip); }
/* F9. This is the tile play affordance for /book/:id and /author/:name, the two screens whose
   wrapper carries `.lib-page` as a second class. It is NOT dead — measured on /book/:id, 1,102
   of them render — but until now it lost a fight it should never have been in: the redesign
   block declared `.lib-page .playdot` a second time, 1,270 lines below, as a 38x38 corner pill,
   and at equal specificity source order handed it every one of these. The result on /book and
   /author was a 46px coral disc bursting out of a 38px coral pill in the bottom-right corner.
   The redesign copy is gone (the library grid emits no .playdot at all — `_card()` renders
   .cover > img + .pill and .meta only, and the selector returned 0 on /library at 1440 and
   1920), so this one is now the only declaration and paints as it was designed to. */
.lib-page .playdot {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  opacity: 0; transition: opacity 0.18s ease;
}
.lib-page .playdot span {
  width: 46px; height: 46px; border-radius: 50%; background: var(--accent);
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 10px 30px -6px rgba(0, 0, 0, 0.6);
  transform: translateY(4px); transition: transform 0.18s ease;
}
.lib-page .playdot span svg { margin-left: 3px; }
.lib-page .bookcard:hover .playdot,
.lib-page .bookcard:focus-visible .playdot { opacity: 1; }
.lib-page .bookcard:hover .playdot span,
.lib-page .bookcard:focus-visible .playdot span { transform: translateY(-4px); }
.lib-page .bk-meta {
  margin-top: 0.4rem; display: flex; align-items: center; gap: 0.6rem;
  font-size: 0.72rem; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums;
}
.lib-page .bk-dur { display: flex; align-items: center; gap: 0.3rem; }

/* F9. Three `.lib-page .bk-title-btn` rules and their Iteration 5.2 note deleted:
   `grep -rn "bk-title-btn" web/app` returns nothing but this file. The nested title link they
   describe was removed from the tile when library-grid.js was rewritten; the book and author
   tiles are a single `role="button"` div with a plain title. */

/* F2. Every cover on the page now takes ONE radius token. This rule said 4.5px, the library
   grid said 14px, the list row said 11px, the base img.cover said var(--radius-helper) and the
   now-playing cover said var(--radius-card): five corners for one object, on the page whose
   entire content is that object. --radius-helper (12px) is the token the base cover already
   used, so it wins; the two nearest literals move 2px in (14 -> 12) and 1px out (11 -> 12), and
   this one moves 7.5px out, which is the only visible change of the three and is the point:
   4.5px on a 172px cover reads as a photograph, not as a book. */
.lib-page img.cover {
  aspect-ratio: 1;
  border-radius: var(--radius-helper);
  /* Iteration 8 (ticket 09 Group F) — trimmed the mid shadow 26px->18px, matching .home-wrap img.cover. */
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 18px -8px rgba(0, 0, 0, 0.55), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}

@media (max-width: 640px) {
  .phead { flex-direction: column; align-items: flex-start; gap: 0.3rem; }
  .psum { margin-left: 0; text-align: left; }
}

/* ============================================================
   Iteration 5.2 — search page (search-screen.js), new /app/search route.
   Lifted from ITERATION 5/5.1 - tweak/search-prototype.html. See the real-data notes at the
   top of search-screen.js for what was deliberately dropped (chapter-title search, fake
   recent-search chips, the fabricated "0.04s local index" readout).
   ============================================================ */
.search-page { max-width: 900px; margin: 0 auto; }
.search-page .shead { text-align: center; padding-top: 0.5rem; }
.search-page .eyebrow-plain {
  font-size: 0.72rem; font-weight: 800; letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--accent);
}
.search-page .shead h1 { margin-top: 0.6rem; }
.search-page .shead h1 em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); }

.search-hero { position: relative; margin: 1.6rem auto 0; max-width: 620px; }
.search-hero .mag { position: absolute; left: 1.15rem; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--muted); pointer-events: none; }
/* F6. This field and the library toolbar's field are the same control on two screens, and they
   disagreed on five properties: 55.6 against 44px tall, 16.8 against 15px, weight 700 against
   500, a 17px literal radius against --radius-chip, and opposite elevation. Four of the five are
   now one decision each, stated:
   - WEIGHT is 500 on both, which is what .sidesearch and the phone popup's field already use.
     700 is a heading weight; nobody types in bold.
   - RADIUS 17px was the only radius in the file that named nothing. --radius (18px) is the
     nearest token, 1px out, and it is what the card this field sits on top of already uses.
   - HEIGHT stays different on purpose. 44px is the toolbar's control height, shared with the
     sort button, the density switch and the category button beside it; 56px is a hero on a
     screen whose only job is this field. A control's height belongs to its row, not to its name.
   - ELEVATION is one rule producing both, not two contradictory ones: a field sits one step away
     from the surface UNDER it, in whichever direction has contrast. Inside the library's
     --surface toolbar card it recesses to --bg; alone on the --bg search page it raises to
     --surface-elev. Same rule, opposite direction, because the backgrounds are opposite. */
.search-hero input {
  width: 100%; padding: 1.05rem 1.2rem 1.05rem 2.9rem; border-radius: var(--radius);
  background: var(--surface-elev); border: 1px solid var(--border);
  font-size: 1.05rem; font-weight: 500;
}

.rmeta { margin-top: 2.6rem; display: flex; align-items: center; gap: 1rem; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; color: var(--faint); text-transform: uppercase; }
.rmeta b { color: var(--accent); font-weight: 800; }
.rmeta .rule { flex: 1; height: 1px; background: var(--border); }

.group { margin-top: 1.7rem; }
.ghead { display: flex; align-items: baseline; gap: 0.7rem; margin-bottom: 0.8rem; }
.ghead h2 { font-size: 1rem; margin: 0; letter-spacing: 0.02em; text-transform: uppercase; }
.ghead .n { font-size: 0.72rem; color: var(--faint); font-weight: 600; }

.bres { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 0.75rem; }
.bcard {
  display: flex; gap: 0.9rem; align-items: center; padding: 0.8rem 0.9rem;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-helper);
  cursor: pointer; transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.bcard:hover, .bcard:focus-visible { transform: translateY(-2px); border-color: var(--accent); background: var(--surface-elev); }
.bcard .cover-wrap { width: 48px; flex: none; }
.bcard img.cover { aspect-ratio: 1; }
.bmeta { flex: 1; min-width: 0; }
.bt { font-size: 0.92rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ba { margin-top: 0.1rem; font-size: 0.8rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bx { margin-top: 0.3rem; font-size: 0.72rem; letter-spacing: 0.02em; color: var(--faint); }
.bpills { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
.bpill {
  display: inline-flex; align-items: center;
  min-width: 0; max-width: 100%;
  padding: 2px 8px;
  border-radius: var(--radius-pill);
  font-size: 0.66rem; font-weight: 700; letter-spacing: 0.01em; line-height: 1.5;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bpill.cat { color: var(--text); background: var(--surface-elev); border: 1px solid var(--hair); }
.bpill.cat.t-psy { background: var(--t-psy); border-color: transparent; }
.bpill.cat.t-biz { background: var(--t-biz); border-color: transparent; }
.bpill.cat.t-tech { background: var(--t-tech); border-color: transparent; }
.bpill.cat.t-health { background: var(--t-health); border-color: transparent; }
.bpill.sub { color: var(--faint); background: transparent; border: 1px solid var(--border); font-weight: 600; }
.bplay {
  width: 34px; height: 34px; border-radius: 50%; flex: none;
  border: 1.5px solid var(--border); display: flex; align-items: center; justify-content: center;
  transition: background 0.15s, border-color 0.15s;
}
.bcard:hover .bplay, .bcard:focus-visible .bplay { background: var(--accent); border-color: var(--accent); }

.ares { display: flex; gap: 0.7rem; flex-wrap: wrap; }
.acard {
  display: flex; align-items: center; gap: 0.7rem; padding: 0.5rem 1.1rem 0.5rem 0.5rem;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-pill);
  cursor: pointer; transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.acard:hover, .acard:focus-visible { transform: translateY(-2px); border-color: var(--accent); background: var(--surface-elev); }
.aavatar {
  width: 42px; height: 42px; border-radius: 50%; flex: none;
  display: flex; align-items: center; justify-content: center;
  font-weight: 800; font-size: 0.85rem; color: #fff;
  background: hsl(var(--av-hue, 355), 55%, 32%);
}
.an { font-size: 0.88rem; font-weight: 800; }
.ac { margin-top: 0.05rem; font-size: 0.72rem; color: var(--faint); }

.search-page .empty { text-align: center; padding: 3.5rem 1rem 2rem; }
.search-page .empty-orb {
  width: 68px; height: 68px; border-radius: 50%; margin: 0 auto; display: flex; align-items: center; justify-content: center;
  color: var(--accent); background: rgba(255, 94, 91, 0.08); border: 1px solid rgba(255, 94, 91, 0.2);
}
.search-page .empty-orb svg { width: 26px; height: 26px; }
.search-page .empty-h { margin-top: 1.4rem; font-size: clamp(1.4rem, 1.1rem + 1.4vw, 2rem); max-width: 22ch; margin-left: auto; margin-right: auto; }
.search-page .empty-h em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); }
.search-page .empty-sub { margin-top: 0.6rem; }
.search-page .empty-actions { margin-top: 1.4rem; display: flex; gap: 0.7rem; flex-wrap: wrap; justify-content: center; }

mark { background: rgba(255, 94, 91, 0.24); color: var(--accent-hover); border-radius: 2px; padding: 0 1px; }

@media (max-width: 640px) {
  /* minmax(0, 1fr), NOT 1fr. A bare `1fr` is `minmax(auto, 1fr)`, and that `auto` is a track
     MINIMUM: the column may never be narrower than the widest item's min-content width. .bt/.ba
     are `white-space: nowrap`, whose min-content is the whole untruncated string (ellipsis
     changes what is painted, not the layout minimum), so one 99-character title floored this
     track at 803px inside a 350px phone container and #app-shell's overflow-x: hidden clipped
     the evidence. The explicit 0 minimum lets the track match the container and the titles
     ellipsise as intended. */
  .bres { grid-template-columns: minmax(0, 1fr); }
}

/* Iteration 5.2 — /app/bookmarks: cross-book bookmarks management page (bookmarks-list.js).
   Reuses .phead/.psum (page head), .filter-bar/.chip (filter row), .empty-state and .icon-btn
   from the existing shared contract; only the group/row layout below is new. Deliberately
   namespaced .bmk-* (not .bm-*) — .bm-row/.bm-time/etc. already belong to the per-book sheet
   (bookmarks-screen.js) and are a required §6 contract selector (frontend-static.test.js #4);
   this page must never collide with that styling. */
.bkm-page { max-width: 900px; margin: 0 auto; }
.bkm-page .filter-bar { margin-top: 1.1rem; }

.bmk-group { margin-top: 2.2rem; }
.bmk-group:first-of-type { margin-top: 1.6rem; }
.bmk-ghead { display: flex; align-items: center; gap: 0.9rem; padding: 0 0.2rem 0.8rem; }
.bmk-cover { width: 40px; aspect-ratio: 1; object-fit: cover; border-radius: 4px; flex: none; background: var(--surface-elev); }
.bmk-headtext { min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.bmk-headtext h2 { font-size: 1.05rem; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bmk-author { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.1em; color: var(--faint); }
.bmk-count { margin-left: auto; flex: none; font-size: 0.75rem; color: var(--faint); white-space: nowrap; }
.bmk-count b { color: var(--text); font-weight: 800; }

.bmk-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); }
.bmk-item { display: flex; align-items: flex-start; gap: 0.9rem; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--border); }
.bmk-item:last-child { border-bottom: none; }
.bmk-item:hover { background: var(--surface-elev); }
.bmk-time {
  flex: none; margin-top: 0.05rem; font-size: 0.72rem; font-weight: 700; font-variant-numeric: tabular-nums;
  color: var(--accent); border: 1px solid rgba(255, 94, 91, 0.3); border-radius: 8px; padding: 0.25rem 0.5rem;
  background: rgba(255, 94, 91, 0.06); cursor: pointer;
}
.bmk-time:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.bmk-body { flex: 1; min-width: 0; }
.bmk-title { font-weight: 800; font-size: 0.92rem; }
.bmk-note { margin-top: 0.15rem; font-size: 0.84rem; color: var(--muted); }
.bmk-untitled { font-style: italic; color: var(--faint); }
.bmk-play { flex: none; margin-top: 0.2rem; color: var(--accent); opacity: 0; transition: opacity 0.15s; }
.bmk-item:hover .bmk-play, .bmk-item:focus-within .bmk-play { opacity: 1; }
.bmk-del { color: var(--faint); }
.bmk-del:hover, .bmk-del:focus-visible { color: var(--danger); background: rgba(255, 92, 82, 0.1); }

@media (hover: none) {
  .bmk-play { display: none; }
}
@media (max-width: 640px) {
  .bmk-ghead { gap: 0.6rem; }
}

/* Iteration 5.2 — /app/stats: real listening-activity dashboard (stats-screen.js). Every rule
   below is scoped under .stats-page so it can never leak into/collide with another screen's
   class of the same short name (matches the .home-wrap/.lib-page/.search-page convention). */
.stats-page { max-width: 1200px; margin: 0 auto; }

.stats-page .heroband {
  margin-top: 1.6rem; display: grid; grid-template-columns: 1.3fr 1fr 1fr 1fr;
  border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface);
}
.stats-page .hb { padding: 1.3rem 1.2rem; border-right: 1px solid var(--border); }
.stats-page .hb:last-child { border-right: none; }
.stats-page .hb .k { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--faint); }
.stats-page .hb .v { margin-top: 0.4rem; font-weight: 900; line-height: 1; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; font-size: clamp(1.5rem, 1.2rem + 1vw, 2rem); color: var(--text); }
.stats-page .hb.big .v { font-size: clamp(2.2rem, 1.6rem + 2vw, 3rem); color: var(--accent); }
.stats-page .hb .v small { font-size: 0.44em; font-weight: 800; }
.stats-page .hb .d { margin-top: 0.4rem; font-size: 0.78rem; color: var(--muted); }

.stats-page .section { margin-top: 2.6rem; }
.stats-page .sec-head { display: flex; align-items: center; gap: 0.8rem; margin-bottom: 0.9rem; }
.stats-page .sec-head h2 { font-size: 1.1rem; margin: 0; }
.stats-page .sec-count { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; color: var(--faint); white-space: nowrap; }
.stats-page .sec-rule { flex: 1; height: 1px; background: var(--border); }

.stats-page .ccard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.2rem 1.3rem; }
.stats-page .ch { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 1rem; }
.stats-page .ch h3 { font-size: 0.92rem; margin: 0; }
.stats-page .ch .m { font-size: 0.68rem; letter-spacing: 0.06em; color: var(--faint); margin-left: auto; white-space: nowrap; }

.stats-page .heat { display: grid; grid-template-rows: repeat(7, 10px); grid-auto-flow: column; grid-auto-columns: 10px; gap: 3px; overflow-x: auto; padding-bottom: 0.4rem; }
.stats-page .heat i { border-radius: 2.5px; background: var(--lv, var(--s0)); }
.stats-page .heat-foot { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.9rem; font-size: 0.68rem; letter-spacing: 0.04em; color: var(--faint); }
.stats-page .heat-foot .sw { display: flex; gap: 3px; margin: 0 0.3rem; }
.stats-page .heat-foot .sw i { width: 10px; height: 10px; border-radius: 2.5px; }

.stats-page .cards2 { display: grid; grid-template-columns: 1.5fr 1fr; gap: 1.3rem; }
.stats-page .cbars { display: flex; flex-direction: column; gap: 0.9rem; }
.stats-page .cbar .t { display: flex; justify-content: space-between; align-items: baseline; font-size: 0.82rem; font-weight: 700; }
.stats-page .cbar .t .m { font-size: 0.7rem; color: var(--faint); font-weight: 500; }
.stats-page .cbar .bar { margin-top: 0.4rem; height: 8px; border-radius: 99px; background: rgba(255, 255, 255, 0.07); overflow: hidden; }
.stats-page .cbar .bar i { display: block; height: 100%; border-radius: 99px; width: calc(var(--w) * 1%); background: linear-gradient(90deg, var(--accent-deep), var(--accent)); }

.stats-page .clock { display: flex; align-items: flex-end; gap: 3px; height: 90px; }
.stats-page .clock i { flex: 1; border-radius: 3px 3px 0 0; background: var(--s2); height: calc(var(--h) * 1%); min-height: 3px; }
.stats-page .clock i.pk { background: linear-gradient(to top, var(--s3), var(--accent)); }
.stats-page .clock-foot { display: flex; justify-content: space-between; margin-top: 0.6rem; font-size: 0.68rem; color: var(--faint); }

.stats-page .ranks { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 1rem; }
.stats-page .rank { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.9rem 0.8rem; cursor: pointer; transition: transform 0.18s ease, border-color 0.18s ease; }
.stats-page .rank:hover, .stats-page .rank:focus-visible { transform: translateY(-3px); border-color: var(--accent); }
.stats-page .rank .no { position: absolute; top: 0; right: 0.5rem; font-size: 2.6rem; font-weight: 900; letter-spacing: -0.04em; color: var(--accent); opacity: 0.13; line-height: 1; }
.stats-page .rank .cover-wrap { width: 100%; }
.stats-page .rank img.cover { aspect-ratio: 1; }
.stats-page .rank .t { margin-top: 0.6rem; font-size: 0.8rem; font-weight: 800; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.stats-page .rank .h { margin-top: 0.3rem; font-size: 0.68rem; color: var(--accent); }

.stats-page .donutwrap { display: flex; align-items: center; gap: 1.3rem; }
.stats-page .donut { position: relative; width: 108px; height: 108px; flex: none; }
.stats-page .donut svg { transform: rotate(-90deg); width: 100%; height: 100%; }
.stats-page .donut circle { fill: none; stroke-width: 11; }
.stats-page .donut .tr { stroke: rgba(244, 244, 246, 0.08); }
.stats-page .donut .fl { stroke: var(--accent); stroke-linecap: round; }
.stats-page .donut .lab { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.stats-page .donut .lab b { font-size: 1.35rem; font-weight: 900; letter-spacing: -0.02em; }
.stats-page .donut .lab span { font-size: 0.6rem; letter-spacing: 0.08em; color: var(--faint); }
.stats-page .dlegend { display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.78rem; color: var(--muted); }
.stats-page .dlegend b { color: var(--text); font-weight: 800; font-variant-numeric: tabular-nums; }
.stats-page .dlegend .row { display: flex; align-items: center; gap: 0.5rem; }
.stats-page .dlegend .dot { width: 8px; height: 8px; border-radius: 3px; flex: none; background: var(--accent); }

.stats-page .records { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem; }
.stats-page .rec { display: flex; gap: 0.8rem; align-items: center; background: rgba(255, 255, 255, 0.03); border-radius: var(--radius-helper); padding: 0.9rem 1rem; }
.stats-page a.rec { cursor: pointer; transition: transform 0.18s ease, box-shadow 0.18s ease; }
.stats-page a.rec:hover, .stats-page a.rec:focus-visible { transform: translateY(-2px); box-shadow: 0 0 0 1px rgba(255, 94, 91, 0.35); }
.stats-page .rec .ic { width: 38px; height: 38px; border-radius: var(--radius-helper); flex: none; background: rgba(255, 94, 91, 0.09); border: 1px solid rgba(255, 94, 91, 0.2); display: flex; align-items: center; justify-content: center; color: var(--accent); }
.stats-page .rec .ic svg { width: 17px; height: 17px; }
.stats-page .rec .v { font-size: 1rem; font-weight: 900; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
.stats-page .rec .k { margin-top: 0.1rem; font-size: 0.76rem; color: var(--muted); }

@media (max-width: 900px) {
  .stats-page .heroband { grid-template-columns: 1fr 1fr; }
  .stats-page .hb:nth-child(odd) { border-right: 1px solid var(--border); }
  .stats-page .hb:nth-child(even) { border-right: none; }
  .stats-page .hb:nth-child(-n+2) { border-bottom: 1px solid var(--border); }
  .stats-page .cards2 { grid-template-columns: 1fr; }
  .stats-page .records { grid-template-columns: 1fr; }
}

/* Iteration 5.2 — /app/author/:name (author-screen.js). The wrapper also carries .lib-page
   (see that file's comment) so the shelf grid below reuses library-grid.js's exact tile
   treatment (portrait covers, hover playdot, bk-meta styling) for free — only the hero header
   and section head below are new. */
.author-page .au-hero { display: flex; align-items: center; gap: 1.4rem; margin-bottom: 1rem; }
.author-page .au-avatar {
  width: 72px; height: 72px; border-radius: 50%; flex: none;
  display: flex; align-items: center; justify-content: center;
  font-weight: 800; font-size: 1.5rem; color: #fff;
  background: hsl(var(--av-hue, 355), 55%, 32%);
}
.author-page .au-head { min-width: 0; }
.author-page .au-head h1 { margin: 0.3rem 0 0; }
.author-page .au-head h1 em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); }
.author-page .au-statline { margin-top: 0.5rem; font-size: 0.8rem; font-weight: 600; color: var(--muted); }
.author-page .sec-head { display: flex; align-items: center; gap: 0.8rem; margin: 1.6rem 0 1rem; }
.author-page .sec-head h2 { font-size: 1.15rem; margin: 0; }
.author-page .sec-rule { flex: 1; height: 1px; background: var(--border); }
.author-page .sec-count { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; color: var(--faint); white-space: nowrap; }

@media (max-width: 640px) {
  .author-page .au-hero { gap: 1rem; }
  .author-page .au-avatar { width: 58px; height: 58px; font-size: 1.2rem; }
}

/* ============================================================
   Iteration 27 — /book/:id (book-detail-screen.js + book-backdrop.js), ported from
   DEV/ITERATION 27 - book page prototype/prototype-book-page-v6.html. Andrew picked v6 over v5's
   animated shader on 2026-07-26 and asked for it on the live site with the bottom player bar left
   alone, so this block replaces the Iteration 5.2 page and touches no shell surface: the app's
   <mini-player>, #tab-bar, site-bar and #app-root are as they were.

   Scoped under .book-page (matching .home-wrap/.lib-page/.search-page/.author-page/.stats-page),
   with the handful of layers that must sit OUTSIDE the content box carrying a `bk-` prefix.
   `.ambient` was NOT reused for the backdrop: that class belongs to site-bar's room glow, which is
   an unrelated layer, and sharing the name would have merged the two.

   EVERY BUTTON RULE HERE RELEASES padding / min-height / min-width / border-radius. The global
   `button` rule (line ~218) sets all four plus a surface fill and a border, and the prototype's
   reset was `border: 0; background: none` alone. This is the same trap Iteration 11 v9 wrote up:
   a declared width loses to the global padding, and the control ends up unable to centre its own
   glyph. A kernel `.icon-square` utility would end the family and still does not exist.
   ============================================================ */

book-detail-screen { display: block; }

/* ---- THE BACKDROP ---------------------------------------------------------------------------
   ITERATION 32. THERE IS NO REVEAL HERE ANY MORE, AND THAT IS THE ENTIRE CHANGE.

   Andrew, 2026-07-27, on the v8 page with the fade already removed: "this issue still persists,
   there is a color change with a noticable abrupt jump ... you are narrow framing the problem. Do
   not tweak it, just redesign it from scratch". Measured on production, per animation frame: the
   page looked finished at 530ms and its colour arrived at 947ms at his 5904 x 3152, a 417ms window
   ending in a one-frame step of 145 levels. Two earlier passes had tuned the TRANSITION, which was
   never the defect. The page had two states and the second one was late; a fade makes that slow
   and no fade makes it abrupt, and neither makes it absent.

   So `opacity: 0` and `body.bk-fx-ready` are GONE, along with the rAF pump and the slice budget in
   book-backdrop.js that made them necessary. The canvas is opaque from the moment it exists,
   because the field is now painted synchronously from a palette that arrived with the metadata the
   page already awaited (items.backdrop, read once on the server), inside the same task that writes
   this markup. There is nothing half-built to hide, so there is nothing to hide it with.

   If no palette is stored the canvas is simply never painted and the hero is `--bg`, exactly like
   every other screen in this app. That is the only fallback and it is deliberate: v6 and v7 also
   carried a CSS approximation of the same picture, and two implementations of one picture drift. */
.bk-fxwrap { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
/* THE FIELD, AS THREE CSS GRADIENTS. `book-backdrop.js` turns the stored palette into two
   `background-image` values and writes them here as custom properties; this rule and the media
   query under it are what choose between them. The breakpoint lives in the stylesheet, with every
   other breakpoint in this app, which is why that file no longer owns a resize listener.

   `background-blend-mode: screen` is the whole reason the layers can be stacked at all. Opaque
   layers OCCLUDE: the accent bloom punches a hole in the brightness ramp under it and the hole has
   an edge, which renders as a dark ring right around the bloom. `screen` is `a + b - ab`, within a
   couple of levels of addition at values this dark, and it can neither clip nor occlude, so each
   layer contributes only its own increment above `--bg` and fades to transparent BLACK, which is
   the identity for screen. Three images, three modes: the last one blends the bottom layer with
   the background-color.

   The `none` fallback is the no-palette case, not decoration. A book with no cover has no stored
   palette, so neither property is ever set, `var()` falls back, and the hero is `--bg` exactly like
   every other screen in this app. 331 of the 3,602 live rows are in that state today. */
.bk-fx {
  position: absolute; inset: 0; width: 100%; height: 100%; display: block;
  background-color: var(--bg);
  background-image: var(--bk-stack, none);
  background-blend-mode: screen, screen, screen;
}
@media (min-width: 900px) {
  .bk-fx { background-image: var(--bk-stack-desk, none); }
}

/* THE DITHER, AND IT IS BACK BECAUSE THE ROUND TO 8 BITS MOVED.
   v6 to v9 owned every pixel of this field, so the dither belonged inside the paint, and v9's
   measured answer was that it needed none at all: the bilinear upscale off a 474 x 253 grid
   already interpolates a continuum of intermediate values. That was true of the ramp and false of
   the CONTOUR the grid's own quantisation left behind, which is the banding Andrew reported the
   same day the field shipped.

   A browser-drawn gradient bands too, and worse: longest flat run of identical bytes along a
   horizontal scan at 5904 x 3152, 214 to 484 device pixels. Chrome dithers its own gradients
   natively and Firefox does not (Bugzilla 876125), so this is the optimistic case. With this
   overlay it is 15 to 19 px.

   OPACITY 0.0039 IS ARITHMETIC, NOT TASTE. With `result = base + A * (noise - base)` and noise
   uniform over 0..255, an alpha of exactly 1/255 spreads every output over exactly one 8-bit
   level, whatever the base is. That is the definition of a dither. The grain overlays published
   everywhere sit at 5 to 15 percent, about thirty times this: that is masking, it measured no
   better, and it cost 5 levels of fidelity against this one's 2.

   `image-rendering: pixelated` and the two resolution queries are not optional. Without them the
   browser filters the tile, halves the amplitude, and a half-level dither does not dither. The
   tile is 6.8 KB and ONE file for the whole site, not one per book. */
.bk-fx::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url("/images/noise-64.png");
  background-repeat: repeat;
  background-size: 64px 64px;
  image-rendering: pixelated;
  opacity: 0.0039;
}
@media (min-resolution: 2dppx) { .bk-fx::after { background-size: 32px 32px; } }
@media (min-resolution: 3dppx) { .bk-fx::after { background-size: 21.33px 21.33px; } }
/* site-bar's room glow is the app's shared backdrop everywhere else. On this page the book's own
   artwork is the backdrop, and the two would composite: the canvas is opaque so the glow is
   invisible under it anyway, but the CSS fallback is not, and the glow would show through its top
   third. One backdrop at a time. */
body.has-book-backdrop site-bar .ambient { display: none; }

/* Bottom clearance. #app-root hardcodes 6.5rem, which is 104px, and the three things that actually
   sit down there measure 62 (tab bar) + 34 (home indicator) + 60 (mini-player card) + breathing
   room, so a phone puts the last rail's titles under the floating card. Applied here unconditionally
   rather than only when the mini-player is mounted: the cost of the other case is empty space below
   the final rail, and the alternative is a second body class for one number. Kernel-wide adoption
   is module 00's call, not this page's. */
body.has-book-backdrop #app-root { padding-bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 90px); }

/* ---- BACK, BOTH WIDTHS ----------------------------------------------------------------------
   Iteration 19 deleted the top bar, so there is exactly ONE navigation: the tab bar. A breadcrumb
   row here would be a navigation idiom with a population of one, which is why the old page's
   .crumbs is not ported. The player's two back affordances are reused verbatim: a glass disc on
   phone and a labelled pill on desktop. */
.bk-back {
  position: fixed; top: max(0.5rem, env(safe-area-inset-top)); left: 12px; z-index: 15;
  width: 44px; height: 44px; padding: 0; min-width: 44px; min-height: 44px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center; color: var(--text);
  background: rgba(255,255,255,.09); border: 0; backdrop-filter: blur(12px);
  transition: opacity 0.18s ease, transform 0.18s ease;
}
.bk-back svg { width: 22px; height: 22px; display: block; fill: currentColor; }
.bk-back:active { background: rgba(255,255,255,.18); }

.bk-backpill {
  display: none;
  position: fixed; top: 24px; left: 28px; z-index: 15; height: 44px; min-height: 44px;
  padding: 0 16px 0 10px; border-radius: var(--radius-pill); align-items: center; gap: 5px;
  background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.09);
  backdrop-filter: blur(12px);
  font-size: 13px; font-weight: 700; letter-spacing: 0.01em; color: var(--text);
  transition: opacity 0.18s ease, transform 0.18s ease;
}
.bk-backpill svg { width: 19px; height: 19px; display: block; fill: currentColor; }
.bk-backpill:hover { background: rgba(255,255,255,.14); }

/* The back control belongs to the artwork and leaves with it. On a page that scrolls, an
   absolutely-parked back button sits on top of the Chapters heading at every scroll position past
   the hero. 48px of travel, not 1px, so a rubber-band overscroll cannot blink it. */
html.bk-scrolled .bk-back, html.bk-scrolled .bk-backpill {
  opacity: 0; transform: translateY(-6px); pointer-events: none;
}

.bk-loading { padding: 80px 0; text-align: center; color: var(--faint); font-weight: 600; font-size: 14px; }
.bk-errbox { padding: 60px 0; text-align: center; color: var(--muted); font-weight: 600; font-size: 14px; }

/* ---- THE PAGE -------------------------------------------------------------------------------
   z-index 1 lifts the content over the fixed backdrop at 0. Without it the opaque canvas paints
   above every non-positioned block in #app-root, which is a blank page with a nice gradient. */
.book-page { position: relative; z-index: 1; }

/* HERO: cover, identity, THE PLAY CARD, then the stat strip. Playback comes first. It is the only
   thing on the page anyone opened it to do; duration / chapters / progress are what you read once
   you have decided. */
.book-page .hero { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 22px; }
.book-page .coverwrap {
  position: relative; flex: none; width: 190px; height: 190px; border-radius: 14px; overflow: hidden;
  border: 1px solid var(--border); box-shadow: var(--sheen), 0 26px 56px rgba(0,0,0,.62);
}
.book-page .coverwrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
.book-page .ident { min-width: 0; flex: 1; width: 100%; }
.book-page .eyebrow {
  display: block; font-size: 10.5px; font-weight: 800; letter-spacing: 0.10em;
  text-transform: uppercase; color: var(--accent);
}
.book-page .ident h1 { font-size: 25px; font-weight: 900; letter-spacing: -0.022em; line-height: 1.18; margin: 9px 0 0; }
/* the connective "by" is not load-bearing and drops to 500; the author name inside it stays at
   800, because the author IS load-bearing. */
.book-page .byline { margin: 9px 0 0; font-size: 13.5px; font-weight: 500; color: var(--muted); }
.book-page .byline b {
  color: var(--text); font-weight: 800; border-bottom: 1px solid rgba(255,255,255,.22);
  padding-bottom: 1px; cursor: pointer;
}
.book-page .byline b:hover { color: var(--accent); border-color: var(--accent); }

/* THE PLAY CARD: the ONLY control on this page that starts audio. Deliberately the single loudest
   object on the screen. Nothing else here is allowed to look like it, which is why no cover carries
   a coral disc. */
.book-page .playcard {
  display: flex; align-items: center; gap: 16px; margin-top: 22px; padding: 15px 17px;
  border-radius: var(--radius-card); background: var(--surface); border: 1px solid var(--border);
  box-shadow: var(--sheen); text-align: left; width: 100%; letter-spacing: -0.01em;
  transition: background 0.18s ease, border-color 0.18s ease;
}
.book-page .playcard:hover { background: var(--surface-elev); border-color: #565660; }
.book-page .playcard:active { background: var(--surface-elev); }
.book-page .ring { position: relative; width: 64px; height: 64px; flex: none; display: flex; align-items: center; justify-content: center; }
.book-page .ring svg.rg { position: absolute; inset: 0; width: 64px; height: 64px; transform: rotate(-90deg); display: block; }
.book-page .ring .disc {
  width: 50px; height: 50px; border-radius: 50%; background: var(--accent); color: #fff;
  display: flex; align-items: center; justify-content: center; box-shadow: 0 8px 22px rgba(255,94,91,.36);
  position: relative; z-index: 2;
}
.book-page .ring .disc svg { width: 22px; height: 22px; display: block; fill: currentColor; }
.book-page .playcard:hover .ring .disc { background: var(--accent-hover); }
.book-page .pcmeta { flex: 1; min-width: 0; display: block; }
.book-page .pclabel {
  display: block; font-size: 11px; font-weight: 800; letter-spacing: 0.09em;
  text-transform: uppercase; color: var(--accent);
}
/* DEFECT FIX. Production clamped this line to ONE line with an ellipsis, and the anonymous label
   "Request access to listen" needs 191px in the 169px the card leaves at 390px, so the only action
   an anonymous visitor had rendered as "Request access to li...". Two lines, not none: the
   signed-in variant interpolates a chapter name, which can be long, so an unbounded line would push
   the stat strip around. */
.book-page .pcline {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
  font-size: 17.5px; font-weight: 900; line-height: 1.2; margin-top: 4px; letter-spacing: -0.015em;
}
.book-page .pcsub {
  display: block; font-size: 12px; font-weight: 500; color: var(--faint); margin-top: 5px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums;
}

/* THE STAT STRIP. Every cell is populated for all 3,602 items, so it can never collapse the way
   publisher / released / ISBN / pages did. Bold is rationed: at 900 these three values wore the
   same weight as the H1 above them, so a duration and a chapter count shouted as loud as the book's
   own title. 600 keeps them plainly legible and stops them competing. */
.book-page .stats { display: flex; width: 100%; margin-top: 22px; border-top: 1px solid var(--hair); border-bottom: 1px solid var(--hair); }
.book-page .stats .cell { flex: 1; padding: 13px 4px; text-align: center; min-width: 0; }
.book-page .stats .cell + .cell { border-left: 1px solid var(--hair); }
.book-page .stats .k { font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--faint); }
.book-page .stats .v { font-size: 15.5px; font-weight: 600; margin-top: 5px; font-variant-numeric: tabular-nums; }

/* SECTION HEADS. The home screen already has this component as .home-wrap .sec-head, and these
   rails ARE home shelves pointed at one book, so it is copied including the "See all" tonal pill.
   44px, not the home shelf's 32px: .lib-page .seeall was raised to 44 in Iteration 11 with the note
   "which is also the constitution's touch-target floor" and the identical control on .home-wrap
   never got the same edit. Two copies of one component, one under the floor. Takes the decided
   value. */
.book-page .sechead { display: flex; align-items: center; gap: 10px; margin: 44px 0 20px; }
.book-page .sechead h2 { font-size: 21px; font-weight: 800; letter-spacing: -0.015em; margin: 0; }
.book-page .sechead .count { font-size: 13.5px; font-weight: 500; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; }
.book-page .sechead .rule { display: none; flex: 1; height: 1px; background: var(--border); margin-top: 3px; }
.book-page .sechead .seeall { margin-left: auto; }
.book-page .seeall {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  color: var(--text); font-weight: 700; font-size: 12px; letter-spacing: 0.01em;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-pill); padding: 0 15px; min-height: 44px;
  box-shadow: var(--sheen); white-space: nowrap;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.book-page .seeall:hover { background: var(--surface-elev); border-color: #565660; }
.book-page .seeall .ar { color: var(--accent); display: flex; }
.book-page .seeall .ar svg { width: 15px; height: 15px; display: block; fill: currentColor; }

/* CHAPTERS. The player already renders this exact data as now-playing .chrow: free rows on a 12px
   radius, no dividers, no chevron, current row coral-tinted. Two chapter lists in one product
   cannot be two components, so .chrow is copied property for property.

   min-height is the one property NOT copied: 12px padding around a 14.5px line measures 41px, four
   under the touch-target floor. 46, not 44: at exactly 44 one row in thirty measured 43.9998px, a
   sub-pixel of the flex column's 2px gap, and a floor you land on to four decimal places is not one
   you have cleared. */
.book-page .chlist, .bk-sheet .shlist { display: flex; flex-direction: column; gap: 2px; }
.book-page .ch, .bk-sheet .ch {
  display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
  padding: 12px 10px; min-height: 46px; min-width: 0; border-radius: 12px; background: none;
  border: 1px solid transparent; letter-spacing: -0.01em; transition: background 0.15s ease;
}
.book-page .ch:hover, .bk-sheet .ch:hover { background: rgba(255,255,255,.045); }
.book-page .ch:active, .bk-sheet .ch:active { background: rgba(255,255,255,.06); }
.book-page .ch .num, .bk-sheet .ch .num {
  width: 24px; flex: none; text-align: right; font-size: 11.5px; font-weight: 600;
  color: var(--faint); font-variant-numeric: tabular-nums;
}
/* the chapter NAME keeps 700: it is a title, which is the case bold is for, and it is the only
   thing in the row anyone scans. The number and the duration are the row's supporting pair and drop
   a step each. */
.book-page .ch .nm, .bk-sheet .ch .nm {
  flex: 1; min-width: 0; font-size: 14.5px; font-weight: 700;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.book-page .ch .tm { font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; flex: none; font-weight: 500; }
/* CHAPTER NAMES ARE WHITE IN EVERY STATE (Andrew, prototype v2). The player fades a finished
   chapter's title to --faint at weight 400; at 36% through a 24-chapter book that is eight of the
   first nine rows greyed out, so the list reads as disabled rather than as read. The finished state
   was never carried by the fade alone: the number beside it is already coral, which is a mark you
   can see at a glance without taking the title away. A DELIBERATE DIVERGENCE from now-playing
   .chrow.done, and it runs the right way: the player has the same grey list and should follow this. */
.book-page .ch.done .nm { color: var(--text); font-weight: 500; }
.book-page .ch.done .num { color: var(--accent-deep); }
.book-page .ch.now, .bk-sheet .ch.now { background: rgba(255,94,91,.13); border-color: rgba(255,94,91,.30); }
/* 800, not 900. The current row already announces itself with a coral tint and a coral border; it
   does not also need the heaviest weight on the page. */
.book-page .ch.now .nm, .bk-sheet .ch.now .nm { font-weight: 800; }
.book-page .ch.now .num, .bk-sheet .ch.now .num { color: var(--accent); }
.book-page.is-anon .ch .tm { opacity: 0.45; }
/* The SHEET keeps the earlier greys. "Chapters should be white text" was about the list in the page
   body; the sidebar was to be left as it was, and one shared .ch rule set changed it by accident
   once already. */
.bk-sheet .shlist .ch .tm { font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums; flex: none; font-weight: 500; }
.bk-sheet .shlist .ch.done .nm { color: var(--faint); font-weight: 400; }

.book-page .chmore {
  display: flex; align-items: center; justify-content: center; gap: 7px; width: 100%; min-height: 44px;
  margin-top: 14px; padding: 11px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 700;
  letter-spacing: 0.01em; color: var(--text); background: var(--surface); border: 1px solid var(--border);
  box-shadow: var(--sheen);
}
.book-page .chmore:hover { background: var(--surface-elev); border-color: #565660; }
.book-page .chmore svg { width: 15px; height: 15px; display: block; fill: currentColor; transition: transform 0.2s ease; }
.book-page .chmore.up svg { transform: rotate(180deg); }

/* RAILS. THE RULE: a cover is navigation, never a transport control. No play glyph, no coral, no
   "Play {title}" label. Home adopted this on 2026-07-27 and its shelf covers now reveal the same
   `.opendot`; `.lib-page .playdot` (38px, corner) is the last holdout, and it belongs to module
   02. Both of those covers route to /book/:id, not /player/:id, since Iteration 14 decoupled
   navigation from playback, which is what made the play glyph a promise neither could keep. */
/* padding-top 6 -> 14, with a matching -8px top margin so nothing moves (Andrew,
   2026-07-27: on hover "a couple of pixels disappear from the upper edge of the cover").
   It was two pixels exactly, and it is not a z-index problem: this rail clips vertically,
   and no stacking order escapes an ancestor's clip. 6px of headroom for an 8px rise cut
   the top 2px off every hovered cover. Measured 2px before, 0px after; the negative margin
   hands the 8px back to the page so the rail's neighbours and bottom edge do not budge.

   14 -> 30 (2026-07-28), same mechanism, same compensation, for the status pill. A rail is a
   scroll container, so `overflow-x: auto` forces `overflow-y` to clip whatever `overflow-y` is
   set to: nothing here can be allowed to hang above the tile without headroom paid for in
   padding. The pill sits at `top: -14px` and rises the cover's 8px on hover, so its top edge
   reaches 22px above the tile and its shadow (0 2px 8px) another 6px, which is the 28 this
   rounds up. 14px of headroom would have sliced the tag in half on hover exactly as 6px sliced
   the cover, and it is the same defect twice, so it gets the same fix.
   `--snav-top` on .railwrap is `padding-top + cover/2` and moves with this number. */
.book-page .rail {
  display: flex; gap: 26px; overflow-x: auto; overflow-y: hidden; padding: 30px 2px 18px;
  margin-top: -24px;
  scrollbar-width: none; scroll-snap-type: x proximity; --fade: 32px;
}
.book-page .rail::-webkit-scrollbar { display: none; }
/* The app's edge fade (.home-wrap .xwrap.can-l/.can-r). Without it the tile at the frame edge is
   sliced through the middle of its cover AND through the middle of a word in its title, which reads
   as a clipping bug rather than as "there is more to the right". */
.book-page .rail.can-r {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - var(--fade)), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - var(--fade)), transparent);
}
.book-page .rail.can-l {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade));
  mask-image: linear-gradient(to right, transparent, #000 var(--fade));
}
.book-page .rail.can-l.can-r {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade), #000 calc(100% - var(--fade)), transparent);
  mask-image: linear-gradient(to right, transparent, #000 var(--fade), #000 calc(100% - var(--fade)), transparent);
}
/* THE SCROLL ARROWS. Andrew, 2026-07-27: "there is no [scroll right] arrow button, so users can
   horizontal scroll book shelves". The rails have always scrolled and the edge fade above has
   always said there was more to the right; there was simply nothing to press. The control itself is
   the shared `.snav` component (see its block, de-scoped from home in this same change), so this
   surface declares only the two things that are its own.

   `position: relative` makes the wrapper the containing block, so `left/right: -14px` hangs each
   arrow just outside the rail exactly as it does on home. The wrapper, not the rail: `.rail` is the
   scroll container, and an arrow inside it would scroll away with the tiles.

   `--snav-top` centres them on the ARTWORK rather than on the tile. A tile is cover + title +
   author + duration, so the 50% fallback lands somewhere in the text below the covers. The cover is
   square and fills the tile's width, and the rail's own `padding-top` is 30px, so the centre is
   30 + width/2: 105px at the base 150px tile, 116px at the 172px tile from 900px up (kept beside
   that width, so the pair cannot drift apart). Both numbers moved by 16 when the rail's
   padding-top went 14 -> 30 to make room for the status pill; they are that padding plus half a
   cover and nothing else, so they are never edited on their own. */
.book-page .railwrap { position: relative; --snav-top: 105px; }
/* THE GLYPH IS SCOPED HERE AND DELIBERATELY NOT PUT ON THE SHARED `.snav`, which would break home.
   Found by looking: with no rule the chevron rendered BLACK and roughly a third of the disc, while
   all 64 numeric checks passed, because `ic()` emits `<svg viewBox="0 -960 960 960">` with no fill
   and no size and this app supplies both per context.

   It cannot go on `.snav` because the two surfaces draw a chevron from different families. Home's
   `ICON_CHEV_L/R` are hand-drawn STROKED paths carrying `fill="none" stroke="currentColor"` and
   their own width/height attributes; a CSS `fill: currentColor` beats a presentation attribute, so
   a shared rule would fill home's outline into a solid blob and resize it at the same time.

   That divergence is worth naming rather than papering over: this program's standing rule is that
   every glyph is a Google Material Symbol as inline SVG with `fill: currentColor`, and home's
   arrows are the surface breaking it, not these. Home is module 01 and is not this branch's to
   edit. Written up for that session rather than changed here. */
.book-page .snav svg { width: 20px; height: 20px; fill: currentColor; display: block; }
/* An arrow appears only when there is somewhere to go, and the answer already exists: `_syncFade`
   puts `can-l`/`can-r` on the rail to drive the edge fade above. The arrows read the same two
   classes off their own previous sibling rather than a second flag on the wrapper, so a rail that
   is fully scrolled cannot fade one edge and offer the arrow for it. */
.book-page .railwrap:hover .rail.can-l ~ .snav.prev { opacity: 1; }
.book-page .railwrap:hover .rail.can-r ~ .snav.next { opacity: 1; }
.book-page .tile {
  flex: none; width: 150px; min-width: 0; padding: 0; border: 0; border-radius: 0;
  scroll-snap-align: start; text-align: left; background: none; position: relative;
  letter-spacing: -0.01em;
}
/* display:block is load-bearing, not tidiness. A tile is a <button>, so its children are <span>s to
   keep the markup valid, and `width`, `aspect-ratio` and `overflow` are all ignored on an inline
   box: the cover came out as a ragged portrait rectangle with the title stranded beside it. Every
   probe passed through it, because nothing was measuring the cover's aspect. Caught by looking. */
.book-page .tile .tcov {
  display: block; position: relative; width: 100%; aspect-ratio: 1; border-radius: 4.5px; overflow: hidden;
  box-shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 18px -8px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.05);
  transition: transform 0.22s ease;
}
.book-page .tile:hover .tcov { transform: translateY(-8px); }
.book-page .tile .tcov img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* the home shelf's under-cover shadow, which squeezes on hover */
.book-page .tile .tcov::after {
  content: ""; position: absolute; left: 8%; right: 8%; bottom: -13px; height: 16px;
  background: radial-gradient(ellipse at center, rgba(0,0,0,.4), transparent 70%);
  transition: transform 0.22s ease, opacity 0.22s ease;
}
.book-page .tile:hover .tcov::after { transform: scaleX(.92); opacity: 0.55; }
/* the ONLY overlay a cover carries: an "opens" chevron, monochrome, dim. It replaces .playdot,
   which put a play triangle on a control that navigates.

   SHARED COMPONENT, not a book-page rule. Home's shelf covers reveal the same badge as of
   2026-07-27, and a second copy of these values under `.home-wrap` would drift the first time
   either surface was touched. Defined once here; each surface contributes only its own reveal
   rule. `transform 0.22s ease` rides along in the transition for the surfaces that MOVE the
   badge (home, whose cover rises out from under it); on this page the badge is inside `.tcov`
   and rises with it, so that half never fires. */
.opendot {
  position: absolute; right: 7px; bottom: 7px; width: 28px; height: 28px; border-radius: 50%;
  background: rgba(12,12,14,.72); color: var(--text);
  display: flex; align-items: center; justify-content: center; opacity: 0;
  transition: opacity 0.18s ease, transform 0.22s ease;
}
.opendot svg { width: 16px; height: 16px; display: block; fill: currentColor; }
.book-page .tile:hover .opendot, .book-page .tile:focus-visible .opendot { opacity: 1; }
/* min-height, not just line-clamp: a one-line title beside a two-line one let the author and
   duration ride up, so a rail of ten had three baselines. */
.book-page .tile .tt {
  font-size: 15.5px; font-weight: 700; margin-top: 15px; line-height: 1.32; min-height: 2.64em;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.book-page .tile .ta {
  display: block; font-size: 13.5px; font-weight: 500; color: var(--muted); margin-top: 4px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.book-page .tile .tm {
  font-size: 12.5px; font-weight: 500; color: var(--muted); margin-top: 8px;
  font-variant-numeric: tabular-nums; display: flex; align-items: center; gap: 5px;
}
.book-page .tile .tm svg { width: 14px; height: 14px; display: block; fill: currentColor; }
/* a progress hairline on a related cover: real data, no glyph, no ambiguity */
.book-page .tbar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(0,0,0,.5); z-index: 2; }
.book-page .tbar i { display: block; height: 100%; background: var(--accent); }
/* THE STATUS PILL'S ONE BOOK-PAGE RULE. Everything the tag looks like is declared once, with the
   library's, down in the `.lib-page .pill` block; this surface owns only the movement.
   It has to be re-stated rather than inherited because the pill is a SIBLING of the box that
   moves. `.tcov` is what rises 8px on hover, and the pill cannot live inside it (`overflow:
   hidden`, which is what rounds the cover, and the pill's whole point is to hang outside the
   corner). So it rides the same 8px on the same 0.22s ease, and staying attached to the corner it
   is drawn against is the entire reason. This is the arrangement home already has for `.opendot`,
   and the same 8px in a second place is the known cost of it.
   The hairline above and this tag are not a duplicate pair: the bar says how far, the tag says
   which state, and the tag is legible at a glance across a rail where a 3px line is not. */
.book-page .tile > .pill { transition: transform 0.22s ease; }
.book-page .tile:hover > .pill { transform: translateY(-8px); }

/* ---- SHEET, the full chapter list -----------------------------------------------------------
   Phone: bottom sheet, safe-area padding, the app's exact radius and easing. Desktop: now-playing
   .navp slides from the LEFT at 480px with a square edge, recorded in this file as Andrew's call. */
.bk-dim {
  position: fixed; inset: 0; z-index: 44; background: rgba(0,0,0,.55); opacity: 0; pointer-events: none;
  transition: opacity 0.2s ease;
}
.bk-dim.open { opacity: 1; pointer-events: auto; }
.bk-sheet {
  position: fixed; z-index: 45; background: #202025; border: 1px solid var(--border);
  display: flex; flex-direction: column;
  left: 0; right: 0; bottom: 0; height: 76%;
  border-radius: 22px 22px 0 0; border-width: 1px 0 0;
  padding: 10px 20px calc(30px + env(safe-area-inset-bottom));
  transform: translateY(101%); transition: transform 0.26s cubic-bezier(.3,.9,.3,1);
}
.bk-sheet.open { transform: translateY(0); }
.bk-sheet .grab { width: 38px; height: 4px; border-radius: var(--radius-pill); background: rgba(255,255,255,.28); margin: 0 auto 16px; flex: none; }
.bk-sheet h3 { display: flex; align-items: baseline; justify-content: space-between; margin: 0 0 20px; flex: none; }
/* the sheet header had the stat strip's fault one level down: the chapter COUNT was 22px/900 in
   coral, the loudest thing in the sheet, while the word "Chapters" beside it was 16px. The heading
   is the load-bearing half. */
.bk-sheet h3 .t { font-size: 16px; font-weight: 800; }
.bk-sheet h3 .v { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--accent); letter-spacing: -0.02em; }
.bk-sheet .shlist { overflow-y: auto; min-height: 0; scrollbar-width: none; }
.bk-sheet .shlist::-webkit-scrollbar { display: none; }

/* ---- DESKTOP, at the app's own 900px breakpoint ----------------------------------------------
   The desktop overrides for `.bk-amb` and `.bk-veil` went with those layers. What they solved is
   worth keeping written down, because it was real and it is the argument against ever bringing a
   blurred-artwork backdrop back: a square cover told to COVER a 2239x929 box is scaled 4.4x and
   centre cropped, so the middle of the jacket blows up to roughly 570px wide and stands there as a
   legible vertical band. Measured as peak column brightness over median, where 1.000 is perfectly
   even: `cover` + blur 78 gave 1.243 / 1.338, `fill` + blur 78 gave 1.157 / 1.324, and it took
   `fill` + blur 300 to reach 1.049 / 1.095. The canvas field has no such artefact because it never
   samples the artwork spatially at all: it reads a palette and draws its own geometry. */
@media (min-width: 900px) {
  /* 84px, not the app's 2rem. The back pill is fixed at top:24 and is 44 tall, so it ends at 68; at
     2rem the cover started at 32 and the pill sat on its top-left corner. Caught by looking at the
     desktop render, not by any of the 117 assertions that were green at the time. On phone the disc
     is centred over the artwork and clears the centred cover on its own. */
  body.has-book-backdrop #app-root { padding-top: 84px; }
  .bk-back { display: none; }
  .bk-backpill { display: flex; }

  .book-page .hero { flex-direction: row; align-items: flex-start; text-align: left; gap: 34px; }
  .book-page .coverwrap { width: 264px; height: 264px; }
  .book-page .ident h1 { font-size: 32px; }
  .book-page .playcard { max-width: 520px; }

  .book-page .stats { width: max-content; max-width: 100%; }
  .book-page .stats .cell { text-align: left; padding: 13px 26px 13px 0; flex: 0 0 auto; min-width: 118px; }
  .book-page .stats .cell + .cell { padding-left: 26px; }

  .book-page .sechead { gap: 14px; }
  .book-page .sechead h2 { font-size: 24px; }
  .book-page .sechead .rule { display: block; }
  .book-page .sechead .seeall { margin-left: 0; }

  /* The desktop chapter list is ONE column held to half the content box, and the space beside it is
     deliberately empty: it is the reservation for the book description arriving from a cloud API in
     a later iteration. 50% minus half the 44px gutter is exactly the left column the old two-column
     list already occupied, so every row keeps the geometry that was measured and only the column
     count changes. Phone is untouched: it was always one column, full width, and reserving
     whitespace there would narrow the list on the screen with the least room. */
  .book-page .chsec { width: calc(50% - 22px); }
  .book-page .chmore { max-width: 420px; margin-left: auto; margin-right: auto; }

  /* The arrow's centre follows the cover, which is as wide as the tile and square. 30px of rail
     padding + 172/2. Kept on the same line as the width it is derived from. */
  .book-page .tile { width: 172px; }
  .book-page .railwrap { --snav-top: 116px; }

  .bk-sheet {
    top: 0; bottom: 0; left: 0; right: auto; width: 430px; height: auto;
    border-width: 0 1px 0 0; border-radius: 0; padding: 24px 20px 14px;
    transform: translateX(-101%); transition: transform 0.28s cubic-bezier(.3,.9,.3,1);
  }
  .bk-sheet.open { transform: translateX(0); }
  .bk-sheet .grab { display: none; }
}

/* ============================================================
   Iteration 5.2 — /app/collections (collections-screen.js). Real subcategory "shelf" cards —
   see that file's header comment for why this replaces the prototype's hand-picked-shelves
   fiction. Scoped under .collections-page (same convention as .stats-page/.author-page) so
   nothing here collides with library-grid.js's own .grid/.grid-item tile classes. */
.collections-page { max-width: 1400px; margin: 0 auto; }
.col-grid {
  margin-top: 1.6rem;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 1.4rem;
}
.col-card {
  /* consumed by the shared .gate/.resume/.col-card/.fr-gate/.cat-card background rule, ~line 544 */
  --glass-angle: 180deg; --glass-top: rgba(38, 38, 47, 0.55); --glass-bottom: rgba(30, 30, 34, 0.5);
  position: relative;
  cursor: pointer;
  padding: 1.3rem 1.2rem 1.1rem;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  transition: transform 0.22s ease, border-color 0.22s ease;
}
.col-card:hover, .col-card:focus-visible { transform: translateY(-3px); border-color: rgba(255, 94, 91, 0.4); }
.col-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.col-fan { position: relative; height: 148px; margin-bottom: 1rem; }
.col-fan .cover {
  position: absolute; left: 50%; bottom: 0; width: 84px; aspect-ratio: 1;
  border-radius: 5px; object-fit: cover; transform-origin: bottom center;
  /* Iteration 8 (ticket 09 Group F) — trimmed 22px->16px. */
  box-shadow: 0 10px 16px -8px rgba(0, 0, 0, 0.55);
}
.col-fan .fan-l { transform: translateX(-50%) translateX(-24px) rotate(-7deg) scale(0.96); z-index: 1; filter: brightness(0.88); }
.col-fan .fan-r { transform: translateX(-50%) translateX(24px) rotate(7deg) scale(0.96); z-index: 2; filter: brightness(0.92); }
.col-fan .fan-c { transform: translateX(-50%) translateY(-6px); z-index: 3; }
.col-kicker { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--faint); }
.col-name { margin-top: 0.25rem; font-size: 1.15rem; font-weight: 800; color: var(--text); letter-spacing: -0.01em; }
.col-meta { margin-top: 0.35rem; font-size: 0.78rem; color: var(--muted); }
.col-go {
  margin-top: 0.7rem; display: inline-flex; align-items: center; gap: 0.4rem;
  font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--accent);
}
.col-go svg { width: 12px; height: 12px; }

/* ============================================================
   Iteration 5.2 — /app/firstrun (firstrun-screen.js). A centered "gate" card, same shape as
   the prototype's welcome screen, minus the fake folder-scan wizard — see that file's header
   comment. Scoped under .firstrun-page. */
.firstrun-page {
  min-height: calc(100dvh - 10rem);
  display: flex;
  align-items: center;
  justify-content: center;
}
.fr-gate {
  /* consumed by the shared .gate/.resume/.col-card/.fr-gate/.cat-card background rule, ~line 544 */
  --glass-top: rgba(38, 38, 47, 0.9); --glass-bottom: rgba(23, 23, 28, 0.96);
  width: min(440px, 100%);
  margin: 0 auto;
  text-align: center;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 2.4rem 2.2rem 2rem;
}
.fr-wordmark { display: inline-flex; align-items: center; gap: 0.6rem; color: var(--text); }
.fr-wordmark .name { font-size: 1.25rem; font-weight: 900; letter-spacing: -0.01em; }
.fr-gate h1 { margin-top: 1.3rem; font-size: clamp(1.5rem, 1.2rem + 1vw, 1.8rem); }
.fr-gate h1 em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); }
.fr-sub { margin: 0.6rem auto 0; max-width: 32ch; font-size: 0.9rem; color: var(--muted); }
.fr-sub b { color: var(--text); font-weight: 700; }
.fr-check { margin-top: 1.3rem; width: 100%; }
.fr-foot {
  margin-top: 1.5rem; padding-top: 1.1rem; border-top: 1px solid var(--border);
  font-size: 0.66rem; font-weight: 600; letter-spacing: 0.1em; color: var(--faint);
}

/* ============================================================
   Iteration 5.2 — /app/categories (categories-screen.js). Real size-ranked category "rooms" —
   see that file's header comment for the scope note (replaces only the /categories route's
   content; category-sidebar.js is untouched). Scoped under .categories-page. */
.categories-page { max-width: 1400px; margin: 0 auto; }
.cat-grid {
  margin-top: 1.6rem;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: 1.2rem;
}
.cat-card {
  /* consumed by the shared .gate/.resume/.col-card/.fr-gate/.cat-card background rule, ~line 544 */
  --glass-angle: 158deg; --glass-top: rgba(31, 31, 37, 0.92); --glass-bottom: rgba(21, 21, 25, 0.8);
  cursor: pointer;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 1.2rem;
  min-height: 150px;
  padding: 1.2rem 1.3rem 1.3rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  transition: transform 0.22s ease, border-color 0.22s ease;
}
.cat-card:hover, .cat-card:focus-visible { transform: translateY(-3px); border-color: rgba(255, 94, 91, 0.35); }
.cat-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.cat-top { display: flex; align-items: center; justify-content: space-between; }
.cat-idx { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em; color: var(--faint); }
.cat-arrow { display: flex; color: var(--faint); transition: color 0.22s ease; }
.cat-card:hover .cat-arrow { color: var(--accent); }
.cat-foot { display: flex; flex-direction: column; gap: 0.9rem; }
.cat-body { display: flex; flex-direction: column; gap: 0.35rem; }
.cat-name { font-size: 1.3rem; font-weight: 800; color: var(--text); letter-spacing: -0.01em; line-height: 1.05; }
.cat-meta { font-size: 0.76rem; color: var(--faint); }
.cat-bar { display: block; height: 4px; border-radius: var(--radius-pill); background: rgba(255, 255, 255, 0.06); overflow: hidden; }
.cat-bar i { display: block; height: 100%; border-radius: var(--radius-pill); background: var(--accent); }

/* ============================================================
   Iteration 5.2 — /app/queue (queue-screen.js). RE-SCOPED from the prototype's persisted,
   drag-reorderable play queue (doesn't exist anywhere in this app) to two real things: the
   current in-progress book, and never-started books as "up next". See that file's header
   comment for the full real-data reasoning. Scoped under .queue-page; img.cover overridden to
   the app's established 2/3 portrait ratio (matches lib-page/home-wrap/bcard, not the base 1:1
   square used elsewhere in the shared contract). ============================================ */
.queue-page { max-width: 760px; margin: 0 auto; }
.queue-page img.cover { aspect-ratio: 1; }

.q-current {
  display: flex;
  align-items: center;
  gap: 1.1rem;
  margin-top: 1.6rem;
  padding: 1rem 1.2rem;
  border: 1px solid rgba(255, 94, 91, 0.35);
  border-radius: var(--radius);
  background: linear-gradient(180deg, rgba(255, 94, 91, 0.07), rgba(30, 30, 34, 0.6));
  cursor: pointer;
  transition: transform 0.18s ease, border-color 0.18s ease;
}
.q-current:hover, .q-current:focus-visible { transform: translateY(-2px); border-color: rgba(255, 94, 91, 0.6); }
.q-current:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.q-current-cover { width: 64px; flex: none; }
.q-current-body { flex: 1; min-width: 0; }
.q-current-kicker { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); }
.q-current-title {
  margin-top: 0.25rem; font-size: 1.1rem; font-weight: 800; letter-spacing: -0.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.q-current-author { font-size: 0.82rem; color: var(--muted); margin-top: 0.1rem; }
.q-current-bar { position: relative; margin-top: 0.7rem; height: 5px; border-radius: var(--radius-pill); background: rgba(255, 255, 255, 0.1); overflow: hidden; }
.q-current-bar i { display: block; height: 100%; border-radius: var(--radius-pill); background: linear-gradient(90deg, var(--accent-deep), var(--accent)); }
.q-current-time { margin-top: 0.4rem; font-size: 0.72rem; font-weight: 600; color: var(--faint); letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }

.q-label {
  display: flex; align-items: center; gap: 0.9rem; margin: 2rem 0.1rem 1rem;
  font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted);
}
.q-label::after { content: ""; flex: 1; height: 1px; background: var(--border); }

.q-list { display: flex; flex-direction: column; gap: 0.6rem; }
.q-row {
  display: flex; align-items: center; gap: 0.9rem;
  padding: 0.6rem 0.8rem;
  min-height: 44px;
  border: 1px solid var(--border);
  border-radius: var(--radius-helper);
  background: var(--surface);
  cursor: pointer;
  transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.q-row:hover, .q-row:focus-visible { transform: translateY(-2px); border-color: rgba(255, 94, 91, 0.3); background: var(--surface-elev); }
.q-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.q-row-cover { width: 40px; flex: none; }
.q-info { flex: 1; min-width: 0; }
.q-title { font-size: 0.92rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.q-author { font-size: 0.78rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.q-dur { flex: none; font-size: 0.76rem; color: var(--faint); font-variant-numeric: tabular-nums; }

.q-browse { display: block; margin: 1.6rem auto 0; }

@media (max-width: 560px) {
  .q-current { gap: 0.8rem; padding: 0.85rem 0.9rem; }
  .q-current-cover { width: 52px; }
  .q-current-title { font-size: 1rem; }
  .q-row { gap: 0.7rem; padding: 0.55rem 0.7rem; }
  .q-row-cover { width: 34px; }
  .q-dur { font-size: 0.68rem; }
}

/* ============================================================
   Iteration 5.2 — offline-screen.js (RE-SCOPED from offline-prototype.html's fake "downloaded
   books" section — see that file's header comment for why there is no download/offline-audio
   feature to port). Rendered by app.js's render() in place of login-screen when checkAuth()
   detects a genuine network failure rather than a reachable 401. ============================ */
.offline-page {
  min-height: 60vh;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center; padding: 3rem 1.2rem;
}
.off-eyebrow {
  display: inline-flex; align-items: center; gap: 0.6rem;
  font-size: 0.7rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted);
}
.off-dot {
  width: 8px; height: 8px; border-radius: 50%; flex: none;
  background: #f0a53a; box-shadow: 0 0 8px 1px rgba(240, 165, 58, 0.55);
}
.off-h1 { margin-top: 1rem; max-width: 480px; }
.off-h1 em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); }
.off-sub { margin-top: 0.6rem; max-width: 380px; }
.off-status { margin-top: 0.6rem; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; color: var(--faint); }
.off-actions { margin-top: 1.6rem; }
.off-retry-btn { display: inline-flex; align-items: center; gap: 0.5rem; }
.off-cache-note { margin-top: 2.4rem; max-width: 420px; font-size: 0.8rem; color: var(--muted); }
.off-cache-note b { color: var(--text); font-weight: 800; }

/* ============================================================================================
   Iteration 9 (legal access redesign) — /get-access, /terms shared styles.
   Scoped under .access-page / .terms-page so no other screen is affected. Urbanist only, open
   pages (no glass card), dark ink on coral, WCAG 2.2 AA targets. See redesign-plan-v2.md §6.
   ============================================================================================ */

/* Shared accessibility helpers (global — used by index.html + the plan table caption). */
.visually-hidden {
  position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.skip-link {
  position: fixed; top: 8px; left: 8px; z-index: 1000; transform: translateY(-160%);
  background: var(--accent); color: var(--accent-ink); font-weight: 800; font-size: 15px;
  padding: 12px 18px; border-radius: 999px; text-decoration: none;
  transition: transform 160ms cubic-bezier(0.25, 1, 0.5, 1);
}
.skip-link:focus { transform: translateY(0); outline: 2px solid var(--accent-ink); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }

/* Page-scoped shared primitives. --faint at .62 (not the app-wide .42) for legal legibility. */
.access-page, .terms-page {
  --faint: rgba(242, 242, 245, 0.62);
  max-width: 1120px; margin: 0 auto; padding: 128px 24px 112px;
  font-family: 'Urbanist', system-ui, sans-serif; color: var(--text);
}
@media (max-width: 1023px) { .access-page, .terms-page { padding-top: 96px; } }
@media (max-width: 640px) { .access-page, .terms-page { padding: 88px 20px 112px; } }

.access-page .eyebrow, .terms-page .eyebrow {
  font-size: 13px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--accent); margin: 0 0 16px;
}
.access-page .lead, .terms-page .lead { font-size: 19px; line-height: 1.55; font-weight: 500; color: var(--text); margin: 0 0 24px; }
@media (max-width: 640px) { .access-page .lead, .terms-page .lead { font-size: 18px; } }

.access-page h1, .terms-page h1 { font-weight: 900; line-height: 1.05; margin: 0 0 20px; }
.access-page h1 { font-size: 48px; }
.terms-page h1 { font-size: 42px; line-height: 1.1; }
@media (max-width: 640px) { .access-page h1 { font-size: 34px; } .terms-page h1 { font-size: 32px; } }
.access-page h2, .terms-page h2 { font-size: 28px; font-weight: 900; line-height: 1.2; margin: 0 0 16px; }
@media (max-width: 640px) { .access-page h2, .terms-page h2 { font-size: 24px; } }
.access-page h3, .terms-page h3 { font-size: 20px; font-weight: 800; line-height: 1.3; margin: 0 0 6px; }
.access-page p, .terms-page p { font-size: 17px; line-height: 1.65; }

/* Buttons + links, page-scoped so app-wide controls are untouched. */
.access-page .primary, .terms-page .primary,
.access-page .secondary, .terms-page .secondary {
  min-height: 52px; padding: 0 24px; border-radius: 999px; font-size: 16px; font-weight: 800;
  cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid transparent;
  transition: background 160ms cubic-bezier(0.25, 1, 0.5, 1);
}
.access-page .primary, .terms-page .primary { background: var(--accent); color: var(--accent-ink); }
.access-page .primary:hover, .terms-page .primary:hover { background: var(--accent-hover); }
.access-page .primary:active, .terms-page .primary:active { background: var(--accent-deep); }
.access-page .secondary, .terms-page .secondary { background: transparent; color: var(--text); border-color: var(--border); }
.access-page .secondary:hover, .terms-page .secondary:hover { border-color: var(--text); }
@media (prefers-reduced-motion: reduce) {
  .access-page .primary, .terms-page .primary, .access-page .secondary, .terms-page .secondary { transition: none; }
}

.btn-link {
  color: var(--text); text-decoration: underline; text-decoration-color: var(--accent);
  text-underline-offset: 2px; font-weight: 600; font-size: 16px;
}
.btn-link:hover { text-decoration-thickness: 2px; }

/* Focus: 2px coral ring, 3px offset, on every interactive control on these pages. */
.access-page :focus-visible, .terms-page :focus-visible,
.skip-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* The page H1 is focused programmatically on load for screen-reader landing (skip target); it is
   not keyboard-tabbable (tabindex="-1"), so suppress its visible ring — a coral box around the
   headline reads as a design mistake, not a focus cue. Real interactive controls keep their ring. */
.access-page h1[tabindex="-1"]:focus, .terms-page h1[tabindex="-1"]:focus { outline: none; }

/* ---- /get-access (v2 — welcoming Material Design 3 reskin) ----
   Scoped under .access-v2. Centred hero, elevated step + plan cards, red/white brand button.
   Kept on top of the shared .access-page base (container, eyebrow, a11y focus rings). */
/* Page width matches the top menu bar (1120px content; 1168 - 48px padding, border-box).
   Soft coral wash forked from mockup-get-access-polish-v1.html .ambient only (no blur blob).
   Fixed to the viewport top so it never scrolls and has no gap under the browser chrome
   (#app-root padding used to push a background-image wash down). */
.access-v2 {
  text-align: center;
  max-width: 1168px;
  position: relative;
}
.access-v2::before {
  content: '';
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 420px;
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(ellipse 70% 60% at 50% 20%, rgba(255, 94, 91, 0.18), transparent 70%);
}
@media (min-width: 1024px) {
  .access-v2::before { height: 520px; }
}
/* Keep real page content above the fixed wash (z-index:0 paints over in-flow otherwise). */
.access-v2 > * { position: relative; z-index: 1; }
.access-v2 > section { max-width: none; margin: 0 auto; }
.access-v2 strong { color: var(--text); font-weight: 800; }
.access-v2 .av-hero + section,
.access-v2 section + section { margin-top: 96px; }
@media (max-width: 640px) { .access-v2 .av-hero + section, .access-v2 section + section { margin-top: 64px; } }

/* Hero — previous centred blob wash retired; fixed page wash is .access-v2::before above. */
.av-hero { position: relative; max-width: none; }
.av-hero::before { content: none; display: none; }
.access-v2 .av-hero h1 { font-size: 60px; letter-spacing: -0.02em; margin: 0 auto 20px; max-width: 26ch; }
@media (max-width: 1023px) { .access-v2 .av-hero h1 { font-size: 46px; } }
@media (max-width: 640px)  { .access-v2 .av-hero h1 { font-size: 34px; max-width: none; } }
/* Brand-red count in the headline. */
.av-count { color: var(--accent); }
.av-lead { font-size: 21px; line-height: 1.55; font-weight: 500; color: var(--faint); margin: 0 auto 32px; max-width: 72ch; }
.av-lead--sub { font-size: 18px; margin-bottom: 40px; }
.av-lead--intro { margin-bottom: 16px; }
@media (max-width: 640px) { .av-lead { font-size: 17px; } }
.av-micro { font-size: 15px; color: var(--faint); margin: 20px 0 0; }

/* Hero use-list — the fair-dealing purposes we can consider. Centred block, left-aligned rows,
   red dot markers. Naming a purpose never guarantees approval (see .av-note). */
.av-uses { list-style: none; margin: 0 auto 32px; padding: 0; display: inline-flex; flex-direction: column; gap: 10px; text-align: left; }
.av-uses li { position: relative; padding-left: 26px; font-size: 17px; font-weight: 600; color: var(--text); }
.av-uses li::before { content: ''; position: absolute; left: 4px; top: 9px; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }

/* Buttons — Material 3 filled (brand red, white label) + text button.
   2026-07-26: the hero pair is now two LONG pills of equal length, one filled and one outlined,
   instead of a 221px filled pill beside a 90px text label. `flex: 0 1 300px` gives them the same
   basis and lets them shrink together rather than one squeezing the other; the 640px rule below
   already sends both to full width on a phone, so this is the desktop half of the same idea. */
.av-cta-row { display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; margin-top: 8px; }
.av-cta-row .av-btn { flex: 0 1 300px; }
.av-btn {
  min-height: 52px; padding: 0 30px; border-radius: 999px; font-family: inherit; font-size: 16px;
  font-weight: 800; letter-spacing: 0.05em; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid transparent;
  transition: background 160ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 160ms, transform 160ms;
}
.av-btn--filled {
  background: var(--accent); color: #ffffff;
  box-shadow: 0 2px 10px rgba(255, 94, 91, 0.35), 0 1px 2px rgba(0, 0, 0, 0.4);
}
.av-btn--filled:hover { background: var(--accent-hover); box-shadow: 0 6px 20px rgba(255, 94, 91, 0.45); transform: translateY(-1px); }
.av-btn--filled:active { background: var(--accent-deep); transform: translateY(0); box-shadow: 0 2px 8px rgba(255, 94, 91, 0.3); }
.av-btn--text { background: transparent; color: var(--text); }
.av-btn--text:hover { background: rgba(255, 255, 255, 0.06); }
/* Outlined: coral border, transparent fill, white label. The secondary treatment for anything
   that is a real control but must not compete with the filled coral primary — the hero's Sign
   in, the verification-trouble action, and the corner sign-in button all share it. */
.av-btn--outline {
  background: transparent; color: #ffffff; border-color: var(--accent);
}
.av-btn--outline:hover { background: rgba(255, 94, 91, 0.12); }
.av-btn--outline:active { background: rgba(255, 94, 91, 0.2); }
@media (max-width: 640px) { .av-cta-row .av-btn { flex: 1 1 100%; } }
@media (prefers-reduced-motion: reduce) { .av-btn { transition: none; } .av-btn--filled:hover { transform: none; } }

/* Section headings on this page are centred. */
.access-v2 h2 { text-align: center; }

/* How it works: three elevated cards, each with a red/white number badge. */
.av-steps { list-style: none; margin: 32px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; text-align: left; }
@media (max-width: 820px) { .av-steps { grid-template-columns: 1fr; max-width: 560px; margin-inline: auto; } }
.av-step {
  background: var(--surface-elev); border: 1px solid var(--border); border-radius: 20px; padding: 28px 24px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); transition: transform 160ms, box-shadow 160ms;
}
.av-step:hover { transform: translateY(-3px); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); }
.av-step-num {
  display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%;
  background: var(--accent); color: #ffffff; font-weight: 800; font-size: 19px; margin-bottom: 18px;
  font-variant-numeric: tabular-nums;
}
.access-v2 .av-step h3 { margin: 0 0 6px; font-size: 19px; font-weight: 800; }
.av-step p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--faint); }
@media (prefers-reduced-motion: reduce) { .av-step { transition: none; } .av-step:hover { transform: none; } }

/* Good to know — a warm tonal panel, not a cold legal box. */
.av-note { max-width: none; background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 44px 48px; }
.av-note h2 { margin: 0 0 12px; font-size: 22px; }
.av-note p { margin: 0 auto; max-width: 88ch; font-size: 18px; line-height: 1.7; color: var(--faint); }
@media (max-width: 640px) { .av-note { padding: 28px 22px; } }

/* Plan-card styles deleted with P1/T09: /get-access no longer sells a plan. */

.av-foot { display: flex; flex-wrap: wrap; justify-content: center; gap: 24px; margin-top: 72px; padding-top: 28px; border-top: 1px solid var(--border); }
/* Footer links read as plain links here (no underline); scoped to this page so the /terms
   back-link's shared .btn-link underline is untouched. */
.av-foot .btn-link, .av-foot .btn-link:hover { text-decoration: none; }

/* ---- /terms ---- */
.terms-head { max-width: 70ch; }
.terms-back { display: inline-block; margin-bottom: 24px; }
.terms-meta { display: flex; gap: 32px; margin: 20px 0; }
.terms-meta div { display: flex; flex-direction: column; }
.terms-meta dt { font-size: 13px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.terms-meta dd { margin: 4px 0 0; font-size: 16px; font-weight: 700; }
.terms-print { margin-top: 8px; }
.terms-draft { margin-top: 20px; padding: 12px 16px; border: 1px solid var(--accent); border-radius: 12px; background: var(--surface-elev); color: var(--text); font-weight: 600; font-size: 15px; }
.terms-status { font-size: 17px; color: var(--muted); margin-top: 16px; }
.terms-status--error { color: var(--error); }
.terms-actions { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 24px; align-items: center; }

.terms-layout { display: grid; grid-template-columns: 3fr 7fr; gap: 48px; margin-top: 48px; align-items: start; }
/* /privacy has no table of contents, so its single article must not land in the 3fr TOC column. */
.terms-layout--single { grid-template-columns: 1fr; }
/* 112px cleared the deleted top bar; found while fixing #app-shell's overflow, which is what
   makes every sticky in the app engage for the first time. */
.terms-toc { position: sticky; top: 24px; }
.terms-toc .toc-title { font-size: 13px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 0 0 12px; }
.terms-toc ol { list-style: none; margin: 0; padding: 0; }
.terms-toc li { margin: 0; }
.terms-toc a { display: block; padding: 6px 0 6px 12px; border-left: 3px solid transparent; color: var(--faint); text-decoration: none; font-size: 15px; line-height: 1.4; }
.terms-toc a:hover { color: var(--text); }
.terms-toc a.is-current { border-left-color: var(--accent); color: var(--text); font-weight: 700; }
@media (max-width: 1023px) {
  .terms-layout { grid-template-columns: 1fr; gap: 32px; }
  .terms-toc { position: static; }
  .terms-toc a { padding-left: 12px; }
}

.terms-article { max-width: 70ch; }
.terms-preamble { color: var(--faint); }
.terms-summary { margin-bottom: 48px; padding: 24px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.terms-summary ul { margin: 0; padding-left: 20px; }
.terms-summary li { margin-bottom: 8px; }
.terms-section { margin-bottom: 40px; scroll-margin-top: 112px; }
.terms-section h2 { font-size: 22px; }
.clause-ref { font-weight: 800; color: var(--muted); }
.clause-list { margin: 8px 0 8px 8px; padding-left: 20px; }
.clause-list--alpha { list-style: lower-alpha; }
.clause-list li { margin-bottom: 6px; }
.terms-cancellation { border-top: 1px solid var(--border); padding-top: 32px; }
.cf-line { font-family: 'Urbanist', monospace; color: var(--text); }
.terms-article a, .terms-article { overflow-wrap: anywhere; }

/* Print: contract only, black on white, chrome hidden, links show destinations. */
@media print {
  #global-site-bar, #tab-bar, #global-mini-player, .skip-link,
  .terms-toc, .terms-print, .terms-back, .terms-actions { display: none !important; }
  body, .terms-page, .terms-article { background: #fff !important; color: #000 !important; }
  .terms-page { max-width: none; padding: 0; }
  .terms-layout { display: block; }
  .terms-article { max-width: none; }
  .terms-section, .terms-summary { break-inside: avoid; }
  .terms-section h2 { break-after: avoid; }
  .terms-article a::after { content: " (" attr(href) ")"; font-size: 0.85em; word-break: break-all; }
  .eyebrow, .clause-ref, .terms-meta dt { color: #000 !important; }
}

/* ============================================================
   Iteration 9 v3 — /get-access join popup (access-join-popup.js)
   The create-account form as a modal <dialog>. Reuses the get-access MD3 look (.gate glass card,
   --accent, .av-btn--filled, .md-checkbox, .pwd-eye) so the popup reads as the same page lifting
   into a dialog. Desktop: centred card, card scrolls internally. Mobile: full-screen sheet.
   ============================================================ */
.sr-only {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

.ajp-dialog {
  padding: 0; border: 0; background: transparent; color: var(--text);
  width: min(480px, calc(100% - 32px)); max-width: 480px; overflow: visible;
}
.ajp-dialog::backdrop { background: rgba(8, 8, 10, 0.68); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); }

.ajp-card {
  position: relative; text-align: left; max-height: 92vh; overflow-y: auto;
  padding: 2.5rem 2.25rem 2rem; border-radius: 24px;
  border: 1px solid rgba(255, 255, 255, 0.09);
  background: linear-gradient(168deg, rgba(38, 38, 47, 0.98), rgba(20, 20, 24, 0.99));
  box-shadow: 0 50px 90px -40px rgba(0, 0, 0, 0.85), 0 0 90px rgba(255, 94, 91, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ajp-close {
  position: absolute; top: 10px; right: 10px; width: 44px; height: 44px; min-width: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  border: none; background: transparent; color: var(--muted); border-radius: 999px; cursor: pointer;
}
.ajp-close:hover { color: var(--text); background: rgba(255, 255, 255, 0.06); }

.ajp-mark { display: inline-flex; align-items: center; gap: 9px; color: var(--text); margin: 0 0 1.1rem; }
.ajp-mark span { font-size: 19px; font-weight: 900; letter-spacing: -0.01em; }

.ajp-eyebrow { margin: 0 0 0.35rem; font-size: 13px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); }
.ajp-title { margin: 0 0 1.45rem; font-size: 1.7rem; letter-spacing: -0.02em; line-height: 1.15; }
.ajp-title:focus { outline: none; }
.ajp-title em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); }
.ajp-status, .ajp-panel-text { font-size: 0.95rem; line-height: 1.5; color: var(--faint); margin: 0.6rem 0 1.2rem; }

/* Fields carry their name in the placeholder (Andrew, 2026-07-24) — the <label> is still there,
   visually hidden, because a placeholder is not an accessible name. */
.ajp-card .field { margin-bottom: 1.5rem; }
.ajp-card input { min-height: 50px; padding: 0.85rem 1rem; }
.ajp-card .pwd-wrap input { padding-right: 3rem; }
.ajp-card input::placeholder { color: var(--muted); opacity: 1; }
.ajp-help { margin: 0.5rem 0 0; font-size: 0.76rem; line-height: 1.4; color: var(--muted); }
.ajp-field-err { margin: 0.45rem 0 0; font-size: 0.8rem; font-weight: 600; color: var(--error); }

.ajp-confirms { border: 0; padding: 0; margin: 2.1rem 0 1.4rem; min-inline-size: 0; }
.ajp-confirms legend { padding: 0; font-size: 0.78rem; font-weight: 500; letter-spacing: 0.02em; color: var(--muted); margin-bottom: 1.1rem; }
.ajp-confirm { margin-bottom: 1.35rem; }
.ajp-confirm:last-child { margin-bottom: 0; }
.ajp-confirm-help { display: block; margin-top: 0.4rem; font-size: 0.76rem; line-height: 1.4; color: var(--muted); opacity: 0.95; }
/* Body copy stays regular weight; bold marks a keyword, nothing else (Andrew, 2026-07-24). */
.ajp-card .md-checkbox-text { font-weight: 400; opacity: 1; color: rgba(242, 242, 245, 0.82); }
.ajp-card .md-checkbox-text strong { font-weight: 700; color: var(--text); }
.ajp-card .md-checkbox-text a { color: var(--accent); text-decoration: underline; }

.ajp-boundary { margin: 0.6rem 0 1.5rem; font-size: 0.8rem; line-height: 1.45; color: var(--muted); }

.ajp-block { width: 100%; min-height: 48px; margin-top: 0.4rem; }
.ajp-link-block { display: block; width: 100%; margin-top: 0.9rem; text-align: center; min-height: 44px; }
.ajp-signin { margin: 1rem 0 0; font-size: 0.82rem; color: var(--muted); text-align: center; }
.ajp-signin .link-btn { min-height: 0; }

/* Error summary — announced (role=alert), sits above the form heading, links jump focus to fields. */
.ajp-errsum {
  margin: 0 0 1.2rem; padding: 0.9rem 1.1rem; border: 1px solid var(--error); border-radius: 12px;
  background: rgba(255, 119, 113, 0.08);
}
.ajp-errsum:focus { outline: 2px solid var(--error); outline-offset: 2px; }
.ajp-errsum h2 { margin: 0 0 0.5rem; font-size: 0.95rem; color: var(--error); }
.ajp-errsum ul { margin: 0; padding-left: 1.1rem; }
.ajp-errsum li { margin: 0.2rem 0; font-size: 0.85rem; }
.ajp-errsum a { color: var(--error); font-weight: 700; }

.ajp-banner {
  margin: 0 0 1.1rem; padding: 0.8rem 1rem; border-radius: 12px; font-size: 0.85rem; line-height: 1.4;
  border: 1px solid var(--border); background: var(--surface-elev); color: var(--text);
}
.ajp-banner--error { border-color: var(--error); color: var(--error); background: rgba(255, 119, 113, 0.08); }

.ajp-discard {
  margin: 0 0 1.2rem; padding: 0.9rem 1.1rem; border: 1px solid var(--accent); border-radius: 12px;
  background: var(--surface-elev);
}
.ajp-discard:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
.ajp-discard p { margin: 0 0 0.7rem; font-size: 0.88rem; color: var(--text); }
.ajp-discard-actions { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
.ajp-discard-actions .av-btn--text { min-height: 40px; padding: 0 18px; border: 1px solid var(--border); }

/* Mobile (~390px): a full-screen sheet, not a tiny centred box. Actions stay in document flow so
   the keyboard never covers the submit button. */
@media (max-width: 640px) {
  .ajp-dialog {
    width: 100%; max-width: none; height: 100%; max-height: 100%;
    margin: 0; top: 0; left: 0;
  }
  .ajp-card {
    max-height: 100%; min-height: 100%; border-radius: 0; border: 0;
    padding: 1.75rem 1.4rem calc(1.75rem + env(safe-area-inset-bottom));
  }
  .ajp-title { font-size: 1.5rem; }
}

@media (prefers-reduced-motion: reduce) {
  .ajp-dialog::backdrop { backdrop-filter: none; -webkit-backdrop-filter: none; }
}


/* ===== Library redesign (Iteration 11) — mockup v3 =====
   Ported verbatim from ITERATION 11 - library page redesign/mockup-v3.html (lines 50-204),
   every selector prefixed with `.lib-page ` to isolate the redesign from the existing global
   .grid/.card/.cover/.chip/.filter-bar and the home page's .shelf-row. The mockup's own
   appbar rules are dropped (the global site-bar owns the top bar). GPU-light: NO
   backdrop-filter, NO drop shadows — premium comes from hairlines, the inset cover sheen,
   type and spacing. Square covers (aspect-ratio:1/1) match the current global state.
   Sort menu / density slider / list view (Task 5), status tabs (Task 6), shelves visuals +
   mobile drawer (Task 7), designed placeholders + status pills + playdot (Task 8) all have
   their CSS ported here now so each later task is a focused JS-only diff. */

/* F9. The `.lib-page { max-width: none; margin: 0 }` that used to sit here existed only to
   cancel a 1400px cap two thirds of the way up this file. Both are gone: an element with no
   max-width is capped by nothing, which is what the pair added up to. .layout owns the width. */

/* ---------- layout: sidebar + content ---------- */
/* F12. 30/30/90 and a 30px gutter were four unrelated numbers; the page rhythm now reads from
   the 4px scale. 90px of bottom clearance becomes 88 (--sp-8 * 2 + --sp-6), which still clears
   the 62px dock and the mini-player band it was sized for. */
/* v12 (Andrew, 2026-07-27): the filter panel's search field and the toolbar's search field
   sat 61.88px apart, because the panel started at the top of the page and the toolbar started
   under the masthead. A grid, not an offset: the masthead and the FILTERS kicker share row 1,
   the panel and the content column share row 2, so the panel's top edge IS the toolbar's top
   edge at every width. A `margin-top` would have restated .phead's height in a second place
   and drifted the first time the masthead's copy or type scale moved, with nothing to say so.
   Two rows, two columns, and the panel exists only at >=1152px, so the single-column reset
   lives in the same @media block that already hides it. */
.lib-page .layout { max-width: 1780px; margin: 0 auto; padding: var(--sp-8) var(--sp-8) 88px;
  display: grid; grid-template-columns: 288px minmax(0, 1fr); grid-template-rows: auto auto;
  column-gap: var(--sp-8); align-items: start; }
.lib-page .side-head { grid-column: 1; grid-row: 1; align-self: end; }
.lib-page .side { grid-column: 1; grid-row: 2; }
.lib-page .phead { grid-column: 2; grid-row: 1; }
.lib-page .content { grid-column: 2; grid-row: 2; min-width: 0; }

/* ---------- the filter PANEL (v4-desktop) ----------------------------------------------------
   Was a single-select two-level nav list: one category and one topic, no chips, no tree search,
   no way to hold two things at once, while the phone beside it had all four. That is the defect
   Andrew reported on 2026-07-25, and it was never a layout problem.

   The panel now renders the SAME tree component as the phone's popup (.lgrow/.lgck/.lglab/.lsrow,
   defined once near the end of this file). Everything below is SIZE, not structure: the popup is
   full screen and thumb-driven, the panel is 264px and pointer-driven, so the rows come down from
   58px to 46px and the checkbox column from 56px to 40px. Nothing here changes what a row IS.

   246px -> 264px, because 246 left 176px of label track and ellipsised 11 of the 66 real topic
   names at the second word.

   78px was clearance for the floating top bar Iteration 19 deleted. Sticky never engaged
   (defect 2 above), so nobody saw the gap; now that it does, the offset has to be real. */
/* The TREE scrolls, not the whole panel. When the panel was the scroller, its head, its search
   field and its footer scrolled away with the list: the count and Clear all vanished exactly when
   a long selection made them useful, and the footer was never on screen at all. `min-height: 0` on
   the list is what lets a flex child actually shrink and scroll rather than pushing the panel
   past its own max-height.
   The height also has to clear the fixed bottom navigation, whose height is --tabbar-h. Without
   that term the tree's last row was sliced 12px above the bar with no scrollbar visible, which
   reads as a rendering fault rather than as "there is more below". */
.lib-page .side { width: 288px; flex: none; position: sticky; top: 22px; align-self: flex-start;
  max-height: calc(100vh - 76px - var(--tabbar-h)); display: flex; flex-direction: column; overflow: hidden; }
/* And again for the mini-player, which is only in the layout while something is playing. Reserving
   its band unconditionally would waste 62px of tree on a page nobody is listening from, so the
   reserve is conditional on the bar actually being shown. `:has()` is already how this file scopes
   the page gutter, so this is not a new mechanism. */
body:has(.mini-player-inner.show) .lib-page .side { max-height: calc(100vh - 76px - var(--tabbar-h) - 62px); }
.lib-page .mlist { overflow-y: auto; min-height: 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.lib-page .mlist::-webkit-scrollbar { width: 8px; }
.lib-page .mlist::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-chip); }

/* One object, not three stacked ones. Head, search, tree and footer share a single surface and a
   single border, so the panel reads as the filter control it is rather than as a heading that
   happens to sit above a list. */
/* v12: 13px of top padding, not 12. The toolbar is `align-items: center` around a 46px
   tallest control, so it centres its 44px search field 14px below its own top edge; the panel
   puts its field at border + padding. 1 + 13 = 14 makes the two fields land on the same line
   to the pixel, in both auth states, since the 46px control is there signed in or out. The
   other three sides stay on the token. */
.lib-page .side { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: calc(var(--sp-3) + 1px) var(--sp-3) var(--sp-3); }
/* v12: "separate the filter text from the filter bar". The kicker is a sibling of the panel
   now (see library-grid.js), sitting in the masthead row, so the hairline it used to draw
   UNDER itself and the margin it used to hold INSIDE the panel are both gone: the panel's own
   border is the line now, and the kicker's 8px of bottom padding is the air above it. */
.lib-page .side-head { display: flex; align-items: center; gap: var(--sp-2); padding: 0 2px var(--sp-2); }
/* F11. The uppercase micro-label existed in three trackings inside this block alone (.18em
   here, .14em on the applied row, .12em in the popup's results lead) and seven across the
   app. One token pair, --faint by default; the shelf heading below is deliberately NOT one of
   these, and says so there. */
.lib-page .side-title { flex: 1; min-width: 0; font-size: var(--kicker-size); font-weight: 800; letter-spacing: var(--kicker-track); text-transform: uppercase; color: var(--faint); }
/* v5. `.side-sel` (the "3 on" badge) and `.side-clear` (the text-link Clear all) are GONE from
   this block. The badge counted selected filters; the footer two hundred pixels below counted the
   books they matched; one panel does not need two numbers in two units, and the badge was the one
   nothing else on the page agreed with. The button moved to the footer and took the results
   header's pill design with it — see `.side-foot` below. */

.lib-page .sidesearch { position: relative; display: block; margin-bottom: var(--sp-3); }
/* v12: "make the 2 search bars similar to each other". These four properties are now the same
   values `.lib-page .search input` and `.lib-page .search svg` carry, so the two fields are one
   design rather than two that happen to share a radius: 15px icon -> 18px, left 11 -> 14,
   13px type -> 15px, and the padding pair -> the toolbar field's. `height: 38px` is not changed
   but DELETED: the global `input { min-height: 44px }` has always won, which is why the live
   field measures 44 while declaring 38. Both fields measured 44 before this change and after. */
.lib-page .sidesearch svg { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--muted); pointer-events: none; }
.lib-page .sidesearch input { width: 100%; padding: 12px 14px 12px 40px; border-radius: var(--radius-chip);
  background: var(--bg); border: 1px solid var(--border); color: var(--text); font-size: var(--t-lg); font-weight: 500; }
.lib-page .sidesearch input::placeholder { color: var(--faint); }
.lib-page .sidesearch input:focus-visible { border-color: var(--accent); outline: none; }

/* F3. 14px was the single most frequent radius on the page and named nothing. --radius-helper
   (12px) is the nearest token in the set and is 2px in. */
.lib-page .mlist { display: flex; flex-direction: column; border: 1px solid var(--hair); border-radius: var(--radius-helper); }
.lib-page .m-all { display: flex; align-items: center; gap: 10px; width: 100%; border: 0; border-bottom: 1px solid var(--hair);
  background: transparent; text-align: left; border-radius: 0; color: var(--text); position: relative;
  min-height: 42px; padding: 0 var(--sp-3); font-weight: 700; font-size: var(--t-md); }
.lib-page .m-all:hover { background: var(--tint-hover); }
.lib-page .m-all .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-page .count { color: var(--faint); font-size: var(--t-sm); font-weight: 600; font-variant-numeric: tabular-nums; }
/* "All categories" is the CLEARED state, not a selectable filter, so it reads as active only when
   nothing is ticked. Ticking anything hands the highlight to the rows that caused it. */
.lib-page .m-all.active { background: rgba(255,94,91,.12); color: var(--text); }
.lib-page .m-all.active::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 3px; background: var(--accent); }

/* ---- the shared tree, at panel scale. Size only. ----
   F12. The values in this block are NOT snapped to the 4px scale, and that is the decision:
   46px and 38px row heights, the 40px checkbox column and the paddings that centre inside them
   were measured against the pointer target and the 264px label track, not chosen. The scale
   governs the rhythm BETWEEN blocks (page, masthead, toolbar, shelf, grid, results, panel box);
   inside a component, a number that was measured keeps its number and says so. */
   v6 UPDATE (Andrew, 2026-07-26: "make the filter interface slightly larger on desktop"). The
   numbers below step up together, because a panel that grows its width but keeps 46px rows gets
   emptier, so width, row heights, the tick column, the checkbox and the type all move by roughly
   one notch. They are still measured rather than snapped, and the paragraph above still applies.
   What this costs is worth stating plainly: the results grid renders `repeat(var(--cols), ...)`,
   where --cols is the reader's own density preference, so a wider panel does NOT drop a column.
   It makes each card narrower by (panel growth / column count). At 1280 with 4 columns that is
   about 5px a card; measured after the change, not predicted. */
.lib-page .side .lgck { width: 44px; }
/* F3. 6px here and 7px on the popup's 23px box were the last two off-token radii among the
   controls; both now read --radius-chip. On a 19px box that is a 42% corner rather than 32%,
   which is the only visible consequence and still reads as a checkbox, not a pill. */
.lib-page .side .lck { width: 21px; height: 21px; border-radius: var(--radius-chip); }
.lib-page .side .lck svg { width: 13px; height: 13px; }
.lib-page .side .lglab { min-height: 52px; padding: 9px 13px; gap: 9px; }
/* --t-lg, matching the popup's own step up. The tracking moves with the base rule rather than
   against it: this selector re-declares letter-spacing, so leaving -.008em here would have kept
   the desktop title tight while the popup loosened, which is the divergence-by-accident this
   whole component was rebuilt to stop. */
.lib-page .side .lgt { font-size: var(--t-lg); letter-spacing: .006em; }
.lib-page .side .lgh { font-size: var(--t-sm); margin-top: 2px; }
.lib-page .side .lgn { font-size: var(--t-md); }
.lib-page .side .lgcar { width: 8px; height: 8px; }
.lib-page .side .lsrow { min-height: 42px; padding: 7px 13px 7px 16px; gap: 10px; }
.lib-page .side .lst { font-size: var(--t-md); line-height: 1.24; }
.lib-page .side .lsp { font-size: 10px; margin-top: 1px; }
.lib-page .side .lsn { font-size: var(--t-sm); }
.lib-page .side .lsh-res { padding: var(--sp-2) var(--sp-3) var(--sp-1); font-size: var(--t-xs); }
.lib-page .side .lsh-none { padding: 22px 14px; font-size: var(--t-sm); }
.lib-page .side .lsh-none b { font-size: var(--t-md); }
/* v5. The count and the way to undo it are one thought, so they are one row. The count sits
   left and the button anchors the right edge, which is where every other Clear all on this page
   already is. */
/* NO border-top. `.mlist` is a bordered box and already closes with a hairline of the identical
   colour, so the footer's own rule put TWO 1px lines 12px apart down the panel — measured at
   y=545 and y=557 at 1600px, both rgba(242,242,245,.09). Two parallel rules that close nothing
   between them read as a mistake, not as structure. The gap does the separating; the list's own
   border is the line. (Pre-existing, from v4; the footer only became worth looking at when the
   Clear all button moved into it.) */
.lib-page .side-foot { margin-top: var(--sp-3); padding-top: 0;
  font-size: var(--t-sm); color: var(--faint); font-variant-numeric: tabular-nums; line-height: 1.45; }
.lib-page .side-foot b { color: var(--text); font-weight: 800; font-size: var(--t-md); }
.lib-page .side-foot[hidden] { display: none; }
.lib-page .side-foot .sf-line { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.lib-page .side-foot .sf-n { min-width: 0; }
/* The reconciliation gets its own line rather than trailing the count as a clause. The panel is
   264px wide and the first line already carries a pill; a clause would have wrapped underneath
   anyway, with nothing to say which of the two numbers the wrapped half belonged to. */
.lib-page .side-foot .sf-more { margin-top: var(--sp-1); font-size: var(--t-xs); }
.lib-page .side-foot .sf-more[hidden] { display: none; }
/* THE SAME Clear all as the results header's, deliberately duplicated rather than shared: the
   original lives inside `@media (min-width: 761px)` under `.applied`, and a panel that only
   exists above 1152px has no business depending on where that block's braces happen to close.
   Every value here is copied from it; if one moves, both move. min-height/min-width/padding are
   reset because the global `button` rule (line ~150) forces 44x44 with 1.1rem of side padding on
   every button in the app, which is what made the phone's chip close mark 44px inside a 26px
   circle. The invisible ::after gives the pill a 44px pointer target without a 44px box. */
.lib-page .side-foot .clearall { position: relative; flex: none; display: inline-flex; align-items: center;
  min-height: 32px; min-width: 0; padding: 0 12px; border-radius: var(--radius-pill);
  border: 1px solid rgba(255,94,91,.42); background: rgba(255,94,91,.12);
  color: #ffb0ae; font-size: var(--t-md); font-weight: 800; }
.lib-page .side-foot .clearall::after { content: ""; position: absolute; left: 0; right: 0; top: -6px; bottom: -6px; }
.lib-page .side-foot .clearall:hover { background: rgba(255,94,91,.22); color: var(--text); }

/* ---------- page head ---------- */
.lib-page .phead { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--sp-5); flex-wrap: wrap;
  padding-bottom: var(--sp-5); border-bottom: 1px solid var(--hair); margin-bottom: var(--sp-5); }
.lib-page .phead h1 { margin: 0; font-size: clamp(38px,5vw,56px); font-weight: 900; letter-spacing: -.03em; line-height: .98; }
.lib-page .phead h1 em { font-family: var(--font-serif); font-style: italic; font-weight: 400; color: var(--accent); letter-spacing: -.01em; }
/* ONE line, serif italic to echo the headline's <em>, whole hours only (Andrew's taste pass).
   text-transform and letter-spacing are reset EXPLICITLY: the global `.psum` rule (line 1367)
   uppercases and tracks it out, which is what made this read as a spreadsheet label instead of
   a masthead. Same global-bleed class as the slider height and the .rmeta row. The numerals stay
   in Urbanist so they keep their tabular alignment and weight against the serif words. */
.lib-page .psum { display: flex; align-items: baseline; justify-content: flex-end; gap: 10px; flex-wrap: wrap;
  text-align: right; color: var(--muted); font-family: var(--font-serif);
  font-style: italic; font-weight: 400; font-size: var(--t-lg); line-height: 1.45;
  letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; }
/* F14: this said `'Urbanist', system-ui, -apple-system, sans-serif` — two fallbacks short of the
   html/body stack, so on a machine with no Urbanist the masthead numerals were the only numerals
   in the app that did not land on Segoe UI or Roboto. One token, one stack. */
.lib-page .psum b { font-family: var(--font-sans); font-style: normal;
  font-weight: 800; color: var(--text); letter-spacing: -.01em; }
.lib-page .psum .sep { font-style: normal; color: var(--faint); opacity: .5; }

/* ---------- toolbar (solid, no blur) ---------- */
/* Same story as .side: 63px was the removed top bar's clearance. */
/* v14 (Andrew, 2026-07-27: "there is no padding between the searchbar and the top of the page").
   `top: 0` meant that once stuck, the toolbar's border-box met the viewport edge and its 18px top
   corners were sliced flat by it, so a rounded card read as a bar welded to the top of the window.
   22px is not a new number. `.lib-page .side` has been sticky at 22px since it was written, and
   measured on production the two sat at the SAME y (207.88) at rest and then split to 0 and 22 the
   moment the page scrolled. One column was obeying a gutter the other ignored, so this is the two
   halves of one layout agreeing again rather than a value chosen by eye.
   The ::before is what makes 22px usable at all. A sticky element with a top offset leaves a live
   window above itself, and this one is directly over the scrolling grid, so without the band the
   gap fills with the tops of book covers sliding past above the search field. It paints the page's
   own background (measured: html and body are both rgb(20,20,22) = --bg) across the full
   BORDER box, hence -1px either side and `calc(100% + 1px)`, which lands its bottom edge on the
   toolbar's top border rather than over it.
   At rest the band covers 22px of page that is already --bg, so it is invisible there; verified by
   screenshot at rest, not reasoned about. */
.lib-page .toolbar { position: sticky; top: 22px; z-index: 30; display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
  padding: var(--sp-3); margin: 0 0 var(--sp-6); border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); }
.lib-page .toolbar::before { content: ""; position: absolute; left: -1px; right: -1px;
  bottom: calc(100% + 1px); height: 22px; background: var(--bg); }
.lib-page .catbtn { display: none; align-items: center; gap: 7px; background: var(--bg); border: 1px solid var(--border);
  border-radius: var(--radius-chip); padding: 0 14px; min-height: 44px; font-weight: 600; font-size: var(--t-md); }
.lib-page .search { position: relative; flex: 1 1 240px; min-width: 190px; }
.lib-page .search svg { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--muted); }
/* F6. 15px / 500 / --radius-chip / recessed to --bg inside the --surface toolbar card. The
   search screen's hero field now agrees on weight and reads a radius token instead of a 17px
   literal; the height difference between the two is a stated decision, written at .search-hero. */
.lib-page .search input { width: 100%; padding: 12px 14px 12px 40px; border-radius: var(--radius-chip); background: var(--bg); border: 1px solid var(--border); color: var(--text); font-size: var(--t-lg); font-weight: 500; }
.lib-page .search input::placeholder { color: var(--faint); }
.lib-page .seg { display: flex; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 3px; }
.lib-page .seg button { border: 0; background: transparent; color: var(--muted); font-weight: 600; font-size: var(--t-md); padding: 8px 14px; border-radius: var(--radius-pill); min-height: 38px; white-space: nowrap; }
/* F13. Ink on coral was written three ways in this app: the --accent-ink token (#141416), this
   literal (#1a0d0d), and #1a0e0e in the reference style DNA. All three pass AA on coral, so the
   defect was duplication, not contrast — and the token was already used correctly four lines
   away on the phone pill count and the chip close mark. Measured after: 6.33:1 -> 6.14:1. */
.lib-page .seg button[aria-selected="true"] { background: var(--accent); color: var(--accent-ink); }
/* Material sort menu (replaces the native <select>) */
.lib-page .menu-wrap { position: relative; }
.lib-page .menu-btn { display: inline-flex; align-items: center; gap: 7px; background: var(--bg); border: 1px solid var(--border); color: var(--text); font-weight: 600; font-size: var(--t-md); padding: 0 var(--sp-3); min-height: 44px; border-radius: var(--radius-chip); }
/* v14 (Andrew, 2026-07-27: "the sort filter's button is off-brand, it needs to be white text").
   Measured on production before the change: the button's own `color` was already --text, so the
   value ("Popularity", "Author") was white all along; the parts that were NOT were the "Sort:"
   label and the caret, both --muted at rgb(153,153,163). One control, two greys and a white, on a
   toolbar where the search placeholder is the only other thing deliberately dimmed.
   Both go to --text. The label keeps weight 500 against the value's 600, so the two halves are
   still told apart by weight, which is the distinction that was doing the work; the grey was only
   ever making the control quieter than the toolbar it sits in.
   THIS MOVES TWO BUTTONS, NOT ONE. `.menu-btn` is also `#lib-statusbtn` ("Status:"), which renders
   for signed-in visitors on narrow widths and is invisible to an anonymous probe. Andrew reported
   the Sort one; leaving Status grey would put two spellings of one component in one toolbar, which
   is the exact divergence-by-accident the library's design rule names. */
.lib-page .menu-btn .mb-cur { color: var(--text); font-weight: 500; }
.lib-page .menu-btn .mcaret { width: 7px; height: 7px; border-right: 2px solid var(--text); border-bottom: 2px solid var(--text); transform: rotate(45deg) translateY(-1px); }
.lib-page .menu { position: absolute; right: 0; top: calc(100% + var(--sp-2)); z-index: 36; min-width: 196px; padding: 6px; border-radius: var(--radius-helper); background: var(--surface-elev); border: 1px solid var(--border); box-shadow: var(--sheen); }
.lib-page .menu[hidden] { display: none; }
.lib-page .menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: 0; background: transparent; color: var(--text); text-align: left; font-weight: 600; font-size: var(--t-lg); padding: 0 var(--sp-3); height: 42px; border-radius: var(--radius-chip); }
.lib-page .menu-item:hover { background: var(--tint-hover); }
.lib-page .menu-item .mi-check { width: 15px; color: var(--accent); opacity: 0; }
.lib-page .menu-item[aria-selected="true"] .mi-check { opacity: 1; }
.lib-page .density { display: flex; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-chip); overflow: hidden; }
/* border-radius: 0 is load-bearing. The global `button` rule (line ~122) gives every button
   999px, which turned this 44x44 toggle into a CIRCLE floating inside its own 8px chip — a
   shape that appears nowhere else in the toolbar, and it left the density caret dangling
   outside the fill. These are two segments of one control, so they take the control's own
   corner: square inner edges, and the group's overflow:hidden clips their outer corners to
   --radius-chip exactly, so the pressed segment is flush with the group's border on all
   three sides. Same fill-and-colour-shift language as the status .seg next to it, in chip
   radius rather than pill. Geometry is unchanged: still 44px targets, still a 90x46 group. */
.lib-page .density button { border: 0; border-radius: 0; background: transparent; color: var(--muted); padding: 0 12px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
.lib-page .density button[aria-pressed="true"] { background: var(--surface-elev); color: var(--text); box-shadow: var(--sheen); }
/* The caret is INLINE beside the icon, not absolutely pinned to the button's bottom-right
   corner as it was. Pinned, it read as a stray mark: 3px off the bottom edge and 2px off the
   right, it collided with the group's own 8px corner radius and touched nothing, so it looked
   like a rendering artifact rather than a control. Beside the icon it reads as one "grid + more"
   cluster, which is the idiom the Sort button two chips to its left already uses (.menu-btn
   .mcaret) — same 45deg rotated-corner glyph, so the toolbar now says "there is more here" in
   exactly one visual language instead of two.
   translate BEFORE rotate is deliberate: the rotated square's visual mass sits low, so it needs
   lifting in the UNROTATED frame. Writing rotate() first (as .mcaret does) shifts it diagonally
   instead, which is why that one needed fudging by eye. Flipping to point up on open is what
   turns a decoration into an honest disclosure control. */
.lib-page .density button .caret { width: 6px; height: 6px; flex: none; border-right: 1.7px solid currentColor; border-bottom: 1.7px solid currentColor; transform: translateY(-2px) rotate(45deg); opacity: .6; transition: transform .22s cubic-bezier(.2,.75,.25,1), opacity .15s ease; }
.lib-page .density button[aria-expanded="true"] .caret { transform: translateY(2px) rotate(-135deg); opacity: .95; }
.lib-page .viewwrap { position: relative; }
/* Only one Status control is ever in the layout. Above 1440 the four-tab segment fits and reads
   faster; below it the segment is the single reason the toolbar wrapped, so it hands over. */
.lib-page .statuswrap { display: none; }
@media (max-width: 1439px) {
  .lib-page .seg { display: none; }
  .lib-page .statuswrap { display: block; }
}
/* books-per-row panel: NOT a floating card. It is the toolbar extending downward — same surface,
   same border, flush to the bar's bottom edge, top corners squared off. Opening wipes it down
   from that edge; closing wipes it back up into the bar.
   Geometry: the toolbar has 12px padding + a 1px border, so -13px / 100%+13px puts the panel's
   right border exactly on the bar's right border line and its top exactly on the bar's outer
   bottom edge. The bar's own bottom border becomes the seam between them. */
.lib-page .cols-pop { position: absolute; right: -13px; top: calc(100% + 13px); z-index: 35; width: 214px; padding: 9px 14px 11px;
  background: var(--surface); border: 1px solid var(--border); border-top: 0;
  border-radius: 0 0 var(--radius) var(--radius); box-shadow: none;
  clip-path: inset(0 0 0 0); opacity: 1; visibility: visible;
  transition: clip-path .26s cubic-bezier(.2,.75,.25,1), opacity .18s ease, visibility 0s; }
/* NOT display:none — the panel has to stay laid out for clip-path to animate against it */
.lib-page .cols-pop[hidden] { display: block; clip-path: inset(0 0 100% 0); opacity: 0; visibility: hidden; pointer-events: none;
  transition: clip-path .2s cubic-bezier(.4,0,1,1), opacity .14s ease, visibility 0s linear .2s; }
/* while it is out, the bar's own bottom-right corner squares up so the two read as one piece */
.lib-page .toolbar:has(.cols-pop:not([hidden])) { border-bottom-right-radius: 0; }
/* The desktop panel hangs 13px past its toolbar because the toolbar is inset from the page
   gutter. The phone's is already at the content edge, so the same overhang would put a 214px
   panel into the gutter at 320px. Flush right here.
   It is also a different OBJECT here. The desktop one is drawn as a continuation of the toolbar
   it drops out of — no top border, square top corners, no shadow — which only works because it
   is welded to a full-width bar. Hanging off a 100px button group it reads as a torn-off strip,
   and where its left edge crosses a card's corner pill there is nothing to say which is in front.
   So on the phone it is a floating surface: closed border, all four corners, and a shadow. */
.lib-page .rviewwrap .cols-pop { right: 0; top: calc(100% + 8px);
  border-top: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: 0 14px 34px rgba(0,0,0,.5); }
.lib-page .cols-pop .cl { display: flex; justify-content: space-between; align-items: baseline; font-size: var(--t-sm); font-weight: 700; letter-spacing: .02em; color: var(--muted); margin-bottom: 5px; }
.lib-page .cols-pop .cl b { color: var(--text); font-size: var(--t-lg); font-variant-numeric: tabular-nums; }
/* slider drawn explicitly so nothing shifts colour on hover or press (accent-color leaves that to the UA) */
/* min-height, margin, padding and border are all overridden rather than inherited. The mockup is
   a standalone file with no global form styling; this app has two rules the slider would
   otherwise pick up — `input[type="range"] { min-height: 32px }` (the player's seek-bar touch
   target) and the generic text-input padding/border. Together with box-sizing:border-box they
   forced the slider to 24-32px and the panel to 68-78px, breaking the locked 62px geometry.
   Scoped to .cols-pop so the seek bar and every text input keep theirs. */
/* v5. 16px -> 32px above the structural breakpoint and 44px below it, which are this spec's two
   pointer-target floors (F3). The DRAWN track stays 4px and the thumb stays 14px: the box grows,
   the graphic does not, so the grab band is the whole height while the control looks identical.
   Chrome centres the runnable track in the content box, so the thumb's -5px offset still lands.
   The 16px value was latent-failing at the desktop too — the harness only catches it now because
   v5 put a slider at phone width, where the floor is higher and the finger is real. */
.lib-page .cols-pop input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 32px; min-height: 32px; margin: 0; padding: 0; border: 0; background: transparent; cursor: pointer; display: block; }
@media (max-width: 1151px) {
  .lib-page .cols-pop input[type=range] { height: 44px; min-height: 44px; }
  /* and a handle a thumb can actually see it is aiming at. 14px is a mouse target. */
  .lib-page .cols-pop input[type=range]::-webkit-slider-thumb { width: 20px; height: 20px; margin-top: -8px; }
  .lib-page .cols-pop input[type=range]::-moz-range-thumb { width: 20px; height: 20px; }
}
.lib-page .cols-pop input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--border); }
.lib-page .cols-pop input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5px; border: 0; border-radius: 50%; background: var(--accent); }
.lib-page .cols-pop input[type=range]:hover::-webkit-slider-thumb, .lib-page .cols-pop input[type=range]:active::-webkit-slider-thumb { background: var(--accent); }
.lib-page .cols-pop input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--border); }
.lib-page .cols-pop input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: var(--accent); }

/* ---------- shelves ---------- */
.lib-page .shelf { margin-bottom: var(--sp-8); }
.lib-page .shelf-head { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); }
/* Category label + See all sized up together (Andrew's taste pass) — they are one cluster, so
   bumping only one would unbalance the row. Tracking eases from .16em to .13em: at 15px the
   original tracking reads as spaced-out rather than deliberate. */
/* F11 looked at this and at .side-title together and called them one role in two specs. They
   are not. .side-title is a KICKER: a small label naming the panel under it, and it reads the
   kicker token pair. This is the shelf's HEADING — the largest thing in its row, with the count
   and See all clustered against it — and its 15px/--text is Andrew's recorded taste pass, not
   drift. It stays, and the size now reads --t-lg so it moves with the scale instead of beside
   it. Uppercase does not make a heading a kicker. */
/* v5. Andrew: "make the category label larger, but keep the size of the see all button as it is
   now." So the pairing the comment above records is deliberately BROKEN — the heading takes the
   new --t-xl rung and .seeall stays on --t-lg. Tracking eases again, .13em to .11em: uppercase
   tracking is a fraction of the size, so holding the em value while the size grows widens the
   gaps in absolute terms and the word starts to come apart. */
.lib-page .eyebrow { display: flex; align-items: center; gap: 10px; font-size: var(--t-xl); font-weight: 800; letter-spacing: .11em; text-transform: uppercase; color: var(--text); min-width: 0; }
/* The row has three items and the label is the one that can be long. Wrapping is the safety
   valve, not a layout: at 320px "COGNITIVE BEHAVIOURAL THERAPY" plus a count plus a 44px pill
   does not fit on one line at any size, and the alternative to wrapping is an overflow. */
.lib-page .shelf-head { flex-wrap: wrap; }
.lib-page .eyebrow::before { content: ""; width: 20px; height: 2.5px; border-radius: 2px; background: var(--accent); flex: none; }
.lib-page .shelf-head .count { color: var(--faint); font-size: var(--t-md); }
/* on-brand tonal "See all" — real Material chrome, tied to its label (mockup v4).
   NO margin-left:auto: that is what flung it 786px from the shelf it belongs to in v1, where it
   collided with the next block. The shelf-head flex row clusters eyebrow + count + pill instead.
   Sized up with the eyebrow. min-height goes 32px -> 44px, which is also the constitution's
   touch-target floor — this control was under it since v1 and the taste pass is the cheapest
   moment to fix that, since the property was being edited anyway. */
.lib-page .seeall { display: inline-flex; align-items: center; gap: 7px; color: var(--text); font-weight: 700; font-size: var(--t-lg);
  letter-spacing: .01em; background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-pill); padding: 0 18px; min-height: 44px; box-shadow: var(--sheen); white-space: nowrap; }
.lib-page .seeall:hover { background: var(--surface-elev); border-color: #565660; }
/* F4. 17px was its own step, used by nothing but these seven arrows. The glyph rides its
   label's size now; at 800 weight and full coral it does not need a size of its own to read. */
.lib-page .seeall .ar { color: var(--accent); font-weight: 800; font-size: var(--t-lg); line-height: 1; margin-top: -1px; }
/* air around the rail covers: column gap = left/right, top padding = above (mockup v4).
   The cover-to-title gap (.meta) is deliberately untouched. */
/* v5. Padding on BOTH axes, because the clip rect has both. The rail is a scroll container, and a
   scroll container's overflow is clipped at its PADDING box: anything inside the padding survives,
   anything past it is cut. The status pill hangs 14px above each card and 7px to the right of it,
   so both need room.
   The top was fixed first (--sp-2 -> --sp-4) and the inline axis was left at 0, which is the same
   bug with the sweep unfinished — and it showed at exactly one width, 1600, where six 168px cards
   and five 24px gaps come to 1128px and land flush on the content column's edge, so the last
   card's pill lost its right cap and its shadow to a hard square cut while the cover beside it was
   whole. Found by reading the render; a probe that scrolled the rail first had said it was fine,
   because scrolling is what makes the overhang reachable.
   RIGHT only, never left: the pill overhangs one way, and left padding moves the first card's
   scroll-snap position off zero, so `scroll-snap-type: x mandatory` snaps the rail 8px along the
   moment it paints. Measured: all nine rails came up with scrollLeft = 8 instead of 0.
   The negative right margin gives the padding back to the page, so the content box keeps its
   width and the same number of covers still fit on a row. */
.lib-page .rail { display: grid; grid-auto-flow: column; grid-auto-columns: 168px; gap: var(--sp-6); overflow-x: auto; overflow-y: hidden;
  padding: var(--sp-4) var(--sp-2) var(--sp-3) 0; margin-right: calc(var(--sp-2) * -1);
  scroll-snap-type: x mandatory; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.lib-page .rail::-webkit-scrollbar { height: 8px; }
.lib-page .rail::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-chip); }
.lib-page .rail .card { scroll-snap-align: start; }

/* ---------- results grid ---------- */
.lib-page .results { display: none; }
.lib-page .results.on { display: block; }
.lib-page .browse.off { display: none; }
/* F4. Nothing on this page lived between 17px and the 56px masthead, which is why the masthead
   read as pasted on and why the filtered state had no headline of its own: 285 books arrived
   at 18px, four points off the card titles under it. --t-head (24px) is that missing step, and
   the results count is the one thing on the page that earns it — it is the answer to the
   question the whole toolbar exists to ask. */
.lib-page .rsum { display: flex; align-items: baseline; gap: var(--sp-3); margin-bottom: var(--sp-5); flex-wrap: wrap; }
.lib-page .rsum .n { font-weight: 800; font-size: var(--t-head); letter-spacing: -.015em; }
.lib-page .rsum .n span { color: var(--muted); font-weight: 600; }
/* How much of the result set is on screen, and in what order. The grid is bounded, so a headline
   count of 285 sat above a grid holding 60 with nothing on the page reconciling the two. */
.lib-page .rsum .rsub { color: var(--faint); font-size: var(--t-md); font-weight: 600; font-variant-numeric: tabular-nums; }
.lib-page .rsum .rsub:empty { display: none; }
/* Grid-or-list AND books-per-row, at the widths where the toolbar that used to carry them is not
   on the page. v5 added the second half; the wrapper exists to anchor the popover, which is the
   same job `.viewwrap` does for the desktop pair, and `margin-left: auto` moved up to it so the
   cluster still parks against the right edge of the results header. */
.lib-page .rviewwrap { display: none; position: relative; margin-left: auto; }
.lib-page .rview { display: inline-flex; gap: 2px; background: var(--bg);
  border: 1px solid var(--border); border-radius: var(--radius-chip); padding: 2px; }
/* The grid button carries a caret now, so it is wider than the square list button beside it.
   min-width, not width: the caret is 6px plus a 5px gap, and a fixed 44px box would have squeezed
   the glyph off-centre rather than growing. Both stay at or above the 44px touch floor. */
.lib-page .rview button { display: inline-flex; align-items: center; justify-content: center; gap: 5px;
  min-width: 44px; min-height: 44px; padding: 0 10px; border: 0; border-radius: var(--radius-chip);
  background: transparent; color: var(--muted); }
.lib-page .rview button[aria-pressed="true"] { background: var(--surface-elev); color: var(--text); box-shadow: var(--sheen); }
/* Same caret as the desktop density button's, same rotation on open, one declaration each. */
.lib-page .rview button .caret { width: 6px; height: 6px; flex: none; border-right: 1.7px solid currentColor; border-bottom: 1.7px solid currentColor;
  transform: translateY(-2px) rotate(45deg); opacity: .6; transition: transform .22s cubic-bezier(.2,.75,.25,1), opacity .15s ease; }
.lib-page .rview button[aria-expanded="true"] .caret { transform: translateY(2px) rotate(-135deg); opacity: .95; }
/* In LIST view this button switches back to the grid; it does not open the density panel, because
   there is no grid to change the density of. A caret there advertises a disclosure that will not
   happen, which is the same defect as a control that cannot perform the action it names. It comes
   back the moment the grid is showing. `visibility`, not `display`, so the button does not change
   width when the view changes. */
.lib-page .rview button[data-view="grid"][aria-pressed="false"] .caret { visibility: hidden; }
/* The results header is `align-items: baseline` and this cluster is a box, not a line of text, so
   it would hang off the count's baseline. `.rsum` also wraps, and on a 320px phone this lands on
   its own row under the count. */
.lib-page .rviewwrap { align-self: center; }
@media (max-width: 1151px) { .lib-page .rviewwrap { display: block; } }
/* v4-desktop: the column count the user ASKED for, capped by what actually fits.
   `repeat(var(--cols,6), minmax(0,1fr))` obeyed the preference at every width, so a 1024px
   window with a 264px filter panel rendered six 100px covers: a fixed count is a decision about
   card size only if the window never changes, and it always changes.
   The track minimum is now max(the width six columns would give, --card-min). When the
   preference fits, `auto-fill` resolves to exactly that many columns and nothing moves. When it
   does not, the floor wins and auto-fill packs as many whole cards as the row can hold.
   Re-measured on this catalogue after the 1152px switch landed, with no stored column
   preference: 1152 -> 4 at 152px, 1280 -> 4 at 184px, 1440 -> 5 at 174px, 1600 -> 5 at 206px,
   1920 -> 6 at 168px. 901 to 1151 is a fixed five-column ladder further down this file, so it
   never reaches this rule. */
/* F12/E5. The column gap was written 26px TWICE inside this one rule — once as the gap and once
   inside the track calculation that has to agree with it — so changing the gutter and forgetting
   the calc would have silently overflowed the row. It is --sp-6 in both places now, and the row
   gap is --sp-8: 34/26 became 32/24, the two nearest steps, and the measured column counts at
   1024/1280/1440/1920 are unchanged. */
/* v5. padding-top --sp-2 -> --sp-4: the status pill now hangs above its card, and the top row of
   cards starts at the grid's content edge. 8px of padding left it 1px proud of the box. 16px, not
   12px, because on a card under 130px the pill rides 15px up (see the container query on .pill),
   which 12px would have clipped by 3px at exactly the density the phone's new slider reaches.
   Nothing else moves: the row gap is 32px and only the first row can reach the top edge. */
.lib-page .grid { --card-min: 132px; display: grid; gap: var(--sp-8) var(--sp-6); padding-top: var(--sp-4);
  grid-template-columns: repeat(auto-fill, minmax(max(var(--card-min), calc((100% - (var(--cols,6) - 1) * var(--sp-6)) / var(--cols,6))), 1fr)); }
/* list view (Task 5) */
.lib-page .list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
/* --row-cover is the ONE number that decides how big a list-row cover is; the phone block at the
   bottom of this section is the only place that overrides it. It exists because the size is used
   three times (the box, and both grid tracks below) and three copies of one number is the bug. */
.lib-page .row { --row-cover: 64px; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4); text-decoration: none; color: inherit; border-bottom: 1px solid var(--hair); }
.lib-page .row:last-child { border-bottom: 0; }
.lib-page .row:hover { background: var(--tint-hover); }
/* F2. Was 11px, the third cover radius on one page. One token, 1px out. */
.lib-page .row .rc { width: var(--row-cover); height: var(--row-cover); flex: none; border-radius: var(--radius-helper); overflow: hidden; position: relative; border: 1px solid var(--border); box-shadow: var(--sheen); }
.lib-page .row .rc .rct { position: absolute; inset: 0; display: grid; place-items: center; padding: 6px; font-weight: 800; font-size: var(--t-xs); line-height: 1.06; text-align: center; }
/* `.rmeta` is ALSO a global class further up this file (line ~1476: an uppercase eyebrow row with
   margin-top:2.6rem and display:flex), and it was bleeding straight into the library list row —
   titles rendered UPPERCASE, the author sat beside the title instead of under it, and the whole
   block was pushed to the bottom of the row. Pre-existing since v1 and invisible while the list
   view was unreachable from the landing page; now that D1 makes the list toggle a headline
   control, it is on the main path. Every inherited property is reset explicitly rather than by
   renaming the class, which would mean touching the markup of a shipped component. */
.lib-page .row .rmeta { flex: 1; min-width: 0; display: block; margin-top: 0; gap: 0;
  font-size: inherit; font-weight: inherit; letter-spacing: normal; text-transform: none; color: inherit; }
.lib-page .row .rt { font-weight: 700; font-size: var(--t-lg); letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lib-page .row .ra { color: var(--muted); font-size: var(--t-md); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* In a list row the pill is not a corner element, it is a cell — so it drops the negative offsets
   and, v5, the drop shadow that exists to lift it off the artwork it no longer sits on. */
.lib-page .row .pill { position: static; top: auto; left: auto; right: auto; flex: none; height: 20px; font-size: var(--t-xs); box-shadow: none; }
.lib-page .row .rd { color: var(--muted); font-size: var(--t-sm); font-weight: 600; font-variant-numeric: tabular-nums; flex: none; width: 66px; text-align: right; }
/* v5, found by reading the 320px list render and then measuring it. FOUR fixed columns — a 64px
   cover, an ~78px status pill, a 66px duration and three 16px gaps — leave the title column
   whatever is left, and at 320px what is left is NOTHING:
       320  title column 0px on every row that has a status pill, 5 rows overflowing the container
       360  11px
       390  33px
   A book list where the book's name is zero pixels wide is not a list. `.rmeta` already has
   min-width: 0, so it shrank obediently all the way to nothing while the two fixed columns beside
   it kept their full width — flex-shrink does exactly what it is told, which is the trap.
   Below 761px the row wraps instead: the title keeps the whole width beside the cover, and the
   pill and the duration drop to a second line indented to the title's own left edge (64px cover +
   16px gap). Nothing is hidden, nothing is truncated, and the desktop row is untouched.
   Pre-existing since the list view landed; it is here because the corner-pill work made every
   list row worth looking at. */
/* 2026-07-26, Andrew: "on the list view, the cover image is too small compared to the list
   container". Measured at 390: the row was 119px tall, the cover 64px, so the artwork filled 54%
   of a row it was supposed to anchor, with a 64x22 hole under it. The cause was the wrap above,
   not the cover's size: a flex item cannot span two flex lines, so the second line (the pill and
   the duration, indented 80px to clear the cover) added 22px of height the cover could never
   reach into. Growing the cover inside that layout just moves the hole down.
   Grid instead, and the cover spans both rows. Three columns — cover, pill, duration — because
   with `auto` for the pill's track its 16px gap would still be spent when no pill exists, and the
   duration would sit 16px right of the title it is meant to align with; `.pill ~ .rd` moves the
   duration to the third column only when there is a pill to sit beside, which is the same sibling
   test the old `margin-left` pair used.
   The cover is 88px, which is what the row's own content box already measured (87px), so the row
   height is unchanged at 120 and the artwork is 37% bigger. Every row is now exactly the same
   height whether or not it carries a status pill, which the old wrap could not manage (118 vs
   124). The desktop row above is untouched: at 64px it already filled its 65px content box. */
@media (max-width: 760px) {
  /* `align-content: center` is not tidying. The cover spans both rows and is taller than they
     are, so the leftover height goes to the two `auto` tracks under the default stretch — which
     pushed the duration to the bottom of the row and left a 30px hole between it and the author.
     Centred, the text group keeps its own spacing and sits level with the cover beside it, which
     is what the desktop row has always done with `align-items: center`. */
  .lib-page .row { --row-cover: 88px; display: grid; grid-template-columns: var(--row-cover) auto 1fr;
    grid-template-rows: auto auto; column-gap: var(--sp-4); row-gap: 8px; align-items: center; align-content: center; }
  .lib-page .row .rc { grid-column: 1; grid-row: 1 / -1; }
  .lib-page .row .rmeta { grid-column: 2 / -1; grid-row: 1; }
  .lib-page .row .pill { grid-column: 2; grid-row: 2; justify-self: start; }
  .lib-page .row .rd { grid-column: 2; grid-row: 2; justify-self: start; width: auto; text-align: left; }
  /* only when a pill is actually there does the duration move over to sit beside it */
  .lib-page .row .pill ~ .rd { grid-column: 3; }
}

/* ---------- book card ---------- */
/* F5. `container-type: inline-size` is what lets the card's own type read the card's own width.
   There was exactly ONE desktop .meta rule and one phone rule, with nothing between them, so a
   132px cover and a 194px cover carried the same 14px title — 10.6% of the cover against 7.2%
   — and the density slider could take a cover to 90px without the labels noticing. The card is
   always sized by its track (a grid column, or the rail's 168px auto-column), never by its
   contents, so inline-size containment cannot collapse it. */
.lib-page .card { container-type: inline-size; position: relative; text-align: left; background: none; border: 0; padding: 0; width: 100%; display: block; text-decoration: none; color: inherit; }
/* F2/F3. 14px, 106 to 108 boxes of it, the most frequent radius on the page and a member of no
   token. --radius-helper (12px) is the nearest one and is already what the base img.cover uses,
   so every cover in the app — grid, rail, list row, book page, author page, home shelf — now
   turns the same corner. Moved 2px in. */
.lib-page .cover { position: relative; aspect-ratio: 1/1; border-radius: var(--radius-helper); overflow: hidden; border: 1px solid var(--border);
  box-shadow: var(--sheen); }
/* real cover image — the mockup has no <img>; production covers fill the .cover box */
.lib-page .cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.lib-page .card:hover .cover { border-color: #565660; }
/* Cover hover, ported from the home shelf (Andrew, 2026-07-27): "no hover animation on the
   category page, implement the home page's, without the player button".
   Home (`.home-wrap .bookcard`, ~1968) lifts the cover 8px, tightens a ground ellipse under
   it, and fades in the coral play disc. The play disc is the part that was excluded. The lift
   is here as-is; the ellipse could not be, because it lives on `.cover-wrap::after` and this
   page's `.cover` is `overflow: hidden` and always will be, that being what rounds the image,
   so the same pseudo-element would be clipped out of existence. A box-shadow paints outside
   the border box and is not clipped, and it stays off at rest, so the resting page is
   unchanged and only the hover gains depth.
   The lift is on `.cover`, the BOX, never on the `img`: the img is `position: absolute;
   inset: 0` inside that clipped box, so moving it would slide the artwork inside its own
   frame rather than raise the tile. On home the img is itself the box.
   `.pill` rides along. It is absolute against `.card`, not `.cover`, so on its own it would
   stay put and open an 8px gap where the rule above wants six pixels of corner contact.
   No `will-change`, which home does carry: this grid paints 60 tiles plus nine rails, and a
   compositor layer per cover is the class of thing that has frozen this tab before. */
.lib-page .card > .cover { transition: transform .22s ease, box-shadow .22s ease; }
.lib-page .card > .pill { transition: transform .22s ease; }
.lib-page .card:hover > .cover { transform: translateY(-8px); box-shadow: var(--sheen), 0 14px 22px -10px rgba(0, 0, 0, .55); }
.lib-page .card:hover > .pill { transform: translateY(-8px); }
/* v14 (Andrew, 2026-07-27: "The .opendot chevron - ADD IT"). v10 ported the home shelf's lift to
   this page and deliberately left the badge out, which is the open question v11 and v12 both
   carried on the record. It is now in, and this surface is the CHEAPEST of the three: two lines,
   no new values, no transform.
   That is a consequence of where the markup put it. The badge is a child of `.cover`, the box
   this page lifts, so it rides the 8px and the 0.22s with it. `.book-page .tile` is arranged the
   same way and contributes the same single reveal. Home is the exception, and its own comment
   says why: there the badge is a sibling of the img that moves, so home must re-state the 8px.
   Three surfaces, one component, one number stated twice instead of three times.
   `--accent` is not involved anywhere here. A cover on this page routes to /book/:id, and the
   coral play disc it replaced put a transport control on a thing that navigates. `.lib-page
   .playdot` is still declared above and is NOT dead: the author page renders `.author-page
   lib-page` and its tiles emit `.playdot`. That surface is module 02's to settle, not this
   change's, and it is the last cover in the app still showing a play triangle. */
.lib-page .card:hover .opendot,
.lib-page .card:focus-visible .opendot { opacity: 1; }
/* The badge is a fixed 28px and this page is the ONLY surface whose covers are not a fixed size:
   the density slider runs 2 to 10 and the reader's choice is saved, so a preference set on a
   desktop follows them onto a phone. Measured with the change applied, hovering a real tile:

     1440  cols 4 -> cover 218.0px, badge 12.8%      390  cols 4 -> cover 71.0px, badge 39.4%
     1440  cols 6..10 -> cover 137.3px, badge 20.4%  390  cols 6 -> cover 42.7px, badge 65.6%
                                                     390  cols 8 -> cover 28.5px, badge 98.2%
                                                     390  cols 10 -> cover 20.0px, badge 140.0%

   At the bottom of that table the badge is LARGER THAN THE ARTWORK IT ANNOTATES, hanging past two
   edges. Nothing throws and every colour, opacity and inset check passes, because the insets are
   measured from the right and bottom and those stay correct; it is the left and top that leave the
   box. Found by driving `?cols=10` at 390, which is a state a real saved preference reaches and
   which no screenshot of this page has ever been taken in.
   112px is 28 / 0.25: the width at which the badge stops being a quarter of the cover and starts
   being a button sitting on it. Below that it is suppressed rather than scaled, because a 12px
   disc holding an 8px chevron is not a smaller affordance, it is an unreadable one, and this is a
   HOVER affordance on covers that at those sizes are thumbnails. Desktop keeps it at every density
   (the narrowest measured is 137.3px); 390 loses it at every density, which costs nothing real,
   since a touch device has no hover to reveal it with in the first place.
   This is a WHEN-to-show-it rule, which is the only thing a surface owns here. The component
   itself is untouched, and `.lib-page .opendot` is still never declared. */
@container (max-width: 112px) {
  .lib-page .card .opendot { display: none; }
}
.lib-page .cover .art { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; padding: 14px; }
.lib-page .cover .art .cb { font-size: var(--t-xs); font-weight: 700; letter-spacing: var(--kicker-track); text-transform: uppercase; opacity: .8; }
.lib-page .cover .art .ct { font-weight: 800; font-size: var(--t-lg); line-height: 1.12; letter-spacing: -.01em; }
.lib-page .cover.ph .art { padding: 16px 14px 14px 20px; justify-content: space-between; }
.lib-page .cover.ph::before { content: ""; position: absolute; left: 8px; top: 8px; bottom: 8px; width: 3px; border-radius: 3px; background: rgba(255,255,255,.16); }
.lib-page .cover.ph .mono { font-family: var(--font-serif); font-style: italic; font-size: 30px; opacity: .5; line-height: 1; }
.lib-page .cover.ph .pt { font-weight: 700; font-size: var(--t-lg); line-height: 1.15; }
/* status PILLS on the cover — SOLID fills, white text (mockup v4).
   The earlier translucent bordered version sat on top of real book art and was unreadable; a
   status has to survive any cover behind it, so it gets its own opaque block. Both fills clear
   4.5:1 against white at this size (red 5.6:1, green 5.3:1). */
/* v4-desktop: the colour moved from the FILL to a 5px dot.
   A solid red capsule on every progressed cover put 6 to 8 red elements on a screenful whose
   accent is red, so nothing on the page could out-shout anything else: the selected filter, the
   primary action and "you started this book" all arrived at the same volume. The word stays,
   because a bare dot is a hidden affordance. What changes is the weight.
   The plate is a near-opaque neutral so it still survives any artwork behind it; contrast
   measured on the rendered page, white on rgba(10,10,12,.78) over the brightest cover in the
   catalogue is 13.4:1, better than the 5.6:1 the red fill gave. */
/* v5. It is a CORNER ELEMENT now, not an overlay. Andrew's report: "the in progress and done tags
   are covering the titles, so titles on the covers are not readable" — true, because a cover's
   own artwork puts its title at the top on roughly half this catalogue, and a plate sitting 8px
   inside the corner lands squarely on it.
   The pill is drawn OUTSIDE `.cover` (see `_card` in library-grid.js), because `.cover` is
   `overflow: hidden` and always will be — that is what rounds the image. Its offsets are negative
   against `.card`, so it straddles the corner and reads as attached to the tile rather than
   printed on the artwork, and the artwork under it is uncovered.
   -14px against a 20px pill leaves SIX pixels over the artwork. -9px was tried first and left
   eleven, and eleven is still enough to cap a cover's own strapline: measured at 768 and 1600, the
   line reading "The Best of the Comedy-O-Rama Hour Season One" was still losing its top row, which
   is the complaint this change exists to answer. Six is the number where the pill visibly touches
   the corner — so it still reads as attached rather than floating above the tile — and covers
   essentially nothing. The opaque plate and the blur stay for those six pixels. */
/* 2026-07-28: the book page's rail tiles carry the SAME pill, so this rule and the three below it
   name both surfaces instead of being copied under `.book-page`. Andrew asked for the library's
   tags on the book page, and two declarations of one tag is how they stop being one tag. The
   selector is `> .pill` because the tile's structure is fixed and a descendant match would also
   catch anything a future rail tile nests. The book page contributes only what is its own: the
   hover lift, beside `.book-page .tile` where the rest of that surface's behaviour lives.
   The `@container` step below does NOT apply there and is not meant to: it needs
   `container-type` (only `.lib-page .card` has it) and a cover under 140px, while a rail tile is
   a fixed 150px or 172px. */
.lib-page .pill,
.book-page .tile > .pill { position: absolute; top: -14px; right: -7px; z-index: 2; display: inline-flex; align-items: center; gap: 5px;
  height: 20px; padding: 0 var(--sp-2); border-radius: var(--radius-pill); font-size: var(--t-xs); font-weight: 700; letter-spacing: .01em;
  border: 1px solid rgba(255,255,255,.13); background: rgba(10,10,12,.78); color: var(--text);
  -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px);
  box-shadow: 0 2px 8px rgba(0,0,0,.45); }
/* The VERTICAL overlap is a flat 6px and needs no scaling: 3% of a 200px cover, 6% of a 99px one.
   The HORIZONTAL size does. The pill is a fixed 81px whatever it sits on, which measures
     1600 grid 39%   1600 rail 48%   390 grid 52%   390 rail 69%   390 grid at 3-up 82%
   of the cover's width. Past about 60% it stops reading as a mark in the corner and starts
   reading as a bar across the top, and the 118px rail cover is the DEFAULT phone browse tile, not
   a density anybody chose. So one step below 140px: smaller type, tighter padding, a smaller dot,
   and the top offset moves with the height so the six pixels of contact are preserved.
   The word STAYS. This file's rule is text plus icon, never colour alone (WCAG 1.4.1), and a
   proportion is not worth trading it for. 9.5px/700 is a size already carried elsewhere in this
   component (`.lsp`), so it is not a new step on the scale. */
@container (max-width: 140px) {
  .lib-page .pill { height: 18px; top: -13px; padding: 0 6px; gap: 4px; font-size: 9.5px; }
  .lib-page .pill .dot { width: 4px; height: 4px; }
  .lib-page .pill svg { width: 9px; height: 9px; }
}
.lib-page .pill .dot,
.book-page .tile > .pill .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
.lib-page .pill svg,
.book-page .tile > .pill svg { width: 10px; height: 10px; }
/* F7. The :root note two thousand lines up says the only green in the file was #1e7a4a on this
   exact selector, and mints --text-success to retire it. #1e7a4a did go — and was replaced by
   #4ade80, a THIRD green, so the token retired nothing and was left referenced only by the
   admin page. The finding was written against the old solid-green pill; since then the colour
   moved off the fill and onto the glyph, so this is now the tick itself. Same fix either way.
   #4bd6a0 on the pill's rgba(10,10,12,.78) plate measures 10.4:1. */
.lib-page .pill.done svg,
.book-page .tile > .pill.done svg { color: var(--text-success); }
/* F9. The mockup's hover play dot used to be declared here, as `.lib-page .playdot` — the SAME
   selector the Iteration 5.2 block 1,270 lines up already used for a 46px centred disc. Two
   declarations, conflicting geometry, resolved by source order alone.
   The library never rendered one: library-grid.js's _card() emits .cover > img + .pill and
   .meta only, and `document.querySelectorAll('.lib-page .playdot')` returned 0 on /library at
   1440 and 1920. What it DID do was capture the 1,102 playdots on /book/:id and the author
   page, whose wrappers also carry .lib-page, and park a 46px coral disc inside a 38px coral
   pill in the corner. Deleted rather than revived: the play glyph on a browse tile that
   navigates was the affordance Iteration 14 removed for lying about what a click does, so
   reintroducing one here would be a design decision this audit has no mandate for. */
/* F4/F5/F10 — the card's three labels, one spec, two steps.
   The base ladder is the SMALL one: --t-md / --t-sm / --t-xs, which is 13 / 11.5 / 10.5, the
   exact set the approved phone mockup uses. That is not a coincidence to be preserved by
   copying it twice; it is the ladder a cover under ~160px wants, wherever that cover is, and
   the phone is simply the width where every cover is that small. The @container step below
   moves it up one for a cover that can carry it.
   COLOUR. The duration was --faint, which at the desktop's 0.42 alpha measures 3.79:1 on the
   page background — under the 4.5:1 floor the spec sets for body text, and evidenced in the
   audit's own F4-contrast-1440.json. It is --muted now (6.52:1), which is also what the home
   shelf card already used, so the two surfaces stop disagreeing about the same line of text.
   Hierarchy is carried by size and weight, which are readable, rather than by a third tone
   that is not.
   WEIGHT. 600 on the large step, 700 on the small one. Not a flip: at 10.5px a 600 tracks
   thinner than the eye reads at 11.5px, so the smaller step pays for its size in weight. */
.lib-page .meta { padding: var(--sp-2) 2px 0; }
.lib-page .meta .t { font-weight: 700; font-size: var(--t-md); line-height: 1.24; letter-spacing: -.01em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.lib-page .meta .a { color: var(--muted); font-size: var(--t-sm); font-weight: 500; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lib-page .meta .d { color: var(--muted); font-size: var(--t-xs); font-weight: 700; margin-top: var(--sp-1); display: flex; align-items: center; gap: 5px; font-variant-numeric: tabular-nums; }
/* 160px is where the small ladder starts wasting the cover: at 168px (the rail) a 13px title is
   7.7% of the cover against the 11% the phone gets at 118px. Above it the card steps up one
   scale step across all three lines, so 132px and 194px covers are no longer given identical
   labels. Measured after: 1024/169px, 1100/174px, 1280/184px and 1920/167px all take the large
   step; the density slider at 10 columns drops back to the small one, which is the case that
   had no rule at all before. */
@media (min-width: 761px) {
  @container (min-width: 160px) {
    .lib-page .meta { padding-top: var(--sp-3); }
    .lib-page .meta .t { font-size: var(--t-lg); line-height: 1.25; }
    .lib-page .meta .a { font-size: var(--t-md); }
    .lib-page .meta .d { font-size: var(--t-sm); font-weight: 600; margin-top: 5px; }
  }
}
.lib-page .grid .card { min-width: 0; }
.lib-page .empty { padding: 60px 0; text-align: center; color: var(--muted); font-size: var(--t-lg); }

/* ---------- mobile ---------- */
.lib-page .backdrop { display: none; }
@media (max-width: 900px) {
  .lib-page .layout { padding: 22px 16px 120px; }
  .lib-page .catbtn { display: inline-flex; }
  /* CRITICAL DEFECT 1 (Iteration 11 v3-mobile): the desktop rule sets `align-self: flex-start`
     so the sticky sidebar sizes to its content, and this block never reset it. As a FIXED
     drawer with top:0 and bottom:0 it still sized to content instead of stretching, so
     `overflow-y: auto` never engaged and 37 of 53 subcategories were unreachable — they were
     laid out below the viewport with nothing to scroll. `stretch` is what makes top/bottom bind. */
  .lib-page .side { position: fixed; top: 0; left: 0; bottom: 0; width: 280px; max-height: none; z-index: 60; background: #17171a;
    align-self: stretch;
    border-right: 1px solid var(--border); padding: 18px 12px; transform: translateX(-100%); transition: transform .2s ease; overflow-y: auto; }
  .lib-page .side.open { transform: none; }
  .lib-page .backdrop.on { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 55; }
  /* CRITICAL DEFECT 3: this was a flat `repeat(2,1fr) !important` across the whole 320-900px
     range, so a 900px tablet showed two covers per row with ~430px of dead space each. A ladder
     instead, and minmax(0,1fr) not 1fr — a bare 1fr is minmax(auto,1fr), whose auto MINIMUM is
     floored by the longest unbroken title, which is what blew the /search cards past their
     container in Iteration 17.
     v5: the ladder's NUMBERS moved into defaultColsFor() in library-grid.js and the track reads
     --cols here. That is the whole of Andrew's sixth item. This rule used to hardcode the count,
     which meant the density slider could not move anything below 900px, which is why v4 withdrew
     the caret and the panel and left the phone with a grid button that looked different and did
     less. A default belongs in the thing that computes defaults; a stylesheet that overrides the
     control is not a default, it is a second decision-maker.
     The fallbacks (2 / 3 / 4) are the same ladder, for the paint before JS sets --cols, and the
     !important still beats the desktop rule's auto-fill track above. */
  .lib-page .grid { grid-template-columns: repeat(var(--cols,2),minmax(0,1fr)) !important; gap: 22px 14px; }
  @media (min-width: 561px) { .lib-page .grid { grid-template-columns: repeat(var(--cols,3),minmax(0,1fr)) !important; gap: 26px 18px; } }
  @media (min-width: 721px) { .lib-page .grid { grid-template-columns: repeat(var(--cols,4),minmax(0,1fr)) !important; } }
  /* left-aligned and a notch down at phone width; flex-wrap (set above) is the safety valve so
     the single line can break rather than push the page into horizontal scroll at 320px. */
  .lib-page .psum { text-align: left; justify-content: flex-start; font-size: 14px; gap: 8px; }
  /* v5. What used to stand here, and why it is gone:
       "Books-per-row is OVERRIDDEN to 2 at this width by the rule above, so the slider cannot
        move anything here ... So below 900px the affordance is withdrawn entirely."
     The observation was right and the conclusion was backwards. The slider could not move
     anything because THIS STYLESHEET was pinning the count, which is a cause that can be fixed,
     not a constraint that has to be designed around. The track reads --cols now (see the ladder
     above), so the control does what it says and the affordance comes back rather than being
     withdrawn — which is what Andrew reported as the phone's grid button being "different" and
     "missing the function of edit the grid number". D1's rule is satisfied by making the control
     work, not by deleting it. */
}
/* `.lib-page *` does NOT match pseudo-elements, which is how a 240ms opacity fade on the popup
   scrim survived reduced motion while every gate said the page honoured it. Animations are killed
   here too: the rule says remove motion, not shorten it. */
@media (prefers-reduced-motion: reduce) {
  .lib-page *, .lib-page *::before, .lib-page *::after {
    transition: none !important; animation: none !important;
    scroll-behavior: auto !important;
  }
}

/* ============================================================================
   ITERATION 11 v3-mobile — the phone's retrieval bar, ported from the approved
   mockup at .../ITERATION 11 - library page redesign/v3-mobile/mockup-mobile-v10.html
   (every measurement and the reasoning behind it: its index.md in the same folder).

   The live toolbar stacks a search field, a four-tab status strip that bursts 39px
   off-screen at 320px, a sort menu and a density switch: 184-242px of chrome before
   a single book. This replaces all four with ONE row of three pills that each open a
   popup, at 2 rows and ~112px.

   EVERYTHING here is inside `@media (max-width: 760px)` or hidden above it, so the
   desktop library approved on 2026-07-24 renders byte-identically. The switch is
   mutual: below 760px the four desktop controls hide and the pills show.
   ============================================================================ */

/* Above the breakpoint the phone's pill row and its popup do not exist. Kept out of the media
   query so the default is "off" and a new width can never accidentally inherit a half-built
   mobile bar. */
.lib-page .fbline { display: none; }

/* ---- the pill row COMPONENT, hoisted out of the phone block (v4-desktop) --------------------
   These rules were inside @media (max-width: 760px), which is why the band from 761px to 1023px
   could not use them: it got the desktop toolbar and an off-canvas drawer instead, the dead zone
   the audit measured at 800px. The component is unconditional now and paints nothing above the
   switch, because .fbline is display:none there. Only the SWITCH is a media query. */
.lib-page .fbwrap { position: relative; flex: 1; min-width: 0; }

/* ONE row, never two, never a scroller: nothing can be clipped by the card edge and
   nothing can be parked off-screen. The row is JUSTIFIED — it starts on the card's
   left content edge and ends on its right one — and the slack goes into the PILLS,
   not the gaps. space-between reaches the same two borders but leaves 23px holes,
   which reads as three unrelated controls instead of one; grown pills also give the
   thumb more to hit. (Andrew, v10: "justify aligned, so they spread horizontally
   between the 2 borders of the container.") */
.lib-page .fbrow { display: flex; flex-wrap: nowrap; gap: 6px; }
.lib-page .fpill { position: relative; flex: 1 0 auto; min-width: 0; display: inline-flex;
  align-items: center; justify-content: center; gap: 5px; min-height: 46px; padding: 0 8px;
  border-radius: var(--radius-pill); background: var(--bg); border: 1px solid var(--border);
  font-size: var(--t-md); font-weight: 700; color: var(--text); white-space: nowrap; }
/* At 320px with "In progress" + "Recently added" the longest real values overrun the
   274px track, so grow is granted to all three but shrink ONLY to a pill that carries
   a value, and inside it only to the value: .lab and .caret are flex:none, so the
   ellipsis can eat "Popularity" but can never eat "Sort". Categories has no value, so
   it never shrinks and its label can never spill its own box. */
.lib-page .fpill:has(.cur) { flex: 1 1 auto; }
.lib-page .fpill .lab { flex: none; }
/* `white-space: nowrap` is what makes the ellipsis beside it work. Without it the value is still
   allowed to wrap, so a squeezed pill rendered a second line the pill's height then hid, and the
   Status pill at 390px read "Status A" — the visible half of "All", no ellipsis, no signal that
   anything had been cut. Found by looking at the signed-in phone row on 2026-07-26; an anonymous
   visitor never sees it, because the Status pill is authed-only. */
/* v14. The SAME defect Andrew reported on the desktop Sort button, on the phone's version of that
   control, INVERTED. `#lib-sortbtn` is `display: none` below 760px and this pill replaces it, so
   the desktop fix reaches nothing here; measured at 390 while checking the change, the label
   "Sort" was white and the value "Popularity" was rgb(153,153,163). Desktop had a grey label and a
   white value, phone a white label and a grey value, and one control cannot be two ways round.
   Both halves are white on both widths now, told apart by weight exactly as before: this stays 500
   against `.fpill`'s 700, which is a wider gap than the desktop pair's 500 against 600.
   It moves the Status pill too (`.fpill .cur` is emitted by both, library-grid.js:1115 and :1118),
   for the same reason the desktop change moves `#lib-statusbtn`.
   Andrew screenshotted the desktop, so this half was not reported. It is here because a defect is
   reported once and fixed everywhere, and because a phone that disagrees with the desktop about
   which half of a control is dim is the drift that produces the next screenshot. */
.lib-page .fpill .cur { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 500; }
.lib-page .fpill .cnt { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 4px;
  border-radius: var(--radius-pill); background: var(--accent); color: var(--accent-ink);
  font-size: var(--t-xs); font-weight: 900; font-variant-numeric: tabular-nums; flex: none; }
/* the control itself carries the selection, first of three places it is stated */
.lib-page .fpill.on { background: rgba(255,94,91,.14); border-color: rgba(255,94,91,.55); }
.lib-page .fpill .caret { width: 7px; height: 7px; border-right: 2px solid var(--muted);
  border-bottom: 2px solid var(--muted); transform: rotate(45deg) translate(-1px,-2px); flex: none; display: block; }

.lib-page .lsheetwrap { display: none; }

/* ---- Filtered by, at desktop scale (v4-desktop) ---------------------------------------------
   WRAPPED IN min-width, and the reason is a mistake worth keeping written down. The comment that
   used to sit here said the phone's chip rules "still win there by source order". They do not.
   `.lib-page .applied .fchip` is three classes and the phone block's `.lib-page .fchip` is two,
   so specificity decided it and the media query never got a say: measured at 390x844, the chip
   rendered at the desktop's 32px against the approved 36px, and `.ap-lab` at 11.5px/56% white
   against the approved 10.5px/#ffb0ae. Source order only breaks a tie between EQUAL specificity.
   A min-width bound removes the race instead of trying to win it, which is also why the fix is
   one line here rather than seventeen re-prefixed selectors in the phone block.

   The row itself: it used to be phone-only, which is half of why the desktop could not show a
   multi-select, since there was nowhere to show it. It is not redundant with the panel, which is
   264px and scrolls; four topics spanning three categories are never all visible in it.

   Desktop drops `.ap-res`, which the phone keeps: at this width it sits 18px above a results
   header that already carries the same count. The explainer line it used to drop alongside is
   gone from both views, so there is nothing left to hide here. */
@media (min-width: 761px) {
  .lib-page .applied { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-4); }
  .lib-page .applied[hidden] { display: none; }
  .lib-page .applied .ap-top { flex: none; }
  .lib-page .applied .ap-lab { display: inline-flex; align-items: center; gap: 7px; font-size: var(--kicker-size); font-weight: 800;
    letter-spacing: var(--kicker-track); text-transform: uppercase; color: var(--faint); }
  .lib-page .applied .bolt { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex: none; }
  .lib-page .applied .fchips { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; flex: 1 1 auto; min-width: 0; }
  /* The count and Clear all anchor the right edge together, so Clear all is a stable target
     whatever the chips do rather than a button that walks along the row as filters are added. */
  .lib-page .applied .ap-tail { flex: none; display: flex; align-items: center; gap: 12px; margin-left: auto; }
  .lib-page .applied .ap-res { color: var(--muted); font-size: var(--t-md); font-variant-numeric: tabular-nums; white-space: nowrap; }
  .lib-page .applied .ap-res b { color: var(--text); font-weight: 800; font-size: var(--t-lg); letter-spacing: -.015em; }

  /* The chip itself, at pointer scale. The phone's version (inside the <=760px block further down,
     36px tall with a 48px invisible target around a 26px mark) is untouched and still wins there by
     source order. A pointer does not need a 48px target, so the desktop chip is 32px and the mark
     22px, which is what keeps a row of six chips from reading as a second toolbar. */
  .lib-page .applied .fchip { display: inline-flex; align-items: center; min-height: 32px; padding: 0 3px 0 11px; position: relative;
    border-radius: var(--radius-pill); background: var(--surface-elev); border: 1px solid var(--border);
    color: var(--text); font-size: var(--t-md); font-weight: 700; max-width: 100%; }
  .lib-page .applied .fchip.whole { background: rgba(255,94,91,.15); border-color: rgba(255,94,91,.5); }
  .lib-page .applied .fchip .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
  .lib-page .applied .fchip .n { color: var(--faint); font-weight: 700; font-size: var(--t-xs); margin-left: 6px; font-variant-numeric: tabular-nums; }
  .lib-page .applied .fchip.whole .n { color: #ffb0ae; }
  /* padding:0 is load-bearing, not tidiness. Under `* { box-sizing: border-box }` a border box can
     never be narrower than its own padding, and the global button rule's 1.1rem a side is 35.2px,
     so overriding min-width alone leaves a 22px circle stuck at 35px. Same trap as the phone chip. */
  .lib-page .applied .fchip .x { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px;
    min-width: 0; min-height: 0; padding: 0; margin-left: 5px; border: 0; border-radius: 50%;
    background: rgba(255,255,255,.07); color: var(--muted); font-size: 13px; line-height: 1; flex: none; }
  .lib-page .applied .fchip .x:hover { background: var(--accent); color: var(--accent-ink); }
  /* The query and the status are filters too, so they are chips. They carry their KIND as a small
     uppercase lead, because "Finished" and "Grief" are otherwise indistinguishable in a row of
     pills and one of them is not a category. */
  .lib-page .applied .fchip.fmeta { background: var(--bg); border-color: var(--border); }
  .lib-page .applied .fchip .k { font-size: 9.5px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase;
    color: var(--faint); margin-right: 7px; flex: none; }
  .lib-page .applied .clearall { display: inline-flex; align-items: center; min-height: 32px; min-width: 0; padding: 0 12px;
    border-radius: var(--radius-pill); border: 1px solid rgba(255,94,91,.42); background: rgba(255,94,91,.12);
    color: #ffb0ae; font-size: var(--t-md); font-weight: 800; }
  .lib-page .applied .clearall:hover { background: rgba(255,94,91,.22); color: var(--text); }
}
/* Defect 19, one line and true at every width: the "No books match" message was being
   laid into ONE grid column, 117px wide at phone size. A message you cannot read is
   not a message. */
.lib-page .grid > .empty { grid-column: 1 / -1; }

@media (max-width: 760px) {
  /* The app stacks TWO gutters on a phone: #app-root's 20px and .layout's own 16px, 36px a side,
     against the mockup's 16px. That left the pill row a 296px track for pills that need 300px,
     so both values were ellipsised at 390px where the design has 28px to spare — measured, not
     guessed, and visible in the render before it was visible in any number. Dropping the inner
     one leaves ONE gutter, and the rule at the end of this file takes that one from 20px to the
     mockup's 16px, which is the "saving screen real estate" this iteration is for. */
  .lib-page .layout { padding-left: 0; padding-right: 0; }
  /* The filter panel is not merely off-screen at phone width, it is GONE. Its opener (.catbtn) is
     hidden below 760px, so no gesture can reach it, but it stayed in the layout with 84 focusable
     controls that Shift+Tab from the top of the page landed in: a keyboard user was dropped into
     a category tree nothing on screen said existed. The phone's popup is the category UI here. */
  .lib-page .side, .lib-page .backdrop { display: none; }

  /* The global `.phead` rule at 640px turns the masthead into a COLUMN, and this block's own
     `align-items: flex-end` then means right-aligned rather than bottom-aligned, so the title
     sat hard against the right edge with the stats line left under it. Identical cause, and
     identical fix, to the one already written down for .admin-page .phead. Restate both
     together, because overriding one without the other is what caused it.
     (The alignment itself is set with the rest of the mockup's phone scale further down —
     centred, not flex-start. Left here as one declaration so the column is stated once.) */
  .lib-page .phead { flex-direction: column; }

  /* the four desktop controls off, the pill row on */
  .lib-page .catbtn,
  .lib-page .seg,
  .lib-page .menu-wrap,
  .lib-page .viewwrap { display: none; }
  .lib-page .applied { display: block; }
  .lib-page .applied[hidden] { display: none; }
  .lib-page .lsheetwrap { display: block; }

  /* The toolbar stops being a row of controls and becomes a card holding two: the
     search field, then the pill row. */
  .lib-page .toolbar { display: block; padding: 10px; gap: 0; margin-bottom: 12px;
    border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); }
  .lib-page .search { flex: none; min-width: 0; }
  .lib-page .search input { min-height: 46px; }
  .lib-page .fbline { display: flex; align-items: center; margin-top: 10px; min-width: 0; }

  /* ---- applied filters: the state, on the page ----
     Values not counts, above the results, each removable in one tap, plus a global
     clear. This is the half of the design the popup cannot do, because a popup that is
     shut shows nothing. */
  .lib-page .applied { margin: 0 0 16px; padding: 12px 13px; border-radius: var(--radius);
    background: linear-gradient(180deg, rgba(255,94,91,.10), rgba(255,94,91,.035));
    border: 1px solid rgba(255,94,91,.26); }
  /* NO rule under the label (Andrew, 2026-07-26: "you re-introduced them again. REMOVE PLEASE").
     The spacing is untouched, only the line is gone: 10px of padding and 11px of margin still hold
     the label off the chips, so nothing below it moves. This hairline is not actually new, it has
     been here since the phone bar was first ported; what changed is that v6 took every other
     hairline off the screen, which left this one the only line in the design. */
  .lib-page .ap-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 11px;
    padding-bottom: 10px; }
  .lib-page .ap-lab { display: flex; align-items: center; gap: 7px; font-size: 10.5px; font-weight: 800;
    letter-spacing: .15em; text-transform: uppercase; color: #ffb0ae; white-space: nowrap; }
  .lib-page .ap-lab .bolt { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex: none; }
  .lib-page .ap-res { margin-left: auto; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
  .lib-page .ap-res b { color: var(--text); font-weight: 800; font-size: 16px; letter-spacing: -.015em; }
  .lib-page .fchips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
  .lib-page .fchip { display: inline-flex; align-items: center; min-height: 36px; padding: 0 4px 0 12px;
    border-radius: var(--radius-pill); background: var(--surface-elev); border: 1px solid #4a4a54;
    font-size: 13px; font-weight: 700; color: var(--text); max-width: 100%; position: relative; }
  /* a whole-category chip is the coral one: it is worth more than a topic chip */
  .lib-page .fchip.whole { background: rgba(255,94,91,.17); border-color: rgba(255,94,91,.55); }
  .lib-page .fchip .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }
  .lib-page .fchip .n { color: var(--faint); font-weight: 700; font-size: 11px; margin-left: 6px; font-variant-numeric: tabular-nums; }
  .lib-page .fchip.whole .n { color: #ffb0ae; }
  /* min-width/min-height are overridden, not inherited. The global `button` rule (line ~150)
     forces 44x44 on every button in the app, which inflated this × from 26px to 44px and the
     chip around it from 36px to 46px — the whole reason the chip row read as bulky beside the
     mockup while every other measured property already matched. Same global-bleed class of bug
     as the density toggle's 999px radius, two hundred lines up.
     The 44px TARGET is not given up: `.x::after` below expands the hit area to 48px and
     `.clearall::after` to 50px, which is how the mockup does it too — a small mark with a big
     invisible target, rather than a big mark. */
  /* padding: 0 belongs with them. Under box-sizing:border-box a border box can never be
     narrower than its own padding, so the global button's 1.1rem sides pinned this to
     35.19px however small `width` was set — a width that silently does nothing is worse
     than no width at all. */
  .lib-page .fchip .x { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px;
    min-width: 0; min-height: 0; padding: 0;
    margin-left: 5px; border-radius: 50%; background: rgba(255,255,255,.08); color: var(--muted);
    font-size: 14px; line-height: 1; flex: none; position: relative; border: 0; }
  .lib-page .fchip .x::after { content: ""; position: absolute; inset: -11px; }   /* 48px target */
  .lib-page .fchip .x:hover { background: var(--accent); color: var(--accent-ink); }
  /* height, not min-height: as a wrapped flex item the button resolves to its 24px line
     box and the min never bites. Measured in the mockup, not assumed. */
  .lib-page .clearall { display: inline-flex; align-items: center; gap: 6px; height: 38px; min-height: 0; padding: 0 14px; position: relative;
    border-radius: var(--radius-pill); background: rgba(255,94,91,.14); border: 1px solid rgba(255,94,91,.34);
    font-size: 12.5px; font-weight: 800; color: #ffb0ae; white-space: nowrap; }
  .lib-page .clearall:hover { background: rgba(255,94,91,.24); color: var(--text); }
  .lib-page .clearall::after { content: ""; position: absolute; left: 0; right: 0; top: -6px; bottom: -6px; }
  /* v4-desktop moved the matched count and Clear all out of the label row and the chip row into
     their own tail, so the phone gets them as one line under the chips. Clear all was previously
     the last item INSIDE the wrapping chip row, which put it in a different place on every
     selection; a fixed line is a target the thumb can learn. */
  .lib-page .ap-tail { display: flex; align-items: center; gap: 12px; margin-top: 11px; }
  .lib-page .ap-res { margin-left: 0; margin-right: auto; }
  .lib-page .fchip .k { font-size: 9px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase;
    color: var(--faint); margin-right: 6px; flex: none; }
  /* Tone only. Every box property comes from `.fchip` above, which is the point of the rename:
     as `.fchip.meta` this chip also matched `.lib-page .meta`, the book card's caption block, and
     inherited a card's `padding: 9px 1px 0` because that rule is the same specificity and lands
     later in the file. That is what made the Status chip sit low and lose its right-hand gap. */
  .lib-page .fchip.fmeta { background: var(--bg); }

  /* ==========================================================================
     The rest of the mockup's phone scale, ported. Everything below was found by
     design-loop.mjs, which measures the mockup and the app side by side in one
     Chrome and diffs every computed property. The first port only carried the
     toolbar and the popup, because those were the parts I thought to check; the
     shelves, the cards and the masthead kept the DESKTOP scale and that is what
     read as "doesn't hit the quality bar" — a 15px eyebrow and a 20px rule at
     390px are a desktop heading squeezed onto a phone.
     Re-run to confirm: node ".../v3-mobile/design-loop.mjs" -> 0 violations.
     ========================================================================== */

  /* The mockup's faint tone is 56% against the app's 42%. Scoped to this page at
     this width rather than changed globally: every faint value in the mockup is
     the 56% one, so porting the token is the honest port, but the token is shared
     with every other screen and those were not designed here. */
  .lib-page { --faint: rgba(242, 242, 245, .56); }

  /* CENTRED, which is what the mockup has always shown. The previous fix made it
     flex-start, which cured the right-edge collision but landed on the wrong side
     of the design: the title IS the wordmark on this page, and a wordmark centres. */
  .lib-page .phead { align-items: center; text-align: center; gap: 9px;
    padding-bottom: 16px; margin-bottom: 16px; }
  .lib-page .phead h1 { font-size: 40px; }
  .lib-page .psum { justify-content: center; gap: 9px; }
  /* v5, found by measuring the wrap rather than by looking at 320 and 390 and stopping.
     The four stats are separated by middots that are their own flex children, so a wrap can leave
     one dangling at either end of a line. The existing guard hides the longest stat at <=360px
     and the comment on it calls a dangling middot "a typo, not a design" — but it was only ever
     checked at 320 and 360, and the band above it is worse, not better:
       390px  ->  line 2 begins  "· 66 topics"        (stranded at the START of a line)
       414px  ->  line 1 ends    "7 categories ·"     (stranded at the END, the exact case named)
     390 is the commonest phone width there is. Rather than move the threshold and re-strand it
     somewhere else, the separators come off entirely below 761px: they are decoration, the gap
     already separates the stats, and a thing that cannot render cannot render stranded. */
  .lib-page .psum .sep { display: none; }
  .lib-page .psum { gap: 6px 14px; }

  .lib-page .search input { padding-left: 39px; }
  .lib-page .search svg { left: 13px; width: 17px; height: 17px; }

  /* shelves: the whole cluster steps down together. Sizing one and not the others
     is what unbalanced it — the eyebrow, its rule, the count and the row gaps are
     one object. */
  .lib-page .shelf { margin-bottom: 26px; }
  .lib-page .shelf-head { gap: 10px; margin-bottom: 12px; }
  /* v5. "On BOTH views" — 12.5px was also the last off-scale size in this block, so growing it
     puts it on the scale in the same edit. .seeall is untouched here as well; it has no phone
     override, so it is already the --t-lg Andrew asked to leave alone. */
  .lib-page .eyebrow { font-size: var(--t-lg); letter-spacing: .12em; gap: 8px; }
  .lib-page .eyebrow::before { width: 16px; }
  .lib-page .shelf-head .count { font-size: 11.5px; font-weight: 600; }
  /* 168px covers with a 26px gutter is the desktop rail. At 390px that is one and a
     half covers visible and no sense that the row continues; 118px/13px shows three
     and a bit, which is what tells the thumb to swipe. */
  /* v5: 2px top -> 20px, for the same reason as the desktop rail — `overflow-y: hidden` clips at
     the padding box and the status pill hangs above the card; 2px guillotined all 44 of them and
     the gate said so. The inline padding carries the pill's 7px right overhang, same as above, and
     is given back to the page by the base rule's negative margin so the first cover keeps its
     alignment. Latent here rather than visible, because the phone's rail always overflows and so
     always scrolls; it is fixed on the same pass as the width where it shows. */
  .lib-page .rail { grid-auto-columns: 118px; gap: 13px; padding: 20px var(--sp-2) 6px 0; }

  /* The mockup's phone ladder, 13/11.5/10.5 with the duration at 700, is now the SCALE's small
     step rather than four literals: --t-md / --t-sm / --t-xs resolve to exactly those numbers,
     so this block states the phone's contract without restating its values. The desktop step is
     inside @media (min-width: 761px), so nothing here has to fight it — a phone grid card is
     172px wide and would otherwise have taken the large step on cover width alone, which the
     mockup deliberately does not do: the whole page scale steps down on a phone, not just the
     cards that happen to be narrow. 9px of cover-to-title gap is the one measured value that
     stays literal, because it was matched against the mockup by design-loop.mjs. */
  .lib-page .meta { padding: 9px 1px 0; }
  .lib-page .meta .t { font-size: var(--t-md); line-height: 1.24; }
  .lib-page .meta .a { font-size: var(--t-sm); }
  .lib-page .meta .d { font-size: var(--t-xs); font-weight: 700; margin-top: var(--sp-1); }

  .lib-page .rsum { align-items: baseline; gap: 10px; margin-bottom: 14px; }
  .lib-page .rsum .n { font-size: 17px; letter-spacing: -.015em; }
}

/* The page gutter. #app-root pays 1.25rem (20px) on every screen below 640px and the
   mockup's phone page pays 16px, so the library sat 4px narrower on each side than the
   design it was ported from. Scoped with :has() to pages that actually carry .lib-page,
   because #app-root's gutter belongs to every other screen too and none of those were
   designed here. .layout's own 16px is already zeroed above, so this is the single
   remaining gutter and it now reads 16px, exactly as measured in the mockup. */
@media (max-width: 760px) {
  #app-root:has(.lib-page) { padding-left: 16px; padding-right: 16px; }
}
/* The narrowest phone gets its own step, exactly as the mockup's 320px instance does:
   12px of gutter instead of 16px and a 35px title instead of 40px. 360px is the breakpoint
   because the mockup only ever states 320 and 390, and 360 is the conventional line between
   a small phone and a standard one — nothing between 321 and 389 is specified by the design. */
@media (max-width: 360px) {
  #app-root:has(.lib-page) { padding-left: 12px; padding-right: 12px; }
  .lib-page .phead h1 { font-size: 35px; }
  /* v5: the shelf head loses its book count on the smallest phone, for the same reason and by the
     same precedent as the stat below it. Measured at 320px before this: label 28..149, count
     159..201, See all 211..292 against a container ending at 292 — flush, with ZERO slack. The
     larger label Andrew asked for spends 40px the row did not have, so See all dropped to a
     second line and every shelf head grew 44px to 72px.
     The count is the cheapest thing in the row to lose: the same number is already on the filter
     panel row, in the popup's category tree, and in the results header the moment you press See
     all. The label and the button are not replaceable by anything else on screen.
     After: label 28..168, See all 176..274, container 292 — one line, 18px to spare. */
  .lib-page .shelf-head .count { display: none; }
  /* two stats, not three — see the comment on .stat-shelved in library-grid.js */
  .lib-page .psum .stat-shelved, .lib-page .psum .sep-shelved { display: none; }
}

/* ---- the popup ----
   Outside the 760px query on purpose: the sheet is only ever MOUNTED by the mobile
   markup (its wrapper is display:none above the breakpoint), and keeping its rules
   unconditional means a phone rotated to 780px mid-selection does not lose the styling
   of a layer that is still on screen. */
.lib-page .lsheetwrap { position: fixed; inset: 0; z-index: 90; overflow: hidden; pointer-events: none; }
/* The scrim. Without it the Status and Sort popups, which dock to the bottom and leave 572px of
   the viewport live, were transparent to taps: `elementFromPoint` above an open sheet returned
   page content and a real click went straight through to a shelf. A layer that declares
   `aria-modal="true"` and then lets you operate the page behind it is lying to everyone.
   Tied to [aria-hidden="false"] rather than to a class so it can never be left painted over a
   closed sheet. */
.lib-page .lsheetwrap::before { content: ""; position: absolute; inset: 0; background: rgba(0,0,0,.5);
  opacity: 0; pointer-events: none; transition: opacity .24s ease; }
.lib-page .lsheetwrap[aria-hidden="false"] { pointer-events: auto; }
.lib-page .lsheetwrap[aria-hidden="false"]::before { opacity: 1; pointer-events: auto; }
.lib-page .lsheet { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg);
  pointer-events: auto; transform: translateY(101%); transition: transform .28s cubic-bezier(.3,.9,.3,1); visibility: hidden; }
.lib-page .lsheet.open { transform: translateY(0); visibility: visible; }
/* the short popups: Status and Sort are four and five rows, so they dock to the bottom */
.lib-page .lsheet.mini { top: auto; height: auto; border-radius: 22px 22px 0 0; border-top: 1px solid var(--border); background: var(--surface); }
.lib-page .lsheet.mini .lsh-body { border-top: 0; }
/* The page under a full-screen popup must not scroll: on iOS it does, and you come back
   somewhere else entirely. */
body.lib-sheet-open { overflow: hidden; }

.lib-page .lsh-head { flex: none; display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-bottom: 1px solid var(--hair); }
/* `padding: 0` is load-bearing (Andrew, 2026-07-26: "the [x] button inside the mobile view filter
   is misaligned"). The global `button` rule puts 0.7rem/1.1rem on every button in the app, and
   under border-box that left this 44px box an 8.8px-wide CONTENT box holding a 20px glyph. Chrome
   will not centre a grid item that overflows its area on the start side, so it pinned the icon to
   the content edge: the X painted at x 27.6-47.6 inside a button spanning 10-54, its centre 5.6px
   right of the button's own. `place-items: center` was being obeyed and had nothing to centre in.
   Same trap, same file, as the one already recorded on `.lpk .x` below. */
.lib-page .lsh-x { width: 44px; height: 44px; padding: 0; border-radius: var(--radius-helper); display: grid; place-items: center; color: var(--text); flex: none; background: transparent; border: 0; }
.lib-page .lsh-x:hover { background: rgba(255,255,255,.07); }
.lib-page .lsh-t { font-size: 17px; font-weight: 900; letter-spacing: -.022em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-page .lsh-sel { margin-left: auto; flex: none; font-size: 11.5px; font-weight: 800; color: #ffb0ae; background: rgba(255,94,91,.15);
  border: 1px solid rgba(255,94,91,.3); border-radius: var(--radius-pill); padding: 5px 10px; font-variant-numeric: tabular-nums; }
.lib-page .lsh-sel[hidden] { display: none; }

/* the field that makes 69 topics tractable: three keystrokes beat scrolling five
   collapsed groups */
.lib-page .lsh-search { flex: none; position: relative; padding: 10px 12px 0; }
.lib-page .lsh-search svg { position: absolute; left: 25px; top: calc(50% + 5px); transform: translateY(-50%); color: var(--muted); }
.lib-page .lsh-search input { width: 100%; padding: 12px 38px 12px 39px; border-radius: var(--radius-chip); background: var(--surface);
  border: 1px solid var(--border); color: var(--text); font-size: 14.5px; font-weight: 500; min-height: 46px; }
.lib-page .lsh-search input::placeholder { color: var(--faint); }
/* Found by looking at the popup with text in it: Chrome draws its own cancel button on a
   `type="search"` input that has a value, and this field is the ONE place in the app that also
   draws a clear control of its own — so a person typing in the filter popup got two X's four
   pixels apart, the native one bold white and unstyled, the designed one grey and circular.
   Scoped to this input on purpose: `#lib-q`, `#lib-sideq` and the search screen's field have no
   clear control of their own, so for them the native button is the only one there is. */
.lib-page .lsh-search input::-webkit-search-cancel-button { display: none; }
/* Found while fixing the close [x] above, and it is the SAME defect one field lower: the global
   button rule's `min-width: 44px` / `min-height: 44px` beat a bare width/height, so this circle
   declared 30x30 and computed 44x44 — a grey disc half again as wide as designed, sitting inside a
   46px search field. The three releases (both mins and the padding) are what `.lpk .x` already
   carries for exactly this reason; anything less and border-box holds the box open. */
.lib-page .lsh-clr { position: absolute; right: 20px; top: calc(50% + 5px); transform: translateY(-50%); width: 30px; height: 30px;
  min-width: 0; min-height: 0; padding: 0;
  border-radius: 50%; display: grid; place-items: center; color: var(--muted); background: rgba(255,255,255,.08); font-size: 13px; border: 0; }
.lib-page .lsh-clr[hidden] { display: none; }
.lib-page .lsh-clr::after { content: ""; position: absolute; inset: -9px; }

.lib-page .lsh-hint { flex: none; padding: 9px 14px 10px; font-size: 11.5px; line-height: 1.45; color: var(--faint); }
.lib-page .lsh-hint b { color: var(--muted); font-weight: 800; }

/* the selection, restated inside the popup. Once a category is collapsed its ticks are
   invisible, and at 43 children you cannot open it to check without losing your place. */
.lib-page .lsh-picked { padding: 12px 13px; border-bottom: 1px solid var(--hair); background: rgba(255,94,91,.055); }
.lib-page .lsh-pk-lab { display: flex; align-items: center; gap: 7px; font-size: 10.5px; font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; color: #ffb0ae; margin-bottom: 9px; }
.lib-page .lsh-pk-lab span { display: inline-grid; place-items: center; min-width: 19px; height: 19px; padding: 0 5px;
  border-radius: var(--radius-pill); background: var(--accent); color: var(--accent-ink); font-size: 11px; font-weight: 900; letter-spacing: 0; }
.lib-page .lsh-pk-chips { display: flex; flex-wrap: wrap; gap: 7px; }
.lib-page .lpk { display: inline-flex; align-items: center; height: 32px; padding: 0 3px 0 11px; border-radius: var(--radius-pill);
  background: var(--surface-elev); border: 1px solid #4a4a54; font-size: 12.5px; font-weight: 700; max-width: 100%; }
.lib-page .lpk.whole { background: rgba(255,94,91,.17); border-color: rgba(255,94,91,.55); }
.lib-page .lpk .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }
/* The three releases at the front of this rule are load-bearing, not tidying (Andrew, 2026-07-26:
   "the circle is much larger than it should be"). The global `button` rule forces
   min-height/min-width 44px and 0.7rem/1.1rem padding on EVERY button in the app, and it beats a
   bare width/height, so this mark computed to 44x44 inside a 32px pill and bulged 6px out of it
   top and bottom, which is why it read as a second button rather than as a remove affordance.
   `padding: 0` is needed as well as the two mins: under the global `box-sizing: border-box` the
   side padding alone would still hold the box at ~35px however small `width` is set. The two
   sibling chip families on this same screen (.fchip, both views) already carry all three for
   exactly this reason. The visible circle is 24px, which is what it was always written to be. */
.lib-page .lpk .x { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px;
  min-width: 0; min-height: 0; padding: 0;
  margin-left: 4px; border-radius: 50%; background: rgba(255,255,255,.08); color: var(--muted); font-size: 13px; line-height: 1; flex: none; border: 0; }
/* The hit area stays 48px and is now bigger than the paint, which is the right way round: the
   finger target must not have to be drawn to be honoured. 24 + 12 + 12 = 48. */
.lib-page .lpk .x::after { content: ""; position: absolute; inset: -12px; }   /* 48px target */
.lib-page .lpk .x:hover { background: var(--accent); color: var(--accent-ink); }

.lib-page .lsh-body { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none; border-top: 1px solid var(--hair); -webkit-overflow-scrolling: touch; }
.lib-page .lsh-body::-webkit-scrollbar { display: none; }

/* category row: TWO targets, deliberately separated by a hairline. Left 56px ticks the
   whole category, right opens it. One row doing two jobs with no visible seam is the
   ambiguity that makes people tap twice. That VERTICAL seam is .lgck's border-right below and
   it stays: it separates two controls inside one row, which is not what was removed here.
   NO horizontal rule between categories, either view (Andrew, 2026-07-26: "remove the dividers
   between categories"). Row height, the tone step on an open group and the continuous tick
   column are what separate one category from the next now. */
.lib-page .lgrow { display: flex; align-items: stretch; }
.lib-page .lgrow.open { background: rgba(255,255,255,.028); }
.lib-page .lgck { width: 56px; flex: none; display: grid; place-items: center; border-right: 1px solid var(--hair); position: relative; background: transparent; border-top: 0; border-bottom: 0; border-left: 0; border-radius: 0; }
.lib-page .lgck:hover { background: var(--tint-hover); }
.lib-page .lglab { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 13px; text-align: left; min-height: 58px; background: transparent; border: 0; border-radius: 0; color: var(--text); }
.lib-page .lglab:hover:not([disabled]) { background: var(--tint-hover); }
.lib-page .lglab[disabled] { cursor: default; }
.lib-page .lgtxt { flex: 1; min-width: 0; }
/* Category title. Bigger and looser, both asked for on 2026-07-26 ("make the category titles
   larger", "slightly increase the letter spacing, to be more readable").
   The tracking is the larger of the two changes in effect: this was carrying -.012em, display
   TIGHTENING, on a 15px 800-weight UI label, a setting borrowed from headline type, where it
   belongs, and applied where it does not. Going to +.006em is an .018em swing, and most of the
   legibility comes from simply not tightening it any more. .02em is about where Urbanist at this
   size stops reading as a label and starts reading as tracked-out chrome, so this is the top of
   "slight". The desktop panel re-declares both properties at .lib-page .side .lgt (3 classes
   beats 2) and has to move with it, or only the popup would change. */
.lib-page .lgt { display: block; font-size: 16.5px; font-weight: 800; letter-spacing: .006em; line-height: 1.2; }
.lib-page .lgh { display: block; margin-top: 3px; font-size: 11px; font-weight: 600; color: var(--faint); }
.lib-page .lgh .on { color: #ffb0ae; font-weight: 800; }
.lib-page .lgn { font-size: 12.5px; font-weight: 700; color: var(--faint); font-variant-numeric: tabular-nums; flex: none; }
/* v5: a category with no topics renders no chevron, so its count used to slide 18px right of the
   six above it and the number column broke on the last row. The count reserves the chevron's
   track instead — a column of figures that only lines up when every row happens to have the same
   controls is not a column. */
.lib-page .lgrow:not(:has(.lgcar)) .lgn { margin-right: 18px; }
.lib-page .lgcar { width: 8px; height: 8px; flex: none; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: rotate(45deg) translate(-2px,-2px); transition: transform .2s ease; }
.lib-page .lgrow.open .lgcar { transform: rotate(-135deg) translate(-3px,-3px); }

/* checkbox: unticked / mixed (dash) / ticked (fill). Mixed is the whole point of a
   two-tier tree — "some of Psychology" must not look like "none". */
.lib-page .lck { width: 23px; height: 23px; border-radius: var(--radius-chip); border: 2px solid #55555f; display: grid; place-items: center;
  color: transparent; flex: none; transition: background .12s ease, border-color .12s ease; }
.lib-page .lck svg { width: 14px; height: 14px; }
.lib-page .lck.on { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.lib-page .lck.mix { border-color: var(--accent); color: var(--accent); }

.lib-page .lgsubs { background: rgba(0,0,0,.24); }
.lib-page .lgsubs[hidden] { display: none; }
/* v6. The divergence recorded here in v5 is GONE. v5 removed the category hairlines from the
   phone popup only and argued the 264px desktop column still needed them to stop the list
   dissolving into its container. Andrew looked at both views on 2026-07-26 and asked for the
   dividers removed, full stop, so one behaviour now, both views, and the deeper tone step that
   was minted for the popup applies to the panel too: it is what says where an expanded
   category's topics end and the next category begins, and that job exists in both containers.
   A tone step, not a line. The line is the thing that was removed. */
.lib-page .lgsubs { background: rgba(0,0,0,.42); }
/* Topic rows lose theirs as well. v5 kept them on the argument that they are a level down and
   not "between categories"; against seven categories they were the only hairlines left on the
   screen, which made the exception look like an oversight rather than a rule. The 50px row, the
   sunken group tone and the coral wash on a ticked row carry it. */
.lib-page .lsrow { display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; padding: 9px 14px 9px 20px; min-height: 50px;
  border: 0; border-radius: 0; background: transparent; color: var(--text); }
.lib-page .lsrow:hover { background: var(--tint-hover); }
.lib-page .lsrow[aria-checked="true"] { background: rgba(255,94,91,.085); }
/* The topic label is the text actually READ, sixty-nine rows of it, and it is the worst case in
   this UI: small, weight 600, and --muted rather than --text. It gets the larger of the two
   tracking bumps for that reason. One declaration covers both views, since .lib-page .side .lst
   re-declares size and line-height only, so tracking inherits from here. */
.lib-page .lst { flex: 1; min-width: 0; font-size: 13.5px; font-weight: 600; line-height: 1.26; letter-spacing: .014em; color: var(--muted);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.lib-page .lsrow[aria-checked="true"] .lst { color: var(--text); font-weight: 700; }
.lib-page .lsp { display: block; margin-top: 2px; font-size: 10.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--faint); }
.lib-page .lsn { font-size: 11.5px; font-weight: 700; color: var(--faint); font-variant-numeric: tabular-nums; flex: none; }
.lib-page .lsrow[aria-checked="true"] .lsn { color: #ffb0ae; }

.lib-page .lsh-none { padding: 34px 20px; text-align: center; color: var(--muted); font-size: 13.5px; line-height: 1.6; }
.lib-page .lsh-none b { color: var(--text); display: block; font-size: 15px; margin-bottom: 6px; }
.lib-page .lsh-res { padding: 11px 14px 4px; font-size: var(--kicker-size); font-weight: 800; letter-spacing: var(--kicker-track); text-transform: uppercase; color: var(--faint); }

/* Nothing commits until Apply, so the footer is pinned and the count in it is live.
   The bottom padding clears the home indicator on an installed iPhone. */
.lib-page .lsh-foot { flex: none; display: flex; align-items: center; gap: 10px; border-top: 1px solid var(--border); background: var(--surface);
  padding: 12px 13px calc(16px + env(safe-area-inset-bottom)); }
.lib-page .lsh-clear { min-height: 48px; padding: 0 16px; border-radius: var(--radius-pill); border: 1px solid var(--border);
  font-weight: 800; font-size: 13px; color: var(--muted); flex: none; background: var(--bg); }
.lib-page .lsh-clear:hover:not([disabled]) { color: var(--text); border-color: #565660; }
.lib-page .lsh-clear[disabled] { opacity: .38; cursor: default; }
/* Andrew, 2026-07-26: "the 'Show [number] books' button is off-brand. That's not my button."
   It was not. It had forked the app's filled primary on five properties at once: weight 900
   where every primary is 800, 14.5px which is off the type scale entirely, a negative -.01em
   tracking no primary uses, dark --accent-ink on the coral where all 18 live filled primaries
   are white, and a coral halo at alpha .9 with a 10px drop against the canonical .35 at 2px,
   the loudest coral shadow in the file. So it now WEARS the shared component rather than
   imitating it: the markup carries `av-btn av-btn--filled` (library-grid.js), and what is left
   here is only what this one placement genuinely needs the component not to decide.
   Fill, label colour, weight, size, radius, shadow, transition and :active now come from
   .av-btn / .av-btn--filled and cannot drift from the rest of the app again.
   NOTE the contrast trade, deliberately taken and worth Andrew knowing: white on #ff5e5b is
   ~2.68:1 and fails WCAG AA, while the --accent-ink it replaces was ~6.14:1. Matching the brand
   button is what was asked for, and the site-wide coral/ink decision is his and still unmade
   (see the --accent-ink comment at the top of this file). One word reverses this button alone. */
.lib-page .lsh-apply { flex: 1; min-height: 48px; padding: 0 14px;
  /* tabular figures: the count is live and changes on every tick, and proportional digits make
     it jitter under the finger that is changing it. The shared component has no reason to know. */
  font-variant-numeric: tabular-nums; }

/* Status and Sort rows: single choice, so the tap IS the decision and there is no Apply. */
.lib-page .lorow { display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; padding: 12px 16px; min-height: 52px;
  border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; background: transparent; color: var(--text); }
.lib-page .lorow:last-child { border-bottom: 0; }
.lib-page .lorow:hover { background: var(--tint-hover); }
.lib-page .lot { flex: 1; font-size: 14.5px; font-weight: 700; }
.lib-page .lorow[aria-selected="true"] .lot { color: var(--accent); }
/* opacity, NOT visibility, and that is the whole of the falling-check-mark bug.
   `.lsheet` hides itself on close with `visibility: hidden` and slides out over 280ms. Visibility
   is inherited, so every child of the closing popup stops painting with it, EXCEPT one that
   re-declares itself visible. This rule used to do exactly that, so the selected row's tick was
   the only thing in the popup still painted while it left, and a lone coral check mark rode the
   slide-out down the page with nothing around it. Andrew, twice: "the check mark icon appears to
   be falling down from the top of the page", "STILL DOING THE WEIRD THING".
   Opacity cannot re-reveal a child of a hidden ancestor, so the tick now leaves with its popup.
   Layout and appearance are unchanged: opacity does not affect layout, and the tick keeps its
   18px box either way, which is what holds the label column the same width on every row. Nothing
   is lost for a screen reader either: the SVG is already aria-hidden and the state is carried by
   the row's aria-selected. */
.lib-page .lon-tick { width: 18px; height: 18px; color: var(--accent); flex: none; opacity: 0; }
.lib-page .lorow[aria-selected="true"] .lon-tick { opacity: 1; }

/* ============================================================================
   Iteration 6 — player-behavior-redesign v2 (styles owned by the orchestrator;
   the JS/markup for these class names is produced by the per-ticket agents).
   Each block below pairs with one ticket in
   DEV/ITERATION 6 - design/player-behavior-redesign-v2/TODO.md.
   ============================================================================ */

/* ---- Ticket A: whole-book progress below the cover, plain + two timers ----
   A read-only bar (no drag thumb) that sits in .np-media directly below the cover,
   sized to match the cover column so the two line up. Same visual language as the
   chapter bar (8px rounded track, coral fill). */
.np-cover-progress {
  display: grid; grid-template-columns: 1fr 1fr;
  grid-template-areas: "bar bar" "elapsed remaining";
  row-gap: 0.5rem;
  width: min(78vw, 320px); margin: 0.6rem auto 0;
}
.np-cover-progress .time {
  font-variant-numeric: tabular-nums; font-size: 0.76rem; color: var(--muted);
  flex-shrink: 0;
}
.np-cover-progress .time:first-child { grid-area: elapsed; text-align: left; }
.np-cover-progress .time:last-child { grid-area: remaining; text-align: right; }
.npc-bar { grid-area: bar; height: 8px; border-radius: 999px; background: var(--surface-elev); overflow: hidden; }
.npc-bar > span { display: block; height: 100%; width: 0%; border-radius: 999px; background: var(--accent); transition: width 0.15s linear; }

/* The cover wrap hugs the image (was a full-width flex-centering box) so the
   corner bookmark hint (Ticket B) and the bar above align to the image edges.
   Same rendered size/centering as before — the width rules move from the img to
   the wrap and the img simply fills it. */
.np-cover-wrap { display: block; width: min(78vw, 320px); margin: 0.5rem auto 1.2rem; }
.np-cover-wrap img { width: 100%; }

@media (min-width: 960px) {
  .np-cover-progress { width: 100%; max-width: 420px; margin: 0.8rem 0 0; }
  .np-cover-wrap { width: 100%; max-width: 420px; margin: 1rem 0; }
}

/* ---- Ticket A: chapter progress is now the DRAGGABLE control, with a big thumb ----
   Fully custom (appearance:none) so the thumb can actually be enlarged (native
   ::thumb sizing is ignored unless appearance is reset). Track matches .chapter-bar. */
.ch-range {
  -webkit-appearance: none; appearance: none;
  flex: 1; height: 30px; background: transparent; cursor: pointer; margin: 0;
  border: none; padding: 0;
}
.ch-range::-webkit-slider-runnable-track { height: 8px; border-radius: 999px; background: var(--surface-elev); }
.ch-range::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 26px; height: 26px; margin-top: -9px; border-radius: 50%;
  background: var(--accent); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45); cursor: grab;
}
.ch-range:active::-webkit-slider-thumb { cursor: grabbing; }
.ch-range::-moz-range-track { height: 8px; border-radius: 999px; background: var(--surface-elev); }
.ch-range::-moz-range-progress { height: 8px; border-radius: 999px; background: var(--accent); }
.ch-range::-moz-range-thumb {
  width: 26px; height: 26px; border: none; border-radius: 50%;
  background: var(--accent); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45); cursor: grab;
}
.ch-range:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 999px; }

/* ---- speed stepper centred, instant-bookmark button pinned to the right ----
   A 1fr/auto/1fr grid keeps the stepper mathematically centred on the row regardless of
   the bookmark button's width (equal outer columns), with the button right-aligned in the
   third column rather than fighting it via margin-left:auto in a flex row. Same red-border
   grey-fill button as the speed-stepper buttons; behaviour = same as tapping the cover
   (dispatches the shared 'cover-tap' event). */
.speed-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; margin-bottom: 1rem; }
.speed-row .speed-stepper { grid-column: 2; justify-self: center; margin-bottom: 0; }
.np-quick-bookmark { grid-column: 3; justify-self: end; }
/* #np-quick-bm (ID selector), not .np-quick-bookmark (class-only): the element also carries
   the base .icon-btn class, and `button.icon-btn` (element+class) OUTRANKS a bare class
   selector in specificity — every earlier size bump here silently lost the padding/svg-size
   fight to button.icon-btn's own padding:0.5rem / svg 28px and only the box's width/height
   (which icon-btn doesn't set) ever actually changed. ID selector always wins, no exceptions. */
#np-quick-bm {
  width: 72px; height: 72px; min-width: 72px; min-height: 72px; padding: 0;
  background: rgba(30, 30, 35, 0.42);
  border: none;
  border-radius: var(--radius-helper);
  color: var(--accent);
}
#np-quick-bm:hover { background: rgba(30, 30, 35, 0.6); color: var(--accent-hover); }
#np-quick-bm svg { width: 44px; height: 44px; fill: currentColor; }

/* Fired on the button whenever a bookmark is saved (cover tap OR this button itself) so the
   confirmation is unmistakable regardless of which control the user actually tapped. Colour
   and shadow are animated INSIDE the keyframes (not toggled by the class) so nothing snaps
   when .bm-pulse is removed at animationend — it eases back to the exact resting state. A
   soft glow (blurred, not a hard expanding ring) plus a restrained scale reads calmer than a
   big bounce; the icon is given its own slightly larger pop so the two layers move at
   different rates, which is what keeps a pulse from reading as one flat, cheap "blink". */
@keyframes bm-pulse {
  0% {
    transform: scale(1);
    background: rgba(30, 30, 35, 0.42);
    border-color: var(--accent);
    color: var(--accent);
    box-shadow: 0 0 0 0 rgba(255, 94, 91, 0);
  }
  35% {
    transform: scale(1.035);
    background: var(--accent);
    border-color: var(--accent);
    color: #fff;
    box-shadow: 0 0 10px 2px rgba(255, 94, 91, 0.35);
  }
  100% {
    transform: scale(1);
    background: rgba(30, 30, 35, 0.42);
    border-color: var(--accent);
    color: var(--accent);
    box-shadow: 0 0 0 0 rgba(255, 94, 91, 0);
  }
}
@keyframes bm-pulse-icon {
  0% { transform: scale(1); }
  35% { transform: scale(1.08); }
  100% { transform: scale(1); }
}
.np-quick-bookmark.bm-pulse {
  animation: bm-pulse 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.np-quick-bookmark.bm-pulse svg {
  animation: bm-pulse-icon 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}

/* ---- semi-transparent bookmark hint badge, cover top-right corner ----
   Decorative affordance only (aria-hidden). A floating circular glass badge (not a clipped
   corner triangle) — larger, more transparent fill, with a visible coral border ring so it
   reads as a distinct "bookmark here" affordance rather than blending into the cover art. */
.cover-bm-hint {
  position: absolute; top: 10px; right: 10px;
  width: 56px; height: 56px;
  pointer-events: none; z-index: 3;
  border-radius: 999px;
  border: 1.5px solid rgba(255, 94, 91, 0.8);
  background:
    url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='white'%3E%3Cpath%20d='M6%202h12a1%201%200%200%201%201%201v18l-7-4.5L5%2021V3a1%201%200%200%201%201-1z'/%3E%3C/svg%3E")
      no-repeat center / 26px 26px,
    rgba(255, 94, 91, 0.3);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}

/* ---- Ticket B: toast action button ("Comment" on the "Bookmark added" toast) ---- */
.abs-toast { display: inline-flex; align-items: center; gap: 0.6rem; }
.abs-toast-action {
  background: var(--accent); color: #fff;
  border: none; border-radius: 999px;
  padding: 0.25rem 0.7rem; font-size: 0.78rem; font-weight: 700;
  min-width: 0; min-height: 0; cursor: pointer;
}
.abs-toast-action:hover { background: var(--accent-hover); }

/* ---- Closing the player: the mini-player falls into place ----
   Three sizes now: a 120px nudge (Iteration 6, ticket E), then a full viewport height, now 30vh.

   The middle one was MEASURED at 390x844 before it was replaced, and the numbers say plainly
   why it read as slow and choppy while dropping almost no frames. The bar is 94px tall and
   rests with its top edge at 680px, so -100vh started it 844px away and the whole 620ms went on
   covering that. For the first 119ms it was off screen entirely, which is the "slow" half of
   the complaint. It then entered already travelling 51px per display frame and peaked at
   63.3px, two thirds of its own height, so consecutive frames barely overlapped. The eye reads
   that as stepping rather than as motion, and no frame counter can see it, because every frame
   was delivered on time.

   Those figures are normalised to a 60Hz frame, which matters: the animation runs on the
   compositor and any sampler runs on the main thread, so a raw sample-to-sample delta measured
   across a main-thread stall invents a step nobody ever saw. Blocking the main thread mid-fall
   and watching the screen keep moving is how that was established rather than assumed.

   253px over 420ms peaks at 24.6px per frame instead, measured the same way: 74% overlap
   between frames, so the motion is continuous. The curve keeps a gentle hold at the front
   (0.08/0.32, against the old 0.02/0.34) so it still falls rather than slides, then spends the
   back two thirds decelerating. It starts ON screen, 427px up, so there is no dead period at
   all, and opacity is up by 18%, about 58px into the fall. */
@keyframes mp-float-down {
  0%   { transform: translateY(-30vh); opacity: 0; }
  18%  { opacity: 1; }
  100% { transform: translateY(0); opacity: 1; }
}
.mini-player-inner.floating-in { animation: mp-float-down 0.42s cubic-bezier(0.32, 0.08, 0.14, 1); }

/* ---- Opening the player: the reader expands up off the bar and fills the page ----
   The mirror of the fall above, same duration and the same curve, so the two directions feel
   like one gesture played forwards and backwards, which is why the retimed drop is copied
   here rather than left at 620ms. Its geometry is untouched: a full-width stage sweeping 38%
   of its own height does not strobe the way a 61px bar does, and it measured 0 dropped frames
   and a 16ms worst case at 4x CPU throttle before this change. The stage grows from its own
   bottom edge (transform-origin) while rising, which is what makes it read as expanding upward
   into the page rather than sliding up over it; the blurred art and its veil fade in behind.

   These rules animate the player's INNER layers and never <now-playing> itself, deliberately:
   a transform on the host would make it the containing block for the chapter/bookmark and
   speed popups, which are position: fixed, and park them against the player's box instead of
   the viewport for as long as the animation ran.

   pl-fade has no `to` frame on purpose. A keyframe that omits a property's end value falls
   back to the element's own computed value, so this one keyframe fades .pl-amb to its resting
   0.5 and .veil to its resting 1 without either number being restated here to drift later. */
@keyframes pl-rise {
  0%   { transform: translateY(38%) scale(0.94); opacity: 0; }
  20%  { opacity: 1; }
  100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes pl-fade { from { opacity: 0; } }
now-playing.rising .stage {
  transform-origin: 50% 100%;
  animation: pl-rise 0.42s cubic-bezier(0.32, 0.08, 0.14, 1) both;
}
now-playing.rising .pl-amb,
now-playing.rising .veil { animation: pl-fade 0.34s ease both; }

@media (prefers-reduced-motion: reduce) {
  .mini-player-inner.floating-in,
  now-playing.rising .stage,
  now-playing.rising .pl-amb,
  now-playing.rising .veil { animation: none; }
}

/* ============================================================
   Iteration 10 — /admin (admin-panel.js).

   Second visual pass, 2026-07-25. The first one moved the screen off structural markup and
   onto the /stats components, but /stats is the app's PLAINEST page, so the result was
   correct and characterless: hairline rules on a flat background, cards whose fill sat one
   step off the page (#1e1e22 on #141416) and so read as no card at all, and 12px section
   headings that disappeared. Andrew's words: cheap, and this is a premium-feel site.

   So this pass re-references the two pages that carry the brand — /library and the signed-in
   home — and lifts their actual containers rather than their thinnest ones:

     .phead + .psum      /library's masthead: heavy headline, a serif-italic summary line on
                         the right with the numerals in Urbanist, a hairline under the lot.
     .stat-row/.statcard  home's "Your listening" strip. Three real boxes with 34px tabular
                         numbers — the visual anchor the admin screen had nothing of.
     .sec-head .eyebrow  /library's shelf header: a coral dash, an uppercase tracked label,
                         the count beside it and the action pill clustered right there.
     .adm-list           the settings card, not a bare bordered list: --sheen inset highlight,
                         --radius-card, and rows divided by --hair (the Iteration 11 premium
                         hairline) rather than the heavy --border.

   Width stays 1200px, matching .stats-page, per Andrew's earlier instruction that /admin line
   up with the other pages. Everything stays scoped under .admin-page so none of these short
   names can leak into the screens they were borrowed from.
   ============================================================ */
.admin-page { max-width: 1200px; margin: 0 auto; }

/* ---------- masthead (lifted from .lib-page .phead/.psum) ---------- */
.admin-page .phead {
  display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap;
  padding-bottom: 18px; border-bottom: 1px solid var(--hair); margin-bottom: 26px;
}
.admin-page .phead h1 {
  margin: 0; font-size: clamp(38px, 5vw, 56px); font-weight: 900;
  letter-spacing: -0.03em; line-height: 0.98;
}
.admin-page .phead h1 em {
  font-family: var(--font-serif); font-style: italic; font-weight: 400;
  color: var(--accent); letter-spacing: -0.01em;
}
/* The global .psum uppercases and tracks its text out, which is what made the old two-line
   "2 ACCOUNTS / 2 LIVE INVITES" read as a spreadsheet header. Reset both explicitly, exactly
   as .lib-page does, and keep the numerals in Urbanist so they stay tabular against the serif. */
.admin-page .psum {
  display: flex; align-items: baseline; justify-content: flex-end; gap: 10px; flex-wrap: wrap;
  text-align: right; color: var(--muted); font-family: var(--font-serif);
  font-style: italic; font-weight: 400; font-size: 16px; line-height: 1.45;
  letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums;
}
.admin-page .psum b {
  font-family: 'Urbanist', system-ui, -apple-system, sans-serif; font-style: normal;
  font-weight: 800; color: var(--text); letter-spacing: -0.01em;
}
.admin-page .psum .sep { font-style: normal; color: var(--faint); opacity: 0.5; }

/* ---------- the numbers strip (lifted from .home-wrap .stat-row/.statcard) ---------- */
.admin-page .stat-row { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 8px; }
.admin-page .statcard {
  flex: 1 1 180px; min-width: 160px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-card); box-shadow: var(--sheen);
  padding: 20px 22px;
}
/* The one addition to home's card: when the number is not zero it earns a tinted left edge,
   so "someone is waiting for you" is visible from across the room and a quiet page stays
   quiet. Coral for work outstanding, green for links currently in the wild. */
.admin-page .statcard.hot { border-color: rgba(255, 94, 91, 0.32); background: rgba(255, 94, 91, 0.05); }
.admin-page .statcard.hot .sv { color: var(--accent); }
.admin-page .statcard.live { border-color: var(--ok-line); background: var(--ok-tint); }
.admin-page .statcard.live .sv { color: var(--text-success); }
.admin-page .statcard .sv {
  display: block; font-size: 34px; font-weight: 800; letter-spacing: -0.02em;
  line-height: 1.05; font-variant-numeric: tabular-nums;
}
.admin-page .statcard .sl { display: block; margin-top: 7px; font-size: 13.5px; font-weight: 600; color: var(--muted); }

/* ---------- section header (lifted from .lib-page .shelf-head/.eyebrow/.seeall) ---------- */
.admin-page .sec { margin-top: 44px; }
.admin-page .sec-head { display: flex; align-items: center; gap: 13px; flex-wrap: wrap; margin-bottom: 14px; }
.admin-page .eyebrow {
  display: flex; align-items: center; gap: 10px; margin: 0;
  font-size: 15px; font-weight: 800; letter-spacing: 0.13em; text-transform: uppercase; color: var(--text);
}
.admin-page .eyebrow::before {
  content: ""; width: 20px; height: 2.5px; border-radius: 2px; background: var(--accent); flex: none;
}
.admin-page .sec-count { color: var(--faint); font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* The rule is what stops a 15px label looking lost across 1200px. It sits AFTER the count and
   before the action, so the button lands at the far right the way "Full stats" does on home. */
.admin-page .sec-rule { flex: 1; height: 1px; background: var(--hair); }
.admin-page .sec-note {
  margin: 0 0 14px; font-size: 14px; line-height: 1.55; color: var(--muted); max-width: 62ch;
}

/* ---------- the card (the /settings container, not a bare bordered list) ---------- */
.admin-page .adm-list {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-card); box-shadow: var(--sheen); overflow: hidden;
}
.admin-page .adm-row {
  display: flex; align-items: center; gap: 18px;
  padding: 18px 22px; border-bottom: 1px solid var(--hair);
  transition: background 0.15s ease;
}
.admin-page .adm-row:last-child { border-bottom: none; }
.admin-page .adm-row:hover { background: rgba(242, 242, 245, 0.03); }
.admin-page .adm-row.stack { align-items: flex-start; }
.admin-page .adm-main { flex: 1; min-width: 0; }
.admin-page .adm-actions { flex: none; display: flex; align-items: center; gap: 8px; }

/* Initial-in-a-circle, so a list of email addresses has something to scan down. */
.admin-page .adm-av {
  width: 42px; height: 42px; border-radius: 50%; flex: none;
  display: flex; align-items: center; justify-content: center;
  font-weight: 900; font-size: 1rem; text-transform: uppercase;
  color: var(--accent); background: rgba(255, 94, 91, 0.09); border: 1px solid rgba(255, 94, 91, 0.2);
}
.admin-page .adm-t { font-weight: 800; font-size: 15.5px; line-height: 1.3; overflow-wrap: anywhere; }
.admin-page .adm-k { margin-top: 4px; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
.admin-page .adm-k b { color: var(--text); font-weight: 700; }

/* One pill, four tints. Status is the first thing the eye should land on in a list of
   otherwise identical rows, which is exactly what the old bold-lowercase-word did not do. */
.admin-page .tag {
  display: inline-flex; align-items: center; gap: 0.35rem;
  padding: 0.22rem 0.62rem; border-radius: var(--radius-pill);
  font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
  border: 1px solid transparent; white-space: nowrap;
}
.admin-page .tag .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; flex: none; }
.admin-page .tag.is-live { color: var(--accent); background: rgba(255, 94, 91, 0.12); border-color: rgba(255, 94, 91, 0.28); }
.admin-page .tag.is-ok { color: var(--text-success); background: var(--ok-tint); border-color: var(--ok-line); }
.admin-page .tag.is-off { color: var(--faint); background: rgba(255, 255, 255, 0.04); border-color: var(--border); }
.admin-page .adm-tags { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
/* .tight = the pills sit UNDER their subject (an account's email, a request's name) rather
   than above it, so the row still reads name-first. */
.admin-page .adm-tags.tight { margin: 8px 0 0; }

/* ---------- invite row: three columns, so 1200px is filled rather than straddled ----------
   The first pass stacked status, dates and URL down the left edge and parked three buttons
   at the far right, leaving a dead channel through the middle of every row. Status and dates
   now hold a fixed lead column, the URL takes the slack, and the actions close the row. */
.admin-page .adm-row.inv { align-items: center; flex-wrap: wrap; }
.admin-page .inv-lead { flex: 0 0 250px; min-width: 0; }
.admin-page .inv-lead .adm-k { margin-top: 7px; }

/* The invite URL. Monospaced and boxed because it is a secret to be copied, not prose. */
.admin-page .adm-url {
  flex: 1 1 260px; min-width: 0; padding: 11px 14px;
  background: var(--bg); border: 1px solid var(--border);
  border-radius: var(--radius-helper);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere;
}
/* A used, revoked or expired link has no URL to show. Rather than collapsing the column and
   letting those rows sit at a different rhythm from the live ones, the slot says why it is
   empty — and says it quietly, since it is the expected end state, not a fault. */
.admin-page .adm-url.is-gone {
  background: transparent; border-style: dashed; color: var(--faint);
  font-family: 'Urbanist', system-ui, -apple-system, sans-serif; font-size: 13px;
}

/* An access request's own words, quoted rather than dumped as another line of body text. */
.admin-page .adm-quote {
  margin-top: 12px; padding: 12px 16px;
  border-left: 2px solid rgba(255, 94, 91, 0.35);
  background: var(--bg);
  border-radius: 0 var(--radius-helper) var(--radius-helper) 0;
  white-space: pre-wrap; font-size: 14.5px; line-height: 1.55; color: var(--text);
}

.admin-page .adm-msg { font-size: 14px; line-height: 1.55; }
.admin-page .adm-msg:empty { display: none; }
.admin-page .adm-msg.ok {
  margin-top: 12px; padding: 12px 16px;
  background: var(--ok-tint); border: 1px solid var(--ok-line);
  border-radius: var(--radius-helper); color: var(--text);
}
.admin-page .adm-msg.bad { margin-top: 8px; color: var(--danger); }
.admin-page .adm-msg code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  background: rgba(0, 0, 0, 0.28); border-radius: 6px; padding: 0.12rem 0.4rem;
  font-size: 0.92em; overflow-wrap: anywhere;
}
/* A row-level message is a full-width note under its row's three columns, not a fourth one. */
.admin-page .adm-row .invite-row-msg { flex-basis: 100%; }

/* Button variants. Height stays at the global 44px minimum (eyes-free touch target) —
   only weight and colour change, so the row reads as one primary and two quiet actions
   instead of three identical grey pills. */
.admin-page .btn-sm { padding: 0.5rem 1rem; font-size: 0.85rem; }
.admin-page .btn-quiet { background: transparent; border-color: var(--border); color: var(--muted); }
.admin-page .btn-quiet:hover { color: var(--text); border-color: var(--faint); background: rgba(255, 255, 255, 0.04); }

/* Access level (Iteration 10, three-role model). A <select>, not a third button: the three roles
   are mutually exclusive and the control has to SHOW the current one as much as offer the others.
   It carries .btn-sm.btn-quiet so it picks up the same padding, colour and border as the Revoke
   button next to it — but the global `button {}` base does not reach a <select>, so the pill
   radius, the 44px touch target and the cursor are restated here instead of inherited. Declared
   AFTER .btn-quiet on purpose: that rule's `background:` shorthand clears background-image, so the
   chevron has to come later to survive it. */
.admin-page .role-select {
  font: inherit; font-weight: 700; border-radius: 999px;
  min-height: 44px; cursor: pointer;
  appearance: none; -webkit-appearance: none;
  padding-right: 2.2rem;
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
                    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: right 1.1rem center, right 0.85rem center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}
/* Native dropdown lists inherit the PAGE background on some platforms and the control's on
   others; naming both keeps the open list readable on the dark theme either way. */
.admin-page .role-select option { background: var(--surface); color: var(--text); }
.admin-page .role-select:disabled { opacity: 0.55; cursor: progress; }
/* A refused role change explains itself under the account's own pills, across the full width of
   the main column, rather than squeezed beside the control that caused it. */
.admin-page .role-msg.bad { margin-top: 8px; }
/* Delete rests quiet and only turns red under the cursor. A list of cleared-out invites is
   mostly delete buttons; painting every one of them red makes a wall of alarm out of the
   page's most routine action. The red still arrives before the click that matters, and the
   armed state below is unmistakable. */
.admin-page .btn-danger { background: transparent; border-color: var(--border); color: var(--muted); }
.admin-page .btn-danger:hover {
  background: rgba(255, 92, 82, 0.12); border-color: rgba(255, 92, 82, 0.45); color: var(--danger);
}
.admin-page .btn-armed { background: var(--danger); border-color: transparent; color: #fff; }
.admin-page .btn-armed:hover { background: var(--danger); }
.admin-page .sec-head button svg,
.admin-page .adm-actions button svg { width: 15px; height: 15px; fill: currentColor; vertical-align: -2px; margin-right: 0.35rem; }

.admin-page .empty-state {
  padding: 2.4rem 1rem; border: 1px dashed var(--border);
  border-radius: var(--radius-card); background: rgba(255, 255, 255, 0.015);
}

@media (max-width: 900px) {
  .admin-page .sec-rule { display: none; }
  .admin-page .sec-head #mint-invite { margin-left: auto; }
  .admin-page .inv-lead { flex: 1 1 100%; }
  .admin-page .adm-url { flex: 1 1 100%; }
}
@media (max-width: 720px) {
  /* The global .phead media rule turns the head into a column but cannot override this
     block's align-items, and flex-end on a COLUMN means right-aligned — which is how the
     masthead ended up hard against the right edge on a phone. Restate both together. */
  .admin-page .phead { flex-direction: column; align-items: flex-start; margin-bottom: 20px; }
  .admin-page .sec { margin-top: 34px; }
  .admin-page .psum { justify-content: flex-start; text-align: left; }
  .admin-page .statcard { flex: 1 1 0; min-width: 0; padding: 14px 12px; }
  .admin-page .statcard .sv { font-size: 26px; }
  .admin-page .statcard .sl { font-size: 12px; margin-top: 5px; }
  .admin-page .adm-row { flex-wrap: wrap; padding: 16px; }
  .admin-page .adm-actions { width: 100%; }
  .admin-page .adm-actions button { flex: 1; }
  .admin-page .sec-head #mint-invite { width: 100%; margin-left: 0; justify-content: center; }
}


/* ============================================================================
   v4-desktop — ONE structural breakpoint, at 1024px.

   There used to be two, 140px apart: the sidebar collapsed to an off-canvas drawer at 900px and
   the filter bar switched to pills at 760px. Everything between got the worst of both, and the
   numbers were monotonic against it. Measured at 901px vs 900px, one pixel apart:

     content column   483 of 901 (54%)   ->  804 of 900 (89%)
     grid columns     3                  ->  4
     card width       144px              ->  188px
     toolbar          186px, three rows  ->  130px, two rows
     covers visible   3                  ->  4

   A design that gets better as the window gets SMALLER is a design with a breakpoint in the wrong
   place. The switch is 1152px, chosen from two measurements rather than from a round number:
   below it the content column cannot hold the toolbar on one row even with the compact Status
   control (needs 622px, has 686px at 1152 and 634px at 1100), and the panel costs 264px of a
   window that has none to spare. Below 1152 the phone's popup is the category UI and the whole
   window is content. The drawer is gone entirely, not hidden.

   The other breakpoint, 760px, is now SCALE only: type, rails, gutters. Structure switches once.
   ============================================================================ */
@media (max-width: 1151px) {
  .lib-page .side, .lib-page .side-head, .lib-page .backdrop, .lib-page .catbtn { display: none; }
  .lib-page .seg, .lib-page .menu-wrap, .lib-page .viewwrap { display: none; }
  /* v12: the two-row grid exists to line the panel up with the toolbar, and there is no panel
     below this width. `block` puts .phead and .content back in document order, which is the
     order they are written in, so nothing here has to restate a placement. */
  .lib-page .layout { display: block; }
  .lib-page .fbline { display: flex; align-items: center; margin-top: 10px; min-width: 0; }
  .lib-page .lsheetwrap { display: block; }
  .lib-page .layout { padding: var(--sp-6) var(--sp-4) 120px; gap: 0; }
  /* 901 to 1023 is the one band the old ladder never reached, because it stopped at 900 and the
     desktop grid took over at 901 with a sidebar in the way. No sidebar now, so it is the widest
     content column on the phone side of the switch and it gets the most columns. */
  @media (min-width: 901px) {
    /* v5: --cols here too, same reason as the ≤900 ladder — the count is a default computed by
       defaultColsFor(), not a lock the density control cannot reach. 5 is that default. */
    .lib-page .grid { grid-template-columns: repeat(var(--cols,5), minmax(0,1fr)) !important; gap: var(--sp-8) var(--sp-5); }
  }
}

/* ============================================================================
   Iteration 28, T08 (P6 + M10): the admin request queue's three groups, the Perpetual grants
   section, and the shared window picklist.

   APPENDED AT THE END OF THE FILE, and nothing above this line is reordered, reformatted or
   rewritten. web/app/styles.css is one of the five shared append-only files and seven tickets
   touch it this iteration, sequenced through T15 (_SHARED-CONTEXT.md sections 4 and 11.2). The
   prefix reserved for this ticket is `.adm-*`, which is admin-panel.js's own existing convention
   rather than a new kernel token, so everything below is either scoped under `.admin-page` or
   carries that prefix.

   ONE EXCEPTION, DECLARED RATHER THAN SMUGGLED: the `.awp-*` rules at the foot of this block are
   NOT scoped to `.admin-page`, because <access-window-picklist> is a component this ticket creates
   and T23's P7 screen imports, and a rule scoped to this page would leave that screen's copy
   unstyled. `.awp-` collides with no prefix in the reserved table. It is claimed here by the
   ticket that owns the component.

   NO DESIGN TOKEN IS DEFINED BELOW. Every colour, radius and space is an existing var(). Module 00
   is the only module that may mint one, and a screen branch that overrides a token locally has
   forked the design system.
   ============================================================================ */

/* ---------- the three groups: a quieter heading than .sec-head, since these sit INSIDE a
     section that already has one. Coral dash and uppercase tracking would compete with it. ---------- */
/* The first group sits closer, under the section's own note; the gap between groups is the wider
   one. Written as an adjacent-sibling rule rather than :first-of-type, which would have matched
   .sec-head: :first-of-type counts by TAG, and .sec-head, .sec-note's sibling wrapper and
   .requests-msg are all divs too. */
.admin-page .adm-group { margin-top: var(--sp-4); }
.admin-page .adm-group + .adm-group { margin-top: var(--sp-5); }
.admin-page .adm-group-h {
  display: flex; align-items: center; gap: var(--sp-2);
  margin: 0 0 var(--sp-3); padding-bottom: 6px; border-bottom: 1px solid var(--hair);
}
.admin-page .adm-group-t {
  margin: 0; font-size: var(--t-lg); font-weight: 700; letter-spacing: -0.01em; color: var(--text);
}
/* Tabular numerals so three counts stacked down the page line up on their own digits. */
.admin-page .adm-group-n {
  font-variant-numeric: tabular-nums; font-size: var(--t-md); font-weight: 700;
  color: var(--muted); background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--hair); border-radius: var(--radius-pill);
  min-width: 24px; padding: 1px 8px; text-align: center;
}
/* An empty group is a fact, not a hole: it keeps the same left edge as a row so the three groups
   read as one column whether or not any of them has content. */
.admin-page .adm-group .empty-state { margin: 0 0 var(--sp-2); }

/* ---------- the note a decision leaves behind (a refusal reason, or the question asked) ---------- */
.admin-page .adm-note {
  margin-top: var(--sp-2); font-size: 14px; line-height: 1.55; color: var(--muted);
  border-left: 2px solid var(--hair); padding-left: 10px;
}
.admin-page .adm-note-l {
  display: block; font-size: var(--t-xs); letter-spacing: var(--kicker-track);
  text-transform: uppercase; color: var(--faint); margin-bottom: 2px;
}

/* ---------- earlier decisions stay attached to the person's current row, collapsed by default
     and deliberately quieter than the live request they explain. ---------- */
.admin-page .req-history { margin-top: var(--sp-3); }
.admin-page .req-history-list {
  margin-top: var(--sp-2); border: 1px solid var(--hair);
  border-radius: var(--radius-helper); overflow: hidden;
  background: rgba(255, 255, 255, 0.02);
}
.admin-page .req-history-list[hidden] { display: none; }
.admin-page .req-history-list .adm-row { padding: 14px; gap: 12px; }
.admin-page .req-history-list .adm-av { width: 34px; height: 34px; font-size: var(--t-sm); }

/* ---------- the revoked-before warning above a window picklist ---------- */
.admin-page .adm-warn {
  margin-bottom: var(--sp-3); padding: 8px 10px; font-size: 14px; line-height: 1.5;
  color: var(--text); background: rgba(255, 94, 91, 0.1);
  border: 1px solid rgba(255, 94, 91, 0.28); border-radius: var(--radius-helper);
}

/* ---------- the three inline panels, and the mint form, which is the same object ----------
   Hidden by BOTH [hidden] and .is-off. The UA's `[hidden] { display: none }` is a bare attribute
   selector and loses to any class rule that sets display, which is the defect that left a nav bar
   showing on the offline screen from M4 to Iteration 19. .is-off is what actually acts. */
.admin-page .adm-panel,
.admin-page .adm-mintform {
  display: block; margin-top: var(--sp-3); padding: var(--sp-4);
  background: rgba(255, 255, 255, 0.03); border: 1px solid var(--hair);
  border-radius: var(--radius-helper);
}
.admin-page .adm-panel.is-off,
.admin-page .adm-mintform.is-off { display: none; }
.admin-page .adm-panel-l {
  display: block; margin-bottom: var(--sp-2);
  font-size: var(--t-md); font-weight: 700; color: var(--text);
}
.admin-page .adm-panel-l + .adm-panel-l,
.admin-page .adm-input + .adm-panel-l { margin-top: var(--sp-4); }
.admin-page .adm-panel-actions {
  display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-4);
}
.admin-page .adm-text,
.admin-page .adm-input {
  display: block; width: 100%; box-sizing: border-box;
  font-family: inherit; font-size: 15px; line-height: 1.55; color: var(--text);
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-helper); padding: 10px 12px;
}
.admin-page .adm-text { resize: vertical; min-height: 76px; }
.admin-page .adm-text:focus-visible,
.admin-page .adm-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.admin-page .adm-text::placeholder,
.admin-page .adm-input::placeholder { color: var(--faint); }
.admin-page .adm-panel-actions button:disabled { opacity: 0.45; cursor: not-allowed; }
.admin-page .adm-actions button:disabled { opacity: 0.45; cursor: not-allowed; }

/* ---------- perpetual grants: the same row as an invite, one column narrower ---------- */
.admin-page .adm-row.perp { align-items: center; }

/* ---------- <access-window-picklist>. Not scoped to .admin-page, see the block header. ----------
   The four options are real <button>s, so the global `button { border-radius: 999px }` and the
   44px minimums already apply and nothing here has to restate a target size. */
access-window-picklist { display: block; }
.awp-opts { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.awp-opt {
  background: transparent; border: 1px solid var(--border); color: var(--muted);
  font-variant-numeric: tabular-nums;
}
/* SCOPED :not(.is-on) ON PURPOSE, and this is the one rule in the block that was wrong first time.
   `.awp-opt:hover:not(:disabled)` is (0,3,0) and `.awp-opt.is-on` is (0,2,0), so the neutral hover
   tint BEAT the selected state and the option lost its coral the moment the pointer was over it,
   which is precisely the moment after you click it. Found by reading a screenshot: aria-pressed was
   true, the class was present, and every assertion passed while the render showed grey. The two
   rules are now mutually exclusive rather than racing on specificity, which is the same lesson
   Iteration 30 wrote down about a hover rule re-skinning a resting state. */
.awp-opt:not(.is-on):hover:not(:disabled) { color: var(--text); border-color: var(--faint); background: var(--tint-hover); }
/* The selected option is a tonal coral, NOT a solid fill: the panel's own submit button is the
   solid one, and two coral fills in one panel leave nothing saying which is the action. */
.awp-opt.is-on {
  color: var(--accent); border-color: rgba(255, 94, 91, 0.55); background: rgba(255, 94, 91, 0.14);
}
/* Hovering the one that is already chosen brightens the same coral rather than replacing it. Stated
   rather than inherited, so there is no width or state at which the selected option can read as
   unselected. */
.awp-opt.is-on:hover:not(:disabled) {
  color: var(--accent-hover); border-color: var(--accent); background: rgba(255, 94, 91, 0.22);
}
.awp-opt:disabled { opacity: 0.45; cursor: not-allowed; }
/* The second confirmation. Deliberately heavier than the options above it: this is the one choice
   in the control that nothing will ever undo on its own. */
.awp-confirm {
  display: flex; align-items: flex-start; gap: 10px; margin-top: var(--sp-3);
  padding: 10px 12px; font-size: 14px; line-height: 1.5; color: var(--text);
  background: rgba(255, 94, 91, 0.08); border: 1px solid rgba(255, 94, 91, 0.28);
  border-radius: var(--radius-helper); cursor: pointer;
}
.awp-confirm.is-off { display: none; }
.awp-check { flex: none; width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--accent); cursor: pointer; }

@media (max-width: 760px) {
  /* The four options each take a full row rather than wrapping two-and-two: at 390px "No expiry"
     and "48 hours" on one line leave a 6px gutter between two 44px targets. */
  .awp-opts { flex-direction: column; align-items: stretch; }
  .awp-opt { width: 100%; }
  .admin-page .adm-panel-actions button { flex: 1 1 100%; }

  /* A REQUEST ROW'S ACTIONS WRAP TWO PER LINE. The existing mobile rule at styles.css:5556-5557
     (`.adm-actions { width: 100% }`, `.adm-actions button { flex: 1 }`) was written for a row with
     one or two buttons, and this ticket puts up to four on a needs_information row. Forced onto one
     line at 390px, `flex: 1` gave each about 70px and "Ask for more information" rendered as four
     stacked words inside a sliver, with "Return to queue" on three. Found by looking at the 390px
     screenshot, not by measuring: nothing overflowed and no assertion had anything to catch.
     Overridden here rather than edited up there, because styles.css is append-only.
     The label is NOT shortened at this width. A control that renames itself by viewport is worse
     than either name, which is the rule Iteration 19 wrote after doing exactly that. */
  .admin-page .adm-row.req .adm-actions { flex-wrap: wrap; align-items: stretch; }
  .admin-page .adm-row.req .adm-actions button { flex: 1 1 calc(50% - 4px); min-width: 0; }
}


/* ============================================================================
   Iteration 28, T23 Phase 1b (P7): admin user detail styles.

   APPENDED ONLY. Prefix `.aud-*` (admin user detail). Markup already lives in
   admin-user-detail.js; this block is the deferred stylesheet that was held for
   T09's Phase 1 lock on styles.css.

   NO NEW DESIGN TOKENS. Colours, radii and spacing reuse existing var()s, matching
   T08's admin/queue append above. The page root also carries `.admin-page` so it
   inherits the 1200px width, .btn-sm, .btn-armed, .empty-state and .awp-* picklist
   rules already defined. Loading/error shells render `.aud-page` alone; those get
   the same max-width here so the empty state is not full-bleed.

   Breakpoints: 760px (stack extend/revoke + full-width targets at ~390px).
   ============================================================================ */

/* ---------- page shell ----------
   #app-root already pays the page gutters and bottom clearance; .admin-page only
   caps width. Do not add a second gutter here (same lesson as .lib-page's double
   gutter note further up the file). */
.aud-page {
  max-width: 1200px;
  margin: 0 auto;
}

.aud-back {
  margin: 0 0 var(--sp-4);
  font-size: var(--t-md);
  font-weight: 700;
}
.aud-back a {
  color: var(--muted);
  text-decoration: none;
}
.aud-back a:hover { color: var(--text); }

/* ---------- identity header ---------- */
.aud-identity {
  padding-bottom: 18px;
  border-bottom: 1px solid var(--hair);
  margin-bottom: var(--sp-6);
}
.aud-name {
  margin: 0;
  font-size: clamp(28px, 4vw, 42px);
  font-weight: 900;
  letter-spacing: -0.03em;
  line-height: 1.05;
  overflow-wrap: anywhere;
}
.aud-email {
  margin: 6px 0 0;
  font-size: var(--t-lg);
  color: var(--muted);
  overflow-wrap: anywhere;
}
.aud-meta {
  margin: 8px 0 0;
  font-size: var(--t-md);
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}
.aud-state {
  margin: var(--sp-3) 0 0;
  font-size: var(--t-lg);
  line-height: 1.45;
  color: var(--text);
}
.aud-state[data-state="expired"],
.aud-state[data-state="revoked"] { color: var(--muted); }
.aud-state[data-state="no_account_activity"] { color: var(--muted); }
.aud-reason { color: var(--muted); }

/* Perpetual badge — same pill grammar as .admin-page .tag.is-live */
.aud-badge {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.22rem 0.62rem;
  border-radius: var(--radius-pill);
  font-size: 0.66rem;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  border: 1px solid transparent;
  white-space: nowrap;
  color: var(--accent);
  background: rgba(255, 94, 91, 0.12);
  border-color: rgba(255, 94, 91, 0.28);
}

/* Throttle banner beside a live window — same weight as .adm-warn */
.aud-throttle-note {
  margin: var(--sp-3) 0 0;
  padding: 8px 10px;
  font-size: 14px;
  line-height: 1.5;
  color: var(--text);
  background: rgba(255, 94, 91, 0.1);
  border: 1px solid rgba(255, 94, 91, 0.28);
  border-radius: var(--radius-helper);
}

/* ---------- sections ---------- */
.aud-section { margin-top: 36px; }
.aud-h2 {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 var(--sp-3);
  font-size: 15px;
  font-weight: 800;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--text);
}
.aud-h2::before {
  content: "";
  width: 20px;
  height: 2.5px;
  border-radius: 2px;
  background: var(--accent);
  flex: none;
}
.aud-h3 {
  margin: 0 0 var(--sp-3);
  font-size: var(--t-lg);
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--text);
}

.aud-page .empty-state {
  padding: 2.4rem 1rem;
  border: 1px dashed var(--border);
  border-radius: var(--radius-card);
  background: rgba(255, 255, 255, 0.015);
  text-align: center;
  color: var(--muted);
}

/* ---------- original request(s) ---------- */
.aud-req {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  box-shadow: var(--sheen);
  padding: 18px 22px;
  margin-bottom: var(--sp-3);
}
.aud-req.is-earlier {
  background: rgba(255, 255, 255, 0.02);
  box-shadow: none;
  border-color: var(--hair);
  padding: 14px 16px;
}
.aud-req-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
  flex-wrap: wrap;
}
.aud-req-head strong {
  font-size: 15.5px;
  font-weight: 800;
  overflow-wrap: anywhere;
}
.aud-req-when {
  font-size: var(--t-md);
  color: var(--faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.aud-req-purpose {
  margin-top: 12px;
  padding: 12px 16px;
  border-left: 2px solid rgba(255, 94, 91, 0.35);
  background: var(--bg);
  border-radius: 0 var(--radius-helper) var(--radius-helper) 0;
  font-size: 14.5px;
  line-height: 1.55;
  color: var(--text);
}
.aud-req-extra {
  margin-top: var(--sp-2);
  display: flex;
  flex-wrap: wrap;
  gap: 8px 14px;
  font-size: var(--t-md);
  color: var(--muted);
}
.aud-req-decision {
  margin-top: var(--sp-3);
  font-size: var(--t-md);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* Older requests: disclosure, quieter than the open newest entry */
.aud-req-history {
  margin-top: var(--sp-3);
  border: 1px solid var(--hair);
  border-radius: var(--radius-helper);
  background: rgba(255, 255, 255, 0.02);
  padding: var(--sp-3);
}
.aud-req-history > summary {
  cursor: pointer;
  font-size: var(--t-md);
  font-weight: 700;
  color: var(--muted);
  list-style: none;
}
.aud-req-history > summary::-webkit-details-marker { display: none; }
.aud-req-history > summary::before {
  content: "▸ ";
  color: var(--faint);
}
.aud-req-history[open] > summary::before { content: "▾ "; }
.aud-req-history[open] > summary { margin-bottom: var(--sp-3); color: var(--text); }
.aud-req-history .aud-req { margin-bottom: var(--sp-2); }
.aud-req-history .aud-req:last-child { margin-bottom: 0; }

/* ---------- window history ---------- */
.aud-window-list {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  box-shadow: var(--sheen);
  overflow: hidden;
}
.aud-window-row {
  padding: 16px 22px;
  border-bottom: 1px solid var(--hair);
  transition: background 0.15s ease;
}
.aud-window-row:last-child { border-bottom: none; }
.aud-window-row:hover { background: rgba(242, 242, 245, 0.03); }
.aud-window-row[data-status="revoked"] { opacity: 0.72; }
.aud-window-row[data-status="active"] {
  border-left: 2px solid var(--accent);
}
.aud-window-main {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  flex-wrap: wrap;
}
.aud-window-kind {
  font-weight: 800;
  font-size: 15.5px;
  line-height: 1.3;
}
.aud-window-status {
  display: inline-flex;
  align-items: center;
  padding: 0.18rem 0.55rem;
  border-radius: var(--radius-pill);
  font-size: 0.66rem;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  border: 1px solid var(--border);
  color: var(--faint);
  background: rgba(255, 255, 255, 0.04);
}
.aud-window-row[data-status="active"] .aud-window-status {
  color: var(--text-success);
  background: var(--ok-tint);
  border-color: var(--ok-line);
}
.aud-window-row[data-status="revoked"] .aud-window-status {
  color: var(--danger);
  background: rgba(255, 92, 82, 0.1);
  border-color: rgba(255, 92, 82, 0.3);
}
.aud-window-row[data-kind="perpetual"] .aud-window-kind { color: var(--accent); }
.aud-window-meta {
  margin-top: 6px;
  font-size: 13px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}
.aud-window-revoked {
  margin-top: 8px;
  font-size: 14px;
  line-height: 1.5;
  color: var(--muted);
  border-left: 2px solid rgba(255, 92, 82, 0.4);
  padding-left: 10px;
}

/* ---------- extend + revoke actions ---------- */
.aud-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--sp-4);
  margin-top: var(--sp-5);
}
.aud-extend,
.aud-revoke {
  display: block;
  padding: var(--sp-4);
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--hair);
  border-radius: var(--radius-helper);
}
.aud-extend access-window-picklist { margin-bottom: var(--sp-3); }
.aud-extend-btn,
.aud-revoke-btn { margin-top: var(--sp-2); }
.aud-extend-btn:disabled,
.aud-revoke-btn:disabled { opacity: 0.45; cursor: not-allowed; }

.aud-label {
  display: block;
  margin-bottom: var(--sp-2);
  font-size: var(--t-md);
  font-weight: 700;
  color: var(--text);
}
.aud-reason-input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin-top: 6px;
  font-family: inherit;
  font-size: 15px;
  line-height: 1.55;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-helper);
  padding: 10px 12px;
  resize: vertical;
  min-height: 64px;
}
.aud-reason-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.aud-reason-input::placeholder { color: var(--faint); }
.aud-reason-input:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.aud-msg {
  font-size: 14px;
  line-height: 1.55;
  margin: var(--sp-2) 0 0;
}
.aud-msg:empty { display: none; }
.aud-msg.ok {
  margin-top: 12px;
  padding: 12px 16px;
  background: var(--ok-tint);
  border: 1px solid var(--ok-line);
  border-radius: var(--radius-helper);
  color: var(--text);
}
.aud-msg.bad {
  margin-top: 8px;
  color: var(--danger);
}

/* ---------- listening activity ---------- */
.aud-listening-summary {
  margin: 0 0 var(--sp-3);
  font-size: var(--t-lg);
  line-height: 1.45;
  color: var(--muted);
}
.aud-listening-list {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  box-shadow: var(--sheen);
  overflow: hidden;
}
.aud-listening-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: 14px 22px;
  border-bottom: 1px solid var(--hair);
}
.aud-listening-row:last-child { border-bottom: none; }
.aud-listening-title {
  font-weight: 700;
  font-size: var(--t-lg);
  min-width: 0;
  overflow-wrap: anywhere;
}
.aud-listening-progress {
  flex: none;
  font-size: var(--t-md);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

/* ---------- throttle events ---------- */
.aud-throttle-list {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  box-shadow: var(--sheen);
  overflow: hidden;
}
.aud-throttle-row {
  padding: 14px 22px;
  border-bottom: 1px solid var(--hair);
}
.aud-throttle-row:last-child { border-bottom: none; }
.aud-throttle-row[data-in-effect="1"] {
  background: rgba(255, 94, 91, 0.06);
  border-left: 2px solid var(--accent);
}
.aud-throttle-meta {
  margin-top: 4px;
  font-size: 13px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.aud-in-effect {
  color: var(--accent);
  font-weight: 700;
  font-size: var(--t-md);
}

/* ---------- mobile: stack panels, full-width targets at ~390px ---------- */
@media (max-width: 760px) {
  .aud-name { font-size: clamp(24px, 7vw, 32px); }
  .aud-section { margin-top: 28px; }
  .aud-req { padding: 14px 16px; }
  .aud-window-row,
  .aud-listening-row,
  .aud-throttle-row { padding: 14px 16px; }
  .aud-actions {
    grid-template-columns: 1fr;
    gap: var(--sp-3);
  }
  .aud-extend-btn,
  .aud-revoke-btn { width: 100%; }
  .aud-req-head { flex-direction: column; align-items: flex-start; gap: 4px; }
  .aud-listening-row { flex-wrap: wrap; }
}

/* ==========================================================================
   Iteration 28, T15 (K1/K2) — kernel: access-countdown + cover-affordance modifiers.
   Reserved prefix: .opendot--* (see _SHARED-CONTEXT.md section 4).
   Does not touch body.is-player #global-site-bar.
   ========================================================================== */

/* <access-countdown> — independent of site-bar so it survives body.is-player. */
access-countdown {
  display: none;
  position: sticky;
  top: 0;
  z-index: 40;
  justify-content: center;
  pointer-events: none;
  padding: 8px 16px 0;
}
access-countdown:not([hidden]) {
  display: flex;
}
.access-countdown__inner {
  display: inline-flex;
  align-items: baseline;
  gap: 10px;
  pointer-events: auto;
  padding: 6px 14px;
  border-radius: 999px;
  background: rgba(12, 12, 14, 0.78);
  border: 1px solid var(--border);
  color: var(--text);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 13px;
  line-height: 1.2;
}
.access-countdown__inner--urgent {
  font-weight: 900;
}
.access-countdown__tick {
  letter-spacing: 0.02em;
}
.access-countdown__abs {
  font-weight: 500;
  color: var(--muted);
  font-size: 12px;
}
@media (prefers-reduced-motion: reduce) {
  .access-countdown__inner {
    transition: none;
  }
}

/* Cover affordance state modifiers on the shared .opendot badge. */
.opendot--play {
  background: var(--accent);
  color: #fff;
}
.opendot--no-window {
  /* default .opendot chrome; chevron_right glyph */
}
.opendot--throttled {
  background: rgba(12, 12, 14, 0.82);
  color: var(--text);
}
.opendot--expired {
  background: rgba(12, 12, 14, 0.82);
  color: var(--muted);
}

/* ============================================================================================
   Iteration 28, T16 (P4) — access-ended screen (.ended-*).
   Mirrors offline-screen's .off-* convention: scoped prefix, shared tokens, no embedded style.
   ============================================================================================ */
.ended-page { padding-top: 0.5rem; }
.ended-eyebrow {
  display: inline-flex; align-items: center; gap: 0.6rem;
  font-size: 0.7rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted);
}
.ended-dot {
  width: 8px; height: 8px; border-radius: 50%; flex: none;
  background: var(--accent); box-shadow: 0 0 8px 1px rgba(255, 94, 91, 0.45);
}
.ended-h1 { margin-top: 1rem; max-width: 520px; }
.ended-lede { margin-top: 0.75rem; max-width: 480px; color: var(--text); font-size: 1.05rem; line-height: 1.5; }
.ended-body { margin-top: 0.85rem; max-width: 480px; color: var(--muted); line-height: 1.55; }
.ended-card {
  margin: 1.6rem 0 0;
  padding: 1rem 1.1rem;
  border-radius: var(--radius, 18px);
  background: var(--surface, #1e1e22);
  border: 1px solid var(--border);
  display: grid;
  gap: 0.75rem;
  max-width: 420px;
}
.ended-card dt {
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em;
  text-transform: uppercase; color: var(--faint, var(--muted));
}
.ended-card dd { margin: 0.2rem 0 0; color: var(--text); font-weight: 600; word-break: break-word; }
.ended-actions { margin-top: 1.6rem; display: flex; flex-direction: column; align-items: flex-start; gap: 0.9rem; }
.ended-secondary { font-size: 0.92rem; color: var(--muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.5rem; }
.ended-reapply--quiet {
  background: transparent; border: 0; padding: 0; color: var(--muted);
  text-decoration: underline; text-underline-offset: 3px; cursor: pointer; font: inherit;
}
.ended-reapply--quiet:hover { color: var(--text); }
.ended-signout, .ended-cancel {
  background: transparent; border: 0; padding: 0; color: var(--muted);
  text-decoration: underline; text-underline-offset: 3px; cursor: pointer; font: inherit;
}
.ended-signout:hover, .ended-cancel:hover { color: var(--text); }
.ended-foot { margin-top: 2rem; font-size: 0.82rem; color: var(--faint, var(--muted)); max-width: 420px; }
.ended-notice { margin-top: 1rem; color: var(--muted); font-size: 0.92rem; }
.ended-success { margin-top: 1.2rem; color: var(--text); font-weight: 600; }
.ended-form { margin-top: 1.2rem; display: grid; gap: 0.85rem; max-width: 440px; }
.ended-form .field { display: grid; gap: 0.3rem; font-size: 0.85rem; font-weight: 600; color: var(--muted); }
.ended-form input, .ended-form textarea {
  font: inherit; font-weight: 500; color: var(--text);
  background: var(--surface, #1e1e22); border: 1px solid var(--border);
  border-radius: 10px; padding: 0.65rem 0.75rem;
}
.ended-opt { font-weight: 500; color: var(--faint, var(--muted)); }

/* ============================================================================================
   Iteration 28, T16 (P5) — limits-reset cool-off screen (.throttle-*).
   ============================================================================================ */
.throttle-page { padding-top: 0.5rem; }
.throttle-eyebrow {
  display: inline-flex; align-items: center; gap: 0.6rem;
  font-size: 0.7rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted);
}
.throttle-dot {
  width: 8px; height: 8px; border-radius: 50%; flex: none;
  background: #f0a53a; box-shadow: 0 0 8px 1px rgba(240, 165, 58, 0.55);
}
.throttle-h1 { margin-top: 1rem; max-width: 520px; }
.throttle-lede { margin-top: 0.75rem; max-width: 480px; color: var(--text); font-size: 1.05rem; line-height: 1.5; }
.throttle-body { margin-top: 0.85rem; max-width: 480px; color: var(--muted); line-height: 1.55; }
.throttle-countdown {
  margin-top: 1.6rem;
  display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 0.5rem 0.85rem;
  padding: 0.7rem 1rem;
  border-radius: var(--radius-pill, 999px);
  background: rgba(12, 12, 14, 0.78);
  border: 1px solid var(--border);
  font-variant-numeric: tabular-nums;
}
.throttle-countdown-label { font-size: 0.8rem; color: var(--muted); font-weight: 600; }
.throttle-countdown-tick { font-size: 1.25rem; font-weight: 800; letter-spacing: 0.02em; }
.throttle-countdown-abs { font-size: 0.85rem; color: var(--muted); font-weight: 500; }
.throttle-actions { margin-top: 1.6rem; }
.throttle-secondary { margin-top: 1.2rem; }

/* ---- /get-access design polish (v3, 2026-07-31) ----
   Approach A: scoped under .access-v2 / .gar-form only.
   Material listbox forked from .lib-page menus for full-width form fields.
   Do not change library toolbar menu rules. */

.access-v2 .av-uses-lead {
  font-size: 18px;
  line-height: 1.55;
  font-weight: 600;
  color: var(--text);
  margin: 0 auto 14px;
  max-width: 52ch;
}
@media (max-width: 640px) {
  .access-v2 .av-uses-lead { font-size: 16px; }
}

/* How it works: headings only, centre-aligned cards */
.access-v2 .av-step {
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.access-v2 .av-step h3 {
  margin: 0;
  font-size: 19px;
  font-weight: 800;
  text-align: center;
}
.access-v2 .av-step-num {
  margin-left: auto;
  margin-right: auto;
}

/* Form card: full-width join-popup language */
.access-v2 .gar-form-sec {
  text-align: left;
  padding: 0;
  background: transparent;
  border: 0;
}
.access-v2 .gar-form-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 24px;
  padding: 44px 48px;
  max-width: 40rem;
  margin: 0 auto;
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
}
@media (max-width: 640px) {
  .access-v2 .gar-form-card { padding: 28px 20px; border-radius: 18px; }
}
.access-v2 .gar-form-card h2 {
  margin: 0 0 12px;
  font-size: 22px;
  text-align: left;
}
.access-v2 .gar-form-lead {
  text-align: left;
  max-width: none;
  margin: 0 0 1.5rem;
  font-size: 16px;
}
.access-v2 .gar-form .field {
  margin-bottom: 1.35rem;
}
/* Field labels only — never .md-checkbox-label (that must stay display:flex, box before text). */
.access-v2 .gar-form .field > label {
  display: block;
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--text);
  margin-bottom: 0.45rem;
}

/*
  Confirm checkboxes — lock to mockup-get-access-polish-v3.html
  (.md-row / .md-box / .md-text). Global `input, textarea { width:100%; min-height:44px;
  padding… }` must not leak onto the hidden checkbox or it stacks the box above the copy.
*/
.access-v2 .gar-form .md-checkbox-label,
.access-v2 .gar-form .gar-confirm {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: 12px;
  margin: 0 0 14px;
  cursor: pointer;
  user-select: none;
  position: relative;
  text-align: left;
  font-size: inherit;
  font-weight: 400; /* global label { font-weight: 700 } would bold the whole row */
  color: inherit;
}
.access-v2 .gar-form .md-checkbox-label input[type="checkbox"] {
  position: absolute;
  left: 0;
  top: 0;
  opacity: 0;
  width: 0;
  height: 0;
  min-width: 0;
  min-height: 0;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  appearance: none;
  -webkit-appearance: none;
  pointer-events: none;
}
.access-v2 .gar-form .md-checkbox {
  width: 20px;
  height: 20px;
  min-width: 20px;
  min-height: 20px;
  box-sizing: border-box;
  border-radius: 4px;
  border: 2px solid rgba(255, 255, 255, 0.3);
  background: transparent;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 2px;
  flex: 0 0 20px;
  color: #fff;
  transition: all 0.15s ease;
}
.access-v2 .gar-form .md-checkbox-label:hover .md-checkbox {
  border-color: var(--accent);
  background: rgba(255, 94, 91, 0.1);
}
.access-v2 .gar-form .md-checkbox-label input[type="checkbox"]:checked + .md-checkbox {
  background: var(--accent);
  border-color: var(--accent);
}
.access-v2 .gar-form .md-checkbox-icon {
  width: 16px;
  height: 16px;
  opacity: 0;
  transform: scale(0.5);
  transition: all 0.12s ease;
}
.access-v2 .gar-form .md-checkbox-label input[type="checkbox"]:checked + .md-checkbox .md-checkbox-icon {
  opacity: 1;
  transform: scale(1);
}
.access-v2 .gar-form .md-checkbox-icon path {
  fill: currentColor;
}
.access-v2 .gar-form .md-checkbox-text {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 13.5px;
  line-height: 1.45;
  font-weight: 400;
  color: var(--text);
  opacity: 0.92;
  text-align: left;
}
.access-v2 .gar-form .md-checkbox-text strong {
  font-weight: 700;
  color: var(--text);
}
.access-v2 .gar-form .md-checkbox-text a {
  color: var(--accent);
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.access-v2 .gar-form .md-checkbox-text a:hover {
  color: var(--accent-hover, var(--accent));
}
.access-v2 .gar-form input[type="text"],
.access-v2 .gar-form input[type="email"],
.access-v2 .gar-form textarea,
.access-v2 .gar-form .menu-btn {
  width: 100%;
  box-sizing: border-box;
  min-height: 50px;
  padding: 0.85rem 1rem;
  border-radius: 12px;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  font: inherit;
  font-size: 1rem;
}
.access-v2 .gar-form textarea {
  min-height: 7.5rem;
  resize: vertical;
  line-height: 1.45;
}
.access-v2 .gar-form input:focus,
.access-v2 .gar-form textarea:focus,
.access-v2 .gar-form .menu-btn:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(255, 94, 91, 0.22);
}

/* Material listbox (full-width field fork; not .lib-page toolbar) */
.access-v2 .gar-form .menu-wrap {
  position: relative;
  width: 100%;
}
.access-v2 .gar-form .menu-btn {
  display: flex;
  align-items: center;
  gap: 10px;
  justify-content: space-between;
  text-align: left;
  cursor: pointer;
  font-weight: 600;
}
.access-v2 .gar-form .menu-btn .gar-menu-val {
  flex: 1;
  min-width: 0;
}
.access-v2 .gar-form .menu-btn .gar-menu-val.placeholder {
  color: var(--muted);
  font-weight: 500;
}
.access-v2 .gar-form .gar-menu-chev {
  width: 22px;
  height: 22px;
  flex: none;
  color: var(--muted);
  display: inline-flex;
  transition: transform 0.15s ease;
}
.access-v2 .gar-form .gar-menu-chev svg {
  width: 100%;
  height: 100%;
  fill: currentColor;
}
.access-v2 .gar-form .menu-wrap.open .gar-menu-chev {
  transform: rotate(180deg);
}
.access-v2 .gar-form .menu {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(100% + 6px);
  z-index: 40;
  max-height: min(280px, 50vh);
  overflow-y: auto;
  padding: 6px;
  border-radius: 12px;
  background: var(--surface-elev, var(--surface));
  border: 1px solid var(--border);
  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
}
.access-v2 .gar-form .menu[hidden] {
  display: none;
}
.access-v2 .gar-form .menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  border: 0;
  background: transparent;
  color: var(--text);
  text-align: left;
  font: inherit;
  font-weight: 600;
  font-size: 0.95rem;
  padding: 0.7rem 0.85rem;
  min-height: 44px;
  border-radius: 10px;
  cursor: pointer;
}
.access-v2 .gar-form .menu-item:hover {
  background: rgba(255, 255, 255, 0.06);
}
.access-v2 .gar-form .menu-item.on {
  background: rgba(255, 94, 91, 0.13);
  color: var(--accent);
}
.access-v2 .gar-form .gar-menu-tick {
  width: 18px;
  height: 18px;
  flex: none;
  opacity: 0;
  color: var(--accent);
}
.access-v2 .gar-form .menu-item.on .gar-menu-tick {
  opacity: 1;
}
.access-v2 .gar-form .gar-menu-tick path {
  fill: currentColor;
}

/* Confirmations block — spacing matches mockup v3 .confirm-block */
.access-v2 .gar-confirms {
  border: 0;
  padding: 0;
  margin: 22px 0 8px;
  min-inline-size: 0;
  text-align: left;
}
.access-v2 .gar-confirm-legend {
  padding: 0;
  font-size: 13px;
  font-weight: 700;
  color: var(--muted);
  margin: 0 0 14px;
}
.access-v2 .gar-boundary {
  text-align: left;
  margin: 8px 0 16px;
  font-size: 13px;
  line-height: 1.45;
}
.access-v2 .gar-submit {
  width: 100%;
  max-width: none;
}

/* Terms collapsible under form */
.access-v2 .av-collapse {
  margin-top: 1.5rem;
  border: 1px solid var(--border);
  border-radius: 16px;
  background: var(--bg);
  overflow: hidden;
}
.access-v2 .av-collapse-head {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 1rem 1.1rem;
  border: 0;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 0.95rem;
  font-weight: 700;
  text-align: left;
  cursor: pointer;
}
/* No hover fill/colour change on the Terms and conditions collapse control. */
.access-v2 .av-collapse-head:hover,
.access-v2 .av-collapse-head:active {
  background: transparent;
  color: var(--text);
}
.access-v2 .av-collapse-head .gar-menu-chev {
  width: 22px;
  height: 22px;
  color: var(--muted);
  transition: transform 0.18s ease;
  display: inline-flex;
}
.access-v2 .av-collapse-head .gar-menu-chev svg {
  width: 100%;
  height: 100%;
  fill: currentColor;
}
.access-v2 .av-collapse.open .av-collapse-head .gar-menu-chev {
  transform: rotate(180deg);
}
.access-v2 .av-collapse-body {
  display: none;
  padding: 0 1.1rem 1.15rem;
  text-align: left;
  border-top: 1px solid var(--border);
}
.access-v2 .av-collapse.open .av-collapse-body {
  display: block;
  padding-top: 1rem;
}
.access-v2 .av-collapse-ver {
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0 0 10px;
}
.access-v2 .av-collapse-law {
  font-size: 14px;
  line-height: 1.5;
  color: var(--text);
  font-weight: 600;
  margin: 0 0 12px;
}
.access-v2 .av-terms-list {
  list-style: none;
  margin: 0 0 1.1rem;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.access-v2 .av-terms-list li {
  position: relative;
  padding-left: 1.1rem;
  font-size: 14px;
  line-height: 1.5;
  color: var(--faint);
}
.access-v2 .av-terms-list li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.45em;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
}
.access-v2 .gar-full-terms {
  width: 100%;
}
