/* cache-bust v2: fresh content hash, paired with the _worker.js fix that 404s a
   missing asset instead of serving the HTML shell — so this new CSS URL cannot
   be poisoned in Cloudflare's edge cache the way the prior hashes were. */
@font-face {
    font-family: "osl";
    src: url("/fonts/osl.woff") format("woff");
    font-weight: normal;
    font-style: normal;
    font-display: block;
}
@font-face {
    font-family: "osr";
    src: url("/fonts/osr.woff") format("woff");
    font-weight: normal;
    font-style: normal;
    font-display: block;
}

/* Reader design tokens (Slice 0 — minimal; Slice 2 expands). */
:root {
    --canvas:         #211c16;  /* warm charcoal — lifted off near-black (was #15110d) */
    --bar-tint:       rgba(33,28,22,.72);  /* translucent --canvas for the frosted top/bottom bars (backdrop-blur) */
    --atmo-tint:      #211c16;  /* default = Origins era; overwritten by atmosphere::apply_tint on scroll */
    --canvas-deep:    #181410;
    --page-bg:        #15110d;  /* the reader text-column background (was hard-coded) */
    --ink:            #d8d0bf;
    --ink-soft:       #b8b09e;  /* secondary text — a step below --ink, above --ink-dim.
                                   Used by the map (loading/count/legend/popover-aside);
                                   was referenced but never defined, so those elements
                                   silently inherited full --ink instead of softening. */
    --ink-dim:        #998f7d;
    --ink-faint:      #7a7160;
    --ink-veryfaint:  #3e382d;
    --rule:           #342d22;
    --rule-soft:      #2b2519;
    --head:           #b8a890;
    --oxblood:        #a82828;
    --oxblood-deep:   #7a1e1e;
    --gilt:           #b39250;  /* softened from #c9a24a — less saturated/bright so it stops stinging */
    --gilt-deep:      #8a6e2e;
    --moss:           #7a9a6a;
    --moss-deep:      #4e6840;
    --steel:          #6c8aa0;
    --steel-deep:     #3e5668;
    --violet:         #8a7298;
    --violet-deep:    #5a4a68;
    --iron:           #4a4a4a;
    --iron-deep:      #2a2a2a;
    --sepia:          #8c6a3a;
    --sepia-deep:     #5e4622;
    --serif-body:    "osr", Georgia, "Iowan Old Style", "Hoefler Text", serif;
    --serif-display: "osl", "osr", Georgia, serif;
    /* Juxtaposition card text. Was a system sans (typeset A/B test), but the app
       now uses osl/osr everywhere — repointed to the osr serif stack so the lens
       matches the reader. Token name kept so its three call sites need no edit. */
    --jx-sans:       var(--serif-body);
    /* Single source of truth for the fixed topbar's height. The topbar, the
       content spacer (.topbar-pad), and the lens overlay all use this so they
       line up identically on every page — no per-page magic numbers. */
    --topbar-h: 48px;
    /* The reading measure, and the width of the fixed topbar on desktop —
       ONE fact, because the operator's ask on 2026-08-09 was literally "about
       as wide as the center status bar". The bar spans this centred on the
       viewport (`50% ∓ half`), and the chronicle column caps at it centred on
       the page; the two axes coincide because the chronicle's side panes are
       deliberately symmetric (see the note on .chron-page > .chron-toc). Equal
       widths on a shared axis therefore means the bar terminates exactly where
       the prose does, at every viewport, with no constant to keep in sync.
       ⚠ Written twice, these silently drift and the alignment is gone with
       nothing in the diff to show it — the same failure mode as the measure
       itself. styles_lint asserts BOTH rules read this token, not a literal. */
    --reading-measure: 800px;
    /* Height of the Juxtaposition lane's sticky column header (.jx-lh). Same
       single-source-of-truth idea as --topbar-h: the header sets it as a
       min-height and .jx-card offsets its scroll anchor by it, so they cannot
       drift apart. See the arithmetic on .jx-lh. */
    --jx-lh-h: 38px;
    --overlay-bg:     rgba(24,20,16,.9);  /* translucent map legend/caption panels */
    /* Contributor cork-board + post-it palette (corkboard.rs). The theme
       sticky reuses canvas/ink vars so it follows the active theme for free. */
    --cork:       #6b4f2e;   /* base cork brown */
    --cork-dark:  #5a4227;
    --cork-frame: #4a3620;   /* wooden frame (reads as dark wood on both themes) */
    --sticky-theme-bg:  var(--canvas-deep);
    --sticky-theme-ink: var(--ink);
    --sticky-yellow: #e8d074;  --sticky-yellow-ink: #3a3110;
    --sticky-pink:   #e0a0ad;  --sticky-pink-ink:   #3f1c25;
    --sticky-blue:   #94b7cc;  --sticky-blue-ink:   #152a36;
    --sticky-green:  #a7c58f;  --sticky-green-ink:  #20330f;
}

/* ============================================================
   THEME: paper (light). The dark tokens above are the base.
   Paper applies when the OS prefers light AND the user hasn't
   forced a theme, OR when the user forces light. Forced dark
   re-asserts the base. Accents are remapped here: gilt/moss/steel and
   violet/iron/sepia darken to their -deep values so they read on cream (the
   cards set `--accent: var(--gilt)` inline, so this remap reaches them for
   free). kinds.rs maps SEVEN kinds to seven tokens and only three were once
   restated here, so Leadership (--violet, 3.60:1) and Dissolution (--sepia,
   4.20:1) failed 4.5:1 as a 13px bold .card-ornament on --canvas. --oxblood is
   the ONE accent deliberately left alone: it already clears on cream (9.46:1)
   AND it doubles as the app-wide danger ink (.auth-err, .fe-cancel,
   .delete-card, .rr-retire, the map's oxblood spines), so darkening it here
   would repaint a dozen unrelated surfaces to fix nothing.
   The forced-light and media-light selectors tie at specificity (0,2,0);
   forced-light wins by source order, and the :not([data-theme="dark"])
   guard deactivates the media block under forced dark.
   ============================================================ */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --canvas:        #f3ecdd;
    --bar-tint:      rgba(243,236,221,.72);
    --atmo-tint:     #f3ecdd;
    --canvas-deep:   #ece3d0;
    --ink:           #2b2722;
    --ink-soft:      #4d473c;
    --ink-dim:       #6b6354;
    --ink-faint:     #8a8170;
    --ink-veryfaint: #cdc4b0;
    --rule:          #ddd4c0;
    --rule-soft:     #e7dfcd;
    --head:          #4a4032;
    /* hybrid accent remap (light only) — all six of the seven kind accents that
       need darkening; --oxblood is the deliberate exception (see the header). */
    --gilt:          #8a6e2e;
    --moss:          #4e6840;
    --steel:         #3e5668;
    --violet:        #5a4a68;
    --iron:          #2a2a2a;
    --sepia:         #5e4622;
    --page-bg:       #f7f2e8;
    --overlay-bg:    rgba(247,242,232,.92);
    --cork:      #c9a878;
    --cork-dark: #bd9a66;
  }
}

/* Forced light — same paper tokens, regardless of OS. */
:root[data-theme="light"] {
  --canvas:        #f3ecdd;
  --bar-tint:      rgba(243,236,221,.72);
  --atmo-tint:     #f3ecdd;
  --canvas-deep:   #ece3d0;
  --ink:           #2b2722;
  --ink-soft:      #4d473c;
  --ink-dim:       #6b6354;
  --ink-faint:     #8a8170;
  --ink-veryfaint: #cdc4b0;
  --rule:          #ddd4c0;
  --rule-soft:     #e7dfcd;
  --head:          #4a4032;
  --gilt:          #8a6e2e;
  --moss:          #4e6840;
  --steel:         #3e5668;
  --violet:        #5a4a68;
  --iron:          #2a2a2a;
  --sepia:         #5e4622;
  --page-bg:       #f7f2e8;
  --overlay-bg:    rgba(247,242,232,.92);
  --cork:      #c9a878;
  --cork-dark: #bd9a66;
}

/* Light-theme only: a teeny warm accent on the top bar and the side scale, so
   they read as gently distinct from the flat canvas. Kept VERY subtle (gilt at
   ~5–9% alpha) — a whisper, not a band. Scoped to [data-theme="light"] so the
   dark theme is untouched. */
:root[data-theme="light"] .topbar {
    /* Faint gilt wash over the flat frosted tint (blur inherited from the base
       .topbar rule). */
    background:
        linear-gradient(to bottom, rgba(138,110,46,.05), transparent 70%),
        var(--bar-tint);
}
/* Light-theme only: darken the wide shaded band the scale sits on so it reads as
   a distinct panel instead of fading into the page canvas. The fill runs the full
   width of the scale — solid out to the divider line (~44px), then a sharp 3px
   cutover to transparent right at the divider — so the whole scale column reads as
   one panel rather than a narrow strip hugging the ticks. */
:root[data-theme="light"] .scrubber {
    background: linear-gradient(to left, #efe7d6 44px, transparent 47px);
}
:root[data-theme="light"] .scrub-rail {
    /* Nudge the rail off neutral toward a faint gilt so the scale picks up the
       same warm hint as the bar. */
    background: var(--ink-faint);
}
/* Light-theme only: darken the scale marks so the ticks and year labels read
   crisply against the bright canvas instead of fading into it. Dark theme keeps
   its own (lighter) inks. */
:root[data-theme="light"] .scrub-tick       { background: var(--ink-faint); }
:root[data-theme="light"] .scrub-tick-major { background: var(--ink-dim); }
:root[data-theme="light"] .scrub-label      { color: var(--ink-soft); }

/* Forced dark — re-assert the base so it wins over the media query. */
:root[data-theme="dark"] {
  --canvas:        #211c16;
  --atmo-tint:     #211c16;
  --canvas-deep:   #181410;
  --ink:           #d8d0bf;
  --ink-soft:      #b8b09e;
  --ink-dim:       #998f7d;
  --ink-faint:     #7a7160;
  --ink-veryfaint: #3e382d;
  --rule:          #342d22;
  --rule-soft:     #2b2519;
  --head:          #b8a890;
  --gilt:          #b39250;
  --moss:          #7a9a6a;
  --steel:         #6c8aa0;
  /* Mirrors the light blocks' accent list exactly — this block's whole job is to
     restate every token light remaps, so the two lists must stay the same length. */
  --violet:        #8a7298;
  --iron:          #4a4a4a;
  --sepia:         #8c6a3a;
  --page-bg:       #15110d;
  --overlay-bg:    rgba(24,20,16,.9);
  --cork:      #6b4f2e;
  --cork-dark: #5a4227;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

/* No MARKUP italics in the reader — but the designer's own italics must live.
   This was `* { font-style: normal !important; … }`, and the !important was a
   silent 24-casualty bug: importance beats specificity and this file declares no
   @layer, so EVERY `font-style: italic` below it was dead — including
   `.rr-colony-inferred` (the hedge that tells a contributor a date was INFERRED,
   not sourced), the map's era name, the cartouche prose, `.map-quiet` and
   `.detail-error`. The intent was only ever to neutralise italics arriving in
   MARKUP, so scope it to the elements the UA slants by default and drop the
   !important, leaving every rule below free to opt back in.
   (`<i>` is also used here as a bare knob/tick box — .map-layers-track i,
   .map-pos i — which carry no text, so this costs them nothing.) */
em, i, cite, address, dfn, var { font-style: normal; }

/* Themed scrollbars. The OS default is a bright white bar that glares against
   the sepia/charcoal page — replace it with the engraved-rule tone, which
   flips light/dark with the rest of the theme via --rule. Track stays
   transparent; only the thumb is drawn. */
* {
    scrollbar-width: thin;
    scrollbar-color: var(--rule) transparent;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
    background: var(--rule);
    border-radius: 6px;
    /* inset the thumb off the track edges so it reads as a slim pill */
    border: 2px solid transparent;
    background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--ink-dim); background-clip: content-box; }

html, body {
    background: var(--atmo-tint);
    color: var(--ink);
    font-family: var(--serif-body);
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
    min-height: 100vh;
    min-height: 100dvh;
    touch-action: pan-y;
    /* Stop top rubber-band overscroll so the content can't bounce away from
       the fixed header and reveal a gap underneath it. */
    overscroll-behavior-y: none;
}
/* The era cross-fade (whole-viewport background animating on every era change
   while scrolling) is the most motion-prominent effect in the stream; gate it
   so a "Reduce Motion" user gets an instant swap, not a 300ms fade. */
@media (prefers-reduced-motion: no-preference) {
    html, body { transition: background 300ms ease; }
}

/* Auth pages (login / signup / signup-verify) */
.auth-shell {
    max-width: 420px;
    margin: 0 auto;
    padding: 60px 24px 40px;
    color: var(--ink);
}
.auth-title {
    font-size: 30px;
    color: var(--head);
    font-weight: 600;
    letter-spacing: -0.01em;
    margin-bottom: 8px;
}
.auth-sub {
    color: var(--ink-dim);
    margin-bottom: 28px;
    font-size: 16px;
}
.auth-form {
    display: flex;
    flex-direction: column;
    gap: 14px;
}
.auth-input {
    padding: 14px 16px;
    background: var(--rule-soft);
    border: 1px solid var(--rule);
    border-radius: 10px;
    color: var(--ink);
    font-size: 18px;
    font-family: inherit;
    letter-spacing: 0.04em;
}
/* The placeholder inherited full --ink, so the sample key (1234-5678-9012) read
   like a prepopulated value. --ink-faint alone was still too readable on the
   input bg (it's the color for visible secondary UI), so drop the opacity to
   land it clearly in "hint" territory — readable but obviously not entered text.
   Opacity is theme-safe: it fades --ink-faint proportionally in both light and
   dark. (opacity replaces Firefox's default placeholder dimming rather than
   pinning it to 1.) */
.auth-input::placeholder {
    color: var(--ink-faint);
    opacity: 0.55;
}
.auth-input:focus {
    outline: none;
    border-color: var(--head);
}
.auth-btn {
    padding: 13px 18px;
    background: var(--head);
    color: var(--canvas);
    border: none;
    border-radius: 10px;
    font-size: 16px;
    font-family: inherit;
    font-weight: 600;
    cursor: pointer;
    letter-spacing: 0.02em;
}
.auth-btn[disabled] { opacity: 0.5; cursor: default; }
.auth-err {
    margin-top: 14px;
    color: var(--oxblood);
    background: var(--rule-soft);
    padding: 10px 14px;
    border-radius: 8px;
    border-left: 3px solid var(--oxblood);
    font-size: 14px;
}

/* /add — the new-event form. A roomier auth-shell with labeled fields. */
.add-shell { max-width: 560px; }
.add-field {
    display: flex;
    flex-direction: column;
    gap: 6px;
}
.add-label {
    font-size: 13px;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--ink-dim);
    text-transform: uppercase;
}
.add-textarea {
    resize: vertical;
    min-height: 90px;
    line-height: 1.5;
    letter-spacing: normal;
}
/* Month / Day / Order side by side. Each input flexes equally; the number
   spinners stay usable at this width. */
.add-row { display: flex; gap: 10px; }
.add-row .auth-input { flex: 1; min-width: 0; letter-spacing: normal; }
.auth-altlink {
    margin-top: 26px;
    color: var(--ink-faint);
    font-size: 14px;
}
.auth-altlink a {
    color: var(--head);
    text-decoration: underline;
}
.auth-label {
    display: flex;
    flex-direction: column;
    gap: 6px;
    color: var(--ink-dim);
    font-size: 13px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}
.auth-label .auth-input {
    margin-top: 4px;
    text-transform: none;
    letter-spacing: 0.01em;
    color: var(--ink);
    font-size: 16px;
}
.auth-textarea {
    resize: vertical;
    min-height: 72px;
    font-family: inherit;
}
.auth-fieldset {
    border: 1px solid var(--rule);
    border-radius: 10px;
    padding: 12px 16px 14px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    background: var(--rule-soft);
}
.auth-fieldset legend {
    padding: 0 6px;
}
.auth-radio {
    display: flex;
    align-items: center;
    gap: 8px;
    color: var(--ink);
    font-size: 16px;
    letter-spacing: 0;
    text-transform: none;
    cursor: pointer;
}
.auth-summary {
    margin: 12px 0 22px;
    padding: 16px 18px;
    background: var(--rule-soft);
    border: 1px solid var(--rule);
    border-radius: 10px;
    display: grid;
    grid-template-columns: max-content 1fr;
    gap: 10px 18px;
    color: var(--ink);
    font-size: 16px;
}
.auth-summary dt {
    color: var(--ink-faint);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-size: 12px;
    align-self: center;
}
.auth-summary dd { margin: 0; }
.auth-actions {
    display: flex;
    gap: 10px;
    margin-top: 12px;
}
.admin-shell {
    max-width: 720px;
    margin: 0 auto;
    padding: 40px 24px;
}
.admin-title {
    font-size: 24px;
    color: var(--head);
    margin-bottom: 22px;
}
.admin-empty {
    color: var(--ink-dim);
    padding: 24px 0;
}
.signup-req-list {
    list-style: none;
}
.signup-req {
    padding: 16px 18px;
    border: 1px solid var(--rule);
    border-radius: 10px;
    background: var(--rule-soft);
    margin-bottom: 10px;
}
.signup-req-row {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 14px;
    color: var(--ink);
}
.signup-req-email {
    color: var(--ink-dim);
    font-size: 14px;
}
.signup-req-fields {
    margin: 10px 0 8px;
    display: grid;
    grid-template-columns: max-content 1fr;
    gap: 4px 14px;
    font-size: 14px;
    color: var(--ink);
}
.signup-req-fields dt {
    color: var(--ink-faint);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-size: 11px;
    align-self: center;
}
.signup-req-fields dd { margin: 0; }
.signup-req-note {
    color: var(--ink-dim);
    font-style: normal;
}
.signup-req-meta {
    margin-top: 4px;
    font-size: 12px;
    color: var(--ink-faint);
    letter-spacing: 0.04em;
}
.signup-req-actions {
    margin-top: 12px;
    display: flex;
    gap: 8px;
}
/* Overrides .auth-btn on the same element. No !important needed: both are
   single-class (0,1,0) and this rule is ~190 lines LATER, so source order
   already decides it. The three !importants that used to be here were cargo. */
.auth-btn-secondary {
    background: transparent;
    color: var(--ink-dim);
    border: 1px solid var(--rule);
}

/* Reader top bar (fixed) */
.topbar {
    position: fixed;
    top: 0; left: 0; right: 0;
    /* Grow the bar up under the status bar / notch on viewport-fit=cover
       devices; the frosted surface extends into the inset while the
       bottom-aligned controls stay clear of it. env() = 0 in a browser tab. */
    height: calc(var(--topbar-h) + env(safe-area-inset-top, 0px));
    /* Bottom-aligned controls; bar height is --topbar-h (>=48px so the 40px
       house glyph clears the top edge). */
    padding: env(safe-area-inset-top, 0px) 16px 8px;
    display: flex;
    align-items: flex-end;
    gap: 12px;
    /* Frosted bar: a flat translucent canvas tint over a backdrop blur, so
       content scrolling under is BLURRED, not sharply see-through. Flat (not a
       gradient) on purpose — a tint that fades to transparent at the edge let
       content show through there; uniform tint + uniform blur keeps the whole
       bar consistent. No element mask (would fade the bottom-aligned buttons). */
    background: var(--bar-tint);
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
    /* Above the lens sheets so the bar stays visible/tappable while a lens is
       open and Home dismisses it.
       ⚠ 45, not 20, and the exact value matters: backdrop-filter above makes
       this bar a STACKING CONTEXT, so every popover hanging off it (the
       bookmarks dropdown, the Display panel) is capped at the BAR's z-index no
       matter its own. At 20 the .map-sheet / .search-overlay (both z 40) and
       the chronicle drawer (41) painted straight over those panels — the bar
       still looked fine because those sheets start below it, so only the parts
       hanging DOWN vanished. 45 clears all three while staying under the card
       settings popover (.meta-backdrop, 60) and the world lightbox (1200),
       which are meant to cover everything. */
    z-index: 45;
    font-size: 13px;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--ink-faint);
    touch-action: manipulation;
}
/* Desktop: cap the bar (background included) to the 800px content column and
   center it, so the bar terminates at the reading width instead of running
   edge-to-edge. Reset padding to the mobile 16px now that the bar itself is
   constrained. Mobile keeps full-width with 16px padding. */
@media (min-width: 760px) {
    .topbar {
        /* Bar spans the reading column with equal margins — right edge at the
           column edge, matching the left. The rail sits ~56px further out in
           the margin; the bar stops at the column, not the rail.
           The half-width comes from --reading-measure rather than the literal
           400px it replaced, so the chronicle measure and this bar cannot drift
           apart — see the token's note. */
        left: max(0px, calc(50% - var(--reading-measure) / 2));
        right: max(0px, calc(50% - var(--reading-measure) / 2));
        margin: 0;
        max-width: none;
        padding-left: 16px;
        padding-right: 16px;
    }
}
/* Empty flex spacer that replaced the year marker (removed for now). Just
   grows to push admin/back/bookmarks to the right edge. (`.topbar-year`, the
   marker's own rule, outlived it by a year and was deleted 2026-07-25 — no Rust
   emits the class.) */
/* min-width: 0 is what lets the progress line below truncate instead of
   widening the bar. The spacer is the FLEX ITEM, and a flex item's default
   `min-width: auto` is its content's min-content width — which, for nowrap
   text, is the whole string. Empty on every other page, so it costs nothing
   there. */
.topbar-spacer { flex: 1; min-width: 0; }
/* Reading progress — /chronicle is the only page that fills the spacer.
   `display: block` is required, not decoration: text-overflow only clips a
   block-level box, so as an inline span this would overflow rather than
   ellipsize. */
.topbar-progress {
    display: block;
    font-family: var(--serif-body);
    font-size: 11px; color: var(--ink-faint);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.topbar-btn {
    color: var(--ink-dim);
    background: var(--canvas-deep);
    border: 1px solid var(--rule);
    /* Larger label (15px) + a bit wider; vertical padding trimmed to 8px so the
       box stays on the shared ~33px height metric (15 + 8+8 + 2 border). */
    padding: 8px 16px;
    border-radius: 9px;
    font-size: 15px;
    /* inline-flex + line-height:1 so this anchor's box height equals the
       bookmark/seg buttons exactly, all on one shared metric. */
    display: inline-flex; align-items: center;
    line-height: 1;
    text-decoration: none;
}
.topbar-btn-admin { color: var(--head); }
/* Vfy/Adm/Theme are icon-only buttons at EVERY size (no text labels): the SVG
   icon shows always (Vfy ✓, Adm ⚙, Theme sun/moon/hemisphere), the text label
   is hidden always. Compact box — far narrower than the text buttons — matched
   in height to the other controls, all three reading as one icon set. */
/* Theme toggle now renders inline SVG (`.tb-icon`) like the other icon buttons,
   so it shares their compact box — no font-size/glyph-nudge needed. (The old
   font-glyph sun depended on U+FE0E to suppress the emoji; phones ignored it.) */
.tb-theme { padding: 2.5px 7px; min-width: 0; justify-content: center; }
/* The icon inherits the button color via currentColor and centers as a vector —
   no glyph side-bearing, which is what made the earlier ⚙/✓ font glyphs drift. */
.tb-icon { display: block; width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
/* Flat-FILLED icons (the Icons/-set style the switcher established): filled
   silhouettes whose knocked-out detail (lens hole, gear hub, check, ferrule)
   needs the evenodd fill-rule. Overrides the stroked .tb-icon defaults. */
/* Same upsize the switcher got: the filled glyphs' viewBoxes hug their ink,
   and the 26px box (up from the stroked set's 20px) + 2.5px vertical padding
   holds the shared 33px control height (2.5 + 26 + 2.5 + 2 border). */
.tb-icon.tb-fill { width: 26px; height: 26px; fill: currentColor; stroke: none; fill-rule: evenodd; }
/* A solid silhouette lays down ~3-4x the ink of the old 1.7px strokes, so at
   the SAME color token the filled glyphs READ darker — in both themes (user-
   confirmed). Blend the idle fill toward the canvas to restore the old
   perceived shade (applies to the filled back-swoosh and dropdown trash
   too). Active states — the gilt search/edit toggles — keep full strength
   so "on" still pops. */
.tb-icon.tb-fill, .bk-swoosh, .bm-trash, .bm-glyph { opacity: 0.65; }
/* Gilt active toggles stay full. */
.active .tb-icon.tb-fill { opacity: 1; }
/* Compact icon-button box for Adm (no text label → no wide text padding).
   2.5px vertical padding holds the shared 33px box (2.5 + 26 + 2.5 + 2 border = 33). */
.tb-admin { padding: 2.5px 7px; min-width: 0; justify-content: center; }

/* Lens switcher [ Lanes · Map ] — one segmented pill matching the other
   controls' fill/border; the two segments share it with a hairline divider. */
.topbar-switcher {
    display: flex; align-items: center;
    background: var(--canvas-deep);
    border: 1px solid var(--rule);
    border-radius: 8px;
    overflow: hidden;
}
.tb-seg {
    /* Icon segments (the text labels Sum·Chro·Lns·Map became glyphs drawn in
       the Icons/-set style). The glyphs carry their own ~3px margins inside
       the 32-grid, so the box is upsized to 26px (20px read too small next to
       the old 18px text); 2.5px vertical padding holds the shared 33px
       switcher height (2.5 + 26 + 2.5 + 2 border). */
    color: var(--ink-faint);
    background: none; border: none; cursor: pointer;
    padding: 2.5px 11px;
    display: flex; align-items: center; justify-content: center;
}
/* The switcher glyphs are FILLED silhouettes (matching the flat Icons/ set),
   unlike the stroked .tb-icon default — flip fill/stroke and use evenodd so
   their knocked-out subpaths (text lines, event marks, place-hole) stay open. */
.tb-seg .tb-icon { width: 26px; height: 26px; fill: currentColor; stroke: none; fill-rule: evenodd; }
.tb-seg:hover { color: var(--ink-dim); }
.tb-seg.active { color: var(--gilt); }
/* Hairline divider between the two segments. */
.topbar-switcher .tb-seg + .tb-seg { border-left: 1px solid var(--rule); }
.topbar-pad { height: calc(var(--topbar-h) + env(safe-area-inset-top, 0px)); }

/* Tap feedback. Mobile Safari/Chrome paint a grey rectangle over a tapped
   element by default (-webkit-tap-highlight-color); it's ugly over our pill
   buttons and chevrons. Suppress it on all interactive controls and replace it
   with a press beat (dim + slight shrink). .chip already has its own :active
   scale (above), so it's excluded from the shrink here to avoid stacking two
   transforms.

   Timing is ASYMMETRIC so a quick tap is still visible: the press snaps in fast
   (the :active rule's 40ms transition), but the RELEASE fades out slowly (the
   base rule's 260ms), so the dim lingers and eases away even when finger-down
   and finger-up are nearly simultaneous. A symmetric fast transition made the
   beat almost invisible. */
button, a, [role="button"], .tb-seg, .chip {
    -webkit-tap-highlight-color: transparent;
}
.tb-seg, .topbar-btn, .tb-theme, .tb-admin, .topbar-back {
    transition: opacity 260ms ease, transform 260ms ease;
}
.tb-seg:active, .topbar-btn:active,
.tb-theme:active, .tb-admin:active, .topbar-back:active {
    opacity: 0.5;
    transform: scale(0.94);
    transition: opacity 40ms ease, transform 40ms ease;
}


/* Cards (solo events) */
.card {
    transition: opacity 220ms ease;
    padding: 18px 16px 22px 18px;
    border-left: 4px solid var(--accent);
    position: relative;
    display: flex;
    flex-direction: column;
    /* Programmatic jumps (bookmark flag / dropdown / back / return-anchor) use
       scroll_into_view(block:Start); reserve the fixed topbar height + a small
       gap so the card's year marker lands just below the bar, not tucked under
       it. COMPUTED, never hardcoded: the bar is
       `calc(var(--topbar-h) + env(safe-area-inset-top))` and --topbar-h is 48px
       on desktop but 57px under the 599px block — so the old flat `56px` landed
       a jumped-to card ~48px UNDER the bar on a notched phone. Every other
       offset in this file already uses this same calc; .cluster and .sub-card
       carry it verbatim (all three are scroll targets). */
    scroll-margin-top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) + 8px);
    /* Suppress iOS long-press text selection across the card so the
     * bookmark corner's gesture doesn't trigger native selection of
     * adjacent prose. */
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
}
.card-ornament {
    position: absolute;
    left: 0;
    top: 28px;
    background: var(--canvas);
    color: var(--accent);
    font-size: 13px;
    line-height: 1;
    padding: 6px 3px;
    text-align: center;
    font-weight: bold;
    transform: translate(-50%, -50%);
}
.card-year {
    /* Fluid so a 4-digit year never overflows the ~250px inner column at 320px;
       upper bound unchanged at 60px on wide screens. */
    font-size: clamp(44px, 15vw, 60px);
    line-height: 0.95;
    letter-spacing: -0.02em;
    color: var(--accent);
    font-feature-settings: "lnum";
    font-family: var(--serif-display);
}
.card-title {
    margin-top: 6px;
    /* 18px keeps the headline clearly LARGER than the 17px body so it reads as
       a title, not body text (the uppercase + heavier osr cut already separate
       them; size reinforces the hierarchy). */
    font-size: 18px;
    color: var(--ink);
    letter-spacing: 0.01em;
    text-transform: uppercase;
    line-height: 1.18;
    /* Lead with the heavier 'osr' cut (real weight, no faux-bold) so the
       headline reads clearly heavier than the osl-cut body prose. */
    font-family: "osr", "osl", Georgia, serif;
    font-weight: 500;
}
/* Reserve a top-right gutter so the title's first line wraps clear of the
   bookmark corner mark; lower lines reflow to full width. */
.card-title::before {
    content: "";
    float: right;
    width: 34px;
    height: 26px;
}
.card-hairline {
    margin: 6px 0 10px;
    width: 42px;
    height: 1px;
    background: var(--accent);
    opacity: 0.6;
}
.card-body {
    /* Lighter display cut (osl) reads less heavy than osr, which felt too
       bold/large for the card prose. Applies to Normal cards; Heavy overrides
       size below but inherits this family. (Nudged 16->17px for readability.) */
    font-size: 17px;
    line-height: 1.7;
    color: var(--ink);
    font-family: var(--serif-display);
}
/* Drop-cap via ::first-letter on the summary text span (no text-splitting, so
   the summary stays one editable string). This SUPERSEDED a `.card-dropcap`
   element (and its .card-heavy / .sub-card variants), all three deleted
   2026-07-25 — nothing in reader/src emits those classes any more. */
.card-body-text::first-letter {
    float: left;
    font-size: 66px;
    line-height: 0.82;
    color: var(--accent);
    padding: 6px 10px 0 0;
    font-family: var(--serif-display);
    font-weight: 700;
}
.card-citation {
    margin-top: 16px;
    font-size: 12px;
    color: var(--ink-faint);
    letter-spacing: 0.04em;
    text-transform: uppercase;
}
.card-heavy { min-height: 88vh; min-height: 88dvh; padding: 60px 16px 40px 18px; }
.card-heavy .card-ornament { top: 80px; font-size: 15px; padding: 7px 3px; }
.card-heavy .card-year { font-size: clamp(64px, 22vw, 96px); letter-spacing: -0.025em; }
/* Heavy keeps the shared 20px headline size (no font-size override) — only its
   extra top margin differs. */
.card-heavy .card-title { margin-top: 14px; }
.card-heavy .card-hairline { margin: 10px 0 16px; width: 56px; }
/* Heavy body uses the lighter display cut (osl) at 17px so the large block of
   prose reads lighter and a touch smaller — osr (the regular body face) was too
   heavy/large for the near-fullscreen Heavy card. */
.card-heavy .card-body { font-size: 17px; line-height: 1.75; font-family: var(--serif-display); }
.card-heavy .card-body-text::first-letter { font-size: 74px; padding: 7px 12px 0 0; }
.card-heavy .card-citation { margin-top: 28px; font-size: 12px; }

/* Year cluster (multi-event year) */
/* scroll-margin-top: the computed bar clearance — see .card for why it is a
   calc and not a flat 56px. */
.cluster { padding-bottom: 14px; scroll-margin-top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) + 8px); }
.cluster-head {
    padding: 24px 16px 6px 18px;
    border-left: 4px solid var(--ink-veryfaint);
    display: flex;
    align-items: center;   /* buttons vertically centered against the year block */
    gap: 12px;
    flex-wrap: wrap;
}
/* Year with its "N events" badge centered directly beneath it. */
.cluster-year-block {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
}
.cluster-year {
    font-size: clamp(44px, 15vw, 60px);
    line-height: 0.95;
    color: var(--head);
    font-feature-settings: "lnum";
    letter-spacing: -0.02em;
    font-family: var(--serif-display);
    font-weight: 600;
}
.cluster-count {
    display: inline-block;
    font-size: 11px;
    letter-spacing: 0.2em;
    color: var(--ink-faint);
    text-transform: uppercase;
    vertical-align: middle;
    padding: 4px 10px;
    border: 1px solid var(--rule);
    border-radius: 6px;
    background: var(--rule-soft);
}
.sub-card {
    transition: opacity 220ms ease;
    padding: 14px 16px 16px 18px;
    position: relative;
    border-left: 4px solid var(--accent);
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
    /* Sub-cards carry data-event-id, so they're the scroll target for most
       events (the ~77% inside year-clusters). They MUST reserve the topbar like
       .card/.cluster — same computed clearance, see .card. */
    scroll-margin-top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) + 8px);
}
.sub-card-ornament {
    position: absolute;
    left: 0;
    top: 22px;
    background: var(--canvas);
    color: var(--accent);
    font-size: 13px;
    line-height: 1;
    padding: 6px 3px;
    text-align: center;
    font-weight: bold;
    transform: translate(-50%, -50%);
}
.sub-card-title {
    /* Match the solo .card-title so the headline is one consistent size/weight
       everywhere (18px, uppercase, medium) — clearly larger than the 17px body. */
    font-size: 18px;
    color: var(--ink);
    letter-spacing: 0.01em;
    text-transform: uppercase;
    font-family: "osr", "osl", Georgia, serif;
    line-height: 1.18;
    font-weight: 500;
}
/* Reserve a top-right gutter so the title's first line(s) wrap clear of the
   bookmark corner mark; lower lines reflow to full width. */
.sub-card-title::before {
    content: "";
    float: right;
    width: 34px;
    height: 24px;
}
.sub-card-body {
    margin-top: 6px;
    /* Cluster sub-cards are the least-emphasized reading level. They keep a
       deliberately quieter body than solo cards (full --ink), but nudged up so
       the prose isn't a strain to read: 17px and a slightly-brighter-than-dim
       ink (#b0a691, a step between --ink-dim #998f7d and --ink #d8d0bf). The
       dimmer-than-solo color preserves the hierarchy. (Nudged 16->17px.) */
    font-size: 17px;
    color: var(--ink-soft);
    line-height: 1.6;
}
/* Sub-card drop-cap via ::first-letter (scaled to the 15px sub-card body).
   The element form (.sub-card-dropcap) went with .card-dropcap, 2026-07-25. */
.sub-card-body-text::first-letter {
    float: left;
    font-size: 44px;
    line-height: 0.82;
    color: var(--accent);
    padding: 4px 7px 0 0;
    font-family: var(--serif-display);
    font-weight: 700;
}

/* Quiet rule (gap between events) */
.quiet-rule {
    padding: 14px 30px;
    display: flex;
    align-items: center;
    gap: 12px;
    color: var(--ink-faint);
    font-size: 10px;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    border-left: 4px solid var(--rule);
}
.quiet-rule-label { white-space: nowrap; }
.quiet-rule-dash { flex: 1; height: 1px; background: var(--rule); }
.quiet-rule-empty { height: 0; }

.reader-main {
    padding-right: 36px;    /* reserve gutter for the edge scrubber */
    /* room for the chip bar — which now grows by the safe-area inset, so the
       last card still clears it on home-indicator iPhones. */
    padding-bottom: calc(100px + env(safe-area-inset-bottom, 0px));
}
/* Reclaim the bar's reserved gutter when collapsed (else collapsing frees no
   reading space). Match the padding transition to the bar slide so reflow +
   movement read as one motion. */
.reader-main { transition: padding .25s ease; }
/* Scale hidden: drop the reserved gutter to near-zero so the cards' own 16px
   inner padding becomes the only right margin (symmetric with the ~18px left),
   instead of leaving a wide dead strip where the rail used to be. */
body.scale-collapsed .reader-main { padding-right: 2px; }
body.chips-collapsed .reader-main { padding-bottom: calc(28px + env(safe-area-inset-bottom, 0px)); }
/* Desktop: cap the Reader card stream at 800px and center it so lines don't run
   the full width of a wide monitor. The Lanes (Juxtaposition) and Map lenses are
   SIBLINGS of .reader-main, not children, so they keep their full width — only
   the reader column is constrained. */
@media (min-width: 760px) {
    .reader-main {
        /* Cap at 800px, but in the narrow-desktop band (760–912px) shrink so the
           column + the scrubber's reserved lane (~112px = 2×56) still fit without
           the rail overlapping text. Above 912px this is a no-op (800px wins). */
        max-width: min(800px, calc(100% - 112px));
        margin-left: auto;
        margin-right: auto;
    }
}
.boot-splash {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 60vh;
    min-height: 60dvh;
    color: var(--ink-faint);
}

/* Edge scrubber (fixed right) */
.scrubber {
    position: fixed;
    /* Rail starts flush under the topbar (no extra gap — the prior 22px offset
       left a visible void at the top of the scale). The rail's 0%..100% span
       maps across this box. */
    top: var(--topbar-h);
    /* Extend the rail down to just above the chip bar's top edge (the chip bar is
       ~66px+inset tall); clears it without colliding. */
    bottom: calc(70px + env(safe-area-inset-bottom, 0px));
    right: 0;
    width: 56px;
    z-index: 9;
    /* Shading is contained to the RIGHT of the divider line (at right:47px):
       solid canvas near the edge, fading to transparent exactly at the line —
       visible gradient on the scale side, no bleed into the reading column. */
    background: linear-gradient(to left, var(--canvas) 30px, transparent 47px);
    touch-action: none;
    clip-path: inset(0 -100px 0 -100px);
    transition: bottom .25s ease;   /* smooth the reclaim when chips drop */
    /* A drag that starts on a year label must pan the rail, never begin a
       text selection of the labels (same guard the cards carry). */
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
}
/* When the chip bar is collapsed (dropped off-screen) its 70px clearance is
   wasted, so let the rail extend down to the bottom edge to use it. Scoped below
   the desktop breakpoint: at >=760px collapse is disabled and the chip bar is
   forced visible, so the rail must keep its clearance there even if the body
   still carries a stale .chips-collapsed class. */
@media (max-width: 759px) {
    body.chips-collapsed .scrubber {
        bottom: calc(8px + env(safe-area-inset-bottom, 0px));
    }
}
/* Desktop: the scrubber's RIGHT edge lines up with the reading column's right
   edge (50%-400px) — same point the topbar's right edge stops at, so the rail
   and the status bar terminate together. The 56px rail extends leftward into the
   column's reserved padding-right gutter. max(0px,…) pins it to the viewport edge
   once the window is too narrow. */
@media (min-width: 760px) {
    .scrubber { right: max(0px, calc(50% - 400px)); }
}
/* Decorative left border drawn just inside the tick column, not at the
 * rail's left edge — the box itself is wider for touch comfort but the
 * visible line stays close to the ticks. */
.scrubber::before {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    right: 47px;   /* divider nudged a hair right toward the scale (was 48);
                      content gutter left untouched so nothing reflows */
    width: 1px;
    background: var(--rule);
    pointer-events: none;
}
.scrub-rail {
    position: absolute;
    top: 0; bottom: 0;
    width: 2px;
    background: var(--ink-veryfaint);
    right: 10px;
}
/* Gesture layer: rail_gestures slides this ONE element (composited
   translateY) during pans/flings instead of updating ~1100 children's `top`
   per pointer move; window_lo commits once when the gesture settles. Same
   box as the scrubber, so the children's % tops are unchanged. */
.scrub-layer {
    position: absolute;
    inset: 0;
    will-change: transform;
}
.scrub-tick {
    position: absolute;
    right: 7px;
    width: 8px;
    height: 1px;
    background: var(--ink-faint);
    transform: translateY(-50%);
    transition: top 220ms ease-out;
}
.scrub-tick-major {
    right: 5px;
    width: 12px;
    height: 2px;
    background: var(--ink-dim);
}
/* Major tick at a year label: clip to half, right end fixed. */
.scrub-tick-labeled {
    width: 6px;
}
.scrub-label {
    position: absolute;
    right: 15px;
    font-size: 11px;
    color: var(--ink-dim);
    transform: translateY(-50%);
    font-feature-settings: "lnum";
    font-weight: 600;
    font-family: var(--serif-body);
    letter-spacing: 0.02em;
    transition: top 220ms ease-out;
}
.scrub-dot {
    position: absolute;
    right: 6px;
    width: 8px; height: 8px;
    border-radius: 2px;
    transform: translateY(-50%);
    box-shadow: 0 0 0 2px var(--canvas);
    transition: top 220ms ease-out, opacity 0.2s;
}
.scrub-dot-heavy { right: 4px; width: 12px; height: 12px; border-radius: 3px; }
.scrub-dot-dim { opacity: 0.18; }
/* Bookmark flags: a small gilt PENNANT on a staff, on the LEFT of the rail
   (kind dots, ticks and labels all extend RIGHT, so the left side is
   collision-free). The element is a HORIZONTAL pole sticking left out from the
   rail; ::before is a pennant hanging DOWN off the pole's far (left) end, its
   long edge flush to the pole and tapering to a point — so it reads as a flag
   on a pole jutting out from the rail. Always rendered but hidden until
   .bm-flag-on (its event id is bookmarked): a stable CSS-toggled set, which is
   what keeps the rail clear of the unmount-on-click crash family. Same
   top-transition as the dots so it tracks zoom/pan smoothly. */
.scrub-bm-flag {
    position: absolute;
    right: 14px;             /* pole's RIGHT end sits just off the rail */
    width: 20px;             /* horizontal pole sticking LEFT out from the rail */
    height: 1px;             /* pole thickness */
    display: none;
    background: var(--gilt);
    opacity: 0.85;                 /* slightly translucent at rest */
    transform: translateY(-1px);   /* center the pole on the year line */
    transition: top 220ms ease-out, opacity 150ms ease;
    cursor: pointer;
}
.scrub-bm-flag::before {
    content: "";
    position: absolute;
    /* Pennant hangs DOWN off the far (left) end of the pole. Its long edge (the
       top) is flush against the pole and it tapers to a point at the bottom —
       a proper pennant, not a triangle pointing into the pole. */
    top: 0;
    left: 0;
    width: 0; height: 0;
    border-top: 10px solid var(--gilt);   /* filled top edge, flush to the pole */
    border-right: 11px solid transparent; /* taper to a point on the right/down */
}
.scrub-bm-flag.bm-flag-on { display: block; }
/* Transparent hit-expander: the visible pole+pennant is ~10px, well under a
   comfortable touch target. This ::after grows the tappable area to ~44px
   (centered on the pole) without changing the flag's look — same trick the
   scale grab-notch uses. Only present when the flag is shown. */
.scrub-bm-flag.bm-flag-on::after {
    content: "";
    position: absolute;
    top: 50%;
    right: -12px;
    width: 44px;
    height: 44px;
    transform: translateY(-50%);
}
/* At a labeled year, the pennant (which hangs DOWN off the pole) would hang into
   the year label sitting on that line. Tilt the whole flag 10° upward, pivoting
   about the pole's right end (where it meets the rail), so the free end + pennant
   swing up and clear the label while keeping the natural flag-on-a-pole look.
   Positive rotate() = clockwise; with the pivot at the rail (right) end, that
   lifts the left/free end UP. translateY(-1px) keeps the pole on the year line. */
.scrub-bm-flag.bm-flag-labeled {
    transform-origin: right center;
    transform: translateY(-1px) rotate(10deg);
}
/* A flag sitting EXACTLY on a label year is hidden entirely (the year label owns
   that line) — see is_on_label in scrubber.rs, which omits the bm-flag-on class.
   So there's no on-label tilt/long-pole rule: that whole collision is avoided by
   simply not drawing the flag there. */
.scrubber.pinching .scrub-bm-flag,
.scrubber.panning .scrub-bm-flag { transition: none; }
@media (hover: hover) {
    .scrub-bm-flag.bm-flag-on:hover { background: var(--head); opacity: 1; }
    .scrub-bm-flag.bm-flag-on:hover::before {
        border-top-color: var(--head);
    }
}
.scrubber.pinching .scrub-tick,
.scrubber.pinching .scrub-label,
.scrubber.pinching .scrub-dot,
.scrubber.pinching .scrub-head,
.scrubber.panning .scrub-tick,
.scrubber.panning .scrub-label,
.scrubber.panning .scrub-dot,
.scrubber.panning .scrub-head {
    transition: none;
}
.scrub-head {
    position: absolute;
    right: 2px;
    width: 18px;
    height: 18px;
    border-radius: 5px;
    background: transparent;
    border: 2px solid var(--head);
    box-shadow: 0 0 0 3px var(--canvas);
    transform: translateY(-50%);
    transition: top 0.12s ease-out, opacity 0.15s;
    z-index: 2;
    opacity: 1;
}
.scrub-head-hidden { opacity: 0; }
.scrub-head::after {
    content: '';
    position: absolute;
    left: 50%; top: 50%;
    width: 6px; height: 6px;
    background: var(--head);
    border-radius: 2px;
    transform: translate(-50%, -50%);
    opacity: 0.7;
}

/* Lane chip bar (fixed bottom) */
.chip-bar {
    position: fixed;
    bottom: 0; left: 0; right: 0;
    /* Frosted bar (mirror of .topbar): flat translucent tint + backdrop blur.
       Content under is blurred, not sharply see-through. */
    background: var(--bar-tint);
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
    /* Lift the chips above the iOS home-indicator / app-switcher gesture zone:
       the 20px base cushion PLUS the device's safe-area inset (0 on hardware
       without a home indicator; ~34px on Face-ID iPhones). viewport-fit=cover
       in index.html is what makes env() resolve to a real value here. */
    padding: 14px 12px calc(20px + env(safe-area-inset-bottom, 0px));
    z-index: 11;
    touch-action: pan-x;
}
/* Desktop: unlike the topbar/reader column (capped at 800px), the filter chips
   are allowed to use the full bar width and CENTER so they all fit on one row
   without horizontal scrolling whenever space is ample. They only fall back to
   scroll if the window is too narrow to hold every chip. */
@media (min-width: 760px) {
    .chip-bar {
        padding-left:  32px;
        padding-right: 32px;
    }
}
/* Collapsible bars: slide the bar off-screen; the grab pill is a separate fixed
   sibling so it stays put. transform-only (composited), reduced-motion safe. */
.chip-bar { transition: transform .25s ease; }
.chip-bar.collapsed { transform: translateY(100%); }

/* The scrubber has clip-path: inset(0 -100px 0 -100px) (100px horizontal bleed),
   so translateX(100%) (its 56px width) leaves a sliver — translate past width +
   bleed and drop the clip while hidden so nothing peeks at the right edge. */
.scrubber { transition: transform .25s ease; }
.scrubber.collapsed { transform: translateX(160px); clip-path: none; }

/* Grab-handle pills — small inset "bar" handles (no glyph), fixed siblings that
   stay tappable when their bar slides away. Frosted to match the bars. */
.bar-grab {
    position: fixed;
    z-index: 21;                /* above bars (chip-bar 11, scrubber 9, topbar 20). */
    border: none;
    /* Notch shares its bar's frosted surface so the seam touching the bar
       vanishes — per-variant rules below set the per-side rounding + hairline
       and (for the scale) swap the tint for the rail's --canvas. */
    background: var(--bar-tint);
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
    padding: 8px 12px;          /* generous hit area; per-variant tightens one axis */
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    touch-action: manipulation;
}
/* While a lens sheet (Lanes/Map, z 15) covers the stream, the reader's
   fixed chrome has no job — and some of it outright pokes through: the
   pills sit at z 21 (above the sheet), and on desktop the chip bar stuck
   out both sides of the Map's own bottom scrubber. Hide all of it; the
   Map's own controls are .map-scale (a different class, unaffected). */
body.lens-open .bar-grab,
body.lens-open .chip-bar,
body.lens-open .scrubber { display: none; }
/* Freeze the document scroll while a lens sheet covers the stream. The reader
   stream is unmounted on lens open (TimelinePage's <Show when=Reader>), so this
   is belt to that suspenders: it guarantees the body can't scroll (or rubber-
   band on iOS) behind the lens even for the frame between the class flip and the
   stream's teardown. The lens sheets carry their own inner scroll. */
body.lens-open { overflow: hidden; }
/* the visible "small bar" line inside the tap target */
.bar-grab-handle {
    display: block;
    width: 28px;
    height: 4px;
    border-radius: 2px;
    background: var(--ink-faint);
    /* The handle color is the only feedback signal these tabs had room for;
       animate it so hover/press read as a live control, not dead pixels. */
    transition: background .12s ease, transform .06s ease;
}

/* ── Grab-tab feedback ────────────────────────────────────────────────────
   The chips/scale pills AND the chronicle Contents grab are the same kind of
   control (a fixed grab-handle that hides a panel) and had NO hover, press, or
   focus state at all — every tap was silent, which reads as "did it even
   register?" and invites the re-tap the panel was reported for. One shared
   block gives all three: a strengthened handle on hover, a clear depress on
   :active (so a tap always answers, even before the .25s panel slide), and a
   keyboard focus ring. Kept fast + on the handle so it never lags the slide. */
.bar-grab:hover .bar-grab-handle,
.chron-toc-grab:hover .bar-grab-handle { background: var(--ink-dim); }
.bar-grab:active .bar-grab-handle,
.chron-toc-grab:active .bar-grab-handle {
    background: var(--ink); transform: scaleX(1.15);
}
.bar-grab:focus-visible,
.chron-toc-grab:focus-visible {
    outline: 2px solid var(--gilt, #b39250); outline-offset: 2px;
}
/* Remove the default focus outline only when NOT keyboard-focused (so the ring
   above is the sole focus affordance and mouse/touch presses stay clean). */
.bar-grab:focus:not(:focus-visible),
.chron-toc-grab:focus:not(:focus-visible) { outline: none; }

/* Filter notch: centered on the chip bar's TOP edge, fused (top corners
   rounded, bottom flush onto the bar). Horizontal handle. */
.bar-grab--chips {
    left: 16px;                     /* toward the left, not centered */
    /* Sit ON the bar's top edge. Bar height = 14 (pad-top) + 35 (chip) + 4
       (.chips pad) + 20 (pad-bottom) ≈ 73px + safe-area. Anchor a few px BELOW
       that so the notch overlaps onto the bar (no gap even if the estimate is
       off / a chip row is shorter); the overlap hides under the bar. */
    bottom: calc(66px + env(safe-area-inset-bottom, 0px));
    /* Match the scale notch dimensions (rotated 90°): depth axis 5/3, length
       axis 12 — equal tab size, just horizontal vs the scale's vertical. */
    padding: 5px 12px 3px;          /* shorter bottom: flush onto the bar */
    border-radius: 8px 8px 0 0;     /* round only the outer (top) corners */
    border-top: 1px solid var(--rule);
    transition: bottom .25s ease, padding .25s ease, border-radius .25s ease;
}
/* Horizontal handle = the scale handle (3×22) rotated. */
.bar-grab--chips .bar-grab-handle {
    width: 22px;
    height: 3px;
}
/* Enlarge the TAP zone without enlarging the visible notch (the scale/chron
   trick, which the chips pill was MISSING — its ~34×11px visible box was the
   whole hit target, well under a finger). A transparent overlay adds reach
   upward (over the bar's top edge, toward the thumb's travel) and sideways.
   A child, so pointer-events land on the button; the visible notch stays slim. */
.bar-grab--chips::before {
    content: "";
    position: absolute;
    top: -12px;
    bottom: -6px;
    left: -12px;
    right: -12px;
}
/* Collapsed: ride to the bottom screen edge as a thin centered lip. */
body.chips-collapsed .bar-grab--chips {
    bottom: 0;
    padding: 4px 12px calc(2px + env(safe-area-inset-bottom, 0px));
}

/* Scale notch: a tab on the rail's LEFT edge, toward the top, fused (left
   corners rounded, right flush onto the rail). Shares the rail's solid --canvas
   surface (the rail is canvas, not the frosted bar tint). Vertical handle. */
.bar-grab--scale {
    right: 47px;                    /* meet the rail's divider line (right:47px) */
    top: calc(var(--topbar-h) + 24px);  /* toward the top of the rail */
    background: var(--canvas);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    padding: 12px 3px 12px 5px;     /* shorter right: flush onto the rail */
    border-radius: 8px 0 0 8px;     /* round only the outer (left) corners */
    border-left: 1px solid var(--rule);
    transition: right .25s ease, padding .25s ease, border-radius .25s ease;
}
.bar-grab--scale .bar-grab-handle {
    width: 3px;
    height: 22px;
}
/* Enlarge the TAP zone without enlarging the visible tab: a transparent overlay
   that extends the clickable area left (toward the reading column) and a bit
   above/below. Clicks on it hit the button (it's a child, pointer-events auto).
   The visible --canvas tab stays slim. */
.bar-grab--scale::before {
    content: "";
    position: absolute;
    top: -14px;
    bottom: -14px;
    left: -22px;
    right: -4px;
}
/* Collapsed: ride to the right screen edge as a thin vertical lip. */
body.scale-collapsed .bar-grab--scale {
    right: 0;
    padding: 12px 2px 12px 4px;
}

/* Desktop (>=760px): collapse is a touch affordance — remove it. Hide the grab
   notches and force both bars to their expanded position regardless of any saved
   collapsed state (so a bar collapsed on mobile can't open hidden, with no handle
   to recover it, on a wide screen). The persisted state + Rust mounting are
   untouched; this is a CSS-only visual override that reverts below 760px. */
@media (min-width: 760px) {
    .bar-grab--chips,
    .bar-grab--scale { display: none; }
    .chip-bar.collapsed { transform: none; }
    .scrubber.collapsed { transform: none; clip-path: inset(0 -100px 0 -100px); }
}

@media (prefers-reduced-motion: reduce) {
    .chip-bar, .scrubber, .reader-main,
    .bar-grab--chips, .bar-grab--scale,
    .bar-grab-handle, .chron-toc-grab { transition: none; }
    /* Keep the press SIGNAL (color/scale is feedback, not decoration) but make
       it instant rather than eased. */
    .bar-grab:active .bar-grab-handle,
    .chron-toc-grab:active .bar-grab-handle { transform: none; }
}
.chips {
    display: flex;
    gap: 8px;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    padding-bottom: 4px;
    touch-action: pan-x;
}
.chips::-webkit-scrollbar { display: none; }
/* Desktop: let the chips use the full bar width and CENTER so they all fit on one
   row without horizontal scrolling when space is ample; wrap to a second row
   rather than clip if the window is narrow (centered overflow is otherwise
   unscrollable on the left — the flex-centering clip bug). Placed AFTER the base
   `.chips` so it wins on source order. */
@media (min-width: 760px) {
    .chips {
        justify-content: center;
        flex-wrap: wrap;
        row-gap: 8px;
        overflow-x: visible;
    }
}
.chip {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    /* ≥44px touch target (the app's tab standard) without enlarging the text. */
    min-height: 44px;
    gap: 7px;
    padding: 7px 10px;
    border: 1px solid var(--rule);
    border-radius: 10px;
    color: var(--ink-dim);
    font-size: 19px;
    line-height: 1;
    background: var(--rule-soft);
    cursor: pointer;
    font-family: var(--serif-display);
    white-space: nowrap;
    justify-content: center;
    transition: transform 0.08s ease, background 0.15s, color 0.15s, border-color 0.15s;
}
.chip:active { transform: scale(0.96); }
.chip-ornament {
    font-size: 18px;
    line-height: 1;
    color: var(--chip-color, var(--ink-faint));
}
.chip[data-on="1"] {
    background: var(--chip-color, var(--head));
    color: var(--canvas);
    border-color: var(--chip-color, var(--head));
}
.chip[data-on="1"] .chip-ornament { color: var(--canvas); }
.chip-all[data-on="1"] {
    background: var(--head);
    color: var(--canvas);
    border-color: var(--head);
}

/* Filtered-out cards/sub-cards/dots */
/* Filtered-out events dim as ONE piece — body, citation, stamp, bookmark
   corner and buttons all fade together (composite opacity), so no stray
   chrome floats at full strength over a gutted card (the old treatment
   display:none'd the body and left dim stubs + bookmark marks behind). */
.card.filtered-out, .sub-card.filtered-out {
    opacity: 0.25;
}

/* ========================================================================
   Admin page — Slice 1 (tabs, refresh button, license table)
   ======================================================================== */

.admin-tabs {
    display: flex;
    gap: 8px;
    margin: 16px 0 24px;
    border-bottom: 1px solid var(--rule);
}
.admin-tab {
    background: transparent;
    border: none;
    color: var(--ink-dim);
    padding: 10px 16px;
    cursor: pointer;
    font-family: var(--serif-display);
    font-size: 15px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
}
.admin-tab.active {
    color: var(--head);
    border-bottom-color: var(--head);
}

.admin-refresh-row {
    display: flex;
    justify-content: flex-end;
    margin-top: 8px;
}
.admin-refresh-btn {
    background: var(--rule-soft);
    color: var(--head);
    border: 1px solid var(--rule);
    padding: 8px 16px;
    border-radius: 8px;
    font-family: var(--serif-display);
    font-size: 13px;
    letter-spacing: 0.04em;
    cursor: pointer;
}
.admin-refresh-btn[disabled] {
    opacity: 0.5;
    cursor: default;
}
.admin-refresh-btn.spinning::after {
    content: '';
    display: inline-block;
    width: 10px; height: 10px;
    margin-left: 8px;
    border: 2px solid var(--head);
    border-top-color: transparent;
    border-radius: 50%;
    animation: admin-spin 700ms linear infinite;
    vertical-align: middle;
}
@keyframes admin-spin { to { transform: rotate(360deg); } }

.admin-audit-placeholder {
    color: var(--ink-faint);
    text-align: center;
    padding: 60px 24px;
    font-size: 14px;
    letter-spacing: 0.04em;
}

/* ========================================================================
   Admin Licenses tab — Slice 1.1 (role-grouped collapsable cards)
   ======================================================================== */

.admin-license-groups {
    display: flex;
    flex-direction: column;
    gap: 12px;
    margin-top: 8px;
}

.admin-license-group {
    border: 1px solid var(--rule);
    border-radius: 10px;
    background: var(--rule-soft);
    overflow: hidden;
}
.admin-license-group.empty {
    /* zero-user shelf: flat pill, no chevron, not interactive */
    opacity: 0.7;
}

.admin-license-group-header {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 18px;
    background: transparent;
    border: none;
    color: var(--head);
    font-family: var(--serif-display);
    font-size: 14px;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    cursor: pointer;
    min-height: 48px; /* mobile tap target */
}
.admin-license-group-header[disabled] {
    cursor: default;
}
.admin-license-group-label {
    flex: 1;
    text-align: left;
}
.admin-license-group-chevron {
    color: var(--ink-dim);
    font-size: 18px;
    line-height: 1;
}

.admin-license-group-body {
    border-top: 1px solid var(--rule);
}

.admin-license-user {
    /* divider between user rows within a group */
    border-bottom: 1px solid var(--rule);
}
.admin-license-user:last-child {
    border-bottom: none;
}

.admin-license-user-row {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    padding: 14px 18px;
    background: transparent;
    border: none;
    cursor: pointer;
    text-align: left;
    min-height: 56px; /* mobile tap target */
}
.admin-license-user-name {
    color: var(--ink);
    font-family: var(--serif-display);
    font-size: 17px;
    line-height: 1.2;
}
.admin-license-user-email {
    color: var(--ink-dim);
    font-size: 13px;
}
.admin-license-user-locked {
    color: var(--oxblood);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    margin-left: 4px;
}

.admin-license-user-body {
    display: grid;
    grid-template-columns: max-content 1fr;
    column-gap: 16px;
    row-gap: 6px;
    margin: 0;
    padding: 0 18px 16px 18px;
}
.admin-license-user-body dt {
    color: var(--ink-faint);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-size: 11px;
    align-self: baseline;
}
.admin-license-user-body dd {
    margin: 0;
    color: var(--ink);
    font-size: 14px;
}
.admin-license-user-body dd.license-key {
    font-family: ui-monospace, "SF Mono", "Cascadia Mono", "Consolas", monospace;
    letter-spacing: 0.04em;
    font-size: 13px;
}

.admin-license-user-revoke {
    margin-top: 12px;
    padding: 8px 14px;
    background: transparent;
    border: 1px solid var(--oxblood);
    border-radius: 6px;
    color: var(--oxblood);
    font-family: var(--serif-display);
    font-size: 13px;
    letter-spacing: 0.04em;
    cursor: pointer;
}
.admin-license-user-revoke:hover {
    background: var(--oxblood);
    color: var(--ink);
}
.admin-license-user-revoke[disabled] {
    opacity: 0.5;
    cursor: default;
}

/* Role-change buttons (viewer <-> contributor) — same shape as Revoke, with a
   moss accent for "promote" and a steel/muted accent for "demote". */
.admin-role-btn {
    margin-top: 12px;
    margin-right: 8px;
    padding: 8px 14px;
    background: transparent;
    border: 1px solid var(--rule);
    border-radius: 6px;
    color: var(--ink);
    font-family: var(--serif-display);
    font-size: 13px;
    letter-spacing: 0.04em;
    cursor: pointer;
}
.admin-role-btn.make-contributor { border-color: var(--moss); color: var(--moss); }
.admin-role-btn.make-contributor:hover { background: var(--moss); color: var(--canvas); }
.admin-role-btn.make-viewer { border-color: var(--steel); color: var(--steel); }
.admin-role-btn.make-viewer:hover { background: var(--steel); color: var(--canvas); }
.admin-role-btn[disabled] { opacity: 0.5; cursor: default; }

/* ============================================================
 * Bookmarks · corner indicator
 * ============================================================ */
.bm-corner {
    position: absolute;
    top: 0;
    right: 24px;
    width: 36px;
    height: 36px;
    background: transparent;
    border: none;
    padding: 0;
    cursor: pointer;
    z-index: 5;
    /* Suppress iOS long-press text selection + native callout sheet. */
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
    touch-action: manipulation;
}
.bm-corner::before {
    content: "";
    position: absolute;
    top: 2px;
    right: 2px;
    width: 20px;
    height: 20px;
    border-top: 2px solid var(--gilt-deep);
    border-right: 2px solid var(--gilt-deep);
    opacity: 0.55;
    pointer-events: none;
    /* Anchor at the bottom-right so the long-press scale-up throws the bracket
       UP-and-left, into the zone above the finger/thumb (which usually covers
       the area below). Right edge stays pinned so it still reads as a corner
       mark. */
    transform-origin: bottom right;
    transition: opacity 200ms ease, border-color 200ms ease, transform 200ms ease;
}
/* Long-press feedback: temporarily enlarge the existing corner mark while held
   so it reads around a fingertip — biased upward for visibility above the
   finger. Unbookmarked → the bracket (::before) scales up + brightens;
   bookmarked → the corner-fold triangle (::after) scales up. The extra
   translateY lifts it clear of the fingertip. */
.bm-corner.is-pressing::before {
    opacity: 1.0;
    border-top-color: var(--gilt);
    border-right-color: var(--gilt);
    transform: translateY(-6px) scale(2.6);
}
.bm-corner.is-bookmarked::before {
    border-color: transparent;
    opacity: 0;
}
.bm-corner.is-bookmarked::after {
    content: "";
    position: absolute;
    top: 2px;
    right: 2px;
    width: 0;
    height: 0;
    border-style: solid;
    border-width: 0 20px 20px 0;
    border-color: transparent var(--gilt) transparent transparent;
    transform-origin: bottom right;
    transition: transform 200ms ease;
}
.bm-corner.is-bookmarked.is-pressing::after {
    transform: translateY(-6px) scale(2.6);
}
@media (hover: hover) and (pointer: fine) {
    .bm-corner::before {
        transition: opacity 150ms ease, border-color 150ms ease;
    }
    .bm-corner:hover::before {
        border-top-color: var(--gilt);
        border-right-color: var(--gilt);
        opacity: 0.8;
    }
}

/* ============================================================
 * Bookmarks · dropdown
 * ============================================================ */
.bm-dropdown-wrap {
    position: relative;
}
.bm-dropdown-btn {
    background: var(--canvas-deep);
    border: 1px solid var(--rule);
    color: var(--ink-dim);
    /* Tighter padding than the text buttons: this pill holds only a glyph + a
       digit. The 21px flag is now the tallest content, so vertical padding is
       trimmed to keep the box at the 33px height of .topbar-btn / .tb-seg
       (21px glyph + 2×5 padding + 2×1 border = 33). */
    padding: 5px 8px;
    border-radius: 9px;
    cursor: pointer;
    font-size: 18px;
    /* line-height:1 so the box height tracks the content, not a leaded line. */
    line-height: 1;
    letter-spacing: 0.04em;
    font-family: inherit;
    display: inline-flex;
    align-items: center;
    gap: 5px;
}
/* (.has-bookmarks used to gild the pill, but with bookmarks in regular use it
   made the button read permanently "selected" — the state class is still set
   on the element, just unstyled now.) */
/* Filled flag, upsized with the rest of the filled set (its viewBox hugs the
   ink); still capped so the pill matches the other topbar buttons' 33px. */
/* 13, not 17 — see the mobile override for the arithmetic: the flag's 9.2×14.6
   viewBox scales uniformly to 13.2 wide at this 21px height, so a wider box only
   reserves dead space beside the ink. */
.bm-glyph { width: 13px; height: 21px; display: block; }
.bm-count {
    font-size: 18px;
    font-weight: 600;
    /* Match every other number in the reader (.card-year, .scrub-label,
       .bm-yr): the display cut with LINING figures, not the body font's
       old-style figures (which dip below the baseline and look off here). */
    font-family: var(--serif-display);
    font-feature-settings: "lnum";
    font-variant-numeric: tabular-nums;
}

/* Transparent full-viewport catcher: a click anywhere off the panel closes the
   dropdown. Sits just under the panel (z 49 < panel z 50) but above page content.
   Replaces the old hand-rolled document click/keydown listeners.
   Always in the DOM; shown only when .open (CSS toggle, NOT <Show> mount — so
   closing never disposes a click handler mid-dispatch; see bookmarks_dropdown.rs). */
.bm-backdrop {
    display: none;
    /* Was `position: fixed; inset: 0`, which only ever covered the ~48px bar:
       .topbar's backdrop-filter is the containing block for fixed descendants,
       so tapping the page below never closed this dropdown. Oversized-absolute
       for the same reason as .prefs-backdrop — see the note there. */
    position: absolute;
    top: -100vh;
    left: -100vw;
    right: -100vw;
    height: 300vh;
    z-index: 49;
    background: transparent;
}
.bm-backdrop.open { display: block; }
.bm-panel {
    /* Hidden until .open — same always-rendered / CSS-toggle rationale as the
       backdrop above. */
    display: none;
    position: absolute;
    top: 100%;
    right: 0;
    margin-top: 6px;
    background: var(--canvas);
    border: 1px solid var(--gilt-deep);
    border-radius: 3px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.8);
    min-width: 280px;
    max-width: 320px;
    max-height: 480px;
    overflow: hidden;
    flex-direction: column;
    z-index: 50;
}
.bm-panel.open { display: flex; }
.bm-panel-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 8px 12px;
    border-bottom: 1px dotted var(--rule);
    color: var(--ink-faint);
    font-size: 9.5px;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    flex-shrink: 0;
}
.bm-count-pill { color: var(--gilt); }
.bm-list {
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;   /* momentum scroll on iOS Safari */
    flex: 1;
}
/* Bookmark rows: "Ledger" treatment (chosen 2026-06-15, mockups/
   bookmarks-archetypes/row-redesign-options.html, option A). The year is a
   gilt left column ruled off from the title; rows are roomier with a solid
   hairline divider; the × delete is hidden until the row is hovered. */
.bm-era-head {
    color: var(--gilt-deep);
    font-size: 9px;
    text-transform: uppercase;
    letter-spacing: 0.12em;
    padding: 8px 14px 4px;
    border-bottom: 1px solid var(--rule);
}
.bm-row {
    display: flex;
    align-items: baseline;
    padding: 10px 14px;
    border-bottom: 1px solid var(--rule-soft);
    font-size: 13px;
    cursor: pointer;
}
.bm-row:last-child { border-bottom: none; }
.bm-row:hover { background: rgba(179,146,80,0.06); }
.bm-yr {
    color: var(--gilt);
    width: 46px;
    flex-shrink: 0;
    font-size: 14px;
    letter-spacing: 0.02em;
    /* Match the card year: the display cut with lining figures. */
    font-family: var(--serif-display);
    font-feature-settings: "lnum";
    font-variant-numeric: tabular-nums;
    border-right: 1px solid var(--rule);
    margin-right: 12px;
}
.bm-ti {
    color: var(--head);
    flex: 1;
    line-height: 1.3;
    /* Echo the card title (reader/styles.css .card-title): heavier 'osr' cut,
       uppercase, slight tracking — so a bookmark reads as the same object as
       the card it points to, not generic body text. */
    font-family: "osr", "osl", Georgia, serif;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.01em;
    font-size: 12px;
}
.bm-del {
    color: var(--ink-veryfaint);
    padding: 0 2px 0 8px;
    cursor: pointer;
    transition: opacity 120ms ease, color 120ms ease;
    /* SVG trashcan instead of a × glyph; align it to the row's first text line
       (the row is align-items:baseline, which would otherwise drop the icon). */
    align-self: center;
    display: inline-flex;
    line-height: 0;
}
.bm-trash { width: 15px; height: 15px; display: block; }
.bm-del:hover { color: var(--oxblood); }
/* Reveal-on-hover only where hover actually exists — on touch devices (no
   hover) the × must stay visible or bookmarks can't be removed. */
@media (hover: hover) {
    .bm-del { opacity: 0; }
    .bm-row:hover .bm-del { opacity: 1; }
}
.bm-empty {
    padding: 16px 12px;
    color: var(--ink-faint);
    font-size: 11.5px;
    text-align: center;
    line-height: 1.5;
}

/* Mobile: anchor the panel to the viewport rather than the button,
 * so the 280px-wide panel doesn't clip off the left edge when the
 * button sits in the middle of the topbar. */
@media (max-width: 599px) {
    .bm-panel {
        position: fixed;
        top: var(--topbar-h);
        right: 12px;
        left: 12px;
        min-width: 0;
        max-width: none;
    }
    /* (The card settings popover is now position:fixed + viewport-centered at
       ALL widths in its base rule, so no mobile-specific override is needed —
       min(320px, 100vw-24px) already keeps it fully on-screen on phones.) */
    /* The bar is crowded on phones (switcher · spacer · Adm · Vfy · Theme ·
       Back · Bookmarks). Tighten the gap/padding AND collapse the two widest
       text labels (Adm, Vfy) to compact glyphs, mirroring the theme toggle,
       so everything fits even on a 320px screen. */
    .topbar { gap: 5px; padding-left: 9px; padding-right: 9px; }
    .topbar-btn { padding-left: 9px; padding-right: 9px; }
    /* Four segments (Summaries·Chronicle·Lanes·Map): the strip is the widest
       topbar item and was getting flex-shrunk, whose overflow:hidden CLIPPED
       the Map seg (seen on iPhone). No shrink + tighter horizontal padding so
       every seg stays whole; the 26px glyph + 2.5px vertical padding keeps the
       shared 33px control height (2.5 + 26 + 2.5 + 2 border). */
    .topbar-switcher { flex-shrink: 0; }
    .tb-seg { padding: 2.5px 5px; }

    /* (Adm/Rev/Edit are icon-only at every size now — the .tb-label spans and
       their show/hide rules are gone.) */

    /* The icon buttons (Admin ⚙, Theme ◐) share ONE fixed box so they read
       as a uniform set. The edit toggle now carries `.tb-admin` itself,
       so it's covered here automatically — no separate .tb-edit sizing. */
    .tb-admin, .tb-theme {
        box-sizing: border-box;
        width: 34px; height: 33px;
        padding: 0; min-width: 0;
        justify-content: center; align-items: center;
    }
}

/* ── Mobile: scale the whole bar up one notch ─────────────────────────────
   The topbar is built on TWO numbers: --topbar-h for the bar, and a single
   shared control height (33px) that every button above is padded to land on
   — the switcher, Search/Edit/Admin/Theme, Back and Bookmarks all compute to
   it, which is why each of their rules carries that arithmetic in a comment.
   So "make the status bar bigger" means moving both together: 48→57 and
   33→41, with every glyph grown 26→32px to match. Anything less and the bar
   gets taller around buttons that stayed put.

   WIDTH is the binding constraint, not height. With four switcher segments
   plus Search/Edit/Admin/Theme/Back/Bookmarks the bar already fills a 375px
   phone — that crowding is what clipped the Map segment before the
   flex-shrink:0 above. So the growth is PAID FOR in the spaces between: the
   bar's side padding drops 9→7px, its gap 5→3px, and each control's own side
   padding is trimmed as its glyph grows. The buttons come out ~24% taller
   with ~23% more ink while the bar's total width lands slightly UNDER where
   it was, so nothing new gets squeezed.

   Selectors are prefixed with `.topbar` to outrank the single-class rules
   that appear LATER in this file (.topbar-back, .bk-swoosh, .tb-search) —
   source order alone would lose to them. */
@media (max-width: 599px) {
    :root { --topbar-h: 57px; }
    /* 57 − 8 bottom padding − 41 control = 8px above: evenly seated. */
    .topbar { gap: 3px; padding-left: 7px; padding-right: 7px; padding-bottom: 8px; }

    /* Switcher: 32px glyph + 2×3.5 padding + the strip's 1px border = 41. */
    .topbar .tb-seg { padding: 3.5px 3px; }
    .topbar .tb-seg .tb-icon { width: 32px; height: 32px; }

    /* Search · Edit · Admin · Theme keep sharing ONE fixed box so they read as
       a uniform set (the rule above, one size up).

       41×41, and BOTH numbers are the same on purpose. This box was 38 wide
       under a comment claiming "3px of air each side" — but `box-sizing:
       border-box` plus the 1px .topbar-btn border means 38 − 2 = 36 inner, so
       the air was 2px at the sides against 3.5px above and below. The glyph sat
       pressed into the side walls with 75% more room top-to-bottom, which reads
       on a phone as a horizontally stretched icon (it isn't: the magnifier's
       lens measures a perfect circle). 41 − 2 = 39 inner gives 3.5px on all
       four sides, matching the vertical exactly.

       The +3px×2 is paid for by the .bm-glyph fix below, which returns ~5px of
       dead width — the bar's total is unchanged, so nothing new gets squeezed
       on a 375px phone. */
    .topbar .tb-search, .topbar .tb-admin, .topbar .tb-theme {
        box-sizing: border-box;
        width: 41px; height: 41px;
        padding: 0; min-width: 0;
        justify-content: center; align-items: center;
    }
    .topbar .tb-icon.tb-fill { width: 32px; height: 32px; }

    /* Back: 23px swoosh + 2×8 padding + 2 border = 41. */
    .topbar .topbar-back { padding: 8px 9px; font-size: 16px; }
    .topbar .bk-swoosh { width: 26px; height: 23px; }
    .topbar .bk-count { font-size: 12px; }

    /* Bookmarks: 26px flag + 2×6.5 padding + 2 border = 41. */
    .topbar .bm-dropdown-btn { padding: 6.5px 6px; gap: 4px; font-size: 20px; }
    /* 16, not 21: the flag's viewBox is 9.2×14.6, i.e. an aspect of 0.63. A
       21px-wide box makes the element 21 wide while the ink can only be
       26×0.63 = 16.4 — the extra ~5px is dead space that widened this tile
       against its neighbours for nothing. Height is what sizes the flag. */
    .topbar .bm-glyph { width: 16px; height: 26px; }
    .topbar .bm-count { font-size: 20px; }

    /* Rounder corners keep pace with the taller boxes. */
    .topbar .topbar-btn, .topbar .topbar-back,
    .topbar .bm-dropdown-btn, .topbar .topbar-switcher { border-radius: 11px; }
}

/* (The breadcrumb PANEL — .bc-section/.bc-head/.bc-count/.bc-row/.bc-yr/.bc-ti/
 * .bc-pill/.bc-pill-arrow and the .is-current row state — was deleted
 * 2026-07-25. `reader/src/reader/breadcrumbs.rs` still exists but renders no
 * markup at all (it carries no `class` attribute), so ~50 lines styled nothing.
 * It was the retired v2 chart's in-chart panel. `.is-current` was grepped
 * separately, being a generic name: nothing in reader/src sets it.) */

/* ============================================================
 * Back button (topbar) — styled to match bookmarks dropdown button
 * ============================================================ */
.topbar-back {
    background: var(--canvas-deep);
    border: 1px solid var(--rule);
    color: var(--ink-dim);
    /* 6px vertical holds the shared 33px box with the 19px-tall swoosh
       (6 + 19 + 6 + 2 border). */
    padding: 6px 12px;
    border-radius: 9px;
    cursor: pointer;
    font-size: 15px;
    /* line-height:1 (like .topbar-btn / .tb-seg) so Back's box is the shared
       33px height — without it the default ~1.2 line-height made it 2px taller. */
    line-height: 1;
    letter-spacing: 0.06em;
    font-family: inherit;
    display: inline-flex;
    align-items: center;
    gap: 5px;
}
/* Filled return swoosh (replaced the ↶ text glyph — font side-bearing
   drifted). Upsized with the rest of the filled set. */
.bk-swoosh { width: 22px; height: 19px; display: block; fill: currentColor; }
.bk-count { font-size: 10.5px; }

/* ===== Juxtaposition lens (Slice A) ===== */
/* (The .tb-home back chevron lived here; removed — the Summaries switcher
   segment covers every case it handled.) */

.jx-launch {
    /* Larger text, tighter internal padding so the label fills the button —
       the outline (border + radius) is unchanged; only text size and the
       text-to-edge margin change. margin-top (gap from the card body) kept. */
    margin-top: 12px; font-family: var(--serif-body); font-size: 14px;
    color: var(--ink-dim); background: var(--canvas-deep);
    border: 1px solid var(--rule); border-radius: 8px;
    padding: 6px 9px; cursor: pointer;
    /* ≥44px touch target without growing the text — inline-flex centers the
       label vertically in the taller box. */
    display: inline-flex; align-items: center; min-height: 44px;
}
.jx-launch:hover { color: var(--gilt); border-color: var(--gilt-deep); }
/* Cluster-header variant: aligned to the right of the year/count header row. */
.jx-launch-cluster { margin-top: 0; font-size: 13px; padding: 5px 8px; }

/* Sheet covering the reader content but sitting BELOW the persistent topbar,
   so the topbar's Home ⌂ dismisses the lens and the lit "Lanes" segment shows
   the active mode. Starts below the topbar (safe-area + topbar height); z-index
   above the cards (~9) but below the topbar (10) so the bar stays tappable. */
.jx-sheet {
    position: fixed;
    /* Cover the ENTIRE viewport (inset:0) so no scrolling body can ever show
       through — then inset the lens's own content below the topbar via
       padding-top (same --topbar-h). This avoids the iOS issue where a fixed
       element offset by a magic top exposes the scrolling body between it and
       the fixed topbar. */
    inset: 0;
    padding-top: var(--topbar-h);
    /* Above the reader cards AND the fixed scrubber (z 9), below the topbar
       (z 20) so the bar stays visible/tappable and Home dismisses the lens. */
    z-index: 15;
    display: flex; flex-direction: column;
    /* Match the reader page's text-area background (--page-bg, set on html/body
       in index.html), not the lighter --canvas used by the header. */
    background: var(--page-bg);
}

/* Column header lives INSIDE its lane (first child) so it pans horizontally
   with the column for free — they're the same flex item, so they can never
   desync. position:sticky pins it to the top vertically while the cards
   below scroll through time. z-index keeps it above scrolling cards. */
.jx-lh {
    position: sticky; top: 0; z-index: 1;
    /* SINGLE SOURCE OF TRUTH for the band's height, shared with .jx-card's
       scroll-margin-top below. Natural height is 8 (pad-top) + ~21 (the 15px
       .jx-lh-orn line box, the tallest child) + 7 (pad-bottom) + 1 (border) ≈
       37px; the floor pins it so the anchor offset is exact by construction
       instead of a hand-guessed 44px that drifts the next time this band's type
       changes. NOT --topbar-h: the lens sheet already insets itself below the
       bar, so what a jumped-to .jx-card must clear is THIS header, not the bar. */
    min-height: var(--jx-lh-h);
    display: flex; align-items: baseline; gap: 7px; color: var(--accent);
    /* Negative side margins cancel the lane padding so the header band
       spans the full column width; padding re-adds the inset for content. */
    margin: 0 -5px 12px; padding: 8px 12px 7px;
    background: var(--canvas-deep); border-bottom: 1px solid var(--rule);
}
.jx-lh-orn { font-size: 15px; }
.jx-lh-name { font-size: 10.5px; letter-spacing: .13em; text-transform: uppercase; }
.jx-lh-ct { font-size: 9px; color: var(--ink-veryfaint); margin-left: auto; }

.jx-wrap {
    flex: 1 1 auto; overflow-x: auto; overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none; -ms-overflow-style: none;
    /* Allow BOTH axes on touch (vertical = time scroll, horizontal = pan
       between threads). Without this, iOS/Android lock to the first-moved
       axis and trap the user. Mirrors the codebase's touch-action use on
       .chips (styles.css:589). */
    touch-action: pan-x pan-y;
    /* none (not contain) to also suppress the rubber-band bounce inside the
       lens scroll, matching the reader page. */
    overscroll-behavior: none;
}
.jx-wrap::-webkit-scrollbar { width: 0; height: 0; display: none; }
.jx-lanes { display: flex; align-items: flex-start; min-height: 100%; width: max-content; }
/* Trailing spacer flex-item: reliably extends the horizontal scroll width by
   ~25vw past the last column (padding on the flex container is ignored for
   scroll-width in Safari, so use a real item). */
.jx-lanes::after { content: ""; flex: 0 0 25vw; align-self: stretch; }
.jx-lane {
    flex: 0 0 292px; border-right: 1px solid var(--rule-soft);
    /* 25vw breathing room below the last card before the scroll bottoms out. */
    padding: 0 5px 25vw;
}

/* scroll-margin-top offsets the auto-scroll-to-anchor (block:Start) so the
   anchor card lands BELOW the sticky .jx-lh header band, not hidden behind it.
   Derived from the band's own token (+8px of air) rather than the flat 44px it
   used to guess — see .jx-lh. */
.jx-card { border-left: 2px solid var(--accent); padding: 0 1px 0 6px; margin-bottom: 16px; cursor: pointer; scroll-margin-top: calc(var(--jx-lh-h) + 8px); }
.jx-card-head { display: flex; align-items: baseline; gap: 6px; }
/* Year is a quiet sans-serif label, not a glowing serif number: matches the
   sans card text, dropped to a muted gilt and regular weight (was serif 18px/600
   hot-gilt). */
.jx-card-yr {
    font-family: var(--jx-sans); font-size: 14px;
    color: var(--gilt-deep); font-weight: 400; letter-spacing: .01em;
}
/* Sans-serif, regular weight card text — the most readable treatment at this
   size on the dark canvas (chosen from the A/B typeset test). */
.jx-card-ti { font-family: var(--jx-sans); font-size: 13px; line-height: 1.34; color: var(--ink); font-weight: 400; margin-top: 4px; }
.jx-card-loc { font-family: var(--jx-sans); font-size: 12.5px; color: var(--ink-faint); margin-top: 3px; }
/* (`.jx-anchor-card` — the gilt-washed "you came from here" card — was deleted
   2026-07-25 with its two child overrides: juxtaposition.rs never sets the
   class. The lens marks the focused card with .jx-focused instead, below.) */

.jx-gap {
    font-size: 9px; color: var(--ink-veryfaint); letter-spacing: .14em;
    text-transform: uppercase; margin: 0 0 16px; padding-left: 6px;
}


/* Time-alignment highlight. Tap an event to focus its year; its contemporaries
   (within ALIGN_RADIUS years) across the threads stay full-strength while the
   non-aligned cards recede — so the aligned set reads as a row-across-threads.
   The recede dims the WHOLE card uniformly (border, year, ornament, title and
   location all muted to one faint grey) so it reads as clean de-emphasis, not a
   faded-text-under-bright-accents glitch. */
.jx-card.jx-focused .jx-card-yr { color: var(--gilt); }
.jx-wrap:has(.jx-focused) .jx-card:not(.jx-aligned) {
    border-left-color: var(--ink-veryfaint);
}
.jx-wrap:has(.jx-focused) .jx-card:not(.jx-aligned) .jx-card-yr,
.jx-wrap:has(.jx-focused) .jx-card:not(.jx-aligned) .jx-card-ti,
.jx-wrap:has(.jx-focused) .jx-card:not(.jx-aligned) .jx-card-loc {
    color: var(--ink-veryfaint);
}

/* ── Event-detail page (/event/:slug) — Slice B ─────────────────────── */

.detail-shell {
  min-height: 100vh;
  background: var(--canvas);
  color: var(--ink);
  padding: 1.25rem 0.85rem 4rem;
  /* 50rem = the app's 800px desktop column (topbar/stream/chronicle/map all
     cap there) — 46rem left this page visibly narrower than the bar above it. */
  max-width: 50rem;
  margin: 0 auto;
  font-family: var(--serif-body);
}

.detail-loading,
.detail-error { opacity: 0.75; font-style: italic; }

/* Back to the card stream — quiet text link above the article, with enough
   padding to be a comfortable touch target. */
.detail-back {
    display: inline-block;
    margin: 0 0 14px;
    padding: 6px 10px 6px 0;
    color: var(--ink-soft);
    text-decoration: none;
    font-size: 0.92rem;
}
.detail-back:hover { color: var(--ink); text-decoration: underline; }

.ev-context-link { display: inline-block; color: var(--gilt); text-decoration: none; border: 1px solid var(--rule-soft); border-radius: 6px; padding: 4px 12px; margin: 8px 0; }

/* Page-open entrance. Opening a different page (tap a card → this event detail)
   used to hard-cut the content in, which read as flashy. Ease it in instead: a
   gentle fade + small upward drift as the article mounts. ease-out so it
   decelerates into place (calm), not a bounce. Honored-once on mount; the
   detail article is freshly created per /event/:slug open, so it doesn't re-run
   on reactive updates. Disabled under prefers-reduced-motion (below). */
@keyframes page-enter {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.detail-event {
  /* Opacity-only, ~150ms: a calm fade with NO movement, so it can never cause a
     layout shift (the earlier 380ms + translateY drift read as flashy/reflowy).
     Disabled under prefers-reduced-motion (below). */
  animation: page-enter 150ms ease both;
}

.detail-title {
  /* Heavier 'osr' cut (matches the card headline) so the title reads heavier
     than the chronicle body prose, not lighter. */
  font-size: 20px;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  line-height: 1.18;
  margin: 0 0 1rem;
  font-weight: 500;
  font-family: "osr", "osl", Georgia, serif;
}

.detail-meta {
  display: flex;
  gap: 0.75rem;
  align-items: baseline;
  color: var(--gilt);
  font-size: 1rem;
  margin-bottom: 0.3rem;
}
.detail-place::before { content: "· "; opacity: 0.6; }

.detail-section { margin: 1.6rem 0; }
.detail-section h2 {
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  opacity: 0.6;
  margin: 0 0 0.5rem;
  font-weight: 600;
}
.detail-section h3 {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  opacity: 0.55;
  margin: 0 0 0.35rem;
}

.detail-source { opacity: 0.85; font-size: 0.95rem; margin: 0; }
.detail-tags p { opacity: 0.7; font-size: 0.9rem; margin: 0; }

/* Chronicle chapter blockquote (ported ChronicleBlockquote). */
.detail-further .story-quote {
  /* Give the chronicle source excerpt its own deeper surface (--canvas-deep) so
     it reads as a distinct, darker "source" panel rather than blending into the
     detail page (.detail-shell --canvas). Was transparent → showed the lighter
     page bg through it. --canvas-deep is the app's recessed-surface token, dark
     in both themes. Inset padding + radius so the bg reads as an intentional box
     (horizontal padding was 0). */
  background: var(--canvas-deep);
  padding: 0.9rem 1rem;
  margin: 0.5rem 0 0;
  border-radius: 10px;
}
.story-quote .quote-header {
  font-weight: 600;
  margin-bottom: 0.75rem;
}
/* quote-body now wraps one <p> per chronicle paragraph (Task 0). */
.story-quote .quote-body { margin: 0 0 0.75rem; }
.story-quote .quote-body p {
  /* Lighter 'osl' cut so the body reads lighter than the osr title. */
  font-family: "osl", "osr", Georgia, serif;
  font-size: 1.05rem;
  line-height: 1.7;
  margin: 0 0 0.9rem;
}
.story-quote .quote-body p:last-child { margin-bottom: 0; }
.story-quote .quote-attr {
  font-size: 0.8rem;
  opacity: 0.6;
  font-style: italic;
}

/* The reader card title link inherits color (no default browser blue). */
.card-title-link {
  color: inherit;
  text-decoration: none;
}
.card-title-link:hover { text-decoration: underline; text-decoration-color: var(--gilt); }

/* ── Map lens (/ overlay) — Slice C ─────────────────────────────────── */
.map-sheet {
  position: fixed;
  inset: var(--topbar-h, 48px) 0 0 0;   /* sit below the persistent topbar */
  background: var(--canvas);
  z-index: 40;
  overflow: hidden;
  /* The three-column atlas page (grid rules live in the atlas section at the
     end of this file — see "The atlas page"). */
}
/* Desktop: full-viewport, like the Lanes lens. (It was briefly capped to the
   800px app column, but a map earns a wide monitor — user call 2026-07-03.) */
.map-loading { color: var(--ink-soft); font-style: italic; padding: 1.5rem; }
.map-leaflet { flex: 1; width: 100%; height: 100%; background: var(--canvas); }
/* Voyager is a light style; nudge it toward the dark/gilt theme — LIGHT touch
   so its detail (rivers, terrain, labels) stays legible and the gilt pins pop.
   Tune at smoke (Task 6); fallback is the dark_all base (see plan Task 7). */
.map-leaflet .leaflet-tile-pane { filter: brightness(0.9) saturate(0.85) sepia(0.12); }
/* On-map REGION caps + NOTABLE-colony name labels were removed (2026-06-16):
   the CARTO basemap already labels places, so the overlaid names were redundant
   and collided. Their `.map-region-lbl` / `.map-colony-lbl` styles are gone with
   them. Colony names live in the tap popover. Only the arc from/to labels remain
   (styled below). */
/* Migration arcs (sourced mass-movements): gilt curved polylines, soft glow.
   The dashes MARCH from origin toward destination (animated stroke-dashoffset)
   so the eye reads the direction of travel. Opacity is set per arc by the arc
   Effect (draw-on / hold / fade); this styles + animates the stroke. The arc is
   drawn origin→destination, so a decreasing dashoffset flows toward the dest. */
/* One arc-march for ALL widths (desktop == mobile, operator choice). The former
   desktop variant (fine dashes 5 9, 2.6s) re-rasterized the SVG path every frame
   and stuttered on iOS Safari; this coarser/slower march (period 8+14=22, 5s,
   offset travel 44 = 2× the period for a seamless loop) is gentler everywhere
   and has far fewer dash-edge re-rasterizations per second. */
.map-arc {
  stroke-dasharray: 8 14;
  stroke-linecap: round;
  animation: map-arc-flow 5s linear infinite;
}
@keyframes map-arc-flow {
  from { stroke-dashoffset: 44; }
  to   { stroke-dashoffset: 0; }
}
/* Traced-journey route (journeys.rs): a longer, calmer dash than the migration
   arc — a documented route told in one line. Same deep-oxblood ink; the longer
   dash + numbered stops (below) keep it distinct from a migration arc. */
.map-journey {
  stroke-dasharray: 10 15;
  stroke-linecap: round;
  animation: map-arc-flow 6s linear infinite;
}
/* Numbered waypoint dot: an oxblood disc with a paper-white number, a paper halo
   so it reads over any terrain. The climax stop (Grant) is a filled star. */
.map-journey-dot {
  display: flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 50%;
  background: #7a1e1e; color: #f2ecdc;
  font: 700 11px/1 Georgia, serif;
  border: 1.5px solid #f7f2e8;
  box-shadow: 0 0 0 1px rgba(122,30,30,.85), 0 0 7px 2px rgba(247,242,232,.85);
  pointer-events: none;
}
.map-journey-dot.is-climax {
  width: 26px; height: 26px;
  background: #a82828; font-size: 15px;
  border-width: 2px;
  box-shadow: 0 0 0 1.5px rgba(74,16,16,.9), 0 0 9px 3px rgba(247,242,232,.9);
}
/* Transparent per-stop tap target — makes a small dot comfortably tappable
   without changing its look (same trick as .scrub-bm-flag's hit-expander). */
.map-journey-hit { cursor: pointer; }
/* Journey popover (the one Selected slot) — matches the raid/migration snippet
   look with the oxblood spine. */
.map-sel.is-journey .jrny-pop-snippet {
  font-style: italic; color: #b8b09e; line-height: 1.5;
  border-left: 2px solid #7a1e1e; padding-left: 9px; margin-top: 2px;
}
.map-sel.is-journey .jrny-pop-cite {
  color: #998f7d; font-size: 11px; margin-top: 6px;
  letter-spacing: 0.02em;
}
/* Leaflet ships a default `.leaflet-div-icon { background:#fff; border:1px solid
   #666 }` on EVERY divIcon — that was the white box behind our text labels (it's
   a different class on the same element, so the .map-arc-lbl rules never touched
   it). Strip it for all our map divIcons; each label/dot supplies its own look. */
.map-leaflet .leaflet-div-icon { background: none; border: none; }

/* Endpoint markers: a small ring at each arc end, and the from/to name labels.
   The arc's draw-on animation (@keyframes map-arc-flow) and its driver are
   UNCHANGED — these endpoints/labels only wear the same paper halo/pill every
   other mark on the plate wears, so the arc stops looking like a different
   piece of software (spec 2026-07-12). */
.map-arc-end {
  border: 1.5px solid #f7f2e8; border-radius: 50%;
  pointer-events: none;
  /* Outer ring tinted deep-oxblood to echo the route line (was neutral brown
     rgba(70,55,30,.8)); paper halo unchanged. */
  box-shadow: 0 0 0 1px rgba(122,30,30,.85), 0 0 8px 3px rgba(247,242,232,.8);
}
/* Destination = filled deep oxblood (#7a1e1e), matching the recolored route
   line (was gold rgba(216,176,74,.85)). */
.map-arc-end.is-to { background: rgba(122,30,30,.9); }
.map-arc-lbl-box {
  /* Positioning only: the fixed-size divIcon box that centers the inner chip
     over the endpoint. Same two-element pattern as .map-colony-lbl-box —
     painting the chip class directly on the divIcon stretched it to the full
     120px iconSize box (the clipped/oversized-chip bug). */
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
}
.map-arc-lbl {
  /* Option C: a light parchment chip, deliberately DISTINCT from the dark
     colony pill (.map-colony-lbl) so a movement-label never reads as a
     place-label. Literal colors — the map is always light regardless of app
     theme. inline-block so it hugs its text inside the centering box.
     Palette folded to the plate's engraved-hand values (spec 2026-07-12). */
  display: inline-block;
  font-family: var(--serif-body); font-size: 11px; font-weight: 600;
  white-space: nowrap; pointer-events: none;
  padding: 2px 8px 2px 7px; border-radius: 3px;
  background: rgba(247,242,232,.94); color: #2b2722;
  border: 1px solid rgba(120,100,70,.35);
  box-shadow: 0 1px 3px rgba(0,0,0,.12);
  -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
}
.map-arc-lbl .dir {
  color: #9a7b3a; font-style: italic; font-weight: 400; margin-right: 3px;
}
/* Leaflet chrome themed to the reader (dark, gilt) */
.map-leaflet .leaflet-control-attribution {
  background: var(--overlay-bg); color: var(--ink-soft); font-size: 10px;
}
.map-leaflet .leaflet-control-attribution a { color: var(--gilt); }
.map-leaflet .leaflet-bar a {
  background: var(--overlay-bg); color: var(--gilt);
  border-bottom-color: rgba(179,146,80,.25);
}
.map-leaflet .leaflet-bar a:hover { background: rgba(179,146,80,.15); }
.map-leaflet .leaflet-popup-content-wrapper, .map-leaflet .leaflet-popup-tip { display: none; }
/* (The Map filmstrip scrubber — the old .map-strip / .map-band track and its
   2800px pixel-width run — was retired 2026-07-12. Its replacement, the
   engraved ruler, is the ".map-scale" section at the END of this file.) */
.card-launch-row { display: flex; gap: 0.5rem; margin-top: 0.6rem; }
/* Trash on its own line at the bottom of the card, pushed to the right. Used by
   BOTH renderers (EventCard + SubCard). The row only has content in edit mode
   (DeleteCard renders nothing otherwise), so it collapses to ~0 height while
   reading. justify-content:flex-end isolates the destructive control at the far
   right, away from the rest of the card. */
.card-delete-row { display: flex; justify-content: flex-end; }
.jx-launch-map { /* inherits .jx-launch; distinct hover if desired */ }

/* Respect the OS "reduce motion" setting. Users who ask for less motion get an
   instant page swap (no entrance fade/drift) and an instant tap state, never a
   crawl.
   The marching arcs USED to be exempted here as "functional", alongside the
   admin spinner. They are not: an infinitely looping dash-march is the largest
   moving element on the plate, every neighbouring map layer is already guarded
   (raids, world events, layer switches, the mark swell), and the direction the
   arc reads is carried by its endpoints and labels, not by the motion. Frozen,
   the dashes simply sit still — nothing is lost.
   The admin spinner (.admin-refresh-btn.spinning::after, `admin-spin … infinite`)
   IS still exempt, deliberately: it is a busy indicator, and a frozen spinner
   reads as a hung request. It is the only looping animation in this file left
   unguarded. */
@media (prefers-reduced-motion: reduce) {
  .detail-event { animation: none; }
  .map-arc, .map-journey { animation: none; }
  .tb-seg, .topbar-btn, .tb-theme, .tb-admin, .topbar-back,
  .tb-seg:active, .topbar-btn:active,
  .tb-theme:active, .tb-admin:active, .topbar-back:active {
    transition-duration: 0ms;
  }
}

/* ── Summary search ─────────────────────────────────────────────── */
/* Match the Adm/Theme icon buttons: same boxed treatment (.topbar-btn) and the
   same compact icon-only box + 20px shared .tb-icon (no stroke-width override,
   so the magnifier reads at the same line weight as its neighbors). */
.tb-search { padding: 2.5px 7px; min-width: 0; justify-content: center; }
.tb-search.active { color: var(--gilt); }

.search-overlay {
  position:fixed; left:0; right:0; top:var(--topbar-h); bottom:0;
  background:var(--page-bg); z-index:40;
  display:none; flex-direction:column;
}
.search-overlay.search-open { display:flex; }

.search-bar {
  position:sticky; top:0; display:flex; align-items:center; gap:10px;
  padding:10px 14px; background:var(--bar-tint);
  -webkit-backdrop-filter:blur(10px); backdrop-filter:blur(10px);
  border-bottom:1px solid var(--rule);
}
.search-input {
  /* font-size:16px is load-bearing: <16px triggers iOS focus auto-zoom. */
  flex:1; min-width:0; background:var(--canvas-deep); border:1px solid var(--gilt-deep);
  color:var(--ink); font-family:var(--serif-body); font-size:16px;
  padding:9px 12px; border-radius:8px; outline:none;
}
.search-input::placeholder { color:var(--ink-faint); }
/* The `outline:none` above removed the UA focus ring and put nothing back — this
   was the only stripped outline in the file with no replacement cue (.auth-input
   swaps its border-color, .field-live keeps a dotted underline + caret, and the
   grab tabs suppress the ring only for :focus:not(:focus-visible)). Restore both
   halves: a warmed border for ANY focus, and the app's shared gilt ring
   (.bar-grab / .rr-tab) for keyboard focus specifically. */
.search-input:focus { border-color: var(--gilt); }
.search-input:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }
/* ≥44px box: this is the one control beside the field in a mobile search bar,
   and at 18px + 4px padding it was a ~26px target. The glyph is centred, so the
   look is unchanged. */
.search-clear {
  background:none; border:none; color:var(--ink-dim); font-size:18px;
  cursor:pointer; padding:4px 8px;
  min-width:44px; min-height:44px; flex:none;
  display:inline-flex; align-items:center; justify-content:center;
}

.search-results { overflow-y:auto; padding:4px 14px 40px; }
.search-empty { color:var(--ink-faint); font-size:14px; padding:24px 6px; text-align:center; }
.search-list { display:block; }
.search-hit {
  display:block; width:100%; text-align:left; background:none; border:none;
  border-bottom:1px solid var(--rule-soft); padding:13px 4px; cursor:pointer;
  color:inherit; font-family:var(--serif-body);
}
.search-hit:hover { background:rgba(179,146,80,.06); }
.search-hit-snip { color:var(--ink-soft); font-size:14px; line-height:1.45; }
.search-hit-snip mark { background:rgba(179,146,80,.28); color:var(--ink); border-radius:2px; padding:0 1px; }
.search-fuzzy {
  color:var(--gilt-deep); font-size:11px; letter-spacing:.04em;
  border:1px solid var(--gilt-deep); border-radius:4px; padding:1px 5px;
  margin-left:8px; text-transform:uppercase; vertical-align:middle;
}

/* Pulse the landed card after a search jump. The class lingers after the jump
   (no JS timer removes it — see jump_to_event); the animation runs once and
   ends transparent, so a lingering class is invisible. jump_to_event forces a
   reflow before re-adding the class so a repeat jump to the same card re-fires
   the animation. */
@keyframes search-pulse-kf {
  0%   { background:rgba(179,146,80,.18); }
  100% { background:transparent; }
}
@media (prefers-reduced-motion: no-preference) {
  .search-pulse { animation:search-pulse-kf 1.2s ease-out; }
}

.search-hit-head { display:flex; align-items:baseline; gap:8px; margin-bottom:5px; }
.search-hit-badge { width:8px; height:8px; border-radius:2px; flex:none; transform:translateY(1px); }
.search-hit-title { font-family:var(--serif-display); color:var(--head); font-size:15px; line-height:1.25; }
.search-hit-year { color:var(--ink-faint); font-size:13px; margin-left:auto; flex:none; }


/* Constrain the search overlay's inner content to the reader column width
   (matching .reader-main's 800px desktop cap) so the bar + results align with
   the card stream instead of running full-bleed on wide viewports. */
.search-bar, .search-results {
  width: 100%;
  max-width: 800px;
  margin-left: auto;
  margin-right: auto;
}


/* ── In-stream editing (edit-in-place) ──────────────────────────────
   Each field shows its own value; in edit mode a small pen sits INLINE
   right after the value. Tapping the pen turns the field's OWN text into a
   contenteditable span — same font/size/color/position, NO input box — and
   saving writes the new text back into the same spot. */
/* TopBar edit toggle carries `.topbar-btn .topbar-btn-admin .tb-admin` in the
   markup, so it inherits the Adm button's box/icon size/spec for free (desktop
   AND mobile). `.tb-edit` sets NO dimensions — it only colors the active state. */
.tb-edit.active { color: var(--gilt); }
/* (`.field-edit`, a wrapper around value+pen, was deleted 2026-07-25 — the pen
   is emitted inline next to the value with no wrapper element.) */
/* Inline pen: sits immediately after the field's value text. */
.field-pen {
  background: none; border: none; cursor: pointer; padding: 0 2px;
  color: var(--steel); opacity: .55; vertical-align: middle;
  display: inline-flex; align-items: center;
}
.field-pen:hover { opacity: 1; }
.pen-icon { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2; }
/* The gear is a FILLED silhouette (a solid toothed cog), not a single-stroke
   line like the pen — so it fills with currentColor and draws no stroke. Sized a
   touch larger so the teeth are legible. */
.gear-icon { fill: currentColor; stroke: none; width: 15px; height: 15px; }
/* Faint placeholder shown for an empty field while editing. */
.field-placeholder { opacity: .4; font-style: italic; }
/* The editable text itself: inherits the field's typography (the span carries
   the field's text-class), so it looks identical to the displayed text. NO
   border, NO background box. A dotted underline + caret are the only cues that
   it's live; the focus outline is suppressed in favor of the underline. */
.field-live {
  outline: none;
  /* Match the DISPLAYED text exactly. The span carries the field's text-class
     and sits inside the field's display container (.card-title / .card-body),
     but a contenteditable span was still picking up a different font at runtime;
     `font: inherit` pins family/size/weight/style to the parent so the editor
     looks identical to the saved text. (text-transform isn't a font property but
     is inherited from the parent anyway, so the title stays uppercase.) */
  font: inherit;
  border-bottom: 1px dotted var(--steel);
  caret-color: var(--accent, var(--ink));
  cursor: text;
  /* Separate the editable text from the ✓/✕ controls that follow it. */
  margin-right: 10px;
}
.field-live:focus { border-bottom-color: var(--accent, var(--steel)); }
/* Kind stays a real <select> (fixed choice list) — keep a light control look. */
.fe-input {
  font: inherit; padding: 1px 4px; border: 1px solid var(--steel);
  border-radius: 4px; background: var(--page-bg); color: var(--ink);
}
/* ✓ / ✕ save+cancel controls: roomy tap targets, clearly separated from each
   other and from the text being edited. */
.fe-ok, .fe-cancel {
  background: none; border: none; cursor: pointer;
  font-size: 1.15em; line-height: 1;
  padding: 4px 6px; margin-left: 6px;
  border-radius: 4px;
}
.fe-ok:hover { background: color-mix(in srgb, var(--moss) 18%, transparent); }
.fe-cancel:hover { background: color-mix(in srgb, var(--oxblood) 18%, transparent); }
.fe-ok { color: var(--moss); }
.fe-cancel { color: var(--oxblood); }

/* Per-card metadata form (year / kind / weight / location) — a gear button
   opens a popover so these "settings" never clutter the card face. */
.card-meta-form { position: relative; display: inline-flex; align-items: center; }
.meta-gear { margin-left: 8px; }
.meta-gear .pen-icon { width: 15px; height: 15px; }
/* Trash control: red-tinted so it reads as destructive, set apart from the gear. */
.delete-card { margin-left: 10px; color: var(--oxblood); }
.delete-card .pen-icon { width: 15px; height: 15px; }
.delete-card:hover { opacity: 1; }
/* Full-viewport transparent catcher: click outside the popover to dismiss.
   Always mounted; shown only with `.open` (CSS toggle, never <Show> — the
   backdrop's own click closes it, and unmounting a click target mid-dispatch
   crashes Leptos; see leptos_show_unmount_own_handler_crash). */
.meta-backdrop {
  position: fixed; inset: 0; z-index: 60; background: transparent;
  display: none;
}
.meta-backdrop.open { display: block; }
/* Settings popover — FIXED to the viewport (not absolute-to-the-gear) at ALL
   widths. Absolute+left:0 off the gear was fragile: from a gear that sits
   partway across the card, `left:0; width:280px` ran off the right edge on
   narrow viewports, AND because placeholder cards use opacity<1 (a stacking
   context) the popover's z-index couldn't beat sibling cards, so it hid behind
   the next card. position:fixed escapes BOTH: it clamps to the viewport (never
   overflows) and stacks against the viewport root (above any card's opacity
   context). Centered horizontally, pinned just below the topbar, height-capped
   with internal scroll. z-index sits above the topbar. */
.meta-popover {
  position: fixed; z-index: 200;
  top: calc(var(--topbar-h) + 10px);
  left: 50%; transform: translateX(-50%);
  width: min(320px, calc(100vw - 24px));
  max-width: calc(100vw - 24px);
  padding: 12px;
  background: var(--page-bg); color: var(--ink);
  border: 1px solid var(--steel); border-radius: 8px;
  box-shadow: 0 12px 32px rgba(0,0,0,.4);
  flex-direction: column; gap: 8px;
  /* Was `var(--vfy-sans, inherit)`. --vfy-sans was the retired /verify
     workstation's control font and is defined NOWHERE, so this only ever
     resolved to the fallback — the reference was a dangling token pretending to
     be a theme hook. */
  font-family: inherit; font-size: .85rem;
  max-height: calc(100vh - var(--topbar-h) - 24px); overflow-y: auto;
  box-sizing: border-box;
  display: none;
}
.meta-popover.open { display: flex; }
/* A dimming backdrop behind the fixed popover so it reads as a focused modal
   (and taps outside are visually distinct). Reuses the always-mounted
   .meta-backdrop element; only its look changes for the fixed layout. */
.meta-backdrop.open { background: rgba(0,0,0,.28); }
/* Pin a compact control font INSIDE the popover. .fe-input uses `font: inherit`
   (the shorthand resets size+family) so without this the year/weight/etc inputs
   render at the card's big reading-serif size, blowing the popover up to ~700px
   tall and pushing Save/Cancel out past the card. Force a small sans control. */
.meta-popover .fe-input,
.meta-popover .meta-save,
.meta-popover .meta-cancel {
  /* (Same dangling --vfy-sans as above, dropped 2026-07-25.) */
  font-family: system-ui, sans-serif;
  font-size: .85rem; line-height: 1.3;
}
.meta-row { display: flex; align-items: center; gap: 8px; }
.meta-label { flex: 0 0 64px; color: var(--ink-faint); font-size: .78rem; }
.meta-row .fe-input { flex: 1 1 auto; min-width: 0; padding: 4px 6px; }
/* (No card-lift needed now: the popover is position:fixed, so it stacks against
   the viewport root and is no longer trapped in a placeholder card's opacity
   stacking context.) */
/* Publish toggle row in the settings popover: a checkbox + label, not a flexed
   input. Left-aligned, compact, inherits the popover's pinned sans font. */
.meta-checkbox-row { gap: 8px; cursor: pointer; }
.meta-checkbox-row input[type="checkbox"] {
  width: 16px; height: 16px; margin: 0; flex: 0 0 auto; accent-color: var(--moss);
}
.meta-checkbox-row .meta-label { flex: 0 0 auto; }
.meta-actions {
  display: flex; gap: 8px; justify-content: flex-end;
  margin-top: 4px; padding-top: 8px;
  /* Keep the actions visibly attached to the form even when the body scrolls. */
  position: sticky; bottom: -12px;
  background: var(--page-bg); border-top: 1px solid var(--steel);
}
.meta-save, .meta-cancel {
  font-size: .85rem; padding: 5px 14px; border-radius: 6px; border: none; cursor: pointer;
}
.meta-save { background: var(--moss); color: var(--page-bg); }
.meta-cancel { background: none; color: var(--ink-faint); }

.add-entry-fab {
  position: fixed; right: 20px; bottom: 20px; z-index: 50;
  padding: 12px 18px; border-radius: 24px; border: none; cursor: pointer;
  background: var(--gilt); color: #1a1206; font-weight: 600;
  box-shadow: 0 4px 14px rgba(0,0,0,.3);
}

.card.placeholder, .sub-card.placeholder {
  /* NO `opacity` here. opacity<1 makes the ENTIRE card subtree translucent —
     INCLUDING the settings popover nested inside it — so the popover panel went
     see-through (card text bled through it) and its z-index got trapped in the
     card's stacking context. A child cannot opt out of a parent's opacity, and
     position:fixed does not escape it (still a DOM descendant). So placeholders
     are distinguished WITHOUT opacity: a slightly muted title color + the
     register pin / dup badge. */
}
/* Mute just the placeholder's own text (not descendants like the popover) so
   the card still reads as "faint/unpublished" without the opacity trap. */
.card.placeholder .sub-card-title, .sub-card.placeholder .sub-card-title,
.card.placeholder .card-title, .sub-card.placeholder .card-title { color: var(--ink-dim, #998f7d); }
.placeholder-pin {
  font-size: 0.78em;
  color: var(--steel-deep, #5a6b7a);
  white-space: nowrap;
  margin-top: 0.15rem;
}
.dup-badge { font-size: 0.7em; color: var(--gilt-deep, #8a6d3b); border: 1px solid currentColor; border-radius: 3px; padding: 0 0.25em; margin-left: 0.4em; }
.placeholder-detail-line { font-style: italic; }
.placeholder-detail-page { font-size: 0.85em; color: var(--steel-deep, #5a6b7a); }

/* Re-source ("Edit source") picker.
   THEME TOKENS, not invented ones. This panel used to read
   `background: var(--panel, #fff)` and `border: … var(--line, #ccc)` — and
   NEITHER --panel nor --line is defined anywhere in this file, so the literal
   fallbacks always won: a white sheet with no `color` of its own, so its text
   inherited --ink (#d8d0bf) — bone-on-white at ~1.5:1 in the DEFAULT (dark)
   theme. Swapped to --page-bg / --rule, which flip with the theme, plus an
   explicit --ink so the panel never depends on what it happens to inherit.
   The hover/current washes moved with it: black-alpha over a dark panel is
   invisible, so they use the theme's own recessed surfaces. */
/* `color: inherit` on every button here: `font: inherit` does NOT carry colour,
   so a transparent button fell back to the UA's ButtonText (black) — invisible
   on the dark card, and newly so inside the now-themed modal. */
.sp-open-btn { font: inherit; color: inherit; padding: 0.4rem 0.8rem; border: 1px solid var(--rule); border-radius: 6px; background: transparent; cursor: pointer; }
.sp-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.35); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 200; }
.sp-backdrop.open { opacity: 1; pointer-events: auto; }
.sp-modal { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.98); width: min(680px, 92vw); max-height: 82vh; overflow: auto; background: var(--page-bg); color: var(--ink); border: 1px solid var(--rule); border-radius: 10px; padding: 1rem; opacity: 0; pointer-events: none; transition: opacity 0.15s, transform 0.15s; z-index: 201; }
.sp-modal.open { opacity: 1; pointer-events: auto; transform: translate(-50%, -50%) scale(1); }
.sp-filter { width: 100%; margin-bottom: 0.6rem; }
.sp-chapters { display: flex; flex-direction: column; gap: 2px; max-height: 60vh; overflow: auto; }
/* min-height: a chapter row is a single line of ~0.35rem-padded text (~28px) in
   a list you scroll and tap on a phone. The button centres its own label, so the
   only change is the target. */
.sp-chapter { text-align: left; padding: 0.35rem 0.5rem; min-height: 44px; flex: none; border: none; background: transparent; color: inherit; cursor: pointer; border-radius: 4px; font: inherit; }
.sp-chapter:hover { background: var(--rule-soft); }
.sp-current { font-weight: 600; background: var(--rule); }
.sp-hint, .sp-note, .sp-err { font-size: 0.85rem; opacity: 0.8; }
.sp-err { color: #b00; }
.sp-paras { display: flex; flex-direction: column; gap: 4px; max-height: 55vh; overflow: auto; }
.sp-para { text-align: left; padding: 0.4rem 0.5rem; border: 1px solid transparent; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; font: inherit; }
.sp-para:hover { border-color: var(--rule); }
.sp-para-sel { background: rgba(80,140,90,0.18); border-color: rgba(80,140,90,0.5); }
.sp-actions, .sp-stage1 > .fe-cancel { margin-top: 0.6rem; }
.sp-back { border: none; background: transparent; color: inherit; cursor: pointer; font: inherit; margin-bottom: 0.5rem; }

/* ── Settings-legend (now pinned at the top of the RIGHT cork board) ───────
   The legend is rendered by corkboard.rs inside .cork-right (it no longer
   hangs free in the margin — see 2026-07-02-contributor-corkboard-design.md,
   which supersedes the standalone pegboard). It scrolls internally so the
   long Weight paragraph never overflows a short board. Width gating comes
   from the same 1340px media query as the boards. */
.settings-legend { display: none; }   /* default hidden; the gate below shows it */

@media (min-width: 1340px) {
    .settings-legend {
        display: block;
        position: absolute;            /* within .cork-right */
        top: 14px;                     /* ＋ Note now lives on the LEFT board */
        left: 14px;
        right: 14px;
        max-height: calc(60vh - var(--topbar-h, 48px));
        overflow-y: auto;
        z-index: 2;
        padding: 14px 14px 16px;
        background: var(--canvas);
        border: 1px solid var(--rule);
        border-radius: 6px;
        box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
        font-family: var(--serif-display, Georgia, serif);
        color: var(--ink);
    }
    .settings-legend-title {
        font-size: 12px;
        text-transform: uppercase;
        letter-spacing: 0.08em;
        color: var(--ink-faint);
        border-bottom: 1px solid var(--rule);
        padding-bottom: 8px;
        margin-bottom: 10px;
    }
    .settings-legend-row { margin-bottom: 11px; }
    .settings-legend-row:last-child { margin-bottom: 0; }
    .settings-legend-field {
        font-size: 13px;
        font-weight: 600;
        color: var(--ink);
        margin-bottom: 2px;
    }
    .settings-legend-body {
        font-size: 12px;
        line-height: 1.5;
        color: var(--ink-soft, var(--ink));
    }
    /* Weight's fuller paragraph — a hair more separation so the long block reads
       as its own unit. */
    .settings-legend-row-wide { margin-top: 4px; margin-bottom: 13px; }
}

/* ── Contributor cork-boards + post-it notes (desktop, edit-mode) ──────────
   Two cork boards fill the dead margins outside the 800px column (component:
   reader/src/reader/corkboard.rs; data: D1 corkboard_notes, shared across
   contributors). Rendered only in edit mode + Reader lens (component gates);
   this media query is the width gate — no board below 1340px (that gate is
   in CSS px: browser ZOOM shrinks the CSS viewport, so zooming in far
   enough hides the boards by design). Notes are absolutely-positioned
   CHILDREN of their board, placed in percentages of the board (inline style
   from corkboard.rs) — the mockup's model; no viewport math anywhere.
   Design source: drafts/corkboard-mockup.html (gitignored). */
.corkboard { display: none; }

@media (min-width: 1340px) {
    .corkboard { display: block; }

    .cork {
        position: fixed;
        top: var(--topbar-h);
        bottom: 0;
        z-index: 6;                    /* below scrubber(9)/topbar(20) */
        /* visible: notes are CHILDREN of the board and may overhang the
           frame slightly (and ride over the column mid-drag) */
        overflow: visible;
        /* own compositor layer — the big gradient texture occasionally
           failed to repaint without it (right board showed bare) */
        will-change: transform;
        /* wood-plank texture: vertical seams every ~120px (plank joins) plus
           two offset layers of wavy grain lines (irregular spacing/angle so
           it doesn't read as a repeating pattern), over a warm base with a
           soft diagonal sheen. */
        background-color: var(--cork);
        background-image:
            repeating-linear-gradient(90deg,
                rgba(0,0,0,.22) 0 2px, transparent 2px 120px),
            repeating-linear-gradient(2deg,
                rgba(0,0,0,.09) 0 1px, transparent 1px 7px,
                rgba(0,0,0,.05) 7px 8px, transparent 8px 15px,
                rgba(0,0,0,.07) 15px 16px, transparent 16px 26px),
            repeating-linear-gradient(-1.4deg,
                rgba(255,255,255,.045) 0 1px, transparent 1px 19px,
                rgba(0,0,0,.04) 19px 20px, transparent 20px 31px),
            linear-gradient(100deg, rgba(255,255,255,.06) 0%, transparent 40%),
            linear-gradient(145deg, var(--cork) 0%, var(--cork-dark) 100%);
        box-shadow: inset 0 0 40px rgba(0,0,0,.25), inset 0 2px 4px rgba(0,0,0,.3);
        border: 6px solid var(--cork-frame);   /* wooden frame edge */
        border-radius: 3px;
    }
    .cork-left  { left: 0;  width: calc(50% - 400px); }
    .cork-right { right: 0; width: calc(50% - 400px); }

    .add-note {
        position: absolute;
        top: 12px;
        right: 12px;
        z-index: 3;
        font: inherit;
        font-size: 12px;
        letter-spacing: .03em;
        background: rgba(0,0,0,.28);
        color: #f4ead6;
        border: 1px solid rgba(0,0,0,.35);
        border-radius: 6px;
        padding: 5px 11px;
        cursor: pointer;
        box-shadow: 0 1px 2px rgba(0,0,0,.3);
    }
    .add-note:hover { background: rgba(0,0,0,.42); }

    /* ── Post-it note ── a CHILD of its board: inline percentages of the
       board (percent_style in corkboard.rs); these px min/max keep the
       rendered size readable regardless of board size. */
    .cork-note {
        position: absolute;
        z-index: 7;                    /* above the board texture */
        min-width: 110px;
        min-height: 90px;
        max-width: 340px;
        max-height: 420px;
        padding: 20px 12px 12px;       /* top room for the corner tab */
        font-family: var(--serif-body);
        font-size: 13px;
        line-height: 1.45;
        color: var(--sticky-ink, var(--sticky-yellow-ink));
        background: var(--sticky-bg, var(--sticky-yellow));
        box-shadow: 0 4px 10px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.25);
        border-radius: 2px;
        transform: rotate(var(--rot, -2deg));
        transition: box-shadow .12s ease;
        /* Native resize grip, bottom-right. overflow:hidden clips the grip to
           the rounded corner; the settled size is saved via ResizeObserver. */
        resize: both;
        overflow: hidden;
        display: flex;
        flex-direction: column;
    }
    .cork-note.dragging {
        box-shadow: 0 14px 26px rgba(0,0,0,.5);
        z-index: 40;                   /* over the column mid-drag is fine */
        transition: none;
    }

    /* corner tab = drag handle (folded-corner look) with a red pin */
    .cn-tab {
        position: absolute;
        top: 0; left: 0; right: 0;
        height: 16px;
        cursor: grab;
        border-radius: 2px 2px 0 0;
        background: linear-gradient(to bottom, rgba(0,0,0,.14), rgba(0,0,0,0));
        touch-action: none;            /* pointer events own the gesture */
        user-select: none;
        -webkit-user-select: none;
    }
    .cork-note.dragging .cn-tab { cursor: grabbing; }
    .cn-tab::before {
        content: "";
        position: absolute;
        top: 3px; left: 50%;
        transform: translateX(-50%);
        width: 8px; height: 8px;
        border-radius: 50%;
        background: radial-gradient(circle at 35% 30%, #fff6, var(--oxblood) 60%, var(--oxblood-deep));
        box-shadow: 0 1px 2px rgba(0,0,0,.4);
    }

    .cn-body { outline: none; flex: 1 1 auto; overflow: auto; }
    .cn-body:empty::before { content: "note…"; color: rgba(0,0,0,.35); }

    .cn-close {
        position: absolute;
        top: 2px; right: 4px;
        border: none; background: none; cursor: pointer;
        color: rgba(0,0,0,.4);
        font-size: 14px; line-height: 1; padding: 2px;
        opacity: 0;
        transition: opacity .12s ease;
    }
    .cork-note:hover .cn-close,
    .cork-note:focus-within .cn-close { opacity: 1; }
    .cn-close:hover { color: rgba(0,0,0,.8); }

    /* swatch palette — hidden at rest (height collapses: no empty gap);
       revealed on hover/focus-within. Pinned to the bottom as the note grows. */
    .cn-swatches {
        display: flex;
        gap: 5px;
        height: 0;
        margin-top: auto;
        overflow: hidden;
        opacity: 0;
        pointer-events: none;
        transition: opacity .12s ease, height .14s ease;
    }
    .cork-note:hover .cn-swatches,
    .cork-note:focus-within .cn-swatches {
        height: 13px;
        opacity: 1;
        pointer-events: auto;
    }
    .cn-sw {
        width: 13px; height: 13px;
        border-radius: 50%;
        cursor: pointer;
        border: 1px solid rgba(0,0,0,.25);
    }
    .cn-sw.active { outline: 2px solid rgba(0,0,0,.5); outline-offset: 1px; }
    .cn-sw-theme  { background: var(--sticky-theme-bg); }
    .cn-sw-yellow { background: var(--sticky-yellow); }
    .cn-sw-pink   { background: var(--sticky-pink); }
    .cn-sw-blue   { background: var(--sticky-blue); }
    .cn-sw-green  { background: var(--sticky-green); }

    /* color variants (class set reactively in Rust: cn-<color>) */
    .cork-note.cn-theme {
        --sticky-bg:  var(--sticky-theme-bg);
        --sticky-ink: var(--sticky-theme-ink);
        border: 1px solid var(--rule);
    }
    .cork-note.cn-yellow { --sticky-bg: var(--sticky-yellow); --sticky-ink: var(--sticky-yellow-ink); }
    .cork-note.cn-pink   { --sticky-bg: var(--sticky-pink);   --sticky-ink: var(--sticky-pink-ink); }
    .cork-note.cn-blue   { --sticky-bg: var(--sticky-blue);   --sticky-ink: var(--sticky-blue-ink); }
    .cork-note.cn-green  { --sticky-bg: var(--sticky-green);  --sticky-ink: var(--sticky-green-ink); }
    /* theme note: dark-alpha affordances are invisible on a dark card — use ink vars */
    .cork-note.cn-theme .cn-body:empty::before { color: var(--ink-faint); }
    .cork-note.cn-theme .cn-close { color: var(--ink-faint); }
    .cork-note.cn-theme .cn-close:hover { color: var(--ink); }
}
/* ^ closes @media (min-width: 1340px) — the corkboard width gate. The
   chronicle section below was once appended INSIDE that block (its closer
   drifted to EOF), scoping every .chron-* rule to ≥1340px and leaving
   mobile completely unstyled. styles_lint.rs now guards this. */

/* ── Chronicle reader (/chronicle) ─────────────────────────────── */

/* The plate layout: one wrapping flex row. `main` centres its own children at
   a measure of 44 × --chron-fs — without that the column grows on wide screens
   while the prose stays put, leaving a dead gap before the aside.
   The flex bases and all pane chrome live in the @media (min-width: 600px)
   block below (including the ⚠ 895 basis-sum constraint) — read the note
   there before touching pane geometry. */
.chron-page {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    /* Raised 1340 -> 1600. The side panes cap at 300px each, so everything
       this adds goes to the reading column and nothing else moves: at 1340 the
       measure came out ~700px, short of its own 52 × --chron-fs ceiling, which
       meant the cap was decorative and the real constraint was the page. The
       LEGACY grid is unaffected — `.chron-page.legacy-grid` sets its own
       max-width: 1280px, which is what the ≥1340px margin-float rules key on. */
    max-width: 1600px;
    margin: 0 auto;
    padding: var(--topbar-h) 0 60px;
    /* ⚠ REQUIRED once .chron-col is capped below its flex share. Capped items
       freeze, the sides freeze at their own 300px max, and the remainder is
       leftover FREE SPACE — which flex-start would pile entirely on the right,
       shoving the whole plate off-axis from the viewport-centred topbar. Centred,
       it splits into equal outer margins and the axes still coincide. */
    justify-content: center;
    /* Apparatus size — ONE ratio, now TWO consumers (the year register and the
       prev/next kicker). It was four until 2026-08-09, when the chapter spine
       was removed and took its caption and its cell labels with it; the two
       sub-ratios that held those to this token, 0.91 and 0.86, went with them.
       The token stays shared rather than being folded back into its remaining
       consumers — the point was never the count. The operator's decision
       2026-08-08: the apparatus SCALES with the Text size control, but at half
       rate, so it grows for a low-vision reader while staying visibly
       subordinate to the prose.
       The max() floor does real work: at the default 17px it resolves to
       exactly 11px, so every consumer keeps the size it has today, and at the
       14px end it cannot shrink below that. This may only ever grow the
       apparatus. The three sub-ratios below it preserve each consumer's
       existing proportion to the register (10/11, 9.5/11) rather than
       flattening four deliberate sizes into one. */
    --chron-apparatus: max(11px, calc(var(--chron-fs, 17px) * 0.5));
    /* The year register's box, and the margin it sits in. Both DERIVED from
       the apparatus, which is the fix as much as the new size is: the box was
       a fixed 56px in a fixed 72px gutter, sized for an 11px year, while the
       year itself scales to 32px at the accessibility ceiling — where "1576"
       measures ~70px and silently overflowed its own box.
       2.8 x apparatus is that measured year (~28px at the default 11px) plus a
       hair; 1.2 x is the gap to the prose. Total gutter 4 x apparatus = 44px,
       down from 72px — the operator's read on 2026-08-09 was that the margin
       was too wide, and the circled part was the DEAD half: a right-aligned
       year in a 56px box left ~28px of nothing to its left. Now ~3px. */
    --chron-register-w: calc(var(--chron-apparatus) * 2.8);
    --chron-register-gutter: calc(var(--chron-register-w) + var(--chron-apparatus) * 1.2);
    /* Where the glossary card's top edge sits, so it lines up with the chapter
       title. 96px is the spec's number and it is a MEASUREMENT NOBODY HAS TAKEN
       — the fourth unmeasured pixel constant in this redesign, and the previous
       three all turned out wrong. If the card and the title do not line up in
       the browser, derive this from the header's real height rather than nudging
       the number. It lives here, defined once, so there is one place to fix. */
    --chronicle-header-offset: 96px;
    /* The reading column's own side padding on desktop, and half of what
       separates the prose from a side panel (the panes add their own 16px
       inset, so the visible gutter is this + 16). Small on purpose: the
       operator's 2026-08-09 read was that the panels sat "much too far out".
       ⚠ It is ALSO the term that makes .chron-col's max-width exact — the pane
       is the measure plus these two paddings, so the prose fills it with
       nothing left to spread. Written as a literal in either place they drift
       and the slack silently returns. */
    --chron-col-pad: 16px;
}
/* Order and the phone case. EVERYTHING that treats a pane as a SIDE PANEL —
   its flex sizing, its min/max width, its stretch, and all of its chrome —
   is scoped to >=600px in the block further down, because below 600px the
   TOC is not a pane at all: it is the fixed bottom drawer, and the column is
   the only thing in flow. Two defects came from getting this wrong: a
   `max-width: 260px` clamped the full-bleed drawer to 260px, and the
   column's `min-width: 380px` pushed the page wider than a 375px phone. */
.chron-page > .chron-toc { order: 1; }
.chron-page > .chron-col {
    order: 2;
    /* Phone: the sole in-flow pane, so it takes the whole row. >=600px
       overrides this with the real flex basis. */
    flex: 1 1 100%; min-width: 0;
    display: flex; flex-direction: column; align-items: center;
    padding: 0 clamp(14px, 2vw, 40px) 90px;
    box-sizing: border-box;
}
/* The measure must track the prose size, so it is 44 × --chron-fs, NOT 44em:
   `em` resolves against THIS element's inherited 16px, which would nail the
   column to 704px while the Text size control drives the prose 14→64px. At
   64px that left ~20 characters per line after the register gutter — the
   large-text affordance made reading harder. Same idiom as .chron-ch-title
   and the year register's `top`. (--chron-fs is set inline on .chron-page,
   so it inherits here.)
   ⚠ 2026-08-09: this comment CLOSED here for a day, and everything below ran as
   live CSS. The parser read the prose plus the real selector as ONE garbage
   prelude and dropped the whole rule — width AND cap. That is the true reason
   five successive edits to the multiplier changed nothing on screen; the
   inert-cap diagnosis below is real but was secondary, and its correct min()
   shipped into a rule the browser was discarding. The lint now refuses any
   comment CLOSER that survives comment-stripping — and note that writing that
   closer literally, here, is itself the bug: this very note closed early on its
   own example and the lint caught it. Name the token, never type it.
   2026-08-09: 44 -> 52 -> 46 -> 44 -> 38, and the shape of that search is the
   useful part. Before the page widened the cap was DECORATIVE — the column ran
   out at ~703px against a 748px ceiling, so no value here was ever on trial and
   "wider" meant fixing the PAGE, not this number. Once the ceiling actually
   bound, every step up read too wide: 52 = 884px (~104 characters a line),
   46 = 782 (~92), 44 = 748 (~88). Settled at 38 = 646px, ~76 characters —
   near the classic measure, and the operator's choice from the ladder below.
   That the answer came out NARROWER than the starting 44 is not a
   contradiction: 44 never rendered as 748, only as ~703, so the whole exercise
   was widening the page and then choosing a line length on its merits.
   ⚠ AND AN ABSOLUTE CEILING, because the multiplier ALONE silently stops
   working. `38 × --chron-fs` is only a cap while it is SMALLER than the column;
   above that `width: 100%` wins and the declaration does nothing. --chron-fs is
   the reader's Text size (14→64px), so at 27px and up the cap exceeded the
   column and the prose ran the full width no matter what this number said —
   five successive edits to it changed nothing on screen, which is exactly what
   was reported. A cap that quietly stops being a cap is worse than no cap: it
   reads as a knob that is being ignored.
   min() keeps both rules: proportional while the text is small (so a low-vision
   reader is not squeezed to twenty characters), hard-stopped so the line NEVER
   sprawls, whatever the text size or the monitor.
   2026-08-09, once the rule was live and 38 could finally be SEEN, it read too
   narrow. Settled by matching the topbar: --reading-measure, which is that bar's
   own width, so the prose terminates exactly where the bar does. See that
   token's note for why the two are one constant.
   ⚠ BOTH halves had to move. The multiplier alone caps nothing above its own
   product, and the ceiling alone is never reached below it — raising the px
   ceiling while leaving 38 would have left 646px on screen and looked, once
   again, like a knob being ignored. 47 × 17 = 799, so the cap binds at the
   default text size instead of sitting decoratively above it.
   The ladder at 17px type: 38 = 646px, 42 = 714, 44 = 748, 47 = 799 (the bar),
   50 = 850. Anything past the bar needs --reading-measure raised too, which
   also widens the topbar — that coupling is the point, not a side effect. */
.chron-page > .chron-col > * {
    width: 100%;
    max-width: min(calc(var(--chron-fs, 17px) * 47), var(--reading-measure));
}
.chron-page > .chron-rail, .chron-page > .chron-legend,
.chron-page > .chron-glossary { order: 3; }

/* The classic grid, kept behind ?layout=legacy and the Display row while the
   plate layout proves itself. DELETE THIS BLOCK deliberately once it has —
   it is an escape hatch, not a second supported design. */
.chron-page.legacy-grid {
    display: grid;
    grid-template-columns: 280px minmax(0, 1fr) 260px;
    gap: 28px;
    max-width: 1280px;
    padding: calc(var(--topbar-h) + 20px) 20px 60px;
    align-items: start;
}
/* ── Side-panel geometry — >=600px ONLY ────────────────────────────────────
   Everything from here to the closer treats the TOC and the aside as SIDE
   PANELS: flex bases, stretch, and all pane chrome. None of it is true on a
   phone, where the TOC is `position: fixed` bottom drawer (the ≤599px block
   near styles.css:~3840) and the column is the only pane in flow.

   The media query is doing SPECIFICITY work, not just width work. The drawer
   rule is a bare `.chron-toc` (0,1,0); these are `.chron-page > .chron-toc`
   (0,2,0) and `.chron-page.legacy-grid > .chron-toc` (0,3,0), and a media
   query adds NO specificity — so left unscoped they beat the drawer at every
   width and silently took its `position: fixed`, its scroll, its background,
   its padding and its radius away in BOTH layouts. Nothing in the diff shows
   it: the drawer lives 700 lines below. If you add a pane rule, add it in
   here, or check what it does to the phone.
   ⚠ The flex BASES must sum to <= the narrowest supported line:
   215 + 465 + 215 = 895. Any larger and the aside wraps below the fold at
   ~920px while looking correct on a desktop. Changing one means re-checking
   the sum. The 2026-08-09 widening moved the numbers around (195/440/260 ->
   215/465/215) but kept the total at exactly 895 ON PURPOSE — the budget is
   the invariant, not the individual figures, and the wrap threshold is
   therefore unchanged. `styles_lint` asserts the sum. */
@media (min-width: 600px) {
    /* Sizing and stretch only. The panes' CHROME — border, background,
       padding, what is sticky — is set below, so the two concerns never fight
       over the same declaration at the same specificity. */
    /* ⚠ THE TWO SIDE PANES ARE DELIBERATELY IDENTICAL — same basis, same grow,
       same min, same max. That symmetry is the whole point: the reading column
       is then centred inside the page, the page is centred in the viewport, and
       the fixed topbar (left:0; right:0) finally sits centred over the text it
       belongs to. Before this the right group was `2 1 260px / max 370px`
       against the toc's `1 1 195px / max 260px`, so the column sat left of
       centre by roughly half their 110px difference and the bar was visibly
       off-axis against it.
       Growing at 1 rather than 8 also matters: the sides reach their cap
       early and everything past that goes to the column.
       ⚠ The cap is 348, raised from 300 on 2026-08-09 ("at least as wide as
       the classic design was"), and the comparison is easy to get WRONG. The
       classic width is NOT the 280px in `grid-template-columns` — above
       1340px the legacy TOC is `position: fixed` with
       `width: min(300px, calc(50% - 400px - 44px))`, so the classic panel a
       desktop actually showed was 300px, and it was the CARD, because the
       legacy override sets the pane's padding to 0. The plate's pane instead
       carries `padding: 0 16px`, so a 300px pane was only a 268px card — 32px
       narrower than classic, not the 12px the grid figure suggests. 348 - 32
       = 316, comfortably past the real floor.
       Where the extra width comes from is the reason this is safe: the
       reading column is capped, so it cannot take it, and the two panes are
       the only items left that can grow. It comes out of the plate's OUTER
       margin (168px of leftover at a 1600px plate, now 72px) and never out of
       the 32px gutter between prose and panel. */
    .chron-page > .chron-toc {
        flex: 1 1 215px; min-width: 230px; max-width: 348px;
        align-self: stretch;
    }
    /* Capped to EXACTLY the measure plus its two paddings, so the prose fills
       the pane and the side panels come to meet it. Before this the pane took
       its full flex share (1029px at a 1600px plate) while the prose stayed at
       the measure, and the 100+px of leftover became dead gutter — which is
       what "the side panels are much too far out" was describing.
       ⚠ The gutter is `--chron-col-pad + the pane's own 16px inset` and is
       INDEPENDENT of the plate width: `pane/2 - measure/2` cancels the padding
       out. Narrowing .chron-page alone could never close it below ~50px; the
       padding is the term that had to move. Overriding the base rule's
       `clamp(14px, 2vw, 40px)` is the point — 2vw is 33px on a 1660px monitor,
       and a padding that GROWS with the screen is precisely the wrong law here.
       Long-hand, not the `padding` shorthand: the base rule's 90px bottom must
       survive. Same selector as that rule, so this wins on SOURCE ORDER alone
       (a media query adds no specificity) — it must stay below it. */
    /* ⚠ Opening line kept one-line and unbroken: styles_lint reads the flex
       shorthand's basis out of this rule by the needle `.chron-page >
       .chron-col { flex:` to check the 895 sum. Reformatting it to a fresh
       line is what made that lint panic mid-change. */
    .chron-page > .chron-col { flex: 8 1 465px; min-width: 380px;
        padding-left: var(--chron-col-pad);
        padding-right: var(--chron-col-pad);
        max-width: calc(var(--reading-measure) + 2 * var(--chron-col-pad));
    }
    .chron-page > .chron-rail, .chron-page > .chron-legend,
    .chron-page > .chron-glossary {
        flex: 1 1 215px; min-width: 230px; max-width: 348px;
        /* align-self: stretch is REQUIRED — the row is align-items:
           flex-start, so without it the sticky child has no travel and
           scrolls away. */
        align-self: stretch;
    }

    /* The classic grid's panes opt out of every flex declaration above. */
    .chron-page.legacy-grid > .chron-toc,
    .chron-page.legacy-grid > .chron-col,
    .chron-page.legacy-grid > .chron-rail,
    .chron-page.legacy-grid > .chron-legend {
        flex: none; max-width: none; min-width: 0;
        border-right: 0; background: none; padding: 0;
        display: block; align-self: start;
    }

    /* Sidebar CHROME. The plate layout stretches each pane to the row's full
       height and makes its CONTENTS sticky (Task 7 adds that); the classic
       grid keeps the original rounded, self-sticky boxes. Splitting them
       matters: a sticky PANE in a flex-start row has no travel, so the panel
       scrolls away instead of holding. */
    .chron-page > .chron-toc, .chron-page > .chron-rail, .chron-page > .chron-legend,
    .chron-page > .chron-glossary {
        position: static;
        overflow: visible;
        max-height: none;
        background: none;
        border: 0;
        border-radius: 0;
        padding: 0;
    }
    /* The chapter selector wears the CLASSIC layout's chrome — a rounded,
       bordered card on --canvas — instead of the flush --canvas-deep panel with
       a hard right edge it had here. Operator's preference, 2026-08-09.
       ⚠ The chrome goes on the INNER wrapper, not on this pane, and that is not
       cosmetic bookkeeping: the pane is `align-self: stretch`, so it is as tall
       as the whole row and a card drawn on it would be a border running the
       length of the document. The wrapper is the thing with a viewport-height
       cap and a sticky position, so it is the thing shaped like a card. The
       legacy rule below can put the chrome on its pane precisely because THERE
       the pane is the sticky, height-capped element.
       The pane keeps only the 16px inset, which is the same one the panes on
       the other side carry — so the two margins stay equal, which is what the
       symmetry above exists for. */
    .chron-page > .chron-toc { padding: 0 16px; }
    /* …but the reset's `padding: 0` leaves the rail and the legend with NO
       inset of their own. The TOC gets away with it — its background +
       border-right make a flush edge read as a panel, and its rows carry 8px
       — while the scripture refs and the sound chart have neither, so below
       1340px (i.e. most widths) their text sat against the raw viewport edge.
       Inset both. Safe for the 895 flex-basis sum: `* { box-sizing:
       border-box }` (line 211) means this padding comes out of the 260px
       basis, not on top of it. */
    .chron-page > .chron-rail, .chron-page > .chron-legend,
    .chron-page > .chron-glossary { padding: 0 16px; }
    .chron-page.legacy-grid > .chron-toc, .chron-page.legacy-grid > .chron-rail {
        position: sticky;
        top: calc(var(--topbar-h) + 16px);
        max-height: calc(100vh - var(--topbar-h) - 32px);
        overflow-y: auto;
        background: var(--canvas);
        border: 1px solid var(--rule-soft);
        border-radius: 10px;
        padding: 14px;
    }

    /* Sticky CONTENTS, static pane — the other half of the chrome reset above.
       The pane is stretched to the row's full height, so it has no travel of
       its own; the wrapper INSIDE it is what holds while the prose scrolls.
       Same offset idiom as every other sticky thing on this page, derived from
       --topbar-h rather than a measured constant: top clears the bar by 16px
       and the height leaves the same 16px at the bottom.
       The wrapper is a column so the LIST absorbs whatever height the volume
       tabs leave over. That is the point — nothing here encodes the tabs'
       height, which is a function of their font-size and padding and would be
       wrong the first time either changed. */
    .chron-page:not(.legacy-grid) > .chron-toc > .chron-toc-inner {
        position: sticky;
        top: calc(var(--topbar-h) + 16px);
        max-height: calc(100vh - var(--topbar-h) - 32px);
        display: flex;
        flex-direction: column;
        /* The classic layout's card, ported. Same four declarations as the
           legacy rule above — kept as literals rather than shared through a
           custom property, because the two are the same LOOK on deliberately
           different elements (that pane, this wrapper) and a shared token would
           imply they can be changed together, which they cannot. */
        background: var(--canvas);
        border: 1px solid var(--rule-soft);
        border-radius: 10px;
        padding: 14px;
    }
    /* `min-height: 0` is load-bearing: a flex child's default `min-height:
       auto` refuses to shrink below its content, so ~350 rows would overflow
       the wrapper instead of scrolling inside it.
       `position: relative` makes the list the rows' offsetParent, which is
       what toc.rs's centring effect measures `offset_top` against — it picks
       whichever of the list and the pane actually overflows, and the two must
       be the same element. Under ?layout=legacy the PANE scrolls, so the list
       is deliberately left unpositioned there and the pane (sticky, hence
       positioned) becomes the offsetParent instead. */
    .chron-page:not(.legacy-grid) .chron-toc-list {
        position: relative;
        flex: 1 1 auto; min-height: 0; overflow-y: auto;
    }
}
/* ^ closes @media (min-width: 600px) — the side-panel geometry gate. Every
   .chron-* rule BELOW this line is unscoped again. */

/* (The TOC's text-size strip — .chron-fontsize / .chron-fs-* — moved to the
   Display panel 2026-07-25; see .prefs-step. `--chron-fs` itself is still set
   inline by pages/chronicle/mod.rs and still drives the prose below.) */

.chron-toc-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
.chron-toc-tab {
    flex: 1; padding: 6px 4px; font-size: 0.72rem;
    background: none; border: 1px solid var(--rule-soft); border-radius: 6px;
    color: var(--ink-soft); cursor: pointer;
}
.chron-toc-tab.active { border-color: var(--gilt); color: var(--ink); }

.chron-toc-year {
    font-size: 0.68rem; letter-spacing: 0.08em; color: var(--ink-dim);
    margin: 10px 0 2px; text-transform: uppercase;
}
/* Rows read as a ruled index, not a list of pills: a hairline between each
   entry, and the current one marked by a gilt rule down its left edge rather
   than a filled gilt block. The 3px border is on EVERY row, transparent until
   current, so nothing shifts by 3px when the selection moves. */
.chron-toc-row {
    display: flex; gap: 8px; align-items: baseline;
    width: 100%; text-align: left;
    padding: 8px 12px 8px 10px;
    border: 0; border-bottom: 1px solid var(--rule);
    border-left: 3px solid transparent;
    background: none; color: var(--ink-soft);
    font-family: var(--serif-body);
    font-size: 15px; line-height: 1.26; cursor: pointer;
}
.chron-toc-row:hover { background: var(--rule-soft); }
.chron-toc-row.current {
    background: var(--rule-soft);
    border-left-color: var(--gilt);
    color: var(--ink);
}
.chron-toc-row.marked::after { content: "§"; color: var(--gilt); }
/* Audio indicator: a small gilt speaker glyph before the title on chapters
   with an approved narration. Opacity keeps it a quiet cue, not a shout.
   The row is a flex line now, so its `gap` does the spacing and `align-items:
   baseline` the alignment — the old margin-right and vertical-align are gone
   rather than fighting it (vertical-align does nothing to a flex item). */
.chron-toc-audio {
    flex: none;
    color: var(--gilt); opacity: 0.9;
}
/* The current row used to be a gilt FILL, so the glyph flipped to the page
   background to stay legible on it. It is now a pale wash with a gilt left
   rule, and page-bg on that is very nearly invisible — the glyph just stays
   gilt and goes to full strength. */
.chron-toc-row.current .chron-toc-audio { opacity: 1; }
.chron-toc-empty, .chron-loading { color: var(--ink-dim); padding: 12px; }
.chron-toc-toggle { display: none; }

/* Tap-outside dismissal for the mobile TOC drawer (bm-backdrop pattern:
   always in the DOM, toggled by class — never <Show>). Dim layer between
   the page and the drawer; hidden at every width by default, displayed
   only by the ≤599px drawer block below (desktop's TOC is a grid column,
   not an overlay, so it never gets a backdrop). */
.chron-toc-backdrop {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 40; /* over content + Contents button (40, earlier in DOM), under the drawer (41) */
    background: rgba(0,0,0,0.28);
}

/* TOC grab-tab — the timeline scale-tab pattern mirrored to the LEFT panel:
   fused to the TOC's right edge while expanded, rides to the screen's left
   edge when collapsed (the iPad ask: free the contents track for the text).
   A FIXED sibling of the panel it controls — never a child — so it stays
   tappable when the panel hides. Reuses .bar-grab-handle for the line.
   Base `left` = the 900–1339 band (20px padding + 280px track, centered
   above 1320 via the max() term); other bands override below. */
.chron-toc-grab {
    position: fixed;
    z-index: 21;
    top: calc(var(--topbar-h) + 24px);
    left: calc(max(0px, 50% - 640px) + 300px);
    background: var(--canvas);
    border: none;
    border-right: 1px solid var(--rule);
    border-radius: 0 8px 8px 0;
    padding: 12px 5px 12px 3px;   /* shorter left: flush onto the panel */
    display: flex; align-items: center; justify-content: center;
    cursor: pointer;
    touch-action: manipulation;
    transition: left .25s ease;
}
.chron-toc-grab .bar-grab-handle { width: 3px; height: 22px; }
/* Enlarge the TAP zone without enlarging the visible tab (scale-tab trick). */
.chron-toc-grab::before {
    content: "";
    position: absolute;
    inset: -14px -22px -14px -6px;
}

/* Collapsed: hide the panel, park the tab at the screen edge. Scoped >=600px
   — below that the TOC is the bottom drawer and a stale collapsed flag must
   never take it out. Flex needs no track rewrite; the legacy grid still does. */
@media (min-width: 600px) {
    .chron-page.toc-collapsed .chron-toc { display: none; }
    .chron-page.toc-collapsed .chron-toc-grab { left: 0; }
    .chron-page.legacy-grid.toc-collapsed { grid-template-columns: minmax(0, 1fr) 260px; }
}
@media (min-width: 600px) and (max-width: 899px) {
    .chron-page.legacy-grid.toc-collapsed { grid-template-columns: minmax(0, 1fr); }
}

/* Reading column. Fills its grid track (TOC edge → rail edge): the user
   preferred long lines over the classic ~68ch book measure. */
.chron-col { overflow-anchor: auto; }
/* CSS-hide, never <Show>-unmount — a nav button may hide itself from its own
   click handler at the ends of the book (crash-family rule; see the note in
   the chronicle page module). */
.chron-prev.hidden, .chron-next.hidden, .chron-nav.hidden { display: none; }
/* Prev/next pairs above and below the chapter (one chapter shown at a time;
   these page through). They name the chapter they lead to, so they are no
   longer bordered pills but two-line stacks — a small kicker over the
   neighbouring chapter's own title — separated from the prose by a rule
   rather than by a box. */
.chron-nav {
    display: flex; justify-content: space-between; gap: 12px;
    margin: 34px 0 0;
    padding: 40px 0 0;
    border-top: 1px solid var(--rule);
}
.chron-nav.chron-nav-top {
    margin: 0 0 18px;
    padding: 16px 0 20px;
    border-top: 0;
    border-bottom: 1px solid var(--rule);
}
.chron-nav .chron-nav-btn {
    display: flex; flex-direction: column; gap: 4px;
    margin: 0;
    background: none; border: 0; padding: 0;
    color: var(--ink-soft); cursor: pointer; text-align: left;
    /* A long title must not crowd its opposite number off the line. */
    max-width: 48%;
}
/* Keep Next on the right edge even when Prev is hidden (first chapter) —
   `.hidden` is `display: none`, so space-between alone would slide a lone
   Next button to the LEFT. */
.chron-nav .chron-next {
    margin-left: auto;
    text-align: right; align-items: flex-end;
}
.chron-nav-kicker {
    font-family: var(--serif-body);
    /* 0.86 = 9.5/11, this kicker's existing proportion to the year register. */
    font-size: calc(var(--chron-apparatus) * 0.86);
    letter-spacing: 0.16em; text-transform: uppercase;
    color: var(--ink-faint);
}
.chron-nav-title {
    font-family: var(--serif-body); font-size: 15px; line-height: 1.25;
}
.chron-chapter { margin-bottom: 44px; scroll-margin-top: calc(var(--topbar-h) + 12px); }
/* Spec §4's kicker, above the title: source · volume · the year it is entered
   under. Its existence is what lets the title drop its leading year — the year
   is still on the page twice (here and the meta line), just not a third time in
   48px type. It is also the element the glossary card measures itself against
   (--chronicle-header-offset), so it is the top of the header block. */
.chron-ch-kicker {
    font-family: var(--serif-body);
    font-size: calc(var(--chron-apparatus) * 0.95);
    letter-spacing: 0.16em; text-transform: uppercase;
    color: var(--ink-dim);
    margin-bottom: 8px;
}
.chron-ch-head { display: flex; align-items: baseline; gap: 10px; }
.chron-ch-title {
    /* Match the timeline card headline (18px uppercase, heavier osr cut,
       medium weight) — the 1.9rem light chapter title read oversized against
       the 17px body. Scales with the text-size setting, keeping the 18:17
       card-headline proportion to the body (18px at the 17px default). */
    font-size: calc(var(--chron-fs, 17px) * 18 / 17);
    letter-spacing: 0.01em;
    text-transform: uppercase;
    line-height: 1.18;
    font-family: "osr", "osl", Georgia, serif;
    font-weight: 500;
    margin: 0 0 6px;
    color: var(--ink);
}
.chron-bm {
    background: none; border: 0; cursor: pointer; padding: 4px;
    color: var(--ink-faint); flex-shrink: 0;
    position: relative;   /* anchors the hit-expander below */
}
/* The visible flag is 14×18 in a ~22×26 box — well under a fingertip, and it
   sits in a baseline flex row with the chapter title, so GROWING the box would
   shove the title. Same transparent hit-expander the rail flag
   (.scrub-bm-flag::after) and the grab tabs (.bar-grab--chips::before) use:
   ~44×48 of reach, zero layout change. */
.chron-bm::before {
    content: "";
    position: absolute;
    inset: -11px;
}
.chron-bm svg { width: 14px; height: 18px; fill: none; }
.chron-bm:hover { color: var(--gilt); }
.chron-bm.marked { color: var(--gilt); }
.chron-bm.marked svg { fill: currentColor; }
.chron-ch-meta {
    font-size: 0.78rem; color: var(--ink-dim);
    border-bottom: 1px solid var(--rule-soft);
    padding-bottom: 10px; margin-bottom: 14px;
    /* Holds the meta text and the copy button at opposite ends of the rule.
       Wraps rather than crushes: on a narrow column the button drops to its own
       line instead of squeezing "~15 min read" into two. */
    display: flex; flex-wrap: wrap;
    align-items: baseline; justify-content: space-between; gap: 4px 14px;
}
/* Copy-chapter-text — anchored to the right of the meta rule, for readers
   feeding a chapter to an outside text-to-speech app (the chapters narration
   hasn't reached). It carries a LABEL: tucked beside the bookmark flag as a
   bare glyph it just read as a second bookmark. Tone matches the meta line it
   sits on, lifting to gilt on hover. */
.chron-copy-wrap {
    /* What the result note positions against. Must stay free of opacity /
       transform / filter — any of those would trap the note's z-index behind
       the prose below (the documented stacking trap). */
    position: relative; display: inline-flex; flex-shrink: 0;
}
.chron-copy {
    background: none; border: 0; cursor: pointer; padding: 2px;
    color: var(--ink-dim); flex-shrink: 0;
    position: relative;
    display: inline-flex; align-items: center; gap: 5px;
    font: inherit; line-height: 1;
}
/* The label plus a 13px glyph is a short target on a phone — the same
   transparent hit-expander the bookmark flag uses gives it a fingertip of
   vertical reach with zero layout change. */
.chron-copy::before { content: ""; position: absolute; inset: -11px -6px; }
.chron-copy svg { width: 13px; height: 13px; fill: none; display: block; }
.chron-copy:hover { color: var(--gilt); }
.chron-copy.done { color: var(--gilt); }
.chron-copy.failed { color: var(--oxblood); }
/* The transient result, floated below the button so confirming a copy never
   reflows the rule; right-anchored so it extends inward and can't push past a
   narrow viewport's edge. */
.chron-copy-note {
    position: absolute; top: calc(100% + 6px); right: 0;
    display: none; z-index: 3;
    white-space: nowrap;
    font-size: 0.72rem; line-height: 1.35;
    padding: 5px 8px;
    color: var(--ink-soft);
    background: var(--canvas-deep);
    border: 1px solid var(--rule);
    border-radius: 3px;
}
.chron-copy-note.show { display: block; }
.chron-copy-note.failed {
    color: var(--oxblood); border-color: var(--oxblood);
    white-space: normal; width: max-content; max-width: 210px;
}
.chron-ch-body p {
    /* Lighter display cut (osl) — the regular osr body face read heavier/
       denser here than the same prose on the timeline cards. Size is reader-
       adjustable via the Display panel's Text size row (--chron-fs is still set
       inline on .chron-page; the 17px fallback = the cards' body size). */
    font-family: var(--serif-display);
    font-size: var(--chron-fs, 17px); line-height: 1.75;
    margin: 0 0 1em; color: var(--ink);
    /* Search jumps scroll a hit paragraph to block:start — reserve the fixed
       topbar (same treatment as .chron-chapter / the reader's cards). The
       head gets it too, for chapter-header hits. */
    scroll-margin-top: calc(var(--topbar-h) + 12px);
}
/* ── Year registers ─────────────────────────────────────────────
   The year a run opens on, set in the margin beside its first paragraph.
   A ::before fed by data-year, NOT a wrapper element: p[data-para] is
   selected by the search-hit scroll, span[data-sent] by the read-along
   scroll, and flag.rs walks UP from a selection looking for data-para.
   A pseudo-element also cannot land in a text selection or in copy_text.
   The gutter holds its width even on paragraphs that print nothing, so
   the measure does not shift between chapters. */
.chron-ch-body { padding-left: var(--chron-register-gutter); }
.chron-ch-body p { position: relative; }
.chron-ch-body p[data-year]::before {
    content: attr(data-year);
    position: absolute;
    /* The box's LEFT edge lands on the body block's left edge, so the year —
       right-aligned inside it — ends exactly `gutter - w` before the prose.
       That difference is the gap term, which is why the two tokens are
       defined as w and w+gap rather than as two independent numbers. */
    left: calc(-1 * var(--chron-register-gutter));
    /* Scales with the reader's text-size control. NOT `0.35em` — `em` on
       `top` resolves against this element's OWN font-size, which the next
       declaration pins at 11px, so an em value would sit at a constant
       3.85px and drift ~19px out of line at the 64px accessibility ceiling.
       Same calc(var(--chron-fs…)) pattern as .chron-ch-title. */
    top: calc(var(--chron-fs, 17px) * 0.35);
    width: var(--chron-register-w);
    text-align: right;
    font-family: var(--serif-body);
    /* The apparatus base. The prev/next kicker is its other type consumer;
       this rule's own box metrics are the third, via --chron-register-*. */
    font-size: var(--chron-apparatus);
    letter-spacing: 0.03em;
    color: var(--ink-faint);
}
@media (max-width: 899px) {
    /* Below 900px there is no room for a margin. At a 600px viewport the
       reading column is ~292px, so even the narrowed 44px gutter leaves
       ~248px of prose — about 29 characters, still well under the ~45 floor,
       so trimming the margin did not move this cut. 900px is the cut
       because the plate layout's three panes sum to 895px, which is where
       the reading column first reaches its 440px base. Below it, the
       register sets above its paragraph and the gutter closes. */
    .chron-ch-body { padding-left: 0; }
    .chron-ch-body p[data-year]::before {
        position: static;
        display: block;
        width: auto;
        text-align: left;
        margin-bottom: 4px;
    }
}
.chron-ch-head { scroll-margin-top: calc(var(--topbar-h) + 12px); }
.chron-ch-pending { color: var(--ink-dim); }
.chron-attr {
    margin-top: 40px; padding-top: 12px;
    border-top: 1px solid var(--rule-soft);
    font-size: 0.75rem; color: var(--ink-dim);
}

/* Chronicle narration player — a compact, engraved strip between the chapter
   head and the body, keyed to the page rules so it reads as part of the plate,
   not a floating media widget. Only renders for chapters with approved audio;
   see pages/chronicle/audio.rs. */
.chron-audio {
    display: grid;
    /* Scrubber · Play/Pause · time — the Play button sits to the RIGHT of the
       scrubber, beside the readout (markup order matches). */
    grid-template-columns: minmax(120px, 1fr) auto auto;
    align-items: center;
    gap: 12px;
    margin: 4px 0 20px;
    padding: 12px 0;
    border-top: 1px solid var(--rule-soft);
    border-bottom: 1px solid var(--rule-soft);
}
/* Voice picker: a full-width row above the transport (chapters with >1 take). */
.chron-audio-voices {
    grid-column: 1 / -1;
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
}
.chron-audio-voices-lbl {
    font-size: 0.68rem; letter-spacing: 0.09em; text-transform: uppercase;
    color: var(--ink-dim);
}
.chron-voice-btn {
    padding: 3px 11px;
    font-family: "osr", "osl", Georgia, serif;
    font-size: 0.8rem;
    color: var(--ink-dim);
    background: transparent;
    border: 1px solid var(--rule-soft);
    border-radius: 6px;  /* unified audio-button shape: rounded rectangle */
    cursor: pointer;
    transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}
.chron-voice-btn:hover { color: var(--gilt); border-color: var(--gilt); }
.chron-voice-btn.sel {
    color: var(--page-bg);
    background: var(--gilt);
    border-color: var(--gilt);
}
.chron-audio-btn {
    min-width: 4.6rem;
    padding: 5px 14px;
    font-family: "osr", "osl", Georgia, serif;
    font-size: 0.95rem;  /* larger "Play"/"Pause" wording */
    color: var(--ink);
    background: var(--rule-soft);
    border: 1px solid var(--rule);
    border-radius: 6px;  /* unified audio-button shape: rounded rectangle (was 2px, too square) */
    cursor: pointer;
}
.chron-audio-btn:hover { color: var(--gilt); border-color: var(--gilt); }
.chron-audio-scrub {
    width: 100%;
    accent-color: var(--gilt);
    cursor: pointer;
}
.chron-audio-time {
    font-size: 0.8rem; color: var(--ink-dim);
    white-space: nowrap; font-variant-numeric: tabular-nums;
}
.chron-audio-error {
    grid-column: 1 / -1;
    font-size: 0.8rem; color: var(--ink-dim);
}
/* Failed load: dim the whole strip so it reads as unavailable, not broken. */
.chron-audio.error .chron-audio-btn,
.chron-audio.error .chron-audio-scrub { opacity: 0.45; pointer-events: none; }
@media (max-width: 520px) {
    /* Original two-row mobile layout, with Play now on the RIGHT of the scrubber:
       row 1 = scrubber (1fr) · Play (auto, compact — does NOT stretch); row 2 =
       time, full-width. Columns are scrubber|play, so the button keeps its
       natural width instead of filling a 1fr cell. */
    .chron-audio { grid-template-columns: 1fr auto; }
    .chron-audio-time { grid-column: 1 / -1; }
}

/* Floating transport bar — TOP-docked (below the fixed topbar), shown only while
   narration is active AND the head strip has scrolled away (audio.rs gates the
   render, so it appears exactly when the head Play button leaves the screen).
   Drives the same <audio> element as the head strip. Docked at the top rather
   than the bottom so it clears the iPhone bottom-edge swipe-up gesture. Solid
   --page-bg (NOT the never-defined --paper it used before, which rendered
   transparent and read as floating "in" the text). */
.chron-audio-float {
    position: fixed;
    left: 50%;
    /* Just below the fixed topbar (single source of truth --topbar-h) + its
       safe-area inset, with an 8px gap. */
    top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) + 8px);
    transform: translateX(-50%);
    /* Above the content, just BELOW the topbar (z 20) so a topbar dropdown
       (TOC, bookmarks) that expands downward covers the bar rather than
       peeking under it. */
    z-index: 19;
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 7px 12px;
    background: var(--page-bg);
    border: 1px solid var(--rule);
    border-radius: 10px;  /* rounded rectangle: a touch larger than its 6px buttons */
    box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
    /* Never wider than the viewport on small screens. */
    max-width: calc(100vw - 24px);
}
.chron-float-btn {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    font-family: "osr", "osl", Georgia, serif;
    font-size: 0.78rem;
    line-height: 1;
    color: var(--ink);
    background: transparent;
    border: 1px solid var(--rule-soft);
    border-radius: 6px;  /* unified audio-button shape: rounded rectangle */
    /* Snug h-padding so the skip buttons (icon + "15") stay compact next to the
       wider play button. Play overrides padding, so this only tightens the skips. */
    padding: 6px 8px;
    cursor: pointer;
    white-space: nowrap;
    transition: color 0.15s ease, border-color 0.15s ease;
}
.chron-float-btn:hover { color: var(--gilt); border-color: var(--gilt); }
/* Skip/play icons: flat-filled currentColor glyphs (the topbar icon idiom). */
.chron-float-ico {
    width: 17px; height: 17px;
    fill: currentColor;
    display: block;
}
.chron-float-num {
    font-size: 0.72rem; font-variant-numeric: tabular-nums;
}
/* Play/pause is the primary control — a filled gilt button so the eye lands on
   it. Wider (min-width + more h-padding) so its lone icon isn't pinched next to
   the "15" skip buttons. Its glyph is ZOOMED (22px vs the skips' 17px — the app's
   larger-primary-glyph style); vertical padding drops to 4px to absorb the taller
   icon so all three buttons stay the same height. */
.chron-float-play {
    color: var(--page-bg);
    background: var(--gilt);
    border-color: var(--gilt);
    padding: 2px 18px;
    min-width: 3.2rem;
    justify-content: center;
}
.chron-float-play .chron-float-ico {
    width: 26px; height: 26px;
}
.chron-float-play:hover { color: var(--page-bg); opacity: 0.9; }
/* Follow — the read-along "come back to the narrated line" button, rendered only
   while auto-scroll is disengaged (the reader scrolled away). Outlined gilt: the
   one live ACTION in the bar, but a step quieter than the filled Play so it
   never competes with the primary control. Its label is a word, not a number,
   so it opts out of .chron-float-num's tabular figures. */
.chron-float-follow {
    color: var(--gilt);
    border-color: var(--gilt);
}
.chron-float-follow:hover {
    color: var(--page-bg);
    background: var(--gilt);
}
.chron-float-follow .chron-float-num {
    font-size: 0.78rem;
    font-variant-numeric: normal;
}
.chron-float-time {
    font-size: 0.78rem; color: var(--ink-dim);
    white-space: nowrap; font-variant-numeric: tabular-nums;
    padding-left: 2px;
}
/* Tap-to-seek affordance: while narration is armed (started), sentences take a
   pointer cursor so the listener sees they can tap to jump there. Off while just
   reading, so plain prose stays normal text. */
.chron-ch-body span.seekable { cursor: pointer; }

/* Read-along highlight: the SENTENCE being narrated. An inline gilt wash on the
   active sentence's <span>; box-decoration-break:clone so a sentence wrapping
   across lines is washed on every line, not just the first. Restrained, with a
   gentle fade as it moves sentence to sentence. */
.chron-ch-body .reading {
    background: color-mix(in srgb, var(--gilt) 16%, transparent);
    border-radius: 2px;
    -webkit-box-decoration-break: clone;
    box-decoration-break: clone;
    transition: background-color 0.25s ease;
}
/* Dark canvas is near-black, so the same 16% wash barely lifts luminance —
   boost the mix (and lean on the lighter gilt) so the highlight reads as
   clearly as it does on light paper. */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .chron-ch-body .reading {
        background: color-mix(in srgb, var(--gilt) 38%, transparent);
    }
}
:root[data-theme="dark"] .chron-ch-body .reading {
    background: color-mix(in srgb, var(--gilt) 38%, transparent);
}

/* Scripture references at the END of the chapter, which is where they went once
   the third pane became the glossary card's.

   ONE WRAPPING LINE between two hairlines — no box, no fill. Three corrections
   to a first attempt that boxed them:
   - `var(--page-bg)` as the fill, which the spec names, is #15110d — exactly
     what the reading column already sits on. The fill was invisible and the
     block read as a bare outline. There is no surface to give it, so it gets
     rules instead of a box.
   - One ref per line was right in a 260px rail. In the full column a 16-ref
     chapter became a ~600px tower of empty space. They flow inline now.
   - The label sits INLINE with the refs, as their first item, not stacked above
     them — which is what makes the whole thing read as one line of apparatus
     rather than a section with a heading.
   Sizes come from --chron-apparatus, so the refs grow with the prose. */
.chron-scripture {
    margin-top: 34px;
    padding: 14px 0;
    border-top: 1px solid var(--rule);
    border-bottom: 1px solid var(--rule);
    display: flex; flex-wrap: wrap; align-items: baseline;
    gap: 7px 14px;
}
.chron-scripture-head {
    font-size: calc(var(--chron-apparatus) * 0.86);
    letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--ink-faint);
    /* A little extra air after the label only, so it reads as the caption to
       the run rather than as one more reference in it. */
    margin-right: 6px;
}
.chron-scripture-ref {
    font-family: var(--serif-body);
    font-size: var(--chron-apparatus);
    color: var(--ink-soft);
    white-space: nowrap;
}

/* Scriptures rail — the pre-glossary position, kept for reference. */
.chron-rail-head {
    font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase;
    color: var(--ink-soft); margin-bottom: 6px;
}
.chron-rail-sub { font-size: 0.74rem; color: var(--ink-dim); margin-bottom: 10px; }
.chron-rail-list { list-style: none; margin: 0; padding: 0; }
.chron-rail-list li {
    padding: 5px 0; font-size: 0.85rem; color: var(--ink-soft);
    border-bottom: 1px solid var(--rule-soft);
}
/* Spec: the rail renders NOTHING when there are no refs (Vol II, undated,
   or a Vol I year with none) — hide the whole panel, don't show an empty box. */
.chron-rail.empty { display: none; }
/* And RELEASE its 260px grid track too — a hidden rail must not reserve a
   dead right gutter (scripture refs exist for only ~26 of Vol I's years, so
   most of the book reads with no rail). Scoped to the mid-width band where
   the rail still lives in the grid; ≥1340px it floats in the margin below.
   LEGACY GRID ONLY — the plate layout needs no equivalent: a hidden flex
   item just stops taking a slot, so there is no track to reclaim. */
@media (min-width: 900px) and (max-width: 1339px) {
    .chron-page.legacy-grid:has(.chron-rail.empty) { grid-template-columns: 280px minmax(0, 1fr); }
}

/* ≥1340px, LEGACY GRID ONLY: the reading text occupies the app's 800px
   centered column — the SAME span as the topbar (left/right = 50% ∓ 400px),
   so the text's right edge ends exactly where the bar ends (the user's red
   line). TOC and rail float in the side margins hugging the column edges
   (the corkboard-margins pattern; 1340px is the same proven margin-width
   gate). Out-of-flow rail bonus: line length no longer shifts when a year
   has no refs.
   The plate layout has no equivalent switch at this width — its own
   .chron-page rule (styles.css:~3087) already holds to max-width:1340px
   with no floating margins. Scoped behind .legacy-grid on purpose: left
   bare, `.chron-page { display: block; … }` here would tie the flex rule's
   `display: flex` on specificity and win on source order, silently
   reverting every screen ≥1340px — the single most common desktop width —
   back to the old block layout. */
@media (min-width: 1340px) {
    .chron-page.legacy-grid {
        display: block;
        max-width: 800px;
        margin: 0 auto;
        padding-left: 0;
        padding-right: 0;
    }
    .chron-page.legacy-grid .chron-toc {
        position: fixed;
        left: auto;
        right: calc(50% + 400px + 24px);   /* hug the column's left edge */
        width: min(300px, calc(50% - 400px - 44px));
        top: calc(var(--topbar-h) + 16px);
        max-height: calc(100vh - var(--topbar-h) - 32px);
    }
    /* Fuse the grab-tab to the floating panel's right edge (which sits at
       50% − 424px; see the .chron-toc right offset above). */
    .chron-page.legacy-grid .chron-toc-grab { left: calc(50% - 424px); }
    .chron-page.legacy-grid.toc-collapsed .chron-toc-grab { left: 0; }
    .chron-page.legacy-grid .chron-rail {
        position: fixed;
        left: calc(50% + 400px + 24px);    /* hug the column's right edge */
        width: min(280px, calc(50% - 400px - 44px));
        top: calc(var(--topbar-h) + 16px);
        max-height: calc(100vh - var(--topbar-h) - 32px);
    }
}

/* ── Collapse is a TOUCH affordance — disable it on desktop (≥900px). ─────────
   The chip/scale bars already made this call (see the @media(min-width:760px)
   block above): a wide, width-capped, CENTERED layout gains nothing from hiding
   a side panel — the reading column can't widen into the freed space, so a
   collapse only strands the grab tab in the margin gutter at the screen edge
   (the reported "tucks away to the far left" bug; especially so ≥1340px, where
   the text is a fixed 800px column and the TOC floats in the margin).

   So above 900px we make .toc-collapsed a NO-OP and remove the grab entirely.
   This also neutralizes a PERSISTED collapsed flag (chron_toc_collapsed_v1)
   without touching localStorage or Rust — a state saved on a phone/iPad can no
   longer strand the desktop view. Collapse still works 600–899px (the iPad
   band) and the TOC is still a drawer ≤599px. Selectors mirror the collapse
   rules they revert, so they tie on specificity and win by later source order;
   the grab-hide is flagged to beat the per-band `left` rules unconditionally. */
@media (min-width: 900px) {
    /* No grab tab on desktop — nothing to strand, nothing to mis-tap. */
    .chron-toc-grab { display: none !important; }
    /* A stale collapsed flag can't hide the panel here. */
    .chron-page.toc-collapsed .chron-toc { display: block; }
}
/* Grid band (900–1339), LEGACY GRID ONLY: restore the TOC's grid track a
   collapse had dropped. The plate layout needs no equivalent — a hidden
   flex item simply stops taking a slot, so there is nothing to restore. */
@media (min-width: 900px) and (max-width: 1339px) {
    .chron-page.legacy-grid.toc-collapsed { grid-template-columns: 280px minmax(0, 1fr) 260px; }
    /* Keep the rail-empty variant honest (matches the base :has() rule above). */
    .chron-page.legacy-grid.toc-collapsed:has(.chron-rail.empty) {
        grid-template-columns: 280px minmax(0, 1fr);
    }
}
/* ≤899px: drop the rail — BOTH layouts (the flex row's own bases already
   assume the rail goes first below ~900px; see the 895-sum note on
   .chron-page). The grid track rewrite and the grab tab's matching offset
   are calibrated to the 240px legacy TOC track, so only those two are
   legacy-grid only. */
@media (max-width: 899px) {
    .chron-page.legacy-grid { grid-template-columns: 240px minmax(0, 1fr); }
    .chron-rail { display: none; }
    /* Narrower 240px track + 20px page padding — legacy grid only. */
    .chron-page.legacy-grid .chron-toc-grab { left: 260px; }
    .chron-page.toc-collapsed .chron-toc-grab { left: 0; }
}

/* 600–899px, PLATE LAYOUT ONLY — the one band where the grab tab is visible
   (≥900px it is `display: none !important`, ≤599px the TOC is a drawer).
   The tab is a FIXED sibling, so its `left` has to equal the panel's right
   edge — and the base formula, `max(0px, 50% - 640px) + 300px`, encodes the
   LEGACY 280px track + 20px page padding. Under flex the panel is fluid
   (195px basis, grow 1 against the column's 8): ~210px at a 768px iPad,
   ~185px at 600px — so the handle floated up to 90px clear of the panel it
   is fused to.
   The fix is to stop deriving it at all: pin the panel to its own basis in
   this band and give the tab that same number. One literal, used twice, two
   lines apart — not two formulas that have to agree. 195 + the column's
   380px min-width = 575, so nothing wraps even at 600px minus a scrollbar.
   `:not(.toc-collapsed)` is load-bearing: this rule is more specific than
   the `left: 0` above, and without it a collapse would strand the tab. */
@media (min-width: 600px) and (max-width: 899px) {
    .chron-page:not(.legacy-grid) > .chron-toc { flex: 0 0 195px; }
    /* 195 − the pane's 16px inset. The tab is fused to the PANEL's edge, and
       since 2026-08-09 the visible panel is the card inside the pane, not the
       pane itself — so the tab has to stop where the card's border is or it
       floats clear of the thing it is a handle for. Same failure the 90px gap
       above describes, just smaller and therefore easier to miss. */
    .chron-page:not(.legacy-grid):not(.toc-collapsed) .chron-toc-grab { left: 179px; }
}

/* ≤599px (mobile): TOC becomes a drawer behind the Contents button. */
@media (max-width: 599px) {
    .chron-page.legacy-grid { grid-template-columns: minmax(0, 1fr); }
    .chron-page { padding-top: calc(var(--topbar-h) + 12px); }
    /* The glossary's phone rules USED to live here and could not work from
       here. `.gl-card { position: static }` (0,1,0) sat ~2,760 lines above
       `.gl-card { position: sticky }` (0,1,0) — equal specificity, so source
       order decided and the LATER base rule won. The phone card was sticky the
       whole time, while the comment here explained why it must not be. A media
       query adds no specificity; it only adds a condition. Same shape as the
       phone drawer a desktop pane rule deleted.
       They now live at the END of this file, after the .gl-* base rules they
       have to beat, in "The glossary on a PHONE". Nothing glossary-related
       belongs in this block. */
    /* The TOC is the bottom drawer here; the collapse tab has no job. */
    .chron-toc-grab { display: none; }
    /* Contents joins the prev/next pair as ONE control family: the 999px pill
       read as a different species of button next to two rounded rectangles.
       Radius and type change; the BOX does not. Side padding stays 18px, and
       the vertical padding drops 10px→9px purely to absorb the taller line box
       the larger type brings — the button keeps its ~37px height. It still
       floats over the text, so it keeps the canvas fill + shadow. */
    .chron-toc-toggle {
        display: block; position: fixed;
        bottom: 18px; right: 18px; z-index: 40;
        background: var(--canvas); color: var(--ink);
        border: 1px solid var(--rule); border-radius: 8px;
        padding: 9px 18px; box-shadow: 0 4px 14px rgba(0,0,0,0.25);
        font-family: inherit; font-size: 1rem; line-height: 1.1;
        cursor: pointer;
    }
    /* The bordered pill this rule was written for is gone — the buttons are
       titled two-line stacks now, in the page's own face at their own sizes,
       so the font-family/size/line-height bump has nothing left to fix. The
       14px side padding has to go with it: unpadded, the stacks line up with
       the prose above them instead of sitting inset from it. */
    .chron-nav .chron-nav-btn { padding: 0; }
    .chron-toc {
        /* 12vh, not 30: with ~350 chapters the drawer IS the navigation, and at
           70vh it showed barely a dozen rows while two-thirds of the screen sat
           dimmed behind it. `top` and `max-height` cap the same box and must
           move together. The remaining strip keeps it legibly a drawer (and
           leaves the backdrop something to be tapped on). */
        /* These three used to arrive from the shared `.chron-toc, .chron-rail`
           base rule, which the plate redesign replaced with pane-scoped rules
           that are now >=600px only. A drawer with no scroll is unusable at
           ~350 chapters and one with no background shows the page through it,
           so they are stated here where the drawer is defined. */
        overflow-y: auto;
        background: var(--canvas);
        border: 1px solid var(--rule-soft);
        position: fixed; inset: auto 0 0 0; top: 12vh; z-index: 41;
        border-radius: 14px 14px 0 0;
        transform: translateY(105%);
        transition: transform 0.25s ease;
        max-height: 88vh;
        /* Clear the home indicator so the last chapter row stays tappable. */
        padding: 14px 14px calc(14px + env(safe-area-inset-bottom, 0px));
        overscroll-behavior: contain;
    }
    .chron-toc.open { transform: translateY(0); }
    .chron-toc-backdrop.open { display: block; }

    /* The index is sized for a desktop side rail; inside a phone drawer those
       sizes read as fine print and the rows are a poor tap target. Step the
       whole drawer up one size and give rows a real touch height. */
    .chron-toc-tab { font-family: inherit; font-size: 0.9rem; padding: 8px 6px; }
    .chron-toc-year { font-size: 0.82rem; margin: 12px 0 3px; }
    .chron-toc-row {
        font-family: inherit; font-size: 1rem; line-height: 1.3;
        padding: 7px 10px;
    }
    /* Keep the narration glyph in proportion with the larger row text (CSS
       beats the SVG's own width/height attributes). */
    .chron-toc-audio { width: 16px; height: 16px; vertical-align: -2px; }
    .chron-toc-empty, .chron-loading { font-size: 1.05rem; }
}

/* Bookmarks dropdown: the "Chronicle" era-head hides via class (never <Show>). */
.bm-hidden { display: none; }

/* Event-detail excerpt: the read-in-context link, styled to belong to the
   quote panel (default browser blue clashes with the excerpt palette). */
.quote-context-link {
    display: inline-block; margin-top: 8px;
    font-size: 0.8rem; color: var(--gilt);
    text-decoration: none;
}
.quote-context-link:hover { text-decoration: underline; }

/* ── Stream windowing ─────────────────────────────────────────────────
   Spacers stand in for unmounted item runs (heights inline, estimated);
   .stream-item is a layout-neutral block wrapper carrying data-item-idx
   for measurement + pending-jump targeting. Cards inside keep their own
   margins — the wrapper adds none. */
.stream-item { display: block; }
.stream-spacer { display: block; width: 100%; }

/* ── Toast + session-expired banner ─────────────────────────────────── */
.toast {
  position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
  z-index: 300; max-width: min(92vw, 520px);
  display: flex; align-items: center; gap: 12px;
  padding: 12px 14px; border-radius: 10px;
  background: var(--canvas); color: var(--ink);
  border: 1px solid rgba(128,128,128,.35);
  box-shadow: 0 6px 24px rgba(0,0,0,.3); font-size: 14px;
}
.toast-error { border-left: 4px solid #d9534f; }
.toast-text { flex: 1; }
.toast-dismiss {
  background: none; border: none; color: inherit; cursor: pointer;
  font-size: 16px; line-height: 1; padding: 2px 4px;
  position: relative;   /* anchors the hit-expander below */
}
/* ~24px of visible glyph is not a touch target, and the toast is a transient
   surface you dismiss in a hurry. Expand the REACH, not the box, so the toast
   doesn't grow ~20px taller to hold a bigger ✕ (the hit-expander idiom). */
.toast-dismiss::before {
  content: "";
  position: absolute;
  inset: -12px -13px;   /* ~20×18 visible → ~44×44 of reach */
}
.session-expired-banner {
  position: sticky; top: 0; z-index: 250;
  padding: 10px 16px; text-align: center;
  background: #d9534f; color: #fff; font-size: 14px;
}
.session-expired-banner a { color: #fff; text-decoration: underline; }

/* ── Map lens error overlay ─────────────────────────────────────────── */
.map-error {
  position: absolute; inset: 0; z-index: 1100;
  display: flex; align-items: center; justify-content: center;
  padding: 24px; text-align: center;
  background: var(--canvas); color: var(--ink);
  font-size: 15px;
}

/* ── Map raid layer (strikes, burns, badges) ────────────────────────── */
/* Strike line: dashed oxblood over the basemap (drawn by Leaflet's SVG
   renderer; className lands on the <path>). */
.raid-line { stroke-dasharray: 6 5; }

/* Riding/parked faction badge (24px divIcon). Pointer events stay ON for the
   parked (tappable) badge; the in-flight badge is created non-interactive. */
.raid-badge { filter: drop-shadow(0 1px 2px rgba(0,0,0,.35)); }

/* Origin tag: faction name (approx origins) or the named from-place. */
.raid-origin-lbl {
  font-family: Georgia, serif; font-style: italic; font-size: 10px;
  color: #a82828; text-align: center; white-space: nowrap;
  text-shadow: 0 1px 2px rgba(255,255,255,.75);
}

/* The Live result stack (inside one divIcon at the target). */
.raid-burn { position: relative; display: block; width: 100%; height: 100%; }
.raid-glow {
  position: absolute; left: 50%; bottom: -6px; width: 54px; height: 54px;
  transform: translate(-50%, 50%); border-radius: 50%;
  background: radial-gradient(circle, rgba(226,88,34,.4), rgba(226,88,34,0) 70%);
  animation: raid-glow-bloom .9s ease-out backwards,
             raid-glow-pulse 1.6s ease-in-out .9s infinite alternate;
}
@keyframes raid-glow-bloom {
  from { transform: translate(-50%, 50%) scale(.35); opacity: 0; }
  to   { transform: translate(-50%, 50%) scale(1); opacity: 1; }
}
@keyframes raid-glow-pulse { from { opacity: .55; } to { opacity: 1; } }
.raid-ring {
  position: absolute; left: 50%; bottom: -6px; width: 26px; height: 26px;
  transform: translate(-50%, 50%) scale(.2); border-radius: 50%;
  border: 2px solid rgba(168,40,40,.85); opacity: 0;
  animation: raid-ring-pop .7s ease-out 1 forwards;
}
@keyframes raid-ring-pop {
  0% { transform: translate(-50%, 50%) scale(.2); opacity: .9; }
  100% { transform: translate(-50%, 50%) scale(2.4); opacity: 0; }
}
/* Settlement glyph (18×13) standing on the target latlng (stack bottom). */
.raid-stlmt {
  position: absolute; left: 50%; bottom: 0; width: 18px; height: 13px;
  transform: translateX(-50%);
  /* The plate's halo, so the settlement floats off the terrain exactly like the
     colony marks do (spec 2026-07-12). Replaces the old flat drop-shadow. */
  filter: drop-shadow(0 0 1px rgba(70,55,30,.9)) drop-shadow(0 0 6px rgba(247,242,232,.95));
}
.raid-stlmt svg { display: block; width: 100%; height: 100%; }
/* Charred copy fades in over the alive glyph while the fire holds (burned). */
.raid-char-overlay {
  position: absolute; inset: 0; opacity: 0;
  animation: raid-charring 2.5s ease-in 1.6s 1 forwards;
}
@keyframes raid-charring { to { opacity: 1; } }

/* — burned · B5 "Ember bloom": the glow swells FIRST (heat before flame),
   then the tongues grow out of it. NO impact flash — rejected. — */
/* Growers: the wrapper carries the staggered entrance (scaleY from the base),
   the inner flame carries the flicker — two elements so the two transform
   animations never fight. */
.raid-fgrow {
  position: absolute; left: 50%; bottom: 9px;
  transform: translateX(-50%) scaleY(.1); transform-origin: 50% 100%;
  opacity: 0; animation: raid-flame-grow .7s ease-out forwards;
}
.raid-g1 { margin-left: -8px; animation-delay: .5s; }
.raid-g2 { animation-delay: .7s; }
.raid-g3 { margin-left: 8px; animation-delay: .95s; }
@keyframes raid-flame-grow {
  to { opacity: 1; transform: translateX(-50%) scaleY(1); }
}
.raid-flame {
  position: relative; display: block; width: 9px; height: 13px;
  background: linear-gradient(to top, #c33d12, #e8722a 55%, #f7c860);
  border-radius: 50% 50% 46% 46% / 68% 68% 32% 32%;
  transform-origin: 50% 100%;
  animation: raid-flick .32s ease-in-out infinite alternate;
}
.raid-flame::after {
  content: ""; position: absolute; left: 2.5px; bottom: 1px;
  width: 4px; height: 7px;
  background: linear-gradient(to top, #f3a13c, #ffe9a8);
  border-radius: 50% 50% 46% 46% / 68% 68% 32% 32%;
}
.raid-f1 { width: 11px; height: 16px; }
.raid-f2 { width: 7px; height: 10px; animation-delay: .12s; }
.raid-f3 { width: 7px; height: 11px; animation-delay: .2s; }
@keyframes raid-flick {
  from { transform: scaleY(.88); }
  to   { transform: scaleY(1.08); }
}
.raid-spark {
  position: absolute; left: 50%; bottom: 20px; width: 3px; height: 3px;
  border-radius: 50%; background: #f7c860; opacity: 0;
  animation: raid-spark-rise 1.4s linear 1.2s infinite;
}
.raid-sp1 { margin-left: -5px; }
.raid-sp2 { margin-left: 4px; animation-duration: 1.7s; animation-delay: 1.8s; }
@keyframes raid-spark-rise {
  0%   { transform: translate(0, 0); opacity: 0; }
  12%  { opacity: .95; }
  100% { transform: translate(5px, -26px); opacity: 0; }
}
.raid-smoke span {
  position: absolute; left: 50%; bottom: 22px; width: 9px; height: 9px;
  transform: translateX(-50%); border-radius: 50%;
  background: rgba(96,90,82,.4); filter: blur(2px);
  animation: raid-rise 2.8s 1.2s infinite;
}
.raid-smoke span:nth-child(2) { animation-delay: 2.6s; }
@keyframes raid-rise {
  0%   { transform: translate(-50%, 0) scale(.55); opacity: 0; }
  12%  { opacity: .55; }
  100% { transform: translate(calc(-50% + 9px), -40px) scale(1.7); opacity: 0; }
}

/* — plundered · P1 "Slash & scatter": fireless; the settlement survives. — */
.raid-jolt { animation: raid-jolt .55s ease-out .1s 1; }
@keyframes raid-jolt {
  0%, 100% { transform: translateX(-50%); }
  15% { transform: translate(calc(-50% - 3px), 1px); }
  35% { transform: translate(calc(-50% + 3px), -1px); }
  55% { transform: translate(calc(-50% - 2px), 1px); }
  75% { transform: translate(calc(-50% + 2px), 0); }
}
.raid-slash {
  position: absolute; left: 50%; bottom: 8px; width: 30px; height: 2.5px;
  margin-left: -15px;
  background: linear-gradient(90deg, transparent, #f6f3e8 30%, #cfd6da 55%, transparent);
  opacity: 0; transform-origin: 50% 50%;
}
.raid-sl1 { animation: raid-slash1 .5s ease-out 1; }
.raid-sl2 { animation: raid-slash2 .5s ease-out .18s 1; }
@keyframes raid-slash1 {
  0%   { opacity: 0; transform: rotate(38deg) scaleX(.15); }
  40%  { opacity: 1; transform: rotate(38deg) scaleX(1); }
  100% { opacity: 0; transform: rotate(38deg) scaleX(1.1); }
}
@keyframes raid-slash2 {
  0%   { opacity: 0; transform: rotate(-42deg) scaleX(.15); }
  40%  { opacity: 1; transform: rotate(-42deg) scaleX(1); }
  100% { opacity: 0; transform: rotate(-42deg) scaleX(1.1); }
}
.raid-dust {
  position: absolute; left: 50%; bottom: 2px; width: 14px; height: 9px;
  margin-left: -7px; border-radius: 50%;
  background: rgba(150,138,112,.5); filter: blur(2px); opacity: 0;
  animation: raid-dust .9s ease-out .1s 1;
}
@keyframes raid-dust {
  0%   { opacity: 0; transform: scale(.4); }
  25%  { opacity: .6; }
  100% { opacity: 0; transform: translate(-6px, -8px) scale(1.9); }
}
/* Loot container is rotated inline to the away-bearing (toward the raiders'
   origin); flecks tumble along its +x axis. */
.raid-loots { position: absolute; left: 50%; bottom: 6px; width: 0; height: 0; }
.raid-loot {
  position: absolute; width: 3.5px; height: 3.5px; border-radius: 1px;
  background: #d8b04a; box-shadow: 0 0 0 .5px rgba(61,58,53,.4); opacity: 0;
}
.raid-lt1 { animation: raid-loot-tumble 1.6s ease-in .25s 1; }
.raid-lt2 { top: -3px; animation: raid-loot-tumble 1.6s ease-in .5s 1; }
.raid-lt3 { top: 2px; animation: raid-loot-tumble 1.6s ease-in .75s 1; }
.raid-lt4 { top: -1px; animation: raid-loot-tumble 1.6s ease-in 1s 1; }
@keyframes raid-loot-tumble {
  0%   { opacity: 0; transform: translateX(0) rotate(0); }
  10%  { opacity: 1; }
  85%  { opacity: 1; }
  100% { opacity: 0; transform: translateX(26px) rotate(260deg); }
}

/* — killed/captured · K1 "Mourning veil": somber; still after the toll. — */
.raid-dimmed {
  /* replaces .raid-stlmt's filter list wholesale (dim + keep the shadow) */
  filter: saturate(.25) brightness(.78) drop-shadow(0 1px 1px rgba(0,0,0,.3));
}
.raid-veil {
  position: absolute; left: 50%; bottom: -1px; width: 24px; height: 18px;
  margin-left: -12px; border-radius: 3px;
  background: linear-gradient(to bottom, rgba(38,34,29,.62), rgba(38,34,29,.4));
  opacity: 0; animation: raid-veil-settle 1.1s ease-out .15s 1 forwards;
}
@keyframes raid-veil-settle {
  from { opacity: 0; transform: translateY(-7px); }
  to   { opacity: 1; transform: translateY(0); }
}
.raid-toll {
  position: absolute; left: 50%; bottom: 0; width: 26px; height: 26px;
  margin-left: -13px; margin-bottom: -8px; border-radius: 50%;
  border: 1.5px solid rgba(47,42,36,.75); opacity: 0; transform: scale(.15);
  animation: raid-toll-ring 1.6s ease-out 1;
}
.raid-toll2 { animation-delay: 1.1s; }
@keyframes raid-toll-ring {
  0%   { opacity: 0; transform: scale(.15); }
  12%  { opacity: .75; }
  100% { opacity: 0; transform: scale(2.2); }
}

/* — spared/fled · F1 "Slip aside, come home". — */
.raid-ghost { animation: raid-ghost-aside 1.6s ease-out .3s 1 forwards; }
@keyframes raid-ghost-aside {
  from { transform: translateX(-50%); opacity: 1; }
  to   { transform: translate(calc(-50% + 8px), 6px); opacity: .6; }
}
/* Dashed stand-off ring where the strike stopped short (own divIcon). */
.raid-standoff {
  display: block; width: 14px; height: 14px; margin: 3px auto;
  border-radius: 50%; border: 1.5px dashed #a82828; opacity: .9;
}

/* — aftermath remnant (own divIcon, 22×17, bottom-anchored) — */
.raid-after {
  position: absolute; left: 50%; bottom: 0; width: 18px; height: 13px;
  transform: translateX(-50%);
  filter: drop-shadow(0 1px 1px rgba(0,0,0,.25));
}
.raid-after svg { display: block; width: 100%; height: 100%; }
.raid-dim {
  filter: saturate(.25) brightness(.8) drop-shadow(0 1px 1px rgba(0,0,0,.25));
}

/* Raid detail lines — now inside the left margin's ONE detail slot
   (.map-sel-note); the floating card they were written for is gone.
   The badge and outcome-line rules went WITH that card and were deleted
   2026-07-25; margins.rs emits only the three lines below, and styles_lint.rs
   now denies the deleted names by string so they cannot float back.
   ⚠ Do NOT write those two class names here — the deny-list is a substring
   check and a mention in a comment would trip it. */
.raid-pop-snippet { font-family: Georgia, serif; font-size: 12.5px; margin: 6px 0; }
.raid-pop-cite { font-size: 11px; opacity: .75; }
.raid-pop-link { display: inline-block; margin-top: 6px; font-size: 12.5px; }
/* Dark theme only: the default blue link stings on the dark card — swap to the
   warm gilt accent used everywhere else on dark. Light theme keeps blue (reads
   fine on cream). Covers both forced-dark and OS-dark-without-a-forced-theme. */
:root[data-theme="dark"] .raid-pop-link { color: var(--gilt, #b39250); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .raid-pop-link { color: var(--gilt, #b39250); }
}

/* Migration detail lines — same: they live inside .map-sel-note now.
   The route line was deleted 2026-07-25: unemitted, and its colour reached for a
   custom property defined nowhere. Its name is on the styles_lint deny-list, so
   (as above) it must not be spelled out in this comment. */
.mig-pop-snippet {
  font-style: italic;
  opacity: 0.85;
  margin-top: 6px;
  font-size: 0.92em;
}

/* Reduced motion: freeze everything into the mockup's static poses.
   (The full strike TRAVEL is downgraded in Rust via reduced_motion().) */
@media (prefers-reduced-motion: reduce) {
  .raid-flame, .raid-smoke span, .raid-glow, .raid-ring, .raid-spark,
  .raid-fgrow, .raid-char-overlay, .raid-jolt, .raid-slash, .raid-dust,
  .raid-loot, .raid-veil, .raid-toll, .raid-ghost {
    animation: none;
  }
  .raid-fgrow { opacity: 1; transform: translateX(-50%) scaleY(1); }
  .raid-glow { opacity: .8; }
  .raid-veil { opacity: 1; }
  .raid-sl1 { opacity: 1; transform: rotate(38deg); }
  .raid-sl2 { opacity: 1; transform: rotate(-42deg); }
  .raid-ghost { transform: translate(calc(-50% + 8px), 6px); opacity: .6; }
  .raid-char-overlay { opacity: 0; }
  .raid-ring, .raid-toll, .raid-dust, .raid-loot, .raid-spark { opacity: 0; }
}

/* ── Raid register (/raids) ─────────────────────────────────────────── */
/* The register column is sized to its content (~680px) and centered on the page
   (margin auto). Everything inside is LEFT-aligned; the whole block reads as a
   centered column. The edit form is a responsive grid that fits this width
   (collapsing to one field-column as needed). */
.raid-register {
  max-width: 680px; margin: 0 auto; padding: 16px 16px 64px;
}
.rr-title { font-size: 26px; margin: 10px 0 4px; }
.rr-intro { font-size: 14px; opacity: .8; margin: 0 0 18px; max-width: 720px; }
.rr-year { margin-top: 8px; }
.rr-year-h {
  font-size: 15px; letter-spacing: .08em; opacity: .7;
  border-bottom: 1px solid var(--rule, #d8d2c4);
  padding-bottom: 4px; margin: 22px 0 10px;
}
/* Row content fills the (narrow, centered) column: badge + text on the left,
   Edit pushed to the right edge. The dotted divider spans the column width. */
.rr-row {
  display: flex; gap: 12px; align-items: flex-start;
  padding: 10px 0; border-bottom: 1px dotted var(--rule, #d8d2c4);
}
.rr-badge { width: 34px; height: 34px; flex-shrink: 0; }
.rr-main { flex: 1; min-width: 0; text-align: left; }
.rr-label { font-weight: 600; }
.rr-meta { font-size: 12.5px; opacity: .75; margin-top: 2px; }
.rr-snippet {
  font-family: Georgia, serif; font-size: 13px; margin: 6px 0;
  padding-left: 10px; border-left: 2px solid var(--rule, #d8d2c4);
}
.rr-cite { font-size: 11.5px; opacity: .65; }
.rr-conf { font-style: italic; }
.rr-edit {
  flex-shrink: 0; font-size: 12.5px; padding: 4px 10px; cursor: pointer;
}
.rr-add {
  margin-top: 18px; padding: 8px 14px; font-size: 14px; cursor: pointer;
}
/* The edit/add form: a responsive grid. auto-fill + minmax packs as many
   ~240px field columns as fit (two on a desktop form, one on a phone) with
   no per-field markup; wide fields opt into a full-row span below. */
.rr-form {
  margin: 8px 0 16px; padding: 16px 18px;
  border: 1px solid var(--rule, #d8d2c4); border-radius: 8px;
  display: grid; gap: 10px 16px;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  align-items: end;
}
.rr-form-h {
  font-weight: 600; font-size: 16px; margin-bottom: 2px;
  grid-column: 1 / -1;
}
.rr-field { display: block; margin-bottom: 0; min-width: 0; }
/* Inputs/selects/textarea fill their grid cell (they had no width, so the
   text was clipping — see the "Imperial troops strip…" cut-off). */
.rr-field .auth-input { width: 100%; box-sizing: border-box; }
.rr-field-name {
  display: block; font-size: 11px; letter-spacing: .04em;
  text-transform: uppercase; opacity: .65; margin-bottom: 4px;
}
/* Free-text and full-sentence fields read better across the whole row. */
.rr-field-wide, .rr-check, .rr-snippet-field { grid-column: 1 / -1; }
.rr-check {
  display: flex; gap: 8px; align-items: center; font-size: 13px;
  margin-top: 2px;
}
.rr-check input { margin: 0; }
.rr-snippet-input { min-height: 72px; resize: vertical; }
.rr-form-actions {
  grid-column: 1 / -1;
  display: flex; gap: 10px; margin-top: 6px;
  padding-top: 12px; border-top: 1px solid var(--rule, #d8d2c4);
}
.rr-save, .rr-cancel, .rr-retire { padding: 8px 16px; cursor: pointer; }
.rr-retire { margin-left: auto; color: #a82828; }

/* Map-register migrations section (Task 10). These rules MUST stay in the
   top-level (non-@media) scope — the css_section_swallowed_by_media_block trap.
   (`.rr-section-h` and its :first-of-type variant were deleted 2026-07-25: the
   three registers became TABS (.rr-tabs / .rr-tab, at the end of this file) and
   the <h2> headings went with the change. `.rr-row-mig` below is still live.) */
.rr-row-mig .rr-meta { font-style: italic; }
.rr-add-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin: 14px 0 20px;   /* top: gap from intro; bottom: separate from the lists */
  padding-bottom: 16px;
  border-bottom: 1px solid var(--rule, #d8d2c4);
}
.rr-add-bar .rr-add { margin-top: 0; }   /* the bar owns the spacing now */
/* (`.rr-add-kind` — the label that wrapped the kind <select> — was deleted
   2026-07-25: the active TAB decides what "+ Add" adds, so the picker's label
   went with the tab rewrite.) */
/* Compact select sized to sit beside the "+ Add" button, not a full form field. */
.rr-kind-select {
  width: auto;
  padding: 8px 12px;
  font-size: 14px;
  font-family: inherit;
  color: var(--ink);
  background: var(--rule-soft);
  border: 1px solid var(--rule);
  border-radius: 8px;
  cursor: pointer;
}
.rr-view-control {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--ink-dim);
}
.rr-view-control .rr-kind-select:disabled {
  cursor: not-allowed;
  opacity: .55;
}

@media (max-width: 599px) {
  .raid-register { padding: 12px 10px 48px; }
  .rr-row { gap: 8px; }
  .rr-badge { width: 28px; height: 28px; }
  /* One column on a phone — the auto-fill min would already force this, but
     make it explicit so a 240px min never causes a sideways scroll. */
  .rr-form { grid-template-columns: 1fr; }
}

/* ── Map colony action-labels ──────────────────────────────────────────
   A name pill on colonies with an event this year (founded / ended / raided).
   TWO elements: `.map-colony-lbl-box` is the divIcon element add_label creates —
   a FIXED 160px-wide transparent box (iconSize), used ONLY to center the pill
   over the point. `.map-colony-lbl` is the visible pill: an inner <span> that
   shrink-wraps its text. Styling the box as the pill would paint a 160px bar,
   hence the split (the arc labels are bare text so they never needed this).
   The basemap is ALWAYS light regardless of app theme, so pill colors are
   LITERAL (like .map-arc-lbl), NOT theme tokens — a dark chip with light text
   reads over the light terrain. Leaflet's default .leaflet-div-icon chrome is
   already neutralized map-wide at .map-leaflet .leaflet-div-icon (styles.css). */
.map-colony-lbl-box {
  /* Positioning only: center the inner pill within the wide anchor box. */
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
}
.map-colony-lbl {
  display: inline-block;
  background: rgba(24,20,16,.92); color: #e8e0cf;
  font-family: var(--serif-body); font-size: 12px; letter-spacing: .01em;
  white-space: nowrap; padding: 2px 8px; border-radius: 7px;
  border-left: 3px solid #b39250;
  box-shadow: 0 2px 8px rgba(0,0,0,.4);
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
}
.map-colony-lbl.is-founded { border-left-color: #d8b04a; } /* gold */
.map-colony-lbl.is-ended   { border-left-color: #a82828; } /* oxblood */
.map-colony-lbl.is-raided  { border-left-color: #a82828; } /* oxblood */
/* "Show all" extras (and a tapped colony with no real event): the same tan
   as the base pill — neutral, matching "Active settlement" in the Key, never
   gold/red for an event that didn't happen this year. */
.map-colony-lbl.is-plain   { border-left-color: #b39250; } /* tan, neutral */

/* ── Realm tint layer (spec 2026-07-09) ────────────────────────────────
   Year-reactive tinted realm polygons under the colony dots, with atlas-style
   name labels.
   Label colors are LITERAL per-realm hues inlined by view.rs (like
   .map-colony-lbl — the basemap is always light regardless of app theme). */

/* Realm name labels: the -box is the fixed-size transparent divIcon element
   (positioning only — same split as .map-colony-lbl-box); the inner span is
   the visible type. Reads as printed ON the base map: small caps, letter-
   spaced, translucent, light halo so it survives both Terrain and Satellite. */
.map-realm-lbl-box {
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
}
.map-realm-lbl {
  font-family: var(--serif-body); font-size: 12px; font-weight: 700;
  letter-spacing: .18em; text-transform: uppercase;
  opacity: .55; white-space: nowrap;
  text-shadow: 0 1px 3px rgba(255,255,255,.55), 0 0 2px rgba(255,255,255,.35);
}

/* ── Period-map overlay chrome (spec 2026-07-10) ─────────────────────
   Toggle + then↔now fade now live in the RIGHT MARGIN (spec 2026-07-12) —
   see the atlas-page section at the end of this file. */

/* ── World-events layer (spec 2026-07-11) ─────────────────────────────
   The era's major events as real period prints framed on the map. The
   "world this year" list is a LEFT-MARGIN section now (.map-wrow, spec
   2026-07-12); only the on-plate art lives here. */

/* Framed period print — the picture-cartouche. The divIcon element
   (.map-world-frame-box) is a transparent positioning box; .world-frame is
   the visible vellum-matted frame (fixed picture window; the scan renders
   background-size:cover and the full sheet lives in the lightbox). LITERAL
   colors: this sits on the light basemap / sepia engraving in both themes
   (the .map-colony-lbl rationale). The parchment placeholder + title
   renders UNDER the image span — a 404'd print never paints and the
   placeholder shows through (no broken-image glyph, no inline JS). */
.map-world-frame-box {
  display: flex; align-items: center; justify-content: center;
}
.world-frame {
  position: relative; display: block;
  width: 180px; height: 130px;
  background: #e9dfc8;                      /* vellum mat */
  border: 1px solid #6b5b43;                /* engraved double border: outer */
  box-shadow:
    inset 0 0 0 4px #e9dfc8,                /* mat ring */
    inset 0 0 0 5px #8a755a,                /* inner rule */
    0 3px 10px rgba(0, 0, 0, .35);
  cursor: pointer;
  animation: world-unveil 1.4s ease-out both;
}
.world-frame-ph, .world-frame-img { position: absolute; inset: 7px; }
.world-frame-ph {
  display: flex; align-items: center; justify-content: center;
  text-align: center; padding: 6px;
  background: #efe6d2;                      /* parchment placeholder */
  color: #5c4d38; font-family: var(--serif-body);
  font-size: 11px; line-height: 1.35;
}
.world-frame-img { background-size: cover; background-position: center; }
/* Ink-fade entrance (~1.4 s), then static — runs once per marker ADD (the
   draw Effect diffs by id, so scrub ticks never re-trigger it). */
@keyframes world-unveil {
  from { opacity: 0; filter: sepia(.9) contrast(.6); }
  to   { opacity: 1; filter: none; }
}
@media (max-width: 759px) {
  .world-frame { width: 140px; height: 101px; }
}

/* Wax-seal marker — same-year sibling moments; tap swaps which is framed
   (print-less seals render identically but are non-interactive). */
.map-world-seal-box {
  display: flex; align-items: center; justify-content: center;
}
.world-seal {
  width: 18px; height: 18px; border-radius: 50%;
  background: radial-gradient(circle at 35% 32%, #b8412e, #7e2417 68%, #5f1a10);
  border: 1px solid rgba(60, 18, 10, .85);
  box-shadow: 0 1px 4px rgba(0, 0, 0, .4),
              inset 0 0 0 2.5px rgba(255, 240, 220, .18);
  cursor: pointer;
  animation: world-unveil 1.4s ease-out both;
}
/* Inert seals (print-less moments) are not tap targets — no pointer lie. */
.map-world-seal-box.is-quiet .world-seal { cursor: default; }

@media (prefers-reduced-motion: reduce) {
  .world-frame, .world-seal { animation: none; }
}

/* Lightbox — the full sheet. Modal over everything (popovers are 1000);
   its backdrop covers the map, so nothing beneath needs to yield. Mobile:
   a full-screen sheet. Frame palette matches .world-frame (literal —
   theme-independent print viewing). */
.world-lightbox {
  position: fixed; inset: 0; z-index: 1200;
  background: rgba(10, 8, 6, .82);
  display: flex; align-items: center; justify-content: center;
  padding: 20px;
}
.world-lightbox-inner {
  position: relative; margin: 0;
  max-width: min(920px, 100%); max-height: 100%;
  overflow: auto;
  background: #e9dfc8; border: 1px solid #6b5b43;
  box-shadow: inset 0 0 0 3px #8a755a, 0 12px 40px rgba(0, 0, 0, .5);
  padding: 14px;
  display: flex; flex-direction: column; gap: 10px;
}
.world-lightbox-img { display: block; max-width: 100%; height: auto; }
.world-lightbox-cap { font-family: var(--serif-body); color: #3d3223; }
.world-lightbox-title { font-size: 15px; font-weight: 700; }
.world-lightbox-credit { font-size: 11.5px; color: #6b5b43; margin-top: 2px; }
/* ✕ pinned to the BACKDROP (viewport corner, lightbox convention) — never
   inside the scroller, so it can't scroll out of reach on tall prints. */
.world-lightbox-x {
  position: absolute; top: 14px; right: 14px; z-index: 1;
  width: 30px; height: 30px; border-radius: 8px;
  background: rgba(233, 223, 200, .9); border: 1px solid #6b5b43;
  color: #3d3223; font-size: 14px; line-height: 1;
  cursor: pointer;
}
@media (max-width: 759px) {
  .world-lightbox { padding: 0; }
  .world-lightbox-inner {
    width: 100%; height: 100%; max-height: none;
    border: none;
  }
  /* Clear a notch/Dynamic Island in standalone mode — the sheet is truly
     full-screen here. */
  .world-lightbox-x { top: calc(10px + env(safe-area-inset-top, 0px)); right: 10px; }
}

/* ── Legibility halo (spec 2026-07-11 §5) ──
   While the Period-map layer is ON, the data art gets a cream halo so
   pills / badges / flames / aftermath marks / arc labels read over the
   engraving. Values from the validated companion mock; tune at smoke.
   LITERAL color: the halo sits over the period scan, identical in both
   themes (the .map-colony-lbl literal-colors rationale). */
.map-sheet.period-on .map-colony-lbl,
.map-sheet.period-on .map-arc-lbl,
.map-sheet.period-on .raid-origin-lbl,
.map-sheet.period-on .raid-burn,
.map-sheet.period-on .raid-standoff {
  filter: drop-shadow(0 0 1.5px #f2ecdc) drop-shadow(0 0 3px #f2ecdc);
}
/* The badge keeps its existing dark drop-shadow; the halo wraps around it
   (this rule REPLACES the base .raid-badge filter while period-on). */
.map-sheet.period-on .raid-badge {
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35))
          drop-shadow(0 0 1.5px #f2ecdc) drop-shadow(0 0 3px #f2ecdc);
}
/* Aftermath marks keep their own base shadow — and the dimmed variant its
   desaturation — under the halo (the .raid-badge treatment: filter values
   replace wholesale across the cascade, so each combination restates its
   base before appending the halo pair). */
.map-sheet.period-on .raid-after {
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .25))
          drop-shadow(0 0 1.5px #f2ecdc) drop-shadow(0 0 3px #f2ecdc);
}
.map-sheet.period-on .raid-after.raid-dim {
  filter: saturate(.25) brightness(.8) drop-shadow(0 1px 1px rgba(0, 0, 0, .25))
          drop-shadow(0 0 1.5px #f2ecdc) drop-shadow(0 0 3px #f2ecdc);
}

/* ── Map layer switches (spec 2026-07-11, re-homed 2026-07-12) ────────
   These rows once lived in a floating top-right drawer. The drawer is GONE:
   they are now bare rows in the right margin, under a "Layers" section head
   (.map-h) like everything else on that page. The PILL SWITCH is untouched —
   only the row's housing changed: full-width, transparent, no panel padding.
   The off-state is a pill switch, NOT a strikethrough (that reads "deleted"). */
/* Rows are <button>s: reset the UA chrome, keep the label/control split. */
.map-layers-row {
  display: flex; align-items: center; justify-content: space-between;
  width: 100%; gap: 12px; padding: 7px 0;
  background: none; border: 0; border-top: 1px solid var(--rule-soft);
  font-family: var(--serif-body); font-size: 12.5px; color: var(--ink-soft);
  text-align: left; cursor: pointer;
  transition: color .15s ease;
}
.map-layers-row:first-of-type { border-top: 0; }
.map-layers-row:hover { color: var(--gilt); }
.map-layers-row.is-off .map-layers-name { color: var(--ink-faint); }
.map-layers-name { white-space: nowrap; }

/* Pill switch: gilt track + knob right when on; dull track + knob left when off. */
.map-layers-track {
  position: relative; flex: none; width: 26px; height: 14px;
  border-radius: 7px; background: var(--rule);
  transition: background .15s ease;
}
.map-layers-track i {
  position: absolute; top: 2px; left: 2px;
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--page-bg);
  transition: left .15s ease;
}
.map-layers-track.is-on { background: var(--gilt); }
.map-layers-track.is-on i { left: 14px; }

/* Base map: a cycler, not a switch — it names the current mode instead. */
.map-layers-mode { font-size: 11px; color: var(--gilt); white-space: nowrap; }

/* The register entrance (contributors/admins only): oxblood marks it as a
   different KIND of row — a place you leave to, not a switch you flip. */
.map-layers-register {
  color: var(--oxblood); text-decoration: none;
}
.map-layers-register .map-layers-mode { color: var(--oxblood); }
.map-layers-register:hover,
.map-layers-register:hover .map-layers-mode { color: var(--oxblood-deep); }

@media (prefers-reduced-motion: reduce) {
  .map-layers-track, .map-layers-track i { transition: none; }
}

/* ── The engraved mark vocabulary (spec 2026-07-12) ───────────────────
   Every mark on the plate: a paper-white keyline, a dark hairline outside it,
   and a soft paper halo — so it carries its own background and floats off ANY
   basemap (relief, satellite, a 1570 engraving). The SILHOUETTE says what it
   is; the colour only reinforces. Replaces the flat 7px circleMarker dots that
   sank into the hillshade.

   LITERAL colours, not theme tokens: these sit on the basemap, which is always
   light regardless of the app theme — the same rationale as .map-colony-lbl. */
.map-mark-box { display: flex; align-items: center; justify-content: center;
                pointer-events: auto; cursor: pointer; }
.map-mark {
  display: block; box-sizing: border-box;
  border: 1.5px solid #f7f2e8;
  box-shadow: 0 0 0 1px rgba(70,55,30,.8), 0 0 8px 3px rgba(247,242,232,.8);
}
/* circle = founded this year */
.map-mark.is-founded { width: 15px; height: 15px; border-radius: 50%; background: #d8b04a; }
/* diamond = an active settlement */
.map-mark.is-active  { width: 12px; height: 12px; background: #b39250; transform: rotate(45deg); }
/* struck lozenge = ended this year */
.map-mark.is-ended {
  position: relative; width: 15px; height: 15px; border-radius: 2px;
  background: #a82828; transform: rotate(45deg);
}
.map-mark.is-ended::after {
  content: ""; position: absolute; inset: 3px; border: 1.5px solid rgba(247,242,232,.9);
}
/* ghost ring = not yet / gone */
.map-mark.is-gone {
  width: 8px; height: 8px; border-radius: 50%; background: transparent;
  border: 1.5px dotted rgba(60,50,35,.65);
  box-shadow: 0 0 5px 2px rgba(247,242,232,.55);
}
/* open ring + centre dot = a place we know, a year we don't (Status::Undated).
   Deliberately distinct from .is-gone, which it must never be confused with: the
   ghost's ring is DOTTED because we HAVE a date that rules the colony out of this
   year; this ring is SOLID because the place is real and it is only the DATE we
   lack. Drawn identically at every year — the constancy IS the signal. */
.map-mark.is-undated {
  position: relative; width: 9px; height: 9px; border-radius: 50%;
  background: transparent;
  border: 1.5px solid rgba(60,50,35,.7);
  box-shadow: 0 0 5px 2px rgba(247,242,232,.6);
}
.map-mark.is-undated::after {
  content: ""; position: absolute; inset: 1.5px; border-radius: 50%;
  background: rgba(60,50,35,.7);
}
/* The "Full size" Layers toggle: Pre/Gone/Undated grow to match Active's
   own 12px (never Founded/Ended's bigger 15px pulse — that stays a distinct,
   rarer signal). Silhouette/colour are UNCHANGED — still a hollow ring, still
   dotted vs solid — so a Gone colony still visibly reads as gone, just no
   longer shrunk to near-invisibility. */
.map-mark.is-gone.is-full { width: 12px; height: 12px; }
.map-mark.is-undated.is-full { width: 12px; height: 12px; }
/* the numeral cluster — several marks too close to separate */
.map-mark.is-cluster {
  width: 22px; height: 22px; border-radius: 50%; background: #f7f2e8;
  border: 1.5px solid #8a6e2e; color: #5a4820;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--serif-display); font-size: 11px; font-style: normal;
  box-shadow: 0 0 0 1px rgba(70,55,30,.4), 0 0 9px 3px rgba(247,242,232,.85);
}
/* a cluster holding a death keeps the oxblood keyline — never swallowed */
.map-mark.is-cluster.has-ended { border-color: #a82828; color: #7a1e1e; }

/* ── The "this one changed" swell ────────────────────────────────────
   Fires ONCE when the scrubber enters a year, on marks whose state changed
   THAT year (founded / ended). Unchanged marks never move — that contrast is
   the whole signal. 1.35x, ~620ms, slight overshoot, halo blooming with it so
   the mark lifts off the terrain for one beat. */
.map-mark.is-swell.is-founded { animation: mark-swell-round .62s cubic-bezier(.2,.9,.25,1) 1; }
.map-mark.is-swell.is-active  { animation: mark-swell-rot   .62s cubic-bezier(.2,.9,.25,1) 1; }
.map-mark.is-swell.is-ended   { animation: mark-swell-rot   .62s cubic-bezier(.2,.9,.25,1) 1; }
@keyframes mark-swell-round {
  0%   { transform: scale(1);
         box-shadow: 0 0 0 1px rgba(70,55,30,.8), 0 0 8px 3px rgba(247,242,232,.8); }
  28%  { transform: scale(1.35);
         box-shadow: 0 0 0 1px rgba(70,55,30,.85), 0 0 16px 7px rgba(247,242,232,.95); }
  60%  { transform: scale(.97); }
  100% { transform: scale(1);
         box-shadow: 0 0 0 1px rgba(70,55,30,.8), 0 0 8px 3px rgba(247,242,232,.8); }
}
/* the rotated shapes must keep their 45deg or they un-rotate mid-animation */
@keyframes mark-swell-rot {
  0%   { transform: rotate(45deg) scale(1);
         box-shadow: 0 0 0 1px rgba(70,55,30,.8), 0 0 8px 3px rgba(247,242,232,.8); }
  28%  { transform: rotate(45deg) scale(1.35);
         box-shadow: 0 0 0 1px rgba(70,55,30,.85), 0 0 16px 7px rgba(247,242,232,.95); }
  60%  { transform: rotate(45deg) scale(.97); }
  100% { transform: rotate(45deg) scale(1);
         box-shadow: 0 0 0 1px rgba(70,55,30,.8), 0 0 8px 3px rgba(247,242,232,.8); }
}
@media (prefers-reduced-motion: reduce) {
  .map-mark.is-swell { animation: none !important; }
}

/* ── The atlas page (spec 2026-07-12) ────────────────────────────────
   The map is a printed plate; the chrome is marginalia. Left margin = what you
   are reading, right margin = what you are controlling, the scale = when. The
   margins are PAPER, not panels: no card, no radius, no shadow, no blur —
   just --page-bg and one hairline against the plate. Panels float; margins
   belong. This replaces the eight floating surfaces the map used to carry. */
/* LEFT = the brethren. RIGHT = the world. The plate is the ground between them — so the
   two margins are EQUAL, and wide enough to read: at the old 244px a line of the year's
   prose is ~35 characters, half the comfortable measure. The ladder is the operator's,
   found with the mock's own overflow scan (2026-07-13-atlas-margins.html): 340px on a
   wide screen, stepping down so the plate never stops being a map. Below ~1000px the
   page reflows to one column (the phone block at the end of this file). */
.map-sheet {
  display: grid;
  grid-template-columns: 264px 1fr 264px;
  grid-template-rows: 1fr auto;
  grid-template-areas: "left plate right" "scale scale scale";
  min-height: 0;
}
@media (min-width: 1250px) {
  .map-sheet { grid-template-columns: 300px 1fr 300px; }
}
@media (min-width: 1500px) {
  .map-sheet { grid-template-columns: 340px 1fr 340px; }
}
.map-margin { background: var(--page-bg); padding: 20px 20px 0; min-height: 0;
              display: flex; flex-direction: column; }
.map-margin.is-left  { grid-area: left;  border-right: 1px solid var(--rule); }
.map-margin.is-right { grid-area: right; border-left: 1px solid var(--rule); }
/* one continuous scrolling column — a margin is a column, not a mode */
.map-margin-col { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden;
                  scrollbar-width: thin; padding-bottom: 20px; }
.map-plate { grid-area: plate; position: relative; min-width: 0; }
/* the Leaflet container fills the plate (it was flex:1 in the old column) */
.map-leaflet { position: absolute; inset: 0; width: auto; height: auto; }
/* the scale spans the foot of the page — see the ".map-scale" section at the
   end of this file, which carries `grid-area: scale`. */

/* the engraved double rule of a printed plate */
.map-frame {
  position: absolute; inset: 11px; pointer-events: none; z-index: 500;
  border: 1px solid rgba(90,72,45,.6);
  box-shadow: inset 0 0 0 3px rgba(247,242,232,.4), inset 0 0 0 4px rgba(90,72,45,.3);
}
/* the running head, printed ON the plate — not a floating caption */
.map-runhead {
  /* left clears Leaflet's zoom control (it sits at ~10px + 34px wide inside the
     plate and was printing straight through the running head). */
  position: absolute; left: 68px; right: 26px; top: 22px; z-index: 600;
  display: flex; justify-content: space-between; gap: 16px; pointer-events: none;
  font-family: var(--serif-display); font-size: 10px; letter-spacing: .18em;
  text-transform: uppercase;
}
.map-runhead .l { color: #6b5426; text-shadow: 0 1px 0 rgba(255,255,255,.45); }
.map-runhead .r { color: #8a8170; text-shadow: 0 1px 0 rgba(255,255,255,.45); }

/* ── THE ONE TYPOGRAPHIC IDIOM every margin section obeys ── */
.map-h {
  font-family: var(--serif-display); font-size: 10px; letter-spacing: .16em;
  text-transform: uppercase; color: var(--gilt);
  border-bottom: 1px solid var(--rule); padding-bottom: 5px;
  margin: 22px 0 10px;
}
/* A sub-group WITHIN one .map-h section (e.g. "Colonies" inside "Layers") —
   same idiom, tighter gap: it marks a division in one list, not a new one. */
.map-h-sub { margin-top: 14px; }
.map-margin-col > .map-h:first-child { margin-top: 0; }
/* (`.map-note` and its `.lede` child were deleted 2026-07-25 — nothing in
   reader/src emits either. The margin's prose is .map-synth / .map-sel-note,
   which carry the same 13.5px/1.62 metrics. `.map-dim` below IS live —
   margins.rs uses it for the attestation line.) */
.map-dim { color: var(--ink-faint); }

/* the Vorsteher line — small caps key, name, tenure */
.map-vor { margin-top: 10px; font-size: 12px; line-height: 1.5; }
.map-vor-k { font-family: var(--serif-display); font-size: 9px; letter-spacing: .14em;
             text-transform: uppercase; color: var(--gilt); margin-right: 7px; }
.map-vor-n { color: var(--ink); }
.map-vor-t { color: var(--ink-faint); margin-left: 6px; font-variant-numeric: tabular-nums; }
.map-vor-note { display: block; color: var(--ink-faint); font-size: 11.5px; margin-top: 2px; }

/* the ONE detail slot — an oxblood spine, the event cards' kind-marker language */
.map-sel { border-left: 2px solid var(--oxblood); padding-left: 12px; }
.map-sel-name { font-family: var(--serif-display); font-size: 18px; line-height: 1.15;
                color: var(--ink); }
.map-sel-dates { font-size: 11.5px; color: var(--ink-faint); margin: 4px 0 7px;
                 font-variant-numeric: tabular-nums; }
.map-sel-now, .map-sel-note { font-size: 13.5px; line-height: 1.62; color: var(--ink-soft); }
.map-sel-note { margin-top: 5px; }
.map-sel-inferred { font-size: 11px; color: var(--ink-faint); margin-top: 5px; }
/* the raid / migration slots reuse the popovers' inner lines (.raid-pop-snippet,
   .raid-pop-cite, .raid-pop-link, .mig-pop-snippet — still emitted by margins.rs);
   they sit inside .map-sel-note, so they inherit the margin's paper, not a card. */

/* the world list — an index: title left, years right, a dagger on a moment */
.map-wrow {
  display: block; width: 100%; text-align: left; background: none; border: 0;
  border-bottom: 1px solid var(--rule-soft); padding: 8px 0; cursor: default;
  font-family: var(--serif-body); color: var(--ink-soft);
}
button.map-wrow { cursor: pointer; }
button.map-wrow:hover .t { color: var(--gilt); }
.map-wrow .t { font-size: 12.5px; color: var(--ink-soft); }
.map-wrow.is-moment .t { color: var(--ink); }
.map-wrow.is-moment .t::before { content: "\2020"; color: var(--oxblood); margin-right: 6px; }
.map-wrow .y { font-size: 10.5px; color: var(--ink-faint); white-space: nowrap;
               float: right; margin-left: 10px; font-variant-numeric: tabular-nums; }
.map-wrow-sum { font-size: 12px; line-height: 1.55; color: var(--ink-faint); margin-top: 3px; }
.map-wrow-act { display: inline-block; margin-top: 4px; font-size: 10.5px;
                letter-spacing: .06em; color: var(--gilt); }
.map-wrow-eff { font-size: 11.5px; line-height: 1.5; color: var(--ink-soft); margin-top: 4px;
                border-left: 2px solid var(--oxblood); padding-left: 8px; }
/* "For the brethren:" — the effect line's key (emitted by margins.rs) */
.map-wrow-eff-k { color: var(--oxblood); margin-right: 5px; }

/* the key — the same marks as the plate, at rest */
.map-krow { display: flex; align-items: center; gap: 9px; padding: 4px 0;
            font-size: 12.5px; color: var(--ink-soft); }
.map-kmark { width: 13px; height: 13px; border-radius: 3px; flex: none; }
.map-kmark.is-founded { background: #d8b04a; }
.map-kmark.is-active  { background: #b39250; }
.map-kmark.is-ended   { background: #a82828; }
.map-kmark.is-gone    { background: transparent; border: 2px dotted var(--ink-faint); }
.map-kmark.is-undated { background: transparent; border: 2px solid var(--ink-faint); }

/* the then↔now fade — a margin row now, not a floating bar over the scrubber */
.map-fade { display: flex; align-items: center; gap: 8px; padding: 10px 0 0; }
.map-fade-k { font-family: var(--serif-display); font-size: 9px; letter-spacing: .12em;
              text-transform: uppercase; color: var(--ink-faint); }
.map-fade input[type="range"] { flex: 1; min-width: 0; accent-color: var(--gilt); }
.map-period-line { font-size: 11.5px; line-height: 1.5; color: var(--ink-faint); margin-top: 8px; }


/* ── The scale, engraved (spec 2026-07-12) ───────────────────────────
   ONE coordinate system: every element is placed by scale::year_pct, so the era
   bands, the ticks, the playhead AND the drag agree by construction. Era bands
   above the axis, ticks below it, the year hung under a gilt playhead in the
   display face — the year is ON the scale, not floating beside it. Replaces the
   2800px filmstrip that translated under a fixed head. */
/* The scale is a Play GUTTER plus the ruler TRACK. Play used to sit inside the
   drag surface, where the moving year numeral printed straight through it at the
   left end of the window. The gutter fixes that by construction — and because the
   TRACK is the element the drag measures, the geometry (px/year, year-at-x) reads
   the ruler itself and stays exact. */
.map-scale {
  grid-area: scale; height: 88px; display: flex; align-items: stretch;
  background: var(--canvas); border-top: 1px solid var(--rule);
  user-select: none;
}
.map-track {
  position: relative; flex: 1; min-width: 0;
  touch-action: none; cursor: ew-resize;
}
.map-eras { position: absolute; left: 0; right: 0; top: 0; height: 24px; }
/* Each era CLIPS its own label, so a narrow band can never spill its name across
   the bands beside it (they are adjacent by construction — an absolutely-
   positioned label centred on a 2%-wide band always intersected its neighbours).
   The tint lives on ::before, NOT on the band's own `opacity`, or the label would
   fade with it. */
.map-era {
  position: absolute; top: 0; height: 24px; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  border-right: 1px solid var(--rule);
}
.map-era::before {
  content: ""; position: absolute; inset: 0; background: var(--gilt); opacity: .07;
}
.map-era.is-live::before { opacity: .16; }
.map-era-lab {
  position: relative; z-index: 1; max-width: 100%; padding: 0 5px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  font-family: var(--serif-display); font-size: 9px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-faint); pointer-events: none;
}
.map-era.is-live .map-era-lab { color: var(--gilt); }
.map-axis { position: absolute; left: 0; right: 0; top: 24px; height: 1px; background: var(--rule); }
/* FOUR tick tiers — a printed scale is read at four depths: you glance for the
   labelled half-century, you count decades, the 5-year strokes give the eye its
   pace, and the year strokes show that a YEAR is the unit the scrubber steps in.
   A single tier read as an empty line. 141 strokes across the window. */
.map-tick { position: absolute; top: 24px; width: 1px; background: var(--rule); }
.map-tick.is-year   { height: 3px;  background: var(--ink-veryfaint); opacity: .8; }
.map-tick.is-fine   { height: 5px;  background: var(--ink-veryfaint); }
.map-tick.is-decade { height: 9px;  background: var(--ink-faint); opacity: .6; }
.map-tick.is-major  { height: 14px; background: var(--ink-faint); }
/* Every decade is labelled; the half-centuries are set louder so the eye still
   has anchors to jump between rather than a flat row of equal numerals. */
.map-ticklab {
  position: absolute; top: 39px; transform: translateX(-50%); pointer-events: none;
  font-size: 9px; color: var(--ink-faint); opacity: .7;
  font-variant-numeric: tabular-nums; letter-spacing: .02em;
}
.map-ticklab.is-major {
  font-size: 10.5px; opacity: 1; color: var(--ink-dim);
}
.map-playhead { position: absolute; top: 24px; width: 1px; height: 33px; background: var(--gilt); }
/* The year, with the live era NAMED beside it — a narrow band (era II is three
   years wide; era VI, "The 1622 Expulsion", is nine) can only hold a numeral, so
   this is where the era gets said in full. Clamped in the Rust to [6%, 94%] so it
   can never run off the track's ends; the playhead hairline above stays exact. */
.map-playyr {
  position: absolute; top: 54px; transform: translateX(-50%); pointer-events: none;
  display: flex; align-items: baseline; gap: 9px; white-space: nowrap;
}
.map-playyr .y {
  font-family: var(--serif-display); font-size: 22px; color: var(--gilt);
  font-variant-numeric: tabular-nums; line-height: 1;
}
.map-playyr .e {
  font-family: var(--serif-display); font-size: 9px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-faint); line-height: 1;
}
/* Play lives in its own gutter, OUTSIDE the track — the ruler cannot reach it,
   so nothing prints through it and its click cannot scrub.
   CENTRED, not left-aligned with a padding. The old `width:74px` +
   `padding-left:16px` + `text-align:left` held a ~38px word, so ~20px of slack
   ALL fell to the right of it and opened a gap before the next chevron — the
   uneven desktop spacing in the 2026-07-19 screenshot. Centring splits any
   remaining slack evenly, so the cluster's two gaps match by construction.
   The trailing letter-spacing (.16em hangs off the final letter) is cancelled
   with a matching text-indent, or the word sits a hair left of true centre. */
.map-play {
  flex: none; width: 66px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; cursor: pointer; text-align: center;
  font-family: var(--serif-display); font-size: 10px; letter-spacing: .16em;
  text-indent: .16em;
  text-transform: uppercase; color: var(--gilt);
}
.map-play:hover { color: var(--ink); }
/* The play control says its word on a desktop and DRAWS its glyph on a phone. The
   glyph is an SVG in currentColor, never the characters ▶/⏸: iOS renders those with
   emoji presentation (a blue rounded square) that no CSS can recolour. */
.map-play-i { display: none; width: 13px; height: 15px; fill: currentColor; flex: none; }

/* The ‹ › single-year steppers, FLANKING Play as one transport cluster in the
   gutter outside the measured track. They were phone-only until 2026-07-19; the
   keyboard (←/→, Shift for ten) is the desktop twin of these, and both funnel
   through the same step_year() so neither can outrun autoplay: a manual step
   always pauses it first.

   THE CLUSTER IS A GRID, NOT A ROW OF PADDED BOXES. Each control is the same
   fixed width and CENTRES its own glyph, so the three gaps are equal by
   construction. The first cut gave Play a 38px box holding a 13px glyph plus an
   asymmetric padding-left — the 25px of slack all fell to the right of the
   triangle and shoved the next chevron away, which is the uneven spacing in the
   2026-07-19 phone screenshot. Never pad these; size them and centre. */
.map-step {
  flex: none; width: 34px; align-self: center; height: 34px;
  padding: 0; border: 0; background: none; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  color: var(--gilt);
}
.map-step:hover { color: var(--ink); }
.map-step:active { color: var(--ink); }
/* The chevrons are STROKED (the play triangle is filled) — a filled chevron at
   this size reads as a blob; fill:none or the path closes into a wedge.
   2.8px with ROUND caps and joins is the weight the operator drew (2026-07-19):
   a confident mark you aim at, not the hairline the first cut shipped. It
   deliberately out-weighs the small-caps PLAY beside it — the arrows are the
   target, the word is a label. */
.map-step-i {
  width: 13px; height: 15px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 2.8;
  stroke-linecap: round; stroke-linejoin: round;
}
/* The cluster's only outer inset — one value, on the leading edge. */
.map-step.is-prev { margin-left: 8px; }

/* ── PHONE-ONLY FURNITURE (spec 2026-07-14) ──────────────────────────
   Three pieces that exist ONLY below 759px, declared hidden here so the base is
   the desktop page and the phone block (at the END of this file) turns each one
   on. A desktop plate simply prints its credit, points at the year you point at,
   and IS its three margins side by side — none of these earn their space there. */
.map-credit { display: none; }   /* the collapsed attribution ⓘ */
.map-tabs   { display: none; }   /* Brethren · The world · Layers */
/* (.map-steps — the old right-hand ‹ › gutter — was retired 2026-07-19; the
   steppers moved into the left transport cluster and now show at every width.
   See the .map-step block above.) */

/* the invisible native range: keyboard + screen-reader control, layered over the
   ruler. pointer-events:none so it never swallows the drag (the ruler owns
   pointer events); it stays focusable and operable by keyboard. */
.map-range {
  position: absolute; inset: 0; width: 100%; height: 100%;
  margin: 0; opacity: 0; -webkit-appearance: none; appearance: none;
  pointer-events: none; z-index: 1;
}

/* ── The narrating margins (spec 2026-07-13) ──────────────────────────
   The map is sitting on a complete, sourced, one-line-per-event narration of 140 years
   and has never spoken it. These are the marks it speaks with. */

/* the year, printed like a plate number */
.map-yr { font-family: var(--serif-display); font-size: 36px; line-height: 1;
          font-variant-numeric: tabular-nums; margin: 0; color: var(--ink); }
/* NB: `.map-era` is NOT free — it is the scrubber's era BAND. This is the margin's era. */
.map-era-name { font-size: 12.5px; color: var(--gilt); font-style: italic; margin: 2px 0 0; }
.map-eratext { font-size: 11.5px; color: var(--ink-soft); margin: 6px 0 0; line-height: 1.5; }

/* WHERE AM I IN THE STORY — the era never told you the year */
.map-pos { margin: 11px 0 0; height: 3px; background: var(--rule);
           position: relative; border-radius: 2px; }
.map-pos i { position: absolute; top: -3px; width: 3px; height: 9px; background: var(--ink); }
.map-posn { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px;
            font-size: 9.5px; color: var(--ink-soft); }

/* the year's LEDE (the synthesis pass fills these later) */
.map-synth { font-size: 14px; line-height: 1.62; margin: 0; color: var(--ink); }

/* THE EMPTY CARTOUCHE — an atlas plate leaves its cartouche ruled and blank until it is
   engraved; it does not leave a hole. Honest, in the idiom, and it doubles as the
   authoring queue: the map itself tells you which years still need writing. */
.map-cartouche {
  border: 1px solid var(--rule); border-radius: 2px; padding: 9px 11px; margin: 0;
  position: relative;
  background: repeating-linear-gradient(-45deg, transparent 0 6px,
              rgba(201,189,166,.13) 6px 12px);
}
.map-cartouche:before { content: ""; position: absolute; inset: 3px;
  border: 1px solid rgba(201,189,166,.55); pointer-events: none; }
.map-cartouche b { display: block; font-family: var(--serif-display); font-size: 10px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--gilt); margin-bottom: 3px; }
.map-cartouche p { margin: 0; font-size: 11.5px; line-height: 1.5; font-style: italic;
  color: var(--ink-soft); position: relative; }

/* the RECORD, beneath the lede */
.map-rec { font-family: var(--serif-display); font-size: 9px; letter-spacing: .11em;
           text-transform: uppercase; color: var(--ink-soft); margin: 14px 0 5px; }
.map-fall { margin: 0; padding: 0; list-style: none; }
.map-fall-i { position: relative; padding: 6px 0 6px 13px; font-size: 12.5px;
              line-height: 1.45; border-bottom: 1px dotted var(--rule); }
.map-fall-i:last-child { border-bottom: 0; }
.map-fall-i:before { content: ""; position: absolute; left: 0; top: 12px;
  width: 4px; height: 4px; border-radius: 99px; background: var(--ink-soft); }
/* The kind vocabulary, borrowed from the marks on the plate. An unknown kind keeps the
   default bullet. ⚠ THE VARIABLE IS --oxblood: there is no --ox, and an undefined custom
   property would render these bullets TRANSPARENT, not red. */
.map-fall-i.k-martyrdom:before, .map-fall-i.k-dissolution:before { background: var(--oxblood); }
.map-fall-i.k-migration:before { background: var(--gilt); }
.map-fall-at { color: var(--ink-soft); font-size: 10.5px; font-style: italic; }
.map-more { display: inline-block; margin-top: 8px; padding: 0; border: 0;
  background: none; font: inherit; font-size: 11px; color: var(--gilt);
  text-decoration: underline dotted; cursor: pointer; }

/* THE FOLD IS A CSS TOGGLE, AND THAT IS LOAD-BEARING.
   Every title and both buttons are ALWAYS mounted; `is-folded`/`is-hidden` hide them. If
   the fold were a Rust conditional instead, clicking "+18 more" would unmount the very
   button being clicked and drop its Closure mid-dispatch — the "closure invoked after being
   dropped" family, which with panic=abort kills the app. (See margins.rs's comment, and the
   four defer_set/defer_close helpers this codebase already carries for it.)
   ⚠ The `6` is year_voice::TITLES_SHOWN + 1. If that constant moves, this moves. */
.map-fall.is-folded > .map-fall-i:nth-child(n + 6) { display: none; }
.map-fall.is-folded > .map-fall-i:nth-child(5) { border-bottom: 0; }
.map-more.is-hidden { display: none; }

/* a silent year, and a quiet world. Say it — an absent section reads as a bug. */
.map-quiet { color: var(--ink-soft); font-style: italic; font-size: 13px; margin: 0; }

/* THE ROAD, and the land it crossed */
.map-road { margin-bottom: 12px; }
.map-road-hd { font-size: 12px; margin: 0 0 4px; }
.map-road-note { margin: 2px 0 0; font-size: 12px; line-height: 1.45; font-style: italic; opacity: .78; }
.map-land-t { margin: 0 0 3px; font-size: 12.5px; line-height: 1.5; }
.map-land-c { margin: 0; font-size: 11px; opacity: .62; }
.map-road-prof { display: block; width: 100%; height: 38px;
  background: rgba(201,189,166,.2); border: 1px solid var(--rule); }
.map-road-prof .fill { fill: rgba(122,92,46,.15); }
.map-road-prof .line { fill: none; stroke: var(--gilt); stroke-width: 1.3; }
.map-road-ft { display: flex; justify-content: space-between; margin-top: 3px;
  font-size: 9.5px; color: var(--ink-soft); }

/* why the plate is here */
.map-why { font-size: 11.5px; color: var(--ink-soft); margin: 0; }

/* THE WORLD'S ENGRAVING, in the right margin. THE CAP IS LOAD-BEARING: without it a wider
   margin makes the White Mountain engraving TALLER, and widening the margins backfires. */
.map-wrow-print { display: block; width: 100%; max-height: 150px; object-fit: cover;
  object-position: center 38%; margin: 8px 0 3px; border: 1px solid var(--rule);
  filter: sepia(.2) contrast(1.03); }
.map-wrow-cred { font-size: 9px; color: var(--ink-soft); font-style: italic; margin: 0; }

/* ── The atlas page on a phone (spec 2026-07-12) ──────────────────────
   A narrow viewport has no margins to spare — and no sheet, no tabs, no
   gesture either. A page on a phone doesn't sprout a draggable drawer; it
   reflows into one column. Grid is off entirely: plate, then the scale,
   then the two margins' sections below, in the SAME order as the desktop
   margins (left, then right), all scrolled as one page. Nothing covers the
   plate; there is no mode to be in. This block sits at the end of the file
   (after the base .map-scale / .map-mark rules it overrides) so cascade
   order — not just specificity — puts it on top at narrow widths.
   (docs/superpowers/mockups/2026-07-12-mobile-fold.html, option B.) */
/* ── THE REFLOW (≤ 999px) ──
   Three real reading margins cannot hold in 900px, so the page drops to ONE column.
   This block carries ONLY the consequences of turning the grid off — it must NOT carry
   the phone's ERGONOMICS (touch targets, the shrunken scale, the hidden decade numerals):
   those would land on an iPad in portrait and a half-screen laptop window, which are not
   phones. They stay in the 759px block below. */
@media (max-width: 999px) {
  .map-sheet {
    display: block;
    /* the desktop rule pins position:fixed + overflow:hidden so the grid's
       columns can be viewport-height panes; here the PAGE scrolls instead. */
    overflow-y: auto;
    overflow-x: hidden;
  }
  /* the Leaflet plate collapses to zero height without one — it's
     position:relative holding an absolutely-positioned Leaflet container. */
  .map-plate { height: 46vh; min-height: 260px; }
  /* the margins stack, so their vertical hairline becomes a horizontal one */
  .map-margin.is-left, .map-margin.is-right {
    border-right: 0; border-left: 0;
    border-top: 1px solid var(--rule);
    padding: 14px 14px 0;
  }
  /* the page scrolls now, not the column */
  .map-margin-col { overflow: visible; }
  .map-margin.is-right { padding-bottom: 28px; }
  .map-margin.is-right .map-margin-col { padding-bottom: 24px; }
}

/* ── THE PHONE (≤ 759px) — ergonomics, AND (since 2026-07-14) its own layout ── */
@media (max-width: 759px) {
  /* ── THE PHONE PAGE (spec 2026-07-14) ──
     The ≤999px block above turns the grid OFF and lets the PAGE scroll. That is
     right for a tablet and wrong for a phone: the map scrolls away the moment you
     read anything, and a Layers switch below the fold is a control you cannot watch
     work. (That is the whole reason this became tabs and not a longer column.)

     So the grid comes back, VERTICAL, and only ONE row scrolls. The plate and the
     scale are always on screen; the tabs swap what is underneath them. Nothing
     floats, nothing is dismissed, and there is no gesture to learn. */
  .map-sheet {
    display: grid;
    grid-template-columns: 1fr;
    /* dvh, not vh: the mobile URL bar showing/hiding must not resize the plate.
       ⚠ minmax(0, 1fr), NOT 1fr. A bare `1fr` is minmax(AUTO, 1fr): its floor is the
       item's min-content height, so a long pane GROWS the row instead of scrolling
       inside it — the row pushes past the fixed sheet, `overflow:hidden` clips it,
       and the bottom of the pane becomes simply unreachable. That is exactly what
       "the lower data scroll stopped working" was. The 0 floor lets the row be
       smaller than its content, which is what makes the pane's own overflow scroll. */
    grid-template-rows: 52dvh auto auto minmax(0, 1fr);
    grid-template-areas: "plate" "scale" "tabs" "pane";
    overflow: hidden;            /* the PANE scrolls, not the sheet */
  }
  /* The ≤999px block hands the plate a fixed height because the page scrolls there.
     Here the grid row sizes it, so give it back. */
  .map-plate { grid-area: plate; height: auto; min-height: 0; }
  .map-scale { grid-area: scale; }
  .map-tabs  { grid-area: tabs; display: flex; border-top: 1px solid var(--rule); }

  /* Both margins occupy the SAME row. Exactly one is shown, chosen by the sheet's
     tab class; and inside the right margin, exactly one of its two panes. */
  .map-margin.is-left, .map-margin.is-right {
    grid-area: pane; min-height: 0;
    overflow-y: auto; -webkit-overflow-scrolling: touch;
    /* Without these, a touch-scroll near a Leaflet map (which owns its own
       pan/zoom gestures in the plate row above) can read as hesitant, and
       hitting the top/bottom of the pane can chain into a body-level bounce —
       both feel like "buggy" scroll even though the pane itself is fine. */
    touch-action: pan-y;
    overscroll-behavior-y: contain;
    border-top: 0; padding: 14px 14px 20px;
  }
  .map-margin-col { overflow: visible; }

  .map-sheet.tab-brethren .map-margin.is-right { display: none; }
  .map-sheet.tab-world    .map-margin.is-left  { display: none; }
  .map-sheet.tab-world    .map-pane.is-layers  { display: none; }
  .map-sheet.tab-layers   .map-margin.is-left  { display: none; }
  .map-sheet.tab-layers   .map-pane.is-world   { display: none; }

  .map-tab {
    flex: 1; padding: 12px 4px; min-height: 44px;
    background: none; border: 0; border-bottom: 2px solid transparent;
    cursor: pointer; color: var(--ink-faint);
    font-family: var(--serif-display); font-size: 11px; letter-spacing: .12em;
    text-transform: uppercase;
  }
  .map-tab.is-active { color: var(--gilt); border-bottom-color: var(--gilt); }

  /* the cartouche is prose, and prose on a phone is read at body size */
  .map-cartouche { padding: 11px 13px; }
  .map-cartouche b { font-size: 11px; }
  .map-cartouche p { font-size: 13px; line-height: 1.55; }

  /* (The 2026-07-12 phone scale — 76px, era names hidden, the year at top:50px —
     was REPLACED by the rebuilt scale at the foot of this block, where the year no
     longer collides with the half-century numerals. Its rules are gone rather than
     overridden: two sets of scale rules in one block is a lie about what is in
     force. `.map-era-lab { display: none }` went with them — the era BANDS are
     hidden outright now, so their labels have nothing to hide inside.) */

  /* ── The controls, on a phone ──
     A 26x14px pill is a desktop control shrunk onto a touchscreen. The rows
     become real touch targets (44px, the Apple/Android minimum) and the
     switches grow to match. The pill's proportions are unchanged — it is the
     same switch, at a size a thumb can actually hit. */
  .map-layers-row { padding: 13px 0; font-size: 15px; min-height: 44px; }
  .map-layers-name { font-size: 15px; }
  .map-layers-mode { font-size: 14px; }
  .map-layers-track { width: 44px; height: 26px; border-radius: 13px; }
  .map-layers-track i { width: 20px; height: 20px; top: 3px; left: 3px; }
  .map-layers-track.is-on i { left: 21px; }

  /* the key reads at body size too — it is prose, not chrome */
  .map-krow { font-size: 14.5px; padding: 6px 0; }
  .map-kmark { width: 15px; height: 15px; }

  /* the fade slider gets a real thumb, and clears the foot of the page */
  .map-fade { padding: 14px 0 8px; }
  .map-fade input[type="range"] { height: 28px; }
  .map-fade-k { font-size: 10px; }
  .map-runhead { left: 20px; right: 20px; top: 18px; }
  .map-mark.is-founded, .map-mark.is-ended { width: 14px; height: 14px; }
  .map-mark.is-active { width: 11px; height: 11px; }

  /* ── THE PLATE, DECLUTTERED (spec 2026-07-14) ──
     The phone screenshot of 1620 had seven floating chips, a zoom control over
     the running head and two lines of credit — and the engraved marks, the
     subject of the whole atlas, were the SMALLEST objects on their own plate.
     Everything that is not the map, the marks, or the plate's printed furniture
     comes off.

     This is CSS and not Rust ON PURPOSE: a Leaflet map is created ONCE, so an
     init flag would freeze the decision at load and get a rotation wrong. */

  /* Pinch and double-tap already zoom a phone; the buttons only covered the
     running head ("RAVIA · UNDER HABSBURG RULE"), which now gets its title back. */
  .map-leaflet .leaflet-control-zoom { display: none; }

  /* The arc from/to chips ("from Auspitz (Moravian heartland)" ran nearly the
     full screen width). The arc still marches origin→destination.
     ⚠ .map-arc-lbl-hit is NOT hidden — it is the transparent 120×24 tap target
     that makes a thin arc tappable, and tapping the arc is how a phone reader
     gets those names back. Hiding it would take the popover away with the label. */
  .map-arc-lbl-box { display: none; }

  /* The framed period engravings pinned on the map crowd the Moravian core. The
     same moments are rows in the World tab, and tapping a row still flies the
     map — nothing is lost; it just stops being printed on top of the map. */
  .map-world-frame-box, .map-world-seal-box { display: none; }

  /* THE CREDIT IS THE LICENCE, so it is COLLAPSED — never removed. Cliopatria is
     CC-BY 4.0 and the Esri/CARTO tiles carry their own terms: the credit must stay
     REACHABLE, not permanently printed. The ⓘ button (view.rs) toggles
     `.credit-open` on the sheet. */
  .map-leaflet .leaflet-control-attribution { display: none; }
  .map-sheet.credit-open .map-leaflet .leaflet-control-attribution {
    display: block; position: absolute; left: 8px; right: 8px; bottom: 8px;
    z-index: 700; padding: 8px 10px; line-height: 1.5; font-size: 10px;
    background: rgba(247,242,232,.96); color: #4a4030;
    border: 1px solid rgba(90,72,45,.5);
  }
  .map-credit {
    position: absolute; right: 14px; bottom: 14px; z-index: 700;
    width: 26px; height: 26px; padding: 0; border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    background: rgba(247,242,232,.92); border: 1px solid rgba(90,72,45,.5);
    color: #5a4820; font-family: var(--serif-display); font-size: 13px;
    line-height: 1; cursor: pointer;
  }
  /* 26px is the DECLUTTERED size and it stays — this whole block exists to take
     ink off the plate. Only the reach grows, to 44×44, via the same transparent
     hit-expander the rail flag and grab tabs use. */
  .map-credit::before {
    content: "";
    position: absolute;
    inset: -9px;
    border-radius: 50%;
  }

  /* ── THE NAMES, ENGRAVED ──
     A colony name was the last FLOATING SURFACE left on the plate: an opaque dark
     chip with a blur, a shadow and a 3px gold border — a UI panel sitting on a
     printed page. The realm labels (.map-realm-lbl) already print the atlas way —
     ink, a paper halo, no chip — and the names now join them. Same words, a
     fraction of the ink, and the plate reads as cartography instead of as an app.

     The KIND survives as the colour of the INK, not as a border. Colours are
     LITERAL, not theme tokens: the basemap is always light regardless of the app
     theme (the .map-colony-lbl rationale). */
  .map-colony-lbl {
    background: none; border-left: 0; border-radius: 0; padding: 0;
    box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none;
    font-size: 11.5px; font-weight: 700; letter-spacing: .02em;
    color: #3a3226;
    text-shadow: 0 1px 3px rgba(247,242,232,.95), 0 0 2px rgba(247,242,232,.9),
                 0 0 6px rgba(247,242,232,.7);
  }
  .map-colony-lbl.is-founded { color: #8a6a12; }  /* gold, in ink */
  .map-colony-lbl.is-ended,
  .map-colony-lbl.is-raided  { color: #8f2020; }  /* oxblood, in ink */
  /* The tapped colony: NEUTRAL ink. A colony tapped in a quiet year has no event,
     and colouring it as a founding would be a lie told in gold. */
  .map-colony-lbl.is-selected { color: #3a3226; }
  /* "Show all" extras: same neutral ink — no event to report, so no colour. */
  .map-colony-lbl.is-plain { color: #3a3226; }

  /* ── THE SCALE, ON A PHONE (rebuilt 2026-07-14) ──
     Three complaints, all real.
     (1) IT LOOKED BROKEN: the year sat at top:50px and the half-century numerals
         at top:39px in 10px type — they ran into each other. Every row now has its
         own band and they cannot touch.
     (2) IT ATE TOO MUCH HEIGHT: 76px. The ERA BANDS go — they are texture, and the
         live era is already named in full beside the year — which buys 24px back.
     (3) IT WAS HARD TO SCRUB: see the lifted readout and the ‹ › steppers below.

     56px, not the 48 the spec guessed: the ticks, the numerals and the year cannot
     fit in 48 without re-creating the very collision this rebuild exists to remove. */
  .map-scale { height: 56px; position: relative; }
  .map-eras { display: none; }            /* the bands: texture, not information */
  .map-axis { top: 0; }
  .map-tick { top: 0; }
  .map-ticklab { display: none; }         /* 14 decade numerals cannot fit at 2.5px/yr */
  .map-ticklab.is-major { display: block; top: 16px; font-size: 10px; }
  .map-playhead { top: 0; height: 30px; }
  .map-playyr { top: 30px; gap: 6px; }
  .map-playyr .y { font-size: 17px; }
  .map-playyr .e { font-size: 8px; letter-spacing: .1em; }
  /* The DRAWN glyph, not the word: "PLAY" in letter-spaced small caps cost 62px of a
     390px scale. The title/aria-label keep the control named for a screen reader. */
  /* ON A PHONE ALL THREE CONTROLS ARE THE SAME COLUMN and centre their own
     glyph — that is what makes the two gaps equal. Play drops its word, its
     74px width and its 16px left padding here: keeping the padding is what
     pushed the next chevron away in the 2026-07-19 screenshot.

     COMPACT: 30px columns, not 40. The cluster went 122px → 92px of a ~390px
     scale, giving ~30px back to the ruler — which is the scarce thing here
     (at ~2.5px/year every pixel of track is a year you can point at, and this
     whole phone scale was rebuilt once already for eating too much room).
     The HEIGHT stays 44px: that is the touch target, and it costs nothing
     because the scale is 56px tall regardless. A 30×44 button is still a
     comfortable hit — it is narrower than the 44px guidance, but these sit in
     a row with 2px between them and nothing else is tappable nearby, so a
     near-miss lands on the neighbouring step, never on something destructive. */
  .map-scale .map-play {
    width: 30px; padding: 0; justify-content: center; text-align: center;
  }
  .map-play-w { display: none; }
  /* ONE rule for the phone play glyph — its display flip and its size together.
     Split across two `.map-play-i` rules, the second silently depends on the
     first for `display:block`; anyone deleting either leaves the glyph hidden
     or full-size. The glyphs shrink WITH the columns (a 13px mark in a 30px
     box reads loose), but the stroke weight holds at 2.8 — that is the
     operator's drawn weight, and it is what keeps a small chevron reading as a
     confident mark rather than a hairline. */
  .map-play-i { display: block; width: 12px; height: 14px; }
  .map-step-i { width: 12px; height: 14px; }

  .map-step { width: 30px; height: 44px; }
  .map-step.is-prev { margin-left: 0; }

  /* THE READOUT LIFTS OUT FROM UNDER THE THUMB.
     While dragging, the year is the one thing you need to see and the one thing
     your finger is on top of. It rises above the band, onto the plate — which needs
     the scale to out-rank Leaflet's panes (z 400–600) for the duration. */
  .map-playyr.is-drag {
    top: -42px; z-index: 700;
    padding: 4px 10px; border-radius: 3px;
    background: rgba(247,242,232,.97);
    border: 1px solid rgba(90,72,45,.55);
    box-shadow: 0 3px 10px rgba(0,0,0,.25);
  }
  .map-scale:has(.map-playyr.is-drag) { z-index: 700; }
}

/* ── Map register: the three tabs + the Colonies queue ──────────────────────
   The tab bar replaces the old <h2 class="rr-section-h"> headings: the three
   registers (Colonies / Raids / Migrations) are now panels, and only the active
   one renders. The bar stays mounted while the panel beneath it swaps. */
.rr-tabs {
  display: flex;
  gap: 2px;
  margin: 18px 0 0;
  border-bottom: 1px solid var(--rule, #d8d2c4);
}
.rr-tab {
  appearance: none; background: none; cursor: pointer;
  font: inherit; font-size: 14px; letter-spacing: .04em;
  padding: 8px 14px; margin-bottom: -1px;
  color: inherit; opacity: .55;
  border: 1px solid transparent; border-bottom: none;
  border-radius: 6px 6px 0 0;
  /* Feedback the press wants: opacity/background/transform ease, but NOT the
     rebuild-lagged panel. Kept short so the highlight still reads as instant. */
  transition: opacity .12s ease, background-color .12s ease, transform .06s ease;
}
.rr-tab:hover { opacity: .85; }
/* A focus ring for keyboard/AT users (the buttons had none). --gilt is the
   theme-aware accent (--accent is only set inline on cards, not at :root). */
.rr-tab:focus-visible {
  outline: 2px solid var(--gilt, #b39250); outline-offset: 2px;
}
/* Press feedback: the button gives back on mousedown, so a click never reads as
   dead even in the tick before the deferred panel paints. */
.rr-tab:active { transform: translateY(1px); opacity: .9; }
.rr-tab.is-active {
  opacity: 1; font-weight: 600;
  border-color: var(--rule, #d8d2c4);
  background: var(--rule-soft);
}
/* Reserve the BOLD width up front so activating a tab doesn't widen its label and
   shove the neighbours sideways (the layout jump). A hidden bold twin sets the box;
   the visible text tracks weight. data-label is mirrored from the button text. */
.rr-tab::after {
  content: attr(data-label);
  display: block; height: 0; overflow: hidden; visibility: hidden;
  font-weight: 600; letter-spacing: .04em;
}

/* The count line: what we hold, and how much of it is still unknown. */
.rr-colony-count {
  font-size: 12.5px; opacity: .7; margin: 14px 0 4px;
}
.rr-colony-group-h {
  margin: 18px 0 2px;
  padding-bottom: 4px;
  border-bottom: 1px solid var(--rule, #d8d2c4);
  font-size: 14px;
  letter-spacing: .05em;
  color: var(--ink-dim);
}
.rr-colony-sorted { font-style: italic; opacity: .8; }

/* A colony row. The flag is the queue's rank made visible: ⚠ still needs a
   human, ✓ placed and dated. */
.rr-colony-row { align-items: center; }
.rr-colony-flag {
  width: 20px; flex-shrink: 0;
  font-size: 14px; opacity: .75; text-align: center;
}
.rr-colony-flag.is-done { opacity: .35; }
.rr-colony-id {
  font-size: 11px; opacity: .45; margin-left: 8px; font-weight: 400;
}
.rr-colony-coords {
  font-size: 12px; opacity: .7; margin-top: 2px;
  font-variant-numeric: tabular-nums;
}
/* No coordinate at all — one of the eleven the map's data could never carry. */
.rr-colony-coords.is-missing { font-style: italic; opacity: .5; }
.rr-colony-dates { font-size: 12px; opacity: .7; margin-top: 1px; }
/* An INFERRED end must READ as a machine guess, not as a fact — it is the
   inferencer's weak-1622 default, and it is nowhere in the sources. */
.rr-colony-inferred { font-style: italic; opacity: .5; }
.rr-colony-inferred-hint {
  grid-column: 1 / -1;
  font-size: 12px; font-style: italic; opacity: .7;
  padding: 8px 10px; margin-top: -2px;
  border-left: 2px solid var(--rule, #d8d2c4);
}

/* A SUGGESTED coordinate: cited, and never written until a human presses save. */
.rr-colony-suggestion {
  grid-column: 1 / -1;
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  font-size: 12.5px;
  padding: 8px 10px;
  border: 1px dashed var(--rule, #d8d2c4); border-radius: 6px;
}
.rr-sugg-coord { font-variant-numeric: tabular-nums; font-weight: 600; }
.rr-sugg-town { opacity: .75; }
.rr-sugg-use {
  font: inherit; font-size: 12px; padding: 3px 10px; cursor: pointer;
  margin-left: auto;
}
/* A doubt we hold is a doubt the contributor must see. */
.rr-sugg-note {
  flex-basis: 100%;
  font-size: 11.5px; font-style: italic; opacity: .7; margin-top: 2px;
}

@media (max-width: 599px) {
  .rr-tabs { gap: 0; }
  .rr-tab { padding: 8px 10px; font-size: 13px; }
  .rr-sugg-use { margin-left: 0; }
  /* The register is a WORK QUEUE, and its queue is worked on a phone as much as
     a desktop — but every button in it was padded for a mouse (Edit ≈ 23px tall,
     Use ≈ 21px, Save/Cancel/Retire ≈ 30px). A <button> centres its own label, so
     a min-height floor buys the target without moving any text. Scoped to the
     phone breakpoint on purpose: the desktop form's compact metrics are correct
     there and this must not loosen them. */
  .rr-edit, .rr-add, .rr-save, .rr-cancel, .rr-retire, .rr-sugg-use {
    min-height: 44px;
  }
}

/* Why this town — the IDENTIFICATION's source, a different claim from the
   coordinate's. Where the chronicle answers it, this quotes the chronicle. */
.rr-sugg-basis {
  flex-basis: 100%;
  font-size: 11.5px; opacity: .8; margin-top: 4px; line-height: 1.45;
  padding-left: 8px; border-left: 2px solid var(--rule, #d8d2c4);
}

/* ── Display panel (prefs_panel.rs) ──────────────────────────────────────────
   The one topbar control holding what used to be three primary icons: the
   edit-mode pencil, the /admin gear, and the theme sun. Structure mirrors the
   bookmarks dropdown — panel + backdrop ALWAYS in the DOM, `.open` toggles
   display — so closing never disposes a handler mid-dispatch.
   Named prefs-*, NOT settings-*: `.settings-legend` / the card settings popover
   already own that word here. */
.prefs-wrap { position: relative; }
.prefs-btn.active { color: var(--gilt); }

/* Tap-anywhere-off catcher.
   ⚠ NOT `position: fixed; inset: 0`, which is the obvious way and is broken
   here: .topbar sets backdrop-filter, making it the CONTAINING BLOCK for fixed
   descendants, so "the whole viewport" resolves to the ~48px bar and a tap
   below it lands on the page instead of the catcher — the panel would not
   close on an outside tap.
   Absolute + deliberately oversized sidesteps it: the box is measured from
   .prefs-wrap and simply made bigger than any viewport, so it covers the screen
   whichever way the containing block computes. Descendants of a fixed-position
   element are excluded from the document's scrollable overflow, so the
   overhang cannot produce scrollbars. */
.prefs-backdrop {
    display: none;
    position: absolute;
    top: -100vh;
    left: -100vw;
    right: -100vw;
    height: 300vh;
    z-index: 49;
    background: transparent;
}
.prefs-backdrop.open { display: block; }

.prefs-panel {
    display: none;
    position: absolute;
    top: 100%;
    right: 0;
    margin-top: 6px;
    background: var(--canvas);
    border: 1px solid var(--gilt-deep);
    border-radius: 3px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.8);
    /* Widened with the controls below so a 3-up segment strip and its label
       sit on one row without crowding. */
    min-width: 300px;
    max-width: 340px;
    flex-direction: column;
    z-index: 50;
    /* The panel is a DOM child of .topbar, which sets `text-transform:
       uppercase; letter-spacing: 0.14em` for the BAR's small-caps labels.
       Without this reset every row inherits it and the hint renders as
       "REVEALS THE CARD PENS; RESETS EACH SESSION" over three lines.
       .bm-panel never hit this only because each of its elements sets its own
       typography outright. Reset once, here, at the panel boundary. */
    font-family: var(--serif-body);
    text-transform: none;
    letter-spacing: normal;
}
.prefs-panel.open { display: flex; }
.prefs-panel:focus { outline: none; }

.prefs-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 8px 12px;
    border-bottom: 1px dotted var(--rule);
    color: var(--ink-faint);
    font-size: 9.5px;
    text-transform: uppercase;
    letter-spacing: 0.1em;
}
.prefs-close {
    background: none;
    border: none;
    color: var(--ink-faint);
    font-size: 16px;
    line-height: 1;
    padding: 0 2px;
    cursor: pointer;
    font-family: inherit;
}
.prefs-close:hover { color: var(--ink); }

.prefs-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 11px 12px;
    border-bottom: 1px solid var(--rule-soft);
}
.prefs-label {
    /* 13px matches .bm-row — the panel's text should read like the other panel's. */
    font-size: 13px;
    color: var(--ink-dim);
    display: flex;
    flex-direction: column;
    gap: 2px;
}
.prefs-hint {
    font-size: 10.5px;
    color: var(--ink-faint);
    line-height: 1.35;
    /* Wider now that it sets in sentence case rather than inherited caps —
       two lines instead of three. */
    max-width: 180px;
}

/* Three-way theme control: the choice is visible, not inferred from a glyph. */
.prefs-seg {
    display: inline-flex;
    border: 1px solid var(--rule);
    border-radius: 3px;
    overflow: hidden;
    flex-shrink: 0;
}
.prefs-seg-btn {
    background: var(--canvas-deep);
    border: none;
    border-right: 1px solid var(--rule);
    color: var(--ink-dim);
    font-family: inherit;
    /* Sized to the app's other controls: .topbar-btn / .tb-seg stand 33px tall,
       and these read undersized next to a 13px label at anything less.
       12px text + 2×9px padding + 2×1px border = 32px. */
    font-size: 12px;
    line-height: 1;
    letter-spacing: 0.04em;
    padding: 9px 12px;
    cursor: pointer;
}
.prefs-seg-btn:last-child { border-right: none; }
.prefs-seg-btn:hover { color: var(--ink); }
.prefs-seg-btn.active {
    background: var(--gilt-deep);
    color: var(--canvas);
}

/* Text-size steppers (Display panel). Same box language as .prefs-seg so the
   two controls in the panel read as one family; the small-A/large-A idiom is
   carried over from the chronicle TOC strip this replaced. */
.prefs-step {
    display: inline-flex;
    align-items: stretch;
    border: 1px solid var(--rule);
    border-radius: 3px;
    overflow: hidden;
    flex-shrink: 0;
}
.prefs-step-btn {
    background: var(--canvas-deep);
    border: none;
    color: var(--ink-dim);
    font-family: inherit;
    line-height: 1;
    padding: 0 12px;
    min-width: 34px;
    cursor: pointer;
}
.prefs-step-btn:hover:enabled { color: var(--ink); }
.prefs-step-btn:disabled { opacity: 0.35; cursor: default; }
.prefs-step-down { font-size: 11px; }
.prefs-step-up { font-size: 16px; }
.prefs-step-val {
    display: flex;
    align-items: center;
    justify-content: center;
    min-width: 34px;
    padding: 7px 0;
    border-left: 1px solid var(--rule);
    border-right: 1px solid var(--rule);
    background: var(--canvas);
    color: var(--ink-dim);
    font-size: 12px;
    font-family: var(--serif-display);
    font-feature-settings: "lnum";
    font-variant-numeric: tabular-nums;
}

.prefs-switch {
    position: relative;
    /* Scaled with the segment strip so the two controls read as one family and
       neither looks like a miniature beside a 13px label. */
    width: 48px;
    height: 28px;
    flex-shrink: 0;
    border-radius: 14px;
    border: 1px solid var(--rule);
    background: var(--canvas-deep);
    cursor: pointer;
    padding: 0;
}
.prefs-switch.on {
    background: var(--gilt-deep);
    border-color: var(--gilt-deep);
}
.prefs-knob {
    position: absolute;
    top: 3px;
    left: 3px;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--ink-faint);
    transition: left 0.14s ease, background 0.14s ease;
}
.prefs-switch.on .prefs-knob { left: 25px; background: var(--canvas); }

.prefs-admin {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 11px 12px;
    /* A solid rule rather than the rows' hairline: this is the divider between
       personal display prefs and the privileged tools below it. */
    border-top: 1px solid var(--rule);
    color: var(--gilt);
    font-size: 13px;
    text-decoration: none;
}
.prefs-admin:hover { background: var(--canvas-deep); }
.prefs-chev { font-size: 15px; line-height: 1; }

/* Phones: span the viewport width instead of hanging off a button that sits
   mid-bar, exactly as .bm-panel does at this width.
   NOT a bottom-anchored sheet, though one was the nicer idea: `.topbar` sets
   backdrop-filter: blur(12px), and a non-none backdrop-filter makes an element
   the CONTAINING BLOCK for its fixed-position descendants. A `bottom: 0` sheet
   inside the bar would therefore anchor to the 44px bar, not the viewport, and
   land on top of it. Top-anchoring is immune: it resolves to just under the bar
   whichever way the containing block is computed, which is why the bookmarks
   panel has worked here for months. */
@media (max-width: 599px) {
    .prefs-panel {
        position: fixed;
        top: var(--topbar-h);
        left: 12px;
        right: 12px;
        margin-top: 0;
        min-width: 0;
        max-width: none;
    }
    .prefs-row { padding: 13px 14px; }
}

/* A topbar slot wrapper whose only job is to let the bar hide a child COMPONENT
   (which owns its own root element and takes no class from outside) without
   unmounting it. Used for the bookmarks dropdown on the Lanes/Map lenses. */
.tb-slot { display: flex; align-items: center; }
.tb-slot.tb-hidden { display: none; }

/* ───────────────────────────────────────────────────────────────────
   Under construction — the operator's site-closed switch.

   Three surfaces: the full-page sign every non-admin sees (.uc-*), the
   admin's switch on /admin (.admin-maint-*, .admin-switch), and the
   reminder pill that follows the admin around (.maint-strip).
   ─────────────────────────────────────────────────────────────────── */

.uc-shell {
    min-height: 100vh;
    min-height: 100dvh;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 32px 20px calc(32px + env(safe-area-inset-bottom, 0px));
    background: var(--canvas);
}

.uc-card {
    width: 100%;
    max-width: 460px;
    text-align: center;
}

/* The art is capped by width AND height: on a short landscape phone a
   width-driven square would push the headline off-screen entirely. */
.uc-art {
    width: 100%;
    max-width: 340px;
    max-height: 42vh;
    height: auto;
    display: block;
    margin: 0 auto 22px;
}

/* Sign lettering. Sized in px (not em) because the text sits in the SVG's
   own 320-unit coordinate space, where the root font-size means nothing. */
.uc-sign-text {
    font-family: var(--serif-display);
    font-size: 15px;
    font-weight: 700;
    letter-spacing: .06em;
    fill: #c0392b;
}

.uc-title {
    font-family: var(--serif-display);
    font-size: 1.7rem;
    font-weight: 600;
    color: var(--head);
    margin: 0 0 10px;
}

.uc-sub {
    font-family: var(--serif-body);
    font-size: 1.02rem;
    line-height: 1.55;
    color: var(--ink-soft);
    margin: 0 auto 26px;
    max-width: 34em;
}

.uc-signout {
    font-family: var(--serif-body);
    font-size: .92rem;
    color: var(--ink-dim);
    background: none;
    border: 1px solid var(--rule);
    border-radius: 6px;
    padding: 8px 18px;
    cursor: pointer;
}
.uc-signout:hover:not(:disabled) { color: var(--ink); border-color: var(--ink-faint); }
.uc-signout:disabled { opacity: .5; cursor: default; }

/* ── the admin's switch (on /admin, above the tabs) ───────────────── */

.admin-maint-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 18px;
    padding: 14px 16px;
    margin: 0 0 18px;
    border: 1px solid var(--rule);
    border-radius: 8px;
    background: var(--canvas-deep);
}
/* Switched on, the row itself goes hazard-orange — the same signal the
   strip and the sign use, so the three read as one state. */
.admin-maint-row.on {
    border-color: var(--sepia);
    background: color-mix(in srgb, var(--sepia) 12%, var(--canvas-deep));
}

.admin-maint-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }

.admin-maint-label {
    font-family: var(--serif-display);
    font-size: 1.02rem;
    color: var(--ink);
}

.admin-maint-hint {
    font-family: var(--serif-body);
    font-size: .86rem;
    line-height: 1.4;
    color: var(--ink-dim);
}

.admin-switch {
    flex: 0 0 auto;
    width: 52px;
    height: 30px;
    border-radius: 15px;
    border: 1px solid var(--rule);
    background: var(--canvas);
    padding: 0;
    cursor: pointer;
    position: relative;
    transition: background .16s ease, border-color .16s ease;
}
.admin-switch:disabled { opacity: .55; cursor: default; }
.admin-switch[aria-checked="true"] { background: var(--sepia); border-color: var(--sepia); }

.admin-switch-knob {
    position: absolute;
    top: 3px;
    left: 3px;
    width: 22px;
    height: 22px;
    border-radius: 50%;
    background: var(--ink-dim);
    transition: transform .16s ease, background .16s ease;
}
.admin-switch[aria-checked="true"] .admin-switch-knob {
    transform: translateX(22px);
    background: #f6f3ec;
}

/* ── the reminder pill that follows the admin ─────────────────────── */

/* Hangs just BELOW the fixed topbar rather than over it: .topbar's
   backdrop-filter makes it a containing block for fixed descendants, and
   anything competing for that strip of screen ends up fighting the bar.
   z-index sits above the cards (~9) and below the bar (20), so the bar's
   controls stay reachable while this is up. */
.maint-strip {
    position: fixed;
    top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) + 6px);
    left: 50%;
    transform: translateX(-50%);
    z-index: 12;
    display: flex;
    align-items: center;
    gap: 8px;
    max-width: calc(100vw - 24px);
    padding: 6px 14px;
    border-radius: 999px;
    border: 1px solid var(--sepia);
    background: color-mix(in srgb, var(--sepia) 24%, var(--canvas-deep));
    color: var(--ink);
    font-family: var(--serif-body);
    font-size: .8rem;
    line-height: 1.2;
    text-decoration: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.maint-strip:hover { border-color: var(--gilt); }

.maint-strip-dot {
    flex: 0 0 auto;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #e8590c;
}

/* Phones: the full sentence does not fit beside the dot, so the pill keeps
   only its short form. The long text is hidden with a pseudo-element swap
   rather than a second element, so the markup stays one anchor. */
@media (max-width: 599px) {
    .maint-strip {
        font-size: 0;
        gap: 6px;
        padding: 6px 12px;
    }
    .maint-strip::after {
        content: "Site closed to visitors";
        font-size: .78rem;
    }
}

/* ── Pronunciation flags ──────────────────────────────────────────────
   Flagging a name the narration got wrong. The bar appears while a word is
   selected in a chronicle chapter, in edit mode only.

   TOP-docked, not bottom. Two reasons, both learned elsewhere in this file:
   the bar carries a text input, and a bottom-fixed input is covered by the
   iOS keyboard the moment it is focused; and the bottom edge is the phone's
   home-indicator swipe, which is why .chron-audio-float is top-docked too.

   It sits BELOW where that transport bar docks, so the two stack instead of
   overlapping — you can still pause while typing a respelling. The offset is
   the transport's own height plus its gap; it costs a little dead space when
   the transport is hidden, which is cheaper than the two fighting. */
:root { --pron-flag-top-offset: 52px; }

.pron-flag-bar {
    position: fixed;
    left: 50%;
    top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) + 8px + var(--pron-flag-top-offset));
    transform: translateX(-50%);
    /* Same band as .chron-audio-float (19): below the topbar's own dropdowns
       (20) so the TOC still covers it, above the page. */
    z-index: 19;
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 7px 12px;
    background: var(--page-bg);
    border: 1px solid var(--rule);
    border-radius: 10px;
    box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
    max-width: calc(100vw - 24px);
}
.pfb-word {
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 9rem;
}
.pfb-input {
    font: inherit;
    font-size: .85rem;
    padding: 4px 8px;
    min-width: 0;
    width: 11rem;
    background: transparent;
    color: inherit;
    border: 1px solid var(--rule);
    border-radius: 6px;
}
.pfb-flag,
.pfb-cancel {
    font: inherit;
    font-size: .85rem;
    padding: 4px 10px;
    background: transparent;
    color: inherit;
    border: 1px solid var(--rule);
    border-radius: 6px;
    cursor: pointer;
}
.pfb-flag { font-weight: 600; }
/* The same selection can mean "this sounds wrong" or "this needs explaining",
   so the bar offers both rather than guessing which mode you are in. */
.pfb-term { font-weight: 600; color: var(--gilt); }
.pfb-cancel { border-color: transparent; padding: 4px 6px; }

/* The chapter's flagged names, under the head. Collapsed to a single count
   line until opened, so it never competes with the chapter title. */
.pron-flag-list { margin: 6px 0 10px; }
.pfl-toggle {
    font: inherit;
    font-size: .8rem;
    padding: 3px 9px;
    background: transparent;
    color: inherit;
    opacity: .8;
    border: 1px dashed var(--rule);
    border-radius: 6px;
    cursor: pointer;
}
.pfl-rows { list-style: none; margin: 6px 0 0; padding: 0; }
.pfl-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 3px 0;
}
.pfl-word { font-weight: 600; font-size: .85rem; }
.pfl-para { font-size: .75rem; opacity: .65; }
.pfl-input {
    font: inherit;
    font-size: .8rem;
    padding: 3px 7px;
    flex: 1 1 8rem;
    min-width: 0;
    background: transparent;
    color: inherit;
    border: 1px solid var(--rule);
    border-radius: 6px;
}
.pfl-del {
    font: inherit;
    background: transparent;
    color: inherit;
    border: 0;
    opacity: .6;
    cursor: pointer;
    padding: 0 4px;
}

/* Phones: the bar must not run off the edge, and the two inputs are the only
   things that can give. */
@media (max-width: 599px) {
    .pron-flag-bar { gap: 6px; padding: 6px 10px; }
    .pfb-word { max-width: 6rem; font-size: .85rem; }
    .pfb-input { width: 7.5rem; }
}

/* Context lens shell — mirrors .jx-sheet because body.lens-open freezes the
   document scroll and the stream is unmounted; the sheet owns its own scroll. */
.ctx-sheet {
    position: fixed; inset: 0;
    padding-top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px));
    z-index: 15; display: flex; flex-direction: column;
    background: var(--page-bg);
}
.ctx-scroll {
    flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
    padding-bottom: env(safe-area-inset-bottom, 0px);
}
.ctx-deep { max-width: 780px; margin: 0 auto; padding: 16px; }
.ctx-empty { color: var(--ink-dim); padding: 24px; text-align: center; }
.ctx-trail { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.ctx-back { font-family: inherit; background: none; border: 0; color: var(--gilt); cursor: pointer; padding: 2px 4px; }
.ctx-overview { color: var(--gilt); text-decoration: none; }
.ctx-overview:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }
.ctx-crumb { color: var(--ink-soft); font-size: 0.85rem; }
.ctx-head { border-bottom: 1px solid var(--rule-soft); padding-bottom: 10px; margin-bottom: 12px; }
.ctx-head-line { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.ctx-head-yr { font-variant-numeric: tabular-nums; color: var(--ink-dim); }
.ctx-head-hl { font-weight: 600; font-size: 1.05rem; margin: 0; }
.ctx-head-hl:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }
.ctx-read { margin-left: auto; color: var(--gilt); text-decoration: none; border: 1px solid var(--rule-soft); border-radius: 6px; padding: 3px 10px; }
.ctx-head-place { color: var(--ink-soft); font-size: 0.85rem; margin-top: 4px; }
.ctx-thread { margin: 14px 0; }
.ctx-thread-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.ctx-thread-name { font-weight: 600; }
.ctx-controls, .ctx-control-group { display: flex; gap: 4px; flex-wrap: wrap; }
.ctx-controls { gap: 8px; }
.ctx-ctl { font-family: inherit; font-size: 0.78rem; padding: 4px 8px; background: none; border: 1px solid var(--rule-soft); border-radius: 6px; color: var(--ink-soft); cursor: pointer; }
.ctx-ctl.active { border-color: var(--gilt); color: var(--ink); }
.ctx-ctl:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }
.ctx-boundary { color: var(--ink-dim); font-size: 0.78rem; margin: 4px 0 8px; }
.ctx-rows { display: flex; flex-direction: column; gap: 2px; }
.ctx-row { display: grid; grid-template-columns: auto auto 1fr auto; gap: 8px; align-items: baseline; width: 100%; text-align: left; text-decoration: none; background: none; border: 0; border-radius: 6px; padding: 6px 8px; color: var(--ink-soft); cursor: default; font-family: inherit; }
.ctx-row-link { cursor: pointer; }
.ctx-row-link:hover { background: var(--rule-soft); }
.ctx-row:focus-visible { outline: 2px solid var(--gilt); outline-offset: -2px; }
/* The anchored row — the event the whole view is hung on.
   Was `background: var(--gilt); color: var(--page-bg)`: a solid gold slab with
   near-black text, once per thread, which on the dark canvas read as a lit box
   dropped into the page. It also left .ctx-row-kind and .ctx-row-why sitting at
   --ink-dim ON the gilt, which is the muddiest pairing in the palette.
   Gilt is the app's INK, not its fill — including on .ctx-ctl.active, this same
   feature's other selected state, which marks itself with a gilt border. A wash
   plus a spine says "this row" just as plainly and keeps the two in one family. */
.ctx-anchor {
    background: color-mix(in srgb, var(--gilt) 13%, transparent);
    color: var(--ink);
    box-shadow: inset 3px 0 0 var(--gilt);
}
.ctx-row-yr { font-variant-numeric: tabular-nums; }
.ctx-row-kind { color: var(--accent, var(--ink-dim)); white-space: nowrap; font-size: 0.8rem; }
.ctx-row-why { color: var(--ink-dim); font-size: 0.75rem; white-space: nowrap; }
.ctx-broaden { margin-top: 8px; }
@media (max-width: 599px) {
    .ctx-row { grid-template-columns: auto minmax(0, 1fr); }
    .ctx-row-yr { grid-column: 1; grid-row: 1 / span 3; align-self: start; }
    .ctx-row-kind, .ctx-row-hl, .ctx-row-why { grid-column: 2; }
    .ctx-row-kind { grid-row: 1; }
    .ctx-row-hl { grid-row: 2; }
    .ctx-row-why { grid-row: 3; white-space: normal; }
}

/* Context overview — shared decade grid, density behind ticks */
/* The flex column is what let `.ovw-zoom`'s `order` move it below the plate
   while it stayed FIRST in DOM/tab order. The cluster is now `position: fixed`
   at every width, so `order` no longer applies to it — but the column is kept
   for the remaining children (legend, navigator, scroller), and DOM order still
   carries the whole tab-order argument: the zoom controls are the keyboard path
   to depth and must not sit behind ~299 tick links. */
.ovw { max-width: 100%; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; }

/* Fixed at every width, so the ladder is reachable from anywhere in a plate that
   now runs thousands of px tall — the same reason the navigator is sticky.

   ⚠ `position: sticky; bottom: 8px` does NOT work here, and that has now been
   established twice. A bottom-sticky element only floats while its containing
   block still has box below it, and this cluster is the last thing in `.ovw`,
   so there is none: it behaves as an ordinary block at the bottom of the plate,
   i.e. exactly what it was meant to avoid. `.ctx-sheet` is already
   `position: fixed; inset: 0`, so a fixed child anchors to the viewport
   cleanly. `env(safe-area-inset-*)` resolves to 0px off a notched phone, so one
   rule serves every width.

   z-index 3 sits under the sticky navigator (6) deliberately: the two are at
   opposite ends of the viewport and never meet, and if a plate were ever short
   enough that they did, the orientation bar is the one that must win. */
.ovw-zoom { position: fixed;
    right: calc(12px + env(safe-area-inset-right, 0px));
    bottom: calc(12px + env(safe-area-inset-bottom, 0px));
    z-index: 3;
    display: flex; gap: 6px;
}

/* The whole collection, always fit to width, with a window marking what the
   plate below is showing. It never zooms — it IS the orientation. Only
   rendered while zoomed; at fit the plate already is this picture. */
.ovw-nav {
    /* Sticky, so the one control that can travel the whole history is reachable
       from anywhere in a plate that is now several thousand px tall.

       ⚠ This works where the AXIS could not, and the difference is which
       ancestor scrolls. `.ovw-axis` lives inside `.ovw-scroll`, which has no
       height constraint and therefore never overflows vertically — sticky there
       pins against a container that never scrolls, i.e. never fires. `.ovw-nav`
       is a sibling of `.ovw-scroll`, directly inside `.ovw`, whose scrolling
       ancestor is `.ctx-scroll` (`flex: 1; overflow-y: auto`). That one really
       scrolls, so this really pins. Do not "consistently" move the axis here.

       `top: 0` is the top of .ctx-scroll, which already sits below the topbar
       (.ctx-sheet pads for it), so no manual topbar offset is needed.

       z-index 6 clears the plate's marks (1) and an open chooser (4). The trade:
       a chooser opened against the very top of the plate scrolls UNDER this bar
       rather than over it. That is the right way round — the bar is permanent
       furniture and the chooser is 34px from being fully visible again. */
    position: sticky; top: 0; z-index: 6;
    height: 34px; margin-bottom: 10px;
    background: var(--rule-soft); border: 1px solid var(--rule);
    border-radius: 6px; cursor: pointer; overflow: hidden;
    /* Says "this is floating above the page" where content passes beneath the
       10px gap under it. Painted outside the border box, so `overflow: hidden`
       above does not clip it. */
    box-shadow: 0 4px 10px color-mix(in srgb, var(--ink) 16%, transparent);
    /* The band owns its pointer entirely — a drag along it must not also
       scroll the page underneath. `none` is right HERE and wrong on
       .ovw-scroll: this is a 34px drag handle whose only gesture is its own,
       which is exactly what every other `touch-action: none` in this stylesheet
       is. The plate is a reading surface and keeps native scrolling. */
    touch-action: none;
}
.ovw-nav:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }

/* The collection itself: one compressed row per kind, density segments reusing
   build_density's bins. A true-zero bin renders no segment at all — the same
   honesty rule as the plate's own cells, and the reason the band is worth its
   34px instead of being an empty position bar. */
.ovw-nav-rows {
    position: absolute; inset: 0;
    display: flex; flex-direction: column;
    pointer-events: none;
}
.ovw-nav-row { position: relative; flex: 1; min-height: 0; }
.ovw-nav-seg {
    position: absolute; top: 0; bottom: 0;
    background: var(--accent);
    opacity: calc(0.15 + 0.7 * var(--i));
}

/* The window reads as "you are here" because everything else is dimmed — the
   scrim is what makes it a viewport rather than a floating box. It works
   because .ovw-nav clips (safe here: unlike the plate, nothing inside this
   band deliberately escapes it). */
.ovw-nav-window {
    position: absolute; top: 0; bottom: 0;
    border: 1.5px solid var(--gilt); border-radius: 4px;
    background: color-mix(in srgb, var(--gilt) 12%, transparent);
    box-shadow: 0 0 0 9999px color-mix(in srgb, var(--page-bg) 62%, transparent);
    pointer-events: none;
}
.ovw-zoom-btn {
    min-width: 34px; min-height: 34px;
    background: var(--rule-soft); color: var(--ink);
    border: 1px solid var(--rule); border-radius: 6px;
    font: inherit; font-size: 0.85rem; cursor: pointer;
}
.ovw-zoom-btn:disabled { opacity: 0.4; cursor: default; }
.ovw-zoom-btn:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }
.ovw-legend { color: var(--ink-soft); font-size: 0.85rem; margin-bottom: 14px; }
/* ⚠ `pan-x pan-y`, NOT `none`. These two values differ in exactly one
   permission: `pan-x pan-y` allows native panning on both axes but excludes
   PINCH-ZOOM, which is precisely the split this feature needs. The browser
   keeps every one-finger scroll — momentum, rubber-band, assistive behaviour,
   all of it, for free — and the two-finger gesture never becomes a page zoom,
   so our handlers get it.

   `none` would additionally make this plate responsible for horizontal
   `scrollLeft`, for writing an ANCESTOR's (`.ctx-scroll`) `scrollTop`, for
   reimplementing inertia, and for a tap-candidate/capture-transfer state
   machine to keep links and `<summary>` clickable — bespoke gesture code on a
   reading surface, with different bugs on iOS Safari than on Chrome Android.
   It buys nothing `pan-x pan-y` does not already give.

   This is the established local idiom: `.jx-wrap` — the predecessor lens's own
   scroller — already carries `touch-action: pan-x pan-y` with a comment
   recording the same trap. Every `touch-action: none` in this stylesheet is a
   small drag handle, never a scroller.

   Known edge case, accepted: `touch-action` is evaluated when a touch sequence
   STARTS, so a finger that begins a pan and then adds a second finger leaves
   the browser already panning and the pinch competes for those frames. Two
   fingers landing together — how people pinch — is unaffected.

   The trade is narrow: browser PAGE ZOOM is given up for touches starting on
   the plate, and nothing else. Acceptable because the plate offers its own zoom
   in that spot with keyboard-reachable buttons. Do NOT touch the viewport
   meta. */
.ovw-scroll { overflow-x: auto; overscroll-behavior-inline: contain; padding: 2px 2px 12px; touch-action: pan-x pan-y; }
.ovw-scroll:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }
.ovw-track { width: 100%; }
/* Zoomed: the decade grid stops being the layout. Every mark is positioned by
   year against an explicitly sized track, so a "decade" is just 10 years of
   width like any other span. The fit path below is untouched — it is still the
   grid, still 50px minimum per decade, still what every returning reader sees
   until they zoom. */
.ovw-track.is-zoomed .ovw-axis,
.ovw-track.is-zoomed .ovw-grid { display: block; position: relative; }
.ovw-track.is-zoomed .ovw-slot { position: absolute; top: 0; bottom: 0; }
.ovw-track.is-zoomed .ovw-grid { min-height: 40px; }
.ovw-track.is-zoomed .ovw-axis-cell {
    position: absolute; transform: translateX(-50%);
    white-space: nowrap; padding: 0 0 4px;
}
/* A slot's left edge is its decade's distance from the domain start, in years,
   times the current pixels-per-year. Zooming rewrites --pxy once per frame on
   the track and every slot moves in one layout pass — no Rust re-runs, which is
   what keeps a pinch at 60fps (see rail_gestures.rs:6-13 for the measured
   counter-example this avoids). */
.ovw-track.is-zoomed .ovw-slot {
    left: calc(var(--pxy) * var(--yr0));
    width: calc(var(--pxy) * 10);
}
/* `--tick-x` names the START of a year, while the deployed target is one year
   wide and centres its dot. Shift that target left by half its own width only
   when zoomed: the centre/dot/caption land on the ruler coordinate and the
   one-year hit area survives unchanged. */
.ovw-track.is-zoomed .ovw-year-target {
    left: calc(var(--tick-x) - 5%);
}
/* Every axis cell is absolute when zoomed, so the axis box has no height of its
   own and would collapse onto the first band. */
.ovw-track.is-zoomed .ovw-axis {
    min-height: 18px;
}
.ovw-track.is-zoomed .ovw-axis-cell {
    left: calc(var(--pxy) * var(--yr0));
}
.ovw-axis, .ovw-grid {
    display: grid;
    grid-template-columns: repeat(var(--cols), minmax(50px, 1fr));
    gap: 2px;
    align-items: stretch;
}
.ovw-axis { border-bottom: 1px solid var(--rule-soft); margin-bottom: 12px; }
.ovw-axis-cell {
    color: var(--ink-dim); font-size: 0.72rem;
    font-variant-numeric: tabular-nums; padding: 0 2px 4px;
}
/* The dive target IS the decade label — a real button, so the decade is
   reachable and announced without a pointer, and there is exactly one per
   decade rather than one per band. Styled to look like the label it replaced. */
.ovw-dive {
    background: none; border: 0; padding: 0;
    font: inherit; color: inherit; cursor: zoom-in;
}
.ovw-dive:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }
.ovw-band { margin: 12px 0; }
.ovw-band-head {
    position: sticky; left: 0; z-index: 1;
    display: flex; align-items: baseline; gap: 12px;
    width: max-content; max-width: calc(100vw - 40px);
    padding: 2px 6px 4px 0; background: var(--page-bg);
}
.ovw-band-name { color: var(--accent, var(--ink)); font-size: 0.85rem; }
.ovw-band-scale { color: var(--ink-dim); font-size: 0.72rem; }
.ovw-slot { position: relative; min-height: 40px; }
/* The 0.08 floor is deliberate and stays: it is what makes a decade holding one
   event out of a busiest-decade's twelve actually visible. What was wrong was
   painting this element for a decade holding NOTHING — 0.08 of a wash reads as
   "a little happened" (2026-07-29 finding #2).

   An empty decade now paints no wash but keeps a hairline on the lane's
   baseline. Rendering NOTHING was the first answer and it was worse: 16 of 51
   decades are empty in every kind and nine of them are consecutive (1420-1500),
   so "nothing" is a ~466x620px hole in the leftmost sixth of the plate, under an
   axis still labelling the years — which reads as a broken render, not as an
   empty archive. The hairline says "this decade exists, the lane runs through
   it, nothing is on it." Absence looks like absence; scarcity still looks like
   scarcity. */
.ovw-cell { position: absolute; inset: 0; border-radius: 3px; background: var(--accent, var(--gilt)); opacity: calc(0.08 + 0.6 * var(--i)); }
.ovw-cell.is-empty {
    background: none;
    opacity: 1;
    border-bottom: 1px solid var(--rule-soft);
}
.ovw-year-target {
    position: absolute; top: 0; bottom: 0;
    left: var(--tick-x); width: 10%;
    display: flex; align-items: center; justify-content: center;
}
.ovw-tick {
    display: flex; height: 100%;
    align-items: center; justify-content: center;
    color: inherit; text-decoration: none;
}
.ovw-tick::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--accent, var(--gilt)); box-shadow: 0 0 0 2px var(--page-bg); }
.ovw-tick-multi::before { width: 12px; height: 12px; }
.ovw-tick:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.ovw-vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); user-select: none; }
.ovw-chooser-wrap[open] { z-index: 4; }
.ovw-chooser-wrap summary { list-style: none; cursor: pointer; }
.ovw-chooser-wrap summary::-webkit-details-marker { display: none; }
.ovw-chooser {
    position: absolute; top: 100%; left: 0; z-index: 2;
    width: max-content; min-width: 220px; max-width: calc(100vw - 48px);
    background: var(--canvas); border: 1px solid var(--rule-soft);
    border-radius: 6px; padding: 6px; list-style: none;
}
.ovw-slot:last-child .ovw-chooser { left: auto; right: 0; }
/* Engraved caption: the dot stays the mark and the text is printed beside it,
   straight on the band — no container. Chosen over a pill/lozenge/underline in
   the 2026-07-31 render review.

   The doubled page-bg text-shadow is a halo that helps perceptually where a
   caption crosses a dense stretch, and counts for NOTHING in a contrast audit —
   it is a soft blur, not a backing plate. It is not a substitute for the tint.

   pointer-events: none is load-bearing: the caption overhangs its 10%-wide tick
   box by design, and without this it would swallow clicks meant for the dots it
   passes over. */
.ovw-cap {
    position: absolute;
    left: 50%; margin-left: 10px;
    top: 0;
    color: color-mix(in srgb, var(--accent) 22%, var(--ink));
    font-size: 0.72rem; line-height: 1;
    white-space: nowrap; pointer-events: none;
    text-shadow: 0 0 3px var(--page-bg), 0 0 3px var(--page-bg);
}
.ovw-cap.is-left { left: auto; right: 50%; margin-left: 0; margin-right: 10px; }

/* ⚠ THE TINT IS NOT THE CONTRAST FIX — the wash cap below is (measured
   2026-08-01). Tinting ink toward the accent moves the text TOWARD the
   accent-washed band it sits on, so it lowers contrast in every direction:

     light theme, over a busiest-decade cell
       Edict       flat --ink 2.75:1   tinted 2.72:1
       Theology    flat --ink 5.28:1   tinted 4.17:1
       Community   flat --ink 4.58:1   tinted 3.85:1
     dark theme
       Martyrdom   flat --ink 7.97:1   tinted 5.70:1
       Theology    flat --ink 4.61:1   tinted 2.99:1   <- the tint CAUSED this one

   So the tint is kept for FIDELITY to the approved render (#e8c9bd on
   Martyrdom, #d5e2ec on Edict) and for nothing else. The failure it was
   introduced to cure — light-theme Edict at 2.7:1 — it does not cure, because
   the problem is the BAND, not the text: light-theme `--iron` is near-black, so
   at 68% opacity it makes a mid-grey and no ink of any colour reads well on it.

   The lever is the wash ceiling, and capping it at 0.45 clears AA everywhere,
   for the tinted ink that actually ships:

       light Edict 2.72 -> 5.10       light Theology 4.17 -> 5.92
       light Community 3.85 -> 5.79   dark Martyrdom 5.70 -> 6.79
       dark Theology 2.99 -> 4.79

   Note dark Theology: the tint pushed a passing 4.61 down to a failing 2.99,
   and the cap is what rescues it. Two failures, neither curable from the text
   side. Anyone re-tuning either value must RE-MEASURE — every number here was
   computed, not reasoned, and the intuition ("tint it toward the kind so it
   reads as that kind") gets the direction exactly backwards. */
.ovw-track.is-zoomed .ovw-cell {
    /* Captions only exist at zoomed depths, so the cap costs the fit plate
       nothing — the density picture at fit keeps its full 0.68 range. */
    opacity: calc(0.08 + 0.37 * var(--i));
}

/* The MARK carries the baseline, so the dot and its name ride the same row.
   Overriding align-items is the point: `.ovw-tick` centres its dot by default,
   which is right at fit and wrong the moment captions stagger. */
.ovw-track.is-zoomed .ovw-tick {
    align-items: flex-start;
    padding-top: calc(6px + var(--baseline, 0) * 20px);
}
.ovw-track.is-zoomed .ovw-cap {
    /* Absolute children do not follow their flex parent's padding. Give the
       caption the same row coordinate explicitly or only the dot moves. */
    top: calc(6px + var(--baseline, 0) * 20px);
}

/* Marks and captions paint ABOVE every density wash.
   All zoomed slots are position:absolute with auto z-index inside a
   non-stacking .ovw-grid, so they paint in tree order: slot(1530s), its cell,
   its ticks, slot(1540s), ITS cell… A caption overhanging into the next decade
   would therefore be painted over by that decade's .ovw-cell, at up to 68%
   opaque accent. Captions average ~200px and a decade at stage 22 is 220px, so
   essentially every caption would have its tail washed out. This never came up
   before because today's dots sit inside their own 10% box and never overhang.
   .ovw-chooser-wrap[open]'s z-index: 4 still wins over both. */
.ovw-track.is-zoomed .ovw-year-target { z-index: 1; }

.ovw-chooser a { color: var(--ink-soft); text-decoration: none; display: block; padding: 3px 4px; }
.ovw-chooser a:hover { color: var(--ink); }
.ovw-chooser a:focus-visible { outline: 2px solid var(--gilt); outline-offset: 1px; }
.ovw-ch-yr { color: var(--ink-dim); font-variant-numeric: tabular-nums; }

@media (max-width: 599px) {
    /* The zoom cluster's fixed, thumb-reachable position is no longer declared
       here — it is the base rule now, at every width. */

    /* Seven mini-rows in 34px is already tight; at 320px give them a little
       less and let the window marker carry the position. */
    .ovw-nav { height: 26px; }
}

/* ⚠ There is deliberately NO `prefers-reduced-motion` rule for the overview
   zoom. Nothing in this design ever gives `.ovw-track` a transition, so such a
   media query would override nothing and pin a dead rule in place permanently.
   Every stage change, gesture commit and decade dive is already an instant
   jump, for everyone — which is what reduced motion would have asked for. If
   the deferred `--pxy` tween is ever built, the rule arrives with it, and then
   it will mean something. */

/* A name already flagged, marked in the prose so an already-submitted entry is
   visible while reading instead of only inside the collapsed list. Edit mode
   only — ordinary readers see untouched text.

   <mark> is the honest element (it IS a highlight for reference), but the UA
   default is a solid yellow block, which would fight the read-along's gilt
   wash on the same words. Reset to a dotted underline instead: it survives the
   wash, and it does not move the text, which matters because these sit inside
   the [data-sent] spans the seek anchors are measured against. */
.pron-flagged {
    background: transparent;
    color: inherit;
    text-decoration: underline dotted;
    text-decoration-thickness: 2px;
    text-underline-offset: 3px;
    text-decoration-color: var(--accent, currentColor);
    cursor: help;
}

/* Corrected in the round that produced the take now playing. A chapter runs
   15-20 minutes and a correction round moves a handful of names, so without
   this the only way to check them is to listen to the whole thing again.

   YELLOW, chosen by the operator after seeing both: these are the words to
   listen for on this pass, and a quiet grey read as "already dealt with"
   when the opposite is true. Only ever the newest round — an older
   correction is simply the text now, and would dilute the signal.

   A wash rather than an underline keeps it a different CHANNEL from
   .pron-flagged, so a word that is both (re-flagged after being corrected)
   still reads as flagged.

   Set explicitly rather than left to the <mark> default: the UA yellow is a
   solid opaque block that buries the read-along's gilt wash on the same
   word. Alpha so both survive, and so it sits on either theme. No padding
   or border — these live inside the [data-sent] spans the read-along and the
   seek anchors measure against, and anything that reflowed the text would
   move them. */
.pron-fixed {
    background: rgba(255, 213, 0, 0.38);
    border-radius: 2px;
    color: inherit;
    cursor: help;
}

/* Glossary term marks. Place and Person share the solid rule DELIBERATELY: the
   handoff's teal oklch(0.52 0.11 210) is not in the token set, and its own
   recommendation was to distinguish the kinds by the card's label instead.
   Argument is dotted — it stays a different CHANNEL from .pron-flagged's dotted
   underline by colour (gilt vs --accent) rather than by pattern, and a word can
   legitimately be both.

   A <button>, not a span, so terms are keyboard-reachable — reset to look like
   prose, because it IS prose. No padding, border or margin: these sit inside
   the [data-sent] spans the read-along and the seek anchors are measured
   against, and anything that reflowed the text would move them. */
.gl-term {
    background: none; border: 0; padding: 0; margin: 0;
    font: inherit; color: inherit; cursor: pointer;
    text-align: inherit;
    text-underline-offset: 2px;
}
.gl-term.gl-solid { text-decoration: underline solid var(--gilt) 1px; }
.gl-term.gl-dotted { text-decoration: underline dotted var(--gilt) 1px; }
/* The open term is marked — it has to be findable again after the reader's eye
   goes to the card and comes back.

   It used to INVERT (background --ink, text --page-bg), which on the dark theme
   meant a near-white block with black text dropped into a paragraph: by a wide
   margin the brightest thing on the page, and a light-theme design decision
   read backwards. What replaces it keeps the block — a wash still says "this
   word" at a glance — but recolours it:

   - The wash is --rule, NOT gilt, and that is the constraint that picks the
     colour. Gilt is spoken for: .chron-ch-body .reading washes the narrated
     SENTENCE in gilt at 38% on dark. A term lives INSIDE a sentence, so a gilt
     term inside a gilt sentence would be a wash on a wash — the open mark
     would vanish at exactly the moment the reader is listening along. --rule
     is themed in both directions (a lift off the page on dark, a darkening on
     light), so the block reads as tinted paper either way.
   - The gilt sits in a 2px inset edge instead. Same argument as the spine
     above: the wash measures ~1.4:1 against the page and carries none of the
     load on its own, while gilt-on-page clears 3:1 comfortably at a fraction
     of the lit area. Small and bright beats large and bright.
   - Text stays --ink. No inversion means no flash, and the prose keeps its
     reading contrast while open.

   Still no padding/border/margin — see the note on .gl-term. background,
   box-shadow and border-radius are all paint, so the [data-sent] geometry the
   read-along and the seek anchors are measured against does not move. */
.gl-term.open {
    background: var(--rule);
    box-shadow: inset 0 -2px 0 var(--gilt);
    border-radius: 2px;
    text-decoration: none;
}
.gl-term:focus-visible { outline: 2px solid var(--gilt); outline-offset: 2px; }

/* The glossary card. STICKY ON THE CARD, not the pane: the pane is stretched to
   the row's full height (align-self: stretch in the pane gate), so it has no
   travel of its own and a sticky pane would simply scroll away — the same trap
   the contents panel hit in Plan A.
   --chronicle-header-offset is not set by anything yet; 96px is the spec's
   number and the fallback carries it. If the card's top edge does not line up
   with the chapter title, MEASURE the header rather than nudging the number —
   this redesign has already paid four times for unmeasured pixel constants. */
.gl-card {
    position: sticky;
    top: var(--chronicle-header-offset, 96px);
    background: var(--canvas-deep);
    border: 1px solid var(--rule);
    padding: 22px 21px;
}
.gl-kicker {
    font-size: calc(var(--chron-apparatus) * 0.86);
    letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--ink-faint);
}
.gl-name {
    font-family: var(--serif-display);
    font-size: 26px; line-height: 1.1; font-weight: 500;
    margin: 6px 0 4px;
}
.gl-meta { font-size: var(--chron-apparatus); color: var(--ink-dim); }
.gl-body {
    font-size: 16px; line-height: 1.5; color: var(--ink-soft);
    margin-top: 12px;
}
.gl-instruction {
    font-size: 14px; line-height: 1.5; color: var(--ink-dim);
    margin-top: 10px;
}
.gl-link {
    display: inline-block; margin-top: 14px;
    font-size: 14px; color: var(--gilt);
}
/* The citation is the visible half of the provenance gate — a body may leave
   the chapter only because this shows where it went. Quiet, but never hidden.
   word-break because a bare URL will not wrap in a 260px pane. */
.gl-cite {
    margin-top: 12px; padding-top: 10px;
    border-top: 1px solid var(--rule);
    font-size: calc(var(--chron-apparatus) * 0.86);
    color: var(--ink-faint); word-break: break-word;
}

/* The provenance line beside the kind. Quiet and permanent: a term that says
   "AI draft" is telling the reader they are the better authority on it. */
.gl-ai { color: var(--ink-faint); }

/* ── The card as editor (edit mode) ──────────────────────────────────────────
   Entries and edits happen where the word is: every field shows its own value
   with a pen, and the pen swaps that value for an editor IN PLACE. Nothing here
   is a form, and nothing floats over the card. */
.gl-editing .gl-name,
.gl-editing .gl-meta,
.gl-editing .gl-body { position: relative; }

.gl-field-live {
    display: block; min-height: 1.4em;
    background: var(--page-bg);
    outline: 1px solid var(--gilt); outline-offset: 2px;
    white-space: pre-wrap;
}
.gl-field-tall { min-height: 5em; }
/* An empty field says what belongs in it — closed (a dimmed span) and OPEN
   (this ::before). Tapping the pen used to swap the prompt for a blank box,
   which hid the hint at exactly the moment it was wanted.
   `:has(> br:only-child)` catches the other empty state: a browser leaves a
   stray <br> behind when you select-all and delete inside a contenteditable,
   and `:empty` alone does not match that. */
.gl-field-live:empty::before,
.gl-field-live:has(> br:only-child)::before {
    content: attr(data-placeholder);
    color: var(--ink-faint); font-style: italic;
    pointer-events: none;
}
.gl-field-placeholder { color: var(--ink-faint); font-style: italic; }
/* ⚠ The dim is gated on HOVER EXISTING. Ungated it applied everywhere and was
   lifted only by :hover/:focus-visible — neither of which a touch screen has —
   so on a phone the pen sat at 50% permanently: the entire editing feature's
   only affordance, half-erased on the devices least able to spare the contrast.
   A pointer device still gets the quiet-until-approached behaviour. */
@media (hover: hover) {
    .gl-pen { opacity: 0.5; }
}
.gl-pen:hover, .gl-pen:focus-visible { opacity: 1; }

.gl-controls {
    display: flex; flex-wrap: wrap; gap: 8px 10px;
    margin-top: 14px; padding-top: 12px;
    border-top: 1px solid var(--rule);
}
.gl-ctl { display: flex; flex-direction: column; gap: 3px; flex: 1 1 auto; }
.gl-ctl-label {
    font-size: var(--chron-apparatus);
    letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint);
}
.gl-ctl-wide { flex-basis: 100%; }
.gl-ctl-check { flex-basis: 100%; flex-direction: row; align-items: center; gap: 6px; }
.gl-ctl select {
    background: var(--page-bg); color: var(--ink);
    border: 1px solid var(--rule); padding: 3px 4px; font: inherit;
    font-size: 14px;
}
/* A citation is REQUIRED beyond one chapter — the provenance gate's rule, and
   the card renders it so a claim reaching hundreds of chapters says where it
   came from. Marked, not enforced here: the worker refuses the save. */
.gl-cite-required { border-top-color: var(--gilt); }

.gl-retire { margin-top: 14px; }
.gl-retire-btn, .gl-confirm-yes, .gl-confirm-no, .gl-hist-toggle, .gl-revert {
    background: none; border: 1px solid var(--rule); color: var(--ink-dim);
    padding: 4px 8px; cursor: pointer; font: inherit; font-size: 13px;
}
.gl-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
/* Reach is INFORMATION, not a veto. It says what the retire costs and then the
   button does exactly what it says — colony_curator's rule. */
.gl-reach { flex-basis: 100%; margin: 0 0 4px; font-size: 13px; color: var(--ink-soft); }
.gl-confirm-yes { border-color: var(--gilt); color: var(--ink); }
.gl-retire-btn.hidden, .gl-confirm.hidden, .gl-hist-list.hidden { display: none; }

/* ── Pictures ────────────────────────────────────────────────────────────────
   A figure in the reading card, and the picker + preview in the editor. The
   credit is rendered, never hidden: it is the only thing that makes the
   credit requirement mean anything to a reader, exactly like .gl-cite. */
.gl-figure { margin: 14px 0 0; }
.gl-figure img,
.gl-image-preview {
    display: block; width: 100%; height: auto;
    border: 1px solid var(--rule);
}
.gl-figure figcaption {
    margin-top: 6px;
    font-size: var(--chron-apparatus);
    color: var(--ink-faint); line-height: 1.4;
}
.gl-image { margin-top: 14px; }
/* The native file input cannot be styled, so the LABEL is the button and the
   input is taken out of flow behind it. Not display:none — that would make it
   unreachable by keyboard, and the label is what forwards the activation. */
.gl-image-pick {
    display: inline-block; margin-top: 8px;
    border: 1px solid var(--rule); color: var(--ink-dim);
    padding: 4px 8px; cursor: pointer; font-size: 13px;
    /* Containing block for the input below, which is `position: absolute`.
       Without it the input escapes to the nearest positioned ancestor — or the
       page — and a stray absolutely-positioned box outside the layout is how a
       phone gets a horizontal scrollbar nobody can find the cause of. */
    position: relative;
}
.gl-image-pick input[type="file"] {
    position: absolute; width: 1px; height: 1px;
    opacity: 0; overflow: hidden;
}
.gl-image-pick:focus-within { border-color: var(--gilt); color: var(--ink); }
.gl-image-note {
    margin: 6px 0 0;
    font-size: var(--chron-apparatus);
    color: var(--ink-faint); line-height: 1.4;
}
.gl-image-credit { margin-top: 6px; font-size: 13px; color: var(--ink-soft); }

.gl-history { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--rule); }
.gl-hist-row { display: flex; align-items: baseline; gap: 6px; margin-top: 6px; }
.gl-hist-when {
    font-size: var(--chron-apparatus); color: var(--ink-faint); flex: 0 0 auto;
}
.gl-hist-what { flex: 1 1 auto; font-size: 13px; color: var(--ink-soft); }
.gl-hist-empty { font-size: 13px; color: var(--ink-faint); }

/* ── Sound chart in the right rail (edit mode only) ──────────────────────────
   data/chronicle_cast/respelling_key.json, rendered by legend.rs. It REPLACES
   the scripture rail rather than stacking above it: editing is exactly when
   the chart is wanted and the refs are not, and 50 rows plus the prose would
   push the refs below a fold anyway.

   The geometry duplicates .chron-rail's at every band instead of joining its
   selectors. The two occupy the same grid cell and are never both mounted, so
   one self-contained block is cheaper — and safer — than threading a second
   class through five responsive rules in a file another session is editing.
   ⚠ Keep the three bands in step with .chron-rail if that ever moves. */
.chron-legend {
    position: sticky;
    top: calc(var(--topbar-h) + 16px);
    max-height: calc(100vh - var(--topbar-h) - 32px);
    overflow-y: auto;
    background: var(--canvas);
    border: 1px solid var(--rule-soft);
    border-radius: 10px;
    padding: 14px;
    font-size: 0.76rem;
    line-height: 1.4;
}
.chron-legend-head {
    font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase;
    color: var(--ink-soft); margin-bottom: 6px;
}
.chron-legend-sub {
    font-size: 0.72rem; color: var(--ink-dim); margin-bottom: 10px;
}
.chron-legend-how {
    list-style: none; margin: 0 0 4px; padding: 0;
    font-size: 0.72rem; color: var(--ink-soft);
}
.chron-legend-how li {
    padding: 2px 0 2px 9px; position: relative;
}
.chron-legend-how li::before {
    content: "·"; position: absolute; left: 1px; color: var(--ink-dim);
}
.chron-legend-sec {
    font-size: 0.7rem; letter-spacing: 0.08em; text-transform: uppercase;
    color: var(--ink); font-weight: 600;
    margin: 14px 0 2px; padding-bottom: 3px;
    border-bottom: 1px solid var(--rule-soft);
}
.chron-legend-note {
    font-size: 0.68rem; color: var(--ink-dim); margin: 3px 0 4px;
}
.chron-legend-tbl { width: 100%; border-collapse: collapse; }
.chron-legend-tbl td { padding: 1px 0; vertical-align: baseline; }
.chron-legend-g td {
    font-size: 0.64rem; letter-spacing: 0.05em; text-transform: uppercase;
    color: var(--ink-dim); padding: 7px 0 2px;
    border-bottom: 1px dotted var(--rule-soft);
}
.chron-legend-spell {
    font-weight: 700; width: 2.6em; color: var(--ink); white-space: nowrap;
}
.chron-legend-ipa { width: 2.8em; font-size: 0.86rem; color: var(--ink); }
.chron-legend-ex { color: var(--ink-soft); font-size: 0.72rem; }
.chron-legend-trap {
    font-size: 0.71rem; color: var(--ink-soft); margin: 6px 0 0;
}
.chron-legend-trap b { color: var(--ink); font-weight: 600; }

/* LEGACY GRID restore, mirroring styles.css:~3161 (Task 6 Step 2) — that
   rule restores .chron-toc/.chron-rail's sticky box but does not list
   .chron-legend, even though the RESET a few lines above it (~3148) resets
   all three. Left out, legend's own base rule above (specificity 0,1,0)
   loses to that reset (.chron-page > .chron-legend, 0,2,0) even under
   .legacy-grid, and the kill switch would silently ship the sound-chart
   legend with no border, no background, no padding, and no sticky travel.
   The values below are identical to the base rule above on purpose — this
   only needs to OUTRANK the reset, not change anything a reader would see. */
.chron-page.legacy-grid > .chron-legend {
    position: sticky;
    top: calc(var(--topbar-h) + 16px);
    max-height: calc(100vh - var(--topbar-h) - 32px);
    overflow-y: auto;
    background: var(--canvas);
    border: 1px solid var(--rule-soft);
    border-radius: 10px;
    padding: 14px;
}

/* ≥1340px, LEGACY GRID ONLY: float in the right margin, hugging the 800px
   column — the same move .chron-rail makes at this width (styles.css:~3667).
   Keep the two bands in step. The plate layout needs no equivalent switch:
   its own .chron-page rule already caps at max-width:1340px with no
   floating margins. */
@media (min-width: 1340px) {
    .chron-page.legacy-grid > .chron-legend {
        position: fixed;
        left: calc(50% + 400px + 24px);
        width: min(280px, calc(50% - 400px - 44px));
        top: calc(var(--topbar-h) + 16px);
        max-height: calc(100vh - var(--topbar-h) - 32px);
    }
}
/* ≤899px: the page drops to two panes and the rail goes with it (both
   layouts — see the matching .chron-rail rule at styles.css:~3797). The
   legend must follow, or it wraps onto a row of its own under the chapter. */
@media (max-width: 899px) {
    .chron-legend { display: none; }
}

/* ── The glossary on a PHONE: a popover at the word ──────────────────────────
   ⚠ THIS BLOCK MUST STAY AT THE END OF THE FILE, after every .gl-* base rule.
   Its selectors and theirs are the same weight, so source order is the only
   thing making these win. The previous attempt sat at ~line 4060 and lost
   silently — see the note there.

   Why a popover and not the pane stacked under the prose (the earlier spec):
   the pane is `order: 3`, so on a single-column phone it renders after the
   WHOLE chapter. Tapping a word in a 3,900-word chapter put its explanation
   several thousand pixels below with no scroll — visually, nothing happened.
   Anchoring it to the word keeps the reader's place, which is the entire point
   of a glossary lookup.

   Geometry comes from `glossary_card::popover_pos` as two custom properties.
   Custom properties specifically, because they are INERT until something reads
   them: the desktop pane is laid out by the flex row alone, and an inline
   `top`/`max-height` would beat every media query and follow it there. Nothing
   above 599px reads --gl-pop-*, so nothing above 599px is affected. */
.gl-pop-close { display: none; }

@media (max-width: 599px) {
    /* The popover is positioned in DOCUMENT coordinates against the initial
       containing block, which is what `popover_pos` computes (a viewport rect
       plus scrollY). This keeps .chron-page from quietly becoming the
       containing block instead — its 48px padding-top would offset every
       popover by exactly that much, downward, forever. */
    .chron-page { position: static; }

    /* Closed, the pane has no job here. Its empty state ("Underlined words in
       the chapter are explained here") is written for a column standing beside
       the prose; at the foot of a phone chapter nobody ever reaches it, and
       floating it over the text would be worse than useless. */
    .chron-page > .chron-glossary { display: none; }

    /* ⚠ THE OPEN-BUT-UNANCHORED STATE FAILS VISIBLE, and that is the whole
       point of splitting it from .gl-pop-at. A popover can legitimately be open
       with nothing measured to point at — a term reopened by an undo rather
       than a tap. This used to fall back to `top: 0`, which is the DOCUMENT's
       top edge: the card opened thousands of pixels above the reader and
       presented as not opening at all, which is indistinguishable from broken
       and hid whatever the real cause was. Docked to the bottom it is always on
       screen; it simply is not pointing at anything. */
    .chron-page > .chron-glossary.gl-pop {
        display: block;
        position: fixed;
        left: 12px; right: 12px;
        bottom: 12px; top: auto;
        width: auto; max-width: none;
        /* Above the floating Contents button (fixed, z-index 40), which sits
           bottom-right — exactly where a downward popover's text lands. Below
           it, the button would punch a hole through the card. The topbar is
           z-index 20 and this paints over it, which is harmless because the
           card is never sized to reach it: popover_pos subtracts --topbar-h
           from the space above. */
        z-index: 41;
    }
    /* Anchored to the word — the normal case, once a tap has been measured.
       DOCUMENT coordinates against the initial containing block, so the card
       scrolls with the paragraph it explains instead of hanging in the
       viewport. `bottom: auto` undoes the docked fallback above; without it a
       box with both `top` and `bottom` set would stretch between them. */
    .chron-page > .chron-glossary.gl-pop.gl-pop-at {
        position: absolute;
        top: var(--gl-pop-top);
        bottom: auto;
    }
    /* Opening upward. translateY on the popover means its own height never has
       to be measured — no render-measure-move pass, no frame where the card is
       visible in the wrong place. Only ever set alongside .gl-pop-at (both come
       from the same measurement), so it never shifts the docked fallback. */
    .chron-page > .chron-glossary.gl-pop-up { transform: translateY(-100%); }

    .chron-page > .chron-glossary.gl-pop > .gl-card {
        /* Not sticky. In a box this size sticky has no travel anyway, and the
           base rule's `top` would fight the popover's own anchor. */
        position: static;
        max-height: var(--gl-pop-max, 60vh);
        overflow-y: auto;
        /* Scrolling to the end of a long entry must not then scroll the
           chapter underneath it. */
        overscroll-behavior: contain;
        padding: 16px 16px 18px;
        border-radius: 6px;
        /* It floats over prose now, so it needs to read as lifted off the
           page rather than as another block in the column. */
        box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
    }
    /* Keep the kicker clear of the close button. */
    .chron-page > .chron-glossary.gl-pop .gl-kicker { padding-right: 34px; }

    .chron-page > .chron-glossary.gl-pop > .gl-pop-close {
        display: block;
        position: absolute;
        top: 4px; right: 4px;
        z-index: 1;
        /* 40px of reach for a glyph that draws at ~18px. */
        width: 40px; height: 40px; padding: 0;
        background: none; border: 0; cursor: pointer;
        color: var(--ink-dim); font: inherit; font-size: 20px; line-height: 40px;
        text-align: center;
    }

    /* ── Touch targets ───────────────────────────────────────────────────────
       Every control below was sized for a mouse: ~24px tall against a ~44px
       thumb. The type sizes rise with them — a control at 13px in a box built
       for 44px reads as a mistake — and the select's 16px is not taste at all:
       iOS Safari ZOOMS THE PAGE when a form control under 16px takes focus,
       and does not zoom back out. One tap on the Kind dropdown and the reader
       is left at ~1.3× with the layout shifted under them. */
    .gl-retire-btn, .gl-confirm-yes, .gl-confirm-no,
    .gl-hist-toggle, .gl-revert, .gl-image-pick {
        padding: 10px 14px; font-size: 15px;
    }
    .gl-ctl select { padding: 9px 8px; font-size: 16px; }
    .gl-controls { gap: 12px 10px; }
    /* The pens are the primary affordance of the whole editor and the smallest
       thing in it. Reach only — the glyph keeps its size, so nothing about the
       card's look changes. */
    .gl-pen { padding: 8px; margin: -8px; }
}
