/* ==========================================================================
   Epochs — the whole design system, in five layers:

     1 TOKENS      every colour, size, space and radius, defined once
     2 BASE        the document itself: reset, body, links, form controls
     3 COMPONENTS  reusable pieces, each with a markup contract
     4 DENSE       the pointer-sized variant of layer 3, for a management screen
     5 VIEWS       layout that belongs to exactly one screen — this game's own

   Layers 1–4 are the READER's, copied from read.mensi.at v3.3.0
   (home-setup/pc-1-mini/compose/reader/app/css/app.css); app/VENDORED.md lists
   what came over verbatim and what was adapted. The reader's own layer 5 was
   dropped — chapter reading, covers, continuous mode and the series/jobs/library
   tables belong to that app, not this one. Two of its blocks were LIFTED UP into
   layer 3 instead, because neither is one screen's layout: the topbar, which
   every screen here wears, and the card/tile/hue system, which is what the lobby
   and the settings screen are built out of.

   Contracts and rationale live in app/STYLE.md — read that before adding a
   component. Rules that hold everywhere:

     · spacing is a multiple of 4 (use the --space-* tokens, never a raw px);
     · --accent and --danger are the only accent colours. --ok / --warn are
       STATUS colours: chips, banners, status text — never a button, never a link;
     · every tap target is at least 44px;
     · Bootstrap Icons only (js/icons.js), never an emoji.
   ========================================================================== */

/* ── 1 · TOKENS ─────────────────────────────────────────────────────────── */

:root {
  /* Two palettes, each colour defined once. The active tokens (--bg, --fg, …)
     alias one set; the scheme rules below just re-point the alias. Default: dark. */
  --dark-bg: #15171a;        --light-bg: #fafaf8;
  --dark-fg: #d8d4cc;        --light-fg: #222;
  --dark-muted: #8a8f98;     --light-muted: #6b6f76;
  --dark-card: #1d2025;      --light-card: #ffffff;
  --dark-line: #3a3d42;      --light-line: #ddd;
  --dark-accent: #8ab4f8;    --light-accent: #2b6cb0;
  --dark-accent-fg: #0c1014; --light-accent-fg: #ffffff;
  --dark-danger: #e8736f;    --light-danger: #b3261e;
  --dark-ok: #7dd491;        --light-ok: #1e7e34;
  --dark-warn: #e8c46f;      --light-warn: #9a6d00;

  /* Identity hues — one per card (STYLE.md §1). A hue colours that card's
     glyph, its border and its shadow, and NOTHING else: --accent and --danger
     are still the only two colours that carry interaction, and a hue carries
     none — it says WHICH card this is, the way a cover says which series a
     dashboard card is about. A card whose feature is off or unconfigured
     re-points --tint to --muted and the colour simply goes.

     The dark value of a pair is the pastel and the light one the deep, saturated
     twin — a hue has to be findable on both grounds. Angles are spread so that
     no two cards SHARING A SCREEN land within ~30° of each other; a pair that
     is close lives on two screens the eye never compares side by side. */
  --dark-hue-appearance: #c4a7ff; --light-hue-appearance: #6b4fc8;
  --dark-hue-reading: #d9b98a;    --light-hue-reading: #8a5f27;
  --dark-hue-ahead: #ffb27a;      --light-hue-ahead: #c45d0e;
  --dark-hue-storage: #aab9c8;    --light-hue-storage: #4f6579;
  --dark-hue-year: #f3a1cf;       --light-hue-year: #b6307d;
  --dark-hue-sync: #cfe28c;       --light-hue-sync: #5f7f12;
  --dark-hue-ws: #82d9ec;         --light-hue-ws: #0d7f97;
  --dark-hue-chat: #8adcc4;       --light-hue-chat: #12806a;
  --dark-hue-admin: #b3b8ff;      --light-hue-admin: #4650c0;
  /* …the management cards. `getmore` is deliberately absent: that card is the
     series page's half of "Chapters ahead" and wears --hue-ahead, because one
     job should not answer to two colours. */
  --dark-hue-wuxia: #ecd06a;      --light-hue-wuxia: #7a6000;
  --dark-hue-auto: #a6e77f;       --light-hue-auto: #3d8a12;
  --dark-hue-cover: #79ded4;      --light-hue-cover: #00786f;
  --dark-hue-teaser: #dda3f2;     --light-hue-teaser: #8a27b5;
  --dark-hue-chapters: #8ec4f7;   --light-hue-chapters: #155ba8;
  --dark-hue-add: #7ecfe8;        --light-hue-add: #0a6d8c;
  --dark-hue-server: #f2a0e0;     --light-hue-server: #a3269a;
  --dark-hue-health: #aeaef7;     --light-hue-health: #3f3fbe;

  /* Epochs' own seven, spread round the wheel so no two of the game's cards
     read as the same colour: rose 340°, amber 45°, then the reader's --hue-sync
     at 95°, green 130°, its --hue-chat at 165°, azure 200°, violet 270°. The
     seventeen above are the reader's, kept as a palette to draw the next card
     from — nothing in this app maps to them yet. --danger is NOT among these:
     "this needs a hand" is an accent, not an identity. */
  --dark-hue-rules: #e5c07b;      --light-hue-rules: #7d5a00;
  --dark-hue-lobby: #f5a3b8;      --light-hue-lobby: #a8265a;
  --dark-hue-players: #9fe6a0;    --light-hue-players: #2f7d32;
  --dark-hue-identity: #c9a9ff;   --light-hue-identity: #5b3bb8;
  --dark-hue-relay: #7fd4f5;      --light-hue-relay: #0b6d99;

  --serif: Georgia, 'Times New Roman', serif;
  --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --mono: ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Type scale. Seven steps cover the whole app; --icon/--icon-lg size the
     inline SVGs (a .bi is 1em, so its box follows the local font-size). */
  --text-xs: 0.75rem;
  --text-sm: 0.85rem;
  --text-md: 0.9rem;
  --text-base: 1rem;
  --text-lg: 1.15rem;
  --text-xl: 1.25rem;
  --text-2xl: 1.75rem;
  --icon: 1.25rem;
  --icon-lg: 1.5rem;
  --icon-xl: 3rem;

  /* Spacing — multiples of 4, named by that multiple. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 20px;
  --space-6: 24px;
  --space-7: 28px;
  --space-8: 32px;
  --space-12: 48px;

  --radius-sm: 8px;
  --radius-md: 10px;
  --radius-lg: 12px;
  --radius-xl: 16px;
  --radius-pill: 999px;

  /* Control heights. --tap is the reader's thumb-sized default; --tap-dense is
     the management density (.dense), where a pointer is the likely input. */
  --tap: 44px;
  --tap-dense: 36px;

  --dur: 0.25s;

  /* Card tint — how far a dashboard card leans toward its cover's hue (§3,
     "card tint"). Strengths, not colours: the hue and chroma themselves come
     from the card's own --cover-hue / --cover-chroma (js/tint.js). */
  --lit-pull: 0.08; /* lightness moved toward mid (0.6): lets white hold a hue */
  --lit-chroma: 0.045; /* the body's chroma cap */
  --lit-line: 0.06; /* the border's chroma cap */
  --lit-glow: 20%; /* how much of the tint the glow carries */

  /* Active palette → dark by default (the app is dark-first). */
  --bg: var(--dark-bg);
  --fg: var(--dark-fg);
  --muted: var(--dark-muted);
  --card: var(--dark-card);
  --line: var(--dark-line);
  --accent: var(--dark-accent);
  --accent-fg: var(--dark-accent-fg);
  --danger: var(--dark-danger);
  --ok: var(--dark-ok);
  --warn: var(--dark-warn);
  --hue-appearance: var(--dark-hue-appearance);
  --hue-reading: var(--dark-hue-reading);
  --hue-ahead: var(--dark-hue-ahead);
  --hue-storage: var(--dark-hue-storage);
  --hue-year: var(--dark-hue-year);
  --hue-sync: var(--dark-hue-sync);
  --hue-ws: var(--dark-hue-ws);
  --hue-chat: var(--dark-hue-chat);
  --hue-admin: var(--dark-hue-admin);
  --hue-wuxia: var(--dark-hue-wuxia);
  --hue-auto: var(--dark-hue-auto);
  --hue-cover: var(--dark-hue-cover);
  --hue-teaser: var(--dark-hue-teaser);
  --hue-chapters: var(--dark-hue-chapters);
  --hue-add: var(--dark-hue-add);
  --hue-server: var(--dark-hue-server);
  --hue-health: var(--dark-hue-health);
  --hue-rules: var(--dark-hue-rules);
  --hue-lobby: var(--dark-hue-lobby);
  --hue-players: var(--dark-hue-players);
  --hue-identity: var(--dark-hue-identity);
  --hue-relay: var(--dark-hue-relay);
}

/* Re-point the active palette to light. A media query and an attribute selector
   can't share one block, so the *mapping* repeats below — but the colour values
   stay single-source above. Light applies when either:
     · the OS prefers light AND the user hasn't forced dark → the `system` default
     · the user explicitly picks Light in Settings          → [data-theme='light']
   `data-theme` (system|light|dark) is written from settings.theme by the inline
   boot script in index.html (pre-paint, no flash) and by applyTheme() in main.js
   (live) — forcing dark on a light OS is handled by the :not() guard here. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) {
    --bg: var(--light-bg);
    --fg: var(--light-fg);
    --muted: var(--light-muted);
    --card: var(--light-card);
    --line: var(--light-line);
    --accent: var(--light-accent);
    --accent-fg: var(--light-accent-fg);
    --danger: var(--light-danger);
    --ok: var(--light-ok);
    --warn: var(--light-warn);
    --hue-appearance: var(--light-hue-appearance);
    --hue-reading: var(--light-hue-reading);
    --hue-ahead: var(--light-hue-ahead);
    --hue-storage: var(--light-hue-storage);
    --hue-year: var(--light-hue-year);
    --hue-sync: var(--light-hue-sync);
    --hue-ws: var(--light-hue-ws);
    --hue-chat: var(--light-hue-chat);
    --hue-admin: var(--light-hue-admin);
    --hue-wuxia: var(--light-hue-wuxia);
    --hue-auto: var(--light-hue-auto);
    --hue-cover: var(--light-hue-cover);
    --hue-teaser: var(--light-hue-teaser);
    --hue-chapters: var(--light-hue-chapters);
    --hue-add: var(--light-hue-add);
    --hue-server: var(--light-hue-server);
    --hue-health: var(--light-hue-health);
    --hue-rules: var(--light-hue-rules);
    --hue-lobby: var(--light-hue-lobby);
    --hue-players: var(--light-hue-players);
    --hue-identity: var(--light-hue-identity);
    --hue-relay: var(--light-hue-relay);
  }
}

:root[data-theme='light'] {
  --bg: var(--light-bg);
  --fg: var(--light-fg);
  --muted: var(--light-muted);
  --card: var(--light-card);
  --line: var(--light-line);
  --accent: var(--light-accent);
  --accent-fg: var(--light-accent-fg);
  --danger: var(--light-danger);
  --ok: var(--light-ok);
  --warn: var(--light-warn);
  --hue-appearance: var(--light-hue-appearance);
  --hue-reading: var(--light-hue-reading);
  --hue-ahead: var(--light-hue-ahead);
  --hue-storage: var(--light-hue-storage);
  --hue-year: var(--light-hue-year);
  --hue-sync: var(--light-hue-sync);
  --hue-ws: var(--light-hue-ws);
  --hue-chat: var(--light-hue-chat);
  --hue-admin: var(--light-hue-admin);
  --hue-wuxia: var(--light-hue-wuxia);
  --hue-auto: var(--light-hue-auto);
  --hue-cover: var(--light-hue-cover);
  --hue-teaser: var(--light-hue-teaser);
  --hue-chapters: var(--light-hue-chapters);
  --hue-add: var(--light-hue-add);
  --hue-server: var(--light-hue-server);
  --hue-health: var(--light-hue-health);
  --hue-rules: var(--light-hue-rules);
  --hue-lobby: var(--light-hue-lobby);
  --hue-players: var(--light-hue-players);
  --hue-identity: var(--light-hue-identity);
  --hue-relay: var(--light-hue-relay);
}

/* Custom scheme: the user picks --custom-bg / --custom-fg (set on <html> by the
   inline boot script and applyTheme()); every other token derives from those two
   via color-mix, so the whole UI stays coherent from just two inputs. Accent is
   the text colour on the background (an inverted, always-legible button). Status
   colours keep their own hue but are pulled 28% toward the chosen foreground, so
   they stay legible on any seed the user picks. The var() fallbacks keep it sane
   if a seed is ever missing. */
:root[data-theme='custom'] {
  --bg: var(--custom-bg, var(--dark-bg));
  --fg: var(--custom-fg, var(--dark-fg));
  --card: color-mix(in srgb, var(--bg) 92%, var(--fg));
  --line: color-mix(in srgb, var(--bg) 78%, var(--fg));
  --muted: color-mix(in srgb, var(--fg) 55%, var(--bg));
  --accent: var(--fg);
  --accent-fg: var(--bg);
  --danger: color-mix(in srgb, #e5484d 72%, var(--fg));
  --ok: color-mix(in srgb, #35a06b 72%, var(--fg));
  --warn: color-mix(in srgb, #d19a2e 72%, var(--fg));
  --hue-appearance: color-mix(in srgb, var(--dark-hue-appearance) 72%, var(--fg));
  --hue-reading: color-mix(in srgb, var(--dark-hue-reading) 72%, var(--fg));
  --hue-ahead: color-mix(in srgb, var(--dark-hue-ahead) 72%, var(--fg));
  --hue-storage: color-mix(in srgb, var(--dark-hue-storage) 72%, var(--fg));
  --hue-year: color-mix(in srgb, var(--dark-hue-year) 72%, var(--fg));
  --hue-sync: color-mix(in srgb, var(--dark-hue-sync) 72%, var(--fg));
  --hue-ws: color-mix(in srgb, var(--dark-hue-ws) 72%, var(--fg));
  --hue-chat: color-mix(in srgb, var(--dark-hue-chat) 72%, var(--fg));
  --hue-admin: color-mix(in srgb, var(--dark-hue-admin) 72%, var(--fg));
  --hue-wuxia: color-mix(in srgb, var(--dark-hue-wuxia) 72%, var(--fg));
  --hue-auto: color-mix(in srgb, var(--dark-hue-auto) 72%, var(--fg));
  --hue-cover: color-mix(in srgb, var(--dark-hue-cover) 72%, var(--fg));
  --hue-teaser: color-mix(in srgb, var(--dark-hue-teaser) 72%, var(--fg));
  --hue-chapters: color-mix(in srgb, var(--dark-hue-chapters) 72%, var(--fg));
  --hue-add: color-mix(in srgb, var(--dark-hue-add) 72%, var(--fg));
  --hue-server: color-mix(in srgb, var(--dark-hue-server) 72%, var(--fg));
  --hue-health: color-mix(in srgb, var(--dark-hue-health) 72%, var(--fg));
  --hue-rules: color-mix(in srgb, var(--dark-hue-rules) 72%, var(--fg));
  --hue-lobby: color-mix(in srgb, var(--dark-hue-lobby) 72%, var(--fg));
  --hue-players: color-mix(in srgb, var(--dark-hue-players) 72%, var(--fg));
  --hue-identity: color-mix(in srgb, var(--dark-hue-identity) 72%, var(--fg));
  --hue-relay: color-mix(in srgb, var(--dark-hue-relay) 72%, var(--fg));
}


/* ── 2 · BASE ───────────────────────────────────────────────────────────── */

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--sans);
  -webkit-text-size-adjust: 100%;
  scrollbar-width: none; /* Firefox: hide the scrollbar (scrolling still works) */
}

/* Immersive: no scrollbar chrome anywhere. Wheel/touch/keys still scroll. */
html::-webkit-scrollbar,
body::-webkit-scrollbar { display: none; } /* WebKit/Blink */

a { color: var(--accent); text-decoration: none; }

/* One keyboard focus ring for the whole app. :where() keeps specificity at 0,
   so component rules (e.g. .swatch.active's outline) still win. */
:where(a, button, input, select, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Text fields. Bare `input` on purpose: several fields ship without a type
   attribute, which an input[type='…'] selector would miss. */
input, select {
  min-height: var(--tap);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  border: 1px solid var(--line);
  background: var(--bg);
  color: var(--fg);
  font-family: inherit;
  font-size: var(--text-base);
}

input[type='checkbox'], input[type='radio'] { min-height: 0; width: auto; }

input[type='color'] { padding: var(--space-1); cursor: pointer; }

/* Inline Bootstrap Icon SVGs (icon() in icons.js): sized by the local
   font-size and seated on the text baseline, like the glyphs they replaced. */
.bi {
  width: 1em;
  height: 1em;
  vertical-align: -0.125em;
  fill: currentColor;
}

/* Visible to a screen reader, not to the eye. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}


/* ── 3 · COMPONENTS ─────────────────────────────────────────────────────── */

/* --- button ---------------------------------------------------------------
   .btn            filled, the primary action
   .btn.ghost      outlined, secondary
   .btn.danger     outlined in --danger; pair with data-confirm (see below)
   .btn.small      compact variant (.dense management UI)
   [aria-busy]     action in flight — dimmed AND inert (controls.js)
   [data-armed]    a danger button waiting for its confirming press          */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2); /* flex eats bare spaces between an icon and its label */
  min-height: var(--tap);
  padding: var(--space-2) var(--space-5);
  border-radius: var(--radius-lg);
  border: none;
  background: var(--accent);
  color: var(--accent-fg);
  font-family: inherit;
  font-size: var(--text-base);
  font-weight: 600;
  cursor: pointer;
}

.btn.ghost {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--fg);
}

.btn.danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }

/* Armed: the destructive press is one tap away, so the quiet outline becomes a
   filled warning. --bg reads against --danger in every palette (it is the
   surface the danger colour was chosen to be legible on). */
.btn.danger[data-armed='true'] {
  background: var(--danger);
  color: var(--bg);
}

/* Stripped to its glyph, with the label revealed on hover / keyboard focus.
   The label is a real <span>, but positioned OVER the layout rather than in it,
   so revealing it never reflows the row — and it is repeated in aria-label and
   title, because a phone has no hover at all and an accessible name may never
   depend on a pointer. Inverted neutrals, so the reveal reads as a label about
   the button rather than another control on the card.

   Markup contract (STYLE.md, "Button"):
     <button class="btn ghost icon-only" aria-label="…" title="…">
       {icon(name)}<span class="label">…</span>
     </button>                                                                */

.btn.icon-only {
  position: relative;
  min-width: var(--tap);
  padding: var(--space-2);
  font-size: var(--icon);
}

.btn.icon-only .label {
  display: none;
  position: absolute;
  right: 0;
  bottom: calc(100% + var(--space-2));
  z-index: 1;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-md);
  background: var(--fg);
  color: var(--bg);
  font-size: var(--text-sm);
  font-weight: 400;
  white-space: nowrap;
  pointer-events: none;
}

.btn.icon-only:hover .label,
.btn.icon-only:focus-visible .label { display: block; }

/* Tap feedback, applied by the click handler on the same tick as the click —
   before any state work — so a button acknowledges the press even when what
   follows is async. Re-renders drop the attribute with the node.
   pointer-events kills mouse/touch; the click handler drops events from busy
   elements, which is what makes it inert for the keyboard too (controls.js
   explains why this is not the `disabled` attribute). */
.btn[aria-busy='true'],
.iconbtn[aria-busy='true'],
.swatch[aria-busy='true'],
.seg[aria-busy='true'] { opacity: 0.55; pointer-events: none; }

/* Permanently unavailable, as opposed to momentarily busy above: a control
   with nothing to do is rendered as a span carrying no data-action and marked
   aria-disabled (views.js). The attribute is what dims it, on every surface —
   the reader footer had this rule to itself until Settings grew one too, and a
   state that paints only inside one container is a state that eventually
   paints nowhere (the `disabled` attribute's problem exactly). */
.btn[aria-disabled='true'] { opacity: 0.35; pointer-events: none; }

/* 20px of glyph and 8px of padding is a 36px box, and every icon-only control
   in this app wears this class: the topbar, the sheets' close buttons, the
   master's overflow menu. --tap is the floor everywhere (app/STYLE.md §1), the
   way `.btn.icon-only` already has it. */
.iconbtn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--tap);
  min-height: var(--tap);
  font-size: var(--icon);
  background: none;
  border: none;
  color: var(--fg);
  padding: var(--space-2);
  cursor: pointer;
}

/* A row of buttons under a card's content.
   `:not(td)` because a table's action cell wears the same class for the same
   reason (it holds the row's buttons) but is NOT this component: the table owns
   that box. Without the exclusion every `td.actions` becomes a flex container
   with a 12px top margin at desktop width, which is not a table cell any more.
   The phone-width stack DOES lay those cells out as a button strip — that rule
   lives with the rest of table.stack, under `.dense`. */
.actions:not(td) { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-top: var(--space-3); }

/* --- chip / pill ----------------------------------------------------------
   One shape, two names: .pill is the app-status pill in the topbar, .chip is
   the inline state marker (job state, series flag). Status modifiers are the
   only place --ok / --warn are allowed to colour anything.                  */

.pill, .chip {
  display: inline-block;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  color: var(--muted);
  font-size: var(--text-xs);
  white-space: nowrap;
}

.chip { font-weight: 600; }

.pill.offline, .chip.danger { color: var(--danger); border-color: var(--danger); }
.pill.syncing, .chip.accent { color: var(--accent); border-color: var(--accent); }
.chip.ok { color: var(--ok); border-color: var(--ok); }
.chip.warn { color: var(--warn); border-color: var(--warn); }

/* --- badge ----------------------------------------------------------------
   A number laid OVER the thing it counts, pill-shaped but not a .pill: that
   class is the topbar's app-status pill and stays unique to it. A badge is an
   overlay, so it differs from a chip in three ways — it is positioned by its
   host, it brings its own opaque background (whatever is behind it can be any
   colour), and it never takes the pointer: a click at its corner belongs to
   the control underneath.

   Markup contract (STYLE.md, "Badge"):
     <span class="badge <kind>" title="<the sentence>">
       <span aria-hidden="true">22/109</span>
       <span class="sr-only">chapter 22 of 109</span>
     </span>
   …or just the text, where the short form already IS the sentence. The host
   must be a positioning context.

   Every badge wears the ghost button's clothes — --card behind, --fg on top, a
   --line border. Solid rather than transparent because a badge lies over cover
   art or over a control, and the border is what separates it from both; --card
   is the surface it belongs to, so on the button it reads as part of it and
   over the art it reads as the card reaching up.
   Deliberately NOT a status colour: neither of these facts is a status.       */

.badge {
  position: absolute;
  z-index: 1;
  background: var(--card);
  border: 1px solid var(--line);
  color: var(--fg);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  pointer-events: none;
}

/* Inside the corner of cover art (dashboard card): where the reader is, or how
   big the series is before they start it. Sized and shaped like a BUTTON, not
   like the topbar's pill — a status pill's metrics leave it too small to read
   at a glance over a poster-sized image. */
.badge.on-cover {
  top: var(--space-2);
  left: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-lg); /* the .btn radius */
  font-size: var(--text-sm);
}

/* Overhanging the corner of a control: how many chapters are downloaded ahead,
   worn by the button that downloads them the way an app wears an unread count
   — small and round, in the same clothes as the button it sits on. */
.badge.count {
  top: calc(-1 * var(--space-2));
  left: calc(-1 * var(--space-2));
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-pill);
  font-size: var(--text-xs);
}

/* --- card ------------------------------------------------------------------
   The one surface in the app. .setting and .login-card are the same box under
   different names, kept so the views read in their own vocabulary.          */

.card, .setting {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--radius-xl);
  padding: var(--space-4);
  margin: var(--space-3) 0;
}

/* --- cover ----------------------------------------------------------------
   A series' cover image, in three sizes: `.full` leads a dashboard card at the
   card's own width, `.lg` sits on the series hero + the admin series page, and
   the bare class is the small fixed box. The box is fixed (a pixel pair, or a
   ratio for `.full`) and the image cropped to it (object-fit), so a card is the
   same height whatever the source aspect ratio is — and the placeholder
   occupies exactly the same box, so a series without a cover does not shift
   its neighbours.

   Markup contract (STYLE.md, "Cover — live"):
     <img class="cover[ lg| full]" src="<object URL>" alt="" width height>
     <span class="cover[ lg| full] placeholder" aria-hidden="true">{icon('book')}</span>
   `src` is ALWAYS an object URL — never a /chapters/ address (see covers.js). */

.cover {
  display: block;
  flex: none;
  width: 56px;
  height: 80px;
  object-fit: cover;
  border-radius: var(--radius-md);
  border: 1px solid var(--line);
  background: var(--card);
}

.cover.lg { width: 96px; height: 136px; }

/* The full-width variant has no pixel box: it takes its width from the card it
   leads and its height from a fixed 3:4, so every card is still the same shape
   whatever the source is — a portrait cover loses a little top and bottom
   rather than the row changing height per series. Flush against the card's
   inner edge, so it carries the card's corner radius (less its 1px border) at
   the top and one hairline at the bottom instead of a frame of its own. */
.cover.full {
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 4;
  border: 0;
  border-bottom: 1px solid var(--line);
  border-radius: calc(var(--radius-xl) - 1px) calc(var(--radius-xl) - 1px) 0 0;
}

/* The glyph is a .bi, i.e. 1em — so it is sized through the box's font-size. */
.cover.placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  font-size: var(--icon);
}

.cover.lg.placeholder { font-size: var(--icon-lg); }
/* Card-sized, a flat --card blank would read as a broken image rather than an
   empty slot — so the big placeholder alone carries a faint tint. */
.cover.full.placeholder {
  font-size: var(--icon-xl);
  background: color-mix(in srgb, var(--fg) 5%, var(--card));
}

/* --- card tint ------------------------------------------------------------
   A dashboard card lit by its own cover. tint.js reads ONE colour off the
   cover — its most vibrant pixel — as an OKLCH hue + chroma pair, and the
   view writes the pair on the card (STYLE.md, "Card tint — live"):

     <div class="card lit" style="--cover-hue: 26.0; --cover-chroma: 0.219">

   Everything visible is composed HERE, at the card's own lightness: the hue
   is the cover's, the chroma a capped fraction of the cover's, and the
   lightness stays the surface's own — pulled a little toward mid so a pure
   white card can hold a hue at all and a dark one lifts rather than sinks.
   That is what lets one pair serve every theme: a navy cover and a white
   cover both tint a white card by hue and by nothing else, and a grey cover
   (chroma 0) tints nothing.

   The border leans the same way; the body fades from the cover down to a
   weaker tint at the foot; and a short, faint glow falls down and to the
   right only — the spread pulls it back inside its own offset, so nothing
   leaks above or left of the card and the grid is lit from one direction.

   Relative colour syntax IS the mechanism, so the block is guarded: a browser
   without it keeps the flat card, and the pair on the element is inert. */
@supports (color: oklch(from red l c h)) {
  .cards .card.lit {
    --lit-surface: oklch(
      from var(--card) calc(l + (0.6 - l) * var(--lit-pull))
        min(var(--lit-chroma), var(--cover-chroma) * 0.7) var(--cover-hue)
    );
    --lit-foot: oklch(
      from var(--card) calc(l + (0.6 - l) * var(--lit-pull) * 0.5)
        min(var(--lit-chroma), var(--cover-chroma) * 0.35) var(--cover-hue)
    );
    --lit-edge: oklch(from var(--line) l min(var(--lit-line), var(--cover-chroma) * 0.8) var(--cover-hue));
    --lit-tint: oklch(62% min(0.16, var(--cover-chroma) * 1.2) var(--cover-hue));
    border-color: var(--lit-edge);
    background: linear-gradient(to bottom, var(--lit-surface) 60%, var(--lit-foot));
    box-shadow: 2px 2px 6px -2px color-mix(in srgb, var(--lit-tint) var(--lit-glow), transparent);
  }

  /* The hairline under the cover leans with the border around it. */
  .cards .card.lit .cover.full { border-bottom-color: var(--lit-edge); }

  /* The badges — the position over the cover, the count on the follow button —
     wear the card's own surface: a plain --card square on a tinted card would
     read as a hole punched through it. */
  .cards .card.lit .badge { background: var(--lit-surface); }
}

/* --- banner ---------------------------------------------------------------
   Page-level, persistent condition (expiring token, degraded backend). Sits
   directly under the topbar, full width. Transient outcomes are toasts.     */

.banner {
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--line);
  font-size: var(--text-sm);
  line-height: 1.5;
}

/* The reader's variant: same rules and wording, one size quieter. A reading app
   is not a management screen, but the operator has to hear about a dying
   scrape token somewhere, and this is the screen they open every day. */
.banner.compact { padding: var(--space-2) var(--space-4); font-size: var(--text-xs); }

.banner a { color: inherit; text-decoration: underline; }
.banner.info { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.banner.warn { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.banner.danger { background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); }

/* --- toast ----------------------------------------------------------------
   Transient outcome of an action. Built by toast.js and mounted on <body>,
   never inside #app (a render would take it out mid-message).               */

.toasts {
  position: fixed;
  z-index: 100;
  left: 50%;
  bottom: calc(var(--space-4) + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  width: min(420px, calc(100vw - var(--space-8)));
  pointer-events: none; /* the stack never blocks the page… */
}

.toast {
  pointer-events: auto; /* …but each toast is interactive */
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-lg);
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--fg);
  font-size: var(--text-md);
  line-height: 1.4;
  box-shadow: 0 6px 24px rgb(0 0 0 / 0.28);
  animation: toast-in 0.2s ease-out;
}

.toast .toast-icon { flex: 0 0 auto; color: var(--muted); font-size: var(--text-base); }
.toast.ok .toast-icon { color: var(--ok); }
.toast.warn .toast-icon { color: var(--warn); }
.toast.danger .toast-icon { color: var(--danger); }
.toast .toast-msg { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }

/* Optional follow-up link ("Job #12 queued → View jobs"). Always an in-app hash
   route — toast.js drops anything else (see safeAction). */
.toast .toast-action { flex: 0 0 auto; color: var(--accent); font-weight: 600; white-space: nowrap; }

.toast .toast-close {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  min-height: 32px;
  margin: -4px -8px -4px 0;
  padding: 0;
  border: none;
  background: none;
  color: var(--muted);
  font-size: var(--text-md);
  cursor: pointer;
}

@keyframes toast-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: none; }
}

/* --- skeleton -------------------------------------------------------------
   Placeholder for content that is on its way. Shape-only: mark it aria-hidden
   and put the words in an .sr-only sibling.                                 */

.skeleton {
  display: block;
  border-radius: var(--radius-sm);
  background:
    linear-gradient(
      90deg,
      color-mix(in srgb, var(--fg) 7%, transparent) 25%,
      color-mix(in srgb, var(--fg) 16%, transparent) 37%,
      color-mix(in srgb, var(--fg) 7%, transparent) 63%
    );
  background-size: 400% 100%;
  animation: shimmer 1.4s ease infinite;
}

.skeleton-block { display: block; } /* groups skeleton lines + their .sr-only label */
.skeleton.line { height: 1em; margin: var(--space-3) 0; }
.skeleton.line.short { width: 55%; }
.skeleton.title { height: 1.6em; width: 60%; margin: 0 0 var(--space-4); }
.skeleton.block { height: 96px; }
.skeleton.cell { display: inline-block; width: 2.5ch; height: 1em; vertical-align: middle; }

@keyframes shimmer {
  from { background-position: 100% 0; }
  to { background-position: 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: none; }
  .toast { animation: none; }
  dialog.sheet[open] { animation: none; }
}

/* --- stat tile ------------------------------------------------------------ */

.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-3); }
.stat .k { color: var(--muted); font-size: var(--text-xs); }
.stat .v { font-size: var(--text-xl); font-weight: 700; font-variant-numeric: tabular-nums; }
/* A tile whose value is a WORD, not a number (a source name, a log level): the
   headline size would wrap it, so it steps down instead. */
.stat .v.small { font-size: var(--text-base); font-weight: 600; }

/* --- segmented control ----------------------------------------------------
   One choice out of a few (a view's tabs, a mode switch). The active segment
   carries aria-pressed="true"; the group carries the label.                 */

.segmented {
  display: inline-flex;
  gap: var(--space-1);
  max-width: 100%;
  padding: var(--space-1);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--card);
  overflow-x: auto;
  scrollbar-width: none;
}

.segmented::-webkit-scrollbar { display: none; }

.seg {
  flex: 0 0 auto;
  /* inline-flex + centring so an <a class="seg"> (a nav tab) sits in its box
     exactly like a <button class="seg"> (a mode switch). */
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-dense); /* + the group's 4px padding = a 44px target */
  padding: var(--space-2) var(--space-4);
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--muted);
  font-family: inherit;
  font-size: var(--text-md);
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
}

/* Two ways to be the active segment, one look: aria-pressed for a button that
   toggles a mode, aria-current for a link that IS the page you are on. */
.seg[aria-pressed='true'],
.seg[aria-current='page'] { background: var(--accent); color: var(--accent-fg); }

/* --- menu -----------------------------------------------------------------
   A dropdown that is a <details>, not a view-model flag: the open state lives
   in the DOM, so nothing has to carry it through a re-render, and choosing an
   option closes the menu by replacing it. main.js closes it on an outside
   click — the one behaviour <details> does not bring.

   Markup contract (STYLE.md, "Menu"):
     <details class="menu">
       <summary class="iconbtn" title="…" aria-label="…">{icon}</summary>
       <div class="menu-panel" role="group" aria-label="…">
         <button class="menu-item" data-action="…" aria-pressed="true|false">…</button>
       </div>
     </details>                                                               */

.menu { position: relative; display: inline-flex; }

/* The summary IS the button, so it must not also draw a disclosure triangle. */
.menu > summary { list-style: none; }
.menu > summary::-webkit-details-marker { display: none; }

.menu-panel {
  position: absolute;
  top: calc(100% + var(--space-2));
  right: 0;
  z-index: 20; /* over the cards, and over the topbar it hangs from (10) */
  min-width: 220px;
  padding: var(--space-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--card);
  box-shadow: 0 6px 24px rgb(0 0 0 / 0.28); /* the toast's, for the same reason */
}

.menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  width: 100%;
  min-height: var(--tap);
  padding: var(--space-2) var(--space-3);
  border: none;
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--fg);
  font-family: inherit;
  font-size: var(--text-md);
  text-align: left;
  cursor: pointer;
}

.menu-item[aria-pressed='true'] { background: var(--accent); color: var(--accent-fg); }

/* The panel's own footnote: quieter than the options, and never a tap target. */
.menu-panel .hint { margin: var(--space-2) var(--space-3) var(--space-1); }

/* The panel holds two groups — what the library is ORDERED by, and what it is
   DRAWN as — and they answer different questions about one list, so a rule
   separates them rather than a heading: two words of chrome over two rows of
   choices would be more label than control. */
.menu-panel > [role='group'] + [role='group'] {
  margin-top: var(--space-2);
  padding-top: var(--space-2);
  border-top: 1px solid var(--line);
}

/* --- notice / error / hint / sub ------------------------------------------- */

.notice {
  margin: var(--space-12) var(--space-4);
  text-align: center;
  color: var(--muted);
  line-height: 1.6;
}

.error { color: var(--danger); margin: var(--space-2) 0 0; min-height: 1.2em; }

/* Explanatory prose under a heading — the sentence that says what a control
   does and what it costs. */
.hint { margin: 0 0 var(--space-3); font-size: var(--text-sm); color: var(--muted); line-height: 1.5; }

/* Secondary identity on one line with a primary: the slug under a title. */
.sub { color: var(--muted); font-size: var(--text-xs); font-weight: 400; }

/* Label/value row. Used by Settings on both surfaces. */
.kv { display: flex; justify-content: space-between; gap: var(--space-4); font-size: var(--text-md); margin: var(--space-1) 0; }
.kv .k { color: var(--muted); }

/* --- sheet (the chatroom panel) -------------------------------------------
   A native <dialog>, mounted on <body> outside #app for the toast's reason:
   every render replaces #app's children wholesale and would take the panel
   with it. showModal() brings the top layer, ::backdrop, Escape (`cancel`),
   an inert page and focus containment — none of which is worth hand-writing.

   The dialog element IS the card: padding 0, its content filling it exactly,
   so a click whose target is the dialog itself can only have landed on the
   backdrop. people.js closes on that, and the rule is why it can.

   Markup contract (STYLE.md, "Sheet"):
     <dialog class="sheet">
       <div class="sheet-head"><h2>…</h2><button …>{x-lg}</button></div>
       <div class="segmented" role="group">…</div>            <!-- optional -->
       <div class="sheet-body">…</div>
     </dialog>                                                                */

dialog.sheet {
  padding: 0;
  width: min(420px, calc(100vw - var(--space-8)));
  max-width: 100%;
  max-height: min(560px, calc(100dvh - var(--space-12)));
  border: 1px solid var(--line);
  border-radius: var(--radius-xl);
  background: var(--card);
  color: var(--fg);
  box-shadow: 0 6px 24px rgb(0 0 0 / 0.28); /* the toast's: one shadow for one layer */
  overflow: hidden;
}

/* The layout hangs off [open] on purpose: `display` on the bare selector would
   beat the UA's `dialog:not([open]) { display: none }` and paint a closed
   dialog over the page. */
dialog.sheet[open] {
  display: flex;
  flex-direction: column;
  animation: sheet-in 0.2s ease-out;
}

dialog.sheet::backdrop { background: rgb(0 0 0 / 0.5); }

.sheet-head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--line);
}

/* Same shape as the topbar's h1, one step quieter: this is a panel over a
   screen, not the screen's own name. */
.sheet-head h2 {
  flex: 1;
  margin: 0;
  font-size: var(--text-lg);
  font-weight: 650;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

dialog.sheet > .segmented { margin: var(--space-3) var(--space-4) 0; }

/* The one thing that scrolls: the head and the tabs stay put while a long
   roster moves under them, and the PAGE never scrolls behind a modal. */
.sheet-body {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: var(--space-3) var(--space-4) var(--space-4);
}

.sheet-body .notice { margin: var(--space-8) 0; }

@keyframes sheet-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: none; }
}

/* On a phone it docks to the bottom edge — the corner a thumb reaches — and
   the safe-area inset is padded INSIDE the scrolling body, so the last row
   clears the home indicator without giving the backdrop-click a strip of
   dialog to land on. */
@media (max-width: 640px) {
  dialog.sheet {
    width: 100%;
    max-height: 85dvh;
    margin: auto auto 0;
    border-radius: var(--radius-xl) var(--radius-xl) 0 0;
    border-bottom: none;
  }

  .sheet-body { padding-bottom: calc(var(--space-4) + env(safe-area-inset-bottom)); }
}

/* --- people (rows, avatars, the topbar count) -----------------------------
   One row per PERSON, not per connection: the chatroom's roster is one entry
   per socket, and people-views.js folds an identity's sockets into a row that
   says "×2" (STYLE.md, "People row").

   Markup contract:
     <button class="people-row" data-action="people-person"
             data-identity="…" data-name="…" [data-friend] [data-self]>
       <span class="avatar lit" style="--cover-hue: …; --cover-chroma: …">AL</span>
       <span class="people-name">{star-fill}<span>Ada</span></span>
       <span class="people-meta">×2</span>
       <span class="chip">token</span>
     </button>                                                                */

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

.people-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  min-height: var(--tap);
  padding: var(--space-2) 0;
  border: none;
  background: none;
  color: var(--fg);
  font-family: inherit;
  font-size: var(--text-md);
  text-align: left;
  cursor: pointer;
}

.people-row + .people-row { border-top: 1px solid var(--line); }

.people-name {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.people-name > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.people-meta {
  flex: 0 0 auto;
  color: var(--muted);
  font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
}

/* My own other devices are a fact about this account, not somebody to meet. */
.people-row[data-self='true'] { color: var(--muted); }

/* The chat hue, on the three things that are about the chatroom itself: the
   friend mark, the ring round a friend's disc, and the topbar count when one
   of them is here. It colours no button and no body text (§1). */
.people-row[data-friend='true'] .bi-star-fill { flex: none; color: var(--hue-chat); }
.people-row[data-friend='true'] .avatar { box-shadow: 0 0 0 2px var(--hue-chat); }

/* The disc. 1–2 initials of the name actually shown, over a colour composed
   from the key's own bytes — the card tint's mechanism, at the same two custom
   properties (see "card tint"). A peer that presented no key gets a dashed
   grey disc instead: keyless is a fact about them, not a missing colour. */
.avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 32px;
  height: 32px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--muted);
  font-size: var(--text-sm);
  font-weight: 700;
  overflow: hidden;
}

.avatar.keyless { border-style: dashed; }

/* The person card's own, sized like a tile's .icon-badge.lg so the two read as
   the same object at two sizes. */
.avatar.lg { width: 72px; height: 72px; font-size: var(--text-2xl); }

/* Relative colour syntax IS the mechanism, so this is guarded exactly as the
   card tint is: without it the disc stays grey and the pair on the element is
   inert. The lightness is the surface's own, pulled toward mid, so one pair
   serves the dark, light and custom themes alike. */
@supports (color: oklch(from red l c h)) {
  .avatar.lit {
    background: oklch(
      from var(--card) calc(l + (0.6 - l) * 0.35) min(0.09, var(--cover-chroma)) var(--cover-hue)
    );
    border-color: oklch(from var(--line) l min(0.1, var(--cover-chroma)) var(--cover-hue));
    color: oklch(from var(--fg) l min(0.12, var(--cover-chroma)) var(--cover-hue));
  }
}

/* The count over the topbar's Chatroom button — the unread-count shape, worn
   the way an app wears one. Scoped to the buttons that carry one rather than
   making every .iconbtn a positioning context; in Epochs that is Chat as well
   as People, and without the second the unread count is positioned against the
   sticky topbar and lands in its corner. It takes no pointer: a click at the
   corner of a control belongs to the control. */
[data-action='people-open'],
[data-action='chat-open'] { position: relative; }

.count-badge {
  position: absolute;
  top: var(--space-1);
  right: 0;
  min-width: 16px;
  height: 16px;
  padding: 0 var(--space-1);
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--fg);
  font-size: var(--text-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: 14px;
  text-align: center;
  pointer-events: none;
}

/* No socket, no count — a number from a connection that has gone is a lie.
   What is left is a dot: the button still says there is a room to open. */
.count-badge:empty { min-width: 8px; height: 8px; padding: 0; }

.count-badge[data-friends='true'] { border-color: var(--hue-chat); color: var(--hue-chat); }

/* The drill-down card: a .setting, so it already has the field row, the
   readonly key field, the .kv rows, the label/input pair and the invalid
   border. Only the margin and the two states of its actions slot are its own.
   The slot is empty for a keyless peer and for my own other device — there is
   nothing to be done about either — and `:empty` is what keeps that from
   leaving a gap under the card. */
.person-card { margin: 0; }
.person-card .card-head { gap: var(--space-4); margin-bottom: var(--space-3); }
.person-card .actions:empty { display: none; }

/* The name field takes the whole slot rather than sitting beside a button: it
   REPLACES the buttons while it is open (people-views.js), and a full-width
   flex item is how it does that inside the row they shared. */
.person-card .friend-form { flex: 1 1 100%; }
.person-card .friend-form .actions { margin-top: var(--space-3); }


/* ── lifted from the reader's VIEWS layer ──────────────────────────────────
   Both of these are components, not screens: the reader's own comments call the
   topbar three screens' chrome, and say of the cards that "nothing below is
   scoped to a page". They live here so layer 5 can start empty.            */

/* --- topbar (dashboard / series / settings) --- */

.topbar {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  /* Epochs' topbar carries more than the reader's — home, the pill, four room
     buttons and settings — and six 44px targets plus a nowrap pill do not fit
     across a 320px phone. It wraps to a second line there instead, because the
     page never scrolls sideways (app/STYLE.md §3): the strip owns that axis. */
  flex-wrap: wrap;
  gap: var(--space-3);
  padding: calc(var(--space-3) + env(safe-area-inset-top)) var(--space-4) var(--space-3);
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--line);
}

/* What the §10 contract puts between the pill and the room buttons. Epochs has
   no heading in its topbar — every screen owns its own <h1>. */
.topbar .spacer { flex: 1 1 0; min-width: 0; }

.topbar h1 {
  flex: 1;
  margin: 0;
  font-size: var(--text-xl);
  font-weight: 650;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.topbar .back {
  font-size: var(--icon-lg);
  line-height: 1;
  padding: var(--space-1) var(--space-2) var(--space-1) 0;
}


/* --- cards, tiles and hues ------------------------------------------------
   Written for Settings first, and no longer only for it: the same cards carry
   the series page's management tiles, the dashboard's Add tile and the jobs
   page. Nothing below is scoped to a page — .setting and .tile ARE the
   component, and a page wrapper only sets the measure.                      */

/* Off the phone a card page has the dashboard's problem: a window-wide card is
   one card per screenful, and a hint line runs the whole width of a laptop.
   Same answer — the page stops being the window, and the cards become a grid
   inside it. One measure for every screen that is a page of cards. */
.settings,
.manage,
.jobs {
  max-width: 1080px;
  margin: 0 auto;
  padding: var(--space-2) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
}

/* Every card carries an identity hue (layer 1): its glyph, a border mixed a
   little toward that hue, and a shadow of it. The card reads --tint and the
   mappings below set it, so "this feature is off" costs exactly one custom
   property — re-point --tint and the whole card greys out, glyph included. */
.setting {
  --tint: var(--muted);
  /* Mixed toward TRANSPARENT, not toward --line: the hue is meant to be found
     at a glance, not to outline the card. A faint tinted edge over whatever is
     behind it reads as a card that has a colour, where a solid one reads as a
     card that is selected. */
  border-color: color-mix(in srgb, var(--tint) 30%, transparent);
  /* Down and to the right only. The spread pulls the shadow back inside its
     own offset, so nothing leaks above or left of the card and the whole grid
     is lit from one direction. */
  box-shadow: 2px 2px 6px -2px color-mix(in srgb, var(--tint) 20%, transparent);
}

/* EPOCHS: the reader's card names are gone; these are this game's. The rule
   above them is untouched — one custom property is still all a card's identity
   costs, and a card that is off or wrong still re-points it. */
.setting[data-card='rules'] { --tint: var(--hue-rules); }
.setting[data-card='lobby'] { --tint: var(--hue-lobby); }
.setting[data-card='players'] { --tint: var(--hue-players); }
.setting[data-card='identity'] { --tint: var(--hue-identity); }
.setting[data-card='relay'] { --tint: var(--hue-relay); }
.setting[data-card='sync'] { --tint: var(--hue-sync); }
.setting[data-card='chat'] { --tint: var(--hue-chat); }
.setting[data-card='danger'] { --tint: var(--danger); }

/* An integration that is off, or has never been configured, is not doing
   anything — so the card says nothing in colour either. It keeps its shadow:
   the shadow is how a card SITS on the page, and a card that stopped sitting
   there would read as disabled rather than as unconfigured. Only the hue goes,
   and --muted lights it instead. Same specificity as the mappings above, so
   this wins on source order alone: keep it after them. */
.setting[data-state='off'] { --tint: var(--muted); }

/* …and the third state: something is WRONG here — a series with teasers in it,
   a token the server refused. That is the one thing a card may say in colour
   beyond its own identity, and it says it in the app's second accent rather
   than in a hue of its own: --danger already means "this needs a hand" on every
   other surface, so a red card is read without a legend. Same specificity as
   the map above, so it too lives after it. */
.setting[data-state='bad'] { --tint: var(--danger); }

.setting h3 { margin: 0 0 var(--space-1); font-size: var(--text-base); }

/* The glyph that leads a card. The colour IS the glyph — no plate behind it,
   because a tinted square would be a second shape competing with the card it
   sits on, and the drawing is what should be recognised. The box is kept (and
   fixed) only so the heading beside it lands in the same place on every card.

   --muted is the fallback: a badge outside a tinted card is grey, rather than
   invalid-at-computed-value.

   Markup contract (STYLE.md, "Icon badge"):
     <span class="icon-badge[ lg]" aria-hidden="true">{icon(name)}</span>      */
.icon-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 32px;
  height: 32px;
  color: var(--tint, var(--muted));
  font-size: var(--icon-lg);
}

/* The closed tile's whole face. Big enough to be what the eye lands on first,
   which is the point of putting an icon on a setting at all. */
.icon-badge.lg {
  width: 72px;
  height: 72px;
  font-size: var(--icon-xl);
}

.card-head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-bottom: var(--space-2);
}

.card-head h3 { margin: 0; }

/* The four always-open cards, two-up off the phone: each is a short row of
   buttons or a three-line list, and four full-width stripes is a scroll for
   nothing.

   Two neighbours share a height (grid's default stretch) and their controls
   share a baseline — the dashboard's rule, for the dashboard's reason: what
   varies between these cards is how many lines a hint runs to, and that should
   vary invisibly. The card is a column and its LAST block takes the slack, so
   whatever ends a card — a button row, a preset strip, the custom-theme panel
   — sits at its foot. */
@media (min-width: 641px) {
  .settings-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: var(--space-4);
  }

  .settings-grid .setting {
    margin: 0;
    display: flex;
    flex-direction: column;
  }

  .settings-grid .setting > :last-child { margin-top: auto; }

  /* `margin-top: auto` REPLACES the action row's own 12px, and collapses to
     nothing the moment the card has no slack to give — which is exactly when
     the buttons end up against the line above them. The gap comes back as
     padding, which an auto margin cannot eat. */
  .settings-grid .setting > .actions:last-child { padding-top: var(--space-4); }
}

.section-title {
  margin: var(--space-6) 0 var(--space-3);
  font-size: var(--text-sm);
  font-weight: 650;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

/* The integrations: two across on a phone, three on a tablet, all five on a
   laptop.

   The counts are written out rather than left to auto-fit, and the reason is
   the span below: auto-fit collapses the empty track at the end of a row, and
   an item spanning several tracks fills them — so opening a tile would
   un-collapse the track and shrink every closed tile beside it. A tile must
   not change size because a neighbour opened. */
.tiles {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-4);
}

@media (min-width: 641px) {
  .tiles { grid-template-columns: repeat(3, 1fr); }
}

/* Five across only from 1000px. Below that the columns fall under ~180px, and
   a card one column wide has a single button in it — "Forget token" measures
   138px plus the card's 32px of padding, so a narrower column would put a
   button through the side of its own card. The breakpoint is set by the
   narrowest card's widest control, not by taste. */
@media (min-width: 1000px) {
  .tiles { grid-template-columns: repeat(5, 1fr); }
}

/* How wide each card opens, in columns. A card should be as wide as its
   CONTENTS need and no wider: a relay token is one field, and stretching that
   across a laptop is a line of empty card either side of a 200px input. So the
   width is a property of the card, declared here, and an open tile stays IN the
   row with its neighbours instead of taking it.

   The numbers are a judgement about how much room each card's contents DESERVE,
   not the width its buttons would like: an action strip is allowed to wrap onto
   a second line, and does in the narrower cards. The one hard rule is that a
   SINGLE control may never be wider than the card holding it — that is what
   sets the five-column breakpoint below, and an e2e test asserts no card body
   overflows at any width the grid offers. */
/* EPOCHS: how wide each card opens, in columns — the same judgement the reader
   made, over this game's cards. Add a line here when you add a card; a card
   with no line takes the three-column default below. */
.tile[data-card='rules'] { --span: 3; }
.tile[data-card='lobby'] { --span: 2; }
.tile[data-card='players'] { --span: 2; }
.tile[data-card='identity'] { --span: 2; }
.tile[data-card='relay'] { --span: 2; }
.tile[data-card='sync'] { --span: 2; }
.tile[data-card='chat'] { --span: 2; }

/* A tile is a card whose closed face IS the button: the whole square is the
   target, so there is no smaller second thing to aim at. The card gives its
   padding to that button, which is what lets the square BE square.

   It is also a flex column, so that a tile sharing a row with a taller one can
   hand the extra height DOWN to the button — the grid stretches every card in
   a row to the tallest (cards side by side are the same height, as everywhere
   else on this screen), and a card whose button stopped at its square would
   otherwise be a square sitting in a tall empty box. */
.tile {
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.tile .tile-head { display: flex; flex-direction: column; flex: 1 1 auto; }

/* …but an OPEN head keeps its natural height: the slack belongs under the body,
   not between the title and the controls it introduces. */
.tile[data-open='true'] .tile-head { flex: 0 0 auto; }

.tile .tile-open { flex: 1 1 auto; }

/* Beats `.setting h3`, whose 4px bottom margin would make the square oblong. */
.tile .tile-head { margin: 0; }

.tile-open {
  display: flex;
  width: 100%;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-4);
  border: 0;
  /* The head is a hover target inside a rounded card, so it carries the card's
     own corners — less the 1px border it sits inside, the way `.cover.full`
     does. `inherit` was wrong: it reads the <h3>'s radius, which is none, and
     the highlight then painted square corners over the card's round ones. An
     OPEN head is only the top of the card, so it rounds only there; a closed
     one IS the card and rounds all four (below). */
  border-radius: calc(var(--radius-xl) - 1px) calc(var(--radius-xl) - 1px) 0 0;
  background: none;
  /* Every declaration here is written for BOTH spellings of the head. A tile
     whose press opens a card is a <button>; one whose press is a route — the
     dashboard's Add tile — is an <a>, and it must sit in this box identically.
     So the colour is stated (an anchor would otherwise take --accent), the
     underline is refused, and `font: inherit` neutralises the button's UA font
     without changing the anchor's. */
  color: var(--fg);
  text-decoration: none;
  font: inherit;
  font-weight: 650;
  text-align: left;
  cursor: pointer;
}

/* A square poster, the glyph over its title — the dashboard card's shape at a
   quarter of the size, which is the whole point of the rhyme. This is the
   DEFAULT, not the `data-open="false"` case, because not every tile is a
   disclosure: the Year card is a toggle and carries no open state at all. */
.tile:not([data-open='true']) .tile-open {
  flex-direction: column;
  justify-content: center;
  aspect-ratio: 1;
  text-align: center;
  border-radius: calc(var(--radius-xl) - 1px);
}

/* Open: the head turns back into a line and the controls appear under it. The
   glyph steps down to the size it is on every always-open card — the big one
   is the CLOSED tile's poster face, and at one column wide it would eat the
   head it is supposed to lead. */
.tile[data-open='true'] .tile-open {
  flex-direction: row;
  justify-content: flex-start;
  text-align: left;
}

.tile[data-open='true'] .icon-badge.lg {
  width: 32px;
  height: 32px;
  font-size: var(--icon-lg);
}

/* …and the card widens to what it declared. On a phone there are two columns
   and no form is usable in one of them, so every open card takes the row; from
   the tablet up each takes its own width and its neighbours keep theirs. */
.tile[data-open='true'] { grid-column: span 2; }

@media (min-width: 641px) {
  .tile[data-open='true'] { grid-column: span var(--span, 3); }
}

/* A label/value row inside a one-column card has ~174px between its two halves
   and wants exactly that, so it tightens: the row must not fold, because a
   value on its own line reads as a second fact rather than as this one's. */
.tile .card-body .kv {
  gap: var(--space-2);
  font-size: var(--text-sm);
}

/* The body takes the slack the row's tallest card gives this one, and hands it
   to the gap ABOVE its last block — so the buttons of two cards sharing a row
   sit on one line, exactly as the always-open cards' do. */
.tile .card-body {
  padding: 0 var(--space-4) var(--space-4);
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
}

.tile .card-body > :last-child { margin-top: auto; }

/* …and the same gap, for the same reason, inside an open tile. */
.tile .card-body > .actions:last-child { padding-top: var(--space-4); }

.tile-title { color: var(--tint); }

/* A closed tile IS a button, so the CARD is what lights up — not the button
   inside it, which would paint a shape of its own within the card's edge. An
   open one is a panel with a form in it: only its head answers the pointer,
   because that is the part that closes it. */
.tile:not([data-open='true']):hover { background: color-mix(in srgb, var(--tint) 10%, var(--card)); }

.tile[data-open='true'] .tile-open:hover { background: color-mix(in srgb, var(--tint) 10%, transparent); }

/* The one card that can only take something away, and the only one below the
   integrations. A card and not a tile: what is inside is a single button, not
   a feature with settings to open. */
.settings .setting[data-card='danger'] { margin-top: var(--space-6); }

.app-version {
  margin: var(--space-4) 0 0;
  text-align: center;
  color: var(--muted);
  font-size: var(--text-xs);
}

/* A labelled text field inside a settings card (the sync token today, and the
   shape the next one copies). The same caption-above-control pair the login
   card uses: a muted caption, and a field that takes the card's width because
   what is pasted into it — a kv token — is several times the default input
   width. Scoped to `label[for]`, so the custom-theme's WRAPPING .color-field
   labels, which are a different shape entirely, are left alone. */
.setting label[for] {
  display: block;
  margin: var(--space-3) 0 var(--space-1);
  color: var(--muted);
  font-size: var(--text-sm);
}

.setting label[for] + input { width: 100%; }

/* A field with one control ON it — the chatroom's public key and its Copy
   button. The field takes the room that is left; `min-width: 0` is what lets
   it shrink inside the flex row instead of forcing the card wider than its
   declared width.

   Markup contract (STYLE.md, "Settings — cards, tiles and hues"):
     <label for="x">…</label>
     <div class="field-row"><input id="x" …><button class="btn ghost icon-only" …></button></div> */
.setting .field-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.setting .field-row input { flex: 1 1 auto; width: auto; min-width: 0; }
/* A field Save refused (main.js marks it; the toast says why). --danger is one of
   the two accent colours, so this is the only colour a refused field may take. */
.setting input[aria-invalid='true'] { border-color: var(--danger); }

/* A value the app fills in and the reader only ever copies (the chatroom's
   derived public key). It sits in the card's own colour rather than the field
   colour, so it reads as a value that happens to be selectable and not as a
   control — and in --mono, because it is a key, compared character by
   character or not at all. */
.setting input[readonly] {
  background: var(--card);
  color: var(--muted);
  font-family: var(--mono);
  font-size: var(--text-sm);
}

/* The banner INSIDE a settings card: the same component, same tints, same "a
   standing condition, not an outcome" rule — but a card is not the page, so it
   trades the full-bleed edge for a block that sits in the card's flow. */
.setting .banner {
  /* Space on BOTH sides: it sits between the key field above it and the format
     hint below, and `.hint` carries no top margin of its own. Adjacent margins
     collapse in the card's block flow, so a label after one is unaffected. */
  margin: var(--space-2) 0;
  padding: var(--space-2) var(--space-3);
  border-bottom: 0;
  border-radius: var(--radius-md);
}

.presets { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }

.presets .btn { min-width: 56px; padding: var(--space-2) var(--space-3); }

.presets input { width: 88px; }


/* ── 4 · DENSE ──────────────────────────────────────────────────────────── */

/* Everything in layer 3 is sized for a thumb. Wrap a management screen in
   .dense to get the pointer-sized variant: shorter controls, smaller type, and
   the table rules, which exist ONLY here.                                   */

.dense { font-size: var(--text-md); }
.dense .btn { min-height: var(--tap-dense); padding: var(--space-2) var(--space-4); font-size: var(--text-md); }
.dense .btn.small { min-height: 28px; padding: var(--space-1) var(--space-3); font-size: var(--text-xs); }
.dense .btn[disabled] { opacity: 0.4; cursor: default; }
.dense input, .dense select { min-height: var(--tap-dense); font-size: var(--text-md); }
.dense .card h2 { margin-bottom: var(--space-3); font-size: var(--text-base); }
.dense code { font-family: var(--mono); font-size: var(--text-xs); }

/* Parameter form: a wrapping row of stacked label/field pairs, bottom-aligned
   so the submit button sits on the same baseline as the inputs. `.check` is the
   one field whose control belongs BESIDE its label, not under it.

   Promoted OUT of .dense (layer 3, not 4): the series page's management tiles
   are made of the same forms and are a READER screen, so the layout is shared
   and only the sizing differs — 44px controls out here, `--tap-dense` inside
   .dense, which is exactly what the density switch is for. */
form.inline { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: end; }

form.inline label {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  font-size: var(--text-xs);
  color: var(--muted);
}

form.inline label.check {
  flex-direction: row;
  align-items: center;
  gap: var(--space-1);
  min-height: var(--tap);
}

.dense form.inline label.check { min-height: var(--tap-dense); }

form.inline label.check input { min-height: 0; width: auto; }
form.inline input.num { width: 90px; }
form.inline input.wide { min-width: min(320px, 100%); }

.dense table { width: 100%; border-collapse: collapse; font-size: var(--text-md); }

/* A count that wants looking at (gaps, teasers) rather than one that is fine. */
.dense .warn-cell { color: var(--warn); font-weight: 600; }

/* …and the cell for a count that is not merely worth a look but WRONG. Teasers
   are chapters that should not be in the library at all, which is why the
   series page's Teasers tile goes red rather than grey for them (§1,
   data-state='bad'); the column says the same thing in the same colour. */
.dense .danger-cell { color: var(--danger); font-weight: 600; }

.dense th {
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--line);
  color: var(--muted);
  font-weight: 500;
  font-size: var(--text-xs);
  text-align: left;
}

.dense td { padding: var(--space-3); border-bottom: 1px solid var(--line); vertical-align: middle; }
.dense tr.clickable { cursor: pointer; }
.dense tr.clickable:hover td { background: var(--card); }

.dense pre.log {
  margin: 0;
  padding: var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--bg);
  font-family: var(--mono);
  font-size: var(--text-xs);
  line-height: 1.5;
  max-height: 360px;
  overflow: auto;
  white-space: pre-wrap;
}

/* A table that must stay tabular (few columns) scrolls inside its card rather
   than pushing the page sideways. Harmless on desktop (no overflow to scroll). */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }

/* Phone: a wide entity list can't stay tabular, so `table.stack` collapses into
   one labelled card per row — each cell announces its column through
   data-label, the header row is hidden, and the row stays the tap target. */
@media (max-width: 640px) {
  .dense table.stack,
  .dense table.stack tbody,
  .dense table.stack tr,
  .dense table.stack td { display: block; width: 100%; }

  /* The header row — a <tr> of <th>s, with or without an explicit <thead>.
     :has(th) rather than tr:first-child: where a real <thead> exists, the
     first row of <tbody> is a first-child too and would vanish with it. */
  .dense table.stack thead { display: none; }
  .dense table.stack tr:has(th) { display: none; }

  .dense table.stack tr {
    margin-bottom: var(--space-3);
    padding: var(--space-1) 0;
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    background: var(--bg);
  }

  .dense table.stack tr.clickable:hover td { background: transparent; }

  .dense table.stack td {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-4);
    padding: var(--space-2) var(--space-3);
    border: none;
    text-align: right;
  }

  .dense table.stack td::before {
    content: attr(data-label);
    flex: 0 0 auto;
    color: var(--muted);
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-align: left;
    text-transform: uppercase;
  }

  .dense table.stack td:empty { display: none; } /* e.g. a no-gaps cell */

  /* A cell whose data is still being scanned (e.g. the Teasers column before
     the report lands) shimmers on a wide table, but a shimmer repeated on every
     stacked phone row is noise, not information — hide it and let the value
     appear when it exists. */
  .dense table.stack td.wait { display: none; }

  /* First cell = the title/id headline: full width, no label. */
  .dense table.stack td:first-child {
    display: block;
    padding: var(--space-3) var(--space-3) var(--space-2);
    font-size: var(--text-base);
    line-height: 1.35;
    text-align: left;
  }

  /* Action cell: buttons on their own separated strip, left-aligned, wrapping.
     Self-contained — the flex box comes from the `td` rule above, not from the
     .actions component (which excludes table cells). */
  .dense table.stack td.actions {
    justify-content: flex-start;
    flex-wrap: wrap;
    gap: var(--space-2);
    padding-top: var(--space-3);
    border-top: 1px solid var(--line);
  }

  .dense table.stack td:first-child::before,
  .dense table.stack td.actions::before,
  .dense table.stack td[colspan]::before { content: none; }

  /* Expanded detail row (colspan): let its content use the full width. */
  .dense table.stack td[colspan] { display: block; padding: var(--space-2); }
}


/* ── 5 · VIEWS — Epochs ─────────────────────────────────────────────────────
   Empty on purpose. Layout that belongs to exactly ONE screen goes here and
   nowhere else: the timeline row and its n+1 slot buttons, the artwork card and
   its reveal, the countdown ring, the table, the home and lobby screens, the QR
   sheet, the stats sheet. Anything a second screen wants belongs in layer 3,
   with a markup contract written into STYLE.md beside it.

   Order matters: this layer is last, so a view rule beats the component it
   varies without needing a stronger selector to do it.                     */
