/* ————— The Odyssey · zoomable timeline —————
   abyss  #0A0E14  night-sea field
   deep   #101623  raised layer
   tide   #1C2635  hairlines
   bronze #C8A15A  accent — ordinals, spine, active
   ivory  #F2EBDC  the page (the only lit object)
   ink    #26221A  text on ivory
   foam   #E7EAF1  primary text on dark
   mist   #8C96A7  secondary text on dark

   light theme — "Pentelic": a cool marble gallery in daylight, you step into
   the stone of the pages instead of pasting the dark chrome onto white. Same
   roles, paler ground, deepened bronze (light hover deepens where dark hover
   brightens).
   ground #F0EEE9 · ink #211F1C · gold #846626 · gold-lit #6B5220            */

@font-face { font-family: 'Cormorant Garamond'; font-weight: 400; font-style: normal; src: url(fonts/cormorant-garamond-latin-400-normal.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'Cormorant Garamond'; font-weight: 500; font-style: normal; src: url(fonts/cormorant-garamond-latin-500-normal.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'Cormorant Garamond'; font-weight: 600; font-style: normal; src: url(fonts/cormorant-garamond-latin-600-normal.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'Cormorant Garamond'; font-weight: 400; font-style: italic; src: url(fonts/cormorant-garamond-latin-400-italic.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'Cormorant Garamond'; font-weight: 500; font-style: italic; src: url(fonts/cormorant-garamond-latin-500-italic.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'EB Garamond'; font-weight: 400; font-style: normal; src: url(fonts/eb-garamond-latin-400-normal.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'EB Garamond'; font-weight: 500; font-style: normal; src: url(fonts/eb-garamond-latin-500-normal.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'EB Garamond'; font-weight: 400; font-style: italic; src: url(fonts/eb-garamond-latin-400-italic.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; src: url(fonts/inter-latin-400-normal.woff2) format('woff2'); font-display: swap; }
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; src: url(fonts/inter-latin-500-normal.woff2) format('woff2'); font-display: swap; }

:root {
  color-scheme: dark;
  --abyss: #0A0E14;          /* the ground */
  --abyss-rgb: 10, 14, 20;   /* --abyss's own rgb triplet, for the veils' rgba() alpha ramps */
  --deep: #101623;
  --tide: #1C2635;           /* hairlines & rules */
  --bronze: #C8A15A;         /* gold: parts, books, live state */
  --bronze-rgb: 200, 161, 90; /* --bronze's own triplet, for the mention underline's alpha ramp */
  --bronze-lit: #E9CE92;     /* gold, hover/live — brightens on dark */
  --bronze-dim: #8a7344;     /* the ordinal columns */
  --ivory: #F2EBDC;
  --ink: #26221A;
  --ink-soft: #6d6353;
  --foam: #E7EAF1;           /* primary text on the ground */
  --foam-lit: #fff;          /* primary text, hover */
  --mist: #8C96A7;           /* secondary text on the ground */
  --spine: #2A3A52;          /* the hanging thread behind the items */
  --node: #35455f;           /* spine node, at rest */
  --node-edge: #42536e;
  --halo: rgba(200, 161, 90, .45);        /* glow under a live gold dot */
  --halo-soft: rgba(200, 161, 90, .35);
  --veil: rgba(10, 14, 20, .94);          /* mid stop of the top/bottom veils */
  --atm-high: rgba(38, 52, 78, .55);      /* atmosphere: the wash overhead */
  --atm-low: rgba(16, 10, 8, .8);         /* atmosphere: the depth below */
  --page-a: #F5EFE1;                      /* the ivory page — light theme brightens these to lift off its warmer ground */
  --page-b: #EFE6D3;
  --page-edge: rgba(255, 255, 255, 0);    /* hairline around the page (light only) */
  --page-hi: rgba(255, 255, 255, .07);
  --page-umbra: rgba(0, 0, 0, .75);
  --page-glow: rgba(160, 135, 90, .09);
  --web: rgba(140, 150, 167, .34);        /* the threads on the map of bonds */
  --panel: #121926;                       /* the profile sheet in the world view */
  --panel-edge: #22304a;
  --scrim: rgba(6, 9, 14, .66);
  --scrim-text: rgba(6, 9, 14, .34);      /* the same sheet, opened over a page the reader was already on */
  --display: 'Cormorant Garamond', 'Iowan Old Style', Georgia, serif;
  --text: 'EB Garamond', Georgia, serif;
  --ui: 'Inter', system-ui, sans-serif;
  --col: 660px;
  --colwide: 780px;
  --maplane: 278px;
  --ease: cubic-bezier(.22, .61, .36, 1);
  --spine-gutter: 44px;    /* spine's offset from the wide column's own left edge */
  --dot-gutter: 47.5px;    /* node-dot's offset from the wide column's own left edge */
  /* how much further right a centered narrow (--col) column's edge sits than a
     centered wide (--colwide) one, in the same box. L5's spine/dot subtract
     this from their own offset, since its column stays centered; L4 instead
     subtracts it straight from its column's position, left-anchoring the
     column itself so every item level (0–4) shares one text edge and gutter. */
  --narrow-shift: calc((var(--colwide) - var(--col)) / 2);
}

/* ————— light: the same room in daylight — values only, no new rules —————
   "Pentelic": a cool marble gallery, chosen over three other proposals
   (Aegean/sea-blue, Kylix/terracotta, Alabaster II) — see git history for
   the retired variants. */
html[data-theme="light"] {
  color-scheme: light;
  --abyss: #F0EEE9;
  --abyss-rgb: 240, 238, 233;  /* --abyss's own rgb triplet, for rgba() alpha ramps (scrims) */
  --deep: #E4E2DD;
  --tide: rgba(33, 31, 28, .16);
  --bronze: #846610;         /* same R/G as the old olive gold, blue crushed near to
                                 zero — chroma up, not lightness — clears 4.66:1 */
  --bronze-rgb: 132, 102, 16; /* --bronze's own triplet, for the mention underline's alpha ramp */
  --bronze-lit: #6B520D;     /* hover deepens here, where dark brightens */
  --bronze-dim: #7B6842;
  --foam: #211F1C;
  --foam-lit: #11100F;
  --mist: #6F6B63;
  --spine: rgba(33, 31, 28, .218);
  --node: #E0DEDA;
  --node-edge: rgba(33, 31, 28, .233);
  --halo: rgba(160, 126, 50, .30);
  --halo-soft: rgba(160, 126, 50, .20);
  --veil: #F0EEE9;
  --atm-high: rgba(255, 253, 246, .58);   /* nearly invisible warm wash overhead */
  --atm-low: rgba(210, 202, 185, .22);    /* a soft marble shadow, not neutral gray */
  --page-a: #FBFAF6;
  --page-b: #F7F5F1;
  --page-edge: rgba(107, 82, 32, .18);
  --page-hi: rgba(255, 255, 255, .7);
  --page-umbra: rgba(63, 51, 30, .18);
  --page-glow: rgba(175, 155, 114, .05);
  --web: rgba(33, 31, 28, .30);           /* ink-warm threads on the marble wall */
  --panel: #FBFAF6;                       /* the profile sheet lifts like the pages */
  --panel-edge: rgba(33, 31, 28, .22);
  --scrim: rgba(94, 82, 58, .30);
  --scrim-text: rgba(94, 82, 58, .15);    /* the same sheet, opened over a page the reader was already on */
}

/* the bare universal selector doesn't reach generated content — ::before/::after
   need their own explicit box-sizing:border-box, or a pseudo-element that
   declares both a width and a border (the mobile spine node is the one that
   actually does) silently renders content-box: its border adds outside the
   declared size instead of being absorbed by it, growing an 8px circle to a
   true 10px one and shifting its visual center a whole pixel off the axis
   its width/left math was computed against — invisible in any getComputedStyle
   check (which reports the specified 8px either way), only visible on screen. */
*, ::before, ::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
  background: var(--abyss);
  color: var(--foam);
  font-family: var(--text);
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
}
/* deep-water vignette; a single fixed wash, no animation */
body::before {
  content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 0;
  background:
    radial-gradient(120% 90% at 50% -10%, var(--atm-high), transparent 60%),
    radial-gradient(140% 100% at 50% 115%, var(--atm-low), transparent 55%);
}

/* veils: content fades out under the chrome, top and bottom. (A batch item
   once re-tuned these ramps to fix a "line fades too early/late" complaint
   that turned out to be about the spine line's own fade, not the veils —
   see .col::before above, which is where that's actually fixed now. This
   reverted back to the original geometry below; only the light-theme ramp
   just under it, and the L4-scoped cushion further down, are unrelated
   fixes that stay.) */
#veil-top, #veil-bottom { position: fixed; left: 0; right: 0; z-index: 30; pointer-events: none; }
#veil-top { top: 0; height: 136px; background: linear-gradient(var(--abyss) 45%, var(--veil) 78%, transparent); }
#veil-bottom { bottom: 0; height: 150px; background: linear-gradient(transparent, var(--veil) 28%, var(--abyss) 52%); }
/* the poem screen doesn't scroll, so its bottom veil has no fade-out job —
   only the hint needs ground. Shortened there so the closing w-unity line
   (the structural argument at the screen's foot) stays fully legible. */
body[data-lvl="0"][data-view="poem"] #veil-bottom { height: 56px; }
/* light only: dark's veils lean on --abyss and --veil being subtly distinct
   colors, so their short, steep alpha ramp in the final ~20% still reads as
   a soft edge against the near-black ground. In light, --abyss and --veil
   are the same flat marble, so that same geometry held the veil fully
   opaque for its first ~78% and dumped the whole fade into a ~30px band —
   a hard gray-reading cutoff that also crushed the rows nearest it toward
   illegibility (the "ghost row" the top of L1/L4 showed in review). Spread
   the same ground tone across the *whole* band instead, in eased alpha
   steps built from --abyss-rgb (never a neutral gray or black over this
   pale ground), so content dissolves into the marble rather than switching
   off, and the first/last visible rows clear a readable alpha floor. */
html[data-theme="light"] #veil-top {
  background: linear-gradient(
    rgba(var(--abyss-rgb), 1) 0%,
    rgba(var(--abyss-rgb), 1) 38%,
    rgba(var(--abyss-rgb), .85) 58%,
    rgba(var(--abyss-rgb), .55) 75%,
    rgba(var(--abyss-rgb), .22) 90%,
    rgba(var(--abyss-rgb), 0) 100%
  );
}
html[data-theme="light"] #veil-bottom {
  background: linear-gradient(
    rgba(var(--abyss-rgb), 0) 0%,
    rgba(var(--abyss-rgb), .18) 15%,
    rgba(var(--abyss-rgb), .5) 35%,
    rgba(var(--abyss-rgb), .82) 55%,
    rgba(var(--abyss-rgb), .97) 75%,
    rgba(var(--abyss-rgb), 1) 100%
  );
}
/* FIX A (design review): passages (L4) stacks short beat rows much closer
   together than any other level (6px point padding + 1.5 line-height, vs
   19px item padding + 1.7-1.8 elsewhere), so on both the default arrival
   scroll and a phone the next beat or scene-plate is already sitting inside
   the ~50px chrome row (or the ~85px chrome+crumb band a wrapped mobile rail
   can reach) while the shared even ramp above has only reached ~65-90%
   alpha there — measured: a beat 28px down read 23% visible on a 1440
   desktop dive, a scene plate 26px into the mobile crumb read 90% visible.
   Every other level clears that same 0-85px band with room to spare, so
   this is scoped to L4 alone. A taller, level-4-only veil: an opaque
   cushion sized (with margin) to the worst-measured chrome/crumb depth,
   then the SAME even 5-step dissolve shape as the base ramp above, just
   carried out across the band beneath the cushion instead of from y=0 —
   still one continuous, evenly-stepped fade to clear, only relocated so it
   finishes hiding content before the chrome starts rather than after.
   (The base dark ramp it was measured against has since reverted to a
   plain two-stop gradient — see the note above — but this cushion's own
   height and shape were sized to L4's real content, not to that ramp, so
   it stays as measured.) */
body[data-lvl="4"] #veil-top {
  height: 170px;
  background: linear-gradient(
    rgba(var(--abyss-rgb), 1) 0%,
    rgba(var(--abyss-rgb), 1) 59%,
    rgba(var(--abyss-rgb), .88) 69%,
    rgba(var(--abyss-rgb), .62) 79%,
    rgba(var(--abyss-rgb), .34) 88%,
    rgba(var(--abyss-rgb), .12) 95%,
    rgba(var(--abyss-rgb), 0) 100%
  );
}
/* the text level gets the same treatment for the same reason (review, NIT):
   at rest on a focused page the outgoing scene plate above it bottoms out at
   y≈98, where the dark base veil is only ~.94-.96 — a mid-phrase sliver
   ("…SING OF ODYSSEUS") ghosted through the last few percent, in both themes.
   Same idiom as L4 above: an opaque cushion over the band the sliver occupies
   (through ~100px — the plate bottoms out at 98 on a desk, 89 hand-held),
   then the same evenly-stepped dissolve, fully spent by ~122px — before the
   focused page's own top edge (~132 desktop, ~123 mobile), so the lit page
   and its halo keep their glow. */
body[data-lvl="5"] #veil-top {
  height: 136px;
  background: linear-gradient(
    rgba(var(--abyss-rgb), 1) 0%,
    rgba(var(--abyss-rgb), 1) 74%,
    rgba(var(--abyss-rgb), .6) 79%,
    rgba(var(--abyss-rgb), .3) 84%,
    rgba(var(--abyss-rgb), .1) 87%,
    rgba(var(--abyss-rgb), 0) 90%
  );
}

/* short glass (≲870px tall): the L0 fix below starts the poem at 140px so
   the opening line clears the 136px veil — but on a short screen those
   140px push the argument's closing line into the bottom veil instead
   (the 720 "whole poem on one screen" guard). Here the poem screen trades
   veil depth for its closing line: the chrome row is ~50px at every width,
   so a 96px veil still grounds it (opaque past the row, spent by 96) and a
   102px start keeps the opening line exactly as clear. Scoped to the poem
   view at L0, the same way its bottom veil already is; abyss-rgb steps so
   one rule serves both themes. */
@media (max-height: 869.98px) {
  body[data-lvl="0"][data-view="poem"] #veil-top {
    height: 96px;
    background: linear-gradient(
      rgba(var(--abyss-rgb), 1) 0%,
      rgba(var(--abyss-rgb), 1) 58%,
      rgba(var(--abyss-rgb), .6) 72%,
      rgba(var(--abyss-rgb), .25) 86%,
      rgba(var(--abyss-rgb), 0) 100%
    );
  }
  /* body-scoped for weight: the ≤900px block's own 140px L0 padding rule
     sits later in the file and must yield to this height gate too */
  body[data-view="poem"] .level[data-lvl="0"] .col { padding-top: 102px; }
}

button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
:focus-visible { outline: 1px solid var(--bronze); outline-offset: 4px; border-radius: 2px; }

/* ————— chrome: one compact row — back + title, rail, credit + toggle ————— */
#chrome {
  position: fixed; inset: 0 0 auto 0; z-index: 40;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px;
  padding: 15px 40px 11px;
  pointer-events: none;
}
#chrome > * { pointer-events: auto; }
#chrome-start, #chrome-end { display: flex; align-items: center; gap: 20px; min-width: 0; }
/* FIX (review: rail/credit collision ~1180–1350px wide): #chrome-end used to
   carry justify-self:end so its cluster (credit + share + toggle) sits at
   the row's right edge rather than spread across the full 1fr track. But an
   end-aligned (non-stretch) grid item sizes itself to its own content first
   and THEN aligns that box within the track — it never actually shrinks to
   the track's width, so at these widths, where the track (1fr, shared
   evenly with #chrome-start once #rail's own fixed 656px is subtracted)
   is narrower than #chrome-end's natural content, the box simply overflowed
   its track leftward, past #rail's own right edge, reading as "…THE
   TEXTHOMER · TR. BRYANT, 1871" with no gap at all — the min-width:0 above
   and #attrib's own overflow:hidden (next rule) never got a chance to
   matter, since nothing had actually asked the box to be narrower than its
   content. Stretching the grid item to its real track width, then packing
   its own children flex-end within that now-correctly-sized box, lets the
   track constraint reach #attrib the way it was always meant to: at ≥1366,
   where the track was already wide enough, this is pixel-identical to the
   old rule (content simply doesn't need to shrink); narrower than that,
   #attrib alone gives up its width per the FIX below, same as it already
   does past the 1179.98 breakpoint's own #rail-gap squeeze. */
#chrome-end { justify-self: stretch; justify-content: flex-end; }
/* FIX (mobile-nav wave): at intermediate widths #chrome-start/#chrome-end were
   ordinary flex children with no flex-shrink set, so once the row ran out of
   room the browser shrank *everything* proportionally — including #back,
   #wordmark and the two fixed-size discs, which have no business compressing
   into ovals. Only #attrib (plain credit text) is the one item that can lose
   width gracefully, so it alone stays free to shrink/ellipsize; every other
   chrome atom is pinned to its natural size. */
#back, #wordmark, #share, #theme-toggle { flex-shrink: 0; }
/* FIX (design review): a held pinch can freeze the microscope transit at any
   progress, and #chrome itself carries no background of its own — it has only
   ever relied on #veil-top's fade to scrim whatever's beneath it. That ramp
   clears past its first ~25% (row-height's worth), so a frozen mid-zoom frame
   can let the outgoing/incoming plane's letterforms thread faintly through
   the rail's labels and, from there, read as text-through-text. Dark never
   shows it — a dark ground behind dark ghost text has too little contrast to
   register — but light's near-white ground makes any dark ghost ink underneath
   read clearly even through a partial wash. Same idiom as #hint::before below
   (a soft abyss-rgb wash, z-index -1, riding under the row's own text) and the
   L4 top-veil override above (opaque cushion sized to the real content height,
   then the same evenly-stepped dissolve carried out past it) — just spanning
   the full row instead of one label, and light-only since dark's veil already
   covers this cleanly. Same color family as the ground it sits on, so it adds
   nothing to the resting (non-zoom) view. */
html[data-theme="light"] #chrome::before {
  content: ''; position: absolute; inset: 0 0 auto 0; z-index: -1;
  height: 108px;
  background: linear-gradient(
    rgba(var(--abyss-rgb), 1) 0%,
    rgba(var(--abyss-rgb), 1) 46%,
    rgba(var(--abyss-rgb), .88) 58%,
    rgba(var(--abyss-rgb), .62) 70%,
    rgba(var(--abyss-rgb), .34) 82%,
    rgba(var(--abyss-rgb), .12) 92%,
    rgba(var(--abyss-rgb), 0) 100%
  );
}
/* FIX (review: panned map plates drifting under the nav row, both themes):
   .rel-head-name (below) already carries an opaque plate cut from the same
   materials as the controls chip, so the wall can't print through "What
   binds them" — #chrome itself never got that backing, so a name plate
   dragged under the row instead showed straight through the rail's own
   labels wherever #chrome's ground was otherwise bare. Scoped to the map
   room alone (data-view, set the instant it opens) and both themes; this
   redeclares every box property the light-only rule above also sets
   (same selector specificity, so a bare inset:0 here would otherwise
   inherit that rule's 108px height and go over-constrained against this
   rule's own bottom:0) rather than layering on top of a fix built for a
   different problem — the zoom transit's ghost-text veil, which only ever
   runs inside the poem room, never here. */
body[data-view="relations"] #chrome::before {
  content: ''; position: absolute; inset: 0; z-index: -1; height: auto;
  background: var(--abyss);
}
#wordmark {
  font-family: var(--display); font-weight: 500; font-size: 19px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--foam);
  transition: opacity .3s var(--ease);
}
#wordmark:hover, #wordmark:focus-visible { opacity: .72; }
#attrib {
  font-family: var(--ui); font-size: 10.5px; font-weight: 400;
  letter-spacing: .18em; text-transform: uppercase; color: var(--mist);
  min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}

/* ————— zoom-glyph: the magnifier-with-minus mark #back and #spine-up now
   both wear (feedback round 7, item 3 — "the two controls should read as
   the same family"). Same hairline idiom #share's own SVG already uses
   (fill:none, stroke:currentColor, thin strokes) rather than a new one —
   currentColor picks up whatever gold each host sets, so this one rule
   serves both, dark and light alike, with no separate theme override. No
   zoom-IN glyph exists anywhere: zooming in happens by clicking a line, so
   a static "+" button would just be dead weight beside it. ————— */
.zoom-glyph svg { display: block; fill: none; }
.zoom-glyph svg circle { stroke: currentColor; stroke-width: 1.3; }
.zoom-glyph svg line { stroke: currentColor; stroke-width: 1.15; stroke-linecap: round; }

/* ————— back: the quiet zoom-out affordance, the rail's own voice mirrored —
   left instead of right, glyph instead of tick. It calls the same ascend()
   Escape and pinch-out already use, so the transit and the arrival's
   highlight are identical, not a second implementation to keep in sync.
   Hidden at the root (nowhere left to rise to) and below the map's own
   1180px lane breakpoint, where the crumb bar's own .crumb-back takes over —
   two back doors would just be noise on a screen with room for only one. ————— */
#back { display: flex; align-items: center; gap: 7px; opacity: .5; transition: opacity .3s var(--ease); }
#back:hover, #back:focus-visible { opacity: .95; }
#back .back-arrow { color: var(--bronze); }
#back .back-arrow svg { width: 13px; height: 13px; }
#back .back-label {
  font-family: var(--ui); font-size: 9.5px; font-weight: 400; letter-spacing: .22em;
  text-transform: uppercase; color: var(--mist); transition: color .3s;
}
#back:hover .back-label, #back:focus-visible .back-label { color: var(--foam); }
/* the poem's root is the top of the climb again (the world room retired;
   the cast now rides the L0 page itself), so #back hides at L0 — nowhere
   left to rise to. Inside the relations room the chrome's zoom-out doors
   bow out with the rest of the poem furniture (see the data-view rules by
   #relations, far below). */
body[data-lvl="0"] #back { display: none; }
@media (max-width: 1179.98px) { #back { display: none; } }

/* ————— spine-up: narrow's own rise-one-level door. Below 1180px there's no
   #back (no room beside the wordmark) and, with the crumb bar hidden for now
   (see the narrow block below), no .crumb-back either — this replaces both.
   Feedback round 7 asked for more visible presence than the old bare
   chevron carried: the magnifier glyph now sits inside a faint circular
   hairline ring — the same quiet disc-chip idiom #theme-toggle/#share
   already use elsewhere in the chrome — and rests a shade brighter (.68 vs
   the old .55). Fixed so it's reachable at any scroll depth, not just at
   the top of a level; the 40px hit area is unchanged, just better filled.
   Root and wide screens hide it — nowhere to rise to at the root, and
   #back already covers this job wide. ——— */
#spine-up {
  display: none; position: fixed; left: 8px; z-index: 39;
  width: 40px; height: 40px; align-items: center; justify-content: center;
  border-radius: 50%; border: 1px solid var(--bronze-dim);
  opacity: .68; transition: opacity .3s var(--ease), border-color .3s;
}
#spine-up .zoom-glyph { color: var(--bronze); }
#spine-up .zoom-glyph svg { width: 17px; height: 17px; }
#spine-up:hover, #spine-up:focus-visible { opacity: 1; border-color: var(--bronze); }

/* ————— theme toggle: a small outlined disc, half-filled — now an ordinary
   chrome child riding the same row as the title (FIX 6), not a separately
   positioned element floating a second line below it */
#theme-toggle {
  width: 24px; height: 24px; border-radius: 50%;
  border: 1px solid var(--bronze-dim);
  display: grid; place-items: center;
  opacity: .62; transition: opacity .3s var(--ease), border-color .3s;
}
#theme-toggle::before {
  content: ''; width: 13px; height: 13px; border-radius: 50%;
  border: 1px solid var(--bronze);
  background: linear-gradient(90deg, var(--bronze) 50%, transparent 50%);
  transition: transform .55s var(--ease);
}
#theme-toggle:hover, #theme-toggle:focus-visible { opacity: 1; border-color: var(--bronze); }
html[data-theme="light"] #theme-toggle::before { transform: rotate(180deg); }
/* light: the resting ring reads too faint at dark's .62 opacity over a bright
   ground — hold it fully opaque and deepen ring + glyph so it doesn't wash out.
   :where() keeps these at the base rules' own specificity (id-only) so they
   still lose to :hover/:focus-visible below; they win the base state only
   because they're declared later in the source — don't reorder them above it. */
:where(html[data-theme="light"]) #theme-toggle { opacity: 1; border-color: #8a7350; }
:where(html[data-theme="light"]) #theme-toggle::before {
  border-color: #8a7350;
  background: linear-gradient(90deg, #8a7350 50%, transparent 50%);
}

/* ————— share: the current location, quiet chrome affordance in the theme
   toggle's own family — same disc, a different glyph (the familiar
   node-and-lines "share" mark), same rest/hover/light-theme treatment. ————— */
#share {
  width: 24px; height: 24px; border-radius: 50%;
  border: 1px solid var(--bronze-dim);
  display: grid; place-items: center; color: var(--bronze-dim);
  opacity: .62; transition: opacity .3s var(--ease), border-color .3s, color .3s;
}
#share svg { width: 14px; height: 14px; }
#share svg circle { fill: none; stroke: currentColor; stroke-width: 1.3; }
#share svg line { stroke: currentColor; stroke-width: 1.1; }
#share:hover, #share:focus-visible { opacity: 1; border-color: var(--bronze); color: var(--bronze); }
:where(html[data-theme="light"]) #share { opacity: 1; border-color: #8a7350; color: #8a7350; }
:where(html[data-theme="light"]) #share:hover, :where(html[data-theme="light"]) #share:focus-visible {
  border-color: var(--bronze); color: var(--bronze);
}
@media (max-width: 1179.98px) { #share { position: fixed; top: auto; bottom: 18px; right: 50px; z-index: 45; } }

/* ————— bookmarks disc: the same family again, one more chip in the mobile
   corner cluster, the page's own ribbon glyph. It has no wide-screen life —
   the ≥1180 nav's own Bookmarks section (renderMap) is that reader's door —
   so unlike #share/#theme-toggle it carries no base (unmedia'd) rule at all,
   only the narrow one below. Empty-shelf readers never see it: app.js
   toggles .off the moment the list goes from/to zero (syncBookmarksDisc),
   and it starts wearing .off in the markup so a slow boot never flashes it. */
#bookmarks-disc {
  display: none; width: 24px; height: 24px; border-radius: 50%;
  border: 1px solid var(--bronze-dim);
  place-items: center; color: var(--bronze-dim);
  opacity: .62; transition: opacity .3s var(--ease), border-color .3s, color .3s;
}
#bookmarks-disc svg { width: 11px; height: 14px; }
#bookmarks-disc svg path { fill: none; stroke: currentColor; stroke-width: 1.2; stroke-linejoin: round; }
#bookmarks-disc:hover, #bookmarks-disc:focus-visible { opacity: 1; border-color: var(--bronze); color: var(--bronze); }
:where(html[data-theme="light"]) #bookmarks-disc { opacity: 1; border-color: #8a7350; color: #8a7350; }
:where(html[data-theme="light"]) #bookmarks-disc:hover, :where(html[data-theme="light"]) #bookmarks-disc:focus-visible {
  border-color: var(--bronze); color: var(--bronze);
}
@media (max-width: 1179.98px) {
  #bookmarks-disc:not(.off) { display: grid; position: fixed; top: auto; bottom: 18px; right: 86px; z-index: 45; }
}
/* …one wide-screen life after all (review, MINOR): at ≥1180 the Bookmarks
   section lives in the nav map beside the spine, which only renders once the
   reader has zoomed — at the poem's own landing (L0) a reader with kept
   passages had no door to them at all. So the disc stands in the desktop's
   otherwise-empty bottom-right corner at L0 only — everywhere deeper the nav
   map already serves, and the relations room keeps its own chrome —
   aligned to #chrome's 40px gutter, same conditional-on-bookmarks rendering
   as always. */
@media (min-width: 1180px) {
  body[data-view="poem"][data-lvl="0"] #bookmarks-disc:not(.off) {
    display: grid; position: fixed; top: auto; bottom: 22px; right: 40px; z-index: 45;
  }
}

/* ————— zoom-out disc: the corner cluster's rise-one-level chip. Same
   magnifier-minus glyph #back/#spine-up already wear (.zoom-glyph), same
   rest/hover/light-theme treatment as #share and #bookmarks-disc beside
   it. Gated on data-view/data-lvl rather than a class of its own — both
   are already kept current every navigation (renderMap, openRelations,
   enterPoem) — so it shows only inside the poem (not relations, whose own
   chrome already bows out the same way) and only at L1 or deeper (at L0
   there is nowhere left to rise). Slot order, per band:
     · ≤900 (phone): theme 14px · share 50px · bookmarks 86px (when any) ·
       this disc 122px, stepping into 86px while the shelf is empty —
       #spine-up is retired here and this disc is its replacement.
     · 900–1179.98 (tablet): no disc — #spine-up (left edge) is this
       band's one rise door, and one rise-one-level door at a time.
     · ≥1180 (desk): share/theme live in the chrome row; the corner holds
       exactly one chip aligned to the 40px gutter — bookmarks at L0 (its
       own rule above), this disc from L1 down. The two gates are disjoint
       (data-lvl 0 vs not 0), so they can never collide. #back in the
       chrome still rises too; the disc is the corner's own, more visible
       door to the same ascend(). */
#zoom-out-disc {
  display: none; width: 24px; height: 24px; border-radius: 50%;
  border: 1px solid var(--bronze-dim);
  place-items: center; color: var(--bronze-dim);
  opacity: .62; transition: opacity .3s var(--ease), border-color .3s, color .3s;
}
#zoom-out-disc .zoom-glyph svg { width: 14px; height: 14px; }
#zoom-out-disc:hover, #zoom-out-disc:focus-visible { opacity: 1; border-color: var(--bronze); color: var(--bronze); }
:where(html[data-theme="light"]) #zoom-out-disc { opacity: 1; border-color: #8a7350; color: #8a7350; }
:where(html[data-theme="light"]) #zoom-out-disc:hover, :where(html[data-theme="light"]) #zoom-out-disc:focus-visible {
  border-color: var(--bronze); color: var(--bronze);
}
@media (max-width: 900px) {
  body[data-view="poem"]:not([data-lvl="0"]) #zoom-out-disc {
    display: grid; position: fixed; top: auto; bottom: 18px; right: 122px; z-index: 45;
  }
  /* the empty-shelf state (no bookmarks) leaves the 86px slot unclaimed —
     this disc steps into it rather than leaving a gap in the cluster, the
     same "no dead slot" spirit #bookmarks-disc's own .off already keeps */
  #bookmarks-disc.off ~ #zoom-out-disc { right: 86px; }
}
/* the desk's own zoom-out disc (user feedback: "in the book level there
   should be a zoom out button on desktop mode") — the same chip, in the
   L0 bookmarks disc's own 40px-gutter corner slot; see the slot-order
   note above for why the two can never meet */
@media (min-width: 1180px) {
  body[data-view="poem"]:not([data-lvl="0"]) #zoom-out-disc {
    display: grid; position: fixed; top: auto; bottom: 22px; right: 40px; z-index: 45;
  }
}

/* ————— share hint: a first-open cue for a reader who has just landed off a
   shared link, one level or more into the poem, with none of this chrome's
   history to draw on — "you can zoom back out" is nowhere else on screen for
   them. Same floating-chip idiom as #toast below (abyss wash, tide hairline,
   3px radius) and the same uppercase/tracked microcopy voice as #hint/
   #back-label, so it reads as this app's own furniture rather than an
   injected tooltip. Always present in the DOM at opacity:0 (app.js toggles
   .show), matching #toast's own show/hide technique — never display:none,
   so the fade actually has something to transition. pointer-events stay off
   permanently: this is a look, never a target, so it can't shift layout,
   steal focus or sit between a tap and whatever is under it.
     Two copy/glyph pairs ride the same box, .coarse-only / .fine-only,
   picked by (pointer: coarse) — a touch reader is told to pinch (the same
   gesture ascend() already answers to), a mouse-and-keyboard reader is told
   Esc, both illustrated with the family's own zoom-glyph (borrowed whole
   from #back/#spine-up/#zoom-out-disc) so the cue's icon and the real
   control's icon are visibly the same mark.
     Position rides the same three-band cascade #zoom-out-disc/#spine-up
   already use (base → ≤1179.98 tablet+phone → ≤900 phone overrides the
   tablet values), because the cue has to sit beside whichever rise-one-level
   door actually exists at that width: the phone/desktop corner cluster
   (#zoom-out-disc, bottom-right both ends of the width range) or, in the
   900–1179.98 gap between them, #spine-up's own ring on the left edge —
   there is no corner disc there to point at. ————— */
#share-hint {
  position: fixed; z-index: 46; pointer-events: none;
  display: flex; align-items: center; gap: 7px;
  max-width: calc(100vw - 28px);
  font-family: var(--ui); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--foam); white-space: nowrap;
  background: rgba(var(--abyss-rgb), .94); border: 1px solid var(--tide); border-radius: 3px;
  padding: 7px 11px; opacity: 0; transform: translateY(4px);
  transition: opacity .5s var(--ease), transform .5s var(--ease);
}
#share-hint.show { opacity: 1; transform: translateY(0); }
#share-hint kbd { font-family: inherit; border: 1px solid var(--tide); border-radius: 3px; padding: 1px 5px; }
#share-hint .zoom-glyph svg { width: 12px; height: 12px; }
.pinch-glyph svg { display: block; fill: none; }
.pinch-glyph svg line, .pinch-glyph svg polyline { stroke: currentColor; stroke-width: 1.15; stroke-linecap: round; stroke-linejoin: round; }
#share-hint .pinch-glyph svg { width: 14px; height: 14px; }
/* one pair shown, one hidden — (pointer: coarse) is the one signal here that
   actually tracks input modality rather than viewport width, which several
   real devices (a mouse-driven desktop windowed narrow, a touch tablet
   docked wide) would get backwards */
.share-hint-part { display: none; align-items: center; gap: 7px; }
.share-hint-part.fine-only { display: flex; }
@media (pointer: coarse) {
  .share-hint-part.fine-only { display: none; }
  .share-hint-part.coarse-only { display: flex; }
}
/* no separate light-theme rule: unlike #hint (which sits directly over the
   scrolling verse and needs full opacity to block a zoom-transit ghost
   plane's letterforms bleeding through), this chip never sits over moving
   text — the plain .94 wash above, same idiom #toast uses in both themes,
   is already enough. */
/* ≤1179.98 default: beside #spine-up's ring (left:8px, 40px tall, top:58px
   once past the root — see the narrow block above) — this is the tablet
   band's own position and stays live down to 0 until the ≤900 block below
   swaps it for the phone one, the same two-step cascade #spine-up/
   #zoom-out-disc already use to hand off between those same two bands. */
@media (max-width: 1179.98px) {
  #share-hint { left: 8px; top: 106px; right: auto; bottom: auto; }
}
/* ≤900: #spine-up has retired (mobile review) and the corner cluster's own
   #zoom-out-disc is the door now — sits just above that row, right edge
   flush with #theme-toggle's own 14px so the whole cluster reads as one
   column. Right-of-center text (not left, as the tablet band above has it)
   because the row it answers to is itself right-anchored. */
@media (max-width: 900px) {
  #share-hint { left: auto; top: auto; right: 14px; bottom: 50px; }
}
/* ≥1180: desktop's own corner disc (bottom:22px, right:40px — the "in the
   book level there should be a zoom-out button on desktop" one) is the more
   visible of its two rise doors (#back rides the chrome row too, quieter);
   this sits just above it, same right edge. */
@media (min-width: 1180px) {
  #share-hint { right: 40px; bottom: 58px; }
}

/* ————— toast: a quiet confirmation for the copy/share fallback — the same
   abyss-rgb veil technique #veil-top/#map already use, so it reads correctly
   in both rooms with no separate light-theme block. ————— */
#toast {
  position: fixed; left: 50%; bottom: 64px; z-index: 60; pointer-events: none;
  transform: translate(-50%, 6px);
  font-family: var(--ui); font-size: 10.5px; font-weight: 500; letter-spacing: .14em;
  text-transform: uppercase; color: var(--foam);
  background: rgba(var(--abyss-rgb), .94); border: 1px solid var(--tide); border-radius: 3px;
  padding: 9px 16px; opacity: 0;
  transition: opacity .35s var(--ease), transform .35s var(--ease);
}
#toast.show { opacity: 1; transform: translate(-50%, 0); }

/* ————— selection share: floats near a text selection on a verse page,
   offering to share just that passage. Parked off-screen until JS positions
   it against the selection's own rect; opacity/transform carry the show
   state so no display swap is needed. ————— */
#sel-share {
  position: fixed; left: -9999px; top: -9999px; z-index: 55;
  display: flex; align-items: center; gap: 7px;
  font-family: var(--ui); font-size: 10px; font-weight: 500; letter-spacing: .1em;
  text-transform: uppercase; color: var(--foam);
  background: rgba(var(--abyss-rgb), .96); border: 1px solid var(--bronze-dim); border-radius: 4px;
  padding: 7px 13px; box-shadow: 0 10px 26px -10px rgba(0, 0, 0, .5);
  opacity: 0; transform: translate(-50%, 4px); pointer-events: none;
  transition: opacity .18s var(--ease), transform .18s var(--ease), border-color .2s, color .2s;
}
#sel-share.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
#sel-share:hover, #sel-share:focus-visible { border-color: var(--bronze); color: var(--bronze-lit); }
#sel-share svg { width: 12px; height: 12px; flex: none; }
#sel-share svg circle { fill: none; stroke: currentColor; stroke-width: 1.3; }
#sel-share svg line { stroke: currentColor; stroke-width: 1.1; }

/* ————— depth rail (atop) ————— */
#rail { justify-self: center; display: flex; align-items: center; gap: 24px; }
.rail-stop { display: flex; align-items: center; gap: 9px; opacity: .45; transition: opacity .3s var(--ease); }
.rail-stop:hover { opacity: .85; }
/* light: dark's .45 resting opacity reads ~#aaa395 on the warm ground — too
   faint; step it darker (leaves dark's .45/.85 untouched). :where() keeps this
   at .rail-stop's own specificity so :hover still wins; it beats the base
   rule only by coming later in source — don't reorder above it. */
:where(html[data-theme="light"]) .rail-stop { opacity: .56; }
.rail-tick { width: 10px; height: 1px; background: var(--mist); transition: background .3s, width .3s var(--ease); }
.rail-name {
  font-family: var(--ui); font-size: 9.5px; font-weight: 400; letter-spacing: .22em;
  text-transform: uppercase; color: var(--mist); transition: color .3s;
}
.rail-stop.here { opacity: 1; }
.rail-stop.here .rail-tick { background: var(--bronze); width: 22px; }
.rail-stop.here .rail-name { color: var(--bronze); }

/* ————— the left timeline: the ancestor chain, then your siblings ————— */
/* the lane belongs to wide screens only; narrower ones get #crumb instead */
#map {
  position: fixed; left: 38px; top: 76px; bottom: 48px; width: 230px; z-index: 35;
  display: none; overflow-y: auto; scrollbar-width: none;
  font-family: var(--ui);
  -webkit-mask-image: linear-gradient(transparent, #000 20px, #000 calc(100% - 20px), transparent);
  mask-image: linear-gradient(transparent, #000 20px, #000 calc(100% - 20px), transparent);
}
#map::-webkit-scrollbar { display: none; }
body[data-lvl="0"] #map { display: none; }
.map-group { border-left: 1px solid var(--tide); padding: 3px 0 14px; }
.map-group.crumbed { padding-bottom: 4px; }
.map-row {
  display: flex; align-items: baseline; gap: 6px; width: 100%; text-align: left;
  padding: 2.5px 2px 2.5px 11px; position: relative;
  color: var(--mist); font-size: 10.5px; letter-spacing: .04em; line-height: 1.35;
  white-space: nowrap; transition: color .2s;
}
.map-row .map-ord {
  flex: none; min-width: 12px; font-size: 8.5px; letter-spacing: .16em;
  text-transform: uppercase; color: var(--bronze-dim);
}
.map-row .map-name { overflow: hidden; text-overflow: ellipsis; }
.map-row:hover { color: var(--foam); }
.map-row.on { color: var(--bronze); }
.map-row.on::before {
  content: ''; position: absolute; left: 0; top: 50%; width: 4px; height: 4px;
  border-radius: 50%; background: var(--bronze); transform: translate(-50%, -50%);
  box-shadow: 0 0 8px 1px var(--halo);
}
/* collapsed ancestors: a route, not a place — gold ordinal, quiet name, no lamp */
.map-group.crumbed .map-row.on { color: var(--mist); }
.map-group.crumbed .map-row.on .map-ord { color: var(--bronze); }
.map-group.crumbed .map-row.on::before { display: none; }
.map-group.crumbed .map-row:hover { color: var(--foam); }
/* kept passages: their own quiet section at the foot of the timeline, only
   printed once at least one exists. Heading matches the rail/ord voice; each
   row is a jump button plus a small remove affordance that only surfaces on
   hover/focus, so the list reads as calm as everything above it. */
.map-bm-heading {
  padding: 10px 2px 6px 11px; font-family: var(--ui); font-size: 8.5px; font-weight: 500;
  letter-spacing: .22em; text-transform: uppercase; color: var(--bronze-dim);
}
.map-bm-row { display: flex; align-items: center; }
.map-bm-row .map-row { flex: 1; min-width: 0; }
.map-bm-remove {
  flex: none; width: 16px; height: 16px; margin-right: 6px;
  display: flex; align-items: center; justify-content: center;
  font-size: 13px; line-height: 1; color: var(--mist);
  opacity: 0; transition: opacity .2s, color .2s;
}
.map-bm-row:hover .map-bm-remove, .map-bm-remove:focus-visible { opacity: .7; }
.map-bm-remove:hover, .map-bm-remove:focus-visible { color: var(--bronze); }
/* a drawn cross (app.js, CLOSE_GLYPH) rather than the × character: a
   font-poor environment (a headless box with no Dingbats/Latin-1-adjacent
   coverage) can drop either ✕ or × to a tofu box, and a shape this small
   has no spare pixels to make a missing glyph legible anyway. Two lines at
   currentColor track the hover/opacity rules above exactly as the
   character did. */
.map-bm-remove .x-glyph { width: 9px; height: 9px; }

/* ————— the breadcrumb bar: the timeline's stand-in on narrow screens ————— */
#crumb { display: none; }
#crumb .crumb-seg {
  font-family: var(--ui); font-size: 10px; font-weight: 500; letter-spacing: .18em;
  text-transform: uppercase; color: var(--mist); white-space: nowrap;
  padding: 3px 0; transition: color .2s;
}
#crumb .crumb-seg:hover { color: var(--foam); }
#crumb .crumb-seg.on { color: var(--bronze); }
#crumb .crumb-sep { color: var(--bronze-dim); font-size: 10px; }
/* the crumb bar's own back door — #back's job on narrow screens, where
   there's no room for it beside a hidden wordmark */
#crumb .crumb-back {
  flex: none; font-family: var(--display); font-size: 15px; line-height: 1;
  color: var(--bronze); padding: 3px 1px; opacity: .8; transition: opacity .2s, color .2s;
}
#crumb .crumb-back:hover, #crumb .crumb-back:focus-visible { opacity: 1; color: var(--bronze-lit); }

/* ————— position thread ————— */
#thread {
  position: fixed; right: 48px; top: 22vh; bottom: 22vh; width: 1px; z-index: 40;
  background: var(--tide);
}
#thread-fill { position: absolute; top: 0; left: 0; width: 1px; height: 0; background: linear-gradient(var(--bronze-dim), var(--bronze)); }
#thread-dot {
  position: absolute; left: 50%; top: 0; width: 5px; height: 5px; border-radius: 50%;
  background: var(--bronze); transform: translate(-50%, -50%);
  box-shadow: 0 0 12px 2px var(--halo-soft);
}

/* ————— stage & levels ————— */
#stage { position: fixed; inset: 0; z-index: 10; }
.level {
  position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden;
  scrollbar-width: none;
}
.level::-webkit-scrollbar { display: none; }
.level.off { display: none; }
/* the text level's pages are ivory — a color-overlay fade here (like the
   #veil-top/#veil-bottom the other levels rely on) would composite a dark
   rgba onto that warm ground and read as a muddy stain. Mask the scroll
   container's own alpha instead, so pages recede by transparency (same
   technique #map already uses), landing on the same bands the veils cover. */
.level[data-lvl="5"] {
  -webkit-mask-image: linear-gradient(transparent, #000 136px, #000 calc(100% - 150px), transparent);
  mask-image: linear-gradient(transparent, #000 136px, #000 calc(100% - 150px), transparent);
}

.col {
  width: min(var(--colwide), calc(100% - 176px));
  margin: 0 auto; padding: 22vh 0 40vh;
  position: relative;
}
/* passages share the verse's narrower column, so the dive holds its measure */
.level[data-lvl="4"] .col, .level[data-lvl="5"] .col { width: min(var(--col), calc(100% - 176px)); }
/* the spine — a hairline thread the items hang from. Solid its full run: it
   used to carry its own 6%/94% fade on top of the veils' own edge dimming,
   so the two fades stacked and the line read as gone well before the
   content actually left frame. The veils (and, at L5, the level's own
   mask — see .level[data-lvl="5"] below) still dim everything near the
   chrome; the line just no longer fades a second time on its own. */
.col::before {
  content: ''; position: absolute; top: 22vh; bottom: 40vh; left: -44px; width: 1px;
  background: var(--spine);
}

/* the left lane is reserved for the timeline on every level, the poem included:
   the column must sit in the same place before and after the first dive */
@media (min-width: 1180px) {
  #map { display: block; }
  .level { left: var(--maplane); }
  .level .col { width: min(var(--colwide), calc(100% - 150px)); }
  .level[data-lvl="4"] .col, .level[data-lvl="5"] .col { width: min(var(--col), calc(100% - 150px)); }
  /* L4 keeps its narrower reading measure but no longer centers itself
     independently: shifted left by --narrow-shift, its column's own left
     edge lands exactly on the wide column's — one text edge and one ord
     gutter across every item level, the spare width simply falling to the
     right. Its spine/dot need no compensation here any more; the unscoped
     -44px/-47.5px offsets above already read off that now-shared edge. */
  .level[data-lvl="4"] .col { left: calc(-1 * var(--narrow-shift)); }
  /* L5 alone still centers itself (its pages are a different material), so
     its spine keeps subtracting --narrow-shift to land on the same thread */
  .level[data-lvl="5"] .col::before {
    left: calc(-1 * (var(--spine-gutter) + var(--narrow-shift)));
  }
}

/* ————— narrow: no lane. The crumb bar that used to run under the chrome
   here is hidden for now (messy alongside the rail at these widths) — the
   markup and its renderCrumb() call are untouched, this is a display-only
   cut, easy to bring back. #spine-up (below) is narrow's rise-one-level
   door in its place. ————— */
@media (max-width: 1179.98px) {
  body:not([data-lvl="0"]) #crumb {
    display: none;
    align-items: center; gap: 7px;
    position: fixed; top: 46px; left: 0; right: 0; z-index: 38;
    padding: 6px 26px 7px 40px;
    background: var(--abyss); border-bottom: 1px solid var(--tide);
    overflow-x: auto; scrollbar-width: none;
    -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 22px), transparent);
    mask-image: linear-gradient(90deg, #000 calc(100% - 22px), transparent);
  }
  #crumb::-webkit-scrollbar { display: none; }
  #rail { gap: 14px; } /* six stops, or the attribution wraps against them */
  /* the toggle rides the credit line's row only where there's room to spare
     (≥1180, alongside #back). Below that the crumb bar already claims the
     full-width strip under the chrome, so the disc drops free of the grid
     into the same free corner it always used on a phone — inline here would
     just force "Homer · tr. Bryant, 1871" into two lines fighting it for
     the last of the row's width. */
  #theme-toggle { position: fixed; top: auto; bottom: 18px; right: 14px; z-index: 45; }
  /* the bar's own height, given back to the scroll so nothing starts beneath it */
  .col { padding-top: calc(22vh + 20px); }
  .col::before { top: calc(22vh + 20px); }
  /* hidden at the root: with the world room retired there is nothing above
     the poem to rise into — the cast sits on this very page, a scroll away */
  body:not([data-lvl="0"]) #spine-up { display: flex; top: 58px; }
  /* #chrome's own z-index (40) carries its ::before scrim above #crumb (38)
     too, and #crumb starts as close as 44-46px here — the desktop-sized
     108px tail was reaching well past that and washing out over #crumb's
     own (already-opaque) background, dulling its text. #crumb needs no help
     from this scrim (it paints its own solid --abyss fill), so the wash
     just needs to clear before #crumb's top edge instead of past it. */
  html[data-theme="light"] #chrome::before {
    height: 40px;
    background: linear-gradient(
      rgba(var(--abyss-rgb), 1) 0%,
      rgba(var(--abyss-rgb), 1) 65%,
      rgba(var(--abyss-rgb), .5) 85%,
      rgba(var(--abyss-rgb), 0) 100%
    );
  }
}

/* the poem, at a glance — one screen, no scroll for its own six movements.
   Persons & Places (app.js, appended after .l0-poem below) rides beneath
   that glance rather than inside it, so the level scrolls again once a
   reader goes looking for the cast — flex + margin:auto on .col still
   centers the short case (nothing to reach, everything already on screen);
   once .col is taller than the viewport those auto margins simply resolve
   to zero and the level scrolls from the top, same mechanic mobile already
   relies on below. */
.level[data-lvl="0"]:not(.off) { display: flex; }
/* top padding stepped down from clamp(84px,12vh,116px) when the structural
   framing joined the screen (w-class above, w-unity below): the whole column —
   epigraph, class line, six parts, argument — must clear the bottom veil at
   900px viewport height without scrolling */
/* top padding raised from clamp(52px, 8.5vh, 92px) (review, MAJOR): the top
   veil is 136px deep and ≥94% opaque down to ~106px, and the epigraph's first
   line was starting at ~77px — "Tell me, O Muse…" sat under the veil at every
   scroll position, on every glass. The veil itself is untouched (the gallery
   below still scrolls under the chrome and needs its full fade); the poem
   simply starts below the veil's own depth. 140px min clears the 136px band
   with a hair to spare at any viewport; the vh term keeps the old proportional
   breathing on tall screens. The margin:auto centering above still absorbs
   the difference whenever the column is shorter than the glass. */
.level[data-lvl="0"] .col { margin: auto; padding: clamp(140px, 16vh, 172px) 0 clamp(24px, 5vh, 56px); }
/* the spine moves to .l0-poem (app.js wraps the epigraph-through-argument
   block in it): that div holds exactly what .col used to hold entirely, so
   its own edges are where the thread always started/stopped — top:0/
   bottom:0 on itself reproduces the old clamp-matched-to-padding math
   without needing to know the viewport at all. Left bare here, .col::before
   would otherwise still fire (base rule, above) and run the full height of
   the now-taller .col, behind the gallery it was never meant to thread. */
.level[data-lvl="0"] .col::before { content: none; }
.level[data-lvl="0"] .l0-poem { position: relative; }
.level[data-lvl="0"] .l0-poem::before {
  content: ''; position: absolute; top: 0; bottom: 0; left: -44px; width: 1px;
  background: var(--spine);
}

/* ————— items ————— */
.item {
  display: flex; align-items: baseline; gap: 26px;
  width: 100%; text-align: left; position: relative;
  padding: 19px 0; scroll-margin: 33vh;
}
.item .ord {
  font-family: var(--ui); font-size: 10px; font-weight: 500; letter-spacing: .18em;
  text-transform: uppercase; color: var(--bronze-dim);
  /* one shared gutter across every item level (0–4) — the widest family
     (was L3/4's own 96px) reserved everywhere, so the ord's right edge and
     the text's left edge both land at the same x regardless of level */
  flex: none; width: 96px; text-align: right; white-space: nowrap;
  transition: color .25s;
}
.item .line { min-width: 0; }
/* every summary line wears the engraved-label voice: small tracked caps */
.item .line {
  font-family: var(--ui); font-weight: 500; text-transform: uppercase;
  color: var(--foam);
  transition: color .25s;
  text-wrap: balance;
}
.item .sub {
  font-family: var(--ui); font-size: 11px; letter-spacing: .04em; color: var(--mist);
  display: block; margin-top: 10px; max-width: 54ch; line-height: 1.6;
}
/* spine node — its left/width already put its geometric center exactly on
   the spine's own (both work out to the same half-pixel coordinate, see the
   FIX 1 comment on the mobile pair below), but that arithmetic isn't what
   lands on screen: the line is a 1px (odd) box with integer edges, so it
   rasterizes crisp with no snapping ambiguity, while this dot is an 8px
   (even) circle with half-pixel edges, which Chromium/Skia's anti-aliasing
   for a border-radius:50% shape under a scale() transform consistently
   resolves about half a CSS px right of true center (measured by rendered-
   pixel centroid, not CSS math, at dpr 1/2/3 — see probe 37 in qa-shots.mjs).
   translateX corrects that rendering bias directly rather than chasing it
   through the box model; it composes with the existing translateY(-50%)
   vertical centering already on every state below. */
.item::before {
  content: ''; position: absolute; left: -47.5px; top: 50%; width: 8px; height: 8px;
  border-radius: 50%; transform: translate(-.5px, -50%) scale(.6);
  background: var(--node); border: 1px solid var(--node-edge);
  transition: transform .25s var(--ease), background .25s, border-color .25s, box-shadow .25s;
}
/* direct child again: hovering anywhere on a passage card must not light all
   of its beats at once — each beat answers for itself (see level 4 below) */
.item:hover > .line { color: var(--foam-lit); }
/* hover: a quiet, reversible cue only — the node rings in gold rather than
   filling, and the ordinal stays put, so a hovered sibling is never mistaken
   for "you are here". (The .line brightening above already carries the
   dark-brightens/light-deepens asymmetry via --foam-lit.) */
.item:hover::before { transform: translate(-.5px, -50%) scale(.85); border-color: var(--bronze); }
/* current: the one filled gold node + gold ordinal. Declared after :hover so
   a hovered *and* current item still reads as current, not as a hover. */
.item.live::before {
  transform: translate(-.5px, -50%) scale(1);
  background: var(--bronze); border-color: var(--bronze);
  box-shadow: 0 0 10px 1px var(--halo-soft);
}
/* direct child only: a passage card's beats carry ordinals of their own, and
   they must not all turn gold just because their card is the current one */
.item.live > .ord { color: var(--bronze); }

/* the poem: six golden lines — engraved caps, read at a glance */
.level[data-lvl="0"] .item { padding: clamp(14px, 2.6vh, 26px) 0; }
.level[data-lvl="0"] .item .ord { font-size: 11px; } /* width: the shared 96px from .item .ord above */
.level[data-lvl="0"] .item .line {
  font-size: clamp(13.5px, 2vh, 16px); line-height: 1.7;
  letter-spacing: .3em; color: var(--bronze); white-space: nowrap;
}
.level[data-lvl="0"] .item:hover .line, .level[data-lvl="0"] .item.live .line { color: var(--bronze-lit); }
/* books: golden plates, kin to the poem level */
.level[data-lvl="1"] .item .line {
  font-size: clamp(12.5px, 1.9vh, 15px); line-height: 1.7;
  letter-spacing: .28em; color: var(--bronze);
}
.level[data-lvl="1"] .item:hover .line, .level[data-lvl="1"] .item.live .line { color: var(--bronze-lit); }
/* episodes and scenes: the same voice in white */
.level[data-lvl="2"] .item .line { font-size: clamp(12px, 1.8vh, 14px); line-height: 1.8; letter-spacing: .26em; }
.level[data-lvl="3"] .item .line { font-size: clamp(11.5px, 1.7vh, 13.5px); line-height: 1.8; letter-spacing: .24em; }
/* light only: gold reaches one level deeper here than in dark. Parts and
   books already read gold above; in daylight episodes join them, and only
   scenes (and deeper) settle into ink — dark keeps gold at parts+books only,
   untouched above. The nav map and separator plates don't already carry a
   level-gated gold split of their own (their gold marks "current"/ordinal,
   not level), so there's no existing pattern there to extend. */
html[data-theme="light"] .level[data-lvl="2"] .item .line { color: var(--bronze); }
html[data-theme="light"] .level[data-lvl="2"] .item:hover .line,
html[data-theme="light"] .level[data-lvl="2"] .item.live .line { color: var(--bronze-lit); }
/* passages: the scene plate above the card carries the title, so the card is
   nothing but its beats. Each beat is a row in the shared gutter — the ¶ it
   opens at, then the line — an item in miniature, one step smaller and
   quieter, and a button: it dives to that paragraph of the verse. */
.level[data-lvl="4"] .item { display: block; padding: 14px 0; }
.level[data-lvl="4"] .point {
  position: relative; display: flex; align-items: baseline; gap: 26px;
  width: 100%; text-align: left; padding: 6px 0;
}
/* the deepest summary level reads almost as the text itself, so it drops the
   engraved-caps voice every other level wears (.item .line's base uppercase,
   overridden here) for plain sentence case. Small caps' wide tracking was
   tuned for the shape of capital letters; on lowercase it just looks like
   loose kerning, so it comes down hard (.2em → near book-text) and the size
   ticks up a touch to keep the row from reading thin against that change. */
.level[data-lvl="4"] .point .line {
  font-size: clamp(11px, 1.6vh, 12.5px); letter-spacing: .012em; line-height: 1.5;
  text-transform: none;
  opacity: .82; transition: color .25s, opacity .25s;
}
/* hover: the beat brightens (deepens in daylight, via --foam-lit) and its ¶
   warms from the ordinal bronze to the full one. The card's own gold stays
   where it always was — the spine node — so a hovered beat is never mistaken
   for "you are here", and no two golds compete on the same row. */
.level[data-lvl="4"] .point:hover .line { color: var(--foam-lit); opacity: 1; }
.level[data-lvl="4"] .point:hover .ord { color: var(--bronze); }
.level[data-lvl="4"] .point:focus-visible { outline-offset: 2px; }
/* a beat that opens on the same ¶ as the one above it: its ord already lost
   the number in markup (see app.js) — dim the bare mark left behind too, the
   quietest way to say "still here" without repeating a number five times in
   a row. Hover/focus and the arrival pulse both restore full tone, same as
   any other beat, so the demotion never costs interaction feedback. */
.level[data-lvl="4"] .point.repeat .ord { opacity: .4; }
/* light only: the resting .82/.4 read fine as de-emphasis against dark's
   near-black ground, but sat too close to the legibility floor on this pale
   one (reviewer's "ghost row"), especially where a beat also fell under the
   softened veil-top above. Raise both a notch — still clearly quieter than a
   hovered/live/current row, but readable at rest. */
html[data-theme="light"] .level[data-lvl="4"] .point .line { opacity: .92; }
html[data-theme="light"] .level[data-lvl="4"] .point.repeat .ord { opacity: .55; }
.level[data-lvl="4"] .point.repeat:hover .ord, .level[data-lvl="4"] .point.repeat.zoom-focus .ord { opacity: 1; }

/* epigraph above level 0 */
.epigraph {
  font-family: var(--display); font-style: italic; font-weight: 400;
  font-size: 15.5px; letter-spacing: .02em; color: var(--mist);
  margin-bottom: clamp(18px, 4.5vh, 52px); max-width: 52ch;
}
.epigraph .attr { display: block; font-style: normal; font-family: var(--ui); font-size: 9.5px; letter-spacing: .2em; text-transform: uppercase; color: var(--bronze-dim); margin-top: 12px; }
/* the structural framing on the poem screen: one catalogue line above the six
   movements (structure.classification) and the poem's whole argument below
   them (structure.unity) — quiet literary matter in the epigraph's own
   register, not new chrome. The class line tucks up under the epigraph
   (negative top margin spends part of the epigraph's own clamp'd gap) so the
   two read as one head block and the screen still fits without scrolling. */
.level[data-lvl="0"] .w-class {
  font-family: var(--display); font-style: italic; font-weight: 400;
  font-size: clamp(11.5px, 1.5vh, 13.5px); letter-spacing: .02em; line-height: 1.6;
  color: var(--mist); max-width: 54ch;
  margin: calc(-1 * clamp(8px, 2.8vh, 34px)) 0 clamp(10px, 2.4vh, 28px);
}
.level[data-lvl="0"] .w-unity {
  font-family: var(--display); font-weight: 500; font-style: normal;
  font-size: clamp(13px, 1.85vh, 17px); line-height: 1.55;
  color: var(--foam); max-width: none; /* two long lines over the movements' own column, not three short ones under the veil */
  margin-top: clamp(10px, 2.2vh, 26px);
  padding-left: 20px; border-left: 1px solid var(--bronze-dim);
}

/* Persons & Places, folded onto the poem screen below the argument (app.js
   appends this after .l0-poem) — THE gallery now that the world room is
   retired, reusing its .w-sec/.w-grid/.ent classes untouched, introduced
   by a heading in the same voice as .w-unity above: same face, weight and
   gold rule, so the block reads as one more paragraph of the page's own
   framing rather than an imported room title.
   Full-bleed: the section slips the poem's reading column and takes the
   old world page's own measure — min(1000px, viewport − gutters) — while
   everything above it keeps the column. --l0-gal-w is that measure; the
   margin centers the wider box within the (itself centered) .col, which
   lands it centered in the level with no transform and nothing for the
   level's overflow-x:hidden to clip (the width never exceeds the level's
   own box: the ≥1180 override subtracts the reserved --maplane the level
   is inset by). The section is still not a zoom target and still sits in
   normal flow, so the L0 scroll/zoom machinery is untouched. */
.level[data-lvl="0"] .l0-persons {
  --l0-gal-w: min(1000px, calc(100vw - 152px));
  width: var(--l0-gal-w);
  margin-left: calc((100% - var(--l0-gal-w)) / 2);
  margin-top: clamp(56px, 10vh, 108px); padding-bottom: clamp(28px, 6vh, 64px);
}
@media (min-width: 1180px) {
  .level[data-lvl="0"] .l0-persons { --l0-gal-w: min(1000px, calc(100vw - var(--maplane) - 152px)); }
}
.level[data-lvl="0"] .l0-persons-head {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 12px 18px;
  font-family: var(--display); font-weight: 500; font-style: normal;
  font-size: clamp(13px, 1.85vh, 17px); line-height: 1.55;
  color: var(--foam);
  padding-left: 20px; border-left: 1px solid var(--bronze-dim);
}
.level[data-lvl="0"] .l0-persons-title { margin: 0; font: inherit; letter-spacing: .02em; }
/* the old world gate's own line, kept as the section's quiet subtitle */
.level[data-lvl="0"] .l0-persons-note {
  font-family: var(--ui); font-size: 11px; letter-spacing: .04em; line-height: 1.6;
  color: var(--mist);
}
.level[data-lvl="0"] .l0-persons .w-note { padding-left: 20px; }
.level[data-lvl="0"] .l0-persons .w-sec:first-of-type { margin-top: clamp(32px, 6vh, 56px); }

/* ————— separators (parent boundaries while scrolling) ————— */
.sep { position: relative; text-align: center; padding: 46px 0 30px; pointer-events: none; }
.sep .sep-rule { display: block; width: 56px; height: 1px; background: var(--tide); margin: 0 auto 16px; }
/* item 2 (feedback round 7): ord and title read as one line — "BOOK XIII.
   Arrival of Odysseus at Ithaca" — not stacked on their own rows. Dropping
   sep-ord's old display:block/margin-bottom is the whole change: both spans
   are inline by default, so they simply flow and wrap together like one
   sentence (the .sep parent is already text-align:center, so a wrap still
   centers cleanly). The period joining them lives in the markup (app.js),
   inside the ord span, so it inherits the ord's own bronze tone rather than
   the title's — read as part of the label, not the sentence. */
.sep .sep-ord {
  font-family: var(--ui); font-size: 9.5px; font-weight: 500;
  letter-spacing: .3em; text-transform: uppercase; color: var(--bronze);
  margin-right: .5em;
}
.sep .sep-title { font-family: var(--display); font-weight: 500; font-size: 19px; color: var(--mist); }
.sep.minor { padding: 26px 0 16px; }
.sep.minor .sep-rule { width: 28px; }
.sep.minor .sep-title { font-size: 14px; font-family: var(--ui); font-weight: 400; letter-spacing: .14em; text-transform: uppercase; color: var(--mist); }
/* a book boundary at the deeper levels stacks its plates — book, then episode,
   then scene. They read as one group, so the followers drop their own approach */
.sep + .sep.minor { padding-top: 6px; }
/* episode and scene plates were the same weight, so a stack of them (a book
   boundary can print both back to back) only parsed by counting the "·"s in
   the ord. One half-step down — smaller title, dimmed title and ord — reads
   scene as subordinate to episode at a glance; not interactive, so no
   hover/live state to restore. */
.sep.minor.sep-scene .sep-title { font-size: 12.5px; opacity: .8; }
.sep-scene .sep-ord { opacity: .8; }

/* ————— the text: ivory pages ————— */
.page {
  position: relative; margin: 34px 0; padding: 44px 52px 48px;
  background: linear-gradient(178deg, var(--page-a), var(--page-b));
  color: var(--ink); border-radius: 3px;
  /* the 4th layer is a zero-alpha stand-in for .zoom-focus's halo below —
     same position, same non-inset type, so the shadow LISTS are the same
     length in both states and box-shadow can actually interpolate between
     them. Drop it and .page.zoom-focus-decay's transition silently stops
     working: mismatched-length shadow lists don't animate, they just cut. */
  box-shadow:
    0 0 0 1px var(--page-edge),
    0 1px 0 var(--page-hi),
    0 24px 70px -18px var(--page-umbra),
    0 0 46px 8px transparent,
    inset 0 0 60px var(--page-glow);
  /* content-visibility:auto (with a tuned contain-intrinsic-size) used to
     soften the cost of mounting all 1178 pages at once. The text level is
     windowed now (app.js), so only a couple dozen pages are ever in the
     DOM — the mitigation had nothing left to save and its placeholder
     sizing only put approximate heights where the height model needs the
     real ones, so it's gone. */
  /* 18vh → 15vh (review, ghost scene title on a point-dive landing): a
     point-dive (descendToPoint, block:'start') and a rail jump/hash boot
     (centerOn's own default, block:'center') land the SAME page at two
     different rest positions — 'start' parks the page top at exactly this
     margin, 'center' parks it wherever the page's own height puts its
     midpoint at mid-screen, which measures to ~132px desktop/~123px mobile
     for an ordinary page. The L5 veil cushion just above (#veil-top) is
     opaque through ~100px and fully spent by ~122, tuned against that
     ~132/123 rest point with room to spare — but at 18vh a 'start' landing
     rested 30px lower (162/152), pushing the outgoing scene separator's
     bottom edge (always exactly 34px above the page's own top, the .page
     margin above) down to ~128/118: past the cushion's fade and still
     faintly legible until the reader's first scroll. 15vh instead lands a
     'start' dive at ~135px desktop/~127px mobile — separator bottom
     ~101/93, comfortably inside the cushion's existing opaque band — so
     the one cushion covers both paths without widening it. Widening it the
     other way risks bleeding onto the lit page's own top edge, since
     'center' already rests close to where the cushion finishes fading.
     Mirrored in app.js's prepareWindow (the windowing math must predict
     the same scroll target scrollIntoView will actually land on) — the
     two must move together. */
  scroll-margin: 15vh;
}
.page .running {
  display: flex; justify-content: space-between; align-items: baseline;
  font-family: var(--ui); font-size: 9.5px; font-weight: 500; letter-spacing: .26em;
  text-transform: uppercase; color: var(--ink-soft);
  border-bottom: 1px solid rgba(109, 99, 83, .25); padding-bottom: 12px; margin-bottom: 30px;
}
.page .verse { font-family: var(--text); font-size: 18.5px; line-height: 1.72; }
.page .verse .vl { display: block; padding-left: 2em; text-indent: -2em; }
/* a person or place named in the line itself: discoverable without asking
   for attention. text-decoration draws in the descender space and never
   touches a glyph's own box, so the height model's measured line heights
   hold exactly as they were pre-wrap; a border or outline could not make
   that promise. The word's own ink stays put at rest — only the rule under
   it says "there's more here" — and warms toward the page's gold, rule
   solidifying, on the hover a mouse can give it. Touch has no hover, so the
   dotted rule alone has to carry that reading at arm's length on its own
   (see the w9 QA shots at 390px). */
.page .verse .ent-ref {
  cursor: pointer;
  text-decoration-line: underline;
  text-decoration-style: dotted;
  text-decoration-color: rgba(var(--bronze-rgb), .58);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
  transition: color .12s var(--ease), text-decoration-color .12s var(--ease), text-decoration-style .12s var(--ease);
}
.page .verse .ent-ref:hover {
  color: var(--bronze);
  text-decoration-style: solid;
  text-decoration-color: var(--bronze);
}
/* the wash mark a shared-passage link lands wrapped in: a warm gold tint over
   the ivory, legible under the page's own dark ink in both themes (the page
   itself stays ivory-family in both rooms, so one fixed tone serves both —
   see the palette note at the top of this file). */
.share-hl { background: rgba(200, 161, 90, .38); border-radius: 2px; box-shadow: 0 0 0 1px rgba(200, 161, 90, .2); }
/* live text selection on the ivory sheet: the browser's default blue is the
   one cold thing on the folio, so selection wears the same warm gold family
   as .share-hl — a step stronger, since a live selection should read above
   a landed highlight — with the page's own ink kept for the glyphs. The
   page stays ivory in both rooms, so one fixed tone serves both themes
   (same reasoning as .share-hl above). ::selection and ::-moz-selection
   must be separate rules: one unknown pseudo-element voids a whole list. */
.page ::selection, .page::selection { background: rgba(200, 161, 90, .55); color: var(--ink); }
.page ::-moz-selection, .page::-moz-selection { background: rgba(200, 161, 90, .55); color: var(--ink); }
.page .folio {
  margin-top: 30px; display: flex; align-items: center; justify-content: center; gap: 10px;
  font-family: var(--ui); font-size: 9px; letter-spacing: .3em; color: var(--ink-soft);
}
/* the bookmark toggle: page furniture riding beside the folio mark — a small
   outline ribbon at rest, filled gold once kept. Sized and weighted to sit
   quietly next to the folio's own small caps, not compete with them. */
.bookmark {
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--ink-soft); opacity: .5; transition: opacity .25s, color .25s;
}
.bookmark svg { width: 11px; height: 14px; display: block; }
.bookmark svg path { fill: none; stroke: currentColor; stroke-width: 1.1; stroke-linejoin: round; }
.bookmark:hover, .bookmark:focus-visible { opacity: .9; color: var(--bronze); }
.bookmark.on { opacity: 1; color: var(--bronze); }
.bookmark.on svg path { fill: currentColor; }

/* ————— zoom transitions: the microscope —————
   One lens, two planes, one progress value. The motion itself lives in JS
   now (applyZoomProgress in app.js poses both planes from a single p — the
   outgoing plane magnifies by K^p about the chosen line while the incoming
   one rides at exactly 1/K of it, children resolving inside the parent
   line), because a pinch scrubs that p directly and may freeze it at any
   point. These classes carry only what the cascade should own: stacking
   order, input guards, and the sibling dim below. Transform-origin is set
   per transit to the chosen line's point on screen. */
.level { will-change: transform, opacity, filter; }
.level.zoom-out-desc, .level.zoom-out-asc { z-index: 2; pointer-events: none; }
.level.zoom-in-desc, .level.zoom-in-asc { z-index: 3; }
/* mid-dive, the item being dived into should read as the one lit line — dim
   its outgoing siblings so they don't compete with .zoom-focus's gold while
   the plane swells away, and ease them just off the focal plane (a small
   per-item blur riding the same ramp) so the chosen line's own label is the
   sharpest thing on its plane for as long as that plane is legible: it is
   the specimen under the lens, everything beside it sits at a different
   depth. --zdim is the transit's own ramp (0→1 across the first third of p,
   set per frame on the outgoing plane by applyZoomProgress), so a scrub
   eases the neighbours down and back with the gesture itself and a reverted
   pinch leaves no residue; the fallback of 1 is the fully-dimmed .25 the
   old keyframes held throughout. Scoped to the outgoing plane of a descend
   only — the arriving level, the ascend's .zoom-focus-decay, and every
   non-transit state are untouched. */
.level.zoom-out-desc .item:not(.zoom-focus) {
  opacity: calc(1 - .75 * var(--zdim, 1));
  filter: blur(calc(var(--zdim, 1) * 1.6px));
}
/* the arriving field, mid-dive: only the chosen line's own children (marked
   .zoom-kin by beginZoom) ride the incoming ghost from the start — the rest
   of that plane (other lines' children, the separators between them) holds
   back at near-nothing until --zkin's late ramp (past the handoff band, set
   per frame like --zdim) brings it up as the dive lands. Without this the
   whole next level fades in everywhere at once and the transit reads as a
   screen crossfade; with it, what grows under the lens is unmistakably the
   brood of the line you chose. Fallback 1 = plain full field (and the value
   applyZoomProgress holds at p=1), so completion never pops. */
.level.zoom-in-desc :is(.item, .page, .sep):not(.zoom-kin) {
  opacity: calc(.08 + .92 * var(--zkin, 1));
}

/* zoom-focus: a transient emphasis on the item being dived into or emerged
   from, so the eye always has something to lock onto while a plane swells
   away or settles into place. It reuses the live/hover gold family rather
   than a new hue — --bronze-lit and --halo already carry the app's own
   dark-brightens/light-deepens asymmetry (see :root vs. the light block
   above), so these rules read correctly in both rooms with no separate
   light override. On an ascend the landing item is usually also .live;
   entrance rides the .25s transition already on .item::before/.ord, so the
   pulse appears at once. Only removal is slowed — .zoom-focus-decay carries
   no color of its own, just a longer transition, so when JS swaps it in for
   .zoom-focus in the same tick the values fall back to whatever .live (or
   the plain state) supplies, over ~900ms instead of a jump cut. Rail jumps and
   hash boots never pass `focus`, so none of this ever lights for them —
   only an actual dive or rise gets the mark. */
.item.zoom-focus > .ord { color: var(--bronze-lit); }
.item.zoom-focus::before {
  background: var(--bronze-lit); border-color: var(--bronze-lit);
  box-shadow: 0 0 20px 4px var(--halo);
}
.item.zoom-focus-decay::before { transition: box-shadow .9s var(--ease), background-color .9s var(--ease), border-color .9s var(--ease); }
.item.zoom-focus-decay > .ord { transition: color .9s var(--ease); }
/* the gold wash: a soft fill plus a left-edge bar reaching into the spine
   gutter, so the chosen row reads as lit ground, not just lit glyphs — the
   ord/line color changes above are easy to miss on a swelling, blurring
   plane, this isn't. Both states paint the same box (content/position/
   background never differ between them) so only opacity needs to transition
   on removal; .zoom-focus itself carries no transition, so entrance is the
   same instant snap as the rest of the pulse. */
.item.zoom-focus::after, .item.zoom-focus-decay::after {
  content: ''; position: absolute; top: -3px; bottom: -3px; left: -22px; right: 0;
  background: linear-gradient(90deg, var(--halo) 0%, transparent 60%);
  border-left: 2px solid var(--bronze-lit);
  opacity: 1; pointer-events: none;
}
.item.zoom-focus-decay::after { opacity: 0; transition: opacity .9s var(--ease); }
/* light only: the wash+edge above reads clearly against dark's near-black
   ground (--halo .45, --bronze-lit a near-white #E9CE92) but light's paler
   tokens (--halo .30, --bronze-lit a muted #6B520D) barely nudge the marble
   --abyss — measured, the wash shifts it only ~11 RGB points where dark's
   shifts ~92 — and the resulting edge reads as no bar at all beside the
   ordinal column's own gold: reviewers had to hunt for the row mid-dive.
   Real presence in the same idiom, not a new one: a heavier, more saturated
   wash, a thicker edge, and the row's own line borrowing --bronze-lit for
   the pulse's duration (every steady state still reads --foam/--ink; only
   this transient mark tints it). Descend and ascend share this rule — both
   read stronger now, since ascend was never the weak one. Dark, and every
   other .item/.page state, are untouched. */
html[data-theme="light"] .item.zoom-focus::after,
html[data-theme="light"] .item.zoom-focus-decay::after {
  background: linear-gradient(90deg, rgba(132, 102, 16, .55) 0%, transparent 65%);
  border-left: 3px solid var(--bronze-lit);
}
html[data-theme="light"] .item.zoom-focus > .line,
html[data-theme="light"] .item.zoom-focus-decay > .line { color: var(--bronze-lit); }
html[data-theme="light"] .item.zoom-focus-decay > .line { transition: color .9s var(--ease); }
/* diving from a single beat: the card stays the lit line (its node above), and
   inside it the chosen beat is the brightest thing while its neighbours recede */
.level[data-lvl="4"] .point.zoom-focus .line { color: var(--foam-lit); opacity: 1; }
.level[data-lvl="4"] .point.zoom-focus .ord { color: var(--bronze-lit); }
.level[data-lvl="4"] .point.zoom-focus::after {
  content: ''; position: absolute; top: -1px; bottom: -1px; left: -22px; right: 0;
  background: linear-gradient(90deg, var(--halo) 0%, transparent 60%);
  border-left: 2px solid var(--bronze-lit);
  pointer-events: none;
}
.level.zoom-out-desc .item.zoom-focus .point:not(.zoom-focus) { opacity: calc(1 - .75 * var(--zdim, 1)); }
/* the same mark on the ivory sheet the beat aimed at: the page keeps its own
   shadow and gains a wider gold ring and a real halo, then loses them on the
   same ~700ms fall (the base .page carries no box-shadow transition, so it
   appears at once and only the decay is slow). Ring at 2px and a halo with
   real spread — not the 1px hairline this used to be — because a single
   device pixel against a warm page reads as nothing at normal viewing
   distance; this has to actually be seen, not just be technically present. */
.page.zoom-focus {
  box-shadow:
    0 0 0 2px var(--bronze-lit),
    0 1px 0 var(--page-hi),
    0 24px 70px -18px var(--page-umbra),
    0 0 46px 8px var(--halo),
    inset 0 0 60px var(--page-glow);
}
.page.zoom-focus-decay { transition: box-shadow .7s var(--ease); }

/* the arrived-section band (item 1, feedback round 7): one contiguous wash
   behind the whole kin block a descend just resolved, not the old per-item
   pulse stacked one wash per child — that read as several overlapping
   backgrounds rather than a single lit section. Built and positioned in
   app.js's createSectionBand the moment kin is known (beginZoom), so it's
   already there under the kin lines before the transit even starts —
   living inside the incoming plane, it inherits that plane's own pose
   (transform/opacity/filter) for free, arriving as a ghost and gathering
   weight exactly like everything else on that plane. Only the fade back
   out is scripted: finishZoom adds .decay once the dive has landed, riding
   the transition below, then removes the element once it's had time to
   finish. Same gold-wash language as the per-item .zoom-focus::after wash
   above, just one band instead of many. */
.section-band {
  position: absolute; left: -22px; right: 0;
  border-radius: 8px;
  background: linear-gradient(90deg, var(--halo) 0%, var(--halo-soft) 45%, transparent 92%);
  box-shadow: inset 0 0 0 1px rgba(200, 161, 90, .18);
  opacity: 1; pointer-events: none;
  transition: opacity 1s var(--ease);
}
.section-band.decay { opacity: 0; }
/* light: dark's --halo (a warm gold at real alpha over near-black) reads
   clearly; light's own paler --halo barely nudges the marble ground — same
   contrast gap the per-item wash above already corrects for, same fix here:
   a heavier, more saturated wash in the deeper light-theme bronze. */
html[data-theme="light"] .section-band {
  background: linear-gradient(90deg, rgba(132, 102, 16, .22) 0%, rgba(132, 102, 16, .12) 45%, transparent 92%);
  box-shadow: inset 0 0 0 1px rgba(132, 102, 16, .16);
}

/* first-load reveal of level 0 */
.level[data-lvl="0"] .reveal { animation: rise .7s var(--ease) backwards; }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } }

/* ————— persons & places: the gallery's shared card materials —————
   Same materials as the levels — engraved gold caps for names, serif for
   anything that wants reading — laid out as a gallery wall (the L0
   .l0-persons section) rather than a single hanging thread. The .w-*
   classes survive the retired world room because the gallery, the profile
   sheet and the map of bonds all still wear them. */
/* the map of bonds sheds the poem's own furniture: nav lane, thread, crumb
   bar, both zoom-out doors and the scroll hint all belong to the
   timeline's coordinates, not this room's */
body[data-view="relations"] #map, body[data-view="relations"] #thread,
body[data-view="relations"] #crumb, body[data-view="relations"] #back, body[data-view="relations"] #spine-up { display: none !important; }
body[data-view="relations"] #hint { opacity: 0; }
/* the map keeps its own footer chrome — Back to the cast, the zoom plate, the
   wheel's pager, its own hint line — and the poem's bottom veil was washing
   them all half-away; the map doesn't scroll vertically, so the veil has no
   fade-out job to do there anyway */
body[data-view="relations"] #veil-top, body[data-view="relations"] #veil-bottom { display: none; }

/* the small caps eyebrow — the relations header still wears it */
.w-eyebrow {
  font-family: var(--ui); font-size: 10.5px; letter-spacing: .18em;
  text-transform: uppercase; color: var(--mist);
}

.w-sec { margin-top: clamp(48px, 9vh, 90px); }
.w-sec-head { display: flex; align-items: baseline; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--tide); }
.w-sec-name {
  font-family: var(--ui); font-weight: 500; font-size: 12px; letter-spacing: .3em;
  text-transform: uppercase; color: var(--bronze);
}
.w-sec-count { font-family: var(--ui); font-size: 10px; letter-spacing: .18em; color: var(--bronze-dim); }
/* the door to the map of bonds, kept at the end of the Persons rule */
.w-sec-act {
  margin-left: auto; font-family: var(--ui); font-size: 10px; letter-spacing: .22em;
  text-transform: uppercase; color: var(--bronze-dim);
  border-bottom: 1px solid var(--tide); padding-bottom: 3px;
  transition: color .25s, border-color .25s;
}
.w-sec-act:hover, .w-sec-act:focus-visible { color: var(--bronze); border-color: var(--bronze-dim); }
/* the figures/emblems switch — exists only when ?figures is in the URL, and
   opens on Figures, which is what the page shows by default; two quiet labels
   beside the Persons count, the live one lit like an active rail stop, the
   other dim until hovered or chosen */
.w-fig-switch { display: flex; align-items: baseline; gap: 10px; }
.w-fig-opt {
  font-family: var(--ui); font-size: 10px; letter-spacing: .18em;
  text-transform: uppercase; color: var(--bronze-dim);
  border-bottom: 1px solid transparent; padding-bottom: 3px;
  transition: color .25s, border-color .25s;
}
.w-fig-opt.on { color: var(--bronze); border-color: var(--bronze-dim); }
.w-fig-opt:hover, .w-fig-opt:focus-visible { color: var(--bronze-lit); }
.w-note { font-family: var(--ui); font-size: 11px; letter-spacing: .04em; line-height: 1.6; color: var(--mist); max-width: 66ch; margin-top: 14px; }
.w-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); gap: 10px; margin-top: 22px; }

/* a card per person or place — kind, name, and the three-to-five-word line */
.ent {
  position: relative; display: grid; align-content: start; gap: 7px;
  text-align: left; padding: 16px 18px 18px;
  border: 1px solid var(--tide); border-radius: 3px; scroll-margin: 24vh;
  transition: border-color .25s, background .25s;
}
.ent:hover { border-color: var(--bronze-dim); background: var(--deep); }
.ent.on { border-color: var(--bronze); background: var(--deep); }
.ent.on::after {
  content: ''; position: absolute; top: 12px; right: 12px; width: 5px; height: 5px;
  border-radius: 50%; background: var(--bronze); box-shadow: 0 0 10px 1px var(--halo-soft);
}
/* the icon rides beside the eyebrow+name, not above the card — a guest, not a lead */
.ent-head { display: flex; align-items: center; gap: 10px; }
.ent-heading { display: grid; gap: 3px; min-width: 0; }
/* the medallion is painted larger than the room it books: 57px of enamel in a
   32px slot, the overflow spent upward and leftward into the card's own padding
   so no card, row, or grid track changes height. Same trick in the profile
   header and on the bond rows below — never grow the box, only the picture. */
.ent-icon {
  width: 57px; height: 57px; flex: 0 0 auto; object-fit: contain;
  margin: -14px -10px -11px -15px;
  filter: drop-shadow(0 1px 3px var(--page-umbra));
}
.ent-kind { font-family: var(--ui); font-size: 8.5px; letter-spacing: .22em; text-transform: uppercase; color: var(--bronze-dim); }
.ent-name {
  font-family: var(--ui); font-weight: 500; font-size: 12.5px; letter-spacing: .2em;
  text-transform: uppercase; color: var(--bronze); line-height: 1.5; transition: color .25s;
  /* names wrap at spaces and hyphens only — the same rule the map's own
     labels learned (.rel-n-name, above): break-word used to be the fallback
     for a long name with no gloss to break on, but it broke inside the word
     itself whenever that wasn't enough ("LAESTRIGO NI" — review), which is
     worse than the overflow it was guarding against. The handful of single
     words break-word was actually covering for (Laestrigoni, Clytemnestra)
     get a per-card font-size step-down instead, scoped to the narrow widths
     where they don't fit (below). */
  overflow-wrap: normal; word-break: normal;
}
.ent:hover .ent-name, .ent.on .ent-name { color: var(--bronze-lit); }
.ent-gloss {
  font-family: var(--display); font-style: italic; font-weight: 400; font-size: 14px;
  letter-spacing: .01em; text-transform: none; color: var(--mist); margin-left: 5px;
}
.ent-line { font-family: var(--text); font-size: 14.5px; line-height: 1.45; color: var(--mist); transition: color .25s; }
.ent:hover .ent-line, .ent.on .ent-line { color: var(--foam); }
/* the person in four or five words, tucked under the epithet — a quieter register
   (sans, small, letter-spaced) so the gold name still leads the card */
.ent-traits {
  font-family: var(--ui); font-size: 9.5px; letter-spacing: .01em; line-height: 1.5;
  color: var(--mist); margin-top: -2px; transition: color .25s;
}
.ent:hover .ent-traits, .ent.on .ent-traits { color: var(--foam); }

/* the profile: a gallery label, docked to the right. #bm-scrim rides the same
   scrim treatment (a second, independent overlay in the same family — see
   #bm-sheet below for why it's a separate node rather than a fourth tenant
   of #w-profile itself). */
#w-scrim, #bm-scrim { position: fixed; inset: 0; z-index: 41; background: var(--scrim); animation: scrimIn .35s var(--ease); }
#w-scrim.off, #w-profile.off, #bm-scrim.off, #bm-sheet.off { display: none; }
/* opened from a mention inside a page rather than from the world/map rooms:
   the reader never left the poem, so the ground behind the sheet is dimmed
   rather than darkened — enough to say "elsewhere" without hiding the page
   they were reading a moment ago. */
#w-scrim.mention { background: var(--scrim-text); }
#w-profile {
  position: fixed; top: 0; right: 0; bottom: 0; width: min(470px, 100vw); z-index: 47;
  overflow-y: auto; scrollbar-width: none;
  background: var(--panel); border-left: 1px solid var(--panel-edge);
  box-shadow: -40px 0 90px -30px var(--page-umbra);
  padding: 94px 44px 76px;
  animation: panelIn .42s var(--ease);
}
#w-profile::-webkit-scrollbar { display: none; }
@keyframes panelIn { from { opacity: 0; transform: translateX(30px); } }
@keyframes scrimIn { from { opacity: 0; } }
/* the bookmarks sheet is the disc's own list — same scrim, and the bottom-
   sheet geometry #w-profile only wears once its own 900px breakpoint hits,
   worn here permanently instead: the disc that opens it never shows past
   1179.98px, so there is no wide-screen state to write a second rule for.
   Kept as its own node rather than folded into #w-profile — that sheet
   already juggles three tenants (world, map of bonds, a mention overlay)
   whose close/escape/history bookkeeping is entity-specific; a plain list
   with no entity and no room would only tangle that, for no shared benefit.
   sheetIn (below) is the same slide-up #w-profile borrows under 900px. */
@keyframes sheetIn { from { opacity: 0; transform: translateY(34px); } }
#bm-sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 47; max-height: 74vh;
  overflow-y: auto; scrollbar-width: none;
  background: var(--panel); border-top: 1px solid var(--panel-edge);
  border-radius: 10px 10px 0 0;
  box-shadow: 0 -30px 80px -30px var(--page-umbra);
  padding: 30px 22px 34px;
  animation: sheetIn .42s var(--ease);
}
/* the disc now has a desktop L0 life (rule by #bookmarks-disc above), and a
   phone sheet stretched across 1440px of glass read as exactly that — so past
   the disc family's own 1180 breakpoint the same list stands as a compact
   panel over its opener's corner instead: bounded width, its own full border,
   the same everything else. */
@media (min-width: 1180px) {
  #bm-sheet {
    left: auto; right: 40px; bottom: 60px; width: 400px; max-height: 62vh;
    border: 1px solid var(--panel-edge); border-radius: 10px;
    box-shadow: 0 30px 80px -30px var(--page-umbra);
    padding: 26px 24px 28px;
  }
}
#bm-sheet::-webkit-scrollbar { display: none; }
.bm-sheet-head {
  font-family: var(--ui); font-weight: 500; font-size: 12.5px; letter-spacing: .24em;
  text-transform: uppercase; color: var(--bronze-dim);
  padding-bottom: 14px; border-bottom: 1px solid var(--tide);
}
.bm-sheet-rows { margin-top: 4px; }
/* the same .map-row/.map-bm-row/.map-bm-remove idiom the ≥1180 nav's own
   Bookmarks section renders (styles above, "kept passages" block) — same
   colors, same hover/focus behavior, just sized for a thumb instead of a
   cursor in a 230px lane. */
.bm-sheet-rows .map-bm-row { padding: 3px 0; }
.bm-sheet-rows .map-row { padding: 11px 4px; font-size: 13.5px; gap: 10px; white-space: normal; }
.bm-sheet-rows .map-row .map-ord { font-size: 10.5px; min-width: 32px; }
.bm-sheet-rows .map-bm-remove {
  width: 32px; height: 32px; margin-right: 2px; opacity: .55; font-size: 17px;
}
.bm-sheet-rows .map-bm-remove .x-glyph { width: 13px; height: 13px; }
.p-close {
  position: absolute; top: 26px; right: 28px; font-family: var(--ui); font-size: 12px;
  line-height: 1; padding: 6px; color: var(--mist); opacity: .75; transition: color .2s, opacity .2s;
}
.p-close:hover { color: var(--bronze); opacity: 1; }
/* the cross itself: drawn (app.js, CLOSE_GLYPH), not the ✕/× characters —
   see .x-glyph's own note by .map-bm-remove above. currentColor keeps the
   hover/opacity rules above working unchanged. */
.x-glyph { display: block; }
.x-glyph line { stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; }
.p-close .x-glyph { width: 11px; height: 11px; }
/* the profile is where a name gets its full portrait: a hand-sized medallion
   that hangs out of the sheet's own left and top padding, with the kind and the
   name standing beside it at its middle height — a gallery label next to a
   painting rather than an eyebrow on top of a thumbnail. It books 106×112 of
   room and paints 168, so nothing under the header moves. */
.p-head { display: flex; align-items: center; gap: 16px; }
.p-heading { min-width: 0; }
.p-icon {
  width: 168px; height: 168px; flex: 0 0 auto; object-fit: contain;
  margin: -52px -18px -4px -44px;
  filter: drop-shadow(0 5px 12px var(--page-umbra));
}
/* light only, one figure only: icons-figures/ulysses.png is painted almost
   entirely in near-black navies and greens (mean luminance ~77 against ~120
   for its siblings — measured from the palette; its transparency is clean),
   which reads fine on the abyss but lands as a solid dark slab on the light
   marble (review: the "dark box"). Not a background rule — the artwork
   itself is dark — so light lifts it toward the family's tone. filter is one
   property, so each icon class restates its own drop-shadow beside the lift. */
html[data-theme="light"] .ent-icon[src*="icons-figures/ulysses"] {
  filter: brightness(1.5) saturate(1.08) drop-shadow(0 1px 3px var(--page-umbra));
}
html[data-theme="light"] .p-icon[src*="icons-figures/ulysses"] {
  filter: brightness(1.5) saturate(1.08) drop-shadow(0 5px 12px var(--page-umbra));
}
html[data-theme="light"] .rel-node-icon[src*="icons-figures/ulysses"] {
  filter: brightness(1.5) saturate(1.08) drop-shadow(0 2px 5px var(--page-umbra));
}
html[data-theme="light"] .rel-icon[src*="icons-figures/ulysses"] {
  filter: brightness(1.5) saturate(1.08);
}
.p-kind { font-family: var(--ui); font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--bronze-dim); }
.p-name {
  font-family: var(--ui); font-weight: 500; font-size: 15px; letter-spacing: .24em;
  text-transform: uppercase; color: var(--bronze); line-height: 1.45; margin-top: 12px;
}
.p-gloss {
  font-family: var(--display); font-style: italic; font-weight: 400; font-size: 17px;
  letter-spacing: .01em; text-transform: none; color: var(--mist);
  /* margin trimmed 10px → 3px: the markup now puts a real space before this
     span (profileHTML — same latent no-break-chance pattern cardHTML's
     .ent-gloss already fixed), and at .p-name's 15px/.24em that space plus
     its tracking spends ~7px of the old gap itself */
  margin-left: 3px;
}
.p-line { font-family: var(--display); font-style: italic; font-size: 17px; color: var(--mist); margin-top: 10px; }
/* the fuller sketch: an aside between the epithet and the prose, set in the text
   face rather than the display one so it does not read as a second epithet */
.p-character { font-family: var(--text); font-style: italic; font-size: 15.5px; line-height: 1.6; color: var(--mist); margin-top: 14px; }
.p-summary { font-family: var(--text); font-size: 16px; line-height: 1.72; color: var(--foam); margin-top: 20px; }
.p-sec { margin-top: 34px; }
.p-sec-head {
  font-family: var(--ui); font-weight: 500; font-size: 9.5px; letter-spacing: .26em;
  text-transform: uppercase; color: var(--bronze-dim);
  border-bottom: 1px solid var(--tide); padding-bottom: 9px;
}
.p-books { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.p-bk {
  font-family: var(--ui); font-weight: 500; font-size: 10px; letter-spacing: .14em;
  color: var(--mist); border: 1px solid var(--tide); border-radius: 2px;
  padding: 5px 8px; min-width: 34px; text-align: center;
  transition: color .2s, border-color .2s;
}
.p-bk:hover { color: var(--bronze); border-color: var(--bronze-dim); }
/* Bonds folded by counterpart: the other name once, its ties stacked beneath.
   The arrows carry the direction — away from this page, or back at it — and the
   key in the section head says so in words, once, rather than every row saying
   it again. The tags themselves stand exactly as the poem stores them. */
.p-sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; }
.p-sec-title { display: flex; align-items: baseline; gap: 14px; }
.p-key { display: flex; gap: 12px; font-size: 9px; letter-spacing: .06em; text-transform: none; color: var(--mist); }
.p-key-i { white-space: nowrap; }
/* the Bonds row's own door to the wheel: quiet at rest (the ordinal-column
   voice every dim chrome affordance in this app shares), gold on hover, a
   faster fade than the rest of the sheet's own .25s controls since it sits
   right beside body text a reader's eye is already resting on. */
.p-see-map {
  flex: none; font-family: var(--ui); font-weight: 500; font-size: 9px; letter-spacing: .2em;
  text-transform: uppercase; color: var(--bronze-dim); white-space: nowrap;
  transition: color .12s;
  /* the rendered word was the whole hit area — ~101×11px, too small for a
     thumb (mobile review, FIX 2). Padding grows the clickable box; an
     equal negative margin gives the flow the same footprint back, so the
     word itself doesn't move and the row's own layout (space-between,
     wrap breakpoint) is untouched — only the invisible box around the
     word gets bigger. Split asymmetrically, not evenly, and measured
     against the actual neighbours rather than guessed: at 390 the Bonds
     heading wraps (.p-sec-head below), and this link's own wrapped line
     sits in a 6px row-gap under the title/key line, then a further 16px
     of the section's own padding-bottom/border/.p-rels margin-top before
     the first bond row's own text starts. 5px up (1px clear of the title)
     and 16px down (flush with, not past, the first row's own text) split
     a ≥32px box across that same budget without a symmetric pull — an
     even 11px/11px was measured (46b probe) to overshoot both neighbours
     at once. */
  padding: 5px 12px 16px; margin: -5px -12px -16px;
}
.p-see-map:hover, .p-see-map:focus-visible { color: var(--bronze); }
/* said only to a reader going by ear: the whole phrase, in its stored order */
.p-vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.p-rels { list-style: none; margin-top: 6px; }
.rel { padding: 10px 0 11px; border-bottom: 1px solid var(--tide); }
.rel:last-child { border-bottom: 0; }
.rel-tag { font-style: italic; }
.rel-phrase { display: block; font-family: var(--text); font-size: 15px; line-height: 1.55; color: var(--mist); }
.rel-ties { list-style: none; margin: 1px 0 0 19px; }
.rel-tie { font-family: var(--text); font-size: 14.5px; line-height: 1.42; color: var(--mist); }
.rel-dir {
  display: inline-block; width: 14px; font-family: var(--ui); font-size: 11px;
  font-style: normal; color: var(--bronze-dim);
}
.rel-dir svg {
  width: 12px; height: 8px; display: inline-block; vertical-align: 0;
  fill: none; stroke: currentColor; stroke-width: 1.2;
  stroke-linecap: round; stroke-linejoin: round;
}
.rel-who {
  font-family: var(--ui); font-weight: 500; font-size: 10.5px; letter-spacing: .16em;
  text-transform: uppercase;
}
/* inline-block, so the negative vertical margins really do shrink what the
   line box counts: 23px of medallion asking for the same 12px of room it always
   took, which is less than the line already gives the text — the bond rows keep
   their height exactly */
.rel-icon {
  display: inline-block; width: 23px; height: 23px; object-fit: contain;
  vertical-align: -3px; margin: -6px 3px -5px -4px;
}
.rel-who { color: var(--bronze); transition: color .2s; }
.rel-who:hover { color: var(--bronze-lit); }
.rel-gloss { display: block; font-family: var(--ui); font-size: 10px; letter-spacing: .05em; color: var(--mist); opacity: .72; margin-top: 5px; }
.rel-tie .rel-gloss { margin: 1px 0 5px 14px; }
.rel-tie:last-child .rel-gloss { margin-bottom: 0; }

/* ————— the map of bonds —————
   A knowledge map in the gallery's own materials: every name a small plate
   with its medallion hung off the left edge, every bond a hairline drawn in
   the direction the poem states it. Plates are opaque, so the threads pass
   behind the text and never through it. One CSS transform carries the whole
   plane; --k is that transform's scale, and every hairline, arrowhead and tag
   divides by it, which keeps them the same size on screen at any zoom. */
#relations { position: absolute; inset: 0; overflow: hidden; overscroll-behavior: contain; }
#relations.off { display: none; }
.rel-vp { position: absolute; inset: 0; overflow: hidden; touch-action: none; cursor: grab; user-select: none; }
.rel-vp:active { cursor: grabbing; }
.rel-plane { position: absolute; left: 0; top: 0; transform-origin: 0 0; --k: 1; }
.rel-plane.glide { transition: transform .55s var(--ease); }
.rel-edges { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }

.rel-edge { fill: none; stroke: var(--web); stroke-width: calc(1.1px / var(--k)); marker-end: url(#rel-arrow); }
.rel-edge.lit { stroke: var(--bronze); stroke-width: calc(1.7px / var(--k)); marker-end: url(#rel-arrow-lit); }
/* a name that stands in a crowd of bonds grades them rather than shouting all
   at once: the strongest few in full bronze, the next at half light, the rest
   a whisper — still bronze, so they read as his, and still there to be hovered */
/* an arrowhead belongs to its thread and travels with it: the half tier takes
   the same dimmed bronze the thread does, and the fading of everything else is
   done with opacity rather than stroke-opacity, since opacity is the one that
   carries a marker with it. The whisper tier keeps no arrowhead at all. */
.rel-edge.lit.half { stroke: var(--bronze-dim); stroke-width: calc(1.15px / var(--k)); opacity: .62; marker-end: url(#rel-arrow-half); }
.rel-edge.lit.faint { stroke: var(--bronze-dim); stroke-width: calc(0.9px / var(--k)); stroke-opacity: .24; marker-end: none; }
#relations[data-lit] .rel-edge:not(.lit) { opacity: .3; }
/* a folded spoke tied both ways at once heads at both ends, since one
   arrowhead could only ever tell half of it truly */
.rel-edge.rel-both { marker-start: url(#rel-arrow-rev); }
.rel-edge.lit.rel-both { marker-start: url(#rel-arrow-rev-lit); }
.rel-edge.lit.half.rel-both { marker-start: url(#rel-arrow-rev); }
.rel-edge.lit.faint.rel-both { marker-start: none; }
.rel-hit { fill: none; stroke: transparent; stroke-width: calc(14px / var(--k)); pointer-events: stroke; cursor: pointer; }
/* the heads are named by id, not by state class: they live in the defs, where a
   sweep over .lit has no business reaching them */
.rel-arrow path { fill: var(--web); }
#rel-arrow-lit path, #rel-arrow-rev-lit path { fill: var(--bronze); }
#rel-arrow-half path { fill: var(--bronze-dim); }
/* the tag rides the thread it belongs to, in the eyebrow voice, and only when
   its thread is lit — a hundred and eleven standing labels would be a thicket */
/* scoped to the wall: the profile sheet writes its bond tags with the same
   class, and an unscoped display:none here used to swallow every one of them */
#relations .rel-tag {
  display: none; text-anchor: middle; fill: var(--bronze);
  font-family: var(--ui); font-weight: 500; font-size: calc(9.5px / var(--k));
  letter-spacing: .16em; text-transform: uppercase;
  paint-order: stroke; stroke: var(--abyss); stroke-width: calc(4px / var(--k)); stroke-linejoin: round;
}
#relations .rel-tag.lit { display: block; }
/* the seam between stacked tags on a folded spoke (buildFocus): a quiet
   middot in the dim bronze, whatever state the tag itself is lit to */
#relations .rel-tag .rel-tag-sep { fill: var(--bronze-dim); }
/* the hushed threads keep their words to themselves until the reader comes close */
#relations .rel-tag.lit.half, #relations .rel-tag.lit.faint { display: none; }
#relations[data-deep] .rel-tag.lit.half, #relations[data-deep] .rel-tag.lit.faint { display: block; fill: var(--bronze-dim); }

.rel-nodes { position: absolute; inset: 0; pointer-events: none; }
.rel-node {
  position: absolute; transform: translate(-50%, -50%); pointer-events: auto;
  display: grid; gap: 3px; width: 158px; --plate-hw: 79px; padding: 9px 12px 10px 33px; text-align: left;
  /* --lw (app.js, longestWordPx): the name's longest unbreakable run at the
     base 9.5px — 45px is this plate's own l+r padding. A word the standard
     width can't hold widens its plate rather than breaking (none of the
     corpus needs it at base size; this is the floor the far-zoom rule below
     scales from) */
  min-width: calc(45px + var(--lw, 113px));
  background: var(--deep); border: 1px solid var(--tide); border-radius: 10px;
  box-shadow: 0 10px 24px -14px var(--page-umbra);
  transition: border-color .2s, opacity .2s, background .2s;
}
/* the medallion carries the plate at this distance, so it is painted larger
   than the plate is tall. Absolutely placed, it costs the layout nothing: the
   plate's footprint is unchanged and the growth lands in its own left padding,
   which is exactly the width the medallion's inboard half now needs.
   It is hung off the plate's middle rather than its left edge — pulled back out
   by --plate-hw — so that a plate which narrows at far zoom keeps its medallion
   in the same place on the wall. That is what lets the threads know the shape
   they must stop outside of, at any zoom: silhouette() in app.js is these same
   numbers, and nothing is drawn under the enamel. */
.rel-node-icon {
  position: absolute; left: 50%; top: 50%; margin-left: calc(0px - var(--plate-hw));
  width: 78px; height: 78px; object-fit: contain;
  transform: translate(-58%, -50%);
  filter: drop-shadow(0 2px 5px var(--page-umbra));
}
.rel-n-name {
  font-family: var(--ui); font-weight: 500; font-size: 9.5px; letter-spacing: .16em;
  text-transform: uppercase; color: var(--bronze); line-height: 1.4;
  /* names wrap at spaces and hyphens only. break-word used to let an
     overflowing name become a second line anywhere at all, which is how the
     review's "ODYSS EUS" / "CLYTEMNES TRA" fragments happened — the real fit
     now comes from the plate itself: every .rel-node carries --lw (its
     longest word, measured), the base and far-zoom width rules size the
     plate to hold that word at whatever size the type is running, and the
     wheel caps its type at --name-max instead (the wheel's geometry owns its
     plate widths). Nothing is left for emergency mid-word breaking to do. */
  overflow-wrap: normal; word-break: normal;
}
.rel-n-line {
  font-family: var(--text); font-size: 10.5px; line-height: 1.3; color: var(--mist);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* pulled back: names only, held at a readable size on screen whatever the zoom */
#relations[data-zoom="far"] .rel-n-line { display: none; }
/* a floor under the shrink, so the medallion — which hangs off the plate's
   middle, not its edge — never floats clear of the plate it belongs to.
   The ceiling gives way to the longest word: the label's type below runs at
   8.2px/--k of plane size once the map is pulled back past that, so the room
   the word needs grows by the same ratio — max() keeps the standard 158px
   plate for everyone the standard plate fits, and hands a long name exactly
   the width its one word measures at the current size (--lw · size/9.5, plus
   the 45px of padding), never a broken word. On screen that works out to a
   roughly constant plate for a constant label size, whatever the zoom. */
#relations[data-zoom="far"] .rel-node {
  width: auto; min-width: 112px;
  max-width: max(158px, calc(45px + var(--lw, 113px) * max(9.5, 8.2 / var(--k, 1)) / 9.5));
}
#relations[data-zoom="far"] .rel-n-name { font-size: max(9.5px, calc(8.2px / var(--k))); letter-spacing: .1em; }
.rel-node:hover, .rel-node.lit { border-color: var(--bronze); background: var(--panel); }
.rel-node.near { border-color: var(--bronze-dim); }
.rel-node.near.faint { border-color: var(--tide); opacity: .62; }
.rel-node.sel { border-color: var(--bronze); box-shadow: 0 0 0 1px var(--bronze), 0 10px 24px -14px var(--page-umbra); }
.rel-node.lit .rel-n-name, .rel-node.sel .rel-n-name { color: var(--bronze-lit); }
#relations[data-lit] .rel-node:not(.lit):not(.near):not(.sel) { opacity: .32; }

/* the map's own chrome: a title strip, a gallery label that fills on hover */
.rel-head {
  position: absolute; left: 48px; right: 48px; top: 108px; z-index: 3; pointer-events: none;
  display: flex; align-items: baseline; gap: 14px;
  white-space: nowrap;
}
/* the head's words on their own plate — the same materials as the controls
   chip beside it (.rel-chip: opaque --panel, hairline edge, the plates'
   shadow), so a panned wall's name plates passing behind the title no
   longer print through the letters. Both themes ride --panel. */
.rel-head-name {
  display: flex; align-items: baseline; gap: 14px; min-width: 0;
  padding: 9px 16px; pointer-events: none;
}
.rel-title {
  font-family: var(--display); font-weight: 500; font-size: 21px; letter-spacing: .16em;
  text-transform: uppercase; color: var(--foam);
}
.rel-sub {
  font-family: var(--ui); font-size: 10.5px; letter-spacing: .04em; color: var(--mist);
  min-width: 0; overflow: hidden; text-overflow: ellipsis;
}
.rel-back {
  position: absolute; left: 48px; bottom: 24px; z-index: 3;
  font-family: var(--ui); font-weight: 500; font-size: 10px; letter-spacing: .24em;
  text-transform: uppercase; color: var(--bronze);
  border: 1px solid var(--bronze-dim); border-radius: 2px; padding: 9px 18px;
  transition: color .25s, border-color .25s, box-shadow .35s var(--ease);
}
.rel-back:hover { color: var(--bronze-lit); border-color: var(--bronze); box-shadow: 0 0 26px -8px var(--halo); }
/* ——— the map's controls ———
   They used to be scattered along the bottom-right corner, where the wall's own
   threads and plates ran straight through them. They stand at the head of the
   view now, in one small plate cut from the same materials as the name plates:
   an opaque surface, a hairline edge, the plates' own shadow. Nothing on the
   wall can print through them, and nothing about them shouts. The shapes are
   the ones a reader already knows from a phone — a knob that slides, a pair of
   segments with one of them lit — kept at the site's own small caps and gold. */
.rel-chip {
  background: var(--panel); border: 1px solid var(--panel-edge); border-radius: 9px;
  box-shadow: 0 10px 24px -14px var(--page-umbra);
}
.rel-ctrls {
  flex: none; margin-left: auto; align-self: center; pointer-events: auto;
  display: flex; align-items: center; gap: 13px; padding: 6px 13px;
}
/* one control per cell, hairline-ruled from its neighbour. View and Places are
   always there; the layout picker drops in after them under ?layouts and needs
   nothing but its own .rel-ctrl — the rule and the spacing come with it. */
.rel-ctrl { display: flex; align-items: center; gap: 9px; }
.rel-ctrl + .rel-ctrl { border-left: 1px solid var(--tide); padding-left: 13px; }
.rel-ctrl-of {
  font-family: var(--ui); font-size: 9.5px; font-weight: 500; letter-spacing: .18em;
  text-transform: uppercase; color: var(--mist); opacity: .85; white-space: nowrap;
}
/* the switch: a track the width of two knobs, and a knob that crosses it */
.rel-switch {
  position: relative; flex: none; width: 34px; height: 19px; border-radius: 10px;
  background: var(--deep); border: 1px solid var(--tide);
  transition: background .3s var(--ease), border-color .3s var(--ease);
}
.rel-switch:hover, .rel-switch:focus-visible { border-color: var(--bronze-dim); }
.rel-knob {
  position: absolute; left: 2px; top: 50%; width: 13px; height: 13px; border-radius: 50%;
  background: var(--mist); opacity: .55; transform: translateY(-50%);
  transition: transform .3s var(--ease), background .3s var(--ease), opacity .3s var(--ease);
}
.rel-switch.on { background: var(--deep); border-color: var(--bronze-dim); box-shadow: inset 0 0 0 12px var(--halo-soft); }
.rel-switch.on .rel-knob { background: var(--bronze); opacity: 1; transform: translate(15px, -50%); }
/* the segmented pair: two words in one groove, the live one lit and lifted */
.rel-seg {
  display: flex; align-items: center; gap: 2px; padding: 2px;
  background: var(--deep); border: 1px solid var(--tide); border-radius: 7px;
}
.rel-seg-opt {
  font-family: var(--ui); font-size: 9px; font-weight: 500; letter-spacing: .14em;
  text-transform: uppercase; color: var(--mist);
  padding: 4px 9px; border-radius: 5px; white-space: nowrap;
  transition: color .22s, background .22s, box-shadow .22s;
}
.rel-seg-opt:hover, .rel-seg-opt:focus-visible { color: var(--bronze-lit); }
.rel-seg-opt.on {
  color: var(--bronze); background: var(--panel);
  box-shadow: 0 1px 3px -1px var(--page-umbra), inset 0 0 0 1px var(--tide);
}
/* the zoom pair keeps the free corner, but on a plate of its own now, so no
   thread or medallion can run behind its glyphs. Scroll and pinch already do
   this, but neither is offered to every reader, and a map that can only be read
   at the size it opens at is not a map. The wheel is fitted and fixed, so it
   never shows them; the wheel's pager takes the same corner instead. */
.rel-zoom {
  position: absolute; right: 48px; bottom: 96px; z-index: 3;
  display: flex; align-items: center; gap: 11px; padding: 6px 13px;
}
#relations[data-focus] .rel-zoom { display: none; }
.rel-zoom .rel-seg { gap: 0; padding: 0; }
.rel-zoom-opt {
  width: 28px; height: 24px; display: grid; place-items: center;
  font-family: var(--ui); font-size: 14px; line-height: 1; color: var(--bronze-dim);
  border-radius: 6px;
  transition: color .2s, background .2s, opacity .2s;
}
.rel-zoom-opt + .rel-zoom-opt { border-left: 1px solid var(--tide); }
.rel-zoom-opt:hover, .rel-zoom-opt:focus-visible { color: var(--bronze-lit); background: var(--panel); }
.rel-zoom-opt.spent { opacity: .34; }

/* ——— the wheel of one name ———
   The View segments carry the reader between the two readings, and everything
   below hangs off data-focus rather than off any switch. The grounds are not on
   the wheel, so the places switch stands down while it is up; the pager takes
   the corner the zoom pair keeps on the full wall, and the two are never on
   duty at once. */
#relations[data-focus] .rel-ctrl-places, #relations[data-focus] .rel-ctrl-lay { display: none; }
#relations:not([data-focus]) .rel-pager { display: none; }
.rel-pager {
  position: absolute; right: 48px; bottom: 96px; z-index: 3;
  display: flex; align-items: center; gap: 6px; padding: 5px 8px;
}
.rel-pager.off { display: none; }
.rel-page-of { min-width: 40px; text-align: center; }
/* eight threads can afford to say their words outright; a hundred and thirteen
   could not, which is the whole difference between the two readings */
#relations[data-focus] .rel-tag { display: block; fill: var(--bronze-dim); }
#relations[data-focus] .rel-tag.lit { fill: var(--bronze); }
/* the name in the middle stands a size larger, lit, and keeps its epithet at
   any zoom — it is the one thing on the wall the reader came here for */
/* Both plate sizes come from the script, which is also what the threads, the
   word-stacks and the fit are reckoned from: one set of numbers, so what is
   drawn and what is measured cannot drift apart. The medallion's inboard share
   is what the left padding has to hold — the same .42 of it that hangs the
   other way in ICON_HANG. The fallbacks are the desk's own measures. */
.rel-hub {
  width: var(--hub-w, 200px); --plate-hw: calc(var(--hub-w, 200px) / 2);
  padding: 13px 16px 14px; padding-left: calc(var(--hub-icon, 108px) * .42);
  background: var(--panel); border-color: var(--bronze-dim);
}
.rel-hub .rel-node-icon { width: var(--hub-icon, 108px); height: var(--hub-icon, 108px); }
/* the wheel is fitted rather than zoomed, so the pulled-back rule that lets the
   crowded map's plates shrink to their names has nothing to do here: a plate
   whose width the geometry was told must keep it */
#relations[data-focus] .rel-hub {
  width: var(--hub-w, 200px); min-width: 0; max-width: none;
}
#relations[data-focus] .rel-spoke {
  width: var(--sp-w, 158px); min-width: 0; max-width: none;
  --plate-hw: calc(var(--sp-w, 158px) / 2);
  padding-left: calc(var(--sp-icon, 78px) * .42);
}
#relations[data-focus] .rel-spoke .rel-node-icon { width: var(--sp-icon, 78px); height: var(--sp-icon, 78px); }
.rel-n-kind {
  font-family: var(--ui); font-size: 8px; letter-spacing: .26em;
  text-transform: uppercase; color: var(--bronze-dim);
}
.rel-hub .rel-n-name { font-size: 11px; letter-spacing: .2em; color: var(--bronze-lit); }
#relations[data-focus][data-zoom="far"] .rel-hub .rel-n-line { display: block; }
.rel-spoke { padding-top: 13px; padding-bottom: 14px; }

.rel-label {
  position: absolute; left: 48px; bottom: 70px; z-index: 3; width: min(320px, 42vw);
  background: var(--panel); border: 1px solid var(--panel-edge); border-radius: 4px;
  padding: 14px 16px 16px; pointer-events: none;
  box-shadow: 0 18px 46px -26px var(--page-umbra);
  animation: labelIn .25s var(--ease);
}
.rel-label.off { display: none; }
@keyframes labelIn { from { opacity: 0; transform: translateY(6px); } }
.rel-l-kind { display: block; font-family: var(--ui); font-size: 8.5px; letter-spacing: .26em; text-transform: uppercase; color: var(--bronze-dim); }
.rel-l-name {
  display: block; font-family: var(--ui); font-weight: 500; font-size: 11px; letter-spacing: .18em;
  text-transform: uppercase; color: var(--bronze); line-height: 1.5; margin-top: 6px;
}
.rel-l-name em { font-family: var(--text); font-size: 13px; letter-spacing: .02em; text-transform: none; color: var(--mist); }
.rel-l-line { font-family: var(--display); font-style: italic; font-size: 14px; color: var(--mist); margin-top: 7px; }
.rel-l-sum {
  font-family: var(--text); font-size: 12.5px; line-height: 1.55; color: var(--foam); margin-top: 8px;
  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
/* a folded spoke's label is a short list, one bond per row, each still in
   its own stated direction */
.rel-l-bonds { list-style: none; margin-top: 4px; }
.rel-l-bonds li { padding: 7px 0; border-top: 1px solid var(--tide); }
.rel-l-bonds li:first-child { border-top: 0; padding-top: 0; }
.rel-l-bonds .rel-l-name { margin-top: 0; }
.rel-hint {
  position: absolute; left: 0; right: 0; bottom: 26px; z-index: 2; text-align: center;
  font-family: var(--ui); font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--mist); opacity: .55; pointer-events: none;
}
.rel-hint kbd { font-family: inherit; border: 1px solid var(--tide); border-radius: 3px; padding: 1px 5px; }

/* in and out of the world: a quiet lift, not the microscope */
#stage > .fade-out-desc { animation: fadeOutDesc .42s var(--ease) forwards; z-index: 2; pointer-events: none; }
#stage > .fade-in-desc { animation: fadeInDesc .5s var(--ease); z-index: 3; }
#stage > .fade-out-asc { animation: fadeOutAsc .42s var(--ease) forwards; z-index: 2; pointer-events: none; }
#stage > .fade-in-asc { animation: fadeInAsc .5s var(--ease); z-index: 3; }
@keyframes fadeOutDesc { to { opacity: 0; transform: translateY(-14px); } }
@keyframes fadeInDesc { from { opacity: 0; transform: translateY(16px); } }
@keyframes fadeOutAsc { to { opacity: 0; transform: translateY(16px); } }
@keyframes fadeInAsc { from { opacity: 0; transform: translateY(-14px); } }

/* ————— hint ————— */
/* FIX C (design review): at rest, a list level's last visible row can land
   right where the hint sits — measured at L1/1440, Book VII's ghosted title
   overlapped the hint's own line, both reading in the same 10-18% visible
   band there. Moving the hint alone (bottom:22px → 12px, carrying it a few
   px deeper into the bottom veil's own tail) thinned that row but didn't
   clear the collision — the row is still legible enough to compete for the
   same line. Left as a full-width box, a scrim behind the hint would have
   to span the whole footer strip to reach it, well past "quiet"; shrinking
   the box to its own text first (width: max-content, centered by
   transform) lets a small ::before sit tight around just the words, adding
   its own soft abyss wash on top of the veil's — the two together, not the
   veil's ramp alone, are what finally hide whatever row is passing beneath.
   The veil's own dissolve is untouched, so every other level's fade stays
   exactly as tuned. */
#hint {
  position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%);
  width: max-content; max-width: calc(100% - 80px); z-index: 40; text-align: center;
  font-family: var(--ui); font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--mist); opacity: .55; pointer-events: none; transition: opacity 1s;
}
#hint::before {
  content: ''; position: absolute; z-index: -1; inset: -8px -140px -8px -90px; border-radius: 3px;
  background: rgba(var(--abyss-rgb), .92);
}
#hint kbd { font-family: inherit; border: 1px solid var(--tide); border-radius: 3px; padding: 1px 5px; }
#hint.gone { opacity: 0; }
/* FIX 5: at the text level there's nothing left for the hint to say — "select
   to descend" doesn't apply (there is no level 6), "scroll to travel" and
   "esc to rise" are already carried by the bottom veil dimming the verse and
   by #back/the rail, and the hint's own dismiss-on-first-move never even
   fires for a reader who deep-links straight to a page. Drop it outright
   here rather than let it sit dim and half-true. */
body[data-lvl="5"] #hint { display: none; }
/* light: same one-step darkening as the rail's inactive state (dark's .55 left
   untouched). :where() keeps this at #hint's own specificity so #hint.gone
   still wins the dismiss; it beats the base rule only by coming later in
   source — don't reorder above it. */
:where(html[data-theme="light"]) #hint { opacity: .66; }
/* FIX (design review): the .92 abyss wash above still lets a sliver of a
   frozen mid-zoom ghost's dark ink letterforms print through onto light's
   near-white ground — visible as faint text-through-text over "TRAVEL",
   "DESCEND" and the <kbd>. Dark never shows this (the same sliver of dark
   ghost against a dark wash has too little contrast to register), so it's
   left untouched there; light needs full opacity to fully block it. Same
   color, same shape, no wider inset — just closing the last few percent. */
:where(html[data-theme="light"]) #hint::before { background: rgba(var(--abyss-rgb), 1); }

@media (prefers-reduced-motion: reduce) {
  /* the transit itself is JS-driven and already snaps straight to arrival
     under this preference (swap() checks the same media query; pinches fall
     back to discrete threshold triggers) — only the first-load reveal still
     runs as a CSS animation and needs flattening here */
  .level[data-lvl="0"] .reveal { animation-duration: .01s; }
  #theme-toggle::before { transition: none; }
  html { scroll-behavior: auto; }
  /* the zoom-focus pulse must still appear (and its decay still happen —
     JS removes the class regardless of motion preference) — just without
     the .25s entrance or the .9s fade, so nothing animates. Light's own
     .line decay (FIX B, above) rides the same .9s clock as .ord/::before
     and needs the same override, or it'd keep fading on its own after
     reduced-motion has already snapped everything else still. The section
     band (item 1, feedback round 7) gets the same flattening — it's a
     plain opacity transition, so one line covers both its states. */
  .item.zoom-focus::before, .item.zoom-focus-decay::before,
  .item.zoom-focus > .ord, .item.zoom-focus-decay > .ord,
  .item.zoom-focus::after, .item.zoom-focus-decay::after,
  html[data-theme="light"] .item.zoom-focus-decay > .line,
  .page.zoom-focus, .page.zoom-focus-decay,
  .section-band, .section-band.decay { transition-duration: .01s; }
  /* the toast and the selection-share affordance both carry a slide + fade
     on entrance/exit — flatten that to a plain cut, same treatment as
     everything else in this block */
  /* share hint too — its own opacity+translateY entrance collapses to a
     plain cut, same "no pulse, static fade only" treatment this preference
     gets everywhere else in the file */
  #toast, #sel-share, #share-hint { transition-duration: .01s; }
  /* the rooms' own entrances — the level fades, the profile sheet, the
     scrim and the map's hover label — flatten to cuts too, and the map's
     glide (a pan/zoom transition on the plane) snaps */
  #stage > .fade-out-desc, #stage > .fade-in-desc,
  #stage > .fade-out-asc, #stage > .fade-in-asc,
  #w-profile, #w-scrim, .rel-label { animation-duration: .01s; }
  .rel-plane.glide { transition: none; }
}

@media (max-width: 900px) {
  #thread { right: 16px; }
  .col, .level[data-lvl="4"] .col, .level[data-lvl="5"] .col { width: calc(100vw - 96px); }
  .col::before { left: -26px; }
  /* FIX 1: the node's center must land on the spine's center, same as the
     desktop pair below (-47.5 vs -44 → both center on -43.5). This mobile
     pair was off by half a pixel (-29 vs -26 → node center -25, spine
     center -25.5): -29.5 restores the same node-radius-minus-half-spine-
     width relationship, so the dot sits exactly on the line rather than
     reading a hair right of it. */
  .item::before { left: -29.5px; }
  /* single column: with #back and #attrib hidden and #share/#theme-toggle
     popped out to their own fixed corners, #chrome-start (just the
     wordmark) and #rail are the only two rows left with real content —
     #chrome-end resolves empty and takes no visible height. Tightened
     row-gap keeps that empty third row from reading as a gap. */
  #chrome { grid-template-columns: 1fr; row-gap: 4px; padding: 14px 14px 8px; }
  #wordmark { font-size: 16px; letter-spacing: .1em; }
  #attrib, #hint { display: none; }
  body:not([data-lvl="0"]) #crumb { top: 44px; right: 0; padding: 6px 20px 7px 14px; }
  /* the crumb bar this used to clear is hidden now (see the 1179.98 block
     above) — #chrome is the only thing left to clear, and its own real
     height (measured with the wordmark row restored) is what this sizes to. */
  .col { padding-top: calc(22vh + 30px); }
  .col::before { top: calc(22vh + 30px); }
  /* taller here than the 1179.98 default above — the stacked wordmark+rail
     rows push #chrome's own bottom edge further down on a phone.
     Moot below, in practice: #spine-up retires at this width regardless of
     level (mobile review — the corner cluster's own #zoom-out-disc, gated
     on data-view/data-lvl above, supersedes it here). One rise-one-level
     door, not two, on a screen this narrow; !important because the
     1179.98 block above this one in the cascade still sets display:flex
     and both media queries are in force at once down here. Tablet widths
     between 900 and 1179.98 are untouched — #spine-up keeps its old job
     there, this rule only reaches width ≤900. */
  #spine-up { top: 80px; left: 6px; display: none !important; }
  /* six stops have to fit a phone: tighter gaps, a hair less tracking. This
     is also where #chrome drops to its single-column layout, and a grid item
     refuses by default to size under its own content (min-width: auto) —
     so #rail was quietly overrunning the chrome's own padding rather than
     shrinking to what it was given. min-width: 0 lets it actually shrink;
     scroll-auto is the fallback once six stops genuinely don't fit (see the
     sub-400 block below, which also gets this container ready with its own
     trailing inset). */
  #rail { gap: 10px; min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
  #rail::-webkit-scrollbar { display: none; }
  .rail-name { font-size: 9px; letter-spacing: .14em; }
  .rail-tick { display: none; }
  .item, .level[data-lvl="4"] .point { gap: 14px; }
  .item .ord { width: 76px; } /* one shared gutter here too — was split 58/50/76 by level */
  /* ord-above (feedback round: "books through scenes, hand-held"): at this
     gutter tier the ordinal leaves the left column and becomes an eyebrow
     above the summary line — scoped to L1–L3 only (L0's six lines keep their
     gutter; L4's ¶ column is a different device entirely, its own item type;
     L5 has no ord at all). The freed 76px column plus its 14px gap collapse
     to nothing, so the line itself starts flush with the item's own left
     edge — the same edge the spine dot already reckons off — and gains that
     width back for the text, which is the point. Sized ~20% under the
     shared mobile ordinal above (10px → 8px): an eyebrow is read at a
     glance, not tracked down a column, so it can afford to sit smaller. */
  .level[data-lvl="1"] .item, .level[data-lvl="2"] .item, .level[data-lvl="3"] .item {
    flex-direction: column; align-items: flex-start; gap: 5px;
  }
  .level[data-lvl="1"] .item .ord, .level[data-lvl="2"] .item .ord, .level[data-lvl="3"] .item .ord {
    width: auto; text-align: left; font-size: 8px;
  }
  /* a phone has no width to spare: the beats keep their ¶ column, but a
     narrower one, so the line itself still gets most of the measure */
  .level[data-lvl="4"] .item { padding: 10px 0; }
  .level[data-lvl="4"] .point { padding: 5px 0; }
  .level[data-lvl="4"] .point .ord { width: 52px; letter-spacing: .1em; }
  .level[data-lvl="4"] .point .line { font-size: 11px; letter-spacing: .01em; }
  .level[data-lvl="0"] .item { gap: 16px; }
  .level[data-lvl="0"] .item .line { white-space: normal; font-size: 12px; letter-spacing: .22em; }
  .level[data-lvl="1"] .item .line { font-size: 11px; letter-spacing: .2em; }
  .level[data-lvl="2"] .item .line,
  .level[data-lvl="3"] .item .line { font-size: 10.5px; letter-spacing: .18em; }
  /* keep the six movements on one phone screen; scroll carries on past the
     argument into Persons & Places same as it always could as a safety
     valve here — .l0-poem::before (base rule) already tracks its own top:0/
     bottom:0 regardless of viewport, so only its left offset needs the
     narrow-screen value the rest of the level's spines take at this width */
  .level[data-lvl="0"]:not(.off) { overflow-y: auto; }
  /* 72px top → 140px: the same first-line-under-the-veil fix as the base rule
     above (the veil is 136px deep at every width; the epigraph was starting
     at ~72px). The six movements still clear an 844 screen; on shorter
     phones the scroll that already carries on into Persons & Places is the
     safety valve, exactly as the note above says. */
  .level[data-lvl="0"] .col { padding: 140px 0 28px; }
  .level[data-lvl="0"] .l0-poem::before { left: -26px; }
  .level[data-lvl="0"] .item { padding: 10px 0; }
  .epigraph { font-size: 13px; margin-bottom: 22px; }
  /* the structural framing folds tighter on a phone: the class line gives up
     its italic display size for a near-caption one, the argument drops to the
     text face's small size — both must leave the six movements a full screen */
  .level[data-lvl="0"] .w-class { font-size: 11.5px; line-height: 1.55; margin: -8px 0 14px; }
  .level[data-lvl="0"] .w-unity { font-size: 13px; line-height: 1.58; margin-top: 14px; padding-left: 14px; }
  .level[data-lvl="0"] .l0-persons { margin-top: 34px; padding-bottom: 8px; }
  .level[data-lvl="0"] .l0-persons-head { font-size: 13px; line-height: 1.58; padding-left: 14px; }
  /* the gallery, hand-held: a tighter wall (the world page's own old 44px
     measure), and the label becomes a bottom sheet */
  .level[data-lvl="0"] .l0-persons { --l0-gal-w: calc(100vw - 44px); }
  .w-grid { grid-template-columns: repeat(auto-fill, minmax(152px, 1fr)); gap: 8px; }
  .ent { padding: 13px 14px 15px; scroll-margin: 18vh; }
  .ent-icon { width: 47px; height: 47px; margin: -12px -10px -9px -11px; }
  .ent-name { font-size: 11.5px; letter-spacing: .16em; }
  .ent-gloss { font-size: 13px; margin-left: 4px; }
  .ent-line { font-size: 13.5px; }
  .ent-traits { font-size: 9px; margin-top: -1px; }
  #w-profile {
    top: auto; left: 0; right: 0; bottom: 0; width: auto; max-height: 84vh;
    border-left: 0; border-top: 1px solid var(--panel-edge); border-radius: 10px 10px 0 0;
    box-shadow: 0 -30px 80px -30px var(--page-umbra);
    padding: 34px 24px 44px; animation-name: sheetIn;
  }
  .p-close { top: 16px; right: 16px; }
  /* the bottom sheet has less padding to hang a portrait out of, so it wears a
     smaller one — still the sheet's centrepiece, still costing it no height */
  .p-icon { width: 120px; height: 120px; margin: -28px -14px -4px -22px; }
  /* the Bonds heading — title, key legend, and now the see-on-map link — has
     no room to hold all three on one line at this width; same wrap-and-stack
     the map's own heading strip already wears just below (.w-sec-head). */
  .p-sec-head { flex-wrap: wrap; row-gap: 6px; }
  /* the map, hand-held: the strip drops its subtitle, the label spans the foot */
  .w-sec-act { font-size: 9px; letter-spacing: .12em; }
  .w-sec-head { flex-wrap: wrap; row-gap: 6px; }
  .w-fig-switch { gap: 8px; }
  .w-fig-opt { font-size: 9px; letter-spacing: .12em; }
  /* hand-held: the strip has no room to hold title and controls on one line, so
     the cluster wraps under the title and the wall is fitted below both */
  .rel-head { left: 18px; right: 18px; top: 74px; flex-wrap: wrap; row-gap: 9px; gap: 11px; }
  .rel-head-name { padding: 7px 12px; gap: 10px; }
  .rel-title { font-size: 16px; }
  .rel-sub { display: none; }
  .rel-ctrls { margin-left: 0; gap: 10px; padding: 5px 11px; }
  .rel-ctrl { gap: 8px; }
  .rel-ctrl + .rel-ctrl { padding-left: 10px; }
  .rel-ctrl-of { font-size: 9px; letter-spacing: .12em; }
  .rel-seg-opt { font-size: 8.5px; letter-spacing: .1em; padding: 4px 7px; }
  /* three layout words are the widest thing in the cluster, and on a phone the
     cluster has one line to itself: the caption stands down and the segments,
     which say what they are, keep the room. The group still names itself to a
     reader going by ear. */
  .rel-ctrl-lay .rel-ctrl-of { display: none; }
  .rel-back { left: 18px; bottom: 16px; padding: 8px 14px; }
  /* the zoom plate and the wheel's pager stand a rung above the foot, clear of
     the bottom veil that would wash them out and clear of the theme disc, which
     takes the bottom-right corner on a hand-held screen. The gallery label
     moves up by the same rung to leave them the room. */
  .rel-zoom { right: 18px; bottom: 62px; gap: 9px; padding: 5px 10px; }
  .rel-zoom-opt { width: 30px; height: 26px; }
  .rel-pager { right: 18px; bottom: 62px; gap: 5px; padding: 4px 7px; }
  .rel-page-of { min-width: 34px; }
  .rel-label { left: 18px; right: 18px; width: auto; bottom: 108px; }
  .rel-hint { display: none; }
  /* sheetIn now lives top-level (near #w-scrim/#bm-sheet) — #bm-sheet needs
     it outside this 900px block too, and a keyframes rule scoped inside a
     media query is only registered while that query matches. */

  /* ——— the wheel, hand-held ———
     A phone gets six names to a page rather than eight, so that none of them
     stands due east or west of the middle where there is no room between two
     plates for a word; the plates and the middle run smaller, and every spoke
     says one bond instead of its whole list. What must not shrink with them is
     the type. The plane is scaled to fit, and everything sized in its units
     therefore arrives on the glass at k times what it says — so the wheel's
     words are written in units of 1/k and land at the size named here however
     small the fit turns out to be. Divided by k for the same reason the tags
     already were: it is the only way a fixed-scale plane keeps a readable
     label, and on a touch screen there is no hover to fall back on. */
  #relations[data-focus] .rel-tag { font-size: calc(11px / var(--k)); letter-spacing: .1em; }
  /* --name-max (set per plate with the wheel's own measures, app.js): the
     wheel's plates keep the width the geometry drew them at, so here it is
     the type that yields — a name whose longest word would overrun its plate
     at 1/k size renders at the largest size that still fits, whole, instead
     of fragmenting ("CALYPS O", review) */
  #relations[data-focus] .rel-n-name { font-size: min(calc(9.5px / var(--k)), var(--name-max, 999px)); letter-spacing: .07em; line-height: 1.35; }
  #relations[data-focus] .rel-hub .rel-n-name { font-size: min(calc(11px / var(--k)), var(--name-max, 999px)); letter-spacing: .12em; }
  #relations[data-focus] .rel-n-kind { font-size: calc(7.5px / var(--k)); letter-spacing: .16em; }
  #relations[data-focus] .rel-hub .rel-n-line { font-size: calc(10px / var(--k)); line-height: 1.25; }
  #relations[data-focus] .rel-node { gap: 2px; border-radius: 8px; }
  #relations[data-focus] .rel-spoke { padding-top: 9px; padding-bottom: 10px; padding-right: 9px; }
  #relations[data-focus] .rel-hub { padding: 10px 12px 11px; padding-left: calc(var(--hub-icon, 108px) * .42); }
}

/* under 400: six stops no longer clear the row even at the 900px block's
   tighter gap, so the last one, "The Text", was landing flush against
   #chrome's own right padding with nothing past it. A shade tighter gap
   gets the whole row inside #rail's box again at ordinary phone widths
   (390, 375) with room to spare; the trailing pad is what that room is —
   the last label's own breathing space, not just the width left over. On
   anything narrower still, #rail's scroll container (min-width: 0 +
   overflow-x: auto, set up above) is the fallback that keeps the row
   contained rather than bleeding past the edge again. */
@media (max-width: 399.98px) {
  #rail { gap: 3px; padding-right: 8px; }
  /* Clytemnestra is the one name in the 56 whose single word (no gloss to
     break on) still overruns the card at this width once .ent-name (above)
     stopped breaking mid-word to paper over it. A card-scoped step-down —
     not a global one — keeps every other card at its usual size; at 390
     and up the card is wide enough that the word already fits at full size. */
  .ent[data-id="clytemnestra"] .ent-name { font-size: 10.5px; letter-spacing: .13em; }
}

/* ————— small mobile: the standard Bryant line, unwrapped at 390 —————
   Under 900px the page already trims its margins once, but a 390-wide phone
   still spends ~100px a side — half the 96px column margin plus the page's
   own 52px padding — before the verse itself gets any room, leaving under
   200px for an iambic-pentameter line at 18.5px. Measured against the whole
   14,915-line corpus (a headless-Chrome canvas harness, the real webfont, no
   eyeballing): at the old metrics essentially every line wraps at 390
   (14,915/14,915, confirmed against a live rendered page too); at these,
   4,820/14,915 (32.3%) at 390, 2,001 (13.4%) at 414, 922 (6.2%) at 430 — the
   remainder are genuinely long lines no reasonable margin fits on one row.
   Scoped to L5 alone: L4's ¶ gutter and every item level's own gutter are
   untouched. The type comes down one step (18.5 → 17px, −8%, still a book
   page, not a caption), the column widens (a 96px margin → 16px), and the
   page's own side padding thins to match (52px → 16px) — top/bottom
   untouched, so the folio/ribbon/running-head stack keeps its own rhythm.
   The hanging indent on a line that still wraps (.vl above) is unaffected;
   so is the level's own recede mask (.level[data-lvl="5"] above). */
@media (max-width: 430px) {
  .level[data-lvl="5"] .col { width: calc(100vw - 16px); }
  .page { padding: 44px 16px 48px; }
  .page .verse { font-size: 17px; }
  /* the running head's own 28px left inset (once here to clear #spine-up's
     ring) is gone: #spine-up no longer shows at this width at all (mobile
     review, spine-up retirement — the corner cluster's #zoom-out-disc has
     its job now, and that disc lives bottom-right, nowhere near the head).
     Nothing left to clear, so the head's left cite goes back to flush with
     the verse column's own left edge, same as every other line on the page. */
}
