/* ============================================================
   Per-page sticky title header + edit-bar chrome, shared across all
   authenticated pages. The rail/bottom-nav/fab shell itself now lives in
   stratum-ui's AppShellLayout (see components/AppShell.tsx) — its edit-mode
   toggle applies .stratum-app-shell--edit-mode at the shell root, which the
   ".stratum-app-shell--edit-mode X" rules below key off of.

   Every page's header renders via stratum-ui's PageHeaderMobile/
   PageHeaderDesktop (own self-contained CSS there); a page's filters go in
   their `bar` slot, its edit bar in their children.
   ============================================================ */

.app-scroll { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; }
@media (min-width: 768px) { .app-scroll { position: static; flex: 1; min-height: 0; overflow-y: auto; } }
/* Desktop: stratum-ui's page wrapper (.stratum-app-shell-page, between the main column and a page) as a
   column, as the main column is, so .app-scroll's `flex: 1; min-height: 0` gets the page's height and
   scrolls - without it, it grows to its content and the main column clips it (Chrome won't scroll at
   all). stratum-ui does this itself from 9362952; drop this with the next stratum-ui bump. */
@media (min-width: 768px) { .stratum-app-shell-page { display: flex; flex-direction: column; } }


/* ── Desktop edit bar (in PageHeaderDesktop's children, shown only in edit mode) ── */
.dt-edit-bar {
  display: none; align-items: center; gap: 10px;
  padding: 10px 36px;
  background: var(--accent-soft);
  border-bottom: 1px solid var(--line);
}
.stratum-app-shell--edit-mode .dt-edit-bar { display: flex; }
.dt-edit-bar .count-label { font-size: 15px; color: var(--ink); font-weight: 600; }
.dt-edit-bar .spacer { flex: 1; }

/* ── Loading spinner (shared) ── */
.spinner {
  width: 20px; height: 20px;
  border-radius: 50%;
  border: 2px solid var(--line-2);
  border-top-color: var(--accent);
  animation: spin .7s linear infinite;
  flex-shrink: 0;
}
.spinner.spinner-sm { width: 12px; height: 12px; border-width: 1.5px; }
.spinner.spinner-inline { display: inline-block; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }

/* Peek-meter ring (novel read page only) — a thin circular progress ring
   hugging the floating paste FAB (stratum-ui's .stratum-app-shell-fab,
   bottom-right inside .stratum-app-shell-main). Full ring = full meter;
   drains while a hold drains, refills as it recovers — same meter state the
   old top bar used. Drawn as the FAB's own ::after so it tracks the button
   through its show/hide translate and press scale for free, and vanishes
   wherever the FAB itself is hidden (desktop, edit mode — display:none kills
   the pseudo too). Present only while body.peek-meter-ring is set, which
   AppShell toggles from ReadPage's published level gated on the /read route
   — other pages keep the pixel-identical plain paste button, and the FAB's
   own look/click behavior is untouched (out-of-flow, pointer-events none,
   floating outside the button edge). The rail FAB is never targeted. */
@property --peek-meter-frac {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}
body.peek-meter-ring .stratum-app-shell-rail-fab {
  position: relative;
}
body.peek-meter-ring .stratum-app-shell-fab::after,
body.peek-meter-ring .stratum-app-shell-rail-fab::after {
  content: "";
  position: absolute;
  inset: 4px;
  border-radius: 50%;
  pointer-events: none;
  background: conic-gradient(#fff calc(var(--peek-meter-frac, 1) * 1turn), transparent 0);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
  mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));
}
/* At 100% the conic-gradient's start/end seam leaves a tiny notch at 12
   o'clock — swap to a solid fill (same ring mask) when AppShell sets
   body.peek-meter-full (shown fraction >= 0.999). Must follow the rule above
   so it wins at equal specificity + extra class. */
body.peek-meter-ring.peek-meter-full .stratum-app-shell-fab::after,
body.peek-meter-ring.peek-meter-full .stratum-app-shell-rail-fab::after {
  background: #fff;
}
/* Sheet's own body has no gap between arbitrary children (see stratum-ui's
   Sheet.css — DownloadsPage.tsx's UploadSheet solves this the same way via
   its own .dl-upload-field) — without this, the input sits flush against
   SheetActions below it. 10px top (not 0): stratum-sheet-head already
   contributes 8px of its own padding-bottom above the body, so 10px here
   brings the total gap above the input to 18px, matching the 18px below —
   without it, the visible gap above is only that bare 8px while below is
   the full 18px, a noticeably uneven sandwich. */
.download-up-to-chapter-input {
  margin: 10px 0 18px;
}
/* No max-height/overflow here — BottomPanel's own body region
   (.stratum-bottom-panel-body) already caps height and scrolls; nesting a
   second scroll container would just double up (same as DictLookupSheet). */
.character-list-body {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* Grid (not flex) so portrait/header/desc can be REARRANGED per breakpoint
   via grid-template-areas alone — same three DOM children either way, no
   duplicated markup. Mobile: portrait + header share a row, description
   spans full width below (a narrow column would otherwise wrap the
   description to a lot of lines next to a small portrait). Desktop (see
   the @media block below): portrait spans both rows beside a header+desc
   column, like a traditional avatar-left layout — there's enough width
   there for a bigger portrait without descriptions ballooning in height. */
.character-list-entry {
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-areas:
    "portrait header"
    "desc     desc";
  column-gap: 14px;
  row-gap: 8px;
  align-items: center;
}
.character-list-entry + .character-list-entry {
  padding-top: 14px;
  border-top: 1px solid var(--line-2);
}
/* Novel has_native_illustrations — no portrait column exists at all, so
   collapse the grid to a single column instead of leaving the "portrait"
   area's auto-sized track present with nothing in it. */
.character-list-entry--no-portrait {
  grid-template-columns: 1fr;
  grid-template-areas:
    "header"
    "desc";
}
.character-list-portrait-wrap {
  grid-area: portrait;
  position: relative;
  width: 200px;
  height: 200px;
}
.character-list-portrait {
  width: 200px;
  height: 200px;
  border-radius: 10px;
  object-fit: cover;
  background: var(--surface-2);
  cursor: pointer;
}
.character-list-portrait--placeholder {
  cursor: default;
}

/* Shared by both PortraitImage sizes (this 200px list portrait and
   .character-list-history-thumb's 100px history-row one) — plays once as
   each portrait's own blob finishes loading (see PortraitImage's own doc),
   so several portraits fetching at once settle in smoothly instead of
   popping in abruptly together. A CSS animation, not a transition: it needs
   no JS-driven "before" state to transition FROM — the <img> simply starts
   this animation the instant it mounts (once the blob is ready), one paint
   after the placeholder box it replaces. */
@keyframes character-list-portrait-fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
.character-list-portrait-image--loaded {
  animation: character-list-portrait-fade-in 0.2s ease;
}
/* Dims the portrait itself while its own regenerate/delete is in flight —
   the loading state lives on the thing that's actually changing, not on the
   name text next to it (see CharacterListSheet.tsx's own doc). */
.character-list-portrait-wrap--pending .character-list-portrait {
  opacity: 0.35;
  pointer-events: none;
}
.character-list-portrait-spinner-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.character-list-portrait-spinner {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: 3px solid var(--line-2);
  border-top-color: var(--ink-3);
  animation: character-list-portrait-spin 0.7s linear infinite;
}
@keyframes character-list-portrait-spin {
  to {
    transform: rotate(360deg);
  }
}
.character-list-header {
  grid-area: header;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.character-list-name {
  font-size: 19px;
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
}
.character-list-name-actions:hover .character-list-name {
  text-decoration: underline;
}
/* Aligns .character-list-name with its own optional .character-list-new tag
   (stratum-ui's SelectOverlay wraps them in a plain inline-flex row with no
   gap/alignment of its own — see CharacterListSheet.tsx's own doc on why
   the tag lives inside this wrapper rather than beside it). */
.character-list-name-actions {
  align-items: center;
  gap: 6px;
}
/* Same small-uppercase-pill treatment as CharacterHistoryBody's own
   "Current" tag (.character-list-history-current) — see
   CharacterListSheet.tsx's own doc on why the same styling fits both. */
.character-list-new {
  flex: none;
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 3px;
  padding: 1px 4px;
}
/* Same shape as .character-list-new above, but --ink-3 (neutral/muted)
   instead of --accent — this is informational (an identity-history fact
   about this row), not something worth the same visual weight as "a new
   character just showed up". Not uppercase (unlike New/Current) since it
   carries a real name, not a fixed short word — uppercasing a Japanese name
   would be a no-op anyway, but keeping it un-uppercased also reads better
   for the "→ <name>" arrow shape. */
.character-list-merged {
  flex: none;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--ink-3);
  border: 1px solid var(--line-2);
  border-radius: 3px;
  padding: 1px 4px;
}
/* "N versions" — shown only when a character/place/world setting has more
   than one. Same muted pill as .character-list-merged (informational). */
.character-list-versions {
  flex: none;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--ink-3);
  border: 1px solid var(--line-2);
  border-radius: 3px;
  padding: 1px 4px;
}
/* Two copies of the same tags exist in the markup (CharacterListSheet.tsx's
   own `tags` const doc) — these two classes are what actually pick which
   one renders at a given width. Mobile (base rules here): the inline copy
   (inside the name row) is hidden, the stacked copy (its own row below
   character-list-reading) is shown — a name competing with a tag like
   "Merged → <long name>" on one line wrapped badly at narrow widths (a real
   screenshot of this). Desktop (the @media block below) flips both back to
   the original single-row layout, which has room for it. */
.character-list-tags-row--inline {
  display: none;
}
.character-list-tags-row--stacked {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  /* On top of .character-list-header's own 2px flex gap (shared with every
     row there, including name-to-reading) — a tag pill has no line-height
     cushion around it the way a text row does, so the same 2px reads as
     noticeably tighter here than it does between name and reading. This
     closes that gap so both look like roughly the same amount of breathing
     room. */
  margin-top: 4px;
}
.character-list-reading {
  font-size: 14px;
  color: var(--ink-3);
}
/* Wraps .character-list-desc + .character-list-desc-translation so the pair
   shares the grid's single "desc" area and stacks via normal block flow
   inside it, rather than each needing its own grid row/track (which would
   leave a stray gap below the description whenever the translation isn't
   rendered — it's conditional, only present while toggled on). */
.character-list-desc-group {
  grid-area: desc;
}
.character-list-desc {
  margin: 0;
  /* Matches .read-text's own mobile size (ReadPage.css) — same bump to 17px
     on desktop below. Always the Japanese description, plain or
     furigana-toggled — the English translation (when toggled on) renders
     as its own separate paragraph below instead, see
     .character-list-desc-translation. */
  font-size: 16px;
  line-height: 1.6;
  color: var(--ink-2);
  /* Tappable — toggles furigana + translation beneath on/off (see
     CharacterListSheet.tsx's toggleDescription). `text`, not `pointer` —
     same reasoning as ReadPage.css's .read-line: this is still prose the
     user selects text from as much as it's a click target, and `pointer`
     reads as "button", not "text". */
  cursor: text;
}
/* Furigana toggle state's own token spacing — same base gap ReadPage.css's
   chapter body uses for real <ruby><rt> (this reuses genuine native ruby
   too, not the position-absolute trick .novel-info-keyword needs for its
   fixed-height pills), scoped here rather than depending on ReadPage.css
   happening to already be loaded. */
.character-list-desc .read-token {
  margin-right: 0.4em;
}
.character-list-desc .read-token:last-child {
  margin-right: 0;
}
/* Furigana readings are never part of a selection (and so never copied) —
   see ReadPage.css's own `.read-line rt` rule and selectionPrompt.ts's
   getSelectionText for the full reasoning; same treatment here, including
   pointer-events: none so a drag/long-press can't hit the <rt> box itself,
   and the -webkit-user-select prefix Safari needs on top of the unprefixed
   property (see ReadPage.css's own rule for why). */
.character-list-desc rt {
  -webkit-user-select: none;
  user-select: none;
  pointer-events: none;
}
/* The English translation shown beneath the Japanese description once
   toggled on — same de-emphasized size/tone as ReadPage.css's own
   .read-translation (flat 15px at every width, never bumping on desktop,
   unlike .character-list-desc above), so it reads as a translation gloss
   rather than primary prose. */
.character-list-desc-translation {
  margin: 4px 0 0;
  font-size: 15px;
  line-height: 1.7;
  color: var(--ink-2);
}

/* Desktop deliberately drops the grid layout mobile uses above: with the
   portrait spanning both the header and desc rows, grid's auto-track-sizing
   algorithm MUST grow those row tracks tall enough between them to fit the
   200px-tall spanning portrait — align-content: start (tried first) only
   redistributes genuinely leftover container space, not the growth grid
   itself forces into a spanning item's own rows, so it couldn't stop this.
   Whenever a character's name+reading+description were shorter than 200px
   combined, that forced growth landed as visible empty space BETWEEN the
   reading line and the description, not at the bottom where it'd be
   harmless. Absolute-positioning the portrait instead removes it from flow
   entirely, so header/desc stack in plain block flow at their own natural
   content height regardless of the portrait's height — the description
   sits a flat 12px below the header for every character, full stop. */
@media (min-width: 768px) {
  .character-list-name {
    font-size: 20px;
  }
  .character-list-reading {
    font-size: 15px;
  }
  .character-list-tags-row--inline {
    display: inline-flex;
    align-items: center;
    gap: 6px;
  }
  .character-list-tags-row--stacked {
    display: none;
  }
  .character-list-entry {
    position: relative;
    display: block;
    /* 200px portrait + 14px gap — same visual column-gap the mobile grid
       layout used, kept identical here since it's no longer a real grid gap. */
    padding-left: 214px;
    min-height: 200px;
  }
  .character-list-entry--no-portrait {
    padding-left: 0;
    min-height: 0;
  }
  .character-list-portrait-wrap {
    position: absolute;
    top: 0;
    left: 0;
    width: 200px;
    height: 200px;
  }
  /* .character-list-entry's own min-height (above) guarantees the entry is
     AT LEAST as tall as the 200px absolutely-positioned portrait, but a
     character with short text content (name/reading/description well
     under 200px combined) means the divider below still lands right at
     that 200px mark, with none of the mobile layout's own row-gap (which
     there is measured from the actual, usually-shorter, text flow) —
     desktop needs its own explicit gap to clear the portrait's bottom edge.
     padding-top alone only moves the entry's NORMAL-FLOW content (the text
     column) — position:absolute's `top` offset is measured from the
     containing block's PADDING EDGE (i.e. right at the border), which
     padding-top does NOT shift, so .character-list-portrait-wrap's `top: 0`
     above stayed flush against the divider regardless (confirmed in
     practice: text gained the gap, the portrait didn't). Its own `top`
     needs the SAME offset explicitly, and min-height needs to grow to
     match so the now-lower portrait doesn't overflow past this entry's own
     bottom into the next one's. */
  /* :not(--no-portrait) on the TARGET side — without it, this rule and
     .character-list-entry--no-portrait above have EQUAL specificity (2
     classes each), and this one, being later in the file, silently won:
     every 2nd+ entry got forced to calc(200px + 12px) tall regardless of
     whether it actually had a portrait to accommodate, leaving a large
     empty gap below a short description in a --no-portrait sheet (has_
     native_illustrations novels, e.g. pixiv — confirmed in practice: a
     2-character no-portrait sheet's 2nd entry measured 212px tall — exactly
     this calc() — with the description filling only a fraction of it). */
  .character-list-entry + .character-list-entry:not(.character-list-entry--no-portrait) {
    /* Matches .character-list-desc-group's own 12px margin-top below — same
       gap size already used elsewhere in this sheet. */
    padding-top: 12px;
    min-height: calc(200px + 12px);
  }
  .character-list-entry + .character-list-entry.character-list-entry--no-portrait {
    padding-top: 12px;
  }
  .character-list-entry + .character-list-entry .character-list-portrait-wrap {
    top: 12px;
  }
  .character-list-header {
    margin: 0;
  }
  .character-list-desc-group {
    /* The fixed gap below name/reading that this whole rule exists for —
       every character's description (+ translation, once toggled on)
       starts here, never further down just because its own portrait
       happens to be taller than its text. */
    margin-top: 12px;
  }
  .character-list-desc {
    /* Same responsive bump as .read-text (ReadPage.css) — only the
       Japanese description; .character-list-desc-translation stays flat
       at 15px (unlike .read-translation, which now gets its own desktop
       bump in ReadPage.css). */
    font-size: 17px;
  }
  .character-list-history-title {
    /* Same bump, same reasoning, as .character-list-desc above —
       .character-list-history-title-translation stays flat at 15px, same
       as .character-list-desc-translation. */
    font-size: 17px;
  }
}

/* ── Panel drill-down (Character history / Merge into…) ── */

/* BottomPanel's own title span only wraps whatever ReactNode it's given in
   a plain <span> (see BottomPanel.tsx) — this lays the back-arrow
   IconButton and label out inline without needing any change to
   BottomPanel itself. */
.character-list-panel-title {
  /* Block-level flex, not inline-flex: as an inline box the title sat on the
     surrounding text line's baseline, which pushed the back button above the
     header's vertical centre. */
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Same 28px size as BottomPanel's own close button (stratum-ui's
   .stratum-bottom-panel-close) — shrunk down from IconButton's default 36px
   circle (stratum-ui's own IconButton.css) so it doesn't look oversized
   next to it, with its 20px glyph brought down to 18px to match the size
   reduction proportionally.

   Negative margins on all sides, not a smaller box, are what actually fix
   the header-height jump this button used to cause: at full 36px (and even
   at 28px with normal margins), this arrow made .stratum-bottom-panel-header
   (which has no fixed height of its own, just padding around whatever
   content it's given) noticeably taller on the history/merge pages than the
   plain, small-text "Characters" title on the list page — the close button
   itself never contributes to this since it's `position: absolute`, taken
   out of flow entirely, but this back button sits in NORMAL flow inside the
   title. `.stratum-bottom-panel` has no `overflow: hidden`, so negative
   margins here shrink the button's effective flex margin-box (what the
   header actually sizes its own height against) without visually shrinking
   the button at all — it still paints at the full 28px, just slightly
   overflowing its own margin box. `margin-left`/`margin-top`/`margin-bottom`
   are each `-(button size - glyph size) / 2`: horizontally, so the visible
   ARROW (not the button's larger, invisible hit-area edge) lines up with
   the body content's own 20px left padding; vertically, so the button's
   effective height contribution collapses down toward the glyph's own
   size — close to the plain-text title's own line-height — which is what
   lets this header shrink back down near .stratum-bottom-panel-close's own
   `top: 10px; height: 28px` floor (the shortest this header can ever get
   without clipping the close button), same as the list page's already does. */
/* Two classes, not one: in a production build stratum-ui's own stylesheet is
   injected at runtime (main.tsx's PROD-only dynamic import), AFTER this
   file's already-linked bundle, so an equal-specificity override of
   .stratum-icon-button--circle's 36px lost to it there (but not in dev). */
.character-list-panel-title .character-list-panel-back {
  width: 28px;
  height: 28px;
  margin-left: -5px;
}
.character-list-panel-title .character-list-panel-back svg {
  width: 18px;
  height: 18px;
}

/* Shared intro line for the history/merge pages — same role as a page's own
   lede paragraph elsewhere in this app, just scoped inside the panel body
   instead of page content. */
.character-list-panel-intro {
  margin: 0 0 12px;
  font-size: 13px;
  color: var(--ink-3);
}

/* ── Character history page ── */

.character-list-history-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.character-list-history-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 0;
}
.character-list-history-row + .character-list-history-row {
  border-top: 1px solid var(--line-2);
}
.character-list-history-row--pending {
  opacity: 0.7;
}

/* Same 100px as .character-list-merge-thumb below — a version's own
   portrait is the main thing distinguishing one row from another in this
   list (that's the whole point of "move"/"delete" existing per-row), so it
   needs to actually read as an image, not a tiny icon-sized swatch, but a
   full ROW PER CHAPTER list is dense enough that this stays the smaller
   size rather than matching .character-list-portrait's 200px. Still opens
   the same full-size viewer on click (see CharacterListSheet.tsx's
   onImageClick wiring), so the pointer cursor stays either way. */
.character-list-history-thumb {
  width: 100px;
  height: 100px;
  border-radius: 10px;
  object-fit: cover;
  background: var(--surface-2);
  flex-shrink: 0;
  cursor: pointer;
}
.character-list-history-thumb--placeholder {
  border: 1px dashed var(--line-2);
  cursor: default;
}

.character-list-history-info {
  min-width: 0;
  flex: 1;
}

/* Holds the chapter-jump button plus the "Current" status tag as SIBLINGS
   (see CharacterListSheet.tsx's own comment on why Current deliberately
   isn't a child of the button) laid out on one line. */
.character-list-history-chapter-row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 8px;
}

/* A plain span in character/place history (only the title's translation
   link opens the chapter there) and a <button> in the world setting history
   (WorldSetting.tsx), which has no titles and so jumps from the number. Same
   bare-number, muted-mono treatment as ReadPage.css's own
   .read-chapter-item-number (the chapters rail) — this row now puts the
   number and the chapter's title (.character-list-history-title, right
   below) side by side the same way, rather than a bold "Chapter N" label
   with the title on its own line beneath. `flex: none` so it only takes the
   width its own digits need, leaving the title to fill the rest of the row. */
.character-list-history-chapter {
  flex: none;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  font-family: var(--mono);
  text-align: left;
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-3);
}
button.character-list-history-chapter:hover {
  text-decoration: underline;
}
span.character-list-history-chapter {
  cursor: default;
}

.character-list-history-current {
  flex: none;
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 3px;
  padding: 1px 4px;
}

/* Per-chapter tags, same small-uppercase-pill treatment as ReadPage.css's
   .read-chapter-item-explicit (warn tone, reused verbatim for the same
   has_explicit_content signal) — .character-list-history-illustration is a
   new pill in the same style rather than a new shared icon, so a chapter
   with both tags just reads as two adjacent pills instead of needing icon
   real estate carved out of an already-dense row. Now direct children of
   .character-list-history-chapter-row's own flex `gap` (they used to sit
   inside the chapter-number button's inline text instead) — no own
   margin-left needed, the row's gap already spaces every child evenly. */
.character-list-history-explicit,
.character-list-history-illustration {
  flex: none;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  border-radius: 3px;
  padding: 1px 4px;
  vertical-align: middle;
}
.character-list-history-explicit {
  color: var(--warn);
  border: 1px solid var(--warn);
}
.character-list-history-illustration {
  color: var(--ink-3);
  border: 1px solid var(--line-2);
}

/* Same tappable-prose treatment as .character-list-desc (`text` cursor, not
   `pointer` — this is still a real chapter title the user might select, not
   just a button) — click reveals .character-list-history-title-translation
   beneath it, one row at a time (CharacterListSheet.tsx's toggledChapter).
   Now a sibling of .character-list-history-chapter in the same flex row
   (`margin: 0`, `min-width: 0` so it wraps/shrinks within the row instead of
   forcing it wider) rather than its own line below — and sized to match
   .character-list-desc's own "general text" size, not the smaller flat 13px
   this used to share with the (still-13px) change-description annotation
   below. */
.character-list-history-title {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: 16px;
  color: var(--ink-2);
  cursor: text;
}
.character-list-history-title-translation {
  margin: 4px 0 0;
  /* Upright like .character-list-desc-translation, but keeps the history
     page's original muted (ink-3) grey rather than the body text colour — flat at every
     width, as that rule's own doc explains. */
  font-size: 15px;
  line-height: 1.7;
  color: var(--ink-3);
}

/* A <button> version of the rule above, used ONLY for the chapter title's
   own toggled translation (CharacterListSheet.tsx) — not
   change_description's, which stays plain, non-clickable prose using the
   shared class above. Self-contained (duplicating margin/font-size/
   font-style/color rather than also applying the shared class alongside
   it) so a <button>'s own UA default styling can't win a cascade fight over
   which class happens to load second. Same restrained "no visible link
   color, just a hover underline" treatment as ReadPage.css's own
   .novel-info-chapter-name-link, so this reads as clickable prose rather
   than a differently-colored link. */
.character-list-history-title-translation-link {
  display: block;
  text-decoration: none;
  width: 100%;
  margin: 4px 0 0;
  padding: 0;
  border: none;
  background: none;
  font-size: 15px;
  line-height: 1.7;
  font-family: inherit;
  text-align: left;
  color: var(--ink-3);
  cursor: pointer;
}
.character-list-history-title-translation-link:hover {
  text-decoration: underline;
}

.character-list-history-tags-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}

.character-list-history-change {
  /* More breathing room than the 2px gap above the title's own translation
     toggle — this is a distinct piece of info (what changed), not another
     rendering of the same title, so it reads better set a bit further off. */
  margin-top: 8px;
  font-size: 13px;
  color: var(--ink-3);
  cursor: text;
}

/* Chapter number doubling as the row's actions menu (move/delete version) —
   a full-width block so the number keeps its own line like the plain span
   version, with the SelectOverlay (and so the tap target) only as wide as
   the number itself. */
.character-list-history-chapter-menu {
  flex: none;
  width: 100%;
  display: flex;
  align-items: center;
  gap: 8px;
}
.character-list-history-chapter--menu {
  width: auto;
  cursor: pointer;
  text-decoration: underline dotted;
  text-underline-offset: 3px;
}

/* Inline, in place of the row's own action buttons while that one row's
   move/delete is in flight — distinct from
   .character-list-portrait-spinner-overlay's own absolutely-positioned
   version above, which dims a whole portrait rather than sitting in a
   row's own action slot. */
.character-list-history-spinner {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 2px solid var(--line-2);
  border-top-color: var(--ink-3);
  flex-shrink: 0;
  margin-right: 8px;
  animation: character-list-portrait-spin 0.7s linear infinite;
}

/* ── Merge target page ── */

.character-list-merge-filter {
  margin-bottom: 10px;
}

.character-list-merge-empty {
  margin: 0;
  font-size: 14px;
  color: var(--ink-3);
}

.character-list-merge-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* A plain <button>, not a styled-to-look-like-a-row div — a whole-row click
   target needs real button semantics (keyboard focus/activation), unlike
   .character-list-history-row's own per-action IconButtons where the row
   itself isn't the interactive element. */
.character-list-merge-row {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  padding: 10px 8px;
  border: none;
  border-radius: 10px;
  background: none;
  font: inherit;
  text-align: left;
  color: inherit;
  cursor: pointer;
}
.character-list-merge-row:hover {
  background: var(--surface-2);
}

.character-list-merge-thumb {
  width: 100px;
  height: 100px;
  border-radius: 10px;
  object-fit: cover;
  background: var(--surface-2);
  flex-shrink: 0;
}
.character-list-merge-thumb--placeholder {
  border: 1px dashed var(--line-2);
}

.character-list-merge-name {
  font-size: 15px;
  font-weight: 600;
  color: var(--ink);
}

/* ── Move version Sheet ── */

.character-list-move-warning {
  margin: 8px 0 0;
  font-size: 13px;
  color: var(--warn);
}

/* World setting row/edit page (WorldSetting.tsx) — reuses the character-list-*
   classes above for the row/history layout; only the edit page's own form
   controls need styling here. */
.world-setting-edit {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 4px 0 8px;
}
.world-setting-edit-textarea {
  width: 100%;
  box-sizing: border-box;
  resize: vertical;
  min-height: 140px;
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid var(--border, rgba(127, 127, 127, 0.35));
  background: var(--surface, transparent);
  color: inherit;
  font: inherit;
  line-height: 1.5;
}
.world-setting-edit-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}
.selprompt-body {
  display: flex;
  flex-direction: column;
}

.selprompt-selected {
  font-size: 15px;
  color: var(--ink-3);
  line-height: 1.5;
  margin: 0;
}
.selprompt-selected span {
  color: var(--ink);
  font-weight: 600;
}

.selprompt-buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 14px;
}
/* Same interaction as LibraryPage/SearchPage's .uploader-link — inherits
   color from .read-header-subtitle rather than the app's usual link/accent
   color, since it's still just one piece of one subtitle line, not a
   standalone link. */
.read-header-author-link {
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}
.read-header-author-link:hover {
  text-decoration: underline;
}

/* Toggles the chapter panel, same as clicking 目次 — cursor/hover-underline
   for the same "this piece of text is clickable" affordance as the author
   link above. */
.read-header-position {
  cursor: pointer;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: inherit;
}
.read-header-position:hover {
  text-decoration: underline;
}

/* The unnamed flex-item `<div>` wrapping title+subtitle (see
   PageHeader.tsx) needs `min-width: 0` — flex items default to
   `min-width: auto`, which would let the row overflow sideways instead of
   staying within its allotted space. */
.read-app-main .stratum-page-header-title-row > div,
.read-app-main .stratum-page-header-desktop-inner > div {
  min-width: 0;
}

/* Read header is a fixed height: exactly one line of title + one line of
   subtitle, each truncated with an ellipsis, on both mobile and desktop.
   Scoped under .read-app-main so no other page's header is affected. The full
   text lives in the title bottom sheet (see ReadPage.tsx's titleSheetOpen).
   The title keeps stratum-ui's own `width: fit-content; max-width: 100%`
   (see PageHeader.css), so a short title still shrink-wraps and taps on the
   empty space beside it hit the header background (scroll to top) instead of
   the title. */
.read-app-main .stratum-page-header-mobile .stratum-page-header-title,
.read-app-main .stratum-page-header-desktop .stratum-page-header-title {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}
/* Subtitle container wraps: trailing controls (position, characters,
   illustrations, Actions, …) flow onto following lines instead of being
   clipped by a container-level ellipsis. */
.read-app-main .stratum-page-header-mobile .stratum-page-header-subtitle {
}
/* The novel/uploader title links truncate with an ellipsis when too long for
   one line; everything after them keeps wrapping. inline-block + max-width
   lets the link shrink-to-fit short titles but never exceed the line;
   vertical-align: bottom keeps it aligned with the surrounding " — " text. */
.read-app-main .stratum-page-header-mobile .stratum-page-header-subtitle .read-header-author-link,
.read-app-main .stratum-page-header-desktop .stratum-page-header-subtitle .read-header-author-link {
  display: inline-block;
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  vertical-align: bottom;
}
/* Novel title vs source domain: both desktop and mobile show the novel title
   (truncated with an ellipsis via the .read-header-author-link rule above);
   the domain never renders anywhere (kept hidden as a safety net). */
.read-header-novel-domain {
  display: none;
}

/* Title bottom sheet content (see titleSheetOpen in ReadPage.tsx) — the
   chapter title wraps fully, never truncated: showing the untruncated text
   is the whole point of the sheet. */
.read-title-sheet-chapter {
  padding-top: 4px;
  font-size: 16px;
  line-height: 2;
  color: var(--ink);
  overflow-wrap: break-word;
}

/* stratum-ui's .stratum-page-header-spacer reserves the mobile header's
   height in normal flow (the header itself is position: absolute — see
   PageHeader.tsx's doc comment). The read header is a fixed height (single-
   line title + subtitle, see above), so this stays constant in practice —
   ReadPage.tsx's ResizeObserver effect still measures the header's actual
   rendered box and writes --read-header-height onto .read-app-main rather
   than hardcoding it here, so any future height change (font load, safe-area
   differences) is picked up instead of letting page content peek out from
   behind the header. Falls back to stratum-ui's original fixed value before
   the effect's first measurement lands (avoids a 0-height flash on first
   paint). */
.read-app-main .stratum-page-header-spacer {
  height: var(--read-header-height, calc(86px + var(--safe-top)));
}

/* Root shell: chapter rail + content column side by side on desktop, same
   pattern as SearchPage's .sr-app-main/.sr-content-col. `height: 100%` (not
   auto) is kept even at mobile width — this box has no `overflow` of its own
   here to begin with (only .read-content-col below does), so there's nothing
   for the mobile native-scroll migration to relax on this specific rule. */
.read-app-main {
  position: relative;
  height: 100%;
  display: flex;
  flex-direction: column;
}
@media (min-width: 768px) {
  .read-app-main {
    flex-direction: row;
    overflow: hidden;
  }
}

/* Mobile: height:auto/overflow:visible, not the viewport-clamped/hidden pair below — lets
   .app-scroll.read-scroll's own content (once IT stops being its own scroll box too, see
   further down) grow past the viewport height so the real document scrolls instead of this
   div clipping it. Part of ReadPage's mobile native-scroll migration (see
   AppShellLayout.css's own `--native-scroll` doc for the full picture) — fixes iOS Safari's
   native text-selection UI breaking inside a scrolled nested container. Desktop (below)
   keeps the exact old clamped/hidden values, unaffected. */
.read-content-col {
  flex: 1;
  min-width: 0;
  position: relative;
  height: auto;
  overflow: visible;
}
@media (min-width: 768px) {
  .read-content-col {
    display: flex;
    flex-direction: column;
    height: calc(var(--vh, 1vh) * 100);
    overflow: hidden;
  }
}

/* stratum-ui's BottomPanel (DictLookupSheet/SelectionPromptSheet/CharacterListSheet)
   defaults to `position: fixed`, centering itself against the whole viewport — desktop-only
   override below: on desktop that default is off-center from .read-main, which is centered
   within .read-content-col's own width (narrower than the viewport once the chapter rail, a
   flex sibling, takes its share), so `position: absolute` scopes it to that narrower column
   instead (.read-content-col is `position: relative`, and at desktop width still spans the
   full viewport height as a non-scrolling container, so this stays docked regardless of
   scroll position). Mobile now uses the untouched `position: fixed` default instead of this
   override — correct once .read-content-col stops being a bounded, non-scrolling container
   there (see its own doc above); there's also no rail to exclude on mobile, so centering
   against the raw viewport is exactly what's wanted anyway. */
@media (min-width: 768px) {
  .read-content-col .stratum-bottom-panel {
    position: absolute;
  }
}

/* Neutralizes .app-scroll's own shared base (mobile) scrolling behavior
   (AppShell.css: `position: absolute; inset: 0; overflow-y: auto`) for
   ReadPage specifically — the actual last piece of the native-scroll chain,
   letting the real document/body do the scrolling instead of this div. Not
   a change to .app-scroll itself (shared by every other page, which all
   stay on the nested-scroll model) — `.app-scroll.read-scroll` (both
   classes, specificity (0,2,0)) deliberately outweighs the shared
   `.app-scroll` rule's (0,1,0) regardless of stylesheet order, so this new
   rule wins without needing `!important`. Desktop is untouched: the
   existing `@media (min-width: 768px) { .app-scroll {...} }` block in
   AppShell.css still applies there exactly as before, since this rule never
   overlaps it. */
@media (max-width: 767px) {
  .app-scroll.read-scroll {
    position: static;
    overflow-y: visible;
    overflow-x: visible;
  }
}

.read-main {
  width: 100%;
  max-width: 720px;
  margin: 0 auto;
  padding: 16px 20px 0;
}

.read-loading,
.read-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 80px 20px;
  color: var(--ink-3);
  text-align: center;
}

.read-error {
  padding: 20px;
  border-radius: 10px;
  background: rgba(255, 69, 58, 0.1);
  color: var(--warn);
  font-size: 14px;
}

.read-text {
  font-size: 16px;
  line-height: 1.9;
  color: var(--ink);
  white-space: pre-wrap;
  word-break: break-word;
  padding: 30px 0;
}

/* Virtual "作品情報" chapter 0's content (syosetu only — see
   ReadPage.tsx's renderNovelInfo/siteMetadata) — synopsis prose, keyword
   chips, then a source-declared metadata list, in the source site's own
   field order. */
.novel-info {
  display: flex;
  flex-direction: column;
  gap: 24px;
  padding: 10px 0 20px;
}
/* Title/author lead — the page header already shows both (see
   read-header-subtitle), but this page stands in for the source's own
   infotop page, which leads with them the same way. */
.novel-info-header {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.novel-info-title {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
  line-height: 1.4;
  color: var(--ink);
}
.novel-info-author {
  font-size: 16px;
  color: var(--ink-3);
}
/* Wraps one `.novel-info-synopsis-line` (+ its `.read-translation`, once
   toggled) per source line — see ReadPage.tsx's synopsis rendering — rather
   than being one field itself, so each line can toggle independently, same
   as real chapter prose. */
.novel-info-synopsis {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.novel-info-synopsis-line {
  margin: 0;
  font-size: 16px;
  line-height: 1.9;
  color: var(--ink);
  white-space: pre-wrap;
  word-break: break-word;
}
/* .novel-info-keywords-block wraps the original-JP row and (once toggled —
   see ReadPage.tsx's keywords rendering in renderNovelInfo) the translated
   row beneath it — the whole cluster of tags acts as ONE toggleable line,
   not one per tag, so both rows share this one wrapper rather than each
   living inside its own independent toggle state. */
.novel-info-keywords-block {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.novel-info-keywords {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 6px 8px;
  /* `stretch` (flex's own default, stated explicitly since it's load-bearing here) is
     what makes every pill in a wrapped row match that row's tallest one — which, once a
     kanji tag picks up its furigana <rt> line on toggle, is however tall the browser's
     own native ruby layout decided that line needs to be. Pairs with the pill's own
     `align-items: flex-end` below: stretch alone would leave a short tag's text floating
     at the TOP of its now-taller pill while a furigana tag's base text sat near the
     bottom, i.e. same pill heights but mismatched baselines. An earlier attempt reserved
     a hardcoded `calc(4px + 0.9em)` of top padding on every pill instead, which is the
     same thing but only correct if that guess happens to equal what native ruby layout
     actually reserves — it didn't, and there's no reason it would across fonts/engines.
     Stretching to a browser-computed height needs no such guess. */
  align-items: stretch;
}
/* display: flex + align-items: flex-end anchors this pill's content to its BOTTOM, so
   the extra height a stretched pill picks up from a furigana-carrying neighbor lands
   above the text (exactly where that neighbor's own reading sits) rather than below it,
   and every tag's base text in the row shares one baseline. The content is wrapped in a
   single .novel-info-keyword-text span (see ReadPage.tsx) specifically so it stays ONE
   flex item — without it each <ruby>/token/bare text node inside would become its own
   flex item and lose normal inline flow/baseline alignment with its siblings. */
.novel-info-keyword {
  padding: 4px 10px;
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--ink-2);
  font-size: 16px;
  /* `text`, like .read-line (chapter prose): the tags are selectable text you click to toggle, not a button. */
  cursor: text;
  display: flex;
  align-items: flex-end;
}
/* Furigana <rt> here uses no explicit CSS at all — same native ruby layout
   chapter body prose relies on, which reserves in-flow height above the
   base text for it. A position: absolute + translateX(-50%) hand-rolled
   centering was tried here instead (to stop a toggled tag's furigana from
   growing the pill's own background/border box), but proved genuinely
   fragile across engines: <ruby>'s default display is one of CSS's special
   "internal" display types (display: ruby, in the same category as
   display: table-row), and different centering techniques on top of it hit
   different real per-engine bugs — offset left on iOS Safari, invisible on
   desktop, then merely correctly-contained-but-still-inconsistently-offset
   once even the containing-block bug itself was fixed. Native ruby
   layout's alignment is the browser's own well-tested code path (the exact
   thing it's designed to get right), so it's used as-is instead of
   continuing to patch the hack. Trade-off: a tag's pill grows taller while
   its furigana is showing — which is why furigana here renders only while
   the line is toggled (ReadPage.tsx's showFurigana) and why the whole row
   stretches together (see .novel-info-keywords' align-items comment), so
   that growth is uniform across the row rather than per-tag. */
/* The translated row (ReadPage.tsx's `.novel-info-keyword--translated`) —
   de-emphasized like every other translation in this app (.read-translation,
   also 15px), and not itself clickable (no data-line-index on this row —
   see its own doc), so no pointer cursor here. Compound selector (not just
   `.novel-info-keyword--translated` alone) so this font-size reliably beats
   .novel-info-keyword's own 16px/17px-on-desktop rules regardless of cascade
   order — same specificity trick needed anywhere a modifier class overrides
   a plain one that's ALSO touched by a later @media block. */
.novel-info-keyword.novel-info-keyword--translated {
  background: transparent;
  border: 1px dashed var(--line-2);
  color: var(--ink-3);
  font-size: 15px;
  cursor: default;
}
/* dt/dd pairs auto-place two-per-row via grid-auto-flow (default 'row')
   across these two columns — no extra wrapper markup needed per pair. */
.novel-info-meta {
  margin: 0;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 10px 16px;
  font-size: 13px;
}
.novel-info-meta dt {
  color: var(--ink-3);
}
.novel-info-meta dd {
  margin: 0;
  color: var(--ink);
  font-family: var(--mono);
}

/* Full chapter list, grouped by section — same source-declared grouping as
   the chapter rail (see ReadPage.tsx's groupChaptersBySection), just not
   truncated by the rail's collapsed/narrow width. Deliberately its own
   classes rather than reusing .read-chapter-item* (the rail/sheet's own
   clickable item styling) — a chapter row's Japanese title still isn't a
   link (only its toggled-in English translation is, see
   .novel-info-chapter-name-link below), and a not-yet-downloaded row only
   gets a fade (.novel-info-chapter--pending), not the rail's own
   cursor/failed-label treatment. */
.novel-info-toc {
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.novel-info-toc-section-title {
  margin: 0 0 4px;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--line);
  font-size: 16px;
  font-weight: 700;
  color: var(--ink);
}
.novel-info-chapter-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.novel-info-chapter {
  display: flex;
  /* Baseline, not flex-start: baseline alignment uses a wrapped item's FIRST
     line only, so the number/date still correctly line up with the title's
     first line even once a long title wraps onto multiple lines — no need
     for the number/name/date spans (three different font-sizes, so three
     different "normal" line-heights) to share a box-edge-aligned layout. */
  align-items: baseline;
  gap: 10px;
  padding: 12px 0;
  font-size: 16px;
  color: var(--ink-2);
  /* A hairline divider per row (rather than the old bare flex `gap`) reads
     much better at the larger chapter-title size (see the responsive bump
     in the @media block below) — without it, a taller two/three-line title
     wrapping in one row starts to visually blur into its neighbors. */
  border-bottom: 1px solid var(--line);
}
.novel-info-chapter-list > .novel-info-chapter:last-child {
  border-bottom: none;
}
/* Not yet downloaded (isChapterReady false) — a lighter fade than the
   chapter rail's own .read-chapter-item--disabled (0.6), since this row
   isn't a dead end the way a disabled rail item is: its Japanese title/
   furigana toggle still works even before translation/download finishes. */
.novel-info-chapter--pending {
  opacity: 0.7;
}
.novel-info-chapter-number {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--ink-3);
  flex-shrink: 0;
  /* Right-aligned in a fixed-ish column so a run of chapter numbers lines
     up on their ones digit instead of ragged-left (1, 12, 106, ...) — more
     noticeable now that the title text next to it is bigger and heavier. */
  min-width: 3ch;
  text-align: right;
}
.novel-info-chapter-name {
  font-size: 16px;
  word-break: break-word;
}
/* The English translation of a TOC row's title doubles as the way to open
   that chapter (ReadPage.tsx's renderNovelInfo) — kept the same muted
   .read-translation color (var(--ink-2)) every other translation in this
   field uses, rather than reading as a differently-colored link; only the
   hover underline hints it's clickable. */
.novel-info-chapter-name-link {
  color: var(--ink-2);
  text-decoration: none;
}
.novel-info-chapter-name-link:hover {
  text-decoration: underline;
}
.novel-info-chapter-date {
  margin-left: auto;
  flex-shrink: 0;
  font-family: var(--mono);
  font-size: 12px;
  color: var(--ink-3);
}

.read-inline-image {
  display: block;
  max-width: 100%;
  /* A tall illustration never takes more than 90% of the space between the header and the bottom bar (ReadPage.tsx sets
     --read-illustration-room; falls back to the viewport); the width follows from the aspect ratio, so it can end up
     narrower than the reading column — left-aligned then (the margin below), not centered. */
  max-height: 90vh;
  max-height: calc(var(--read-illustration-room, 100dvh) * 0.9);
  height: auto;
  /* ReadPage.tsx's renderChapterText always collapses the source text's own
     blank lines immediately around an embedded/AI illustration image (down
     to zero) — this margin is what supplies the paragraph-gap breathing
     room instead, sized to roughly match a normal paragraph's blank-line
     rhythm (.read-text: 16px / line-height 1.9 ≈ 30px per blank line, same
     approximation .read-translation's own margin-bottom uses). */
  margin: 30px auto 30px 0;
  border-radius: 10px;
  cursor: pointer;
}
/* Applied by renderChapterText only when nothing precedes/follows the image
   at all (the very start/end of the chapter) — .read-text's own top/bottom
   padding already supplies the gap there, so the margin above would just
   double up on it. */
.read-inline-image--no-top-margin {
  margin-top: 0;
}
.read-inline-image--no-bottom-margin {
  margin-bottom: 0;
}

/* Auto-linked urls within the chapter prose itself — accent blue, matching
   the Prev/次へ/目次 buttons' own ghost-variant color, rather than UrlLink's
   default muted/mono treatment (which stays as-is for the header's own url
   and for Library/Search/Downloads' source-url display — this override is
   scoped to just links inside .read-text and the 作品情報 synopsis, which is prose
   too but lives in .novel-info rather than .read-text). */
.read-text .stratum-url-link,
.novel-info-synopsis .stratum-url-link {
  color: var(--accent);
}

/* Click-to-space-out affordance (ReadPage.tsx's renderChapterText/
   onLineClick) — `text`, not `pointer` — matches
   bilingual-translate-extension's own `.original-text.dict-active { cursor:
   text }`: this is still prose the user selects text from as much as it's
   a click target, and `pointer` reads as "button", not "text". */
.read-line {
  cursor: text;
}
/* Chapter-body lines (renderChapterText) are blocks, not inline spans, so
   every line visually starts on its own line even when separated from the
   previous one by a single "\n" — with inline spans that break depended on
   a raw-newline text node rendering under pre-wrap, which silently merged
   some single-\n lines (e.g. a chapter-title first line) into the line
   below. The \n-vs-\n\n rhythm is unchanged: renderChapterText re-emits
   every separator run one newline short, since the block boundary already
   breaks the line (lone \n = bare break, \n\n = one blank line, ...).
   Scoped to .read-text so ToggleableField's own .read-line usages outside
   the chapter body (page header title, 作品情報 fields — inline in their own
   layouts) are unaffected. */
.read-text .read-line {
  display: block;
}
.read-line--loading {
  cursor: wait;
  opacity: 0.6;
  transition: opacity 0.15s;
}

/* DeepSeek translation shown below the active line (ReadPage.tsx's
   renderChapterText) — block-level, like the chapter-body .read-line above
   it, so it actually sits on its own line beneath the Japanese prose.
   De-emphasized relative to the main text, same tone as
   .novel-info-synopsis's secondary copy above. */
.read-translation {
  /* Just breathing room between the line and its translation — the
     paragraph gap below still comes from the newline-run separator itself
     (see renderChapterText's flushSeparator), unaffected by this. */
  margin: 4px 0;
  font-size: 15px;
  line-height: 1.7;
  color: var(--ink-2);
  white-space: pre-wrap;
  word-break: break-word;
  cursor: default;
  /* Positioning context for the ghost reveal button (see
     .read-reveal-ghost) — zero layout impact on its own. */
  position: relative;
  /* No iOS tap-highlight flash: Mobile Safari paints a translucent gray box
     over a pressed element, and that gray sits exactly over the translation
     block, reading as a halo/shadow around the text. Transparent keeps it
     clean; the ghost button still activates via click, and selection +
     dict lookup on revealed lines are unaffected. */
  -webkit-tap-highlight-color: transparent;
  /* Quick fade when a tap-to-reveal lands (see revealParagraph in
     ReadPage.tsx) — filter/opacity only, no layout properties, so revealing
     never shifts surrounding text. */
  transition:
    filter 0.18s ease,
    opacity 0.18s ease;
}

/* Gamified reveal (prototype — see ReadPage.tsx's meter constants):
   every translation renders blurred until a tap on its ghost button
   unblurs it. The blur lives on the inner .read-translation-text span, not
   this paragraph — `filter`, not an overlay, so it blurs in place with
   zero reflow (reveal can't jump the reading position) while the button
   itself stays crisp above it. Selection is disabled while blurred so a
   long-press can't highlight or copy what the reader hasn't earned yet,
   and -webkit-touch-callout suppresses the iOS magnifier/callout on that
   same gesture. */
.read-translation--blurred {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
.read-translation--blurred .read-translation-text {
  filter: blur(6px);
}

/* Bleed room for the blur halo (see .read-translation--blurred above) —
   every blurred line starts flush at this box's own left edge, so without
   this the halo's left falloff has to paint OUTSIDE the border box, where
   it gets sliced into a hard vertical cutoff (seen on iPhone: the left
   edge reads as a sharp slice while every other side fades softly). This
   symmetric padding pulls that falloff INSIDE the box instead, and the
   compensating negative margins keep the text itself pixel-aligned with
   the revealed state (same content width, so identical line-wrapping) and
   the vertical rhythm untouched — revealing still causes zero reflow.
   Compound selector (not just .read-translation--blurred alone) so the
   margin reliably beats .read-translation's own `margin: 4px 0` regardless
   of cascade order. Sized ~3x the 6px blur sigma so the halo reaches
   effectively transparent before the box edge; blur strength itself is
   unchanged. */
.read-translation.read-translation--blurred {
  padding: 0 18px;
  margin: 4px -18px;
}

/* Tap-to-reveal ghost button (see RevealGhostButton in ReadPage.tsx) — a
   small quiet circular button absolutely centered over each still-blurred
   translation paragraph while a reveal is affordable (gated on
   canAffordReveal in the TSX: below cost nothing renders at all). The
   paragraph itself (.read-translation) is the positioning context.
   Translucent and theme-aware via color-mix so it stays subtle in both
   light and dark mode; pointer events live only on the button, so
   scrolling and text selection elsewhere are untouched. touch-action:
   manipulation drops the mobile double-tap-zoom delay on the button
   without affecting panning anywhere else. */
.read-reveal-ghost {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--ink) 18%, transparent);
  background: color-mix(in srgb, var(--bg) 60%, transparent);
  color: var(--ink-2);
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  opacity: 1;
  transition:
    opacity 0.18s ease,
    background 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease;
  animation: read-reveal-ghost-in 0.18s ease;
}
.read-reveal-ghost:hover {
  border-color: color-mix(in srgb, var(--ink) 32%, transparent);
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  color: var(--ink);
}
.read-reveal-ghost:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
@keyframes read-reveal-ghost-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* (Peek-meter readout moved to the floating paste-button ring — see
   AppShell.css's body.peek-meter-ring rule, driven by AppShell from
   ReadPage's published meter level.)

/* Per-chapter identified characters — shown via the header's clickable
   count + CharacterListSheet (a BottomPanel) now, not inline here anymore.
   See ReadPage.tsx's charactersPanelOpen / frontend/src/components/
   CharacterListSheet.tsx. */
.read-header-characters {
  cursor: pointer;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: inherit;
}
.read-header-characters:hover {
  text-decoration: underline;
}

/* Chapter-header actions menu (fix-alignment, etc.) — same clickable-header-
   text treatment as .read-header-characters/.read-header-position above, not
   button chrome. The dropdown itself is stratum-ui's SelectOverlay (a native
   <select> stretched over this trigger); .read-header-actions-trigger is
   just the visible text underneath it. */
.read-header-actions-trigger {
  cursor: pointer;
}
.read-header-actions:hover .read-header-actions-trigger {
  text-decoration: underline;
}
/* While a check is in flight — SelectOverlay's own `disabled` prop already
   stops the underlying <select> from opening; this just stops the trigger
   text from still LOOKING clickable while that's true. */
.read-header-actions-trigger--pending {
  cursor: default;
  opacity: 0.6;
}
.read-header-actions:hover .read-header-actions-trigger--pending {
  text-decoration: none;
}

/* One span per kuromoji token (see renderTextSegment) — margin, not an
   inserted space character, so copy/selection still yield the exact
   original text. */
.read-token {
  margin-right: 0.4em;
}
.read-token:last-child {
  margin-right: 0;
}

/* A character name (see characterNameReadings.ts) — a faint dotted underline
   marks it as tappable (opens the cast sheet on that character). Its furigana
   renders like any other token's. */
.read-token--name {
  text-decoration: underline dotted color-mix(in srgb, var(--accent) 60%, transparent);
  text-underline-offset: 0.2em;
}

/* Dictionary lookup match highlight (ReadPage.tsx's dictHighlight/
   renderTextSegment) — a matched word can span several adjacent
   .read-token spans (a conjugated verb kuromoji split into multiple
   tokens), so the background bleeds into the token's own right margin via
   an equal-color box-shadow, bridging the gap so adjacent matched tokens
   read as one continuous highlighted run instead of separate islands —
   same technique bilingual-translate-extension's own .dict-match uses.
   Rounded corners only at the START and END of a run: detected via pure
   CSS sibling matching (:has() + adjacent-sibling) rather than threading
   first/last bookkeeping through the renderer, since matched tokens are
   always contiguous DOM siblings within one line. */
.read-token.dict-match {
  background: rgba(255, 200, 0, 0.4);
  box-shadow: 0.4em 0 0 0 rgba(255, 200, 0, 0.4);
  border-radius: 0;
}
.read-token.dict-match:not(.dict-match + .dict-match) {
  border-top-left-radius: 3px;
  border-bottom-left-radius: 3px;
}
.read-token.dict-match:not(:has(+ .dict-match)) {
  border-top-right-radius: 3px;
  border-bottom-right-radius: 3px;
  box-shadow: none;
}

/* Furigana readings are never part of a selection (and so never copied) —
   an earlier version of this avoided `user-select: none` here over an iOS
   Safari long-press-to-select regression on a toggled line; re-tried and
   accepted as fine now, in favor of dropping the copy-event-listener/
   clone-and-strip-<rt> workaround it required (see selectionPrompt.ts's
   getSelectionText and the removed onCopy effect in ReadPage.tsx).
   pointer-events: none on top of that — so a drag/long-press never hits
   the <rt> box as its own selection target in the first place, rather than
   relying solely on engines correctly honoring user-select on a ruby-text
   box (the actual
   source of the iOS regression noted above: touch hit-testing landing on
   <rt> triggered its own selection/callout there regardless of
   user-select). Doesn't affect click-to-toggle: ReadPage.tsx's line click
   handler already re-resolves the real target via
   document.elementFromPoint rather than trusting event.target, so a click
   that now passes through <rt> just resolves to the ruby/base element
   underneath instead.
   -webkit-user-select explicitly alongside the unprefixed property —
   confirmed on real Safari (desktop) that the unprefixed property alone
   still let a drag-select highlight <rt> text; Safari's ruby-text box
   apparently needs its own vendor-prefixed value to honor this, not just
   whatever -webkit-user-select happens to be inherited from an ancestor. */
.read-line rt {
  -webkit-user-select: none;
  user-select: none;
  pointer-events: none;
}

/* Narou's dot-based emphasis convention (bouten, delivered as <ruby> tags
   whose reading is just "・" repeated per character — see ReadPage.tsx's
   renderTextSegment/SEGMENT_RE) rendered as real emphasis dots via CSS
   rather than the literal "(・)" text the source markup would otherwise
   flatten to. text-emphasis-position left at its default (over), which
   matches the standard convention for horizontal Japanese text. */
.read-emphasis {
  -webkit-text-emphasis: filled dot;
  text-emphasis: filled dot;
}

@media (min-width: 768px) {
  .read-main { padding: 24px 20px 0; }
  .read-text { font-size: 24px; }
  /* Same ratio to .read-text as the mobile 15px/16px pairing (~0.94), scaled
     up to the desktop 24px size — keeps the translation legibly smaller/
     de-emphasized relative to the Japanese prose above it rather than
     staying flat at 15px and looking disproportionately tiny next to 24px
     text. */
  .read-translation { font-size: 20px; }
  /* Title-sheet translation stays at the mobile 15px on desktop too — the
     sheet's own Japanese text stays small (17px, below), so the 20px
     body-prose size above would loom over it. Two-class specificity beats
     the global rule regardless of order. */
  .read-title-sheet-chapter .read-translation { font-size: 15px; }
  /* Matches .character-list-desc's desktop size. */
  .read-title-sheet-chapter { font-size: 17px; }
  /* Same responsive bump as .read-text above, for 作品情報's own toggleable
     content fields (ReadPage.tsx's renderNovelInfo) — synopsis, author,
     keyword tags, each TOC entry's chapter title, and each section heading
     — so they read at the same size as real chapter body prose rather than
     looking smaller just because they're informational fields. */
  .novel-info-synopsis-line,
  .novel-info-author,
  .novel-info-keyword,
  .novel-info-chapter-name,
  .novel-info-toc-section-title {
    font-size: 24px;
  }
  /* novel-info-title needs its own bump too, not just the fields above —
     without one it stays pinned at its mobile 22px while the body prose
     around it grows to 24px, leaving the page's biggest heading rendering
     smaller than the plain text underneath it. Scaled up keeping the same
     ~1.375 ratio to body text that the mobile 22px/16px pairing has. */
  .novel-info-title {
    font-size: 33px;
  }
}

/* Previous / TOC / Next, hugging together and centered as one group —
   not spanning/flushed against the page edges anymore now that there's a
   third button in the middle. */
.read-chapter-nav {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 6px;
  padding: 8px 0;
}

/* Desktop chapter rail — same shape as SearchPage's .sr-filter-rail, except
   width is user-resizable (see ReadPage.tsx's railWidth/onRailResizeStart)
   rather than fixed, so it's set inline rather than here — this rule only
   supplies everything that ISN'T the width. The divider line lives on
   .read-chapter-rail-resize instead of a border here, since that's now a
   separate flex sibling between the rail and the content column.

   Unlike .sr-filter-rail (short filter lists that rarely overflow), this is
   the primary way to navigate a chapter list that's often long — same
   reasoning as .read-sheet-body's own carve-out below from this app's
   general "hide scrollbar but stay scrollable" convention, just for the
   mouse-driven desktop rail instead of the touch sheet: with no scrollbar,
   there's no visual hint the list even continues past the visible portion. */
.read-chapter-rail {
  display: none;
}
@media (min-width: 768px) {
  .read-chapter-rail {
    display: block;
    flex-shrink: 0;
    height: calc(var(--vh, 1vh) * 100);
    overflow-y: auto;
  }
  .read-chapter-rail--collapsed {
    width: 0;
    overflow: hidden;
  }
}

/* Drag handle between the rail and the content column — a wide-ish invisible
   hit area (6px) with a thin visible line down the middle, thickening and
   accent-colored on hover/drag for grab affordance (thin line always
   visible, so the divider itself never looks like it's just disappeared).
   The line sits flush against the RIGHT edge (the content column's side)
   rather than centered — centered left a ~2-3px sliver of bare page
   background between the line and the content column's own header, visible
   once that header's scrolled background/blur paints right up to its own
   edge (flush against the divider's hit-box) while the centered line itself
   sat inset from it. The rail side has no equivalent background, so the
   mirror-image gap that appears on ITS edge instead now stays unnoticeable. */
.read-chapter-rail-resize {
  display: none;
}
@media (min-width: 768px) {
  .read-chapter-rail-resize {
    display: block;
    position: relative;
    width: 6px;
    flex-shrink: 0;
    cursor: col-resize;
    touch-action: none;
  }
  .read-chapter-rail-resize::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    width: 1px;
    background: var(--line);
    transition: background .12s, width .12s;
  }
  .read-chapter-rail-resize:hover::after,
  .read-chapter-rail-resize--active::after {
    width: 3px;
    background: var(--accent);
  }
}

.read-chapter-rail-inner {
  padding: 20px 16px 40px;
}
.read-chapter-rail-title {
  font-size: 13px;
  font-weight: 700;
  color: var(--ink);
  /* Left edge aligned with .read-chapter-item-number below — that item's own
     10px left padding (see .read-chapter-item) sits it 10px further right
     than the plain rail-inner padding this heading otherwise starts at. */
  margin: 0 0 14px 10px;
}

.read-chapter-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.read-chapter-item {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 8px 10px;
  border-radius: 8px;
  font-size: 13px;
  color: var(--ink-2);
  text-decoration: none;
  cursor: pointer;
}
a.read-chapter-item:hover { background: var(--surface-2); }
.read-chapter-item--active {
  background: var(--surface-2);
  color: var(--ink);
  font-weight: 600;
}
.read-chapter-item--disabled {
  color: var(--ink-3);
  cursor: default;
  opacity: .6;
}
.read-chapter-item-number {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--ink-3);
  flex-shrink: 0;
}
.read-chapter-item-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.read-chapter-item-status {
  margin-left: auto;
  font-size: 11px;
  color: var(--warn);
  flex-shrink: 0;
}
/* Explicit-content tag — chapters list panel row (each chapter's own,
   plus the virtual 作品情報 entry's aggregate — see ReadPage.tsx's
   chapterList) and the chapter-header subtitle (.read-header-explicit
   below, same treatment as the other plain-text header spans). */
.read-chapter-item-explicit {
  margin-left: auto;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--warn);
  border: 1px solid var(--warn);
  border-radius: 3px;
  padding: 1px 4px;
  flex-shrink: 0;
}
.read-header-explicit {
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--warn);
}
/* Novel-level "a continue-novel batch is actively running" indicator — see
   ReadPage.tsx's job-status poll effect. Accent (not warn) tone: unlike
   .read-header-explicit above, this isn't a caution, just an in-progress
   status, same tone as .read-header-actions-trigger's own active state. */
.read-header-job-downloading {
  color: var(--accent);
}
/* Mobile chapter sheet — same shape as SearchPage's .sr-sheet/.sr-sheet-scrim. */
.read-sheet-scrim {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 50;
  background: rgba(0, 0, 0, .3);
  opacity: 0;
  transition: opacity .22s;
}
.read-sheet-scrim.open {
  display: block;
  opacity: 1;
}

.read-sheet {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 51;
  background: var(--surface);
  border-radius: 20px 20px 0 0;
  box-shadow: 0 -12px 40px rgba(0, 0, 0, .15);
  transform: translateY(100%);
  transition: transform .3s cubic-bezier(.22, 1, .36, 1);
  display: flex;
  flex-direction: column;
  max-height: 80dvh;
}
.read-sheet.open { transform: translateY(0); }
/* Desktop: center the sheet as a narrow bottom sheet. (margin: 0 auto +
   max-width doesn't reliably center a fixed element whose width is auto —
   auto margins resolve to 0 when the abspos solver sizes the width — so
   center explicitly with left: 50% + translateX(-50%), composed with the
   existing translateY open/close animation.) */
@media (min-width: 768px) {
  .read-sheet {
    left: 50%;
    right: auto;
    width: min(560px, calc(100% - 32px));
    transform: translateX(-50%) translateY(100%);
  }
  .read-sheet.open {
    transform: translateX(-50%) translateY(0);
  }
}

.read-sheet-grip {
  padding: 10px 0 4px;
  display: flex;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
}
.read-sheet-bar {
  width: 36px; height: 4px;
  border-radius: 2px;
  background: var(--line-2);
}

.read-sheet-head {
  padding: 0 20px 12px;
  flex-shrink: 0;
}
.read-sheet-head h4 {
  margin: 0;
  font-size: 17px;
  font-weight: 600;
  letter-spacing: -.01em;
}

/* Unlike SearchPage's .sr-sheet-body (short filter lists that rarely
   overflow), this panel is the primary way to navigate a chapter list
   that's often long, on touch-only devices. iOS Safari gives no other hint
   that a panel is scrollable (no flash-on-load, no hover state), so —
   unlike the rest of this app's "hide scrollbar but stay scrollable"
   convention — keep the native scrollbar visible here. */
.read-sheet-body {
  flex: 1;
  overflow-y: auto;
  padding: 0 12px calc(20px + var(--safe-bottom));
}

.read-header-illustrations {
  cursor: pointer;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: inherit;
}
.read-header-illustrations:hover {
  text-decoration: underline;
}
/* Skeleton rows (DlRowPlaceholders.tsx): laid out by DownloadsPage.css's own .dl-row/.dl-thumb/
   .dl-main rules; the thumbnail's skeleton fills it, the text lines stretch to their set widths. */
.dl-row-placeholder { pointer-events: none; }
.dl-row-placeholder .dl-main { align-items: stretch; }
.dl-thumb-skeleton.stratum-skeleton { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 0; }
/* ============================================================
   Downloads page — active/failed/recent job feed.
   ============================================================ */

/* Neutralizes .app-scroll's own shared base (mobile) scrolling behavior (AppShell.css:
   `position: absolute; inset: 0; overflow-y: auto`) for DownloadsPage specifically — see
   LibraryPage.css's `.app-scroll.lib-scroll` for the full reasoning (identical here: no
   `-content-col`/`-app-main` wrapper to relax too, `.app-scroll` is a direct child of
   `.stratum-app-shell-main`). Desktop is untouched. */
@media (max-width: 767px) {
  .app-scroll.dl-scroll {
    position: static;
    overflow-y: visible;
    overflow-x: visible;
  }
}

.dl-empty { padding: 12px 20px; color: var(--ink-3); font-size: 14px; }
@media (min-width: 768px) { .dl-empty { padding-left: 36px; padding-right: 36px; } }

.dl-row {
  display: grid;
  grid-template-columns: 72px 1fr auto;
  gap: 14px; align-items: center;
  padding: 14px 20px; position: relative;
}
.dl-row + .dl-row::before {
  /* Border, not a 1px background: equally thick at fractional zoom (see stratum-ui's Settings.css). */
  content: ''; position: absolute; left: 106px; right: 20px; top: 0; border-top: 1px solid var(--line);
}
@media (min-width: 768px) {
  .dl-row { padding: 14px 36px; grid-template-columns: 160px 1fr auto; gap: 18px; }
  .dl-row + .dl-row::before { left: 214px; right: 36px; }
  .dl-row:hover { background: rgba(0, 0, 0, .02); }
}

/* Bulk-select checkbox badge (Recent section's "Select" mode, see
   DownloadsPage's editing/selected/onToggleSelect props on DlRow) — same
   visual language as LibraryPage.css's .sel, just anchored to .dl-row
   (already position:relative for the divider pseudo-element above) instead
   of a grid tile, since .dl-thumb itself has several differently-shaped
   early-return branches in DlThumb that would each need their own overlay
   slot otherwise. */
.dl-sel {
  position: absolute; top: 8px; left: 14px; z-index: 5;
  width: 22px; height: 22px; border-radius: 50%;
  background: rgba(255, 255, 255, .85);
  border: 1.5px solid rgba(255, 255, 255, .98);
  backdrop-filter: blur(10px);
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .1), 0 2px 6px rgba(0, 0, 0, .18);
  transition: transform .12s, background .12s;
}
.dl-sel svg { width: 13px; height: 13px; color: #fff; opacity: 0; transition: opacity .1s; }
.dl-row.selected .dl-sel { background: var(--accent); border-color: #fff; transform: scale(1.05); }
.dl-row.selected .dl-sel svg { opacity: 1; }
@media (prefers-color-scheme: dark) { .dl-sel { background: rgba(255, 255, 255, .2); border-color: rgba(255, 255, 255, .4); } }
@media (min-width: 768px) { .dl-sel { top: 10px; left: 30px; } }

/* ── Mobile edit bar ── (same shape as LibraryPage.css's/SearchPage.css's
   .edit-bar — see its own comment there for the fixed-vs-absolute
   reasoning; duplicated here rather than shared since each of those pages'
   CSS is already self-contained this way). */
.edit-bar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 25;
  background: rgba(251, 250, 247, 0.92);
  backdrop-filter: saturate(1.5) blur(20px);
  -webkit-backdrop-filter: saturate(1.5) blur(20px);
  border-top: 1px solid rgba(0, 0, 0, .08);
  padding: 12px 18px calc(6px + var(--safe-bottom));
  display: none; align-items: center; gap: 14px;
}
.stratum-app-shell--edit-mode .edit-bar { display: flex; }
@media (min-width: 768px) { .edit-bar { display: none !important; } }
@media (prefers-color-scheme: dark) { .edit-bar { background: rgba(10, 10, 11, .92); border-top-color: rgba(255, 255, 255, .06); } }
.edit-bar .count-label { font-size: 15px; color: var(--ink); font-weight: 600; }
.edit-bar .spacer { flex: 1; }
.edit-bar .delete { color: var(--warn); }
.edit-bar .delete svg { width: 16px; height: 16px; }

.dl-thumb {
  position: relative; width: 72px; height: 72px;
  border-radius: 10px; overflow: hidden; background: var(--surface-2); flex-shrink: 0;
}
.dl-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.dl-thumb.clickable { cursor: pointer; }
/* Same subtle zoom as Library's tiles (stratum-ui's .stratum-thumb--zoom), on every thumbnail that opens something. */
.dl-thumb img { transition: transform 0.2s ease; }
a.dl-thumb:hover img, a.dl-thumb:active img,
.dl-thumb.clickable:hover img, .dl-thumb.clickable:active img { transform: scale(1.03); }
/* A video row's player (see DlThumb) — hidden under the thumbnail until it's playing fullscreen. */
.dl-thumb .thumb-chip {
  position: absolute; bottom: 4px; right: 4px;
  background: rgba(0, 0, 0, .7); color: #fff;
  font-size: 9.5px; font-weight: 600; padding: 2px 5px; border-radius: 4px;
  line-height: 1; letter-spacing: .02em; display: inline-flex; align-items: center;
}
/* Subtle "has a near-duplicate" marker in the status line, next to "Saved ·
   <time>" — colored text rather than an icon, overriding the row's own
   green so it reads distinctly. Color signals match strength: accent blue
   for medium confidence, warn red for high. */
.dl-status .sim-tag { font-weight: 600; text-decoration: none; }
.dl-status .sim-tag.sim-medium { color: var(--accent); }
.dl-status .sim-tag.sim-high { color: var(--warn); }
.dl-status .sim-tag.clickable { cursor: pointer; }
.dl-status .sim-tag.clickable:hover { text-decoration: underline; text-underline-offset: 2px; }
.dl-thumb.inspect {
  background: var(--surface-2); display: flex; align-items: center; justify-content: center;
  font-family: var(--mono); font-size: 9.5px; color: var(--ink-3);
  letter-spacing: .04em; overflow: hidden;
}
.dl-thumb.inspect::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent 0%, rgba(10, 132, 255, .15) 50%, transparent 100%);
  animation: dl-shimmer 1.6s linear infinite;
}
@keyframes dl-shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
.dl-thumb.fail { background: #fdecea; display: flex; align-items: center; justify-content: center; color: var(--warn); }
.dl-thumb.fail svg { width: 22px; height: 22px; }
@media (prefers-color-scheme: dark) { .dl-thumb.fail { background: rgba(255, 69, 58, .16); } }
.dl-thumb.text { background: var(--surface-2); display: flex; align-items: center; justify-content: center; color: var(--ink-3); cursor: pointer; text-decoration: none; }
.dl-thumb.text svg { width: 22px; height: 22px; }
/* Novel not readable yet (see DownloadsPage.tsx's isNovelReadable) — a plain
   div instead of a Link, so cursor:pointer above would be misleading. */
.dl-thumb.text.not-ready { cursor: default; }
@media (min-width: 768px) { .dl-thumb { width: 160px; height: 160px; border-radius: 11px; } }

.dl-main { min-width: 0; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.dl-name-row { display: flex; align-items: baseline; gap: 6px; min-width: 0; width: 100%; }
.dl-name-row .dot { color: var(--ink-4); font-size: 10px; flex-shrink: 0; cursor: default; }
.dl-name { font-size: 15px; font-weight: 600; color: var(--ink); line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.dl-name-row .dl-name { flex-shrink: 0; max-width: 45%; }
.dl-name.muted { color: var(--ink-3); font-weight: 500; }
.dl-name-link { display: block; cursor: pointer; text-decoration: none; }
.dl-name-link:hover { text-decoration: underline; }
.dl-title { font-size: 15px; font-weight: 600; color: var(--ink); line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1 1 0; min-width: 0; }
.dl-title-link { cursor: pointer; text-decoration: none; }
.dl-title-link:hover { text-decoration: underline; }

/* Color/font/hover-underline come from stratum-ui's UrlLink (.stratum-url-link)
   — this only supplies the layout specific to this row. */
.dl-sub { font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; letter-spacing: -.01em; display: inline-block; max-width: 100%; }
.dl-status {
  font-size: 12px; font-weight: 500;
  display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums;
  flex-wrap: nowrap; white-space: nowrap; min-width: 0; max-width: 100%; overflow-x: auto;
}
/* Leads the status line, Active/Failed only (see DownloadsPage's DlRow
   `showMode`) — those sections now combine every mode in the current mode's
   group, so each row needs its own mode called out. Plain text (no chip/
   background) so it reads like the rest of the line rather than standing
   out as its own element — same treatment as the sep dots, just bolder. */
.dl-status .dl-mode-tag {
  color: var(--ink-3); font-weight: 600;
  /* A button (switches to that mode on click) styled as plain inline text. */
  background: none; border: 0; padding: 0; font: inherit; font-weight: 600; cursor: pointer;
}
.dl-status .dl-mode-tag:hover { text-decoration: underline; }
.dl-status.saved { color: var(--ok); }
.dl-status.failed { color: var(--warn); }
.dl-status.queued { color: var(--ink-3); }
.dl-status.active { color: var(--accent); }
/* A local "Download for offline" save in progress (offlineNovel.ts/offlineMedia.ts) — same
   accent tone/bar treatment as .dl-status.active's real server-side download, since visually
   this is the same "something's actively progressing" state, just client-side. */
.dl-status.offline-saving { color: var(--accent); }
.dl-status .sep { color: var(--ink-4); }
.dl-bar {
  margin-top: 8px; height: 4px; border-radius: 2px; background: var(--surface-2); overflow: hidden; position: relative; width: 100%;
}
.dl-bar .fill {
  height: 100%; background: var(--accent); border-radius: 2px;
  transition: width .4s cubic-bezier(.22, 1, .36, 1); position: relative;
}
.dl-bar.indeterminate .fill { width: 40% !important; animation: dl-indeterminate 1.2s ease-in-out infinite; }
@keyframes dl-indeterminate { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
@media (prefers-color-scheme: dark) { .dl-bar { background: rgba(255, 255, 255, .08); } }

.dl-actions { display: flex; align-items: center; gap: 6px; }

.dl-actions .dt-only { display: none; }
.dl-actions .mb-only { display: flex; }
@media (min-width: 768px) {
  .dl-actions .dt-only { display: inline-flex; }
  .dl-actions .mb-only { display: none; }
}

/* Upload sheet */
.dl-upload-field { margin: 18px 0; }
/* Skeleton cards (FeedPlaceholders.tsx): laid out by the feed's own .feed-list/.feed-item rules
   (LibraryPage.css), just not interactive. */
.feed-item-placeholder { cursor: default; pointer-events: none; }
.feed-placeholders { padding-top: 0; }
/* ============================================================
   Library page — grouped media grid, select/delete mode, lightbox.
   ============================================================ */

/* Neutralizes .app-scroll's own shared base (mobile) scrolling behavior (AppShell.css:
   `position: absolute; inset: 0; overflow-y: auto`) for LibraryPage specifically — the actual
   last piece of the native-scroll chain, letting the real document/body do the scrolling
   instead of this div. Not a change to .app-scroll itself (shared by every other page, which
   all stay on the nested-scroll model) — `.app-scroll.lib-scroll` (both classes, specificity
   (0,2,0)) deliberately outweighs the shared `.app-scroll` rule's (0,1,0) regardless of
   stylesheet order, so this new rule wins without needing `!important`. Desktop is untouched:
   the existing `@media (min-width: 768px) { .app-scroll {...} }` block in AppShell.css still
   applies there exactly as before, since this rule never overlaps it. Unlike ReadPage/
   SearchPage, Library has no `-content-col`/`-app-main` wrapper to relax too — `.app-scroll`
   is a direct child of `.stratum-app-shell-main` here (no desktop side rail to lay out
   beside), so this is the only CSS change this page's migration needs. */
@media (max-width: 767px) {
  .app-scroll.lib-scroll {
    position: static;
    overflow-y: visible;
    overflow-x: visible;
  }
}

.date-sep {
  display: flex; align-items: center; gap: 12px;
  font-family: var(--mono); font-size: 10px; color: var(--ink-3);
  text-transform: uppercase; letter-spacing: .14em;
  padding: 20px 20px 10px;
}
.date-sep .rule { flex: 1; height: 1px; background: var(--line); }
@media (min-width: 768px) { .date-sep { padding: 20px 36px 10px; } }

/* The Library's virtual list (LibraryFeed.tsx): each row - a date heading or a line of tiles - is
   placed at its measured offset. A line of tiles keeps the old spacing: the list's gap below each
   line (24px on mobile, 16px on desktop) and 24px after a date group's last line. The space above
   the very first line (no date heading over it) is PageContent's, under the header. */
/* overflow-anchor: the virtualizer keeps the scroll position steady itself as rows above the screen are
   measured; the browser's own scroll anchoring would correct for the same change again. */
.feed-virtual { position: relative; overflow-anchor: none; }
.feed-virtual-row { position: absolute; top: 0; left: 0; width: 100%; }
.feed-row { padding: 0 20px 24px; }
@media (min-width: 768px) {
  .feed-row { padding: 0 36px 16px; }
  .feed-row-last { padding-bottom: 24px; }
}

.feed-section { padding: 0 20px 24px; }
@media (min-width: 768px) { .feed-section { padding: 0 36px 24px; } }

.feed-list { display: flex; flex-direction: column; gap: 24px; }
@media (min-width: 768px) { .feed-list { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } }

.feed-item { position: relative; cursor: pointer; min-width: 0; }

/* A fixed height: a name in Chinese or Japanese falls back to a CJK font with a taller line, which made
   its row ~2px taller than the rest - and every row after it shift once it was measured. */
.feed-meta { display: flex; align-items: center; gap: 8px; height: 17px; margin-bottom: 10px; font-size: 13px; min-width: 0; }
.feed-meta .uploader { font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; max-width: 40%; min-width: 0; }
.feed-meta .uploader-link { cursor: pointer; text-decoration: none; }
.feed-meta .uploader-link:hover { text-decoration: underline; }
/* The "/" in a group tile's "1 / 5". The meta line's pieces are spaced apart by its gap alone, no dots. */
.feed-meta .dot { color: var(--ink-4); font-size: 10px; flex-shrink: 0; }
.feed-meta .novel-title {
  font-family: inherit; font-weight: 600; color: var(--ink); cursor: pointer; text-decoration: none;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  flex-shrink: 1; min-width: 0;
}
.feed-meta .novel-title:hover { text-decoration: underline; }
/* Hover-underline comes from stratum-ui's UrlLink (.stratum-url-link) — this
   overrides its color/font (UrlLink defaults to --ink-3/--mono, meant for
   raw URL text) and supplies the compact-metadata-row-specific layout. */
.feed-meta .src {
  font-size: 11px;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  flex: 1 1 0; min-width: 0;
}
.feed-meta .time { color: var(--ink-3); font-size: 12px; flex-shrink: 0; cursor: default; }
@media (min-width: 768px) { .feed-meta { font-size: 12px; margin-bottom: 8px; } }

/* "This item has a near-duplicate" link (to its similar page - see postGroups.ts's similarPageHref) — same
   medium/high color scheme as Downloads' equivalent .sim-tag, shared here
   since Library and Search both render through this same .feed-meta row. */
.feed-meta .sim-tag { font-weight: 600; cursor: pointer; flex-shrink: 0; text-decoration: none; }
.feed-meta .sim-tag.sim-medium { color: var(--accent); }
.feed-meta .sim-tag.sim-high { color: var(--warn); }
.feed-meta .sim-tag:hover { text-decoration: underline; text-underline-offset: 2px; }

/* Selection indicator (edit mode) */
.sel {
  position: absolute; top: 16px; right: 16px; z-index: 5;
  width: 26px; height: 26px; border-radius: 50%;
  background: rgba(255, 255, 255, .8);
  border: 1.5px solid rgba(255, 255, 255, .98);
  backdrop-filter: blur(10px);
  display: none; align-items: center; justify-content: center;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .1), 0 2px 6px rgba(0, 0, 0, .18);
  transition: transform .12s, background .12s;
}
.sel svg { width: 14px; height: 14px; color: #fff; opacity: 0; transition: opacity .1s; }
.stratum-app-shell--edit-mode .sel { display: flex; }
@media (min-width: 768px) { .sel { top: 10px; right: 10px; width: 22px; height: 22px; } }
.feed-item.selected .sel { background: var(--accent); border-color: #fff; transform: scale(1.05); }
.feed-item.selected .sel svg { opacity: 1; }
.feed-item.selected .stratum-thumb { box-shadow: 0 0 0 3px var(--accent), 0 0 0 5px rgba(10, 132, 255, .2); }
@media (prefers-color-scheme: dark) { .sel { background: rgba(255, 255, 255, .2); border-color: rgba(255, 255, 255, .4); } }

/* ── Mobile edit bar ── (shared by LibraryPage, SearchPage, and DownloadsPage,
   all render <div className="edit-bar">) */
.edit-bar {
  /* `fixed`, not `absolute`: same reasoning/precedent as AppShellLayout.css's own
     .stratum-app-shell-nav/-fab — this is already mobile-only (display:none below, see the
     media query further down), and for a page that hasn't opted into
     AppShellLayout's scrollMode="native" (both consumers here, currently), the ancestor this
     used to be positioned against stays exactly viewport-sized regardless, so `fixed` renders
     pixel-identical to the old `absolute` there too. Applied unconditionally for the same
     reason: no gating needed, not just for pages that migrate later. */
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 25;
  background: rgba(251, 250, 247, 0.92);
  backdrop-filter: saturate(1.5) blur(20px);
  -webkit-backdrop-filter: saturate(1.5) blur(20px);
  border-top: 1px solid rgba(0, 0, 0, .08);
  padding: 12px 18px calc(6px + var(--safe-bottom));
  display: none; align-items: center; gap: 14px;
}
.stratum-app-shell--edit-mode .edit-bar { display: flex; }
@media (min-width: 768px) { .edit-bar { display: none !important; } }
@media (prefers-color-scheme: dark) { .edit-bar { background: rgba(10, 10, 11, .92); border-top-color: rgba(255, 255, 255, .06); } }
.edit-bar .count-label { font-size: 15px; color: var(--ink); font-weight: 600; }
.edit-bar .spacer { flex: 1; }
/* Chrome (transparent bg, font size/weight, hover, disabled opacity) comes
   from stratum-ui Button's own .stratum-button--ghost class — only the
   warn color and the one icon-size difference from Button's own default
   are unique to this control. */
.edit-bar .delete { color: var(--warn); }
.edit-bar .delete svg { width: 16px; height: 16px; }

.lib-empty { padding: 48px 24px; text-align: center; color: var(--ink-3); }

/* Multi-item post: count badge on the cover tile (see groupPrefixOf) — tapping the tile opens
   GroupPage with every item. pointer-events:none so the tap still reaches the thumbnail. */
/* Bottom-LEFT: MediaThumb's own duration / character-count chip owns the bottom-right corner, and used
   to sit on top of this badge. */
.group-badge {
  position: absolute; left: 10px; bottom: 10px; z-index: 2; pointer-events: none;
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 8px; border-radius: 999px;
  background: rgba(0, 0, 0, .65); color: #fff;
  font-family: var(--mono); font-size: 11px; font-weight: 600;
}
.group-badge svg { width: 12px; height: 12px; fill: none; stroke: currentColor; }
.group-section { padding-top: 16px; }
/* Breadcrumb button in the group page's header subtitle — reads as plain subtitle text, like the
   read page's .read-header-position. */
/* Source link in the group page's header subtitle: the subtitle's own font, not UrlLink's mono. */
.group-src { font-family: inherit; }
/* Uploader link in the group page's header subtitle — inherits the subtitle's color like the read
   page's .read-header-author-link, underlined only on hover. */
.group-uploader { color: inherit; text-decoration: none; cursor: pointer; }
.group-uploader:hover { text-decoration: underline; }
/* Group page while its post is still loading: header is laid out but invisible (see GroupPage). */
.group-loading .stratum-page-header-mobile,
.group-loading .stratum-page-header-desktop { visibility: hidden; }

/* =========================================================================
   Search Page
   ========================================================================= */

/* Root: sits inside stratum-ui's already-positioned AppShellLayout main
   column, so it needs its own positioning context (for .lib-header's
   absolute mobile header) and its own full-height flex layout (filter rail +
   content column on desktop). */
.sr-app-main {
  position: relative;
  height: 100%;
  display: flex;
  flex-direction: column;
}
@media (min-width: 768px) {
  .sr-app-main {
    flex-direction: row;
    overflow: hidden;
  }
}

/* Content column (wraps the desktop header + app-scroll). Mobile: height:auto/overflow:visible, not
   the viewport-clamped/hidden pair below — part of SearchPage's native-scroll migration (see
   AppShell.tsx's NATIVE_SCROLL_ROUTE_PREFIXES / AppShellLayout.css's own `--native-scroll`
   doc) — lets .app-scroll.sr-scroll's own content (once IT stops being its own scroll box
   too, see further down) grow past the viewport height so the real document scrolls instead
   of this div clipping it. Desktop (below) keeps the exact old clamped/hidden values,
   unaffected. */
.sr-content-col {
  flex: 1;
  min-width: 0;
  position: relative;
  height: auto;
  overflow: visible;
}
@media (min-width: 768px) {
  .sr-content-col {
    display: flex;
    flex-direction: column;
    height: calc(var(--vh, 1vh) * 100);
    overflow: hidden;
  }
}

/* Neutralizes .app-scroll's own shared base (mobile) scrolling behavior (AppShell.css:
   `position: absolute; inset: 0; overflow-y: auto`) for SearchPage specifically — the actual
   last piece of the native-scroll chain, letting the real document/body do the scrolling
   instead of this div. Not a change to .app-scroll itself (shared by every other page, which
   all stay on the nested-scroll model) — `.app-scroll.sr-scroll` (both classes, specificity
   (0,2,0)) deliberately outweighs the shared `.app-scroll` rule's (0,1,0) regardless of
   stylesheet order, so this new rule wins without needing `!important`. Desktop is untouched:
   the existing `@media (min-width: 768px) { .app-scroll {...} }` block in AppShell.css still
   applies there exactly as before, since this rule never overlaps it. */
@media (max-width: 767px) {
  .app-scroll.sr-scroll {
    position: static;
    overflow-y: visible;
    overflow-x: visible;
  }
}

/* A fixed gap below the desktop header: a sibling spacer, not padding/margin *inside* the header, so
   the frosted-glass sticky header's own background doesn't stretch to cover it too. Desktop only - on
   mobile, stratum-ui's .stratum-page-header-spacer (PageHeaderDesktop renders it) already reserves the
   fixed mobile header's measured height, filter bar included, as on every other page. */
/* The first heading (a date, a similar group's) sits PageContent's distance under the header,
   not its own on top. */
.date-sep.sr-first,
.sr-group-header.sr-first {
  padding-top: 0;
}

/* Dim + disable the (possibly stale) results while a new filter-driven
   search is in flight, instead of silently swapping them out once the
   fetch resolves a few seconds later. */
.sr-refreshing {
  opacity: .4;
  pointer-events: none;
  transition: opacity .15s ease;
}

/* Desktop filter rail: stratum-ui's FilterRail, at the viewport's height as this page lays it out. */
@media (min-width: 768px) {
  .sr-app-main .stratum-filter-rail {
    height: calc(var(--vh, 1vh) * 100);
  }
}

/* Filter panel (shared between sheet + rail) */
.sr-filter-panel {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.sr-fgrp {
  padding: 14px 0;
  border-top: 1px solid var(--line);
}
.sr-fgrp:first-child { border-top: none; padding-top: 0; }

.sr-fgrp-label {
  font-size: 12px;
  font-weight: 600;
  color: var(--ink-2);
  text-transform: uppercase;
  letter-spacing: .06em;
  margin-bottom: 10px;
  display: flex;
  align-items: baseline;
  gap: 6px;
}
.sr-fgrp-aux {
  font-size: 11px;
  font-weight: 400;
  color: var(--ink-3);
  text-transform: none;
  letter-spacing: 0;
}

/* Grouped results (similarity view) */
.sr-group {
  padding: 0 0 8px;
}
.sr-group-header {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 20px 8px;
  font-size: 12.5px;
  font-weight: 600;
  color: var(--ink-3);
}
.sr-group-header .rule {
  flex: 1;
  height: 1px;
  background: var(--line);
}
@media (min-width: 768px) { .sr-group-header { padding: 14px 36px 8px; } }

/* Date jump */
/* No pointer-events: none here — DateField's own `disabled` prop already
   threads down to the real native <input disabled>, which the browser
   already refuses to interact with; blocking pointer-events at this wrapper
   level would prevent the field's own hover/cursor styling from ever
   applying (the whole subtree becomes non-hit-testable). */
.sr-datejump.disabled { opacity: .45; }
/* Duration while only images are searched (see durationFilterDisabled) — dimmed and not draggable. */
.sr-fgrp.disabled { opacity: .45; }

.sr-date-hint {
  margin-top: 6px;
  font-size: 11.5px;
  color: var(--ink-3);
}

/* The mobile filter sheet's Reset / Show footer is stratum-ui's FilterSheetFooter, and Reset in
   the desktop rail is FilterRail's own, so the panel has no reset button of its own. */
/* ============================================================
   Settings page — cookies + account. Uses the theme vars from index.css.
   ============================================================ */

/* Neutralizes .app-scroll's own shared base (mobile) scrolling behavior (AppShell.css:
   `position: absolute; inset: 0; overflow-y: auto`) for SettingsPage specifically — see
   LibraryPage.css's `.app-scroll.lib-scroll` for the full reasoning (identical here: no
   `-content-col`/`-app-main` wrapper to relax too, `.app-scroll` is a direct child of
   `.stratum-app-shell-main`). Desktop is untouched. */
@media (max-width: 767px) {
  .app-scroll.st-scroll {
    position: static;
    overflow-y: visible;
    overflow-x: visible;
  }
}

/* PageContent's padding, in a narrower centered column. */
.st-main { max-width: 620px; margin: 0 auto; box-sizing: border-box; }

.st-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; position: relative; min-height: 56px; }
/* Border, not a 1px background, so every hairline is equally thick at fractional zoom (see stratum-ui's Settings.css). */
.st-row + .st-row::before { content: ''; position: absolute; left: 56px; right: 0; top: 0; border-top: 1px solid var(--line); }
.st-row .ico {
  width: 28px; height: 28px;
  border-radius: 7px;
  background: var(--accent-soft);
  color: var(--accent);
  display: flex; align-items: center; justify-content: center;
  flex-shrink: 0;
}
.st-row .ico svg { width: 16px; height: 16px; stroke-width: 1.8; }
.st-row .ico.neutral { background: var(--surface-2); color: var(--ink-2); }
.st-row .txt { flex: 1; min-width: 0; }
.st-row .ttl { font-size: 15px; font-weight: 500; color: var(--ink); line-height: 1.25; }
.st-row .val { margin-top: 2px; font-size: 12.5px; color: var(--ink-3); font-family: var(--mono); letter-spacing: -.01em; }
.st-row.tappable { cursor: pointer; transition: background .12s; }
.st-row.tappable:active { background: var(--surface-2); }

/* Mode-row descriptions stay on a single line: on narrow screens the Active
   pill + stat badge squeeze the text column, and long descriptions (e.g. the
   NTV one) would otherwise wrap to two lines. Truncate with an ellipsis. */
.stp-mode-row .val {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.st-row .badge { padding: 2px 8px; font-size: 11px; font-weight: 600; border-radius: 999px; letter-spacing: .02em; flex-shrink: 0; background: var(--surface-2); color: var(--ink-3); }
.st-row .badge.ok { background: #dff6e4; color: #2b7a3a; }

.st-row .stratum-button { flex-shrink: 0; }

.st-error { padding: 8px 16px; color: var(--warn); font-size: 13px; }

.st-storage-bar { margin-top: 8px; height: 5px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.st-storage-bar-fill { height: 100%; border-radius: inherit; background: var(--accent); transition: width .3s ease; }

/* Per-mode stat badge: a plain span — the whole row is the toggle (see
   SettingsPage.tsx's row onClick), so the row's own press feedback is the
   correct one. The badge shows the storage total by default and the item
   count while its mode is toggled, never both at once. */
/* ============================================================
   Sign-in page — all states. Uses the theme vars from index.css.
   ============================================================ */

/* Mobile native-scroll migration (see AppShell.tsx's NATIVE_SCROLL_ROUTE_PREFIXES doc for the
   overall migration and index.css's `body.native-scroll` for the base override this builds
   on). SignInPage renders standalone outside AppShellLayout entirely (App.tsx's `/signin`
   route isn't nested under it), so it toggles `body.native-scroll` itself (see the
   useEffect in SignInPage.tsx) rather than through AppShell.tsx, and relaxes its own
   height/overflow chain here instead of reusing `.app-scroll`'s. `.si-page`'s `overflow:
   hidden` height-locks the whole page, and `.si-mobile`'s `overflow-y: auto` is the actual
   nested scroll container standing in for `.app-scroll` on this page — same
   `position/height/overflow` relaxation idea as `.app-scroll.*-scroll` elsewhere, just scoped
   through `body.native-scroll` descendant selectors since these class names are already
   unique to this page (no shared base rule to out-specificity). Desktop (`.si-desktop-wrap`,
   including its own `.si-right-col` scroll container) is untouched. */
@media (max-width: 767px) {
  /* min-height (not a hard height:auto) on both: keeps `.si-mobile`'s flex column at a full
     viewport tall when content is short — so its `.si-spacer` flex children still distribute
     the leftover space and push the CTA button down/legal text to the bottom like before —
     while letting it grow taller than one viewport for content that genuinely needs it (e.g.
     the OTP step with a validation error), which is what actually lets the document scroll
     natively instead of clipping inside `.si-mobile`. `.si-mobile-wrap` needs its own
     `min-height` rather than inheriting via `height: 100%` from `.si-page` — percentage
     heights only resolve against an ancestor with a definite (non-auto) height, which
     `.si-page` no longer has once its own `height` is relaxed. */
  body.native-scroll .si-page {
    min-height: calc(var(--vh, 1vh) * 100);
    height: auto;
    overflow: visible;
  }
  body.native-scroll .si-mobile-wrap {
    min-height: calc(var(--vh, 1vh) * 100);
    height: auto;
  }
  body.native-scroll .si-mobile {
    overflow-y: visible;
  }
}

.si-page {
  height: calc(var(--vh, 1vh) * 100);
  background: var(--bg);
  overflow: hidden;
}

/* Mobile layout visible by default; desktop hidden */
.si-mobile-wrap { display: flex; height: 100%; }
.si-desktop-wrap { display: none; }

@media (min-width: 768px) {
  .si-mobile-wrap { display: none; }
  .si-desktop-wrap {
    display: grid;
    grid-template-columns: 1.05fr 1fr;
    height: 100%;
  }
  .si-desktop-wrap > :last-child { background: var(--bg); }
}

.si-mobile {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 74px 28px 40px;
  overflow-y: auto;
}

.si-brand { display: flex; align-items: center; justify-content: center; margin-top: 24px; }

.si-logo {
  width: 72px; height: 72px;
  border-radius: 20px;
  background: linear-gradient(145deg, var(--accent), color-mix(in oklab, var(--accent) 70%, #8a5cf6 30%));
  display: grid; place-items: center;
  color: #fff;
  font-weight: 700;
  font-size: 34px;
  letter-spacing: -.04em;
  box-shadow: 0 10px 28px rgba(10,132,255,.28), 0 2px 6px rgba(0,0,0,.08);
  user-select: none;
}

.si-hero { margin-top: 28px; text-align: center; }
.si-hero h1 { margin: 0 0 8px; font-size: 32px; line-height: 1.08; letter-spacing: -0.025em; font-weight: 700; }
.si-hero p { margin: 0 auto; font-size: 15px; color: var(--ink-3); line-height: 1.45; max-width: 280px; }

.si-spacer { flex: 1; }

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

.si-legal { text-align: center; margin-top: 14px; font-size: 12px; color: var(--ink-3); line-height: 1.5; }
.si-legal a { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px; }

.si-back {
  position: absolute;
  top: 56px;
  left: 16px;
  z-index: 5;
}

.si-email-form { margin-top: 18px; display: flex; flex-direction: column; gap: 14px; }
.si-field { display: flex; flex-direction: column; gap: 6px; }
.si-label {
  font-size: 12px;
  font-weight: 500;
  color: var(--ink-2);
  font-family: var(--mono);
  text-transform: uppercase;
  letter-spacing: .1em;
}
.si-input {
  width: 100%;
  padding: 14px 16px;
  border-radius: 12px;
  border: 1.5px solid var(--line);
  background: var(--surface);
  font-family: inherit;
  font-size: 16px;
  color: var(--ink);
  transition: border-color .15s, box-shadow .15s;
  outline: none;
  box-sizing: border-box;
}
.si-input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
}
.si-input::placeholder { color: var(--ink-4); }

.si-error {
  font-size: 12.5px;
  color: var(--warn);
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 4px;
}
.si-error svg { width: 14px; height: 14px; flex-shrink: 0; }

.si-help { font-size: 13px; color: var(--ink-3); text-align: center; margin-top: 8px; line-height: 1.5; }

.si-email-tag {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--mono);
  font-size: 12.5px;
  color: var(--ink);
  background: var(--surface-2);
  padding: 4px 10px;
  border-radius: 999px;
  margin: 0 2px;
}

.otp-block { display: block; width: 100%; max-width: 392px; }
@media (max-width: 767px) { .otp-block { margin: 0 auto; } }

.otp-row { display: flex; gap: 8px; justify-content: center; margin: 8px 0 4px; }
.otp-help { margin-top: 16px; margin-bottom: 22px; }
@media (min-width: 768px) {
  .otp-row { justify-content: flex-start; }
  .otp-help { text-align: left; }
}
.otp-cell {
  width: 46px;
  height: 56px;
  border-radius: 12px;
  border: 1.5px solid var(--line);
  background: var(--surface);
  display: grid; place-items: center;
  font-family: var(--mono);
  font-size: 22px;
  font-weight: 600;
  color: var(--ink);
  transition: border-color .15s, box-shadow .15s;
  text-align: center;
  padding: 0;
  outline: none;
  caret-color: transparent;
}
.otp-cell.filled { border-color: var(--ink-3); }
/* .active hints "type here next" (including the fake caret below) before any
   cell has real focus — e.g. right after autoFocus lands, or after a paste
   fills some cells. Once something IS actually focused (:focus-within on the
   row), that real focus always wins instead: without the guard, clicking or
   Tabbing to a different cell than the algorithmically-"next" one showed two
   rings at once — one on the real focus, one on .active's own guess. */
.otp-row:not(:focus-within) .otp-cell.active,
.otp-cell:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
}
.otp-row:not(:focus-within) .otp-cell.active:not(.filled)::after {
  content: '';
  width: 2px;
  height: 26px;
  background: var(--accent);
  animation: si-blink 1s steps(2) infinite;
}
@keyframes si-blink { 50% { opacity: 0; } }

.si-success {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  padding: 40px;
  text-align: center;
}
.si-success .ring,
.si-right-success .ring {
  width: 80px; height: 80px;
  border-radius: 50%;
  background: linear-gradient(145deg, var(--ok), #28a047);
  display: grid; place-items: center;
  color: #fff;
  box-shadow: 0 10px 30px rgba(50,199,89,.3);
  animation: si-pop .45s cubic-bezier(.22,1.4,.36,1);
}
.si-success .ring svg,
.si-right-success .ring svg { width: 38px; height: 38px; stroke-width: 3; }
@keyframes si-pop { from { transform: scale(.4); opacity: 0; } }
.si-success h3, .si-right-success h3 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.02em; }
.si-success-email {
  font-family: var(--mono);
  font-size: 13px;
  color: var(--ink-2);
  background: var(--surface-2);
  padding: 5px 10px;
  border-radius: 8px;
}

/* ── Desktop left branding panel ── */
.si-left {
  position: relative;
  background: var(--surface-2);
  border-right: 1px solid var(--line);
  padding: 56px 52px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  overflow: hidden;
}
.si-left .si-logo { width: 54px; height: 54px; border-radius: 14px; font-size: 26px; box-shadow: 0 6px 18px rgba(10,132,255,.3); }
.si-left-copy { position: relative; z-index: 2; }
.si-left-copy h2 {
  margin: 0 0 14px;
  font-size: 44px;
  line-height: 1.02;
  letter-spacing: -0.03em;
  font-weight: 700;
  max-width: 420px;
  color: var(--ink);
}
.si-left-copy h2 em { font-style: normal; color: var(--accent); }
.si-left-copy p { margin: 0 0 22px; max-width: 380px; font-size: 15px; color: var(--ink-3); line-height: 1.5; }
.si-features { display: flex; flex-direction: column; gap: 12px; max-width: 380px; }
.si-feat { display: flex; gap: 12px; align-items: flex-start; font-size: 14px; color: var(--ink-2); line-height: 1.4; }
.si-feat .dot {
  width: 28px; height: 28px;
  border-radius: 8px;
  background: var(--accent-soft);
  color: var(--accent);
  display: grid; place-items: center;
  flex-shrink: 0;
}
.si-feat .dot svg { width: 15px; height: 15px; stroke-width: 2; }
.si-feat b { display: block; color: var(--ink); font-weight: 600; margin-bottom: 1px; font-size: 14px; }

/* ── Desktop right panel ── */
.si-right-col { display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.si-right {
  padding: 64px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  flex: 1;
  max-width: 520px;
  width: 100%;
  margin: 0 auto;
  box-sizing: border-box;
  position: relative;
}
.si-right-state { display: flex; flex-direction: column; min-height: 460px; justify-content: center; }
.si-right-success {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 18px;
  min-height: 460px;
}
.si-right h3 { margin: 0 0 6px; font-size: 26px; font-weight: 700; letter-spacing: -0.02em; }
.si-right .sub-text { margin: 0 0 24px; font-size: 14px; color: var(--ink-3); line-height: 1.5; }
.si-right .si-actions { gap: 10px; }
.si-right .stratum-cta-button { padding: 12px 16px; }

.si-back-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: none;
  border: none;
  padding: 6px 10px 6px 6px;
  border-radius: 8px;
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-3);
  cursor: pointer;
  transition: color .12s, background .12s;
  align-self: flex-start;
  margin-bottom: 28px;
  margin-left: -6px;
  transform: translateY(-8px);
}
.si-back-link:hover { color: var(--ink); background: var(--surface-2); }
.si-back-link svg { width: 14px; height: 14px; stroke-width: 2; }
/* Mirrors electron's RecorderApp.tsx layout (its Transcribe page): a video rail alongside a
   header + transcript content column — see WatchPage.tsx's own top-of-file doc. Deliberately
   stays on the nested-scroll model (RecorderApp's own approach: a bounded, `position: relative`
   content column that self-scrolls under a pinned header) rather than this app's native-mobile-
   scroll migration (AppShell.tsx's NATIVE_SCROLL_ROUTE_PREFIXES, used by ReadPage/Search/
   Downloads/Settings) — that migration relies on the content column becoming an UNBOUNDED,
   `position: static` box on mobile so the real document scrolls, which is exactly incompatible
   with "stack a rail above a column whose own header stays pinned to the top of that column
   specifically" (not the raw viewport, which would float the header over the rail instead).

   height: 100% (not 100vh) fills AppShellLayout's own already-clamped main content area (see
   AppShell.css) — same reasoning as RecorderApp.css's own .rec-app. */
.wt-app-main {
  height: 100%;
  display: flex;
  flex-direction: column;
}
@media (min-width: 768px) {
  .wt-app-main {
    flex-direction: row;
  }
}

/* Toggled by the header subtitle's Summary link (WatchPage.tsx's railOpen) — when closed
   the rail and divider are hidden ([hidden], still mounted) and the transcript column
   takes the full width. Width is
   user-resizable on desktop (WatchPage.tsx's railWidth/onRailResizeStart, same pattern as
   electron's RecorderApp.tsx own preview rail) rather than fixed — set inline as a style, so this
   rule only supplies everything that ISN'T the width. */
.wt-rail {
  flex: 0 0 auto;
  overflow-y: auto;
}
@media (min-width: 768px) {
  .wt-rail {
    height: 100%;
  }
}
/* `width: 100% !important` is needed specifically because railWidth is applied as an inline style
   (WatchPage.tsx's onRailResizeStart drag), which otherwise wins over a plain class rule
   regardless of media query — `!important` in a stylesheet rule is the one thing that still beats
   a normal-priority inline style. Not user-resizable here (desktop's own width divider stays
   resizable — this pane is just content-sized), matching RecorderApp.css's identical mobile
   override for its own preview rail. */
@media (max-width: 767px) {
  .wt-rail {
    width: 100% !important;
  }
}

/* Drag handle between the rail and the content column — desktop only in practice (see the mobile
   override below). A wide-ish invisible hit area (6px) with a thin visible line down the middle,
   thickening/accent-colored on hover or drag, matching electron's RecorderApp.css's identical
   .rec-preview-rail-resize exactly. */
.wt-rail-resize {
  position: relative;
  width: 6px;
  flex-shrink: 0;
  cursor: col-resize;
  touch-action: none;
}
.wt-rail-resize::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  right: 0;
  width: 1px;
  background: var(--line);
  transition: background 0.12s, width 0.12s;
}
.wt-rail-resize:hover::after,
.wt-rail-resize--active::after {
  width: 3px;
  background: var(--accent);
}
/* Below 768px, the rail stacks above the transcript instead of beside it (same breakpoint
   AppShellLayout itself switches from the desktop rail to the mobile bottom nav at) — this
   divider becomes a plain static separator line instead of a drag handle (`pointer-events: none`
   means onMouseDown/onTouchStart, still attached unconditionally in the JSX, never actually fire,
   so there's nothing to drag and no col-resize cursor to fight either), and collapses to a flat
   1px line (no separate invisible hit-area band) directly matching the header's own bottom
   divider's weight — `::after` is disabled in favor of a plain background on the element itself,
   since there's no hover/drag state left to animate independently of it here. */
@media (max-width: 767px) {
  .wt-rail-resize {
    width: 100%;
    height: 1px;
    cursor: default;
    pointer-events: none;
    background: var(--line);
    /* Only shown once the header (a sibling several levels deep inside .wt-content-col) actually
       hides on scroll-down — while it's visible, its OWN bottom divider (see
       .stratum-page-header-mobile-inner's border-bottom above) is already the separator between
       it and the transcript below, and a second line directly under the video would just double
       up right above it. Once the header slides away, the transcript scrolls directly up against
       the video with nothing between them at all, which is exactly when this one is needed
       instead. :has() reaches across to the header's own state without any prop/JS wiring, same
       spirit as the token-match rules further down already using it for sibling-aware styling. */
    opacity: 0;
    transition: opacity 0.15s;
  }
  .wt-rail-resize::after {
    display: none;
  }
  .wt-app-main:has(.stratum-page-header-mobile--hidden) .wt-rail-resize {
    opacity: 1;
  }
}

/* Mobile top padding: exactly the safe-area inset, no extra room — so the video sits as close to
   the status bar/notch as possible without going under it. iOS Safari's standalone web apps blur a
   strip along the top edge of the screen (why PageHeaderMobile adds 30px, stratum-ui
   PageHeader.css), which slightly softens the top of the video here — accepted over the extra
   space. Desktop needs no such
   adjustment (no notch or blur in a browser window). */
.wt-rail-inner {
  padding: var(--safe-top) 16px 16px;
}
@media (min-width: 768px) {
  .wt-rail-inner {
    padding: 20px 16px 40px;
  }
}

.wt-video {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: 12px;
  background: #000;
}

.wt-video-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 16 / 9;
  border-radius: 12px;
  background: var(--surface-2);
  color: var(--ink-4);
}
.wt-video-placeholder svg {
  width: 28px;
  height: 28px;
}

.wt-content-col {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  /* Belt-and-suspenders alongside flex: 1 1 auto — RecorderApp.css's own .rec-content-col sets
     both too. Not strictly required by spec (the flex-grow above should already stretch this to
     fill .wt-app-main's remaining height), but costs nothing and removes any ambiguity for
     .stratum-page-header-mobile's own position: absolute override below, which needs this
     column's box to genuinely span the visible content area, not just whatever flex happens to
     compute given this column's own children are all position: absolute (contributing no natural
     content height of their own for the flex algorithm to size against). */
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  /* Anchors .stratum-page-header-mobile's own position: absolute override below. */
  position: relative;
}

/* PageHeaderMobile defaults to `position: fixed`, pinning to the raw browser viewport — on this
   page specifically that would float it over the video rail (a flex sibling of this column,
   stacked ABOVE it on mobile) instead of docking to the top of the transcript area below it.
   Ported from RecorderApp.css's identical override. Mobile-only in effect anyway (PageHeaderMobile
   itself is display:none at desktop width), so no explicit @media gate is needed here the way the
   dictionary-panel rule further down has one. */
.wt-content-col .stratum-page-header-mobile {
  position: absolute;
}

/* PageHeaderMobile's own top padding always reserves env(safe-area-inset-top) space (see
   PageHeader.css's .stratum-page-header-mobile-inner), on the assumption that it sits at the
   literal top of the screen under the notch/status bar — true for every other page in this app,
   but NOT here: this header is repositioned below the video rail (see the override just above),
   so the VIDEO is what actually touches that area now, not this header. Dropped entirely (not
   just the safe-area portion) — .wt-rail-inner's own bottom padding is already the visual gap
   between the video and this header, so any top padding here just stacked more space on top of
   that. */
.wt-content-col .stratum-page-header-mobile-inner {
  padding-top: 0;
}

/* PageHeaderDesktop renders .stratum-page-header-spacer to reserve PageHeaderMobile's height in
   normal flow (see PageHeader.tsx) — its default height bakes in the SAME padding this column's
   header no longer has (see above), so it has to shrink by that same amount too, or this column's
   content ends up with a leftover gap where the header used to reserve extra space for. */
.wt-content-col .stratum-page-header-spacer {
  height: 56px;
}

/* Separates the header text from the transcript below it — mobile only, matching this page's own
   divider language (WatchPage.tsx's own wt-rail-resize, above) rather than relying purely on
   whitespace the way this header's own padding used to before it was dropped just above. Starts
   transparent and only fades in once actually scrolled (PageHeaderMobile's own `scrolled` prop,
   already wired up from useScrollHeader — see PageHeader.css's own
   .stratum-page-header-mobile--scrolled for the identical technique this mirrors), same as every
   other page's mobile header: no divider needed while sitting flush at the very top with nothing
   to separate from yet. */
@media (max-width: 767px) {
  .wt-content-col .stratum-page-header-mobile-inner {
    border-bottom: 1px solid transparent;
    transition: border-bottom-color 0.18s;
  }
  .wt-content-col .stratum-page-header-mobile--scrolled .stratum-page-header-mobile-inner {
    border-bottom-color: var(--line);
  }
}

.wt-summary-link { cursor: pointer; background: none; border: none; padding: 0; font: inherit; color: inherit; }
.wt-summary-link:hover { text-decoration: underline; }

/* Header title truncation — mirrors ReadPage.css: exactly one line with an ellipsis on both
   mobile and desktop, plus the min-width: 0 flex-item fix so the title row can't push the
   header sideways. cursor: pointer, like read — the title opens the full-title bottom sheet
   (see WatchPage.tsx's titleSheetOpen). */
.wt-app-main .stratum-page-header-title-row > div,
.wt-app-main .stratum-page-header-desktop-inner > div {
  min-width: 0;
}
.wt-app-main .stratum-page-header-mobile .stratum-page-header-title,
.wt-app-main .stratum-page-header-desktop .stratum-page-header-title {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}

/* Title bottom sheet content (see titleSheetOpen in WatchPage.tsx) — mirrors ReadPage.css's own
   .read-title-sheet-chapter: the recording title wraps fully, never truncated, since showing
   the untruncated text is the whole point of the sheet. Static text only (no dict tokens or
   segment markers — see the sheet's own doc in the TSX), so taps here never start lookup. */
.wt-title-sheet-title {
  padding-top: 4px;
  font-size: 16px;
  line-height: 1.6;
  color: var(--ink);
  overflow-wrap: break-word;
}

/* English translation of the title (nittere, see downloader/src/tvTitle.ts), muted under it. */
.wt-title-sheet-title-en {
  padding-top: 4px;
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink-3);
  overflow-wrap: break-word;
}

/* The dictionary-lookup popup (DictLookupSheet -> stratum-ui's BottomPanel, `position: fixed`) is
   rendered via a React portal straight onto document.body (see WatchPage.tsx's own doc) rather
   than scoped/repositioned relative to .wt-content-col the way the header above is — a real,
   confirmed mobile Safari bug otherwise: a position:fixed element nested inside a genuinely
   scrolling ancestor (.wt-scroll's own overflow-y:auto, the nested-scroll model this whole file is
   built on) can drift away from the true viewport instead of staying pinned to it, especially
   noticeable once actually installed as an iOS PWA. Portaling it out from under that ancestor
   entirely sidesteps the bug rather than working around its symptoms. `.wt-dict-portal` is the
   marker wrapper WatchPage.tsx renders around it (portals move real DOM position, not the React
   tree, so a plain descendant selector like this still needs a real DOM ancestor to hang off of).
   Desktop-only override here: at that width the video rail sits BESIDE the transcript column, so
   the panel's own centering needs to exclude that width — `left: var(--wt-rail-width)` does that
   directly against the real viewport, no ancestor-relative positioning needed at all. The rail's
   own width is now user-resizable (see WatchPage.tsx's railWidth), so this reads a CSS custom
   property WatchPage.tsx keeps in sync with it (on documentElement, not scoped to any element
   here — this portaled panel isn't a descendant of anything that own value would otherwise
   cascade from) rather than a hardcoded pixel value that would drift out of sync the moment
   someone drags the divider. Mobile keeps the untouched default (`left: 0`), which is exactly
   right once the rail stacks above instead of beside. `.wt-selprompt-portal` (the ask-about-this
   panel, see WatchPage.tsx), `.wt-titlesheet-portal` (the full-title panel), and
   `.wt-summarysheet-portal` (the summary placeholder) get the identical treatment via the shared
   selector below. */
@media (min-width: 768px) {
  .wt-dict-portal .stratum-bottom-panel,
  .wt-selprompt-portal .stratum-bottom-panel,
  .wt-titlesheet-portal .stratum-bottom-panel,
  .wt-summarysheet-portal .stratum-bottom-panel {
    left: var(--wt-rail-width, 440px);
  }
}

/* Summary bottom sheet placeholder body (see summarySheetOpen in WatchPage.tsx) — muted single
   line until the real transcript summary lands. */
.wt-summary-sheet-empty {
  padding-top: 4px;
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink-3);
}

.wt-body {
  display: flex;
  flex-direction: column;
  min-height: 0;
  max-width: 720px;
  margin: 0 auto;
  padding: 16px 20px 210px;
}
/* Mobile: 2px sides + the feed's own 18px gutter (below) = ReadPage's 20px text inset (.read-main). */
@media (max-width: 767px) {
  .wt-body {
    padding: 16px 2px 210px;
  }
}

/* Transcript line: timestamp on its own row above the text, 12px gaps.
   Matches the stratum-ui-demos watch-page mockup. */
.wt-body .stratum-transcript-feed {
  gap: 12px;
  /* 18px gutters inside the transcript scrollport: the blurred translation bleed box (.wt-translation--blurred padding + negative margins, mirroring ReadPage) extends 18px past the line box on each side, and this feed overflow-y auto forces overflow-x to auto, so without this room the scrollport slices the blur halo at its edge. */
  padding: 0 18px;
}
/* A line's text as one full-width block (the dictionary hook's outer data-line-key span, not its
   per-token ones), like ReadPage's chapter lines: a tap beside a short line or between its wrapped
   rows still lands on it, not only one on the glyphs. */
.wt-body .stratum-transcript-text > [data-line-key] {
  display: block;
}
.wt-body .stratum-transcript-line {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}
.wt-body .stratum-transcript-time {
  position: relative;
  display: block;
  width: 100%;
  min-width: 0;
  padding: 0;
  margin: 0;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.02em;
  background: none;
  border-radius: 0;
}
/* No hover background on timestamps. */
.wt-body .stratum-transcript-time--clickable:not(.stratum-transcript-time--active):hover {
  background: none;
}
/* Expand the timestamp's click area to the full line width plus 6px
   above/below, without changing appearance — transparent, no layout impact. */
.wt-body .stratum-transcript-time--clickable::before {
  content: '';
  position: absolute;
  top: -6px;
  bottom: -6px;
  left: 0;
  right: 0;
}

.wt-empty {
  padding: 60px 20px;
  color: var(--ink-3);
  font-size: 14px;
  text-align: center;
}

/* Per-speaker indicator for Meta (diarized) transcripts only (see WatchPage.tsx's
   renderTextWithSpeaker) — a small muted speaker letter (A/B/C) in a consistent per-speaker
   color, prepended to the line text. aria-hidden and user-select: none so it stays out of
   selection/copy and screen-reader output — it's a visual cue, not transcript content. */
.wt-speaker {
  display: inline-block;
  font-size: 28px;
  font-weight: 700;
  line-height: 1;
  user-select: none;
}

@media (min-width: 768px) {
  .wt-speaker {
    font-size: 32px;
  }
}

/* Continuation marker for an untimed same-speaker paragraph (a split turn's
   2nd+ paragraph) — same size and per-speaker color as .wt-speaker, just
   dimmed so it reads as "same speaker, still talking" rather than a new turn. */
.wt-speaker--continued {
  opacity: 0.55;
}

/* One span per kuromoji token (see useTranscriptDictionary.tsx's renderText) — margin, not an
   inserted space character, so Japanese text still reads/copies as the exact real transcript. */
.wt-token {
  margin-right: 0.4em;
}
.wt-token:last-child {
  margin-right: 0;
}

/* Zero-width furigana spacer prepended to every line — invisible, so it shouldn't act like real
   selectable/interactive text either. */
.wt-furigana-spacer {
  user-select: none;
  cursor: default;
}

/* Dictionary lookup match highlight — a matched word can span several adjacent .wt-token spans
   (a conjugated verb kuromoji split into multiple tokens), so the background bleeds into the
   token's own right margin via an equal-color box-shadow, bridging the gap so adjacent matched
   tokens read as one continuous highlighted run instead of separate islands. Rounded corners
   only at the START and END of a run, via pure CSS sibling matching. Ported from ReadPage.css's
   own .dict-match / RecorderApp.css's own .rec-token--match. */
.wt-token.wt-token--match {
  background: rgba(255, 200, 0, 0.4);
  box-shadow: 0.4em 0 0 0 rgba(255, 200, 0, 0.4);
  border-radius: 0;
}
.wt-token.wt-token--match:not(.wt-token--match + .wt-token--match) {
  border-top-left-radius: 3px;
  border-bottom-left-radius: 3px;
}
.wt-token.wt-token--match:not(:has(+ .wt-token--match)) {
  border-top-right-radius: 3px;
  border-bottom-right-radius: 3px;
  box-shadow: none;
}

/* Underlines whichever token(s) are playing right now (see useTranscriptDictionary.tsx's own doc
   on activeWordRange) — Apple's per-word Speech-recognition timing, one level more granular than
   TranscriptFeed's own whole-line activeSegmentId highlight. Deliberately just an underline, not
   a background — background is reserved for the dictionary-lookup match above, so the two never
   read as the same kind of thing even on the rare token both apply to at once. */
.wt-token.wt-token--playing {
  text-decoration: underline;
  text-decoration-color: var(--accent);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
}

/* Furigana readings are never part of a selection/click target — a real, confirmed Safari
   drag-select regression on ruby-text boxes (same reasoning as ReadPage.css's own `.read-line
   rt`). pointer-events: none also means a click landing visually on the reading resolves (via
   document.elementFromPoint) to the base text/token underneath instead of nothing. */
.wt-body rt {
  -webkit-user-select: none;
  user-select: none;
  pointer-events: none;
}

/* Matches ReadPage.css's sizes (.read-text/.read-translation: 16px/15px on mobile, 24px/20px on
   desktop) — scoped locally here rather than changing TranscriptFeed.css's own base font-size
   (20px), which is a shared stratum-ui component electron's RecorderApp also uses unmodified;
   changing it there would change the live-recording transcript's own size too, not just this
   page's replay view. The translation is .wt-translation (transcriptTokens.css: 0.9em of the
   feed), so mobile pins it to 15px rather than letting it follow the feed's size. */
@media (max-width: 767px) {
  .wt-body .stratum-transcript-feed {
    font-size: 16px;
  }
  .wt-body .wt-translation {
    font-size: 15px;
  }
}
@media (min-width: 768px) {
  .wt-body .stratum-transcript-feed {
    font-size: 24px;
  }
  .wt-body .stratum-transcript-translation {
    font-size: 20px;
  }
}

/* Auto-active speaker block's translation (see WatchPage.tsx's
   renderBlockTranslation) — blurred until revealed via its ghost button,
   draining the shared peek meter. Mirrors ReadPage.css's
   .read-translation--blurred/.read-reveal-ghost: the blur lives on the
   inner text span (`filter`, zero reflow) so the button stays crisp above
   it, and selection is disabled while blurred. .wt-translation itself
   (stratum-ui's transcriptTokens.css) supplies the typography; this only
   adds the positioning context for the button. */
.wt-translation {
  position: relative;
  -webkit-tap-highlight-color: transparent;
}
.wt-translation-text {
  transition: filter 0.18s ease;
}
.wt-translation--blurred {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
.wt-translation--blurred .wt-translation-text {
  filter: blur(6px);
}
/* Bleed room for the blur halo — same reasoning as ReadPage.css's
   `.read-translation.read-translation--blurred`. */
.wt-translation.wt-translation--blurred {
  padding: 0 18px;
  margin-left: -18px;
  margin-right: -18px;
}

/* Tap-to-reveal ghost button (see components/RevealGhostButton.tsx) — same
   look as ReadPage.css's .read-reveal-ghost. */
.wt-reveal-ghost {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--ink) 18%, transparent);
  background: color-mix(in srgb, var(--bg) 60%, transparent);
  color: var(--ink-2);
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition:
    background 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease;
  animation: wt-reveal-ghost-in 0.18s ease;
}
.wt-reveal-ghost:hover {
  border-color: color-mix(in srgb, var(--ink) 32%, transparent);
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  color: var(--ink);
}
.wt-reveal-ghost:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
@keyframes wt-reveal-ghost-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
:root {
  --bg: #fbfaf7;
  --surface: #ffffff;
  --surface-2: #f4f3ef;
  --ink: #141414;
  --ink-2: #3d3d3e;
  --ink-3: #75757a;
  --ink-4: #a4a4a9;
  --line: #e8e7e2;
  --line-2: #dcdbd5;
  --accent: #0a84ff;
  --accent-soft: #e7f0ff;
  --warn: #ff3b30;
  --ok: #32c759;
  --dev-accent: #ff9500;
  --mono: "JetBrains Mono", ui-monospace, Menlo, monospace;
  --pwa-nav-pad: 24px;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0a0a0b;
    --surface: #141416;
    --surface-2: #1e1e21;
    --ink: #f5f5f7;
    --ink-2: #c7c7cc;
    --ink-3: #8e8e93;
    --ink-4: #636366;
    --line: #2a2a2e;
    --line-2: #3a3a3f;
  }
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: calc(var(--vh, 1vh) * 100); }
body {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
  background: var(--bg);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  overflow: hidden;
}
/* Native-scroll migration — toggled by AppShell.tsx's own NATIVE_SCROLL_ROUTE_PREFIXES while
   on a migrated page (ReadPage, SearchPage, ... — see that constant's own doc for the current
   list). `body`'s own overflow IS the viewport's overflow (html never sets its own overflow
   anywhere in this codebase, so per CSS overflow propagation it's inherited straight from
   body) — or at least it WAS, before the change documented below; this comment's own history
   is worth keeping for the "why not just `overflow: visible` on body/html directly" question.

   Originally (through 2026-09-12) this was a single `body.native-scroll { overflow: visible }`
   override, making `document`/`window` itself the real scrolling element for every migrated
   page's own height:auto/overflow:visible chain (AppShellLayout, .app-scroll.*-scroll,
   .*-content-col, ...) to reach. That's no longer true: a real, confirmed iOS 26 Safari bug
   (reported on Apple's own developer forums and widely elsewhere — search "iOS 26 Safari fixed
   sticky position elements misplaced") desyncs `position: fixed` elements (AppShellLayout's own
   bottom nav/FAB) from the true viewport bottom specifically while `document`/`window` itself is
   the thing scrolling — the nav visibly floats mid-content, worse the longer/faster you scroll.
   Confirmed via a real device + Safari Web Inspector session (not guessed): `document.body.style.
   position = 'fixed'` alone fixed the nav's position instantly but froze scrolling entirely
   (body itself can no longer scroll once it's the fixed element). The fix that actually worked,
   after several iterations narrowing it down live against the real device: keep BOTH `html` and
   `body` themselves permanently pinned (`position: fixed`, `inset: 0`, `overflow: hidden` — never
   scrolling), and delegate the actual scrolling to `#root` (React's own mount div, marked with
   stratum-ui's `.stratum-native-scroll-root` — see scrollTarget.ts's own doc for why stratum-ui's
   shared scroll-target resolution needed to change too, not just this CSS) via `overflow-y: auto`
   on IT instead. `#root` is shallow enough in the DOM (a direct child of `body`) that it still
   avoids the OLDER, separate, ALSO-real iOS bug 'native' scroll mode was originally created to
   dodge in the first place (native text-selection UI breaking inside a deeper nested scroll
   container) — confirmed by hand, text selection ("Ask about this") still works correctly with
   this setup. Two things tried and rejected before landing here, in case this needs revisiting:
   making `<html>` (not `body`) the fixed element, sized via `--vh`/`100vh`/`100dvh` — all three
   left a large (~60px on the test device), un-fixable gap between the nav and the true bottom
   edge, because a `position: fixed` `<html>` specifically stops getting `viewport-fit=cover`'s
   normal full-bleed safe-area treatment, no matter how its CSS height is set. `body.native-scroll`
   (0,1,1) beats plain `body` (0,0,1) regardless of source order, so this doesn't need to be more
   specific than that or `!important`. Desktop is untouched (every page keeps its current
   nested-scroll layout there) — see AppShellLayout.css's own `--native-scroll` modifier for why. */
@media (max-width: 767px) {
  body.native-scroll {
    position: fixed;
    inset: 0;
    overflow: hidden;
  }
  body.native-scroll #root {
    height: 100%;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
  }
}
input, textarea {
  -webkit-user-select: text;
  user-select: text;
}
#root { height: 100%; }
/* Thumbnail covers (thumbCover.ts) — an opaque layer over every thumbnail while the setting is on,
   above the image and muted video preview (both still rendered underneath), below the badges.
   pointer-events: none keeps the thumbnail tappable, so opening the item still works. */
body.thumbs-covered .stratum-thumb::after,
body.thumbs-covered .dl-thumb:not(.inspect, .fail, .text)::after {
  content: "";
  position: absolute;
  inset: 0;
  /* Over the media (image, poster, skeleton at 0; the video at 1, which this follows in the DOM) but
     under the badges on it - duration, character count, group count (2) and the select check (5). */
  z-index: 1;
  pointer-events: none;
  background: var(--surface-2);
}
