/* Voice Compiler web frontend.
   Design tokens mirror the Android Compose theme (apps/android/.../ui/theme/Theme.kt). */

:root {
    --bg: #f7f4ee;
    --surface: #fdfbf6;
    --surface-raised: #ffffff;
    --text: #1c2722;
    --muted: #647067;
    --border: rgba(28, 39, 34, 0.12);
    --accent: #0e6b57;
    --on-accent: #f6fbf8;
    --accent-soft: rgba(14, 107, 87, 0.1);
    --danger: #b3261e;
    --danger-soft: rgba(179, 38, 30, 0.1);
    /* Text color for solid --danger fills (button.danger-fill, .status.error). Kept separate from
       --danger itself because dark mode's --danger is a light, text-oriented pink (see below) — using
       it as a fill with hardcoded white text used to pass here but silently broke in dark mode. */
    --on-danger: #ffffff;
    --warning-soft: rgba(184, 92, 56, 0.14);
    --switch-off: #8a968d;
    --switch-knob: #ffffff;
    --highlight-word: #f6c445;
    --on-highlight-word: #1c2722;
    --highlight-paragraph: rgba(246, 196, 69, 0.18);
    --shadow: 0 10px 30px rgba(28, 39, 34, 0.1);
    --radius-s: 10px;
    --radius-m: 16px;
    --radius-l: 22px;
    --max-width: 820px;
    --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
    --font-serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
    color-scheme: light;
}

[data-theme="dark"] {
    --bg: #0f1412;
    --surface: #161d1a;
    --surface-raised: #1d2521;
    --text: #e8ede9;
    --muted: #98a49d;
    --border: rgba(232, 237, 233, 0.14);
    --accent: #49c699;
    --on-accent: #06231a;
    --accent-soft: rgba(73, 198, 153, 0.12);
    --danger: #f2b8b5;
    --danger-soft: rgba(242, 184, 181, 0.12);
    /* --danger flips to a light pink in dark mode (meant for text on a dark surface), so a solid
       --danger fill needs a dark, not white, label — a dark maroon in the same spirit as --on-accent
       being a dark green rather than plain black. White-on-#f2b8b5 measured 1.71:1 (WCAG requires
       4.5:1); this pairing measures 10.5:1. */
    --on-danger: #2a0e0c;
    --warning-soft: rgba(224, 146, 110, 0.16);
    --switch-off: #6b7873;
    --switch-knob: #f2f5f2;
    --highlight-word: #f0c24b;
    --on-highlight-word: #231a02;
    --highlight-paragraph: rgba(240, 194, 75, 0.12);
    --shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
    color-scheme: dark;
}

* {
    box-sizing: border-box;
}

.sprite {
    position: absolute;
}

.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

.m0 {
    margin: 0;
}

.mt6 {
    margin-top: 6px;
}

.mt8 {
    margin-top: 8px;
}

.mb8 {
    margin: 0 0 8px;
}

.grow {
    flex: 1;
    min-width: 0;
}

.center {
    text-align: center;
}

.inline-row {
    display: flex;
    gap: 8px;
    align-items: center;
}

.dialog-actions.start {
    justify-content: flex-start;
}

button.danger-fill {
    background: var(--danger);
    border-color: var(--danger);
    color: var(--on-danger);
}


html,
body {
    margin: 0;
    padding: 0;
}

html {
    /* Reserve the scrollbar track permanently so content stops shifting
       sideways when the page grows past the viewport. */
    scrollbar-gutter: stable;
    /* Android's native tap flash is a squared-off translucent overlay that ignores border-radius
       (a blue rectangle around round buttons). Suppress it; the button :active rules below give
       radius-respecting press feedback instead. */
    -webkit-tap-highlight-color: transparent;
}

body {
    font-family: var(--font-ui);
    background: var(--bg);
    color: var(--text);
    min-height: 100vh;
    line-height: 1.55;
    /* No background/color transition here: children don't transition their own colors, so a lagging
       body produces a visible mixed light/dark flash on every theme switch. Theme flips are atomic. */
}

/* The floating bar reserves exactly its own footprint: --player-bar-clearance is kept live from
   app.js (bar height + bottom inset + gap), so content never hides under it and there is no dead
   space. The fixed fallback covers the first paint before the ResizeObserver measures the bar.
   The padding lives on #app-scroll (the wrapper around header/main/footer) so it works both when
   the body scrolls (desktop) and when #app-scroll itself is the scroller (mobile app shell). */
body.has-player #app-scroll {
    padding-bottom: var(--player-bar-clearance, 210px);
}

/* ---------- Mobile app shell (stable browser chrome) ---------- */

/* Below 900px the page scrolls inside #app-scroll instead of the root. Mobile browsers collapse
   their top/bottom chrome only on ROOT scrolling; with the root pinned, the chrome stays put, the
   visual viewport never resizes mid-scroll, and the fixed player/generate bar stops drifting
   downward while reading. Desktop keeps native root scrolling (keyboard paging, stable scrollbar
   gutter) — there #app-scroll is just a plain wrapper. Scoped to body.app-shell (index.html only):
   help.html/privacy.html share this stylesheet but have no #app-scroll wrapper, so an unscoped
   overflow:hidden would freeze them entirely on phones. */
@media (max-width: 900px) {
    html {
        height: 100%;
    }

    body.app-shell {
        height: 100%;
        overflow: hidden;
    }

    body.app-shell #app-scroll {
        height: 100%;
        overflow-y: auto;
    }

    /* The root already can't scroll here; freeze the app scroller too while a modal is open. */
    body.app-shell.modal-open #app-scroll {
        overflow: hidden;
    }
}

/* Set by app.js's single dialog-open MutationObserver whenever any <dialog> is open, so the page
   underneath never scrolls behind a modal. */
body.modal-open {
    overflow: hidden;
}

button {
    font-family: inherit;
}

/* ---------- App shell ---------- */

/* Full-bleed bar; the inner wrapper carries the actual max-width so the border spans the viewport. */
.app-header {
    border-bottom: 1px solid var(--border);
}

.app-header-inner {
    max-width: 1400px;
    margin-inline: auto;
    padding: 0 24px;
    height: 64px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
}

.app-header .brand {
    display: flex;
    align-items: center;
    gap: 10px;
    min-width: 0;
}

/* The brand block doubles as a home button; strip the default button chrome and
   keep it left-aligned text. */
.app-header .brand-btn {
    border: none;
    background: transparent;
    padding: 0;
    border-radius: var(--radius-s);
    text-align: left;
    cursor: pointer;
}

.app-header .brand-btn:hover:not(:disabled) {
    transform: none;
    border-color: transparent;
}

.app-header .brand-btn:hover h1 {
    text-decoration: underline;
    text-underline-offset: 2px;
}

.app-header .brand-btn:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 4px;
}

.app-header .app-logo {
    width: 32px;
    height: 32px;
    border-radius: 9px;
    flex-shrink: 0;
    box-shadow: var(--shadow);
}

.app-header h1 {
    font-family: var(--font-serif);
    font-size: 20px;
    letter-spacing: -0.2px;
    margin: 0;
    /* Should the actions cluster ever press in, the wordmark ellipsizes instead of hard-clipping
       mid-letter ("Voi"). The responsive account rules below keep this from happening at all. */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.app-header .header-actions {
    display: flex;
    align-items: center;
    gap: 8px;
}

/* Header-only icon buttons (theme toggle, settings) get a slightly tighter hit area than the
   40px default so they line up with the 36px-tall account pill/Sign out/divider beside them. */
.app-header .icon-btn {
    width: 36px;
    height: 36px;
}

main {
    margin: 0 auto;
    padding: 10px 20px 80px;
}

.view {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

/* main is now unconstrained so the reader can widen independently (see #reader-view below);
   every other view centers itself at the exact width main used to enforce directly, so nothing
   changes for them. */
.view:not(#reader-view) {
    max-width: var(--max-width);
    margin-inline: auto;
}

/* The reader escapes the app's normal single-column width for a wider three-column working area
   (library rail / text / reserved). The max-width is sized so the centre column can reach its full
   --max-width (820) between the two minmax(260px,1fr) side columns and the 32px gaps
   (260 + 820 + 260 + 64 = 1404), so the reader text column lines up pixel-for-pixel with the other
   views' 820px column — the back arrow/title/actions share the same left/right edges everywhere. */
#reader-view {
    max-width: 1440px;
    margin-inline: auto;
}

.hidden {
    display: none !important;
}

/* ---------- Typography helpers ---------- */

h2 {
    font-family: var(--font-serif);
    font-size: 22px;
    margin: 0;
}

.section-label {
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 1.4px;
    text-transform: uppercase;
    color: var(--muted);
    margin: 8px 0 -6px;
}

.muted {
    color: var(--muted);
    font-size: 14px;
}

/* One-line explanatory text under a settings control (e.g. a toggle), quieter than a heading
   paragraph and tighter than .muted's default spacing. */
.setting-help {
    color: var(--muted);
    font-size: 13px;
    margin: 4px 0 0;
}

/* ---------- Buttons & inputs ---------- */

button {
    cursor: pointer;
    white-space: nowrap;
    border: 1px solid var(--border);
    background: var(--surface-raised);
    color: var(--text);
    border-radius: 999px;
    padding: 9px 18px;
    font-size: 14px;
    font-weight: 500;
    transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}

button:hover:not(:disabled) {
    border-color: var(--accent);
    background: var(--accent-soft);
}

/* Filled variants (solid --accent/--danger background + a near-white/near-black "on-*" text) must
   NOT fall through to the generic hover above: swapping a solid fill for the ~10% translucent -soft
   tint makes the fill nearly match the surface behind it while the text stays near-white/near-black,
   collapsing contrast to ~1.1:1 (unreadable — this was the reported bug). Brightness-only keeps the
   same fill/text pairing (so contrast stays close to the resting ratio) and still reads as a distinct
   hover state; :active below already used this trick, just darkening instead of lightening. */
button.primary:hover:not(:disabled) {
    background: var(--accent);
    border-color: var(--accent);
    filter: brightness(1.05);
}

button.danger-fill:hover:not(:disabled) {
    background: var(--danger);
    border-color: var(--danger);
    filter: brightness(1.05);
}

/* Press feedback replacing the suppressed native tap highlight (see the html rule): colour-only,
   so the pressed state respects every button's radius and nothing changes size or position. */
button:not(:disabled):active {
    border-color: var(--accent);
    background: var(--accent-soft);
}

button.primary:not(:disabled):active {
    background: var(--accent);
    border-color: var(--accent);
    filter: brightness(0.92);
}

button.danger-fill:not(:disabled):active {
    background: var(--danger);
    border-color: var(--danger);
    filter: brightness(0.92);
}

button:disabled {
    opacity: 0.45;
    cursor: default;
}

button.primary {
    background: var(--accent);
    color: var(--on-accent);
    border-color: var(--accent);
}

button.danger-text {
    color: var(--danger);
}

/* Text-only danger buttons (e.g. "Delete forever") otherwise inherit the generic accent-tinted
   hover/active above, which reads oddly — a delete action turning green when you touch it. Tint with
   --danger-soft instead; both measured well above 4.5:1 (5.5:1 light / 7.0:1 dark). */
button.danger-text:hover:not(:disabled) {
    border-color: var(--danger);
    background: var(--danger-soft);
}

button.danger-text:not(:disabled):active {
    border-color: var(--danger);
    background: var(--danger-soft);
}

button.icon-btn {
    width: 40px;
    height: 40px;
    padding: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
}

button.icon-btn svg {
    width: 20px;
    height: 20px;
}

button.chip {
    padding: 7px 14px;
    font-size: 13px;
    display: inline-flex;
    align-items: center;
    gap: 6px;
}

button.chip svg {
    width: 15px;
    height: 15px;
}

button.chip.selected {
    background: var(--accent);
    color: var(--on-accent);
    border-color: var(--accent);
}

/* Same filled-variant problem as button.primary/.danger-fill above: a selected chip (e.g. the
   "Active" library filter, worn by default) is on-accent text over a solid accent fill, and the
   generic button:hover swaps that fill for the near-transparent accent-soft tint underneath it —
   text and background nearly merge. Brightness-only keeps it filled and readable. */
button.chip.selected:hover:not(:disabled) {
    background: var(--accent);
    border-color: var(--accent);
    filter: brightness(1.05);
}

svg.icon {
    /* Default icon size so every `.icon` is sane even in contexts without a more specific rule.
       Context rules (.icon-btn svg, .chip svg, .doc-ring svg, …) come later and win on source order.
       Without this default an unscoped `<svg class="icon">` falls back to the SVG replaced-element
       size (300×150) and balloons — e.g. the generate-row "Read aloud" button rendered huge. */
    width: 20px;
    height: 20px;
    fill: currentColor;
    flex-shrink: 0;
}

/* Unified base for every text-like form control (standalone dialog inputs, settings fields,
   storage filters, …) so they all share one background/border/radius/padding/height, regardless
   of which view renders them. Selects keep their native dropdown-arrow appearance; only the box
   around it is unified. Per-context tweaks (e.g. .number-stepper input, .cleanup-inline input)
   layer compact sizing on top without touching border/radius/focus. */
input[type="text"],
input[type="search"],
input[type="password"],
input[type="number"],
input[type="url"],
select,
textarea {
    width: 100%;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-s);
    color: var(--text);
    font-family: inherit;
    font-size: 15px;
    padding: 9px 12px;
}

/* min-height applies to single-line controls only — textarea sizes by its own rows/resize rule.
   The fixed line-height keeps inputs and selects the same computed height (selects otherwise add
   ~2px from their UA line box, leaving visibly unequal boxes side by side in the storage filters). */
input[type="text"],
input[type="search"],
input[type="password"],
input[type="number"],
input[type="url"],
select {
    min-height: 40px;
    height: 40px;
    line-height: 20px;
}

/* Strip the native search-field chrome (rounded pill + built-in cancel button in WebKit) so
   type="search" renders pixel-identical to type="text" under the shared rule above. */
input[type="search"] {
    appearance: none;
    -webkit-appearance: none;
}

input[type="search"]::-webkit-search-cancel-button {
    -webkit-appearance: none;
}

/* One focus style for every control, including buttons — no per-view variants. */
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
button:focus-visible,
/* Keyboard-operable rows that stay <div role="button"> because they nest real buttons — they would
   otherwise fall back to the browser's default outline instead of the accent ring used everywhere. */
.doc-row:focus-visible,
.continue-card:focus-visible,
/* Reader paragraphs double as the keyboard/screen-reader seek target (see reader.js buildTextBlock);
   same reasoning as the row/card cases above. Outline only — no size/position change, so a paragraph
   never reflows its neighbours just because it gained focus. */
.reader-paragraph:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
}

textarea {
    resize: vertical;
    min-height: 84px;
    line-height: 1.55;
}

label {
    display: block;
    font-size: 13px;
    font-weight: 500;
    color: var(--muted);
}

label > input,
label > select,
label > textarea {
    margin-top: 5px;
}

input[type="range"] {
    width: 100%;
    accent-color: var(--accent);
}

/* ---------- Custom select (K1) ---------- */

/* Every K1-listed native <select> (see initCustomSelects() in app.js) is wrapped so pointer AND
   keyboard users get one consistent trigger + rounded popover listbox instead of the browser's
   native, unthemeable OS dropdown. The native <select> stays in the DOM as the form-value/'change'
   source of truth but is visually hidden and pulled out of the tab order/a11y tree (aria-hidden,
   tabindex -1) — the trigger below fully replaces its interaction and accessible name. The trigger
   is a plain <button>, so it already inherits the base button's hover/:focus-visible rules above;
   only the geometry that makes it read as an input (radius-s, surface bg, left-aligned label) is
   overridden here. */
.vc-select {
    position: relative;
    width: 100%;
}

.vc-select-native {
    position: absolute;
    inset: 0;
    opacity: 0;
    pointer-events: none;
    z-index: -1;
}

label > .vc-select {
    margin-top: 5px;
}

.vc-select-trigger {
    width: 100%;
    height: 40px;
    min-height: 40px;
    padding: 9px 12px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    border-radius: var(--radius-s);
    background: var(--surface);
    font-size: 15px;
    font-weight: 400;
    text-align: left;
}

.vc-select-trigger-label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.vc-select-chevron {
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    color: var(--muted);
    fill: currentColor;
    transition: transform 140ms ease;
}

.vc-select-trigger[aria-expanded="true"] .vc-select-chevron {
    transform: rotate(180deg);
}

/* position: fixed (not absolute) so the popover is measured/placed with getBoundingClientRect and
   escapes any clipping ancestor — including a <dialog>'s own overflow:hidden and its .dialog-body's
   overflow-y:auto (which the browser silently upgrades overflow-x to "auto" too, see the .dialog-body
   fix below) — a fixed-position box's containing block is the viewport, not those ancestors, as long
   as none of them sets a transform/filter. */
.vc-select-popover {
    position: fixed;
    z-index: 70;
    display: flex;
    flex-direction: column;
    gap: 2px;
    max-height: 280px;
    overflow-y: auto;
    padding: 6px;
    background: var(--surface-raised);
    border: 1px solid var(--border);
    border-radius: var(--radius-s);
    box-shadow: var(--shadow);
}

/* The [hidden] attribute selector must be at least as specific as the plain-class rule above (an
   author rule always wins over the UA stylesheet's own bare `[hidden]{display:none}`, regardless of
   specificity, only until an author rule of equal-or-higher specificity re-asserts it) — without
   this the popover would stay visible while "hidden". */
.vc-select-popover[hidden] {
    display: none;
}

.vc-select-option {
    width: 100%;
    min-height: 36px;
    padding: 8px 10px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    border: none;
    border-radius: var(--radius-s);
    background: transparent;
    color: var(--text);
    font-size: 14px;
    font-weight: 500;
    text-align: left;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.vc-select-option:hover:not(:disabled) {
    background: var(--accent-soft);
}

/* Inset (negative) outline-offset instead of the app-wide +1px: the popover itself scrolls
   (overflow-y: auto), which — same rule as above — forces its overflow-x to compute as "auto" too,
   so an outline that bled *outward* past an edge-row's own box would get clipped. Keeping the ring
   fully inside the option's own border box sidesteps that entirely. */
.vc-select-option:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
    background: var(--accent-soft);
}

.vc-select-option[aria-selected="true"] {
    color: var(--accent);
    font-weight: 650;
}

.vc-select-option-check {
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    fill: currentColor;
    visibility: hidden;
}

.vc-select-option[aria-selected="true"] .vc-select-option-check {
    visibility: visible;
}

/* ---------- Library filter popover (L1) ---------- */
/* Reuses .vc-select-popover/.vc-select-option for the box + row look (see buildFilterPopover() in
   app.js), but is its own standalone popover (not tied to a native <select>) with two checkbox-style
   sections — Labels and Sources — instead of one flat option list. */
.filter-popover {
    min-width: 220px;
}

.filter-popover-section + .filter-popover-section {
    margin-top: 6px;
    padding-top: 6px;
    border-top: 1px solid var(--border);
}

.filter-popover-heading {
    margin: 4px 10px;
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--muted);
}

.filter-popover-option[aria-checked="true"] {
    color: var(--accent);
    font-weight: 650;
}

.filter-popover-option .vc-select-option-check {
    visibility: hidden;
}

.filter-popover-option[aria-checked="true"] .vc-select-option-check {
    visibility: visible;
}

.filter-popover-empty {
    padding: 10px;
}

/* Quiet caption under the Labels rows explaining the Ctrl/Cmd-click (tap on touch) multi-select —
   that gesture isn't otherwise discoverable. Smaller and unstyled (no uppercase/weight) compared to
   .filter-popover-heading above it, so it reads as a hint, not another section title. */
.filter-popover-hint {
    margin: 4px 10px 2px;
    font-size: 11.5px;
    line-height: 1.4;
}

/* The count badge lives inside a .chip, which already applies its own 6px inter-child gap — the
   badge's app-wide left margin would double that spacing, so it's zeroed out here only. */
.filter-trigger .badge {
    margin-left: 0;
}

/* ---------- Cards ---------- */

/* K3: the one shared top-level "card" look — settings sections, the library import box, and every
   standalone storage-view card (usage, costs, automatic cleanup, recycle bin) all use this class, so
   fixing it here fixes all of them at once. Previously used --radius-l (22px) and asymmetric 18px/20px
   padding, a size step above every other card-like surface in the app (reader rail, and the K1 select
   popover) which all already used --radius-m — the mismatch was the most visible "these don't match"
   tell. Dialogs' own internal grouping boxes (.notice, .cleanup-body, #voice-outputs-wrap, …) keep
   their distinct look on purpose — they read as nested sections *within* a dialog, not standalone
   cards, so they aren't part of this normalization. */
.card {
    background: var(--surface-raised);
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    padding: 20px;
    display: flex;
    flex-direction: column;
    gap: 10px;
}

/* ---------- Status / notices ---------- */

.status {
    position: fixed;
    left: 50%;
    bottom: max(24px, env(safe-area-inset-bottom, 0px));
    transform: translateX(-50%);
    background: var(--text);
    color: var(--bg);
    padding: 10px 18px;
    border-radius: 999px;
    box-shadow: var(--shadow);
    font-size: 14px;
    z-index: 60;
    max-width: min(90vw, 560px);
}

body.has-player .status {
    bottom: calc(var(--player-bar-clearance, 210px) + 12px);
}

.status.error {
    background: var(--danger);
    color: var(--on-danger);
}

.notice {
    background: var(--warning-soft);
    border-radius: var(--radius-s);
    padding: 8px 12px;
    font-size: 14px;
}

.notice.error {
    background: var(--danger-soft);
}

/* Storage-full banner at the top of the library: a warning notice laid out as a row so the
   "Manage storage" action sits opposite the message. */
.notice-warn {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 14px;
}

.notice-warn .chip {
    flex: none;
}

/* ---------- Library ---------- */

.import-actions {
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
}

.import-actions .muted {
    margin-left: auto;
}

/* On narrow screens the server-hint would otherwise squeeze into a skinny column beside the
   buttons; give it the full row below them instead. */
@media (max-width: 600px) {
    .import-actions .muted {
        flex-basis: 100%;
        margin-left: 0;
    }
}

.import-input {
    min-height: 46px;
    resize: none;
    overflow-y: auto;
    line-height: 1.5;
}

.import-mode {
    margin: 10px 0 6px;
}

.import-hint {
    margin: 0 0 10px;
    font-size: 13px;
    min-height: 1.1em;
}

/* The continue section now sits below the filter chips, reading as the highlighted
   first item of the library list. A little extra top gap separates it from the chips. */
#continue-section {
    margin-top: 6px;
}

#now-playing-section {
    margin-top: 6px;
}

#now-playing-section .section-label,
#continue-section .section-label,
#upnext-section .section-label {
    margin-bottom: 8px;
}

#upnext-section {
    margin-top: 6px;
}

/* Compact "Up next" entry point in the library: one full-width row that opens the queue dialog.
   Same card family as the library rows; a fixed min-height so the count changing (3 → 2 articles)
   never reflows the list around it. */
.upnext-card {
    width: 100%;
    min-height: 56px;
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 16px;
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    background: var(--surface-raised);
    text-align: left;
    font-size: 14px;
}

.upnext-card > svg.icon {
    color: var(--accent);
    flex-shrink: 0;
}

.upnext-card .grow {
    font-weight: 600;
}

.upnext-card small {
    display: block;
    font-weight: 400;
    color: var(--muted);
}

.now-playing-card {
    width: 100%;
    min-height: 76px;
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 14px 18px;
    border: 1px solid var(--accent);
    border-radius: var(--radius-l);
    background: var(--accent-soft);
    color: var(--text);
    text-align: left;
}

.now-playing-card:hover:not(:disabled) {
    transform: none;
}

.now-playing-card strong,
.now-playing-card small {
    display: block;
}

.now-playing-card strong {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.now-playing-card small {
    margin-top: 4px;
    color: var(--muted);
}

.now-playing-pulse {
    width: 12px;
    height: 12px;
    flex: 0 0 12px;
    border-radius: 50%;
    background: var(--accent);
    box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 18%, transparent);
}

.continue-card {
    background: var(--accent-soft);
    border: 1px solid var(--accent);
    border-radius: var(--radius-l);
    padding: 16px 20px;
    display: flex;
    align-items: center;
    gap: 16px;
    cursor: pointer;
}

.continue-card svg {
    width: 30px;
    height: 30px;
    color: var(--accent);
}

.continue-card .grow {
    flex: 1;
    min-width: 0;
}

.continue-card h3 {
    margin: 0 0 8px;
    font-size: 16px;
}

.progress-track {
    height: 5px;
    border-radius: 3px;
    background: var(--border);
    overflow: hidden;
}

.progress-fill {
    height: 100%;
    border-radius: 3px;
    background: var(--accent);
    transition: width 300ms ease;
}

.library {
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.library .doc-row {
    background: var(--surface-raised);
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    padding: 14px 18px;
    display: flex;
    align-items: center;
    gap: 14px;
    cursor: pointer;
    transition: border-color 120ms ease;
}

/* Hover: only the border colour changes (the border is always 1px, so the box never resizes and
   the row never shifts). Deliberately no transform — a translateY "lift" reads as the row jumping. */
.library .doc-row:hover {
    border-color: var(--accent);
}

.library .doc-row.now-playing {
    border-color: var(--accent);
    background: var(--accent-soft);
}

.doc-row .grow {
    flex: 1;
    min-width: 0;
}

.doc-row h4 {
    margin: 0 0 3px;
    font-size: 15.5px;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}

.doc-row .meta {
    color: var(--muted);
    font-size: 13px;
    margin: 0;
}

.doc-ring {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    position: relative;
    color: var(--accent);
}

.doc-ring svg {
    width: 16px;
    height: 16px;
}

.doc-ring::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: conic-gradient(var(--accent) calc(var(--progress, 0) * 1%), var(--border) 0);
    -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px));
    mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px));
}

/* Same ring while this document's narration is being generated: --progress carries the segment
   percentage and the waveform icon breathes. Before the first progress event (total still 0) the
   ring has nothing to fill, so a short arc orbits instead (.indeterminate). Purely additive — the
   ring swaps back to the headphones badge via the normal library re-render when the job ends. */
.doc-ring.generating svg {
    animation: doc-generating-pulse 1.6s ease-in-out infinite;
}

.doc-ring.generating.indeterminate::before {
    background: conic-gradient(var(--accent) 0 12%, var(--border) 12% 100%);
    animation: doc-generating-orbit 1.2s linear infinite;
}

@keyframes doc-generating-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.35; }
}

@keyframes doc-generating-orbit {
    to { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
    .doc-ring.generating svg,
    .doc-ring.generating.indeterminate::before {
        animation: none;
    }
}

.empty-state {
    text-align: center;
    padding: 36px 12px;
    color: var(--muted);
}

.empty-state h3 {
    color: var(--text);
    font-family: var(--font-serif);
    margin: 0 0 6px;
}

.search-row {
    display: flex;
    align-items: center;
    gap: 10px;
    background: var(--surface-raised);
    border: 1px solid var(--border);
    border-radius: 999px;
    padding: 4px 6px 4px 14px;
}

.search-row > svg {
    width: 17px;
    height: 17px;
    color: var(--muted);
    flex-shrink: 0;
}

.search-row input {
    border: none;
    background: transparent;
    padding: 8px 0;
    flex: 1;
    min-width: 0;
}

.search-row .icon-btn {
    width: 32px;
    height: 32px;
    border: none;
}

/* The focus ring belongs to the pill, not the input inside it: :focus-within recolours the
   always-present border and adds a 1px shadow ring that follows the pill's own 999px radius, so
   the ring wraps the magnifier icon and clear button too. The input's own outline (a squared
   ring around just the text field, ignoring the icon) is suppressed in favour of it. */
.search-row:focus-within {
    border-color: var(--accent);
    box-shadow: 0 0 0 1px var(--accent);
}

.search-row input:focus-visible {
    outline: none;
}

.doc-row.archived {
    opacity: 0.65;
}

/* Tap-to-open feedback: applied to a library row/card while openReader() is in flight, so a slow
   fetch never reads as an unresponsive tap. Cleared on success or failure. */
.is-opening {
    opacity: 0.55;
    pointer-events: none;
}

/* Header bar above the library list while viewing the Trash: a hint plus the Empty-trash action. */
.trash-bar {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 14px;
    background: var(--surface-raised);
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    font-size: 13px;
    margin-bottom: 10px;
}

.doc-labels {
    color: var(--muted);
    font-size: 12px;
    margin: 3px 0 0;
}

/* Single-line, horizontally scrollable chip row — no clipped/wrapped second row, no "More filters"
   toggle. Chips that overflow are reached by scrolling (or by focus, which auto-scrolls them into
   view); a right-edge fade hints that more are off-screen. */
#library-filters {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-mask-image: linear-gradient(to right, black calc(100% - 32px), transparent);
    mask-image: linear-gradient(to right, black calc(100% - 32px), transparent);
}

#library-filters::-webkit-scrollbar {
    display: none;
}

#library-filters .chip {
    flex: 0 0 auto;
}

/* W2: same fixed-height-list fix as #bookmark-list above and for the same reason — #queue-list has
   no rule of its own otherwise, so it's a plain block box that grows/shrinks by one row every time
   an article is queued/dequeued/auto-advanced, jumping the dialog around it. A single `height` (not
   just a min/max range — measured live: 2 rows and 3 rows still differed by ~27px under a
   130/320 min/max, so the range alone did not fully kill the jump) makes this box exactly the same
   size at 0 rows and at 50, with its own scroll past that; the hint text above and "Clear queue"
   below (outside .dialog-body) stay in place either way. */
#queue-list {
    height: 210px;
    max-height: 210px;
    overflow-y: auto;
}

.queue-row {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 6px 0;
    background: var(--surface);
    border-radius: var(--radius-s);
}

/* Elevated while being dragged so it reads as lifted off the list. */
.queue-row.dragging {
    opacity: 0.85;
    box-shadow: var(--shadow);
    position: relative;
    z-index: 2;
}

.queue-pos {
    width: 20px;
    text-align: right;
    color: var(--muted);
    font-size: 13px;
    font-variant-numeric: tabular-nums;
    flex-shrink: 0;
}

.queue-handle {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    flex-shrink: 0;
    color: var(--muted);
    cursor: grab;
    border-radius: var(--radius-s);
    /* Keep the grip from scrolling the list under touch; the rest of the row stays scrollable. */
    touch-action: none;
}

/* Extend the tappable/draggable area via a transparent pseudo-element, without growing the drawn
   30px grip or the row's height (an absolutely positioned pseudo-element never affects flow).
   Horizontal growth is capped at half the row's 4px gap: expanding the full 7px would make
   neighbouring targets overlap, so a tap on this control's visible edge would land on the next one. */
.queue-handle::before {
    content: "";
    position: absolute;
    inset: -7px -2px;
}

.queue-handle:active {
    cursor: grabbing;
}

.queue-handle svg {
    width: 18px;
    height: 18px;
}

.queue-handle:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
}

.queue-row .grow {
    cursor: pointer;
}

/* "Clear queue" is the only footer button now that the header X replaced "Close"; push it to the
   left so it never reads as the primary/right-aligned action. */
#queue-clear {
    margin-right: auto;
}

.queue-row .title {
    font-weight: 600;
    font-size: 14px;
    margin: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.queue-row .meta {
    color: var(--muted);
    font-size: 12px;
    margin: 0;
}

.queue-row .icon-btn {
    position: relative;
    width: 30px;
    height: 30px;
}

.queue-row .icon-btn svg {
    width: 15px;
    height: 15px;
}

/* Same hit-area expansion as .queue-handle above, with the same non-overlap cap: these row buttons
   sit 4px apart, so only vertical growth can be taken in full. */
.queue-row .icon-btn::before {
    content: "";
    position: absolute;
    inset: -7px -2px;
}

.status .toast-action {
    background: transparent;
    border: none;
    color: inherit;
    font-weight: 700;
    text-decoration: underline;
    padding: 0 0 0 12px;
}

/* ---------- Reader ---------- */

/* One topbar geometry for every view (reader / settings / storage / preview): the same negative
   inline margin on all of them so the back button, title and right-aligned action all sit at the
   article column's edges identically — the back arrow no longer jumps horizontally when switching
   between views. (Settings previously overrode margin-left to 0, which shifted its back arrow ~8px
   right of every other view.) */
.reader-topbar {
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 0 -8px;
}

/* The reader's topbar now lives inside .reader-content, not as a sibling of .reader-layout (see
   index.html), so back/title/menu share the article text's exact left/right edges (B4). It still
   needs the vertical gap the view's own flex column used to provide above the content below. */
.reader-content > .reader-topbar {
    margin-bottom: 16px;
}

.reader-topbar .title {
    flex: 1;
    min-width: 0;
    font-weight: 600;
    font-size: 15px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.reader-meta h2 {
    font-size: 28px;
    line-height: 1.25;
    margin: 4px 0 6px;
}

.reader-layout {
    display: grid;
    grid-template-columns: minmax(260px, 1fr) minmax(0, 820px) minmax(260px, 1fr);
    justify-content: center;
    align-items: start;
    gap: 32px;
}

/* The article text is the center column so it stays at the exact viewport centre (equal 1fr sides);
   the library rail is the FIRST (left) column and the third column stays empty (reserved). Explicit
   placement is required now that the player-panel aside is gone — otherwise the two remaining
   children would auto-fill columns 1 and 2, pushing the text off-centre. */
.reader-content {
    grid-column: 2;
    grid-row: 1;
    min-width: 0;
}

/* ---------- Reader library/up-next rail (left column, >=1180px) ---------- */

/* K3: border/radius/bg already matched the .card standard; only padding (14px) didn't — bumped to
   the shared 20px so the rail reads as the same "card" family as every other top-level box. */
.reader-library-rail {
    grid-column: 1;
    grid-row: 1;
    position: sticky;
    top: 84px;
    align-self: start;
    justify-self: end;
    display: flex;
    flex-direction: column;
    gap: 10px;
    width: 100%;
    max-width: 300px;
    padding: 20px;
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    background: var(--surface-raised);
}

.reader-library-rail > p {
    font-size: 12.5px;
    line-height: 1.45;
}

/* Groups the "Up next" and "Library" blocks so each keeps the rail's old internal rhythm (10px
   between heading/copy/list) even though they're now two sub-sections of one card instead of the
   rail's own flat list of children. */
.reader-rail-section {
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.reader-rail-heading,
.reader-rail-row,
.reader-rail-actions {
    display: flex;
    align-items: center;
}

.reader-rail-heading {
    justify-content: space-between;
    gap: 8px;
    font-weight: 700;
}

/* "View all" moved out of the heading to a quiet footer link (B3), so it no longer needs to look
   like a button competing with the section titles above it. */
.reader-rail-viewall {
    align-self: flex-start;
    margin-top: 2px;
    padding: 4px 2px;
    border: none;
    background: transparent;
    color: var(--accent);
    font-size: 12.5px;
    font-weight: 600;
}

.reader-rail-viewall:hover {
    background: transparent;
    border-color: transparent;
    text-decoration: underline;
}

.reader-rail-list {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.reader-rail-row {
    gap: 4px;
    min-width: 0;
    border: 1px solid transparent;
    border-radius: var(--radius-s);
    padding: 4px;
}

.reader-rail-row:hover,
.reader-rail-row.now-playing {
    border-color: var(--accent);
}

.reader-rail-open {
    flex: 1;
    min-width: 0;
    border: 0;
    background: transparent;
    text-align: left;
    padding: 7px 6px;
}

.reader-rail-open strong,
.reader-rail-open small {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.reader-rail-open strong {
    font-size: 13px;
}

.reader-rail-open small {
    margin-top: 2px;
    color: var(--muted);
    font-size: 11.5px;
}

.reader-rail-actions {
    flex-shrink: 0;
    gap: 2px;
}

.reader-rail-actions .icon-btn {
    width: 34px;
    height: 34px;
    border: 0;
}

.reader-rail-actions .icon-btn svg {
    width: 16px;
    height: 16px;
}

/* Reader "…" menu: a compact dialog whose buttons render as full-width, left-aligned rows
   (icon + label) instead of the default centered dialog-action buttons. */
#reader-menu-dialog {
    width: min(94vw, 320px);
}

#reader-menu-dialog .dialog-body {
    gap: 2px;
}

#reader-menu-dialog button {
    width: 100%;
    border: none;
    background: transparent;
    border-radius: var(--radius-s);
    text-align: left;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    font-weight: 500;
}

#reader-menu-dialog button:hover:not(:disabled) {
    background: var(--accent-soft);
    border-color: transparent;
    transform: none;
}

#reader-menu-dialog svg {
    width: 17px;
    height: 17px;
    flex-shrink: 0;
}

/* Library row "…" queue menu: same compact button-list look as the reader "…" menu just above. */
#row-queue-dialog {
    width: min(94vw, 320px);
}

#row-queue-dialog .dialog-body {
    gap: 2px;
}

#row-queue-dialog .dialog-body button {
    width: 100%;
    border: none;
    background: transparent;
    border-radius: var(--radius-s);
    text-align: left;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    font-weight: 500;
}

#row-queue-dialog .dialog-body button:hover:not(:disabled) {
    background: var(--accent-soft);
    border-color: transparent;
    transform: none;
}

#row-queue-dialog .dialog-body svg {
    width: 17px;
    height: 17px;
    flex-shrink: 0;
}

/* H1: keyboard-shortcuts reference, opened only via "?" (no menu entry point on purpose — see
   app.js) — a small dialog, one row per binding. */
#shortcuts-dialog {
    width: min(94vw, 380px);
}

.shortcut-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 6px 2px;
}

.shortcut-keys {
    display: flex;
    gap: 6px;
    flex-shrink: 0;
}

.shortcut-keys kbd,
.card p kbd {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 26px;
    height: 26px;
    padding: 0 7px;
    font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
    font-size: 12.5px;
    font-weight: 600;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-s);
    color: var(--text);
}

/* renderGenerationBanner() rewrites this on every segment-completion event, growing from a bare
   "preparing…" line to label + progress-track + the "already play" note once the first segment
   lands. With no reserved height that growth pushed #reader-text (and the reader's scroll position)
   down a line at a time, then snapped it back up the moment generation finished and the banner
   collapsed to .hidden. Sized to the tallest realistic render: the label line and the "You can
   already play the finished part." note can each wrap to 2 lines on a narrow reader column (14px
   font at the shared 1.55 line-height ≈ 22px/line — the same per-2-line figure #bookmark-hint/
   #queue-hint already reserve at 44px below), plus the fixed 6px progress-track and the two 8px gaps
   between the three rows, plus this box's own padding/border: 44 + 8 + 6 + 8 + 44 + 28 (padding) + 2
   (border) = 140px. A longer label (more concurrent generations, bigger segment counts) only adds
   digits, not lines, so it stays inside the same 2-line budget. An unusually long server error
   message in the failed sub-state is the one case min-height can't cap — it is a floor, not a
   ceiling, so that rare state is simply allowed to grow past it same as before this fix. */
.generation-banner {
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    background: var(--surface-raised);
    padding: 14px 16px;
    display: flex;
    flex-direction: column;
    gap: 8px;
    font-size: 14px;
    min-height: 140px;
}

.generation-banner .progress-track {
    height: 6px;
}

.reader-text {
    font-family: var(--font-serif);
    font-size: 19px;
    line-height: 1.85;
    padding-bottom: 8px;
}

/* Loading placeholder shown while a document's detail/alignment fetch is in flight: 7 pulsing line
   bars built entirely from CSS gradients (no images, CSP-safe pure-CSS animation) so no article ever
   pops in over an empty pane. The reserved min-height keeps the reader from jumping once the real
   text replaces it. */
.reader-skeleton {
    min-height: 420px;
    background-repeat: no-repeat;
    background-image:
        linear-gradient(var(--border) 100%, transparent 0),
        linear-gradient(var(--border) 100%, transparent 0),
        linear-gradient(var(--border) 100%, transparent 0),
        linear-gradient(var(--border) 100%, transparent 0),
        linear-gradient(var(--border) 100%, transparent 0),
        linear-gradient(var(--border) 100%, transparent 0),
        linear-gradient(var(--border) 100%, transparent 0);
    background-size:
        100% 15px, 92% 15px, 97% 15px, 60% 15px, 100% 15px, 88% 15px, 70% 15px;
    background-position:
        0 6px, 0 42px, 0 78px, 0 114px, 0 150px, 0 186px, 0 222px;
    animation: reader-skeleton-pulse 1.4s ease-in-out infinite;
}

@keyframes reader-skeleton-pulse {
    0%, 100% {
        opacity: 0.5;
    }

    50% {
        opacity: 1;
    }
}

.reader-paragraph {
    margin: 0 0 14px;
    padding: 6px 10px;
    border-radius: var(--radius-s);
    white-space: pre-wrap;
    transition: background 200ms ease;
}

.reader-paragraph.current {
    background: var(--highlight-paragraph);
}

.reader-word {
    border-radius: 4px;
    cursor: pointer;
}

.reader-word:hover {
    background: var(--accent-soft);
}

.reader-word.current {
    background: var(--highlight-word);
    color: var(--on-highlight-word);
    /* No font-weight change: bolding the active word re-measures the line and shifts the text.
       Background + color + ring mark the word without altering glyph metrics, so text stays static. */
    box-shadow: 0 0 0 2.5px var(--highlight-word);
}

/* Structured block styles. Headings/quotes/lists render distinctly while the word-span highlight
   machinery (and tap-to-seek offsets) stays identical across every block type. */
.reader-heading {
    font-weight: 700;
    line-height: 1.25;
    margin: 26px 0 10px;
}

.reader-h1 {
    font-size: 1.6em;
}

.reader-h2 {
    font-size: 1.35em;
}

.reader-h3 {
    font-size: 1.15em;
}

.reader-quote {
    font-style: italic;
    border-left: 3px solid var(--accent-soft);
    color: var(--muted);
}

/* Consecutive LIST_ITEM blocks are grouped by reader.js into one real <ul> (better semantics and a11y
   than a bare run of same-styled <li>s outside any list) — reset the browser's own <ul> marker/indent
   since .reader-list already draws its own bullet, and carry the paragraph-equivalent 14px trailing
   space on the GROUP rather than on every item (see .reader-list below) so a list reads as one tight
   unit instead of floating each line apart like a run of separate paragraphs. */
.reader-list-group {
    list-style: none;
    margin: 0 0 14px;
    padding: 0;
}

.reader-list {
    list-style: none;
    /* Tight vertical rhythm between items — .reader-list-group's own margin supplies the normal
       paragraph-sized gap once, after the whole list, not after every line. */
    margin: 0 0 2px;
    padding-left: 26px;
    position: relative;
}

.reader-list:last-child {
    margin-bottom: 0;
}

.reader-list::before {
    content: "•";
    position: absolute;
    left: 10px;
    color: var(--accent);
}

/* ---------- Reader images (IMAGE blocks) ---------- */

.reader-image-block {
    margin: 20px 0;
}

.reader-image {
    display: block;
    width: 100%;
    height: auto;
    border-radius: var(--radius-m);
    /* Faint placeholder tone while the bytes are still loading, instead of a stark blank rectangle. */
    background: var(--surface);
    /* The reserved box uses the image's own declared ratio (see reader.js), so cropping should be
       rare — object-fit: cover only matters if a source's declared width/height doesn't exactly match
       the real file, and is safer there than the default (which would stretch/distort instead). */
    object-fit: cover;
}

/* Only used when the source markup declared no width/height at all (see reader.js's buildImageBlock)
   — a generic placeholder ratio so at least some space is reserved, since there is nothing to compute
   an exact reservation from in that case. */
.reader-image-unsized {
    aspect-ratio: 16 / 9;
}

.reader-image-caption {
    margin: 8px 2px 0;
    font-size: 13px;
    color: var(--muted);
    text-align: center;
}

/* A failed/removed image collapses to its caption (or nothing, when there is no alt text) instead of
   ever showing the browser's own broken-image glyph, or leaving a large blank reserved box once the
   source is known to be dead — see reader.js's "error" listener on the <img>. */
.reader-image-block.reader-image-failed .reader-image {
    display: none;
}

.resume-sync {
    position: fixed;
    left: 50%;
    transform: translateX(-50%);
    bottom: calc(var(--player-bar-clearance, 0px) + 12px);
    z-index: 45;
    box-shadow: var(--shadow);
}

/* ---------- Player bar (floating, bottom-centre — ElevenReader-like) ---------- */

/* A floating rounded bar centred over the content, not a full-width dock. Its width, inset and
   radius are the same across every view; the single most important surface in the app. */
.player-bar {
    position: fixed;
    left: 50%;
    transform: translateX(-50%);
    /* env(): keep clear of iOS home-indicator / gesture areas; 0px everywhere else. */
    bottom: calc(12px + env(safe-area-inset-bottom, 0px));
    width: min(96vw, 860px);
    background: var(--surface-raised);
    border: 1px solid var(--border);
    border-radius: 18px;
    box-shadow: var(--shadow);
    padding: 14px 20px;
    display: flex;
    flex-direction: column;
    gap: 8px;
    z-index: 50;
}

/* The player state stacks the scrub row above the title/transport/extras row. (The generate,
   read-aloud and generating sub-states are single rows and lay themselves out directly.) */
#player-main {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

/* Stacked mode: the open article has no narration of its own (generate / generating sub-state)
   while another article keeps playing. Both zones share the bar — the open article's row on top,
   the ongoing playback below a hairline — so generation is always reachable without killing
   playback. The class is set in renderPlayerBarContent(). */
.player-bar.stacked #player-main {
    border-top: 1px solid var(--border);
    padding-top: 10px;
}

/* Dismiss (stop playback + close the bar): quiet icon button at the end of the scrub row, so it
   never overlaps the duration label and is present whenever the player sub-state is. */
#player-dismiss {
    width: 28px;
    height: 28px;
    margin-left: 2px;
    border: none;
    background: transparent;
    color: var(--muted);
}

#player-dismiss:hover:not(:disabled) {
    color: var(--text);
    background: var(--accent-soft);
    border-color: transparent;
}

#player-dismiss svg {
    width: 16px;
    height: 16px;
}

/* Position/scrub/duration merged into one row: the time labels get a fixed min-width (character-based,
   not pixel) so digit-count changes (0:09 -> 0:10) never shift the scrubber (no layout jump). */
.player-bar .scrub-row {
    display: flex;
    align-items: center;
    gap: 8px;
}

.player-bar .scrub-row input[type="range"] {
    flex: 1;
    min-width: 0;
    width: auto;
    margin: 0;
}

.player-bar .scrub-time {
    flex: none;
    min-width: 5ch;
    font-size: 12.5px;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}

#player-position.scrub-time {
    text-align: left;
}

#player-duration.scrub-time {
    text-align: right;
}

/* Row 2: three zones on one line. The left (title) and right (extras) zones flex from a 0 basis
   and can shrink to nothing (min-width: 0), so they stay equal width and keep the fixed-size centre
   transport horizontally centred; the row never shifts as the title length changes. Below 800px the
   extras zone stops growing so its icons never overflow into the transport (see the media queries). */
.player-mainrow {
    display: flex;
    align-items: center;
    gap: 12px;
}

.player-zone-lead {
    flex: 1 1 0;
    min-width: 0;
    display: flex;
    align-items: center;
}

.player-transport {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
}

.player-extras {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 4px;
}

@media (min-width: 800px) {
    .player-extras {
        flex: 1 1 0;
        min-width: 0;
    }
}

/* Left zone: the always-available "now playing" title. A caption sits above the article title,
   which truncates with an ellipsis capped near 34 characters. The whole zone is one button that
   opens the playing article, so it stays clickable and its height never changes with the text. */
.player-context {
    display: flex;
    align-items: center;
    min-width: 0;
}

.player-context-open {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 1px;
    min-width: 0;
    max-width: 34ch;
    padding: 4px 6px;
    border: none;
    background: transparent;
    border-radius: var(--radius-s);
    text-align: left;
}

.player-context-open:hover:not(:disabled) {
    background: var(--accent-soft);
    border-color: transparent;
}

.player-context-caption {
    font-size: 10.5px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--muted);
    line-height: 1.2;
}

.player-context-title {
    display: block;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 14px;
    font-weight: 600;
    line-height: 1.3;
    color: var(--text);
}

/* Centre transport (player v3 proportions): 48px skip buttons flanking the 60px round primary
   play/pause. The skip buttons are plain (no border/fill) so only the play button carries the accent,
   and each shows its configured skip interval as a number centred inside the circular-arrow icon. */
.player-transport .play-btn {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    background: var(--accent);
    color: var(--on-accent);
    border-color: var(--accent);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
}

.player-transport .play-btn:hover:not(:disabled) {
    background: var(--accent);
    border-color: var(--accent);
}

.player-transport .play-btn svg {
    width: 26px;
    height: 26px;
}

.player-transport .icon-btn {
    width: 48px;
    height: 48px;
    border: none;
    background: transparent;
    color: var(--text);
}

.player-transport .icon-btn:hover:not(:disabled) {
    background: var(--accent-soft);
    border-color: transparent;
}

.player-transport .icon-btn svg {
    width: 24px;
    height: 24px;
}

/* The skip interval (e.g. "15") sits absolutely centred in the open middle of the ring icon. It
   rides on currentColor and is aria-hidden — the seconds are voiced through the button's
   aria-label. The 1px nudge matches the ring centre of the redrawn icons (see #i-skip-back/-fwd:
   ring centre y=13 in a 24px viewBox → +1px at the rendered 24px size). */
.player-skip {
    position: relative;
}

.skip-num {
    position: absolute;
    top: calc(50% + 1px);
    left: 50%;
    transform: translate(-50%, -50%);
    font-size: 10px;
    font-weight: 600;
    line-height: 1;
    color: currentColor;
    font-variant-numeric: tabular-nums;
    pointer-events: none;
}

/* Right zone: speed plus the icon-only extras (voice, up-next, sleep, bookmarks, save position).
   The extras carry a visually-hidden text label at every width now — their live state lives on the
   button's aria-label instead — so the row width never changes with queue count or sleep state. */
.player-extras .icon-btn {
    border: none;
    background: transparent;
    color: var(--muted);
}

.player-extras .icon-btn:hover:not(:disabled) {
    color: var(--text);
    background: var(--accent-soft);
    border-color: transparent;
}

.player-extras .icon-btn.active {
    color: var(--accent);
}

.player-bar .speed-btn {
    width: 56px;
    min-width: 56px;
    height: 40px;
    padding: 0;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
    border: none;
    background: transparent;
    color: var(--text);
}

/* The extras' text labels are always hidden (icon-only); the id selector repeats so it wins over
   the base #player-queue-label rule that would otherwise re-show the queue count. */
.player-extras .extras-label,
#player-queue-label {
    display: none;
}

/* Segment prev/next only apply to a multi-segment narration (toggled in app.js's updatePlayerUi).
   Like #bookmark-hint's .hidden override elsewhere in this file, keep the .hidden state's box in
   flow (just invisible) instead of the usual display:none collapse, so the rest of this row — whose
   whole point is a width that never changes (see the comment above) — never shifts either. */
#player-prev-segment.hidden,
#player-next-segment.hidden {
    display: inline-flex !important;
    visibility: hidden;
}

/* The duration label doubles as a button: click/Enter/Space toggle total vs. remaining time. */
.player-bar .time-toggle {
    cursor: pointer;
    border-radius: 4px;
}

.player-bar .time-toggle:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

#settings-save-prefs {
    min-width: 154px;
}

#settings-save-prefs.saved {
    background: var(--accent-soft);
    color: var(--accent);
}

/* Generate sub-state: a centred button row with the voice chip and read-aloud beside the prominent
   filled "Generate audio" primary, plus a cost line below so the price is visible before generating. */
.player-bar .generate-substate {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.player-bar .generate-row {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
}

/* Character/segment/cost summary under the generate button. Hidden when it has no text so it never
   reserves an empty gap. */
.generate-cost-line {
    text-align: center;
    font-size: 12px;
}

.generate-cost-line:empty {
    display: none;
}

.player-bar .generate-row .primary {
    flex: 1;
}

/* "Read aloud" secondary action: a quiet outline button beside the filled "Generate audio" primary
   so it stays available without competing with generation. */
.player-bar .generate-row #generate-read-aloud {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    white-space: nowrap;
}

/* Active read-aloud bar: a compact status row with pause/stop, shown in place of the player. */
.player-bar .readaloud-row {
    display: flex;
    align-items: center;
    gap: 10px;
}

.player-bar .readaloud-row > svg.icon {
    flex: none;
    color: var(--accent);
}

.player-bar .readaloud-row .grow {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Mid-generation player state: keep the voice picker reachable via a chip beside the progress. */
.player-bar .generating-row {
    display: flex;
    align-items: center;
    gap: 12px;
}

.player-bar .generating-progress {
    flex: 1;
    min-width: 0;
}

/* Quiet "Cancel" beside the progress bar: aborts the in-flight generation fetch. */
.generating-cancel {
    flex: none;
    padding: 6px 12px;
    border: 1px solid var(--border);
    background: transparent;
    color: var(--muted);
    font-size: 13px;
    border-radius: var(--radius-s);
    cursor: pointer;
}

.generating-cancel:hover:not(:disabled) {
    color: var(--text);
    border-color: var(--accent);
}

/* ---------- Dialogs ---------- */

/* Every dialog shares one skeleton: a fixed max-height box laid out as a flex column (header /
   body / actions), with the body as the single internal scroll region. That way the header and
   footer always stay pinned and only the middle ever scrolls, no matter how much content a given
   dialog holds — the sizing/scroll behavior that #voice-dialog and #export-dialog pioneered is
   now the baseline for all 14 dialogs, not a special case. */
dialog {
    border: 1px solid var(--border);
    border-radius: var(--radius-l);
    background: var(--surface-raised);
    color: var(--text);
    padding: 26px 28px;
    width: min(94vw, 760px);
    max-height: min(85vh, 900px);
    box-shadow: var(--shadow);
    /* Explicit centring rather than trusting the UA default: some mobile WebView stylesheets ship
       a zero margin for dialog, which pins the box to the top-left of the viewport. */
    margin: auto;
}

/* Preserve the native closed state even when a specific dialog uses flex while open. Without this
   rule the Narration picker remains in normal page flow after close(), including on sign-in. */
dialog:not([open]) {
    display: none;
}

dialog[open] {
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

dialog::backdrop {
    background: rgba(10, 14, 12, 0.5);
    backdrop-filter: blur(2px);
}

dialog h3 {
    margin: 0;
    font-family: var(--font-serif);
    font-size: 24px;
}

.dialog-header {
    flex: none;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
}

.dialog-close {
    flex: none;
}

/* K2 root cause: setting overflow-y: auto here (needed so only the body scrolls, never the whole
   dialog) makes the browser silently compute overflow-x as "auto" too — per the CSS Overflow spec,
   if one axis is non-visible and the other would be visible, the visible one is forced to "auto"
   instead. Every focus-visible outline (offset 1px + 2px width = 3px of bleed) and the voice dialog's
   selected-format-option box-shadow (K4, 1px of bleed) on a child flush against the body's own edge
   then gets clipped left/right, reading as a randomly "cut" ring. The fix insets the body by 4px on
   each side via matching negative margin + padding, which cancels out to the exact same visible
   width/position for its children (nothing moves, no layout jump) while giving 4px of slack inside
   the body's own border box — comfortably more than the 3px/1px of bleed above — for outlines/shadows
   to render into before they'd reach the now-auto overflow-x clip edge.
   Same story on the bottom edge: a control on the LAST row (e.g. the labels dialog's "New label"
   input) has its focus ring clipped there too, since overflow-y: auto leaves zero slack below the
   last child — you'd see the ring's inner offset gap but not the ring itself. -4px margin-bottom +
   4px padding-bottom cancel out the same way (the dialog-actions divider below stays pixel-put). */
dialog .dialog-body {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    gap: 12px;
    margin: 10px -4px -4px;
    padding: 0 4px 4px;
    overflow-y: auto;
    overscroll-behavior: contain;
}

dialog .dialog-actions {
    flex: none;
    display: flex;
    gap: 8px;
    justify-content: flex-end;
    margin-top: 14px;
}

.chip-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
}

/* Custom labels accumulate over a library's lifetime with no cap, so an unbounded #labels-chips grows
   the unpinned #labels-dialog and re-centres it every time a genuinely new label is added (toggling
   an *existing* chip only swaps its colour/border via .selected — that never changes the chip's size,
   so it stays safe without this fix). Same fixed-height-plus-internal-scroll idea as #queue-list
   above and #bookmark-list below: a `height` (not just a max) keeps the box the same size at 0 chips
   and at 50.
   button.chip rows are ~36px tall (13px font at the shared 1.55 line-height + 7px×2 padding + 1px×2
   border) with .chip-row's own 8px gap between wrapped rows; 3 rows fit inside 128px (3×36 + 2×8 =
   124, rounded up), leaving the "New label" input below always reachable without scrolling past it. */
#labels-chips {
    height: 128px;
    max-height: 128px;
    overflow-y: auto;
}

.option-row {
    display: flex;
    align-items: center;
    gap: 12px;
    min-height: 54px;
    padding: 11px 13px;
    border-radius: var(--radius-s);
    cursor: pointer;
    border: 1px solid var(--border);
    background: var(--surface-raised);
}

.option-row:hover {
    background: var(--accent-soft);
}

.option-row.selected {
    border-color: var(--accent);
    background: var(--accent-soft);
}

.option-row .grow {
    flex: 1;
    min-width: 0;
}

.option-row .title {
    font-weight: 600;
    font-size: 14px;
    margin: 0;
}

.option-row .meta {
    color: var(--muted);
    font-size: 12.5px;
    margin: 0;
}

.option-row .radio {
    width: 18px;
    height: 18px;
    border-radius: 50%;
    border: 2px solid var(--muted);
    flex-shrink: 0;
}

.option-row.selected .radio {
    border-color: var(--accent);
    background: radial-gradient(var(--accent) 0 45%, transparent 50%);
}

.option-row .icon-btn {
    width: 32px;
    height: 32px;
}

.option-row .icon-btn svg {
    width: 15px;
    height: 15px;
}

/* B1: bookmark rows get their own dedicated look (time pill + note + jump/delete) instead of the
   shared .option-row used by voice/output/label pickers, since the shape here — a fixed-width
   monospace time badge, an optional note, then two icon actions — doesn't fit that generic layout.
   W2: the dialog itself only carries a max-height (see the shared `dialog` rule), so it still grows
   and shrinks with the list below that cap — every bookmark added/removed jumped the whole dialog.
   A single `height` (not just a min/max range — measured live on the twin #queue-list fix: a range
   still let the box grow/shrink between the floor and the ceiling) makes this box exactly the same
   size at 0 bookmarks and at 50, with its own scroll past that — the same "fixed box + internal
   scroll" idea #voice-dialog uses, just scoped to the list instead of the whole dialog (the note
   input above it should never scroll out of view). */
#bookmark-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
    height: 220px;
    max-height: 220px;
    overflow-y: auto;
}

/* Neither #bookmarks-dialog nor #queue-dialog has an id-level height, so any change in the content
   ABOVE the fixed-height list (#bookmark-list/#queue-list) still resizes and re-centres the whole
   dialog. #queue-hint swaps between a one-line and a two-line message depending on whether the
   queue is empty; #bookmark-hint is shown/hidden depending on whether the open article is the one
   playing. Reserve the taller two-line height on both so neither the line count nor the visibility
   toggle changes the dialog height — #bookmark-hint stays in flow (just invisible) instead of the
   usual .hidden collapse so hiding it can't shrink the dialog either. */
#bookmark-hint,
#queue-hint {
    min-height: 44px;
}

#bookmark-hint.hidden {
    display: block !important;
    visibility: hidden;
}

/* Same fix as #bookmark-hint just above: the #edit-body "input" listener toggles this notice the
   instant the trimmed body first differs from the saved one, and #edit-dialog has no id-level height
   of its own, so the plain .hidden collapse visibly grew and re-centred the whole dialog around
   whatever key the user had just pressed. The warning's text is static — only its visibility ever
   changes — so keeping it in flow reserves exactly its own height automatically, with no min-height
   number to compute or get wrong. */
#edit-warning.hidden {
    display: block !important;
    visibility: hidden;
}

.bookmark-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 12px;
    border-radius: var(--radius-s);
    background: var(--surface);
    border: 1px solid var(--border);
}

.bookmark-row:hover {
    background: var(--accent-soft);
}

.bookmark-time {
    flex: 0 0 auto;
    font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
    font-size: 12.5px;
    font-weight: 600;
    padding: 3px 9px;
    border-radius: 999px;
    background: var(--accent-soft);
    color: var(--accent);
}

.bookmark-note {
    flex: 0 1 auto;
    min-width: 0;
    max-width: 60%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 14px;
}

.bookmark-note.muted {
    color: var(--muted);
}

.bookmark-row .icon-btn {
    flex-shrink: 0;
    width: 32px;
    height: 32px;
}

.bookmark-row .icon-btn svg {
    width: 15px;
    height: 15px;
}

/* B4: Import cleanup phrase manager (ChatGPT-memories style add/edit/delete rows instead of a bare
   textarea). Row shape mirrors .bookmark-row (12px padding, radius-s, 1px border, 10px gap) so the
   two "list of small text items" cards in this app read as one system. */
.cleanup-phrase-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.cleanup-phrase-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 12px;
    border-radius: var(--radius-s);
    background: var(--surface);
    border: 1px solid var(--border);
}

.cleanup-phrase-row:hover {
    background: var(--accent-soft);
}

.cleanup-phrase-text {
    flex: 1 1 auto;
    min-width: 0;
    font-size: 14px;
    overflow-wrap: break-word;
}

/* Editing swaps the static text for this input in place. It keeps the shared input's border/
   background/radius/focus (unlike an earlier version that stripped them to `border:none;
   background:transparent`, which left a bare, tightly-wrapped input floating inside the row's own
   12px padding — the row visibly shrank to the input's one-line height, and the focus ring then hugged
   that small box instead of the row, reading as a ring nested oddly inside the field). Only height/
   padding are overridden — matching the icon buttons' 32px so the row's total height (32px content +
   24px padding + 2px border) is pixel-identical to the resting row instead of shrinking. The extra
   `.cleanup-phrase-row` ancestor in the selector is needed to out-specificity the shared
   `input[type="text"]` rule (an attribute selector already outweighs a single class) rather than
   relying on source order alone. */
.cleanup-phrase-row .cleanup-phrase-input {
    flex: 1 1 auto;
    width: auto;
    min-width: 0;
    min-height: 32px;
    height: 32px;
    padding: 0 10px;
    font-size: 14px;
}

.cleanup-phrase-row .icon-btn {
    flex-shrink: 0;
    width: 32px;
    height: 32px;
}

.cleanup-phrase-row .icon-btn svg {
    width: 15px;
    height: 15px;
}

.speed-value {
    font-size: 24px;
    font-weight: 600;
    text-align: center;
    font-variant-numeric: tabular-nums;
}

/* ---------- Speed dialog (Pocket Casts style) ---------- */

/* Narrow, focused sheet. A fixed width keeps the segmented control, stepper and presets from
   reflowing as the value changes; nothing inside ever scrolls horizontally. */
#speed-dialog {
    width: min(94vw, 380px);
}

.speed-dialog-body {
    display: flex;
    flex-direction: column;
    gap: 16px;
    /* The base .dialog-body uses negative side margins + overflow-y:auto, which the browser upgrades
       to overflow-x:auto — the native range thumb then spills a couple of px and shows a horizontal
       scrollbar. Clip it: nothing here needs horizontal scroll and the thumb stays fully visible at
       the value positions the presets/stepper use. */
    overflow-x: hidden;
}

/* Two-button segmented control: "All articles | This article". An always-present border and a
   background swap on the active segment mean selecting never changes size or position. */
.segmented {
    display: flex;
    gap: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    padding: 3px;
    background: var(--surface);
}

.segment-btn {
    flex: 1 1 0;
    min-width: 0;
    padding: 8px 10px;
    border: none;
    border-radius: calc(var(--radius-m) - 3px);
    background: transparent;
    color: var(--muted);
    font-size: 13.5px;
    font-weight: 600;
    text-align: center;
    cursor: pointer;
}

.segment-btn[aria-pressed="true"] {
    background: var(--accent);
    color: var(--on-accent);
}

.speed-scope-note {
    text-align: center;
    font-size: 12.5px;
    margin-top: -8px;
}

/* Big − / + round buttons flanking the large value readout. */
.speed-stepper {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 18px;
}

.speed-step-btn {
    width: 48px;
    height: 48px;
    flex: none;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: var(--surface-raised);
    color: var(--text);
    font-size: 24px;
    font-weight: 600;
    line-height: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    user-select: none;
}

.speed-step-btn:hover:not(:disabled) {
    border-color: var(--accent);
    color: var(--accent);
}

#speed-value {
    font-size: 32px;
    min-width: 4ch;
}

/* Exactly three quiet text presets on one non-scrolling row. */
.speed-presets {
    display: flex;
    gap: 8px;
}

.speed-presets button {
    flex: 1 1 0;
    min-width: 0;
    padding: 8px 0;
    border: 1px solid transparent;
    background: transparent;
    color: var(--muted);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
    border-radius: var(--radius-s);
}

.speed-presets button:hover {
    color: var(--text);
    background: var(--accent-soft);
}

.speed-presets button.selected {
    color: var(--accent);
    background: var(--accent-soft);
}

/* ---------- Sleep dialog ---------- */

/* renderSleepDialog() toggles this ~90px block (the countdown + "Cancel timer" button) with the
   plain .hidden utility, and #sleep-dialog has no id-level height either — so removing it from flow
   shrinks and re-centres the whole dialog around whatever the pointer was just over, including the
   Cancel-timer click itself (it re-renders without closing the dialog) and the 1s countdown ticker
   doing the same the instant a running timer expires while the dialog is still open. Same fix as
   #bookmark-hint/#edit-warning above: keep it in flow and hide with visibility instead of display, so
   the box always occupies the space its own (unchanging) markup needs — #sleep-countdown's digits
   change but stay one tabular-nums line, so that never grows the block either — whether a timer is
   running or not. */
#sleep-active.hidden {
    display: block !important;
    visibility: hidden;
}

/* ---------- Settings ---------- */

.settings-grid {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

#settings-providers,
#settings-key-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

/* renderSecretExtraFields() rebuilds this with one <label>+<input> per extra credential field on
   every provider switch — 0 fields for most providers, 1 for Azure (region), 2 for Amazon Polly
   (access key id + region, the only provider needing that many). With no reserved space, picking a
   provider that needs more fields than the previous one pushed the "Save key" row, the pricing card
   and every settings card below it down the page. Reserve height unconditionally for the largest
   provider (Polly) so the box never resizes on selection, at the cost of some empty space under
   single-field providers — an accepted trade-off for a box that must never move. Each field is
   ~65px (13px label line at the shared 1.55 line-height + 5px margin + the shared 40px input height);
   two fields plus the 8px gap between them — matching #settings-key-list's own rhythm right above —
   is 65 + 8 + 65 = 138px, rounded up. */
#settings-secret-extra {
    display: flex;
    flex-direction: column;
    gap: 8px;
    min-height: 140px;
}

/* renderRateOverride()'s list/custom radio choice toggles this single <label>+<input> with the plain
   .hidden utility on every click, for every provider that shows pricing — the most common interaction
   in this card — and #settings-rate-override has no id-level height, so showing "Custom rate" pushed
   the Save-pricing button and everything below it down the page. Same fix as #sleep-active/
   #edit-warning above: the field's own text and input never change, only its visibility, so keeping
   it in flow reserves exactly its own height with no min-height number needed. */
#settings-rate-value-wrap.hidden {
    display: block !important;
    visibility: hidden;
}

.setting-choice-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin: 12px 0;
}

.setting-choice,
button.setting-choice-button {
    width: 100%;
    min-height: 62px;
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 12px 14px;
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    background: var(--surface-raised);
    color: var(--text);
    text-align: left;
    cursor: pointer;
}

.setting-choice:has(input:checked),
button.setting-choice-button.selected {
    border-color: var(--accent);
    background: var(--accent-soft);
}

.setting-choice input {
    width: 18px;
    height: 18px;
    margin: 0;
    accent-color: var(--accent);
    flex: none;
}

.setting-choice span,
.setting-choice-button span {
    display: flex;
    flex-direction: column;
    gap: 2px;
    white-space: normal;
}

.setting-choice small,
.setting-choice-button small {
    color: var(--muted);
    font-size: 12.5px;
    font-weight: 400;
}

.setting-choice-button[aria-checked="true"]::before {
    content: "";
    width: 18px;
    height: 18px;
    flex: none;
    border-radius: 50%;
    border: 2px solid var(--accent);
    background: radial-gradient(var(--accent) 0 42%, transparent 48%);
}

.setting-choice-button[aria-checked="false"]::before {
    content: "";
    width: 18px;
    height: 18px;
    flex: none;
    border-radius: 50%;
    border: 2px solid var(--muted);
}

.preference-field {
    display: grid;
    grid-template-columns: minmax(120px, 1fr) auto;
    align-items: center;
    gap: 8px 16px;
    padding: 14px 0;
    border-top: 1px solid var(--border);
}

.preference-label {
    font-size: 14px;
    font-weight: 650;
}

.number-stepper {
    display: flex;
    align-items: center;
    gap: 6px;
}

.number-stepper button {
    width: 42px;
    height: 42px;
    padding: 0;
    font-size: 20px;
    line-height: 1;
    display: inline-grid;
    place-items: center;
    border-radius: var(--radius-s);
}

.number-stepper label {
    width: 76px;
}

.number-stepper input {
    height: 42px;
    padding: 7px 8px;
    text-align: center;
    font-variant-numeric: tabular-nums;
}

.stepper-unit {
    min-width: 58px;
    color: var(--muted);
    font-size: 13px;
}

.preset-row {
    grid-column: 2;
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
}

.preset-row button {
    min-width: 54px;
    height: 42px;
    padding: 0 12px;
    border-radius: var(--radius-s);
    font-size: 14px;
    line-height: 1;
}

.preset-row button.selected {
    border-color: var(--accent);
    background: var(--accent-soft);
    color: var(--accent);
}

@media (max-width: 620px) {
    .preference-field {
        grid-template-columns: 1fr;
    }

    .preset-row {
        grid-column: 1;
    }

    .number-stepper {
        justify-content: space-between;
    }
}

.storage-track {
    width: 100%;
    height: 8px;
    overflow: hidden;
    border-radius: 999px;
    background: var(--border);
}

.storage-fill {
    width: 0;
    height: 100%;
    border-radius: inherit;
    background: var(--accent);
    transition: width 180ms ease;
}

/* ---------- Storage management view ---------- */

/* Per-month cost history rows: a compact stacked list under the totals in the Costs card. */
.storage-costs-months {
    display: flex;
    flex-direction: column;
    gap: 2px;
    margin: 6px 0;
}

.storage-controls {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    align-items: end;
    gap: 12px;
}

.storage-search-label {
    grid-column: span 2;
}

.storage-controls label {
    display: flex;
    flex-direction: column;
    gap: 5px;
    font-size: 13px;
    font-weight: 500;
    color: var(--muted);
}

.storage-selectall {
    flex-direction: row !important;
    align-items: center;
    gap: 8px;
    min-height: 39px;
    font-size: 13px;
    font-weight: 500;
    color: var(--text);
    cursor: pointer;
}

.storage-selectall input {
    width: auto;
    margin: 0;
}

.storage-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.storage-row {
    background: var(--surface-raised);
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    overflow: hidden;
}

.storage-row-main {
    padding: 12px 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    /* S1/S3: the whole row (not just the title button) toggles selection — see the click/drag
       wiring in storage.js. The cursor here is the only visual cue since the row itself has no
       hover background of its own (buttons/inputs inside keep their normal appearance). */
    cursor: pointer;
}

/* S3: suppress text selection app-wide while a drag-select gesture is in progress over the storage
   list — otherwise every browser starts selecting row text as the pointer moves mid-drag. */
body.is-drag-selecting,
body.is-drag-selecting * {
    user-select: none;
}

.storage-row.selected {
    border-color: var(--accent);
    background: var(--accent-soft);
}

.storage-row .storage-check {
    width: auto;
    margin: 0;
    flex-shrink: 0;
    cursor: pointer;
}

.storage-row .storage-open {
    flex: 1;
    min-width: 0;
    padding: 2px 4px;
    border: 0;
    border-radius: var(--radius-s);
    background: transparent;
    text-align: left;
    white-space: normal;
}

.storage-row .storage-open:hover:not(:disabled) {
    transform: none;
    background: var(--accent-soft);
    border-color: transparent;
}

.storage-row h4 {
    margin: 0 0 3px;
    font-size: 15px;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}

.storage-row .meta {
    color: var(--muted);
    font-size: 12.5px;
    margin: 0;
}

.storage-row .badge {
    margin-left: 6px;
}

.storage-row .icon-btn {
    width: 34px;
    height: 34px;
    flex-shrink: 0;
}

.storage-row .icon-btn svg {
    width: 17px;
    height: 17px;
}

.storage-row .storage-star.pinned {
    color: var(--highlight-word);
}

.storage-row .storage-listen.playing {
    color: var(--accent);
}

.storage-output-list {
    padding: 0 16px 14px 46px;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 8px;
}

.storage-output {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    min-width: 0;
    padding: 10px 12px;
    border: 1px solid var(--border);
    border-radius: var(--radius-s);
    background: var(--surface);
    cursor: pointer;
}

.storage-output:hover,
.storage-output.selected {
    border-color: var(--accent);
    background: var(--accent-soft);
}

.storage-output-check {
    flex: 0 0 auto;
    width: 18px;
    height: 18px;
    margin: 2px 0 0;
    accent-color: var(--accent);
}

.storage-output-copy {
    display: flex;
    flex-direction: column;
    min-width: 0;
    gap: 2px;
}

.storage-output-copy strong,
.storage-output-copy span,
.storage-output-copy small {
    overflow: hidden;
    text-overflow: ellipsis;
}

.storage-output-copy span,
.storage-output-copy small {
    color: var(--muted);
}

.storage-output-copy small {
    font-size: 11px;
}

.storage-pagination {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 14px;
}

.storage-pagination span {
    min-width: 110px;
    text-align: center;
    color: var(--muted);
    font-size: 13px;
}

/* Bottom selection action bar, styled like the player bar. Stacked above .player-bar (not just
   layered under it) via --player-bar-clearance, kept live from app.js, so loaded narration and a
   Storage multi-select never overlap. */
.storage-action-bar {
    position: fixed;
    left: 50%;
    transform: translateX(-50%);
    bottom: max(var(--player-bar-clearance, 0px), env(safe-area-inset-bottom, 0px));
    width: min(100vw, var(--max-width));
    background: var(--surface-raised);
    border: 1px solid var(--border);
    border-bottom: none;
    border-radius: var(--radius-l) var(--radius-l) 0 0;
    box-shadow: var(--shadow);
    padding: 14px 22px 18px;
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
    z-index: 60;
}

.storage-selection-label {
    font-weight: 600;
    font-size: 14px;
}

.storage-action-buttons {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-left: auto;
    flex-wrap: wrap;
}

.storage-delete-after {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 13px;
    color: var(--muted);
    white-space: nowrap;
}

@media (max-width: 900px) {
    .storage-controls {
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }

    .storage-search-label {
        grid-column: 1 / -1;
    }
}

@media (max-width: 620px) {
    .storage-controls {
        grid-template-columns: 1fr;
    }

    .storage-row-main {
        gap: 7px;
        padding: 10px;
    }

    .storage-output-list {
        grid-template-columns: 1fr;
        padding: 0 10px 10px;
    }
}

/* ---------- Auto-cleanup card ---------- */

.cleanup-field {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.cleanup-inline {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    font-size: 14px;
    color: var(--text);
    font-weight: 500;
}

.cleanup-inline input[type="number"] {
    width: 90px;
}

.cleanup-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 4px;
}

/* Sub-controls that only take effect while their feature toggle is on. Dimmed and made inert when
   off (kept in the DOM, not hidden, so toggling the feature never resizes the card — no layout jump). */
.cleanup-body {
    display: flex;
    flex-direction: column;
    gap: 10px;
    transition: opacity 140ms ease;
}

.cleanup-body.is-disabled,
.cleanup-inline.is-disabled {
    opacity: 0.4;
    pointer-events: none;
}

/* ---------- Listening stats view ---------- */

/* Session facts on the account card. The values start as an em dash and are replaced with a
   formatted date once /api/me resolves, so each value reserves a fixed two-line box up front: a
   locale date/time wraps to two lines on a narrow phone, and letting the box grow at that moment
   would shove the sign-out buttons down under the reader's finger. */
.session-facts {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: 14px;
    margin: 12px 0 0;
}

.session-fact {
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.session-fact dt {
    margin: 0;
    font-size: 12px;
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.session-fact dd {
    margin: 0;
    font-size: 15px;
    line-height: 1.35;
    min-height: 2.7em;
}

.stats-lifetime-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
    gap: 14px;
    margin: 4px 0 0;
}

.stats-stat {
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.stats-stat-value {
    margin: 0;
    font-family: var(--font-serif);
    font-size: 22px;
}

.stats-stat-label {
    margin: 0;
    font-size: 12px;
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

/* Fixed height regardless of the selected range (7/30/90/365 days) or the loading -> loaded
   transition, so switching ranges never changes the card's height — the owner's no-layout-shift
   rule. Bars fill it proportionally to whichever day in range has the most listening; the visual
   chart is aria-hidden via role="img" (read as one labeled unit) — #stats-chart-table right below it
   is the real per-day data for a screen-reader user, see index.html. */
.stats-chart {
    display: flex;
    align-items: flex-end;
    gap: 2px;
    height: 160px;
    padding: 8px 2px 0;
    margin-top: 8px;
    border-bottom: 1px solid var(--border);
    overflow-x: auto;
}

.stats-bar-col {
    flex: 1 1 0;
    min-width: 2px;
    height: 100%;
    display: flex;
    align-items: flex-end;
}

.stats-bar {
    width: 100%;
    min-height: 0;
    background: var(--accent);
    border-radius: 2px 2px 0 0;
    transition: height 200ms ease;
}

/* Warning-toned note (settings, or the voice dialog's free-tier-exceeded line). */
.setting-help.is-warn,
.voice-cost-line.is-warn {
    color: var(--danger);
    font-weight: 500;
}

/* Feature on/off toggles render as a switch so the active state is unmistakable at a glance.
   Fixed track + knob: only the background colour and the knob offset animate, so toggling never
   resizes the row or shifts the label (no layout jump). */
.toggle-row {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 0.5rem 0;
    cursor: pointer;
    font-weight: 500;
    color: var(--text);
}

.toggle-row input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    flex: none;
    width: 44px;
    height: 26px;
    margin: 0;
    padding: 0;
    border: none;
    border-radius: 999px;
    background: var(--switch-off);
    position: relative;
    cursor: pointer;
    transition: background 140ms ease;
}

.toggle-row input[type="checkbox"]::before {
    content: "";
    position: absolute;
    top: 3px;
    left: 3px;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--switch-knob);
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
    transition: transform 140ms ease;
}

.toggle-row input[type="checkbox"]:checked {
    background: var(--accent);
}

.toggle-row input[type="checkbox"]:checked::before {
    transform: translateX(18px);
}

.toggle-row input[type="checkbox"]:disabled {
    opacity: 0.45;
    cursor: default;
}

.toggle-row input[type="checkbox"]:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* Native selection checkboxes (multi-select rows, inline options): a bigger hit target and an accent
   tint so ticked vs unticked reads clearly, without altering surrounding layout. */
.checkbox-row input[type="checkbox"],
.storage-row .storage-check,
.storage-output-check,
.storage-selectall input[type="checkbox"],
.storage-delete-after input[type="checkbox"] {
    width: 18px;
    height: 18px;
    accent-color: var(--accent);
    cursor: pointer;
}

/* Provider API-key rows: status badge, last-test line, help link, test button. */

.help-link {
    color: var(--accent);
    font-size: 13px;
    font-weight: 500;
    text-decoration: none;
    white-space: nowrap;
}

.help-link:hover {
    text-decoration: underline;
}

.badge {
    display: inline-block;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.02em;
    padding: 2px 8px;
    border-radius: 999px;
    margin-left: 8px;
    vertical-align: middle;
}

.badge-ok {
    background: var(--accent-soft);
    color: var(--accent);
}

.badge-off {
    background: var(--warning-soft);
    color: var(--muted);
}

.key-test-btn {
    padding: 6px 14px;
    font-size: 13px;
}

.key-test-btn.busy {
    opacity: 0.6;
}

.key-test-result.ok {
    color: var(--accent);
}

.key-test-result.err {
    color: var(--danger);
}

/* Cost-awareness line under the provider chips: how much text this document sends before Generate. */
.voice-cost-line {
    margin: 2px 0 0;
    font-size: 13px;
}

.voice-default-row {
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
    margin-top: 10px;
}

.voice-default-row .muted {
    font-size: 13px;
}

/* Client-side filter above the voice list; only shown for large catalogs (>12 voices). */
.voice-filter {
    margin-bottom: 8px;
    font-size: 14px;
}

/* Bordered wrap for the per-voice list inside the narration dialog (the only place this class is
   used). Scroll behavior lives entirely in the #voice-dialog .voice-list-wrap rule below — the base
   rule used to also set max-height/overflow-y, but #voice-dialog always overrode both, so that pair
   was dead weight kept here. */
.voice-list-wrap {
    border: 1px solid var(--border);
    border-radius: var(--radius-s);
}

.voice-list {
    display: flex;
    flex-direction: column;
}

/* Stable-size narration dialog. Its content varies a lot between providers — the model select,
   style box, voice filter, cost line and voice count all appear/disappear — which made a normally
   centred <dialog> resize and visibly jump on every provider switch. Pin it to a fixed height (the
   generic dialog[open]/.dialog-body rules above already make the body the single scroll region),
   so the box never moves: the title and the Close/Generate actions stay put and only the middle
   scrolls. */
#voice-dialog {
    width: min(94vw, 720px);
    height: min(94vh, 1000px);
    max-height: min(94vh, 1000px);
}

#voice-dialog .dialog-header {
    padding-bottom: 12px;
    border-bottom: 1px solid var(--border);
}

#voice-dialog .dialog-body {
    padding-top: 18px;
    padding-bottom: 20px;
}

/* Spacing between the body's sections comes solely from the base `.dialog-body { gap: 12px }` now.
   A former `margin-top: 14px` override here predated that shared gap and stacked on top of it (26px
   real spacing), needlessly inflating the non-list height and starving the flex-growing voice list
   of the room it needs to absorb long catalogs internally. */
#voice-outputs-wrap,
#voice-favorites-wrap {
    position: relative;
    padding: 14px;
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    background: var(--surface);
}

#voice-outputs-wrap .section-label,
#voice-favorites-wrap .section-label {
    margin: 0 0 8px;
}

/* Generated-narration rows are plain .option-row cards (full 4-side border, unlike the divider-style
   .voice-row used in the favorites/catalog lists below), so the right rhythm to match is the other
   plain-.option-row lists — #settings-providers / #settings-key-list — which both use this same 8px
   flex gap. Without it the rows had no wrapper spacing at all and sat flush against each other.
   Gap only adds space BETWEEN rows; row height (min-height: 54px on .option-row) is untouched, so a
   single narration's row looks exactly as before. */
#voice-outputs {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

/* N1: the voice list is the flex-growing internal scroll region. It fills the space left between the
   sections above it (outputs/favorites/chips/model) and below it (style/format/default) and absorbs
   long catalogs (Gemini ~30 voices, Google Cloud 100+) by scrolling INTERNALLY, so the dialog body
   itself stays put whenever the list can hold the overflow — no double-scrollbar fight. min-height
   keeps a usable list even when the surrounding sections are tall; overscroll-behavior stops wheel
   momentum from chaining out to the body. A fixed-size list region also means switching providers
   (2 voices vs 100+) never resizes the dialog. */
#voice-dialog .voice-list-wrap {
    flex: 1 1 auto;
    min-height: 220px;
    max-height: none;
    overflow-y: auto;
    overscroll-behavior: contain;
}

/* B2: user-resizable voice list. The handle is a permanent 24px row directly under the list (never
   conditionally inserted, so grabbing it never shifts anything) — dragging it sets an explicit
   inline height + flex:none on #voice-list-wrap via JS, overriding the flex-grow sizing above.
   Only the pill's colour reacts to hover/drag; its size and position never change. */
#voice-list-resizer {
    flex: none;
    height: 24px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: ns-resize;
    touch-action: none;
    -webkit-tap-highlight-color: transparent;
}

#voice-list-resizer::before {
    content: "";
    width: 36px;
    height: 4px;
    border-radius: 999px;
    background: var(--border);
}

#voice-list-resizer:hover::before,
#voice-list-resizer.dragging::before,
#voice-list-resizer:focus-visible::before {
    background: var(--accent);
}

/* B1: equal-width grid columns used to force every chip in a row to the same track width, which
   either stranded a short label's mark+text off-centre in extra space or squeezed a long label
   (e.g. "Google Cloud TTS") until its text nearly touched the chip's edge. Flex + wrap lets each
   chip size to its own content instead, so the mark/gap/label spacing is identical for every chip
   and nothing ever clips — chips that don't fit the current row width just wrap to the next one. */
#voice-dialog #voice-providers {
    display: flex;
    flex-wrap: wrap;
}

/* N3: provider chips keep the brand logo readable in every state. Both states sit on the surface
   (never a deep-green fill behind the logos); selection is shown with an accent ring + accent text.
   A constant 1px border plus a 1px inset accent shadow renders as a "2px accent edge" when selected
   without ever changing the chip's size (box-shadow doesn't reflow), so nothing jumps.
   B1: padding is asymmetric (10px left / 14px right) so the label always keeps daylight before the
   chip's edge even on the widest labels; white-space:nowrap plus content-sized (no grid stretch)
   width means that gap never shrinks at any dialog width. */
#voice-dialog #voice-providers .chip {
    min-height: 44px;
    font-size: 14px;
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 8px 14px 8px 10px;
    white-space: nowrap;
    background: var(--surface);
    border: 1px solid var(--border);
    box-shadow: inset 0 0 0 1px transparent;
}

#voice-dialog #voice-providers .chip:hover {
    background: var(--accent-soft);
}

#voice-dialog #voice-providers .chip.selected {
    background: var(--surface);
    color: var(--accent);
    border-color: var(--accent);
    box-shadow: inset 0 0 0 1px var(--accent);
}

#voice-dialog #voice-providers .chip.selected:hover {
    background: var(--accent-soft);
}

/* Provider identity mark: a real brand logo (sprite <use>) or, for providers without one, a letter
   badge. Fixed 26px box so rows/chips never reflow between the two forms. */
.provider-mark {
    width: 26px;
    height: 26px;
    flex: 0 0 26px;
    display: inline-grid;
    place-items: center;
    border-radius: 8px;
    background: var(--accent-soft);
    color: var(--accent);
    font-size: 10px;
    font-weight: 800;
    letter-spacing: -0.02em;
}

/* Logo marks sit directly on the surface (no accent-soft tile) so brand colours read cleanly. The
   two-selector specificity (0,2,0) beats button.chip svg (0,1,2), so the logo keeps its 22px size
   inside a provider chip instead of shrinking to the generic 15px chip-icon size. */
.provider-mark.has-logo {
    background: transparent;
}

.provider-mark .provider-logo {
    width: 22px;
    height: 22px;
    display: block;
}

/* Mono marks (xAI, ElevenLabs) follow the surrounding text colour, so they invert with the theme and
   turn accent when their chip is selected. */
.provider-mark.mark-mono .provider-logo {
    color: var(--text);
}

/* Dark theme: fixed-brand-colour logos need a subtle light backing disc to stay legible on the dark
   surface. Mono marks follow text colour and need no disc. */
[data-theme="dark"] .provider-mark.mark-color {
    background: #f2f5f2;
    border-radius: 50%;
}

.voice-output-title {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    flex-wrap: wrap;
}

.voice-output-title .title {
    flex: 1 1 240px;
    min-width: 0;
    margin: 0;
}

.voice-output-title .badge {
    margin-left: 0;
    flex: none;
}

#voice-dialog select {
    margin-top: 5px;
}

#voice-dialog .voice-row {
    min-height: 58px;
    border-width: 0 0 1px;
    border-radius: 0;
}

#voice-dialog .voice-row:last-child {
    border-bottom: 0;
}

.voice-format-field {
    margin: 4px 0 0;
    padding: 0;
    border: 0;
}

.voice-format-field legend {
    margin-bottom: 8px;
    color: var(--muted);
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

.format-options {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 10px;
}

.format-badge {
    color: var(--accent);
    background: var(--accent-soft);
    border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}

.output-details {
    margin-top: 7px;
    color: var(--muted);
    font-size: 12px;
}

.output-details summary {
    width: fit-content;
    cursor: pointer;
    color: var(--text);
    font-weight: 600;
}

.output-details p {
    margin: 5px 0 0;
    line-height: 1.45;
}

.output-convert {
    margin-top: 9px;
    min-height: 36px;
    padding: 7px 11px;
    font-size: 12.5px;
}

.output-derived-note {
    display: inline-block;
    margin: 8px 0 0;
    color: var(--accent);
    font-size: 12px;
    font-weight: 650;
}

@media (max-width: 720px) {
    .format-options {
        grid-template-columns: 1fr;
    }
}

.format-option {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    min-height: 72px;
    padding: 12px;
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    background: var(--surface-raised);
    cursor: pointer;
}

.format-option:has(input:checked) {
    border-color: var(--accent);
    box-shadow: 0 0 0 1px var(--accent);
}

.format-option:has(input:disabled) {
    opacity: 0.5;
    cursor: not-allowed;
}

.format-option input {
    margin-top: 3px;
    accent-color: var(--accent);
}

.format-option span,
.format-option strong,
.format-option small {
    display: block;
}

.format-option small {
    margin-top: 4px;
    color: var(--muted);
    line-height: 1.35;
}

#voice-dialog .dialog-actions {
    padding-top: 14px;
    border-top: 1px solid var(--border);
}

#export-dialog {
    width: min(94vw, 760px);
    height: min(90vh, 760px);
}

#export-dialog .dialog-header {
    padding-bottom: 12px;
    border-bottom: 1px solid var(--border);
}

#export-dialog .dialog-actions {
    padding-top: 14px;
    border-top: 1px solid var(--border);
}

#export-dialog .section-label {
    margin-bottom: 8px;
}

.export-selection-toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 8px;
}

.export-selection-toolbar button {
    padding: 7px 12px;
    font-size: 12.5px;
}

.export-document-list {
    max-height: 260px;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 6px;
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
}

.export-document-row {
    display: flex;
    align-items: center;
    gap: 10px;
    min-height: 48px;
    padding: 8px 10px;
    border-radius: var(--radius-s);
    color: var(--text);
    cursor: pointer;
}

.export-document-row:hover {
    background: var(--accent-soft);
}

.export-document-row input {
    width: 18px;
    height: 18px;
    flex: none;
    accent-color: var(--accent);
}

.export-document-row span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 14px;
    font-weight: 600;
}

.import-conflict {
    border-color: var(--warning-soft);
    background: var(--warning-soft);
}

.import-invalid {
    border-color: var(--danger-soft);
    background: var(--danger-soft);
}

.export-fixed-choice {
    cursor: default;
}

#export-confirm {
    display: inline-flex;
    align-items: center;
    gap: 7px;
}

/* Bottom sheets on narrow screens: every dialog (including the fixed-size voice/export dialogs
   above, whose id-level width/height rules would otherwise win regardless of viewport) docks to
   the bottom edge instead of floating centred. */
@media (max-width: 600px) {
    dialog {
        margin: auto 0 0 0;
        width: 100vw;
        max-width: 100vw;
        border-radius: var(--radius-l) var(--radius-l) 0 0;
        max-height: 92dvh;
        padding: 20px;
    }

    #voice-dialog,
    #export-dialog {
        width: 100vw;
        height: 92dvh;
        max-height: 92dvh;
    }

    /* Every dialog with an id-level width rule (more specific than the bare `dialog` above) must
       repeat the full-bleed width here, or it stays narrow and — with the sheet margins — ends up
       pinned to the bottom-LEFT with a gap on the right (this is exactly what the speed dialog
       did). If a new dialog gets its own width, add it to this list. */
    #speed-dialog,
    #reader-menu-dialog,
    #shortcuts-dialog,
    #row-queue-dialog {
        width: 100vw;
        max-width: 100vw;
    }
}

.voice-row .title {
    display: inline-flex;
    align-items: center;
}

.voice-locale {
    text-transform: none;
    letter-spacing: 0;
}

.voice-play-btn.playing {
    color: var(--accent);
}

/* While a preview blob loads, spin the icon and lock the button. */
.voice-play-btn.loading {
    pointer-events: none;
    opacity: 0.6;
}

.voice-play-btn.loading svg {
    animation: voice-spin 0.8s linear infinite;
}

@keyframes voice-spin {
    to {
        transform: rotate(360deg);
    }
}

/* Quiet star toggle in each voice row; only the filled/accent state draws attention. */
.voice-favorite-btn {
    color: var(--muted);
}

.voice-favorite-btn.favorited {
    color: var(--accent);
}

/* Favorites section pinned above the per-provider picker; same row look as the voice list. */
#voice-favorites-wrap {
    margin-bottom: 4px;
}

.key-actions-row {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    align-items: center;
    margin-top: 8px;
}

.settings-browse-row {
    margin-top: 12px;
}

.slider-label {
    display: flex;
    justify-content: space-between;
    font-size: 14px;
    color: var(--text);
    font-weight: 500;
}

.checkbox-row {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 10px;
    font-weight: 600;
}

.checkbox-row input {
    width: auto;
}

.cleanup-matches {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin-top: 8px;
}

.cleanup-matches p {
    margin: 0;
}

footer {
    max-width: var(--max-width);
    margin: 0 auto;
    padding: 12px 20px 36px;
    color: var(--muted);
    font-size: 13px;
    text-align: center;
}

/* ---------- Help page (help.html) ---------- */

.help-page {
    max-width: var(--max-width);
    margin: 0 auto;
    padding: 24px 20px 64px;
}

.help-back {
    display: inline-block;
    margin-bottom: 16px;
    color: var(--accent);
    text-decoration: none;
    font-weight: 500;
}

.help-page .card {
    margin-bottom: 16px;
    scroll-margin-top: 16px;
}

.help-page ol,
.help-page ul {
    padding-left: 22px;
    line-height: 1.65;
    margin: 8px 0;
}

.help-page li {
    margin: 4px 0;
}

.help-page a {
    color: var(--accent);
}

.help-page code {
    background: var(--accent-soft);
    padding: 1px 6px;
    border-radius: 6px;
    font-size: 13px;
    font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

.help-tag {
    display: inline-block;
    font-size: 11px;
    font-weight: 600;
    padding: 2px 8px;
    border-radius: 999px;
    margin-left: 8px;
    background: var(--accent-soft);
    color: var(--accent);
    vertical-align: middle;
}

@media (max-width: 520px) {
    .reader-text {
        font-size: 17.5px;
    }
}

/* Below 600px the floating bar keeps its rounded look but hugs the screen edges (8px inset) and its
   title/transport/extras stack into three centred rows, so six controls never crowd one line. The
   extras stay icon-only (as at every width). Body clearance is handled live by
   --player-bar-clearance, so no fixed padding-bottom override is needed here. This is now the single
   mobile player breakpoint (the old ≤520px player rules were folded in). */
@media (max-width: 600px) {
    .player-bar {
        left: 8px;
        right: 8px;
        width: auto;
        transform: none;
        bottom: calc(8px + env(safe-area-inset-bottom, 0px));
        border-radius: 16px;
        padding: 8px 12px;
    }

    .player-mainrow {
        flex-direction: column;
        gap: 8px;
    }

    .player-zone-lead {
        flex: 0 0 auto;
        width: 100%;
        justify-content: center;
    }

    .player-context-open {
        max-width: 100%;
        align-items: center;
        text-align: center;
    }

    .player-transport .play-btn {
        width: 52px;
        height: 52px;
    }

    .player-transport .play-btn svg {
        width: 24px;
        height: 24px;
    }

    .player-transport .icon-btn {
        width: 44px;
        height: 44px;
    }

    .player-extras {
        flex: 0 0 auto;
        flex-wrap: wrap;
        justify-content: center;
    }

    /* Stacked mode is tall on a phone (generate row + full player). The extras belong to the
       backgrounded playback — drop that row here so the stack stays compact; every extra returns
       the moment the open article's own narration takes over the bar. */
    .player-bar.stacked .player-extras {
        display: none;
    }
}

/* Between 960 and 1180px: a single centered text column at the shared --max-width, no side rail.
   Below 960px: unchanged full-width single column (unaffected by the max-width below since
   .reader-layout is already display:block there). */
@media (min-width: 960px) and (max-width: 1179.98px) {
    .reader-content {
        max-width: var(--max-width);
        margin-inline: auto;
    }
}

/* Below 1180px the library rail is hidden and the reader collapses to the single centered column. */
@media (max-width: 1179.98px) {
    .reader-layout {
        display: block;
    }

    .reader-library-rail {
        display: none;
    }
}

.reader-topbar h2 {
    margin: 0;
}

/* ---------- Sign-in & account ---------- */

.auth-card {
    max-width: 420px;
    margin: 40px auto;
    text-align: center;
}

/* Product tagline. Used to live in the header; now shown once, on the sign-in card only. */
.tagline {
    margin: 2px 0 0;
    color: var(--muted);
    font-size: 14px;
}

.google-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    padding: 11px 22px;
    margin: 14px 0 6px;
    border: 1px solid var(--border);
    border-radius: 24px;
    background: var(--surface-raised);
    color: var(--text);
    font-weight: 600;
    font-size: 0.95rem;
    text-decoration: none;
    box-shadow: var(--shadow);
}

.google-btn:hover {
    border-color: var(--accent);
}

.local-try-btn {
    display: block;
    margin: 10px auto 12px;
}

#local-data-banner {
    position: sticky;
    top: 8px;
    z-index: 20;
    align-items: center;
}

/* Local mode has no server-side listening history to chart, so the stats entry point is hidden
   rather than left clickable and failing every time — the same pattern as import-pdf-btn. */
body.guest-mode #settings-provider-card,
body.guest-mode #settings-provider-keys-card,
body.guest-mode #settings-ctc-card,
body.guest-mode #settings-security-card,
body.guest-mode #settings-stats-card {
    display: none;
}

.google-logo {
    width: 20px;
    height: 20px;
    flex: none;
}

.auth-advanced {
    margin-top: 18px;
    text-align: left;
}

.auth-advanced summary {
    cursor: pointer;
    font-size: 0.85rem;
    color: var(--muted);
}

.auth-advanced label {
    display: block;
    margin-top: 14px;
}

.auth-advanced label input {
    margin-top: 6px;
}

.auth-advanced .dialog-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 14px;
}

/* Account cluster: avatar+name pill, Sign out, and the divider before the theme/settings icons —
   grouped so all three hide together while signed out (see app.js renderAccount()). */
.header-account-group {
    display: flex;
    align-items: center;
    gap: 8px;
}

.account {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    height: 36px;
    padding: 0 14px 0 5px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface-raised);
    min-width: 0;
}

.account-avatar {
    width: 26px;
    height: 26px;
    flex: none;
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--accent-soft);
    color: var(--accent);
    font-size: 12px;
    font-weight: 700;
    text-transform: uppercase;
}

.account-email {
    color: var(--text);
    font-size: 13px;
    font-weight: 500;
    max-width: 160px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

button.account-logout {
    height: 36px;
    padding: 0 12px;
    border: none;
    background: transparent;
    color: var(--muted);
    font-size: 13px;
    font-weight: 600;
}

button.account-logout:hover:not(:disabled) {
    color: var(--text);
    background: var(--accent-soft);
}

.header-divider {
    width: 1px;
    height: 20px;
    flex: none;
    background: var(--border);
}

/* Signed-in header on small screens: the e-mail (and below 480px also the Sign out text button
   and its divider) yield so the "Voice Compiler" wordmark keeps its full space. The avatar stays
   as the signed-in indicator, and signing out remains available at every width via
   Settings → Account & security. */
@media (max-width: 760px) {
    .account-email {
        display: none;
    }

    .account {
        padding: 0 5px;
    }
}

@media (max-width: 480px) {
    button.account-logout,
    .header-divider {
        display: none;
    }

    .app-header-inner {
        padding: 0 16px;
    }
}
