/* App-chrome typeface (design system rebrand) -- a variable font (single
   file, weight axis 100-900) rather than one file per static weight like
   the reader fonts below, since Inter's own official distribution ships
   this way and it covers every weight this rebrand needs (400/600/700) in
   one download. font-display:swap is deliberate and not used elsewhere in
   this block: Inter is now the primary UI-blocking font (unlike the reader
   fonts below, which are opt-in from a picker), so avoiding FOIT on first
   paint matters more here. Fetched from google/fonts' own OFL mirror,
   byte-verified against the GitHub API's reported size (876576 bytes,
   matched exactly) before being committed -- same discipline as every
   other bundled font/model asset in this project. License: Inter-OFL.txt
   (SIL OFL 1.1), same license family as ComicNeue-OFL.txt below. */
@font-face{
  font-family:'Inter';
  src:url('/static/fonts/Inter-Variable.ttf') format('truetype-variations');
  font-weight:100 900;
  font-style:normal;
  font-display:swap;
}
@font-face{
  font-family:'Lora';
  src:url('/static/fonts/Lora-Regular.ttf') format('truetype');
  font-weight:normal;
}
@font-face{
  font-family:'Roboto';
  src:url('/static/fonts/Roboto-Regular.ttf') format('truetype');
  font-weight:normal;
}
@font-face{
  font-family:'ComicNeue';
  src:url('/static/fonts/ComicNeue-Regular.ttf') format('truetype');
  font-weight:normal;
}
/* Bold face of the same 'ComicNeue' family above (already self-hosted for
   the reflow reader's own font picker) -- added for translated bubble
   text (.reader-bubble-text div), so it reads like real comic dialogue
   rather than a plain UI sans-serif pasted over hand-lettered artwork.
   License (SIL OFL 1.1) is in app/static/fonts/ComicNeue-OFL.txt, same
   convention as this project's other bundled fonts. Bold only -- comic
   lettering is conventionally bold/all-caps-leaning, and this is the one
   new weight this addition actually needed. */
@font-face{
  font-family:'ComicNeue';
  src:url('/static/fonts/ComicNeue-Bold.ttf') format('truetype');
  font-weight:bold;
}
@font-face{
  font-family:'OpenSans';
  src:url('/static/fonts/OpenSans-Regular.ttf') format('truetype');
  font-weight:normal;
}
@font-face{
  font-family:'OpenDyslexic';
  src:url('/static/fonts/OpenDyslexic-Regular.otf') format('opentype');
  font-weight:normal;
}
@font-face{
  font-family:'OpenDyslexic';
  src:url('/static/fonts/OpenDyslexic-Bold.otf') format('opentype');
  font-weight:bold;
}
:root{
  color-scheme: light;
  /* Brand palette -- from the design system reference (calibre_design_system.html).
     Applied at the token level so it cascades through the whole file; kept
     as separate --color-* tokens (not aliased into --text-primary etc.) so
     it's clear which rules are neutral-ink vs. deliberately brand-colored. */
  --color-primary-bright:#0099CC; --color-primary-medium:#0077A0; --color-primary-dark:#004A6B;
  --color-accent-bright:#00D4FF; --color-accent-medium:#00A8CC; --color-accent-light:#66E6FF;
  --color-gray-900:#1A1A1A; --color-gray-800:#2D2D2D; --color-gray-700:#404040;
  --color-success:#10B981; --color-warning:#F59E0B; --color-error:#EF4444;
  /* .btn-primary/.badge-primary gradient uses Medium->Dark, not the design
     doc's literal Bright->Medium: white text on Bright alone is 3.27:1
     contrast (fails WCAG AA for normal-size text), while Medium->Dark is
     5.06-9.58:1 (passes throughout) and still reads as clearly on-brand --
     the doc's own "Accessibility First / WCAG AA" principle wins over its
     literal example gradient. */
  --btn-primary-gradient: linear-gradient(135deg, var(--color-primary-medium) 0%, var(--color-primary-dark) 100%);
  --btn-primary-shadow: 0 6px 20px rgba(0,119,160,0.3);
  --btn-primary-shadow-hover: 0 10px 32px rgba(0,119,160,0.4);
  --badge-primary-shadow: 0 4px 12px rgba(0,119,160,0.3);

  --surface-2:#ffffff; --surface-1:#f9f9f9; --surface-0:#eaf3f7;
  --text-primary:#1a1a1a; --text-secondary:#404040; --text-muted:#5b6472;
  --text-warning:#92400e; --bg-warning:#fef3c7;
  --text-success:#065f46; --bg-success:#d1fae5;
  --text-error:#991b1b; --bg-error:#fee2e2;
  --border:#e1e8ed; --border-strong:#c7d3db;
  --radius:8px;
  /* Dedicated status-dot palette, distinct from --text-success/--text-error
     above: those are tuned for readable body text on a light background
     (dark, low-saturation), which made the error/success/neutral dots hard
     to tell apart at a glance in a 12px circle -- and, since dark muted red
     and dark muted green share nearly the same lightness, functionally
     indistinguishable for red-green colorblind readers, who rely on
     lightness/saturation differences when hue alone doesn't help. These
     four are chosen to differ in both hue and lightness from each other --
     left untouched by the brand-palette rebrand for that reason, not an
     oversight. */
  --dot-neutral:#9CA3AF; --dot-running:#F59E0B; --dot-success:#16A34A; --dot-error:#DC2626;
}
/* overflow-anchor:none -- the dominant real cause behind the mobile topbar's
   hide-on-scroll flicker (base.html), found only by instrumenting a live
   session and testing this exact toggle directly: a single, deliberate
   scroll to one fixed target produced 2918 raw scroll events and 92
   hide/reveal flips with the browser's default scroll anchoring left on,
   vs. 128 events and 4 flips with it disabled -- confirming anchoring, not
   momentum noise, was the primary driver. Anchoring exists to stop the page
   jumping when content *above* the viewport changes size (e.g. a lazy-
   loaded image); .topbar collapsing/expanding via max-height is exactly
   that kind of above-the-fold size change, and the browser's own
   compensating scroll adjustment fires as a real 'scroll' event indistin-
   guishable from a user's own input -- which base.html's own hide/reveal
   logic then reacts to, potentially toggling the topbar again and
   triggering another compensation, a genuine feedback loop. Nothing in
   this app relies on scroll anchoring's own jump-prevention behavior
   (no infinite-scroll/prepended-content pattern exists here), so disabling
   it globally has no known downside. base.html's own reversal-confirmation
   logic stays as a second, independent layer -- this fixes the dominant,
   self-inflicted cause; that logic still guards against genuine scroll-
   event noise unrelated to anchoring. */
body{overflow-anchor:none;background:#D4E8F7;font-family:'Inter',-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--text-primary);margin:0;padding:2rem;}
.wrap{max-width:1300px;margin:0 auto;background:var(--surface-2);border-radius:12px;border:1px solid var(--border);padding:1.5rem;box-shadow:0 1px 3px rgba(0,0,0,0.06);}
/* Reported directly: on a machine with a tall viewport, the app card ends
   wherever its content ends (a short Browse results page, a short Settings
   section, etc.) and the plain body background shows as a large blank gap
   below it down to the bottom of the window -- .wrap has never had any
   height beyond what its content needs, and nothing here was ever meant to
   force-fill the viewport. min-height (a floor, not a ceiling -- a
   genuinely long page still grows past it and scrolls exactly as before)
   makes the card itself reach the bottom of the viewport instead. Scoped
   to .wrap-app specifically, NOT the bare .wrap class alone: login.html/
   setup.html/forgot_password.html/reset_password.html/login_totp.html all
   reuse the plain .wrap class for a small, deliberately compact, centered
   card via their own inline max-width/padding overrides -- stretching
   those to fill the viewport too would leave a much worse blank gap
   *inside* a half-empty login card. base.html's own top-level wrap (every
   in-app page: Dashboard/Browse/Shelves/Settings/etc.) is the only one
   that carries this second class. 4rem subtracts body's own 2rem top +
   2rem bottom padding so the card's bottom edge lands flush with the
   viewport's bottom edge, not 2rem short of it. */
.wrap-app{min-height:calc(100vh - 4rem);}
/* No global a{} rule existed before this rebrand -- bare anchors rendered
   browser-default blue/purple. Uses Primary Medium, not the design doc's
   literal Primary Bright: Bright-on-white is 3.27:1 (fails AA for 14px
   body text), Medium is 5.06:1 (passes). More specific link-ish rules
   elsewhere (.tab-btn, .lib-switcher-row, .format-badge, etc.) already
   override this via specificity/more-specific selectors, so this only
   affects genuinely bare anchors. */
a{color:var(--color-primary-medium);text-decoration:none;}
a:hover{color:var(--color-primary-bright);text-decoration:underline;}
/* Global focus-visible ring -- design doc's own "Focus Indicators" principle
   calls for a cyan halo on every interactive element, and most custom
   buttons here (tab-btn, view-btn, star-btn, chevrons, etc.) had no focus
   treatment at all before this. The doc's literal `outline:3px solid
   #00D4FF` alone is 1.77:1 against white, failing WCAG 1.4.11's 3:1
   non-text-contrast minimum -- using a compliant Primary Bright outline
   (3.27:1, what the 3:1 rule actually measures) with an outer cyan glow
   preserves the doc's visual intent without failing its own stated
   "WCAG AA" principle. */
:focus-visible{outline:2px solid var(--color-primary-bright);outline-offset:2px;box-shadow:0 0 0 4px rgba(0,212,255,0.4);}
/* -webkit-appearance/appearance:none is the actual fix for a real report,
   screenshot included: .btn-primary's own submit button (Request a title's
   "Submit request") rendered as a near-blank white pill with barely-legible
   text -- every property this stylesheet sets for it (gradient background,
   white text, box-shadow) checked out correct via getComputedStyle in this
   environment's own browser, meaning the values were right but something
   about a real device's rendering wasn't applying them at paint time. A
   <button> keeps the OS's native control chrome (a plain white/gray
   capsule on iOS Safari in particular) until appearance:none tells the
   browser to stop drawing it and defer entirely to this stylesheet's own
   background/border -- exactly the class of bug that shows up as "my CSS
   is right but the button still looks unstyled," and this stylesheet had
   never reset it anywhere. Applied at the base button{} rule so every
   button in the app gets it, not just .btn-primary -- most other buttons
   here already have their own explicit flat background/border (so nothing
   visually changes for them), but .btn-primary's gradient+box-shadow
   combination is the one most likely to actually expose the gap. */
button{-webkit-appearance:none;appearance:none;font-family:inherit;border-radius:var(--radius);border:1px solid var(--border-strong);background:transparent;cursor:pointer;color:var(--text-primary);font-weight:500;transition:all 0.15s ease;}
button:hover{background:var(--surface-1);border-color:var(--color-primary-medium);}
button:disabled{opacity:0.5;cursor:default;}
input,select{font-family:inherit;height:36px;border-radius:var(--radius);border:1px solid var(--border-strong);padding:0 10px;background:var(--surface-1);color:var(--text-primary);box-sizing:border-box;font-weight:400;transition:all 0.15s ease;}
input:hover,select:hover{border-color:var(--color-primary-medium);background:var(--surface-0);}
input:focus,select:focus{outline:none;border-color:var(--color-primary-bright);box-shadow:inset 0 0 0 1px var(--color-primary-bright);}
table{width:100%;font-size:13px;border-collapse:collapse;}

/* Prominent-CTA button/link components, ported from calibre_design_system.html
   -- applied ONLY to the app's genuinely standalone, single-primary-action
   pages (login/setup/forgot-password/reset-password/change-password/totp
   setup), never to the dense per-row/multi-button admin and reader
   surfaces (see CLAUDE.md-style scope note: decoration on prominent CTAs
   only, palette-only everywhere else). The doc's own .btn::before radial-
   ripple click flourish is deliberately omitted -- not requested, and its
   position:relative;overflow:hidden would need re-verifying against every
   target button's own layout. */
.btn{padding:14px 32px;border-radius:8px;font-size:15px;font-weight:700;border:none;cursor:pointer;transition:all 0.3s cubic-bezier(0.4,0,0.2,1);letter-spacing:0.3px;font-family:inherit;}
/* background-color is a deliberate, explicit solid fallback -- reported
   directly, twice, with a screenshot: the button rendered as a near-blank
   white pill on a real device, with only its box-shadow (a separate,
   non-gradient property, confirmed still painting fine) visible as a blue
   glow underneath. Every computed value checked out correct in this
   environment's own browser both times (light and dark color-scheme), so
   this isn't a wrong CSS value -- it's that `background` was set purely to
   a gradient with nothing behind it, so on whatever real device/rendering
   path fails to paint that gradient, there's nothing left to fall back to.
   background-image (the gradient) always paints over background-color
   when both apply, so this changes nothing in the working case -- it only
   guarantees a strong, correctly-contrasted solid blue behind the white
   text instead of a blank one if the gradient layer itself ever fails. */
/* forced-color-adjust:none is the real fix, found from a real diagnostic
   dump, not another guess: a report's own on-screen debug panel showed
   every computed value on this button already correct (right gradient,
   right white text, right box-shadow, no filter, no opacity, appearance
   already reset) on a genuine desktop Windows Chrome -- yet the button
   still rendered blank in the same screenshot. That combination (correct
   computed style, wrong paint) is the signature of Windows' own "forced
   colors" / High Contrast mode: Chrome/Edge substitute a restricted system
   palette onto *native form controls* specifically (button/input/select)
   at paint time, while getComputedStyle keeps reporting the page's own
   original authored values, since the override happens after the cascade,
   not as part of it -- which is exactly why two earlier fixes that changed
   what value was declared (an appearance:none reset, then an explicit
   background-color fallback) had zero visible effect: forced-colors
   ignores the declared value either way unless told not to. This button's
   own gradient+white-text combination is already a deliberate, confirmed
   WCAG-AA-compliant design (see the .btn-primary/.badge-primary comment in
   :root above) -- forced-color-adjust:none opts it out of the automatic
   substitution so the browser renders what was actually designed. */
.btn-primary{background-color:var(--color-primary-dark);background-image:var(--btn-primary-gradient);color:#fff;box-shadow:var(--btn-primary-shadow);forced-color-adjust:none;}
/* The actual root cause, found from a real, precise report ("turns white
   when hovering"), not another guess: generic button:hover{background:
   var(--surface-1);...} (style.css's own base button rule) combines an
   element selector with a pseudo-class, which outranks .btn-primary's
   plain class selector in CSS specificity -- so on hover it silently won
   the cascade and reset the background to a near-white color, wiping out
   the gradient while .btn-primary's own color:#fff (untouched by that
   rule) stayed white. box-shadow was never affected because that's a
   separate specificity contest .btn-primary:hover already won on its own
   -- which is exactly why the blue glow always rendered fine while the
   background went blank. .btn-secondary never had this bug because it
   already redeclares its own background directly on :hover. Redeclaring
   the background here (matching the base rule) gives this selector the
   same class-bucket count as .btn-primary itself plus :hover, which
   already outranks button:hover regardless of its own element selector. */
.btn-primary:hover{background-color:var(--color-primary-dark);background-image:var(--btn-primary-gradient);transform:translateY(-3px);box-shadow:var(--btn-primary-shadow-hover);}
.btn-secondary{background:transparent;color:var(--color-primary-bright);border:2px solid var(--color-primary-bright);transition:all 0.3s ease;forced-color-adjust:none;}
.btn-secondary:hover{background:var(--color-primary-bright);color:#fff;transform:translateY(-3px);}
/* Secondary/tertiary links on the same whitelisted pages (e.g. "Forgot
   password?", "Back to login") -- distinct from the ambient a{} rule
   above, since these are meant to read as a deliberate call-to-action
   link, not incidental body-text navigation. */
.link-accent{color:var(--color-primary-bright);text-decoration:none;cursor:pointer;position:relative;font-weight:600;}
.link-accent::after{content:'';position:absolute;bottom:-2px;left:0;width:100%;height:2px;background:linear-gradient(90deg,var(--color-primary-bright),var(--color-accent-bright));transform:scaleX(0);transform-origin:left;transition:transform 0.3s ease;}
.link-accent:hover{color:var(--color-accent-bright);}
.link-accent:hover::after{transform:scaleX(1);}

/* Pill badges, ported from the design doc -- used sparingly (see scope note
   above); currently applied to just the account-switcher's role label. */
.badge{display:inline-block;padding:8px 16px;border-radius:24px;font-size:13px;font-weight:700;letter-spacing:0.3px;transition:all 0.3s ease;}
.badge:hover{transform:translateY(-2px);box-shadow:0 6px 16px rgba(0,0,0,0.15);}
.badge-primary{background-color:var(--color-primary-dark);background-image:var(--btn-primary-gradient);color:#fff;box-shadow:var(--badge-primary-shadow);forced-color-adjust:none;}
.badge-success{background:linear-gradient(135deg,var(--bg-success) 0%,#a7f3d0 100%);color:var(--text-success);box-shadow:0 4px 12px rgba(16,185,129,0.2);}
.badge-warning{background:linear-gradient(135deg,var(--bg-warning) 0%,#fde68a 100%);color:var(--text-warning);box-shadow:0 4px 12px rgba(245,158,11,0.2);}
.badge-error{background:linear-gradient(135deg,var(--bg-error) 0%,#fecaca 100%);color:var(--text-error);box-shadow:0 4px 12px rgba(239,68,68,0.2);}
/* .badge's doc-literal 8px/16px padding is chunky for an inline label next
   to a 14px name inside a 220px-wide dropdown row (its one current use). */
.badge-compact{padding:3px 10px;font-size:10px;}

.topbar{
  display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;
  position:sticky;top:0;z-index:40;
  background:var(--surface-2);
  border-bottom:1px solid var(--border);
  border-radius:12px 12px 0 0;
  margin:-1.5rem -1.5rem 1.25rem;
  padding:1.5rem 1.5rem 1rem;
  /* Needed for the mobile hide-on-scroll max-height:0 collapse (below) to
     actually reach zero -- without it, max-height on a content-box element
     only constrains the content area, leaving this rule's own padding
     (1.5rem/1rem) and border-bottom still rendering as a visible residual
     strip even at max-height:0. */
  box-sizing:border-box;
}
.tabs{display:flex;gap:4px;flex-wrap:wrap;align-items:center;}
.tab-btn{border:none;background:none;padding:8px 4px;font-size:14px;font-weight:500;color:var(--text-secondary);border-bottom:2px solid transparent;cursor:pointer;margin-right:16px;text-decoration:none;display:flex;align-items:center;gap:6px;}
.tab-btn.active{color:var(--color-primary-medium);border-bottom:2px solid var(--color-primary-medium);}
/* Book requests' own notification badge (the actual point of that feature
   -- see templating.py's _inject_pending_request_badge) -- the first
   numeric "N new things" indicator anywhere in this app, so this is a
   genuinely new small addition, not a reuse of an existing broken pattern.
   position:relative only on the one badged tab, not every .tab-btn, so
   this can't affect any other tab's layout. */
.tab-btn-badged{position:relative;}
.tab-badge{position:absolute;top:0;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:var(--color-error, #ef4444);color:#fff;font-size:10px;font-weight:700;line-height:16px;text-align:center;}
/* Icon/label toggling below is scoped to .tabs .tab-btn specifically (the
   Dashboard/Browse/Shelves/Knowledge Base nav links) -- .tab-btn itself is
   also reused for the account-switcher button and the Password link in the
   .inline row on the right, which must NOT get the column layout below or
   they misalign against the plain settings-btn/Log out button in that
   same row. */
.tabs .tab-icon{width:20px;height:20px;display:none;}
.tabs .tab-label{white-space:nowrap;}
@media (max-width:640px){
  .tab-dash{display:none;} /* the logo already links home on mobile -- redundant */
  .tabs .tab-btn{flex-direction:column;gap:2px;padding:4px;margin-right:8px;}
  .tabs .tab-icon{display:block;}
  .tabs .tab-label{display:none;font-size:10px;}
  /* Hide-on-scroll-down/reveal-on-scroll-up (base.html's own scroll
     listener toggles this class) -- scoped to mobile in both the JS
     (which force-clears the class above MOBILE_BREAKPOINT) and here, so a
     stale class from a resize can never visually apply on desktop even in
     the gap before JS's own resize handler runs.

     A max-height collapse, not transform:translateY(-100%) -- switched for
     the same reason .browse-sticky-toolbar (below) already uses this
     technique, confirmed directly against a real report (screenshot: a
     persistent blank gap at the top of the page once the toolbar below
     started properly collapsing). transform never removes an element from
     document flow, so hiding .topbar that way left its own ~129px of
     layout space fully reserved even though its painted content had moved
     off-screen -- measured directly: .browse-sticky-toolbar's own position
     never moved (stayed at the same y) across a topbar hide/reveal toggle,
     confirming nothing below it was ever shifting up to fill that space.
     300px is a generous cap (a real measured topbar is ~129px, even
     wrapped onto two lines on a very narrow/many-tab layout this is
     nowhere close to 300px) -- same "transition between two fixed numbers"
     technique as .browse-sticky-toolbar, same reasoning for why.

     overflow is visible at rest (the account-switcher's own dropdown,
     .account-dropdown, is position:absolute *inside* .topbar, and an
     always-on overflow:hidden would clip it open) but must stay hidden
     for the *entire* duration of the reveal animation too, not flip to
     visible the instant the class toggles -- a real, reported bug
     (screenshot: Dashboard/Settings metric cards visibly overlapping the
     topbar's own text while scrolling up), root-caused through two
     attempts: the first fix tried `transition:overflow 0s linear <dur>`
     to delay the flip via CSS, reasoning that transition-delay keeps a
     property's old value in effect until the delay elapses -- confirmed
     directly that this doesn't work: `overflow` isn't on the CSS list of
     animatable/transitionable properties at all (unlike `visibility`,
     which the same delayed-discrete-flip trick is specifically documented
     for), so browsers just ignore a `transition` entry naming it and
     apply the new value instantly regardless of any duration/delay
     specified. Real fix is in base.html's JS instead (see setHidden/
     applyHiddenState there): the *element's own inline* `style.overflow`
     is force-set to 'hidden' right before removing the -hidden class (so
     the still-growing box clips its content for the whole grow), then
     cleared back to the stylesheet's own visible value once the max-height
     transition genuinely finishes (a `transitionend` listener, with a
     timer fallback in case that event doesn't fire for some reason --
     e.g. a browser respecting prefers-reduced-motion and skipping the
     transition outright). Hiding doesn't need this JS-side handling at
     all: overflow:hidden in the -hidden class rule itself already applies
     instantly and correctly the moment that class is added, which was
     always the wanted behavior for that direction. */
  .topbar{max-height:300px;overflow:visible;transition:max-height 0.6s ease, padding 0.6s ease, border-width 0.6s ease;}
  /* max-height alone still left a 41px strip behind (padding-top 24px +
     padding-bottom 16px + the 1px border-bottom -- confirmed directly by
     measuring topbarHeight after the class toggle) -- max-height can only
     ever compress the content box, never the fixed padding/border values,
     regardless of box-sizing. Zeroing padding and border-bottom-width
     explicitly here is what actually finishes the collapse to a true 0. */
  .topbar-hidden{
    max-height:0;padding-top:0;padding-bottom:0;border-bottom-width:0;overflow:hidden;
  }
}
/* Settings/preferences gear moved into .inline as its own last child
   (reported directly: it should sit at the topbar's absolute rightmost
   position, in every layout, not just after the last nav tab) -- being
   .inline's last DOM child is what puts it there for free on desktop,
   since .inline itself is already pinned to the topbar's right edge via
   .topbar's own justify-content:space-between; no margin trick needed
   there. See the mobile override below for how "last child gets pushed to
   the line's own right edge" still works now that the last child is
   usually this gear rather than the Log out form. */
.settings-btn{display:flex;align-items:center;justify-content:center;width:32px;height:32px;flex-shrink:0;border-radius:var(--radius);color:var(--text-secondary);text-decoration:none;}
.settings-btn:hover{background:var(--surface-1);color:var(--color-primary-medium);}
.settings-btn.active{color:var(--color-primary-medium);background:var(--surface-1);}
/* Account row (right side of the topbar): account-switcher button, Password
   link, settings gear, Log out button all need to share one visual baseline
   -- they previously mixed a plain underline-style .tab-btn (no border) with
   a bordered <button> (Log out) and a bare 32px icon box (settings), which
   read as misaligned even though .inline's align-items:center kept them on
   one row. Normalize height/padding/border so they read as one control group. */
.inline .tab-btn{margin-right:0;padding:6px 10px;border-radius:var(--radius);border-bottom:none;height:32px;box-sizing:border-box;}
.inline .tab-btn:hover{background:var(--surface-1);color:var(--text-primary);}
.inline form{margin:0;display:flex;}
.inline form button{height:32px;padding:0 12px;font-size:14px;box-sizing:border-box;}
/* Reported directly: on mobile, the non-admin account row (account-switcher
   name, Password link, Log out button) wraps onto a second line instead of
   staying together as one row -- this row has no icon-only mobile fallback
   the way .tabs .tab-btn already does (see the @media block above), so the
   full-width text of all three keeps its desktop footprint on a narrow
   phone. Shrinks padding/font-size and caps the account name's own width
   (a long display name is the most likely single culprit) rather than
   hiding any of the three outright -- all three stay visible and legible,
   just narrower, which is enough headroom in the common case. */
@media (max-width:640px){
  /* flex:1 1 100% -- same "claim the whole wrapped line" pattern already
     used below for .lib-switcher/.shelf-status-tabs. Without it, .inline
     wraps onto its own line (below .tabs) but stays shrink-to-fit at
     roughly its own content width instead of spanning the row -- confirmed
     directly (a real 62px gap between the pushed-right last child and the
     row's true right edge): a flex item with no explicit width/flex-grow
     doesn't stretch to fill a line it has entirely to itself just because
     nothing else shares that line, so the margin-left:auto trick below had
     no real free space to consume until this was added. */
  .inline{gap:4px;flex:1 1 100%;}
  .inline .tab-btn{padding:4px 6px;font-size:12px;height:28px;}
  .account-name{max-width:64px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .inline form button{height:28px;padding:0 8px;font-size:12px;}
  /* Reported directly, refining the above: only the row's last real
     control should dock to the line's right edge -- account-switcher and
     Password should stay left-aligned, not travel with it. margin-left:auto
     on .inline itself (an earlier attempt) pushed the *whole* group right,
     taking name/Password along with it. Moving the auto-margin onto
     .inline's own last child instead consumes the row's remaining space
     right before that one element, so only it -- and only it -- gets
     pushed to the far right. That last child is usually the settings gear
     now (moved here from .tabs so it's always the topbar's rightmost
     element -- see the .settings-btn comment above), or the Log out form
     itself for a guest account, which never gets the gear at all -- either
     way, ":last-child" tracks whichever one is actually last without
     needing separate rules per case. */
  .inline > *:last-child{margin-left:auto;}
}

/* ============================================================
   Settings — left rail + one-section-at-a-time shell (redesign)
   ============================================================
   Replaced the earlier "8 independently-collapsible cards, click a header
   to expand" layout: with 8 sections, that meant scrolling past however
   many were open just to find the next one, with no persistent wayfinding.
   Now a single Alpine scope on #settings-shell (see onboarding.html) holds
   one activeSection value; the rail on the left is the only way to switch
   which section's content is shown, defaulting to Library management on
   first load per direct request. Every existing hx-post/form action inside
   each section is completely unchanged -- this is a navigation-shell and
   visual restyle, not a functional rewrite. */
.settings-shell{display:flex;gap:0;margin:0 -1.5rem;}
.settings-rail{width:220px;flex-shrink:0;padding:4px 12px 24px 24px;display:flex;flex-direction:column;gap:2px;border-right:1px solid var(--border);}
.settings-rail-group{display:flex;flex-direction:column;gap:2px;}
.settings-rail-label{font-size:10px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;color:var(--text-muted);margin:0;padding:14px 10px 4px;}
.settings-rail-group:first-child .settings-rail-label{padding-top:2px;}
.settings-rail-item{display:flex;align-items:center;gap:9px;width:100%;padding:8px 10px;border-radius:8px;border:none;background:none;font-family:inherit;font-size:13px;font-weight:500;color:var(--text-secondary);text-align:left;cursor:pointer;}
.settings-rail-item .icon{width:16px;height:16px;flex-shrink:0;color:var(--text-muted);}
.settings-rail-item:hover{background:var(--surface-1);color:var(--text-primary);}
.settings-rail-item.active{background:var(--surface-0);color:var(--color-primary-dark);font-weight:600;}
.settings-rail-item.active .icon{color:var(--color-primary-medium);}
.settings-main{flex:1;min-width:0;padding:4px 24px 40px 24px;}
.settings-page-head{display:flex;align-items:flex-start;gap:12px;margin-bottom:20px;}
.settings-page-head-icon{width:46px;height:46px;border-radius:12px;background:var(--surface-0);color:var(--color-primary-medium);display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.settings-page-head h1{font-size:20px;font-weight:700;letter-spacing:-0.005em;margin:0 0 3px;}
.settings-page-head p{font-size:13px;color:var(--text-muted);margin:0;font-weight:400;}
.settings-section-summary{margin-bottom:14px;}

/* Visual-fidelity pass -- the first redesign pass got the rail/one-section
   navigation right but left every row still looking like the old plain
   flat-text rows underneath (reported directly: "not faithful to the
   generated mockup"). These bring Library/Users/AI models/What's New rows
   in line with the approved mockup's actual values -- avatars, colored
   status pills, icon boxes, the rail's own active accent bar -- reusing
   this app's REAL tokens (--color-primary-bright/medium/dark,
   --btn-primary-gradient), not the mockup's own standalone --primary-... or
   --grad names, which don't exist here. Scoped to .settings-main (not the shared .metric-card/-label/
   -value classes' bare selectors) specifically so Dashboard's own use of
   those same shared classes is untouched -- only Settings' own metric
   strips get the bigger, bolder mockup treatment. */
.settings-page-head-icon{background:var(--btn-primary-gradient);color:#fff;box-shadow:0 6px 16px rgba(0,119,160,0.25);}

.settings-rail-item.active{position:relative;}
.settings-rail-item.active::before{content:'';position:absolute;left:-12px;top:6px;bottom:6px;width:3px;border-radius:0 3px 3px 0;background:var(--btn-primary-gradient);}

.settings-main .metric-label{font-size:11px;font-weight:600;letter-spacing:0.01em;color:var(--text-muted);margin:0 0 4px;}
.settings-main .metric-value{font-size:22px;font-weight:800;letter-spacing:-0.01em;}

.settings-avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700;flex-shrink:0;background:var(--btn-primary-gradient);}
.settings-avatar.alt{background:linear-gradient(135deg,var(--color-accent-medium),var(--color-primary-medium));}
.settings-avatar.guest{background:var(--surface-1);color:var(--text-muted);border:1.5px dashed var(--border-strong);}
.settings-avatar svg{width:16px;height:16px;}

.settings-status-pill{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:700;padding:4px 9px;border-radius:999px;background:var(--bg-success);color:var(--text-success);white-space:nowrap;}
.settings-status-pill.off{background:var(--surface-1);color:var(--text-muted);}
.settings-status-pill .dot{width:6px;height:6px;border-radius:50%;background:currentColor;flex-shrink:0;}

.settings-libs-pill{font-size:11.5px;font-weight:600;color:var(--color-primary-dark);background:var(--surface-0);padding:4px 9px;border-radius:999px;white-space:nowrap;}

.settings-role-pill{font-size:10px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;color:var(--color-primary-medium);background:var(--surface-0);padding:4px 8px;border-radius:6px;white-space:nowrap;}
.settings-default-pill{font-size:10.5px;font-weight:700;color:var(--text-success);background:var(--bg-success);padding:3px 9px;border-radius:999px;white-space:nowrap;}

.settings-icon-box{width:32px;height:32px;border-radius:8px;background:var(--btn-primary-gradient);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.settings-icon-box.off{background:var(--surface-1);color:var(--text-muted);}
.settings-icon-box svg{width:15px;height:15px;}

.run-all-btn{border:1px solid var(--border-strong);background:var(--surface-2);color:var(--text-secondary);font-size:11.5px;font-weight:600;padding:6px 12px;border-radius:7px;flex-shrink:0;}
.run-all-btn:hover{border-color:var(--color-primary-medium);color:var(--color-primary-dark);background:var(--surface-0);}

/* Library-access pills + privilege toggle switches -- the edit panel's own
   controls still looked like the pre-redesign plain checkbox list (reported
   directly, with screenshots comparing the live app against the approved
   mockup). Both wrap a real <input type="checkbox"> (see .sr-only-checkbox
   below) so this is still an ordinary HTML form underneath -- no JS, no
   change to any route/field name, purely a CSS reskin driven by :has(). */
.sr-only-checkbox{position:absolute;opacity:0;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;padding:0;}

.lib-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 11px;border-radius:8px;border:1.5px solid var(--border);background:var(--surface-2);font-size:12.5px;font-weight:600;color:var(--text-secondary);cursor:pointer;user-select:none;transition:border-color .15s ease,background .15s ease,color .15s ease;}
.lib-chip:hover{border-color:var(--border-strong);}
.lib-chip:has(input:checked){border-color:var(--color-primary-bright);background:var(--surface-0);color:var(--color-primary-dark);}
.lib-chip:has(input:focus-visible){outline:2px solid var(--color-primary-bright);outline-offset:2px;}
.lib-chip-check{width:15px;height:15px;border-radius:5px;border:1.5px solid var(--border-strong);flex-shrink:0;display:flex;align-items:center;justify-content:center;color:transparent;background:transparent;transition:background .15s ease,border-color .15s ease,color .15s ease;}
.lib-chip-check .icon{width:10px;height:10px;stroke-width:3;}
.lib-chip:has(input:checked) .lib-chip-check{background:var(--btn-primary-gradient);border-color:transparent;color:#fff;}

.settings-toggle-list{display:grid;grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));gap:12px 24px;}
.settings-toggle-row{display:flex;align-items:flex-start;gap:10px;cursor:pointer;}
.settings-switch{position:relative;width:36px;height:20px;flex-shrink:0;margin-top:1px;}
.settings-switch-track{position:absolute;inset:0;border-radius:999px;background:var(--border-strong);transition:background .15s ease;}
.settings-switch:has(input:checked) .settings-switch-track{background:var(--btn-primary-gradient);}
.settings-switch:has(input:focus-visible) .settings-switch-track{outline:2px solid var(--color-primary-bright);outline-offset:2px;}
.settings-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,0.25);transition:left .15s ease;}
.settings-switch:has(input:checked) .settings-switch-knob{left:18px;}
.settings-toggle-text{display:flex;flex-direction:column;gap:1px;min-width:0;}
.settings-toggle-name{font-size:12.5px;font-weight:600;color:var(--text-primary);}
.settings-toggle-desc{font-size:11px;color:var(--text-muted);line-height:1.4;}
.settings-mobile-topbar{display:none;}
.settings-rail-backdrop{display:none;}
@media (max-width:900px){
  .settings-rail{width:196px;}
}
@media (max-width:640px){
  .settings-shell{margin:0;flex-direction:column;}
  .settings-rail{
    position:fixed;top:0;left:0;bottom:0;z-index:50;width:250px;border-right:none;
    transform:translateX(-100%);transition:transform .22s ease;
    box-shadow:16px 0 32px rgba(0,0,0,0.22);background:var(--surface-2);
  }
  .settings-rail.open{transform:translateX(0);}
  .settings-rail-backdrop{
    display:block;position:fixed;inset:0;background:rgba(15,23,42,0.4);z-index:45;
    opacity:0;pointer-events:none;transition:opacity .2s ease;
  }
  .settings-rail-backdrop.open{opacity:1;pointer-events:auto;}
  .settings-mobile-topbar{
    display:flex;align-items:center;gap:10px;padding:0 0 14px;
  }
  .settings-mobile-menu-btn{width:34px;height:34px;border-radius:8px;border:1px solid var(--border-strong);background:var(--surface-2);color:var(--text-secondary);display:flex;align-items:center;justify-content:center;flex-shrink:0;}
  .settings-mobile-title{font-size:14px;font-weight:600;}
  .settings-main{padding:0 0 40px;}
  .settings-page-head h1{font-size:18px;}
}

.card{border:1px solid var(--border);border-radius:12px;padding:14px;margin-bottom:14px;background:var(--surface-1);box-shadow:0 1px 2px rgba(0,0,0,0.04);}
.card-title{font-size:13px;font-weight:500;margin:0 0 10px;}
.hint{font-size:12px;color:var(--text-secondary);margin:0 0 10px;}
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;}
/* A bare placeholder disappears the moment a field has a value -- fine for a
   single self-explanatory input (e.g. SMTP host/port next to an "Email" card
   title), but several same-shaped number fields side by side (the AI section's
   cost cap / daily limit / candidate count) become unreadable once filled in,
   since "120" alone means nothing. field-label is a persistent caption for
   exactly that case -- wrap the input in a plain <div> (a field-row's grid
   item can be any element) with this label above it. */
.field-label{display:block;font-size:11px;color:var(--text-secondary);margin-bottom:3px;}
.inline{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
/* Flex, not a <table> -- a per-row expandable panel inside a table cell has
   no definite width for its own content (checkboxes, in this case) to size
   against, which let a CSS grid child blow out past the card edge. See
   CLAUDE.md's Users-section note. flex-wrap sidesteps this entirely: no
   track-sizing math, it just wraps whatever doesn't fit onto the next line. */
/* Same row+inline-edit-panel shell used by Users, AI models, and What's
   New (see _users_section.html/_ai_section.html/_whats_new_admin_section.
   html) -- .user-list now reads as one bordered card of rows (matching
   .lib-list just above) instead of a bare stack, and the edit panel gets
   a tinted background + left indent so it visibly nests under its own row
   rather than looking like just another row. Column widths are untouched
   -- those are load-bearing for real content, not cosmetic. */
.user-list{border:1px solid var(--border);border-radius:10px;overflow:hidden;}
/* The header row (.user-row-head) is a sibling of every .user-entry, not
   inside one -- so ".user-entry:first-child" is never actually the first
   child of .user-list (the header row is) and would incorrectly skip
   nothing. The adjacent-sibling form below only ever matches a .user-entry
   that follows another .user-entry, correctly leaving just the very first
   data row border-free (the header row's own background already divides
   it from the rows below). */
.user-entry + .user-entry .user-row{border-top:1px solid var(--border);}
.user-row{display:flex;gap:12px;align-items:center;padding:11px 14px;}
.user-row.user-row-head{padding:10px 14px;font-size:11px;font-weight:700;letter-spacing:0.03em;text-transform:uppercase;color:var(--text-muted);background:var(--surface-1);}
.user-col-name{flex:0 0 150px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;}
.user-col-email{flex:1 1 160px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.user-col-access{flex:1 1 200px;min-width:0;}
.user-col-status{flex:0 0 150px;min-width:0;}
.user-col-ai{flex:0 0 180px;min-width:0;font-size:12px;}
.user-col-actions{flex:0 0 60px;text-align:right;}
.user-edit-panel{padding:16px 16px 16px 40px;background:var(--surface-1);border-top:1px dashed var(--border-strong);}
/* Equal-width columns, not free-flowing flex -- reported directly with a
   screenshot: real library display names vary wildly in length ("Comics
   [FR] - Periodicals" vs "Aviation"), so letting each checkbox size to its
   own label made the whole list look ragged/misaligned rather than a real
   grid. auto-fit + minmax matches this app's own established convention
   for exactly this ("reflow to fewer columns as space shrinks, no
   hand-picked breakpoint" -- .metric-grid/.dashboard-side-grid already use
   the same pattern). A label whose text is longer than one column wraps to
   a second line within its own cell (#users .inline's existing nowrap+
   align-items:flex-start fix, below, is what keeps the checkbox pinned to
   the top rather than floating away once that happens) instead of
   overflowing into the next column. */
.checkbox-wrap{display:grid;grid-template-columns:repeat(auto-fit, minmax(230px, 1fr));gap:6px 16px;align-items:start;}
/* Two bugs found by direct DOM measurement on the mobile per-user edit
   panel (library-access checkboxes AND the standalone can_download/
   notify_new_books/ai_recommendations/tts_enabled/comic_translation_enabled
   checkboxes right below them -- same <label class="inline"><input>text
   </label> markup shape in both places, so both need the same fix). The
   identical checkbox-wrap/label markup is also reused, unwrapped by
   .user-edit-panel, by the "Enable guest access" and "Create user" forms
   further down this same card -- so this is scoped to #users (the whole
   Users management card), not the narrower .user-edit-panel, specifically
   so those two forms get the same fix rather than needing their own
   near-duplicate rules later. .inline is reused broadly elsewhere in the
   app (outside #users) where this behavior is unaffected.

   1. .inline's own base rule sets flex-wrap:wrap for its two flex children
      (the checkbox <input> + a bare text node) -- fine for text that fits
      on one line, but for longer text that needs to wrap (a long library
      name, or "Email when new books arrive (off by default; ...)"), that
      same flex-wrap let the checkbox and its text split onto separate flex
      lines instead of staying together with the text wrapping internally,
      leaving the checkbox floating away from its own label.
   2. .inline > *:last-child{margin-left:auto} (below) exists to push a
      real last item -- e.g. Log out -- to the right edge of a multi-item
      topbar row. It also, unintentionally, matches here: a text node
      doesn't count toward :last-child, so <label class="inline"><input>
      text</label>'s only *element* child (the checkbox) is simultaneously
      :first-child and :last-child, pulling in that same auto margin and
      shoving the checkbox away from its own label text regardless of
      whether the text wraps at all -- confirmed via getComputedStyle
      reporting a resolved margin-left in the tens of px with nothing in
      this file authoring that value directly; the only auto-margin rule
      matching was that unrelated one. */
#users .inline{flex-wrap:nowrap;align-items:flex-start;}
/* A third bug, reported after the first two were confirmed fixed: the
   checkbox's own box was correctly pinned to the top (align-items above),
   but the global input,select{height:36px} rule a few lines up (meant for
   real text inputs/selects) also stretches this checkbox's box to 36px
   tall -- and the browser vertically centers the actual native checkbox
   glyph *inside* that oversized box, so the visible checkmark sits well
   below the box's own top edge, landing roughly between a wrapped label's
   two text lines instead of level with the first one. Confirmed via
   getComputedStyle showing height:36px on the checkbox with nothing in
   this rule authoring it. height:auto lets it size to its own native,
   un-stretched dimensions, so the glyph itself -- not just its box --
   sits flush with the top. */
#users .inline input{flex-shrink:0;margin-top:3px;margin-left:0;height:auto;}
/* Same .inline > *:last-child{margin-left:auto} rule, a fourth place it
   unintentionally reaches: the action-button rows below the checkboxes
   (Save access alone in its own .inline div -- a single child is always
   also :last-child, so it got shoved to the far right; Reset password /
   Deactivate-or-Reactivate / Delete -- the last button in that row got
   shoved away from the others). Reported directly as buttons looking
   "thrown without order." Reset to a normal packed-left row here too. */
#users .inline > *:last-child{margin-left:0;}

/* Generic "info block + trailing action button" list row -- same flex-row
   shape as .user-row (min-width:0 on the growable side, same reasoning as
   §9's ancestor-width lessons: a flex item that must shrink below its
   content needs it explicitly). Originally built for the (now-removed)
   Library Data Quality Review results list; kept and renamed since Book
   Requests' own admin list (_book_requests_admin.html) already reused it
   verbatim. */
.review-row{display:flex;gap:12px;align-items:center;justify-content:space-between;padding:8px 0;border-top:1px solid var(--border);}
.review-row-info{flex:1;min-width:0;}
.result-ok{font-size:12px;color:var(--text-success);}
.result-err{font-size:12px;color:var(--text-error);}

/* Traffic-light dot bumped from 12px to 14px and the whole row/panel given
   more breathing room -- direct feedback on the redesign asked for the
   real per-library actions (sync/fuzzy/AI index/book briefs/quality) to
   read clearly, dot-state-first, rather than as a dense text line; every
   hx-post/target/form action underneath is untouched, this is padding and
   sizing only. */
.lib-list{border:1px solid var(--border);border-radius:10px;overflow:hidden;}
.lib-row{display:flex;align-items:center;gap:10px;padding:11px 14px;font-size:13.5px;border-bottom:1px solid var(--border);}
.lib-entry:last-child .lib-row:only-child{border-bottom:none;}
.lib-row-status{padding:14px 14px 14px 38px;font-size:12.5px;border-bottom:1px solid var(--border);background:var(--surface-1);}
.lib-entry:last-child .lib-row-status{border-bottom:none;}
.lib-alias-input{flex:1;min-width:0;height:30px;font-size:13px;font-weight:600;}
.lib-row select{height:30px;font-size:12px;}
.lib-status-dot{flex-shrink:0;width:14px;height:14px;border-radius:50%;background:var(--dot-neutral);}
.lib-status-dot.lib-status-error{background:var(--dot-error);}
.lib-status-dot.lib-status-running{background:var(--dot-running);}
.lib-status-dot.lib-status-success{background:var(--dot-success);}
.lib-expand-btn{padding:4px;margin-left:auto;display:flex;align-items:center;justify-content:center;background:none;border:1px solid var(--border-strong);border-radius:7px;width:26px;height:26px;cursor:pointer;color:var(--text-secondary);transition:transform 0.2s,background 0.15s,color 0.15s;flex-shrink:0;}
.lib-expand-btn.is-open{transform:rotate(180deg);background:var(--surface-0);color:var(--color-primary-medium);border-color:var(--color-primary-medium);}
.lib-status-details{padding:8px 10px;font-size:12px;background:var(--surface-1);border-top:1px solid var(--border);}
.lib-entry:last-child .lib-status-details{border-bottom:none;}
.lib-name-group{display:flex;align-items:center;gap:8px;flex:1;min-width:0;}
.sync-row strong{font-size:12.5px;font-weight:600;}
.sync-needed-flag{display:inline-block;padding:3px 9px;background:var(--bg-warning);color:var(--text-warning);border-radius:6px;font-size:0.85em;font-weight:700;white-space:nowrap;flex-shrink:0;}
.tag-badge{display:inline-block;padding:1px 8px;background:var(--surface-0);color:var(--text-secondary);border:1px solid var(--border);border-radius:999px;font-size:0.8em;font-weight:500;white-space:nowrap;margin-right:6px;}
.btn-small{padding:6px 12px;font-size:12px;}
/* Knowledge Base tag badges: same pill look as .tag-badge but split
   into a clickable label (filters by that concept, unchanged behavior) and
   a small delete "x" -- kept as a sibling of the label rather than nested
   inside a single hx-get element, so a click on the x doesn't also bubble
   up and re-trigger the label's own hx-get concept filter. */
.kb-tag{display:inline-flex;align-items:center;gap:4px;padding:1px 4px 1px 8px;}
.kb-tag-label{cursor:pointer;}
.kb-tag-delete{border:none;background:none;padding:0 2px;font-size:1.1em;line-height:1;color:var(--text-muted);cursor:pointer;border-radius:999px;}
.kb-tag-delete:hover{color:var(--text-error);background:var(--bg-error);}

.progress-bar{background:var(--surface-1);border-radius:999px;height:8px;overflow:hidden;margin-bottom:8px;}
.progress-bar-fill{background:var(--color-success);height:100%;transition:width 0.3s ease;}
.progress-bar-mini{height:4px;margin-bottom:0;}
.sync-row-detailed{margin-bottom:8px;}
.sync-row{display:flex;justify-content:space-between;padding:3px 0 4px;font-size:12px;}
.status-queued{color:var(--text-muted);}
.status-syncing{color:var(--text-primary);}
.status-done{color:var(--text-success);}
.status-error{color:var(--text-error);}
.status-fill-queued{background:var(--border-strong);}
.status-fill-error{background:var(--text-error);}

[x-cloak]{display:none !important;}

.browse-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:14px;}

/* Wraps .browse-toolbar + .browse-controls (base.html's scroll listener
   toggles browse-sticky-toolbar-hidden on it, mirroring .topbar-hidden) --
   see _browse_results.html's own comment on the wrapper div. Mobile-only,
   same reasoning as .topbar-hidden's own media-query scoping.

   A max-height collapse, not transform:translateY(-100%) -- confirmed
   directly against a real report (screenshot) that translate doesn't work
   here the way it does for .topbar: .topbar sits at the very top of the
   viewport (y=0), so sliding it up by its own height moves it fully
   off-screen. This toolbar sits *below* .topbar in normal flow, so the
   identical translateY(-100%) only slides it up into .topbar's own
   now-vacated space -- still fully inside the viewport, just repositioned,
   which is exactly what the screenshot showed ("still not working" even
   though the JS-side hidden state was correctly true). A height collapse
   has no such position dependency: the box shrinks to zero in normal flow
   regardless of where on the page it sits.

   overflow is visible at rest (the library switcher's own dropdown,
   .lib-switcher-dropdown, is position:absolute *inside* this wrapper, and
   an always-on overflow:hidden would clip it open) but must stay hidden
   for the entire expand animation too, not snap to visible the instant
   the class toggles -- same real, reported bug as .topbar (see its own
   comment above for the full account: a `transition:overflow 0s linear
   <dur>` CSS delay trick was tried first and confirmed not to work,
   since `overflow` isn't an animatable/transitionable property at all,
   so browsers ignore a transition entry naming it and flip it instantly
   regardless of any specified delay). Fixed the same way, in base.html's
   JS (setHidden/applyHiddenState) rather than in CSS -- collapsing still
   needs nothing beyond this rule's own instant overflow:hidden, which was
   always correct for that direction. max-height's 600px baseline is
   deliberately generous (the real toolbar is nowhere near that tall, even
   wrapped onto multiple lines) -- transitioning between two fixed numbers
   works regardless of the actual content height, the same common
   technique this project reaches for wherever an exact "auto" height
   can't be transitioned directly. */
@media (max-width:640px){
  .browse-sticky-toolbar{max-height:600px;overflow:visible;transition:max-height 0.6s ease;}
  .browse-sticky-toolbar-hidden{max-height:0;overflow:hidden;}
}

.lib-switcher{position:relative;flex-shrink:0;}
/* Shelves' status-tab row (All/Want to read/Currently reading/Read/DNF)
   sits in the same toolbar slot Browse's library-switcher dropdown
   occupies, but it's a genuinely different shape of thing -- several
   independently-wrapping tab links, not one truncatable button -- so it
   gets its own class rather than reusing .lib-switcher (which used to be
   shared here and had a mobile override tuned for a single shrinking/
   truncating trigger; applied to a multi-item wrapping tab list instead,
   it let the view-toggle/sort controls share the same toolbar line as
   the (now multi-row, wrapped) tabs, landing them vertically centered
   partway down the tab text -- a real visual overlap on a phone, not
   just a cosmetic crowding issue). See the mobile override below for the
   fix: forcing this onto its own full-width line. */
.shelf-status-tabs{display:flex;flex-wrap:wrap;gap:4px;}
.lib-switcher-trigger{display:flex;align-items:center;gap:8px;padding:8px 14px;font-size:14px;width:300px;box-sizing:border-box;}
.lib-switcher-trigger span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}
.view-toggle{display:flex;gap:8px;flex-shrink:0;}
.lib-switcher-dropdown{position:absolute;z-index:20;margin-top:6px;width:260px;max-height:280px;overflow:auto;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.12);background:var(--surface-1);}
.lib-switcher-search{padding:8px;position:sticky;top:0;background:var(--surface-2);border-bottom:1px solid var(--border);}
.lib-switcher-search input{width:100%;height:30px;font-size:12px;}
.lib-switcher-row{display:block;padding:8px 12px;font-size:13px;color:var(--text-primary);text-decoration:none;}
.lib-switcher-row:hover{background:var(--surface-1);}
.lib-switcher-row.selected{background:var(--surface-1);font-weight:500;color:var(--color-primary-medium);}

.account-switcher{position:relative;flex-shrink:0;}
/* Narrower than the library switcher's dropdown (220px vs 260px) and
   right-aligned to its own trigger button, which -- on desktop -- sits
   comfortably left of it with room to spare. As a real CSS class instead
   of an inline style, the mobile override below can actually beat it;
   an inline style on the element itself would out-specificity any
   external stylesheet rule regardless of media query. */
.account-dropdown{left:auto;right:0;width:220px;}

.whats-new-switcher{position:relative;flex-shrink:0;}
.whats-new-dropdown{left:auto;right:0;width:280px;max-height:360px;overflow:auto;}
/* .lib-switcher-row is display:block by default -- overridden here so the
   teaser text and the unseen dot sit side by side (text left, dot right)
   instead of the dot wrapping onto its own line under a block-level
   anchor. */
.whats-new-row{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;}
.whats-new-row-text{flex:1;min-width:0;}
.whats-new-dot{width:8px;height:8px;border-radius:50%;background:var(--color-primary-bright);flex-shrink:0;margin-top:5px;}

.browse-flex{display:flex;gap:16px;align-items:flex-start;}
.browse-controls{display:flex;gap:8px;margin-bottom:0;}
/* The search input is now wrapped in .search-input-wrap (below) so a custom
   clear-x can be absolutely positioned inside it -- the flex-shrink rule
   that used to sit directly on the input (see this file's own history of
   flex-item min-width:0 gotchas) moves to the wrapper, which is now the
   actual flex item in .browse-controls's row. */
.search-input-wrap{position:relative;flex:1;min-width:0;display:flex;}
.search-input-wrap input{width:100%;min-width:0;padding-right:30px;}
/* Suppress the native type=search cancel-x in favor of the custom button
   below -- reported directly that the native one is inconsistently
   rendered (often tiny, easy to miss) on mobile Chrome, not a reliable way
   to clear the search. Both vendor pseudo-elements covered since Firefox
   and WebKit each use their own. */
.search-input-wrap input[type="search"]::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none;}
.search-input-wrap input[type="search"]::-webkit-search-decoration{-webkit-appearance:none;}
.search-input-wrap input[type="search"]::-ms-clear{display:none;}
.search-clear-btn{position:absolute;right:4px;top:50%;transform:translateY(-50%);width:24px;height:24px;padding:0;
  border:none;background:transparent;border-radius:50%;cursor:pointer;color:var(--text-muted);
  align-items:center;justify-content:center;font-size:18px;line-height:1;}
.search-clear-btn:hover{background:var(--surface-1);color:var(--text-primary);}
.view-btn{padding:6px 10px;display:inline-flex;align-items:center;justify-content:center;}
.view-btn.active{background:var(--surface-1);color:var(--color-primary-medium);}
/* margin-left:auto pushes this to the toolbar row's right edge, lining it
   up with the search button at the right edge of the row below (that
   button lands there because the search input next to it is flex:1 and
   absorbs the rest of the row -- same effect, different mechanism). Reset
   on mobile, where the toolbar wraps to its own lines and a hard right
   edge no longer means anything relative to the row below it. */
.sort-control{display:flex;gap:6px;align-items:center;flex-shrink:0;margin-left:auto;}
.sort-control select{height:36px;font-size:12px;padding:0 8px;}
.icon{vertical-align:-3px;flex-shrink:0;}

.browse-table-scroll,.browse-grid{overflow:auto;max-height:calc(100vh - 220px);min-height:200px;border:1px solid var(--border);border-radius:8px;background:var(--surface-1);}
.resizable-table{table-layout:fixed;width:100%;}
.resizable-table th{position:relative;overflow:hidden;}
th.sortable{cursor:pointer;user-select:none;}
th.sortable:hover{background:var(--color-primary-dark);}
th.sortable:hover .th-label{color:#ffffff;}
table thead th{padding:8px 10px;font-weight:600;text-align:left;color:#ffffff;border-bottom:2px solid var(--color-primary-dark);white-space:nowrap;background:var(--color-primary-medium);}
.browse-table-scroll thead th{position:sticky;top:0;z-index:1;}
.th-label{overflow:hidden;text-overflow:ellipsis;}
.sort-caret{font-weight:700;}
.col-resize-handle{position:absolute;top:0;right:0;width:6px;height:100%;cursor:col-resize;user-select:none;}
.col-resize-handle:hover,.col-resize-handle.resizing{background:var(--border-strong);}
table tbody td{padding:8px 4px;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
table tbody td:first-child{color:var(--text-primary);}
.browse-row{cursor:pointer;border-bottom:1px solid var(--border);background:var(--surface-2);transition:all 0.15s ease;}
.browse-row:nth-child(even){background:var(--surface-1);}
.browse-row:hover{background:var(--surface-0);}
.browse-row.selected{background:var(--surface-1);box-shadow:inset 3px 0 0 var(--color-primary-bright);border-left:3px solid var(--color-primary-bright);}

.browse-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:14px;}
.grid-card{cursor:pointer;border-radius:8px;padding:4px;min-width:0;}
.grid-card.selected{outline:2px solid var(--border-strong);}
.grid-cover{aspect-ratio:2/3;width:100%;min-width:0;border-radius:8px;display:flex;align-items:center;justify-content:center;margin-bottom:6px;background:var(--surface-1);overflow:hidden;}
.grid-cover img{width:100%;height:100%;object-fit:cover;}
.grid-title{font-size:12px;font-weight:500;margin:0;line-height:1.3;}
.grid-author{font-size:11px;color:var(--text-secondary);margin:2px 0 0;}

.dash-row{display:flex;gap:12px;overflow-x:auto;padding-bottom:4px;margin-bottom:1.5rem;}
.dash-card{flex:0 0 110px;width:110px;text-decoration:none;color:inherit;}
.dash-card.dash-more{display:flex;align-items:center;justify-content:center;text-align:center;color:var(--text-secondary);font-size:12px;border:0.5px dashed var(--border-strong);border-radius:8px;aspect-ratio:2/3;}
/* Reported directly with a screenshot: on desktop, this row (Continue
   reading / Recently finished / New since your last visit) only ever
   shows a handful of fixed-110px cards, leaving the rest of a wide screen
   as dead space -- flex items don't grow to fill remaining width without
   being told to. Kept as the original flex+horizontal-scroll on mobile
   (a real, already-working touch-swipe pattern, left untouched), switched
   to a grid with auto-fit/minmax on wider viewports instead: auto-fit
   (not auto-fill) collapses any track beyond the actual card count to 0
   width, so the real cards grow to fill the row rather than leaving empty
   grid cells trailing off to the right.

   The growth itself was originally unbounded (minmax(110px, 1fr)) -- a
   real regression reported directly with a screenshot: with only 1-2 real
   cards (the normal case for "new since your last visit" -- a handful of
   arrivals, not a full page of them) and a wide desktop viewport, those 1fr
   tracks absorbed the *entire* row's leftover width each, ballooning a
   normal ~110px cover into a ~650px one. Capped the track's max to 150px
   instead of 1fr -- cards still grow a little (110->150px) to use some of
   the extra room, but never balloon past a sane cover size; any width
   past what a handful of capped cards need is left as plain trailing
   space, the same unremarkable look most "recently added" shelf UIs
   already have for a short row, rather than one enormous cover.

   A later attempt to raise this cap further (240px, then a flexbox rework
   to make the row genuinely fill its full width) was tried, verified live,
   and then explicitly reverted -- the owner preferred the original,
   smaller 150px cover size after seeing both. Full history of that
   attempt (including a real CSS Grid auto-fit bug it uncovered along the
   way) is in CLAUDE.md's BookWyrm section rather than repeated here --
   don't re-attempt the same "fill the row" change without checking there
   first. */
@media (min-width:641px){
  .dash-row{display:grid;grid-template-columns:repeat(auto-fit, minmax(110px, 150px));overflow-x:visible;}
  .dash-card{width:auto;}
}

.pagination{display:flex;align-items:center;gap:12px;justify-content:flex-end;margin-top:12px;}
.pagination button{padding:6px 12px;font-size:12px;}

.detail-col{flex-shrink:0;}
/* A small, viewport-anchored toast, not a panel tied to #detail-pane's own
   position. Earlier versions anchored this against the detail pane/bottom
   sheet's live position (via JS getBoundingClientRect() on desktop, a
   dvh-based bottom offset tracking the sheet's top edge on mobile) so it
   would sit right above whatever it was narrating -- but that meant its
   position had to be recomputed on every scroll/resize, and each new mobile
   viewport quirk (browser chrome collapsing, vh vs dvh) surfaced as a fresh
   drift bug. This narration is only ever on screen for the few seconds an
   AI search takes and never needs to line up with anything else -- fixed at
   a constant spot near the top of the viewport needs no tracking at all and
   can't drift, on any width. One rule for every breakpoint; no mobile
   override needed. */
#ai-search-progress{
  display:none;
  position:fixed; top:16px; left:50%; transform:translateX(-50%);
  z-index:60;
  width:min(320px, calc(100vw - 32px));
  font-size:12px;color:var(--text-secondary);
  background:var(--surface-2); border:1px solid var(--border); border-radius:12px;
  padding:8px 12px;
  height:70px; overflow:hidden;
  box-shadow:0 4px 16px rgba(0,0,0,0.15);
}
#ai-search-progress div{margin-bottom:2px;}
.detail-pane{width:220px;flex-shrink:0;border:1px solid var(--border);border-radius:12px;padding:12px;position:relative;background:var(--surface-2);box-shadow:0 1px 3px rgba(0,0,0,0.06);}
/* Reported directly from a Boox tablet (~10.3in e-ink Android, confirmed
   elsewhere in this project's own history to report window.innerWidth
   above the 640px mobile breakpoint -- see CLAUDE.md's Boox touch-fix
   section -- so it gets this desktop/tablet layout, not the ≤640px
   bottom-sheet one): the only breakpoint in this file jumps straight from
   a 220px fixed sidebar (this rule) to a full-width bottom sheet at
   exactly 640px, with nothing in between for a genuine tablet-width
   viewport. Just above 640px, the fixed 220px pane competes hard against
   .browse-grid's auto-fill columns for width -- the math lines up almost
   exactly with "only 2 columns instead of 3" at a viewport in the
   roughly 650-750px range. Narrowing the pane (and its cover, which
   fills it) in this range frees enough width for a 3rd grid column
   without touching the well-tested ≤640px phone layout or the ≥1025px
   desktop one. */
@media (min-width:641px) and (max-width:1024px){
  .detail-pane{width:160px;}
  /* Confirmed with a real, corrected debug reading (the previous script had
     a timing bug -- see CLAUDE.md §23): at a real 661px Boox viewport, this
     narrower pane renders at 185px border-box, leaving .browse-grid exactly
     346px to work with -- still only enough for 2 columns at the default
     minmax(120px,1fr) floor (3 columns need 120*3 + 14px*2 gaps = 388px,
     46px more than's available). Rather than shrink the pane further (it's
     already down from 220px, and the original report was specifically that
     the cover looked *too small a fraction of the sidebar's own space* --
     no, too *large* -- shrinking it more risks the opposite complaint),
     lowering the grid's own floor to 100px is the more surgical fix: 3
     columns then need only 100*3 + 14*2 = 328px, comfortably inside the
     real 346px measured, with ~18px to spare. */
  .browse-grid{grid-template-columns:repeat(auto-fill,minmax(100px,1fr));}
}
.detail-close-btn{display:none;}
.detail-backdrop{display:none;}
.detail-cover{aspect-ratio:2/3;border-radius:8px;display:flex;align-items:center;justify-content:center;margin-bottom:10px;background:var(--surface-1);overflow:hidden;}
.detail-cover img{width:100%;height:100%;object-fit:cover;}
.detail-title{font-size:14px;font-weight:500;margin:0 0 2px;line-height:1.3;}
.detail-title-btn{font-size:14px;font-weight:500;margin:0 0 2px;line-height:1.3;background:none;border:none;padding:0;font-family:inherit;color:var(--text-primary);cursor:pointer;transition:color 0.2s ease;text-align:left;width:100%;}
.detail-title-btn:hover{color:var(--color-primary-medium);}
.detail-author{font-size:12px;color:var(--text-secondary);margin:0 0 10px;}
/* detail-author-row replaces the old single detail-author-btn as the outer
   element carrying the bottom margin -- each author inside it is now its
   own independent trigger (author-menu-wrap), so the margin can't live on
   the button itself without adding a gap between two authors sharing one
   "&" separator. */
.detail-author-row{display:flex;align-items:center;flex-wrap:wrap;gap:4px;margin:0 0 10px;}
.detail-author-sep{font-size:12px;color:var(--text-secondary);}
.author-menu-wrap{position:relative;display:inline-flex;}
.detail-author-btn{font-size:12px;color:var(--text-secondary);margin:0;background:none;border:none;padding:0;font-family:inherit;cursor:pointer;font-weight:inherit;transition:color 0.2s ease;display:inline-flex;align-items:center;gap:2px;}
.detail-author-btn:hover{color:var(--text-primary);}
/* Narrower than the library-switcher's own 260px -- this menu only ever
   holds two short rows, and .detail-pane has no overflow:hidden of its own
   (confirmed before relying on it) so the dropdown is free to overflow the
   pane's edge exactly like the account-switcher's dropdown already does in
   the topbar, rather than needing its own left/right-alignment override. */
.author-menu-dropdown{width:200px;}
.author-menu-item{display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;font-size:13px;color:var(--text-primary);background:none;border:none;font-family:inherit;text-align:left;cursor:pointer;}
.author-menu-item:hover{background:var(--surface-1);}
.detail-label{font-size:11px;color:var(--text-secondary);margin:0 0 4px;}
.detail-value{font-size:12px;margin:0 0 10px;}
/* Image-quality badge (LD/SD/HD/UHD) sits to the right of the Shelf select
   -- this wrapper renders even with no badge (holds the select alone,
   unchanged from before) and even for admin (badge alone, no select). */
.detail-shelf-row{display:flex;align-items:center;gap:8px;}
.quality-badge{height:28px;width:auto;flex:none;}
.detail-formats{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
.format-badge{display:inline-flex;align-items:center;gap:4px;padding:5px 9px;font-size:11px;border-radius:var(--radius);border:0.5px solid var(--border-strong);text-decoration:none;color:var(--text-primary);cursor:pointer;}
.format-badge:hover{background:var(--surface-1);}
/* Reported directly, twice: even a non-link, bordered/padded badge box
   next to real action badges still looked like a button. No border, no
   background, no hover, no padding-box -- just the bare icon inline in
   the same row, sized to align with the badges around it. */
.format-badge-icon{display:inline-flex;align-items:center;color:var(--text-secondary);cursor:default;}
/* .format-card-list is the mobile-only per-format layout (one shared
   bordered list, each format its own 2-line row -- label, then an
   icon-only action row -- separated by a thin divider rather than each
   getting its own card/border/gap) -- hidden here by default so it never
   appears on desktop; the ≤640px media query below is what actually shows
   it (and hides .format-badge-desktop-only, the flat list this replaces
   there). Hiding the list wrapper is enough to hide every .format-card
   row inside it too, so those don't need their own display:none here.
   Kept as a real, separate DOM structure per format rather than one
   structure reshaped by CSS across the breakpoint, since the two
   genuinely differ in shape: desktop's Download badge is one combined
   icon+text+size link, mobile's is a plain text label plus a separate
   icon-only button -- no single markup serves both cleanly. */
.format-card-list{display:none;}
/* .format-badge-brief-cover mirrors .format-card exactly (mobile-only
   copy of the Book Brief button, positioned beneath the cover there) --
   hidden by default so desktop only ever shows the one already living
   inside .detail-formats (.format-badge-brief-desktop-only). */
.format-badge-brief-cover{display:none;}
.detail-synopsis{font-size:12px;line-height:1.5;color:var(--text-secondary);margin:0;}
/* .detail-rating/.star-btn removed -- the interactive star-rating control
   was deleted from _browse_detail.html on request, and that partial was
   the only consumer of these two classes (confirmed via a template-wide
   search before removing). Shelves' own results list still shows a
   book's rating (Shelves being a separate, unrelated concern from this
   detail-pane redesign), but as plain '*' * n text, not through these
   classes -- so the underlying rating data/routes are untouched, only
   this dead CSS is gone. */

.reader-body{margin:0;padding:0;height:100dvh;overflow:hidden;display:flex;flex-direction:column;background:var(--surface-2);}
/* position:relative + z-index:5 is load-bearing, not decorative -- without
   it this bar (an unpositioned, z-index:auto element) paints BELOW
   #reader-tap-middle's position:fixed;z-index:3 strip, which covers the
   same full-width top-50px band. Reported directly on tablets (Boox,
   iPad): the back button stopped navigating away and instead toggled
   fullscreen -- the tap was landing on tap-middle's chrome-toggle
   handler, never reaching the <a> underneath it. The comment beside
   .reader-tap-middle already documented this exact intent (header/footer
   bars sit above it); this rule just never actually carried it out. Not
   reproduced on phones during earlier testing -- plausible that the
   narrower mobile topbar padding (see the @media override below) happens
   to land the back button below the 50px band there, while the wider/
   taller tablet layout does not; z-index is the correct, geometry-
   independent fix either way, not a pixel-chasing one. */
/* pointer-events:none on the bar itself, re-enabled only on its real
   controls below -- reported directly, immediately after the z-index fix
   above shipped: raising the topbar above #reader-tap-middle fixed the
   back button, but it also meant the topbar's own background (the title/
   author text area in particular -- exactly "the upper-middle section" a
   reader would tap to toggle fullscreen) now caught every tap in that
   band itself, on every device, with nothing there to forward it to
   tap-middle underneath. Before the z-index fix this accidentally worked,
   since tap-middle painted on top of an unpositioned topbar and caught
   those taps directly. pointer-events:none makes the bar (and any
   non-interactive child that doesn't set its own value, e.g.
   .reader-title-wrap/.reader-topbar-spacer -- pointer-events is
   inherited) transparent to hit-testing, so the browser keeps searching
   and finds tap-middle exactly as before z-index:5 was added; .reader-back
   and .reader-topbar-icon-btn set their own pointer-events:auto to stay
   genuinely clickable regardless. */
.reader-topbar{position:relative;z-index:5;pointer-events:none;display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--border);flex-shrink:0;max-height:100px;overflow:hidden;transition:max-height 0.2s ease,padding 0.2s ease,opacity 0.15s ease,border-color 0.2s ease;}
.reader-back{pointer-events:auto;display:flex;align-items:center;justify-content:center;width:32px;height:32px;flex-shrink:0;border-radius:var(--radius);color:var(--text-secondary);text-decoration:none;font-size:18px;}
.reader-back:hover{background:var(--surface-1);color:var(--text-primary);}
.reader-title-wrap{flex:1;min-width:0;}
.reader-title{font-size:14px;font-weight:500;margin:0;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.reader-author{font-size:12px;color:var(--text-secondary);margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.reader-topbar-spacer{width:32px;flex-shrink:0;}
.reader-toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,12px);background:var(--text-primary);color:var(--surface-2);padding:8px 16px;border-radius:999px;font-size:13px;font-weight:500;opacity:0;pointer-events:none;transition:opacity 0.2s ease,transform 0.2s ease;z-index:200;}
.reader-toast.visible{opacity:1;transform:translate(-50%,0);}
/* Kindle-style bookmark ribbon -- deliberately NOT inside .reader-topbar,
   so it stays visible through the chrome auto-hide transition (reported
   directly, matching a real screenshot). z-index above the tap zones (2)
   so a tap on its own small area hits the ribbon, not the page-turn zone
   underneath it. Outline by default; .active fills it solid the same
   color as the ribbon's own stroke, matching Kindle's own on-state. */
.reader-bookmark-ribbon{position:fixed;top:0;right:20px;width:36px;height:44px;display:flex;align-items:flex-start;justify-content:center;padding-top:8px;border:none;border-radius:0 0 4px 4px;background:var(--surface-2);color:var(--text-secondary);box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:25;}
.reader-bookmark-ribbon.active{color:#2563eb;}
.reader-bookmark-ribbon.active svg{fill:currentColor;}
.reader-bookmark-ribbon[disabled]{opacity:0.6;}

/* "Ember" busy indicator -- see reader.html's own comment for why this
   exists separately from the controls-bar button's .busy pulse (that one
   is invisible once chrome auto-hides). Sits just left of the bookmark
   ribbon's own tab; position:fixed and a sibling of the collapsing chrome
   containers, same survives-fullscreen technique as the ribbon itself.
   Hidden by default -- reader.js toggles .visible in lockstep with the
   button's own .busy class. */
.reader-translate-busy-dot{position:fixed;top:6px;right:64px;width:10px;height:10px;border-radius:50%;background:#f97316;box-shadow:0 0 6px rgba(249,115,22,0.7);z-index:25;pointer-events:none;display:none;}
.reader-translate-busy-dot.visible{display:block;animation:reader-ember-pulse 1s ease-in-out infinite;}
@keyframes reader-ember-pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.35;transform:scale(0.75);}}
@media (prefers-reduced-motion:reduce){
  .reader-translate-busy-dot.visible{animation:none;opacity:1;transform:none;}
}

/* position:fixed and centered near the bottom, independent of
   .reader-controls-epub's own chrome-hidden collapse -- playback controls
   need to stay reachable during immersive (chrome-hidden) reading, same
   reasoning as the bookmark ribbon above. Sits just above the controls bar
   rather than overlapping it. */
.reader-tts-bar{position:fixed;left:50%;bottom:72px;transform:translateX(-50%);display:flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--border);border-radius:999px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:6px 10px;z-index:26;max-width:92vw;}
.reader-tts-bar[hidden]{display:none;}
.reader-tts-bar button{padding:6px 10px;font-size:13px;border-radius:999px;background:transparent;color:var(--text-primary);border:none;min-width:32px;}
.reader-tts-bar button:hover{background:var(--surface-1);}
.reader-tts-bar #reader-tts-play{font-size:16px;}
.reader-tts-status{font-size:11px;color:var(--text-secondary);white-space:nowrap;padding:0 4px;max-width:120px;overflow:hidden;text-overflow:ellipsis;}

.reader-viewport{position:relative;flex:1;min-height:0;overflow:hidden;background:var(--surface-0);}

/* touch-action:none -- these overlay divs sit above .reader-page-stage and
   are what actually receive touches, so the browser's own gesture
   recognition needs disabling here too, not just on the stage underneath,
   or it fights the custom pinch/pan/swipe handling in reader.js. Scoped
   off via .reader-continuous-mode (toggled on #reader-viewport alongside
   the existing continuous-scroll toggle) since continuous mode has no
   custom gesture handling and these same zones need to allow normal
   vertical scrolling there instead. */
/* These zones have no background/border of their own -- the only thing that
   ever made them visible was the browser's own default tap-feedback (a grey/
   blue flash box on Android Chrome and older iOS Safari, sized exactly to
   the tapped element) plus iOS's press-and-hold callout menu. Both are
   opt-out, not opt-in, so they show up on a real phone even though nothing
   here ever draws them. -webkit-tap-highlight-color must be set on the
   actual tapped element, not just an ancestor, to suppress it reliably. */
/* Tap zones for page navigation (left/right) and chrome toggle (middle).
   Left/right are absolutely positioned inside the viewport (30% each side,
   full height). Middle is now positioned at the TOP of the screen as a fixed
   zone (moved out of viewport, positioned above it, to reduce interference
   with text selection in the center). */
.reader-tap-zone{position:absolute;top:0;bottom:0;width:30%;z-index:2;cursor:pointer;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;user-select:none;}
.reader-viewport:not(.reader-continuous-mode) .reader-tap-zone{touch-action:none;}
.reader-viewport.reader-continuous-mode .reader-tap-zone{touch-action:pan-y;}
.reader-tap-left{left:0;}
.reader-tap-right{right:0;}
/* Middle zone: positioned at TOP of screen for chrome toggle (show/hide
   header/footer). Full width, shallow height, fixed positioning so it stays
   at the top even when scrolling. z-index 5 sits above the page content and
   left/right edge zones (2) but below the header/footer bars (which are
   position:relative;z-index:5 themselves). */
.reader-tap-middle{position:fixed;left:0;right:0;width:auto;height:50px;top:0;z-index:3;cursor:pointer;}
/* This used to be pointer-events:none for reflow specifically, so a tap in
   this zone (top 50px, center 70% width -- see below) fell through to the
   real epub.js iframe underneath, letting native text selection start there
   and forwarding the reveal-chrome tap back via rendition.on('click', ...)
   in reader.js. Two independent attempts at making that iframe-forwarding
   reliable both failed completely on a real iPhone -- confirmed via a real
   on-device debug log that neither rendition.on('click') nor a listener
   attached directly to each content document ever received a tap here,
   while tapLeft/tapRight (genuine outer-document elements, never iframe-
   forwarded) logged every tap correctly. That comparison is what settled
   it: iframe-forwarding itself wasn't reaching this app's JS at all in this
   state on this device, not a bug in which specific forwarding technique
   was used. Reverted back to pointer-events:auto (the base rule above) so
   tapMiddle catches the tap directly, the same proven way tapLeft/tapRight
   already do -- reader.js's own click listener on it now handles the
   reveal-chrome toggle without any iframe involved. The one accepted cost:
   a text selection can no longer *start* inside this exact narrow band
   (top 50px, center 70% width) -- selection anywhere else on the page,
   including immediately below this band, is unaffected. */
/* Follow-up fix, reported directly from a real device: with the edges still
   at the paged viewer's own 30%/35% width (see below), long-press-to-select
   could only ever reach the narrow ~40% strip in the middle -- most of a
   real page's text runs right out to the margins, so the large majority of
   words a reader might actually try to long-press on sat under the opaque
   edge zones instead, reading as "selection doesn't work" even though the
   center zone itself was fine. Narrowed to 15% each side for reflow only --
   still a comfortably tappable edge target (~50px on this project's own
   documented real device, well above standard minimum touch-target
   guidance) for page-turning, while freeing up the rest of the page's
   actual text to be selectable. Higher specificity than both
   .reader-tap-zone's base 30% and its own 35% mobile-breakpoint override
   below, so this wins at every viewport width without needing to be
   duplicated inside that media query. The paged (PDF/comic) viewer's own
   30%/35% zones are untouched -- its zigzag tap-to-explore gesture system
   depends on that exact geometry (see reader.js), and this selector only
   ever matches reflow. */
.reader-kind-reflow .reader-tap-left,
.reader-kind-reflow .reader-tap-right{width:15%;}
.reader-kind-reflow .reader-tap-middle{left:15%;right:15%;}
/* touch-action:none in single-page mode -- custom JS handles pinch/pan/
   swipe/double-tap directly (see reader.js), and letting the browser's own
   native gesture recognition run at the same time fights it (inconsistent
   scale reads, unwanted rubber-banding). Continuous mode has no custom
   gesture handling and just wants normal vertical scrolling.
   overflow:hidden, not auto: all panning of a zoomed page is done via CSS
   transform in JS -- overflow:auto on a flex-centered child that overflows
   its container additionally gives the *browser* its own native scroll
   position for that overflow, which competes with the transform and was
   very likely why a zoomed page-turn didn't reliably land where the JS
   told it to (two independent position mechanisms stacking/fighting). */
.reader-page-stage{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;overflow:hidden;touch-action:none;}
.reader-pdf-native{position:absolute;inset:0;width:100%;height:100%;border:0;}
.reader-page-img,.reader-page-canvas{max-width:100%;max-height:100%;object-fit:contain;display:block;}
/* No legitimate use for the OS's native image-save/callout UI in this
   reader, and it would otherwise race the full-page-translate long-press
   timer (reader.js) on a real touch device -- .reader-tap-zone already
   suppresses this for its own area (line 379 above); this covers the
   bulk of the page underneath it, where the long-press actually fires. */
.reader-page-img,.reader-page-frame{-webkit-touch-callout:none;user-select:none;-webkit-user-drag:none;}
.reader-page-stage.reader-continuous{display:block;overflow-y:auto;overflow-x:hidden;touch-action:pan-y;}
.reader-page-stage.reader-continuous .reader-page-wrap{display:flex;justify-content:center;padding:4px 0;min-height:40px;}
.reader-page-stage.reader-continuous .reader-page-img,
.reader-page-stage.reader-continuous .reader-page-canvas{max-width:100%;height:auto;}

/* Comic Translation Phase 1 -- see COMIC_TRANSLATION_PLAN.md. Wraps the
   comic <img> so a sibling SVG bubble overlay can share the exact same
   rendered box: .reader-page-frame is the new pan/zoom transform target
   (moved off the bare <img> in reader.js's clampAndApply()).
   Deliberately sized as an exact copy of .reader-page-stage's own box
   (position:absolute;inset:0, same flex centering) rather than shrink-
   wrapped to the image's rendered size -- a shrink-wrapped frame only has
   a *max-height*, never an explicit height, and CSS only resolves a
   percentage height on a child (the img's own height:100%, tried first)
   against an ancestor whose height is explicitly specified, not merely
   clamped by max-height; confirmed live, this silently fell back to
   height:auto and let the image render at its full natural-ratio height,
   ignoring the stage's actual height entirely. Making the frame itself
   stage-sized sidesteps that: .reader-page-img keeps its original,
   already-correct max-width/max-height:100%+object-fit:contain sizing
   rule (now just resolving one level deeper, still against a genuinely
   definite box), and the SVG overlay -- also sized to fill that same
   full box -- does its OWN independent "fit and center" letterboxing via
   its viewBox + the default preserveAspectRatio:xMidYMid meet, which is
   mathematically the same contain-and-center algorithm as object-fit --
   so both elements, sharing the same source aspect ratio and the same
   outer box, letterbox identically and stay pixel-aligned with no
   coordinate math needed. Only built in single-page comic mode (see
   reader.js); PDF/two-page/continuous modes never get a
   .reader-page-frame at all, so .reader-page-img/-canvas above are
   otherwise unaffected. */
.reader-page-frame{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;touch-action:none;}
.reader-bubble-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;touch-action:none;}

/* Freehand ink layer -- same shape/reasoning as .reader-bubble-overlay
   immediately above (position:absolute;inset:0 alone is not sufficient to
   stretch an SVG root to fill .reader-page-frame's flex box -- explicit
   width/height:100% is required, matching that already-proven rule
   exactly). This rule was missing entirely for one real testing round (the
   ink SVG existed with correct viewBox/paths/attributes the whole time --
   confirmed via a real device's on-screen debug log -- but had no CSS at
   all, so it rendered as an unstyled, unpositioned flex item disconnected
   from the coordinate space reader.js's frame.getBoundingClientRect()-based
   stroke math assumes, rather than overlaying the page). */
.reader-ink-layer{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;touch-action:none;}

/* Individual bubble shapes are the only pointer-events:auto part of the
   overlay -- everywhere else on the page still falls straight through to
   the existing tap-zone/pinch/pan/swipe handling exactly as before this
   phase. Bubbles sitting under the left/right 30-35%-wide tap zones (which
   stack above .reader-page-stage via z-index:2) are unreachable by touch
   until a future phase adds an edge-zone-aware handler, the same gap this
   project already accepted -- and later closed with a parallel handler --
   for the reflow viewer's own long-press-to-select gesture. */
.reader-bubble-region{pointer-events:auto;cursor:pointer;}
/* vector-effect:non-scaling-stroke on every stroked shape below -- an SVG
   stroke-width is otherwise a *viewBox-unit* size like anything else in
   this SVG, so it would render at a fraction of a CSS pixel on a real,
   high-resolution comic scan (confirmed live alongside the same-cause
   text-size bug below) and would additionally thicken/thin with pinch-
   zoom. non-scaling-stroke pins it to a real, constant on-screen width
   regardless of the page's native resolution or the current zoom level --
   the standard SVG fix for exactly this, not something reader.js needs to
   compute per page the way font-size below does. */
.reader-bubble-region .reader-bubble-hit{fill:transparent;stroke:none;vector-effect:non-scaling-stroke;}
.reader-bubble-region:focus-visible .reader-bubble-hit{stroke:var(--text-primary);stroke-width:2;}
@media (hover:hover) and (pointer:fine){
  /* CSS :hover, gated to real hover-capable pointers, instead of a JS
     'hover' state -- avoids the classic touch-device "sticky hover after
     tap" problem for free and needs no extra bookkeeping. */
  .reader-bubble-region:hover .reader-bubble-hit{stroke:var(--text-primary);stroke-width:2;fill:rgba(127,127,127,0.08);}
}
.reader-bubble-mask,.reader-bubble-spinner,.reader-bubble-text{display:none;}
/* Reported directly: a small corner spinner alone was easy to miss,
   especially once background prefetch contention (§22) made a real
   translate take many seconds -- the whole bubble now gets a faint tint
   the instant a tap/long-press registers, so "something is happening
   here" reads at a glance rather than requiring the reader to spot a
   16px icon. Distinct opacity from the solid state-translated mask below
   it fades into once the real result lands, rather than a jarring swap. */
.reader-bubble-region.state-loading .reader-bubble-mask{display:block;fill:var(--surface-0);fill-opacity:0.4;stroke:var(--border);stroke-width:1;vector-effect:non-scaling-stroke;}
.reader-bubble-region.state-loading .reader-bubble-spinner{display:block;fill:none;stroke:var(--text-secondary);stroke-width:2;stroke-dasharray:10 6;vector-effect:non-scaling-stroke;transform-box:fill-box;transform-origin:center;animation:reader-spin 0.8s linear infinite;}
@media (prefers-reduced-motion:reduce){
  .reader-bubble-region.state-loading .reader-bubble-spinner{animation:none;}
}
.reader-bubble-region.state-translated .reader-bubble-mask{display:block;fill:var(--surface-0);stroke:var(--border);stroke-width:1;vector-effect:non-scaling-stroke;}
.reader-bubble-region.state-translated .reader-bubble-text{display:block;}
/* font-size/padding are NOT set here -- a fixed CSS value would suffer the
   same tiny-text bug this comment is next to (this SVG's viewBox maps a
   real page's native resolution down to a much smaller displayed box, and
   that scale-down applies to a foreignObject's CSS content too). Both are
   set inline per page by reader.js's buildBubbleRegion(), scaled from the
   image's actual native-px-per-displayed-px ratio. */
.reader-bubble-text div{width:100%;height:100%;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;line-height:1.25;color:var(--text-primary);font-family:'ComicNeue','Comic Sans MS',cursive,sans-serif;font-weight:bold;overflow:hidden;}

.reader-loading{position:absolute;inset:0;z-index:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--text-secondary);font-size:13px;pointer-events:none;}
/* The [hidden] attribute's own display:none is only as specific as a class
   selector, so without this override reader-loading's own display:flex
   above wins the tie and setting .hidden=true from JS would do nothing. */
.reader-loading[hidden]{display:none;}
.reader-loading::before{content:"";width:28px;height:28px;border-radius:50%;border:3px solid var(--border);border-top-color:var(--text-secondary);animation:reader-spin 0.8s linear infinite;}
@keyframes reader-spin{to{transform:rotate(360deg);}}

/* position:relative;z-index:5 for the same reason as .reader-topbar above
   -- .reader-tap-zone (left/right) are position:absolute, constrained to
   #reader-viewport's own box, not this separate sibling bar, so this is
   defensive/for-consistency rather than a confirmed second instance of
   the topbar's actual bug, but costs nothing and matches what the
   .reader-tap-middle comment already says both bars are meant to do. */
.reader-controls{position:relative;z-index:5;display:flex;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0;flex-wrap:wrap;max-height:100px;overflow:hidden;transition:max-height 0.2s ease,padding 0.2s ease,opacity 0.15s ease,border-color 0.2s ease;}
.reader-body.chrome-hidden .reader-topbar{max-height:0;padding-top:0;padding-bottom:0;opacity:0;border-bottom-color:transparent;}
.reader-body.chrome-hidden .reader-controls{max-height:0;padding-top:0;padding-bottom:0;opacity:0;border-top-color:transparent;}
/* Reported directly with a screenshot: the glyph/text inside these buttons
   (the scroll-toggle arrows in particular) read as off-center within their
   own square frame on iPad. Root cause: this rule set a fixed width/height
   but relied on the browser's own default <button> content alignment/
   padding to center it -- which Safari/iPadOS does not do as tightly as
   Chrome for an icon glyph with unusual font vertical metrics. .reader-
   topbar-icon-btn (the TOC button) already centers correctly because it
   explicitly flexes its content; doing the same here instead of trusting
   UA defaults. */
.reader-controls button{display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;font-size:16px;line-height:1;flex-shrink:0;}
/* Same [hidden]-loses-to-a-class-selector gap already fixed once for
   .reader-loading above -- #reader-tts-main-btn starts hidden until
   GET .../tts/voices confirms tts_enabled, but the button rule right above
   this one has equal-or-higher specificity than the UA's own [hidden]
   default, so without this override the button rendered fully visible and
   tappable regardless of its hidden attribute. Reported directly as "the
   TTS button does not do anything" -- confirmed via a real-device debug log
   showing the button's own click handler firing with voicesEnabled still
   false, which is only possible if it was tappable before that ever
   resolved (or resolved negative), matching this exactly. */
.reader-controls button[hidden]{display:none;}
.reader-controls button.active{background:var(--surface-1);}
/* Reported directly, with a real screenshot: the shared .active treatment
   above (a barely-off-white background) was invisible against this bar's
   own white background on a real phone -- fine for the lower-stakes scroll/
   direction toggles, but this button's on/off state is the reader's only
   way to tell whether auto-translate is actually running, so it needs a
   real, unambiguous look. Reuses the same solid filled-button pattern
   already proven for a real selected state elsewhere in this file
   (.reader-translate-langs button.selected), not a new color invented here. */
#reader-translate-page-toggle.active{background:var(--text-primary);color:var(--surface-2);border-color:var(--text-primary);}
/* Independent of .active (on/off mode) -- pulses for as long as a
   translate_all request is actually in flight for the current page, so a
   tap/long-press that lands behind contended background work (§22) still
   reads as "working" rather than "did nothing" while it waits its turn. */
.reader-controls button.busy{animation:reader-btn-pulse 1.1s ease-in-out infinite;}
@keyframes reader-btn-pulse{0%,100%{opacity:1;}50%{opacity:0.45;}}
@media (prefers-reduced-motion:reduce){
  .reader-controls button.busy{animation:none;outline:2px solid var(--text-secondary);outline-offset:-2px;}
}
#reader-direction-toggle{width:auto;padding:0 8px;font-size:11px;font-weight:500;}
.reader-page-indicator{font-size:12px;color:var(--text-secondary);flex-shrink:0;cursor:pointer;}
/* Was a leftover, box-model-only rule from an earlier "jump to page"
   dialog that was removed outright (a real gesture conflict with the ink
   tool's own double-tap, see reader.js) -- reused for the tap-to-edit
   page indicator instead of adding a near-duplicate class, now with real
   visual styling since it's an actual visible control again, not dead
   code. font-size/color match .reader-page-indicator so swapping between
   the two doesn't visibly jump. */
.reader-page-jump{width:56px;flex-shrink:0;text-align:center;padding:2px 4px;font-size:12px;color:var(--text-primary);background:var(--surface-1);border:1px solid var(--border);border-radius:4px;font-family:inherit;}
.reader-page-jump:focus{outline:none;border-color:var(--color-primary-medium);}

@keyframes slideInUp{from{opacity:0;transform:translate(-50%, -40%);} to{opacity:1;transform:translate(-50%, -50%);}}

/* This app's first true centered modal dialog + dimming backdrop --
   generalized as a reusable pair (deliberately generic names, not
   feature-specific) rather than a one-off, since a second admin modal is
   plausible later. Same slideInUp entrance the "Jump to page" ribbon used
   to use before that feature was removed outright (see reader.js's
   initPaged for why) and .detail-backdrop below (dimming overlay). */
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:100;}
.modal{box-sizing:border-box;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface-2);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.2);z-index:101;padding:20px;width:min(420px, calc(100vw - 32px));max-height:min(480px, calc(100vh - 64px));animation:slideInUp 0.2s ease;}
/* Reuses .detail-close-btn's own circular-× recipe (that class alone is
   display:none outside its own ≤640px mobile media query, since it was
   built for the mobile detail sheet) -- this re-declares the same visual
   shape unconditionally, since a centered modal needs its close button
   visible at every viewport width, not just mobile. */
.modal-close-btn{display:flex;align-items:center;justify-content:center;position:absolute;top:8px;right:8px;z-index:1;width:28px;height:28px;border-radius:50%;background:var(--surface-1);border:0.5px solid var(--border-strong);font-size:16px;line-height:1;color:var(--text-secondary);}
.modal-scroll{max-height:360px;overflow-y:auto;}

/* Settings backup-destination folder-tree picker (_folder_tree.html) --
   an expandable tree modeled on Google Drive's own "Move to folder"
   dialog: one indented list, a rotating disclosure triangle per folder
   that has children, click-to-select rows. Nested <ul>s get their own
   left padding, so indentation is just inherited, no per-depth class
   needed. */
.folder-tree,.folder-tree ul{list-style:none;margin:0;padding:0;}
.folder-tree ul{padding-left:18px;}
.tree-row{display:flex;align-items:center;gap:4px;border-radius:var(--radius);}
.tree-row-selected{background:var(--surface-1);}
.tree-toggle{border:0;background:none;padding:0;width:18px;height:24px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--text-secondary);transition:transform 0.15s ease;}
.tree-toggle:hover{background:none;border:0;}
.tree-toggle-open{transform:rotate(90deg);}
.tree-toggle-spacer{display:inline-block;width:18px;flex-shrink:0;}
.tree-label{flex:1;border:0;background:none;text-align:left;padding:4px 4px;font-weight:400;border-radius:var(--radius);}
.tree-label:hover{background:var(--surface-1);border:0;}

.epub-container{position:absolute;inset:0;}
/* One-row epub controls toolbar. Achieved purely through sizing (smaller
   buttons on mobile, tighter gaps, smaller progress-bar floor), NOT through
   overflow-x:auto (which turned the bar into a scrollable container whose
   content scrolled sideways out of view, looking like the bar had vanished
   -- confirmed on a real device with a debug overlay printing the exact DOM
   measurements. See CLAUDE.md §7 for the full investigation). If this still
   doesn't fit on a narrow device, drop/relocate a control rather than making
   the bar scrollable. */
.reader-controls-epub{justify-content:flex-start;flex-wrap:nowrap;gap:4px;}
.reader-theme-switch{display:flex;gap:4px;}
.reader-theme-switch button{width:auto;height:30px;padding:0 8px;font-size:11px;}
.reader-theme-switch button.active{background:var(--surface-1);font-weight:500;}
.reader-font-switch{display:flex;gap:4px;flex-wrap:wrap;}
.reader-font-switch button{width:auto;height:30px;padding:0 8px;font-size:11px;}
.reader-font-switch button.active{background:var(--surface-1);font-weight:500;}
.reader-progress{display:flex;align-items:center;gap:6px;flex:1;min-width:36px;}
/* touch-action:none -- without it, a real touch-drag on the track can get
   claimed by the browser's own scroll/pan gesture recognition before this
   element's own pointermove handler (reader.js's makeProgressDraggable)
   ever sees it, the same class of gesture-hijacking this project has
   hit before with other custom drag interactions. */
/* Reported on iPad specifically (wide bar, so the effect is most obvious
   there): --surface-1 (#fafaf8) reads as flat white against this bar's own
   background -- the exact same near-invisible contrast already documented
   above for the EN button's .active state. At 0% progress (page still
   "Loading...", "-/-") the dark fill is 0-width, so the only visible thing
   was the small drag-handle circle riding the fill's own edge -- stranded
   alone at the track's far-left with no visible line connecting it to the
   indicator far to the right, reading exactly like a stray, misplaced icon
   rather than a progress bar at 0%. --border is real theme contrast used
   elsewhere for thin lines against this same background. */
.reader-progress-track{flex:1;height:6px;border-radius:999px;background:var(--border);cursor:pointer;position:relative;touch-action:none;padding:10px 0;margin:-10px 0;background-clip:content-box;}
.reader-progress-fill{height:100%;border-radius:999px;background:var(--text-primary);width:0;position:relative;}
/* Reported directly: click-to-seek worked but nothing showed *where* the
   current position was or invited a drag -- a handle riding the fill's
   own trailing edge, growing/shrinking with it for free (no separate JS
   positioning needed), same real-drag support (see makeProgressDraggable)
   now backs both this and the reflow reader's identical progress bar. */
.reader-progress-fill::after{content:'';position:absolute;right:-6px;top:50%;transform:translateY(-50%);width:12px;height:12px;border-radius:50%;background:var(--text-primary);box-shadow:0 1px 3px rgba(0,0,0,0.35);}
.reader-progress-pct{font-size:11px;color:var(--text-secondary);flex-shrink:0;width:26px;text-align:right;}

.reader-toc-panel{position:fixed;top:0;bottom:0;right:0;width:280px;max-width:80vw;background:var(--surface-2);border-left:1px solid var(--border);box-shadow:-6px 0 24px rgba(0,0,0,0.15);transform:translateX(100%);transition:transform 0.2s ease;z-index:10;display:flex;flex-direction:column;}
.reader-toc-panel.open{transform:translateX(0);}
#reader-toc-list{flex:1;overflow-y:auto;overflow-x:hidden;padding:8px 14px;}
.reader-toc-panel a{display:block;padding:8px 4px;font-size:13px;color:var(--text-primary);text-decoration:none;border-bottom:1px solid var(--border);}
.reader-toc-panel a:hover{background:var(--surface-1);}
/* TOC button moved from the bottom controls bar into the topbar (reported
   directly), sitting between the back arrow and the title -- same 32px
   icon-button treatment as .reader-back for visual consistency there. */
.reader-topbar-icon-btn{pointer-events:auto;display:flex;align-items:center;justify-content:center;width:32px;height:32px;flex-shrink:0;border:none;background:none;border-radius:var(--radius);color:var(--text-secondary);font-size:18px;margin-left:4px;}
.reader-topbar-icon-btn:hover{background:var(--surface-1);color:var(--text-primary);}

/* Per-book bookmarks list -- same slide-in mechanic as .reader-toc-panel,
   reuses .reader-chat-header/.reader-chat-title for its header row rather
   than duplicating that styling a third time. */
.reader-bookmarks-panel{position:fixed;top:0;bottom:0;right:0;width:280px;max-width:80vw;background:var(--surface-2);border-left:1px solid var(--border);box-shadow:-6px 0 24px rgba(0,0,0,0.15);transform:translateX(100%);transition:transform 0.2s ease;z-index:10;display:flex;flex-direction:column;}
.reader-bookmarks-panel.open{transform:translateX(0);}
.reader-bookmarks-list{flex:1;overflow-y:auto;overflow-x:hidden;padding:8px 14px;}
.reader-bookmark-row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border);}
.reader-bookmark-row a{flex:1;min-width:0;font-size:13px;color:var(--text-primary);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.reader-bookmark-row a:hover{text-decoration:underline;}
.reader-bookmark-row button{flex:none;font-size:11px;padding:2px 8px;}

/* Same slide-in mechanic as .reader-toc-panel, but a fixed header/input
   row around a scrollable message list instead of a flat link list. */
.reader-chat-panel{position:fixed;top:0;bottom:0;right:0;width:340px;max-width:88vw;background:var(--surface-2);border-left:1px solid var(--border);box-shadow:-6px 0 24px rgba(0,0,0,0.15);transform:translateX(100%);transition:transform 0.2s ease;z-index:10;display:flex;flex-direction:column;}
.reader-chat-panel.open{transform:translateX(0);}
.reader-chat-header{display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid var(--border);flex:none;}
.reader-chat-title{flex:1;font-size:13px;font-weight:500;margin:0;}
.reader-chat-header button{width:28px;height:28px;flex:none;font-size:16px;line-height:1;}
.reader-chat-messages{flex:1;overflow-y:auto;overflow-x:hidden;padding:14px;display:flex;flex-direction:column;gap:10px;}
.reader-chat-empty{margin:0;}
.reader-chat-message{max-width:88%;padding:8px 10px;border-radius:10px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;}
.reader-chat-message-user{align-self:flex-end;background:var(--text-primary);color:var(--surface-2);border-bottom-right-radius:2px;}
.reader-chat-message-assistant{align-self:flex-start;background:var(--surface-1);color:var(--text-primary);border-bottom-left-radius:2px;}
.reader-chat-message-error{align-self:stretch;background:transparent;color:var(--text-secondary);font-size:12px;text-align:center;}
.reader-chat-citations{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.reader-chat-citation{font-size:11px;padding:2px 7px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-secondary);cursor:pointer;}
.reader-chat-citation:hover{background:var(--surface-1);}
.reader-chat-citation[disabled]{cursor:default;opacity:0.5;}
.reader-chat-typing{align-self:flex-start;font-size:12px;color:var(--text-secondary);padding:0 2px;}
.reader-chat-input-row{display:flex;align-items:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--border);flex:none;}
.reader-chat-input{flex:1;resize:none;max-height:96px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface-1);color:var(--text-primary);font-size:13px;font-family:inherit;}
.reader-chat-input:focus{outline:none;border-color:var(--text-primary);}
.reader-chat-input-row button{width:auto;height:auto;flex:none;padding:6px;display:flex;align-items:center;justify-content:center;}
.reader-chat-input-row button[disabled]{opacity:0.4;}

/* Selection toolbar (Phase D) -- positioned via inline left/top set in JS
   from the selection's own bounding rect, not laid out in normal flow.
   z-index above the tap zones (2) and the toc/chat panels (10) so it's
   never hidden behind either. */
.reader-selection-toolbar{position:fixed;display:flex;gap:2px;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:3px;z-index:20;}
.reader-selection-toolbar[hidden]{display:none;}
.reader-selection-toolbar button{padding:6px 10px;font-size:12px;border-radius:6px;background:transparent;color:var(--text-primary);white-space:nowrap;}
.reader-selection-toolbar button:hover{background:var(--surface-1);}

.reader-chat-explain-label{font-size:11px;color:var(--text-secondary);font-weight:600;margin:0 0 4px;}

/* Define's own popup -- a small, self-contained card near the selection,
   not the shared chat panel (see reader.js's runDefine). Positioned via
   inline left/top exactly like the selection toolbar; z-index above the
   toolbar (20) and handles (25) since Define's own click already hides
   both before this shows. */
.reader-define-popup{position:fixed;width:240px;max-width:80vw;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:8px 10px;z-index:30;}
.reader-define-popup[hidden]{display:none;}
.reader-define-header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px;}
.reader-define-header strong{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.reader-define-header button{width:22px;height:22px;flex:none;font-size:15px;line-height:1;padding:0;background:transparent;color:var(--text-secondary);}
.reader-define-header button:hover{background:var(--surface-1);}
.reader-define-body{font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;color:var(--text-primary);}
/* Translate's language popup -- same position/size/z-index recipe as
   Define's popup just above (deliberately identical, not just similar). */
.reader-translate-popup{position:fixed;width:240px;max-width:80vw;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:8px 10px;z-index:30;}
.reader-translate-popup[hidden]{display:none;}
.reader-translate-langs{display:flex;flex-wrap:wrap;gap:4px;}
.reader-translate-langs button{padding:5px 10px;font-size:12px;border-radius:6px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-primary);}
.reader-translate-langs button:hover{background:var(--surface-1);}
/* Multi-select: tapping a language toggles this instead of firing
   immediately -- same "filled = active" idiom as the theme switch's
   own .active state, just a different class name since multiple can be
   selected at once here. */
.reader-translate-langs button.selected{background:var(--text-primary);color:var(--surface-2);border-color:var(--text-primary);}
.reader-translate-go{width:100%;margin-top:8px;padding:8px;font-size:13px;font-weight:500;}
.reader-translate-go[disabled]{opacity:0.5;}
.reader-translate-results{font-size:13px;line-height:1.45;}
.reader-translate-result{margin-bottom:10px;}
.reader-translate-result:last-child{margin-bottom:0;}
.reader-translate-result-lang{font-size:11px;color:var(--text-secondary);font-weight:600;margin:0 0 2px;}
.reader-translate-result-text{margin:0;white-space:pre-wrap;word-break:break-word;}

/* Preferences popup -- same card recipe as Define/Translate above, holding
   just the theme switch for now (moved out of the main toolbar row). */
.reader-prefs-popup{position:fixed;width:220px;max-width:80vw;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:8px 10px;z-index:30;top:52px;right:14px;}
.reader-prefs-popup[hidden]{display:none;}

/* Ink settings popup -- same card recipe/positioning as .reader-prefs-popup
   above (a static top-right corner card, not positioned relative to its
   trigger button -- matches that popup's own precedent even though its own
   button lives in the bottom controls bar too, since neither popup needs
   to track a text-selection position the way Define/Translate's do). */
.reader-ink-popup{position:fixed;width:220px;max-width:80vw;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:8px 10px;z-index:30;top:52px;right:14px;}
.reader-ink-popup[hidden]{display:none;}
/* No title text in this popup's header (see reader.html's own comment) --
   .reader-define-header's shared justify-content:space-between would
   otherwise place a single remaining child (the close button) at the
   start, not the end. */
.reader-ink-popup-header{justify-content:flex-end;margin-bottom:2px;}
/* margin-top replaces the spacing the removed "Color" label's own margin
   used to provide, so this row doesn't crowd the close button above it. */
.reader-ink-colors{display:flex;gap:8px;margin-top:6px;}
.reader-ink-colors button{width:26px;height:26px;border-radius:50%;padding:0;border:2px solid transparent;}
.reader-ink-colors button.active{border-color:var(--text-primary);}
/* Same compact icon-button recipe as .reader-ink-actions below -- a dot
   whose own radius scales with the option, no text label needed.
   margin-top matches .reader-ink-actions' own -- reported directly that
   the width and actions rows read as touching once the "Width" label
   (which used to carry this same 10px via its own margin) was removed;
   this keeps all three rows (color/width/actions) evenly spaced now that
   none of them have a text label doing that job implicitly. */
.reader-ink-widths{display:flex;gap:4px;margin-top:10px;}
.reader-ink-widths button{display:flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;}
.reader-ink-widths button.active{background:var(--surface-1);}
/* Requested directly: three stacked full-width text buttons (Eraser/Undo/
   Clear page) made this popup taller than it needed to be -- a single
   compact row of small icon buttons instead, same sizing recipe as this
   file's other small icon-button rows (e.g. .reader-controls button). */
.reader-ink-actions{display:flex;gap:6px;margin-top:10px;}
.reader-ink-actions button{display:flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;}
.reader-ink-actions button.active{background:var(--surface-1);}

.metric-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px;margin-bottom:6px;align-items:stretch;}
/* display:flex + justify-content:space-between -- reported directly, with
   real screenshots: a card whose label wraps to two lines ("Missing
   description", "No readable format") pushed its own value down further
   than a sibling in the same grid row whose label stays on one line
   ("Missing author"), so the big numbers didn't line up across a row even
   though the cards themselves were the same height (grid's own default
   align-items:stretch already equalizes card height within a row -- the
   mismatch was purely each card's internal label/value position, not the
   card heights). Flex-column pins the label to the top and the value to
   the bottom regardless of how many lines the label wraps to, so the
   value row reads flush across every card in a row. */
.metric-card{background:var(--surface-1);border-radius:var(--radius);padding:1rem;box-sizing:border-box;display:flex;flex-direction:column;justify-content:space-between;}
.metric-card.metric-success{background:var(--bg-success);}
.metric-card.metric-success .metric-value,.metric-card.metric-success .metric-label{color:var(--text-success);}
.metric-card.metric-warning{background:var(--bg-warning);}
.metric-card.metric-warning .metric-value,.metric-card.metric-warning .metric-label{color:var(--text-warning);}
.metric-card.metric-error{background:var(--bg-error);}
.metric-card.metric-error .metric-value,.metric-card.metric-error .metric-label{color:var(--text-error);}
.metric-label{font-size:13px;color:var(--text-secondary);margin:0 0 4px;}
.metric-value{font-size:24px;font-weight:500;margin:0;}
/* The "Online now" card is a real <button> (keyboard focus/Enter/Space and
   the app's existing global :focus-visible ring, for free) -- this strips
   the parts of the global button{}/button:hover{} base rules that would
   otherwise fight .metric-card.metric-success's own green background/
   border, and gives a subtle hover darken instead of a second hardcoded
   green. */
/* display:flex here, not block -- .metric-card's own flex-column/
   justify-content:space-between (above) needs to survive on the <a>/
   <button> variants this class is applied to, and since both rules are
   single-class selectors of equal specificity, this one (later in the
   stylesheet) would otherwise win and silently flatten the layout back to
   block on every clickable card. */
.metric-card-clickable{cursor:pointer;text-align:left;font:inherit;width:100%;display:flex;flex-direction:column;justify-content:space-between;text-decoration:none;color:inherit;}
.metric-card-clickable:hover{filter:brightness(0.97);}

/* Online-users modal rows -- flat/minimal like the Settings audit log's own
   rows, not a decorated per-user card (decoration is reserved for
   prominent CTAs, per this app's design-system rebrand). .lib-status-dot/
   .badge are reused verbatim from elsewhere in this file. */
.online-user-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:0.5px solid var(--border);font-size:12px;}
.online-user-row:last-child{border-bottom:none;}
.online-user-name{font-weight:500;}
.online-user-lastseen{margin-left:auto;white-space:nowrap;}

.charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:1.5rem;}
.charts-grid > div{min-width:0;}
.chart-box{position:relative;height:210px;}
.chart-with-frozen-axis{display:flex;}
.chart-axis-frozen{width:46px;flex-shrink:0;position:relative;}
.chart-scroll{flex:1;min-width:0;overflow-x:auto;overflow-y:hidden;position:relative;overscroll-behavior:contain;}
.chart-scroll-inner{position:relative;height:100%;}
/* Dashboard redesign: the Media Type Distribution donut is a 3rd chart
   sharing this grid -- auto-fit/minmax (same reasoning as .browse-grid's
   own auto-fill columns) instead of a fixed 3-column split, so it
   reflows to 2 or 1 columns as space shrinks without a hand-picked extra
   breakpoint. The plain .charts-grid rule above still applies too (both
   classes are present together), including its own mobile override
   further down this file. */
.charts-grid-3{grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));}

/* Ask BookWyrm + Reading Topics side by side -- reported directly, wanted
   as a 2-column pair rather than each stacked full-width (originally built
   for Library DNA in this same slot, since replaced by BookWyrm). Same
   auto-fit reasoning as .charts-grid-3 just above: reflows to 1 column on a
   narrow viewport with no separate mobile override needed, and degrades
   gracefully to a single, full-width column if only one of the two is
   actually present (e.g. no tags yet). The floor was originally 320px --
   confirmed via a real harness at this project's own documented real-device
   width (338 CSS px) that 320px exceeds the actual available content width
   (338 - 2*16px body padding = 306px) on a narrow phone, forcing the single
   auto-fit column (and the canvas inside it, sized to 100% of that column)
   14px wider than its own container and into the page's right padding --
   exactly the "word bleeding into the margin" report. Lowered to 260px,
   matching .charts-grid-3's already-proven-safe floor just above. */
.dashboard-side-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:16px;align-items:center;margin-bottom:1.5rem;}
.dashboard-side-grid > div{min-width:0;}

/* Ask BookWyrm chat panel -- shared shape between the reader Dashboard
   panel and the admin Settings docs-only panel (app/static/bookwyrm.js's
   initBookWyrmChat() binds to these same class names in either page). A
   normal in-flow flex column, not position:fixed like .reader-chat-panel
   (§16's in-book chat panel, the direct structural template this mirrors) --
   BookWyrm fills a grid cell instead of sliding in over the page. A light
   border (not the heavier shadowed .card treatment) marks it as a distinct
   interactive region -- Library DNA's own plain-prose panel had its .card
   framing removed on request, but a chat surface with real input controls
   reads as incomplete with no boundary at all. */
.bookwyrm-panel{display:flex;flex-direction:column;height:480px;border:2px solid var(--border-strong);border-radius:var(--radius);overflow:hidden;background:var(--surface-2);}
/* Wraps .bookwyrm-panel so the mascot (below) has something to anchor
   against that ISN'T subject to the panel's own overflow:hidden -- that
   rule exists to clip the panel's normal flex children to its rounded
   corners and stays untouched; the mascot is a sibling one level up
   instead, free to poke above the panel's own top edge. */
/* margin-top deliberately reserves headroom for the mascot below, rather
   than relying on whatever incidental whitespace happens to precede this
   panel in the page flow -- a first version did exactly that (measured the
   real gap above .dashboard-side-grid and sized the mascot to fit inside
   it) and it worked, but capped the mascot to a tiny sliver since that
   incidental gap was only ~20-24px. Reported back directly as not matching
   a supplied reference image closely enough -- reserving real space here
   instead decouples the mascot's size from whatever content happens to
   sit above the panel on a given page. */
.bookwyrm-panel-wrap{position:relative;margin-top:68px;}
/* The circle-free character art (app/static/bookwyrm-character.png) is a
   BUST crop, not the full head-to-books illustration a first version used
   -- cropped at the elbow bend (~65% down the full character's own
   height), deliberately excluding the open book and coiled body below it,
   so the panel's own top border reads as the surface the elbow is resting
   on, rather than competing with a drawn book in the same spot. Isolated
   via alpha-channel connected-component analysis (zero pixels from the
   source file's "Ask"/wordmark text or the circular badge's ring), with
   the crop line landing mid-stroke through the resting arm/elbow linework
   -- confirmed directly (a real pixel scan) that real artwork content runs
   all the way to the image's very last row, so translateY(-100%) on the
   row below lands those two cut line-endpoints exactly on the border, not
   a tuned approximation.

   The image and the title are now flex SIBLINGS in one row
   (.bookwyrm-mascot-row), not the image alone with the title left behind
   in the card's own header -- requested directly, "the text should be
   vertically center aligned with the image of the character," which a
   lone absolutely-positioned image can't satisfy against text sitting in
   a completely different flow context. align-items:center on the row is
   what actually centers them against each other; translateY(-100%) is
   still what lifts the whole row (image + text together) so the image's
   own bottom edge -- not the row's, which is taller once the title is
   folded in -- lands on the border, matching the already-approved
   position exactly. The old .bookwyrm-header this used to live inside is
   gone outright, not left as an empty div -- once the title moved out, it
   had no remaining content to hold. */
.bookwyrm-mascot-row{position:absolute;left:16px;top:0;transform:translateY(-100%);display:flex;align-items:center;gap:12px;z-index:2;}
.bookwyrm-character{width:76px;height:auto;flex:none;pointer-events:none;filter:drop-shadow(0 3px 4px rgba(0,0,0,0.18));}
.bookwyrm-header-title{font-size:17px;font-weight:600;margin:0;white-space:nowrap;}
.bookwyrm-messages{flex:1;overflow-y:auto;overflow-x:hidden;padding:14px;display:flex;flex-direction:column;gap:10px;}
.bookwyrm-empty{margin:0;}
.bookwyrm-message{max-width:92%;padding:8px 10px;border-radius:10px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;}
.bookwyrm-message-user{align-self:flex-end;background-color:var(--color-primary-dark);background-image:var(--btn-primary-gradient);color:#fff;border-bottom-right-radius:2px;}
.bookwyrm-message-assistant{align-self:flex-start;background:var(--surface-1);color:var(--text-primary);border-bottom-left-radius:2px;}
.bookwyrm-message-error{align-self:stretch;background:transparent;color:var(--text-secondary);font-size:12px;text-align:center;}
.bookwyrm-citations{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.bookwyrm-citation{font-size:11px;padding:2px 7px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-secondary);}
.bookwyrm-books-intro{margin:0 0 6px;}
.bookwyrm-book-list{margin:0 0 10px;padding-left:1.3em;}
.bookwyrm-book-list li{margin-bottom:4px;}
.bookwyrm-open-browse-btn{display:inline-block;padding:6px 14px;border-radius:999px;background:var(--color-primary-medium);color:#fff;font-size:12px;font-weight:600;text-decoration:none;}
.bookwyrm-open-browse-btn:hover{background:var(--color-primary-dark);color:#fff;text-decoration:none;}
.bookwyrm-typing{align-self:flex-start;font-size:12px;color:var(--text-secondary);padding:0 2px;}
.bookwyrm-input-row{display:flex;align-items:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--border);flex:none;}
.bookwyrm-input{flex:1;resize:none;height:40px;max-height:96px;box-sizing:border-box;padding:8px 10px;border:2px solid var(--border-strong);border-radius:8px;background:var(--surface-1);color:var(--text-primary);font-size:13px;font-family:inherit;}
.bookwyrm-input:focus{outline:none;border-color:var(--text-primary);}
.bookwyrm-input:disabled{opacity:0.6;}
/* A circular send button carrying a small crop of the same character art as
   the mascot above the panel, rather than a generic send-arrow glyph -- the
   circle's diameter is pinned to .bookwyrm-input's own single-line height
   (40px, border included via box-sizing) so the two sit flush in the row
   regardless of how tall the textarea grows past that as the reader types a
   longer question. Its own 2px border matches .bookwyrm-input's exactly, so
   the input box and the send icon read as one consistent stroke weight. */
.bookwyrm-send{width:40px;height:40px;box-sizing:border-box;flex:none;padding:0;border:2px solid var(--border-strong);border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;background:var(--surface-1);}
.bookwyrm-send img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;}
.bookwyrm-send[disabled]{opacity:0.4;}

/* Reading Topics tag cloud -- wordcloud2.js draws onto #tagCloudCanvas
   (packed/rotated layout, real collision-avoiding placement -- something
   plain CSS flex-wrap can't do); width:100% + the JS side reading
   wrap.clientWidth is what makes the canvas's own drawing-buffer size
   track the actual available width. .tag-cloud/.tag-cloud-item below are
   the <noscript> plain-link fallback only (kept for JS-disabled
   browsers), not the canvas's own styling -- canvas text is drawn
   directly by the library, not styled via CSS. */
.tag-cloud-wrap{width:100%;}
.tag-cloud-wrap canvas{width:100%;max-width:100%;}
.tag-cloud{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:baseline;}
.tag-cloud-item{color:var(--text-primary);text-decoration:none;font-weight:500;line-height:1.3;}
.tag-cloud-item:hover{text-decoration:underline;}

.needs-attention{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;}
.needs-row{display:flex;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--border);font-size:13px;}
.needs-row:last-child{border-bottom:none;}
.needs-row .icon{margin-right:6px;color:var(--text-muted);vertical-align:-3px;}

/* Collection health & operations' per-library rows (Sync/Quality/AI) --
   same bordered-list shape as .needs-attention/.needs-row above, but with
   a third (action button) slot and flex-wrap so name/meta/button reflow
   onto their own lines on a narrow viewport instead of overlapping --
   these rows carry more content than a plain needs-row. */
.health-ops-list{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:4px;}
.health-op-row{display:flex;align-items:center;flex-wrap:wrap;gap:4px 12px;padding:10px 12px;border-bottom:1px solid var(--border);font-size:12px;}
.health-op-row:last-child{border-bottom:none;}
.health-op-row-name{font-weight:500;flex:1 1 140px;min-width:0;}
.health-op-row-meta{color:var(--text-secondary);flex:2 1 220px;min-width:0;}
.health-op-row-action{margin-left:auto;font-size:11px;padding:3px 10px;flex:0 0 auto;}

@media (max-width: 640px) {
  /* overflow-x:hidden is a page-level safety net: if any descendant ever
     spills a sub-pixel past the viewport (high-DPR rounding, a wide cover,
     etc.) it can't shift the whole layout rightward and eat the right
     margin -- a failure mode that's invisible on a desktop test but real
     on a 3x-DPR phone. */
  body{padding:1rem;overflow-x:hidden;}
  /* .sort-control's margin-left used to be reset to 0 here, back when
     the switcher fix below didn't exist and its "right edge" had nothing
     meaningful above it to line up with once the toolbar wrapped. Now
     that the switcher claims its own full-width line (see .lib-switcher
     below), sort-control sharing the next line with view-toggle again
     has a real right edge to align to -- so the desktop rule's own
     margin-left:auto (push to this line's right edge) is exactly what's
     wanted here too, lining sort-control up with the switcher's right
     edge above it. No override needed any more; removed rather than
     reset to auto explicitly, since that's just the base rule's default. */
  /* .wrap keeps its desktop padding (1.5rem = 24px each side) unless
     overridden here. Combined with body's own 1rem padding that was
     80px of pure padding eaten out of a ~360-390px phone screen before
     any content started -- razor-thin margins in the toolbar row and
     table were a direct consequence, not just an input-shrink bug. */
  .wrap{padding:0.75rem;}
  /* body's own padding drops to 1rem top+bottom here (2rem total, not 4rem)
     -- .wrap-app's desktop min-height would otherwise overshoot by 2rem on
     mobile, matching the reasoning right above for .wrap's own padding. */
  .wrap-app{min-height:calc(100vh - 2rem);}
  /* iOS Safari's overflow:auto scrollbar is always an overlay that takes
     no layout space; Android Chrome commonly reserves a real gutter for
     it, which shrinks the scrollable box's content width on Android only
     -- exactly why this looked centered on iPhone but not on Android.
     The row/card cap already tells the user there's more below, so the
     scrollbar itself isn't needed here; hide it and reclaim that gutter. */
  .browse-grid, .browse-table-scroll{scrollbar-width:none;-ms-overflow-style:none;}
  .browse-grid::-webkit-scrollbar, .browse-table-scroll::-webkit-scrollbar{display:none;}
  /* Originally this let the switcher shrink (flex:1, min-width:0) to
     whatever room was left after view-toggle + sort-control's fixed
     sizes, keeping everything on one row and relying on the trigger's
     ellipsis rule to truncate the label. In practice, on a real phone
     width the room left over after those two fixed-size sibling groups
     is too small even for the ellipsis rule to show any label text at
     all -- the inner <span> (flex:1, min-width:0) collapses to ~0 width,
     leaving a bare chevron with no indication of which library is
     selected. Claiming the whole toolbar line instead (flex-basis:100%,
     .browse-toolbar already wraps) guarantees the switcher gets the
     row's full width to show its label in, and pushes view-toggle +
     sort-control onto their own line below -- same fix already applied
     to Shelves' status tabs for the identical failure mode. */
  .lib-switcher{flex:1 1 100%;min-width:0;}
  .lib-switcher-trigger{width:100%;}
  /* Unlike .lib-switcher above, this isn't one shrinkable/truncatable
     control -- it's a set of independent tab links that wrap internally.
     Claiming the whole toolbar line (flex-basis:100%, on a flex-wrap
     parent) forces the view-toggle/sort controls onto their own line
     below instead of sharing this one, which is what was landing them
     vertically centered on top of the tabs' wrapped text before. */
  .shelf-status-tabs{flex:1 1 100%;}
  /* The account-switcher's trigger button sits near the LEFT edge of the
     topbar (it's one of the first items in the row), but .account-dropdown
     is right:0 by default -- aligning the dropdown's right edge with the
     button's right edge. That assumes there's 220px of room to the
     button's *left*, which is true for the library switcher (a wide
     button, usually further right in the toolbar, using the separate
     .lib-switcher-dropdown base rule) but not for this narrower,
     further-left button on a narrow phone screen: right:0 pushed the
     dropdown's left edge to a negative x, clipping its first ~20-30px of
     content off the visible viewport. Anchoring from the left instead
     keeps the whole box on-screen regardless of how narrow or far-left
     the trigger button is. */
  .account-dropdown{left:0;right:auto;}
  /* Confirmed directly at a real 375px viewport: .whats-new-switcher sits
     as the SECOND item in .inline (after account-switcher), not the first
     -- so neither of .account-dropdown's own two anchor options (left:0 or
     right:0, both relative to the *trigger button itself*) keeps a 280px-
     wide dropdown fully on screen from that mid-row position; right:0
     measured clipping ~115px off the left edge. Rather than hardcode a
     fragile fix tied to this exact trigger position (itself dependent on
     account-switcher's own width, which varies with the logged-in
     account's name length), .whats-new-switcher gives up its own
     position:relative at this breakpoint so .whats-new-dropdown's
     position:absolute resolves against .inline instead (the next
     positioned ancestor up) -- .inline itself already claims the full
     wrapped row width here (flex:1 1 100%, above), so right:0 relative to
     it stays safely on screen regardless of where any individual child
     button happens to sit within that row. */
  .inline{position:relative;}
  .whats-new-switcher{position:static;}
  .field-row{grid-template-columns:1fr;}
  .browse-flex{flex-direction:column;}
  /* Constrain the results column to its parent's width. Without an explicit
     width it sizes to the grid's content min-width and overflows the frame
     (measured 305px inside a 280px parent), so nothing downstream has a
     definite width for the grid's minmax(0,1fr) tracks to shrink into --
     which is why the right-margin padding had no effect. width:100% +
     min-width:0 forces the definite width so the grid shrinks to fit and
     the margin can open. */
  .browse-flex > div:first-child{width:100%;min-width:0;}
  .charts-grid{grid-template-columns:1fr;}

  /* Library management row (checkbox, status dot, .lib-name-group [alias
     input + sync flag + Run all], grouping select, expand chevron):
     .lib-row is nowrap by default and its ancestor .lib-list has
     overflow:hidden. On a narrow phone, the grouping <select> has no
     min-width:0 (the classic flex-item content-floor problem -- see the
     "Rule of thumb" note elsewhere in this file), so it refuses to shrink
     below roughly the width of its longest option text ("Publishing date
     by decade"). That alone eats most of the row's available width,
     squeezing .lib-name-group down to an unreadable sliver -- and since
     the row still doesn't fit and nowrap doesn't let it wrap onto a new
     line, the rest (most visibly the expand chevron, pushed to the row's
     far right via margin-left:auto) overflows the row's box and gets
     silently clipped away by .lib-list's overflow:hidden, rather than
     just looking crowded. Fixed by letting the row wrap, and giving
     .lib-name-group/select each their own full-width line so they're
     actually readable; the checkbox/dot/chevron stay compact together on
     the first line (order puts the chevron there instead of after the two
     full-width fields in source order).

     This block originally targeted .lib-alias-input directly, from before
     the admin Settings redesign (§44) wrapped it (plus the sync-needed
     flag and the Run all button) inside a new .lib-name-group container --
     .lib-alias-input is no longer a direct child of .lib-row, so giving IT
     flex:1 1 100%/order:2 only reordered it within .lib-name-group's own,
     already-narrow flex context, never actually forcing a full-width line.
     .lib-name-group itself never got that treatment, so it stayed squeezed
     on the same line as the checkbox/dot/chevron -- reported directly: the
     library name unreadable, and the leftover sliver overlapping the
     expand button. Retargeted below to the real .lib-row child. */
  .lib-row{flex-wrap:wrap;row-gap:6px;}
  .lib-row select{min-width:0;}
  .lib-name-group,.lib-row select{flex:1 1 100%;}
  .lib-expand-btn{order:1;}
  .lib-name-group{order:2;}
  .lib-row select{order:3;}
  .col-secondary{display:none !important;}

  /* Browse table: title only. Row-count cap (max-height) is set inline by JS
     (capMobileRowHeights in base.html) so it reflects real rendered row height.
     table-layout MUST stay fixed here: with auto layout, cells that are
     white-space:nowrap (see table tbody td rule above) make the browser size
     the table to fit the longest unwrapped title in the data, ignoring the
     container width entirely -- on a real library with long titles this blew
     the table out to 800+px inside a ~340px container. Fixed layout sizes
     columns from the declared widths only, independent of cell content, so
     it can't overflow; the hidden th/td simply aren't table-cell boxes so
     they don't participate in the column count. */
  .resizable-table{table-layout:fixed;width:100%;}
  .resizable-table th:not(:first-child),
  .resizable-table td:not(:first-child){display:none !important;width:0;padding:0;border:0;}
  .resizable-table th:first-child,
  .resizable-table td:first-child{width:100% !important;}
  .col-resize-handle{display:none;}

  /* Right margin for the whole browse column comes from ONE place: padding
     on #browse-results, the normal-block parent of both the toolbar and the
     grid. Putting it here (rather than on the grid and toolbar separately)
     guarantees they share the exact same right edge on the device -- the
     grid is a scroll container and the toolbar isn't, so per-element
     padding rendered at different effective widths and left the icons
     sticking out past the covers. The search input is flex:1 so it absorbs
     the inset (the icons' extra margin comes out of the search bar width),
     and the grid fills the reduced width so its right column lines up under
     the icons. Left edge is untouched, so this only opens the right margin. */
  #browse-results{padding-right:1px;}
  /* minmax(0, 1fr) not just 1fr: a bare 1fr is minmax(auto, 1fr), whose auto
     floor refuses to shrink a column below its content's min-content width,
     so the two columns stayed pinned near their minimum and overflowed the
     container -- the padding above then had no slack to pull them in, and on
     a high-DPR phone the columns rode right to the frame edge. minmax(0, ...)
     drops that floor so the columns shrink to fit and the right margin can
     actually open. */
  .browse-grid{grid-template-columns:repeat(2, minmax(0, 1fr));gap:6px;}

  .detail-pane{
    position:fixed; left:0; right:0; bottom:0; width:auto;
    /* dvh (dynamic viewport height) after the vh fallback, not instead of
       it -- reported directly: the AI-search progress box above (meant to
       align with this sheet's own top edge, both using the same 75-unit
       reference) visibly drifted as the reader scrolled, ending up
       sandwiched mid-results instead of staying anchored. Root cause:
       plain vh is defined against the *largest* possible viewport (chrome
       collapsed), not whatever's *currently* visible -- as a real mobile
       browser's address bar collapses/expands during a scroll gesture,
       the actual visible area changes size while a plain-vh value stays
       fixed, so a position:fixed element anchored with it visibly shifts
       relative to what's actually on screen even though it never moved
       relative to the (stale) viewport definition it was computed against.
       dvh is defined against whatever the *current* visible viewport
       genuinely is, updating live as browser chrome shows/hides -- the
       standard fix for exactly this class of mobile drift. Declaring vh
       first and dvh second (same property) is progressive enhancement:
       a browser that doesn't understand dvh simply can't parse that
       declaration and keeps the vh one instead of falling back to no
       height cap at all. */
    max-height:75vh;
    max-height:75dvh;
    display:flex; flex-direction:column;
    overflow:hidden; padding:0;
    border-radius:16px 16px 0 0;
    box-shadow:0 -6px 24px rgba(0,0,0,0.18);
    z-index:50;
    transform:translateY(105%);
    transition:transform 0.25s ease;
  }
  .detail-pane.mobile-open{transform:translateY(0);}
  /* #ai-search-progress needs no mobile override -- its base rule above is
     already a constant, viewport-anchored position that works unchanged at
     every width; see that rule's own comment for why. */
  .detail-sticky{flex-shrink:0;padding:12px 12px 0;}
  .detail-scroll{flex:1;min-height:0;overflow-y:auto;padding:0 12px 12px;overscroll-behavior:contain;}
  /* Side-by-side header, mobile only -- requested directly, to free up
     vertical space in the bottom sheet for the description. First version
     put title/author/series beside the cover with the format badges kept
     full-width below; requested variation swaps that: title/author/series/
     pages moved back to a plain stacked block *below* the header row, and
     the format-badge actions (download/Kindle/read/Book Brief) moved into
     the row beside the cover instead. Desktop's own .detail-pane is
     untouched *visually except for one accepted DOM-order side effect*:
     .detail-header-row has no rule at all outside this media query, so it
     stays a plain unstyled block box there -- .detail-formats now renders
     directly after the cover rather than after series/pages, since both
     breakpoints share one DOM order and only this narrower one turns the
     row into an actual flex layout. Not requested for desktop, but not
     asked to be avoided either -- worth a real look if it reads as
     out of place there. */
  .detail-header-row{display:flex;gap:12px;align-items:flex-start;margin-bottom:10px;}
  /* .detail-cover-col wraps the cover image and (mobile-only) the Book
     Brief button in one flex column, so the button stacks directly
     beneath the cover -- requested directly, moved here from the end of
     the format-card list below. The 40% width (up from an original 35%,
     a 15% relative increase requested separately) moved here from
     .detail-cover itself, since the column as a whole is now the actual
     flex child of .detail-header-row. */
  .detail-cover-col{display:flex;flex-direction:column;gap:8px;width:40%;flex-shrink:0;}
  .detail-cover{width:100%;margin:0;}
  /* min-width:0 -- the same flex-child shrink fix this file's own Browse
     rules already establish (see the "flex/grid items don't shrink below
     their content" note elsewhere in this stylesheet): without it,
     .detail-formats' own flex-wrap row refuses to wrap narrower than its
     longest unbroken run of badges, pushing the column wider than the
     space actually left beside the cover. margin-bottom:0 here since
     .detail-header-row's own margin-bottom above already provides the
     spacing before the title block that follows -- keeping both would
     double it only on whichever side ends up taller. */
  .detail-header-row .detail-formats{flex:1;min-width:0;margin-bottom:0;flex-direction:column;align-items:flex-start;}
  .detail-shelf-select{display:block;margin-bottom:12px;width:100%;}
  .detail-shelf-row{flex-wrap:wrap;}
  .quality-badge{height:24px;}
  /* Grouped per-format actions -- requested directly, with a screenshot:
     the flat badge list repeated the format name in every action button
     ("Read AZW3", "Download AZW3", "AZW3 793.7 KB", ...), which for a
     multi-format book stacked into a long, repetitive column (§32 already
     put one action per row; this collapses several rows into one card).
     .format-badge-desktop-only is the exact same markup that renders on
     desktop, just hidden here in favor of .format-card. Same reasoning
     for .format-badge-brief-desktop-only -- the Book Brief button that
     used to trail the format list now hides here in favor of
     .format-badge-brief-cover, which sits under the cover instead. */
  .format-badge-desktop-only{display:none;}
  .format-badge-brief-desktop-only{display:none;}
  /* Re-enables the base display:none set near .format-card-list above --
     .format-badge's own display:inline-flex is what this restores, so
     the shared .format-badge-brief rule below (width:100%/justify-
     content:center) has an inline-flex box to actually center within. */
  .format-badge-brief-cover{display:inline-flex;}
  /* Requested directly: the earlier one-card-per-format version (each
     with its own border, radius, and margin-bottom gap) "looked too
     scattered." Replaced with one shared bordered list -- the exact same
     bordered-list-of-rows shape this app already uses for Settings'
     collection-health operations rows -- so formats read as one cohesive
     group with thin dividers between them, not several separately
     floating boxes. */
  .format-card-list{
    display:block; border:0.5px solid var(--border);
    border-radius:var(--radius); overflow:hidden; margin-bottom:6px;
  }
  .format-card{padding:6px 10px;border-bottom:0.5px solid var(--border);}
  .format-card:last-child{border-bottom:none;}
  .format-card-label{font-size:12px;font-weight:400;margin:0 0 4px;}
  /* Requested directly: the format name (AZW3/EPUB/MOBI) should read as
     bold, distinct from its size next to it -- 600, this app's own
     established "weight for emphasis" convention (§25's design-system
     rebrand explicitly favors a weight bump over italics for this). */
  .format-card-name{font-weight:600;}
  .format-card-actions{display:flex;gap:4px;}
  /* 32px -- tightened from an original 36px as part of the same "too
     scattered" pass above; still comfortably tappable in a row of at
     most 4 icons, and matches this app's own compact-touch-target
     precedent (.reader-controls button) just at the smaller end of it. */
  .format-card-btn{
    display:inline-flex; align-items:center; justify-content:center;
    width:32px; height:32px; border-radius:var(--radius);
    border:0.5px solid var(--border-strong); color:var(--text-primary);
    background:none; cursor:pointer; text-decoration:none;
  }
  .format-card-btn:hover{background:var(--surface-1);}
  /* Book Brief is now the one action rendered after every format's own
     card (moved out of the per-format loop entirely, see
     _browse_detail.html) -- full-width and centered here so it reads as
     its own standalone row rather than a small badge trailing the last
     card. */
  .format-badge-brief{width:100%;justify-content:center;margin-top:2px;}
  .detail-close-btn{
    display:flex; align-items:center; justify-content:center;
    position:absolute; top:8px; right:8px; z-index:1;
    width:28px; height:28px; border-radius:50%;
    background:var(--surface-1); border:0.5px solid var(--border-strong);
    font-size:16px; line-height:1; color:var(--text-secondary);
  }
  .detail-backdrop.mobile-open{
    display:block; position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:49;
  }

  /* Reader controls sized to the standard ~44px touch-target minimum on
     phones rather than the 36px desktop/mouse size used elsewhere. */
  .reader-topbar{padding:8px 10px;}
  .reader-controls{padding:8px 10px;}
  .reader-controls button{width:44px;height:44px;}
  /* Requested directly on a narrow device (S25 Ultra): the progress bar
     was squeezed into the same row as every other paged-viewer button,
     competing for space via flex-wrap's own automatic (and previously
     unpredictable) reflow. flex-basis:100% forces it to occupy a full row
     on its own -- nothing else can share a row with a 100%-basis flex
     item -- and order:1 (above every button's own default order:0) is
     what actually pushes it to the row *below* the buttons rather than
     above them, even though it's the first child in the DOM (reused
     verbatim from the reflow viewer's own .reader-progress markup, kept in
     that order there deliberately -- reordering it in markup wasn't worth
     disturbing just for this one breakpoint). */
  .reader-controls .reader-progress{flex-basis:100%;order:1;}
  .reader-page-jump{width:48px;}
  /* left:0 instead of a computed width: pinning both edges directly avoids
     relying on 100% resolving to the same value as the actual visual
     viewport, which mobile Chrome's dynamic URL-bar can throw off (measured:
     panel landed ~28px past the left edge using width:100% + right:0). */
  .reader-toc-panel{left:0;width:auto;max-width:none;}
  .reader-chat-panel{left:0;width:auto;max-width:none;}
  .reader-bookmarks-panel{left:0;width:auto;max-width:none;}
  .reader-tap-zone{width:35%;}
  .reader-tap-middle{left:35%;right:35%;width:auto;}

  /* User management mobile layout: the desktop version lays every field
     out as columns in one flex row (see .user-row above). On a narrow
     phone there's no width left once Name/Access/Status/Actions all try
     to sit side by side -- .user-col-status and .user-col-actions have
     flex-shrink:0 (fixed width, by design, so they never get truncated),
     which leaves .user-col-access squeezed into a sliver. Access has no
     overflow:hidden (unlike Name/Email, which are meant to truncate with
     an ellipsis) because it's the one field an admin actually needs to
     read in full on mobile -- so instead of clipping, its unbreakable
     long words (a joined library-name list) were overflowing visually
     past their own narrow box and bleeding into the Status column
     rendered right next to it, reading as "PeActodivals" (Periodicals +
     Active overlapping). Fixed by abandoning the column-row layout
     entirely below this width: stack every field full-width, top to
     bottom, so nothing needs to share horizontal space at all. */
  .user-row{flex-direction:column;align-items:stretch;gap:4px;padding:10px 0;}
  .user-row-head{display:none;} /* labels don't help once nothing is a column anymore */
  .user-col-email,.user-col-ai{display:none;}
  .user-col-name{flex:none;width:100%;font-weight:600;font-size:15px;overflow:visible;text-overflow:clip;white-space:normal;}
  .user-col-access{flex:none;width:100%;font-size:13px;color:var(--text-secondary);}
  .user-col-status{flex:none;width:100%;font-size:12px;}
  .user-col-actions{flex:none;width:100%;text-align:left;margin-top:2px;}
  /* EPUB controls buttons shrink from 44px to 38px on mobile, still
     touch-usable but tighter. On a very narrow phone the one-row layout
     (flex-wrap:nowrap) doesn't fit everything, so allow wrapping back to
     two rows here rather than forcing it all into one row and having it
     cut off. The button shrink + tighter gaps help minimize how often
     wrapping is needed; this is a fallback for phones narrower than ~340px. */
  .reader-controls-epub{flex-wrap:wrap;}
  .reader-controls-epub button{width:38px;height:38px;}
}

/* Goodreads Modal -- gr- prefixed to avoid colliding with the existing
   .modal/.modal-backdrop pair (admin online-users dialog, line ~854 above). */
.gr-modal{display:flex;position:fixed;top:0;left:0;right:0;bottom:0;align-items:center;justify-content:center;z-index:1000;}
.gr-modal-backdrop{position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);}
/* Flex column so the header is always visible and only the frame area
   scrolls -- close button was previously position:absolute inside an
   overflow-y:auto container, making it scroll away with the content on
   mobile. overflow:hidden on the outer box lets flex:1 on .gr-frame-wrap
   do the actual scrolling. */
.gr-modal-content{display:flex;flex-direction:column;overflow:hidden;background:var(--surface-0);border-radius:8px;width:min(760px, calc(100vw - 48px));max-height:90vh;box-shadow:0 10px 40px rgba(0,0,0,0.3);z-index:1;}
.gr-modal-header{display:flex;align-items:center;gap:12px;padding:14px 16px 12px;border-bottom:1px solid var(--border);flex-shrink:0;}
.gr-modal-title{margin:0;font-size:17px;font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.gr-modal-close{background:none;border:none;font-size:28px;cursor:pointer;color:var(--text-secondary);padding:0;width:32px;height:32px;display:flex;align-items:center;justify-content:center;transition:color 0.2s ease;flex-shrink:0;}
.gr-modal-close:hover{color:var(--text-primary);}
.gr-modal-hidden{display:none !important;}
/* Scrollable area: flex:1 takes remaining height below the fixed header */
.gr-frame-wrap{position:relative;flex:1;overflow-y:auto;}
.gr-modal-loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:var(--surface-0);border-radius:4px;z-index:2;}
.gr-modal-loading.gr-hidden{display:none;}
.gr-modal-loading p{margin:0;font-size:13px;color:var(--text-secondary);}
.gr-spinner{width:32px;height:32px;border:3px solid var(--border);border-top-color:var(--color-primary-medium);border-radius:50%;animation:gr-spin 0.8s linear infinite;}
@keyframes gr-spin{to{transform:rotate(360deg);}}
/* Mobile: bottom-sheet treatment, matching the detail pane */
@media(max-width:640px){
  .gr-modal{align-items:flex-end;}
  .gr-modal-content{width:100%;max-height:75vh;border-radius:12px 12px 0 0;box-shadow:0 -4px 24px rgba(0,0,0,0.18);}
}
