/* ============================================================
   CHILL CHUMS SAGA — the generic cinematic player (/saga/)
   Layers on quest.css (tokens, .scene, .dialog, HUD, fx) and adds
   the data-driven stage: backdrop, actor placement, enter motions,
   the choice panel, the saga shelf, and the draft badge.
   ============================================================ */

/* ---------- the shelf (no ?c=, or load errors) ----------
   THE SHELF IS THE VAULT (2026-10-09, Batch 4). The shelf used to be a title,
   one line and two outline buttons on the starfield, while the only picture
   of the Vault (the Keeper's rig) was locked inside the unpublished film. It
   now shows the rig itself, dark, in a slim royal bezel above the list: each
   published saga lights one monitor (a real link over its glass, centre
   screen first, then left to right, top to bottom), and an empty archive
   plays NO SIGNAL on the centre screen. player.js (bootVault / fitVault /
   dressVault) sizes the glass to a whole number of DEVICE pixels per art
   pixel (floor 2 CSS px: 320x180 on a 390px phone) and positions the
   overlays at the same step. If the art cannot load the bezel stays hidden
   and the shelf is the title, the copy and the list, as before.

   The scene SCROLLS: the column is at least the scene's full height (so a
   short shelf still centres) and grows past it when the list is long, at
   which point the picture sits at its floor (2x on a phone, 4x on a column
   900px or wider: a 320x180 picture on a desktop is the wrong trade) and
   the list is reached by scrolling. justify-content must be flex-start
   here: .scene's flex-end would hang an over-tall column off the TOP, which
   is overflow no scrollbar can reach. The 52px top padding keeps the title
   from starting under the fixed session HUD (44px pills at 12px) at rest. */
.saga-shelf { justify-content: flex-start; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; }
/* #stage's hand cursor is the play screen's tap-to-advance; on the shelf only
   the links are clickable, so a lit monitor's pointer means something */
.saga-shelf { cursor: default; }
.saga-shelf .scene-stage {
    flex: 0 0 auto;
    min-height: 100%;
    max-width: none;
    padding-top: 52px;
}
.saga-shelf-title { font-size: clamp(var(--px-2, 16px), 3.4vw, var(--px-3, 24px)); color: var(--gold); text-align: center; }
.saga-shelf-sub {
    color: var(--dim);
    font-size: 15px;
    line-height: 1.6;
    text-align: center;
    max-width: 480px;
    -webkit-font-smoothing: antialiased;
    letter-spacing: 0.02em;
}

/* ---- the Vault: a slim royal bezel around the glass (the Chum Flap cabinet's
   phone frame: 3px --win-line inset plus 5px of royal surface, brackets off
   because a 13px bracket cannot live in a 5px strip). The kicker rises 11px
   above the frame, hence the 14px margin. --vs-halo is set by the player in
   step with the art scale, so a lit screen's glow is authored in art px. */
.saga-vault {
    flex: 0 0 auto;
    margin-top: 14px;
    padding: 8px;
    --vs-halo: 8px;
}
/* no max-width on the bezel or the glass, on purpose: the art must never be
   squeezed to a non-integer step (the overlays are placed at the step and
   would drift off their monitors). A 360px phone's 342px bezel overflows its
   332px column by 5px a side, into the scene padding; under 320px the player
   drops the floor to 1.5x instead. */
.saga-vault-glass {
    position: relative;
    width: 320px;             /* the 2x floor; fitVault sets the real step */
    height: 180px;
    background: #03031a;
    overflow: hidden;
}
.saga-vault-art { position: absolute; inset: 0; }
.saga-vault-art svg, .saga-vault-fx svg { display: block; width: 100%; height: 100%; }
/* the lens: the play stage's own vignette on the ART layer only, so the lit
   screens stay light sources and a focus ring is never dimmed by it */
.saga-vault-art::after {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: radial-gradient(ellipse at 50% 46%, rgba(2, 2, 16, 0) 56%, rgba(2, 2, 16, 0.46));
}
.saga-vault-fx { position: absolute; inset: 0; }
/* a screen: the player places it over that monitor's glass at the art step.
   .vs-art holds the sprite and carries the halo; the SVG inside it takes the
   clip that carves out what stands in front of the glass (the cabinet, the
   Game Boy). The halo is a drop-shadow on the holder, so it is drawn from
   the clipped silhouette and never blooms through the cabinet (a box-shadow
   on the link did). The hit area and the focus ring live on the link. */
.vault-screen, .vault-static { position: absolute; display: block; }
.vault-screen .vs-art { position: absolute; inset: 0; }
.vault-static { pointer-events: none; box-shadow: 0 0 var(--vs-halo) rgba(24, 88, 144, 0.3); }
a.vault-screen { color: inherit; text-decoration: none; outline: none; cursor: pointer; }
/* the touch floor: the hit area reaches --vs-hit past the glass on every
   side (the player sets it per screen: 6px from 2x up, so the smallest
   screen, 25x17 art px, is 62x46 at 2x; more at the 1.5x floor, where it
   would otherwise be 49x37) */
a.vault-screen::before { content: ''; position: absolute; inset: calc(-1 * var(--vs-hit, 6px)); }
a.vault-screen:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
/* a lit screen is a light source on the wall: CSS carries the halo (the
   doctrine's rule for glow), tinted by the skin the player chose for it.
   Hover and keyboard focus throw more light: the monitor is the thing to
   open, and the list cards beside it already lift and glow. */
.vault-screen.lit .vs-art {
    filter: drop-shadow(0 0 var(--vs-halo) var(--vs-glow, rgba(72, 192, 232, 0.4)));
    transition: filter 0.18s ease;
}
a.vault-screen.lit:hover .vs-art, a.vault-screen.lit:focus-visible .vs-art {
    filter: brightness(1.12) drop-shadow(0 0 calc(var(--vs-halo) * 1.5) var(--vs-glow-hot, rgba(72, 192, 232, 0.7)));
}
.vault-screen--green { --vs-glow: rgba(63, 191, 115, 0.36); --vs-glow-hot: rgba(63, 191, 115, 0.66); }
.vault-screen--purple { --vs-glow: rgba(154, 92, 174, 0.42); --vs-glow-hot: rgba(154, 92, 174, 0.72); }
@media (prefers-reduced-motion: reduce) {
    .vault-screen.lit .vs-art { transition: none; }
}

/* ---- the list: .cc-window cards, so the shelf is usable without the picture.
   Title in the pixel face at 16px, the byline in DotGothic16, a WATCH route
   on the right. The whole card is the link (a.cc-window lifts and glows). */
.saga-shelf-list { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 480px; }
.saga-shelf-list:empty { display: none; }   /* no gap slot for an empty list */
a.saga-shelf-item {
    display: flex;
    align-items: center;
    gap: 14px;
    text-align: left;
}
.saga-shelf-item .si-body { flex: 1 1 auto; min-width: 0; }
.saga-shelf-item .si-name {
    display: block;
    font-family: 'Press Start 2P', monospace;
    font-size: var(--px-2, 16px);
    line-height: 1.5;
    color: var(--gold);
    -webkit-font-smoothing: none;
    overflow-wrap: anywhere;
}
.saga-shelf-item .si-meta {
    display: block;
    margin-top: 4px;
    color: var(--dim);
    font-size: 14px;
    line-height: 1.6;
    letter-spacing: 0.03em;
    -webkit-font-smoothing: antialiased;
}
.saga-shelf-item .si-watch {
    flex: 0 0 auto;
    font-family: 'Press Start 2P', monospace;
    font-size: var(--px-1, 8px);
    letter-spacing: 1px;
    line-height: 1.4;
    color: var(--glint);
    white-space: nowrap;
    -webkit-font-smoothing: none;
}
a.saga-shelf-item:hover .si-watch, a.saga-shelf-item:focus-visible .si-watch { color: var(--gold); }

/* ---------- title screen ---------- */
.saga-kicker { font-size: var(--px-1, 8px); color: var(--glint); letter-spacing: 2px; }
.saga-name { font-size: clamp(var(--px-2, 16px), 4.6vw, var(--px-4, 32px)); color: var(--gold); text-align: center; line-height: 1.5; max-width: 560px; }
.saga-byline {
    color: var(--dim);
    font-size: 15px;
    text-align: center;
    -webkit-font-smoothing: antialiased;
    letter-spacing: 0.02em;
}
.saga-menu { display: flex; flex-direction: column; gap: 14px; align-items: center; }

/* ---------- the playing stage ---------- */
/* .scene gives us a flex column that ends with the dialog; the viewport is
   everything above it. Inside it, .saga-stage is the 16:9 AUTHORING FRAME —
   the very box the Studio's builder stage shows — fitted to the viewport
   HEIGHT (the ratio derives its width; max-width caps it):
     · viewport wider than 16:9 (desktop, landscape tablets): the frame
       pillarboxes, centered, the starfield showing at the sides. The whole
       backdrop is visible and every actor stands exactly where it was
       composed — nothing is sliced off the top.
     · viewport narrower than 16:9 (portrait phones): the frame is capped at
       the viewport width, so the backdrop cover-crops (bottom-anchored) to
       its middle slice — the builder's PHONE GUIDE band. Actor positions are
       % of the frame, which IS the screen here, so the cast never crops.
   Virtual %-coords position actors within the frame. The viewport's flex
   basis is 0 (not auto) so the frame's ratio can never inflate it and steal
   height from the dialog below.

   Two layouts for the dock (choice panel + royal window) — not to be confused
   with the player's THEATER auto-play mode, which is about pacing:
     · STACKED (portrait phones, landscape screens ≤640px tall — the compact
       dialog tier): the dock is a normal flex item under the picture — the
       window never covers the cast.
     · OVERLAY (landscape, ≥641px tall): the dock is a zero-height flex
       column anchored at the scene's bottom padding edge; with
       justify-content:flex-end its content overflows UPWARD, hanging over the
       foot of the picture like a JRPG window / film subtitle. The viewport
       then fills the whole scene between the letterbox bars, so the 16:9
       frame is as big as the screen allows (~87% of a 16:9 window's width,
       ~76% of a 2:1 one — the bars shave the vertical room). A 641-900px
       screen gets a mid-size window (17px) so the window's share of the
       picture stays ~14-20% everywhere (12% on 1440p): a ground-band hero
       loses legs to belt, never the face. No padding values are duplicated: the dock simply
       sits where the flex column ends.
   Integer pixel scaling (player.js fitStage) makes the frame height a whole
   number of art steps by growing the letterbox bars symmetrically
   (quest.css --bar-extra) — the frame stays height:100% and centered, no
   slack strip. The viewport is a centered flex column so a width-capped
   frame is centered too.
   The media query is mirrored in player.js (OVERLAY_MQ) — keep them equal. */
.saga-play { justify-content: flex-end; }
.saga-viewport {
    position: relative;
    flex: 1 1 0px;
    width: 100%;
    min-height: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}
.saga-dock {
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 100%;
    flex: 0 0 auto;
}
@media (orientation: landscape) and (min-height: 641px) {
    .saga-dock {
        position: relative;   /* paints above the (positioned) viewport */
        z-index: 2;
        height: 0;
        min-height: 0;        /* a flex item's auto minimum must not grow it back */
        overflow: visible;
        justify-content: flex-end;
    }
    /* mid-size royal window for laptop-class screens: keeps the floating
       window's share of the picture ~16-20% (desktop-class screens keep the
       full 20px window; ≤640px screens are stacked and already compact) */
    @media (max-height: 900px) {
        .saga-play .dialog { font-size: 17px; min-height: 6.5em; padding: 12px 16px 18px; }
    }
}
.saga-stage {
    position: relative;
    flex: 0 0 auto;
    height: 100%;            /* always: integer scaling adjusts the bars, not the frame */
    aspect-ratio: 16 / 9;    /* the width follows from the height… */
    max-width: 100%;         /* …until the viewport is narrower than 16:9 */
    /* a proscenium: walk-ins emerge from the frame's edge instead of crossing
       the starfield wings; nameplates are clamped inside it by the player */
    overflow: hidden;
}
/* ---------- the lighting pass (2026-08-21) ----------
   `.saga-bloom` holds a CLONE of the backdrop svg, blurred and crushed so only
   the highlights survive, screen-blended back over the crisp original. That is
   how the hi-bit / HD-2D look is built: the art stays a hard pixel grid and the
   glow is added on top — never a blur of the picture itself. The blur radius is
   set in art pixels (player.js sets --bloom-r from the frame height) so a scene
   glows identically on a phone and a monitor. `isolation` keeps the blend
   inside the frame instead of picking up the page starfield behind it. */
.saga-stage { isolation: isolate; }
.saga-bloom {
    position: absolute;
    inset: 0;
    pointer-events: none;
    mix-blend-mode: screen;
    /* tuned against a simulation of this exact chain: a heavier bloom washed
       the CRT scanlines out, which is the one thing the hi-bit rule forbids.
       The high contrast crushes everything but real highlights to black, and
       black contributes nothing under `screen`. */
    opacity: 0.4;
    filter: blur(var(--bloom-r, 7px)) brightness(1.15) contrast(3) saturate(1.1);
}
.saga-bloom svg { display: block; width: 100%; height: 100%; }
/* the frame's own vignette: pulls the eye into the picture and softens the
   hard proscenium edge. Generated content paints last, so it sits over the
   cast too — deliberately, it is the lens, not a backdrop layer. */
.saga-stage::after {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: radial-gradient(ellipse at 50% 46%, rgba(2, 2, 16, 0) 56%, rgba(2, 2, 16, 0.46));
}
@media (prefers-reduced-transparency: reduce) {
    .saga-bloom { display: none; }
}

/* ---------- the camera (2026-08-21) ----------
   Everything inside the frame rides .saga-cam, so a scene can push into part
   of its own backdrop: the player sets transform-origin to the focus point
   and transitions `scale`. .saga-stage's overflow:hidden does the clipping,
   which is what turns a scale into a push-in. Art is vector rects with
   shape-rendering:crispEdges, so a zoom stays hard-edged instead of blurring.
   The layer is transform-only, so it composites without repainting the
   thousands of rects underneath. */
.saga-cam {
    position: absolute;
    inset: 0;
    transform-origin: 50% 50%;   /* the player's camFrame math assumes this */
}
/* promoted only while a move is running (the player adds/removes this) */
.saga-cam.moving { will-change: transform; }
@media (prefers-reduced-motion: reduce) {
    .saga-cam { transition: none !important; }
}

.saga-bg { position: absolute; inset: 0; }
.saga-bg svg { display: block; width: 100%; height: 100%; }
.saga-bg img { display: block; width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
/* solid backgrounds are rendered by CCSprites.solidBackdrop as a 160×90
   ordered-dither vignette (color in the lit middle, the page's night at the
   sides and corners) — no CSS vignette on top, it would muddy the dither */
.saga-actors { position: absolute; inset: 0; }
.saga-actor { position: absolute; transform: translate(-50%, -100%); }
.saga-actor svg { display: block; }
/* `scale` (independent property) so the step-bob transform animation and the
   flip compose instead of overriding each other */
.saga-actor.flip svg { scale: -1 1; }

/* speaker cue: gold caret above whoever the line points at; the rest of the
   cast dims softly. The royal window stays the reading surface — the cue is
   only the spatial link. */
.saga-actor { transition: filter 0.35s ease; }
.saga-actor.dimmed { filter: brightness(0.55) saturate(0.8); }
.saga-cue {
    position: absolute;
    transform: translate(-50%, -100%);
    font-family: 'Press Start 2P', monospace;
    font-size: 12px;
    line-height: 1;
    color: var(--gold);
    text-shadow: 0 0 6px rgba(248, 204, 80, 0.75);
    pointer-events: none;
    animation: saga-cue-bob 1.1s steps(2) infinite;
}
@keyframes saga-cue-bob {
    0%, 100% { margin-top: 0; }
    50% { margin-top: 4px; }
}

/* character nameplate — hover or tap an actor to see who they are */
.saga-actor { cursor: pointer; }
.saga-nameplate {
    position: absolute;
    transform: translate(-50%, -100%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
    background: rgba(6, 6, 34, 0.94);
    border: 2px solid var(--frame, #f8f8f8);
    border-radius: 7px;
    padding: 7px 11px;
    /* natural width on BOTH wings: with only `left` set, an abspos box would
       shrink-to-fit against the space to its right and a right-side actor's
       plate would crush into a word-per-line column; the player then clamps
       the natural-width plate inside the frame */
    width: max-content;
    max-width: 250px;
    text-align: center;
    pointer-events: none;
    /* scoped to .saga-stage, which `isolation: isolate` made a stacking
       context: this wins against the cast and the bloom, but NOT against the
       royal window (.saga-dock, z-index 2 in the frame's parent context).
       Plates sit above a standing actor's head, well clear of the window, and
       the player clamps them inside the frame. */
    z-index: 6;
}
.np-name {
    font-family: 'Press Start 2P', monospace;
    font-size: var(--px-1, 8px);
    color: var(--gold);
    letter-spacing: 1px;
}
.np-title {
    font-size: 13px;
    color: var(--glint);
    -webkit-font-smoothing: antialiased;
    line-height: 1.3;
}
@media (prefers-reduced-motion: reduce) {
    .saga-cue { animation: none; }
    .saga-actor { transition: none; }
}

/* enter motions (fixed presets; data selects, code implements) */
@keyframes saga-walk-left {
    from { translate: -46vw 0; }
    to { translate: 0 0; }
}
@keyframes saga-walk-right {
    from { translate: 46vw 0; }
    to { translate: 0 0; }
}
@keyframes saga-rise {
    from { translate: 0 46px; opacity: 0; }
    to { translate: 0 0; opacity: 1; }
}
@keyframes saga-drop {
    from { translate: 0 -42vh; opacity: 0; }
    60% { translate: 0 0; opacity: 1; }
    75% { translate: 0 -8px; }
    to { translate: 0 0; }
}
@keyframes saga-fade {
    from { opacity: 0; }
    to { opacity: 1; }
}
@keyframes saga-step-bob {
    0%, 100% { transform: translateY(0); }
    50% { transform: translateY(-3px); }
}
.saga-actor.enter-walk-left { animation: saga-walk-left 1.7s linear both; }
.saga-actor.enter-walk-right { animation: saga-walk-right 1.7s linear both; }
.saga-actor.enter-rise { animation: saga-rise 1.1s ease-out both; }
.saga-actor.enter-drop { animation: saga-drop 1.1s ease-in-out both; }
.saga-actor.enter-fade { animation: saga-fade 1.4s ease both; }
.saga-actor.enter-walk-left svg, .saga-actor.enter-walk-right svg {
    /* 0.283s x 6 = 1.7s, the exact walk length (0.275 left the last 50ms
       dead-legged), and `inherit` takes the wrap's own animation-delay — the
       player sets it inline before adding the enter- class — so a staggered
       walker bobs WHILE it walks instead of bobbing in place off-frame */
    animation: saga-step-bob 0.283s steps(2) 6;
    animation-delay: inherit;
}

/* ---------- the choice panel (branching-lite) ---------- */
.saga-choice {
    width: 100%;
    max-width: 640px;
    flex: 0 0 auto;
    background: linear-gradient(180deg, var(--win-top), var(--win-bot));
    border: 3px solid var(--frame);
    border-radius: 8px;
    box-shadow: inset 0 0 0 3px var(--win-line), 0 6px 0 rgba(0, 0, 0, 0.45);
    padding: 14px 16px;
    margin-bottom: 10px;
}
.saga-choice[hidden] { display: none; }
.saga-choice-prompt { font-size: var(--px-1, 8px); letter-spacing: 1px; color: var(--gold); margin-bottom: 12px; line-height: 1.8; }
.saga-choice-opts { display: flex; flex-direction: column; gap: 8px; }
.saga-choice-opt {
    display: block;
    width: 100%;
    text-align: left;
    background: rgba(4, 4, 28, 0.55);
    border: 2px solid var(--win-line);
    border-radius: 6px;
    color: var(--ink);
    font-family: 'Press Start 2P', monospace;
    font-size: var(--px-1, 8px);
    letter-spacing: 1px;
    line-height: 1.6;
    padding: 12px 12px 11px;
    min-height: 44px;
    cursor: pointer;
}
.saga-choice-opt::before { content: '\25b8 '; color: transparent; }
.saga-choice-opt:hover, .saga-choice-opt:focus-visible {
    border-color: var(--gold);
    color: var(--gold);
    outline: none;
}
.saga-choice-opt:hover::before, .saga-choice-opt:focus-visible::before { color: var(--gold); }

/* ---------- the end card ---------- */
.saga-fin-name {
    color: var(--dim);
    font-size: 15px;
    text-align: center;
    -webkit-font-smoothing: antialiased;
    letter-spacing: 0.02em;
}
button.end-link { background: none; cursor: pointer; }

/* ---------- ambient fx, confined to the picture (2026-08-22) ----------
   The shared #fx-layer is page-wide and fixed, so motes rose across the
   letterbox bars and the starfield beside the frame — they read as an overlay
   running on top of the film rather than as air inside the world. The player
   remaps each spawn into the frame's rect and tags it `in-frame`; these rules
   start it at the frame's bottom (or top) and travel only the frame's height,
   so the drift begins and ends inside the shot. */
.mote.in-frame {
    bottom: auto;
    top: calc(var(--fx-top, 0px) + var(--fx-h, 100vh));
    animation-name: mote-rise-framed;
}
@keyframes mote-rise-framed {
    0% { transform: translate(0, 0); opacity: 0; }
    12% { opacity: 0.9; }
    100% { transform: translate(var(--sway, 20px), calc(var(--fx-h, 100vh) * -1)); opacity: 0; }
}
.sparkfall.in-frame {
    top: var(--fx-top, 0px);
    animation-name: spark-fall-framed;
}
@keyframes spark-fall-framed {
    0% { transform: translate(0, 0); opacity: 0; }
    12% { opacity: 0.9; }
    100% { transform: translate(var(--sway, -30px), var(--fx-h, 100vh)); opacity: 0; }
}
.sstar.in-frame { top: calc(var(--fx-top, 0px) + var(--fx-h, 0px) * 0.18); }

/* ---------- "turn your phone" (2026-08-21) ----------
   A portrait phone's stage is not 16:9, so the backdrop cover-crops to its
   middle band: four of the Vault's five screens are off-frame entirely, and
   a camera move that dives into one of them would arrive at bare wall. The
   player also disables camera moves at that aspect. Landscape is the whole
   composition, so ask for it once, and let it be waved away. */
.rotate-nudge {
    position: fixed;
    inset: 0;
    z-index: 60;
    display: none;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 16px;
    padding: 24px;
    text-align: center;
    background: rgba(4, 4, 28, 0.96);
}
@media (orientation: portrait) and (max-width: 820px) {
    body.playing:not(.rn-off) .rotate-nudge { display: flex; }
}
.rn-phone {
    width: 46px;
    height: 76px;
    border: 3px solid var(--gold);
    border-radius: 7px;
    box-shadow: inset 0 0 0 2px rgba(248, 204, 80, 0.25);
    animation: rn-turn 2.4s ease-in-out infinite;
}
@keyframes rn-turn {
    0%, 30% { transform: rotate(0deg); }
    55%, 85% { transform: rotate(-90deg); }
    100% { transform: rotate(0deg); }
}
.rn-title { font-size: var(--px-2, 16px); color: var(--gold); letter-spacing: 1px; }
.rn-sub {
    color: var(--dim);
    font-size: 15px;
    max-width: 320px;
    -webkit-font-smoothing: antialiased;
}
.rn-go {
    margin-top: 6px;
    min-height: 44px;
    padding: 12px 18px;
    font-size: var(--px-1, 8px);
    letter-spacing: 1px;
    color: var(--ink);
    background: rgba(16, 16, 74, 0.8);
    border: 2px solid var(--win-line);
    border-radius: 7px;
    cursor: pointer;
}
.rn-go:hover, .rn-go:focus-visible { border-color: var(--gold); color: var(--gold); outline: none; }
@media (prefers-reduced-motion: reduce) {
    .rn-phone { animation: none; transform: rotate(-90deg); }
}

/* ---------- draft badge ---------- */
.saga-badge {
    position: fixed;
    top: max(10px, env(safe-area-inset-top));
    left: max(10px, env(safe-area-inset-left));
    z-index: 39;
    background: rgba(40, 8, 24, 0.85);
    border: 2px solid var(--rose);
    border-radius: 6px;
    color: var(--rose);
    font-size: var(--px-1, 8px);
    letter-spacing: 1px;
    padding: 6px 8px 5px;
}

@media (prefers-reduced-motion: reduce) {
    .saga-actor, .saga-actor svg { animation: none !important; }
}

/* ---- actor MOTION: idle movement that loops (2026-08-23) ----
   Pose lives in the sprite's frames; POSITION lives here. That split is what
   every sprite engine does, and doing without it is why a critter drawn to
   travel inside its own grid could only ever shuttle between two spots — the
   8-frame cap is the whole budget.

   On the SVG, not the wrap: the wrap's `animation` is already spoken for by the
   `enter-*` entrance, and `animation` is a shorthand, so a second rule there
   would clobber it. (An actor with a walk-in entrance also animates its svg for
   the step-bob; keep `motion` off walkers or the later rule wins.)

   `steps`-like holds, not a slide: the position JUMPS while the frog is
   AIRBORNE and holds between, so the travel reads as part of the hop instead of
   ice-skating. Each animation is two sprite cycles (2 x 3560 = 7120ms), and the
   jumps sit at the MIDPOINT of each apex frame — not its leading edge — so ~90ms
   of setTimeout drift either way still lands mid-air.
   THE TWO ANIMS HAVE DIFFERENT CYCLE LENGTHS AND DIFFERENT DURATIONS — that is
   the whole point of the pair. `hop` is 3560ms and `hop-b` 3260ms, so two frogs
   drift apart continuously instead of merely starting offset (an earlier pair
   both ran 3560ms and locked into unison after the first hop, which is the
   "one object drawn twice" artifact this exists to avoid). Each animation spans
   two sprite cycles: 7.12s and 6.52s.
   Apex midpoints — hop 22.89 / 46.49 / 72.89 / 96.49, hop-b 17.33 / 46.17 /
   67.33 / 96.17. Derive from the sprite, never by eye: tools/gen_critters.py
   owns the timings. Both paths return to 0 so the loop is seamless. */
@keyframes saga-hop-about {
    0%,      22.88% { transform: translateX(0); }
    22.89%,  46.48% { transform: translateX(-58%); }
    46.49%,  72.88% { transform: translateX(34%); }
    72.89%,  96.48% { transform: translateX(-22%); }
    96.49%, 100%    { transform: translateX(0); }
}
@keyframes saga-hop-about-b {
    0%,      17.32% { transform: translateX(0); }
    17.33%,  46.16% { transform: translateX(46%); }
    46.17%,  67.32% { transform: translateX(-30%); }
    67.33%,  96.16% { transform: translateX(20%); }
    96.17%, 100%    { transform: translateX(0); }
}
/* THREE FROG LIVES, not one loop. Each of the three `seam-*` presets is one
   frog's whole existence: it waits, squeezes out of the rift, falls, hops about
   the cave floor, and finally HOPS OUT OF FRAME — the stage clips at its edges,
   so leaving is a real exit rather than a blink. Then an empty beat covers the
   reset and it comes through again.

   Emerging is in the sprite (the gap does not move); falling and wandering are
   the element's. ease-in on the drop because it is gravity; steps(1, end) on
   every hop so the position JUMPS while the frog is airborne instead of
   ice-skating between spots.

   THE FALL IS DIAGONAL, and that is art direction: each lands clear of the
   dialogue window's 1080p band (art cols 49.6-110.4), because a straight drop
   from the seam put the landing at art col 73.6 where the royal window covers
   it on every tier below 1440p — the payoff played behind the box. `seam` and
   `seam-c` land left, `seam-b` lands right.

   The three differ in BOTH their empty lead (1 / 3 / 5 beats) and their number
   of floor hops (4 / 4 / 5), so they neither arrive together nor ever fall back
   into step: 10.64s / 12.04s / 15.16s. Every percentage below is DERIVED from
   the sprite's cumulative frame times, never eyeballed — tools/gen_critters.py
   owns those timings, and hand-copying them has already caused two bugs. */
@keyframes saga-seam {
    0%     { transform: translate(0, 0); animation-timing-function: steps(1, end); }
    12.97% { transform: translate(0, 0); animation-timing-function: ease-in; }
    15.60% { transform: translate(-421%, 174%); animation-timing-function: steps(1, end); }
    30.92% { transform: translate(-501%, 174%); animation-timing-function: steps(1, end); }
    47.09% { transform: translate(-395%, 174%); animation-timing-function: steps(1, end); }
    63.25% { transform: translate(-581%, 174%); animation-timing-function: steps(1, end); }
    79.42% { transform: translate(-1248%, 174%); animation-timing-function: steps(1, end); }
    93.42% { transform: translate(0, 0); }
    100%   { transform: translate(0, 0); }
}
@keyframes saga-seam-b {
    0%     { transform: translate(0, 0); animation-timing-function: steps(1, end); }
    23.09% { transform: translate(0, 0); animation-timing-function: ease-in; }
    25.42% { transform: translate(592%, 174%); animation-timing-function: steps(1, end); }
    38.95% { transform: translate(699%, 174%); animation-timing-function: steps(1, end); }
    53.24% { transform: translate(539%, 174%); animation-timing-function: steps(1, end); }
    67.52% { transform: translate(779%, 174%); animation-timing-function: steps(1, end); }
    81.81% { transform: translate(1352%, 174%); animation-timing-function: steps(1, end); }
    94.19% { transform: translate(0, 0); }
    100%   { transform: translate(0, 0); }
}
@keyframes saga-seam-c {
    0%     { transform: translate(0, 0); animation-timing-function: steps(1, end); }
    27.57% { transform: translate(0, 0); animation-timing-function: ease-in; }
    29.42% { transform: translate(-528%, 174%); animation-timing-function: steps(1, end); }
    40.17% { transform: translate(-608%, 174%); animation-timing-function: steps(1, end); }
    51.52% { transform: translate(-475%, 174%); animation-timing-function: steps(1, end); }
    62.86% { transform: translate(-688%, 174%); animation-timing-function: steps(1, end); }
    74.21% { transform: translate(-848%, 174%); animation-timing-function: steps(1, end); }
    85.55% { transform: translate(-1248%, 174%); animation-timing-function: steps(1, end); }
    95.38% { transform: translate(0, 0); }
    100%   { transform: translate(0, 0); }
}
.saga-actor.motion-hop-about svg { animation: saga-hop-about 7.12s linear infinite; }
.saga-actor.motion-hop-about-b svg { animation: saga-hop-about-b 6.52s linear infinite; }
.saga-actor.motion-seam svg { animation: saga-seam 10.64s linear infinite; }
.saga-actor.motion-seam-b svg { animation: saga-seam-b 12.04s linear infinite; }
.saga-actor.motion-seam-c svg { animation: saga-seam-c 15.16s linear infinite; }

/* ---- the window withdraws for a held frame (2026-08-22) ----
   The royal window is the reading surface, not part of the picture. On wide
   screens it floats over the bottom-centre of the frame, and during a scene's
   `hold` and its entrance pause it was sitting there EMPTY — covering the
   bottom fifth of exactly the shots the author held the camera on. It now
   withdraws until the first line types, so a held frame and a camera dive
   play as full-frame shots, and a wordless beat never shows a box at all.

   ONLY IN THE OVERLAY LAYOUT. In the stacked layout (portrait, and any
   landscape screen <=640px tall) the dock is a normal flex item below the
   picture and the window covers nothing, so withdrawing it would trade no
   benefit for a ~150px blank reserved band at every scene change. The query
   below must stay character-identical to player.js's OVERLAY_MQ.

   The away direction is a CUT, not a fade: per the Transitions spec the
   after-change style's `transition` governs, so `transition: none` on `.away`
   makes arriving at the hidden state instant while LEAVING it still uses the
   0.38s fade above. That is what lets swap() hide it under the crossfade's
   black without the text visibly dissolving first.

   No `pointer-events: none`: an invisible box is still the hit-shield that
   routes a tap in that band up to #stage and advances the scene. Without it
   the tap falls through to the cast underneath, whose nameplate handler calls
   stopPropagation, and the scene silently refuses to advance. */
@media (orientation: landscape) and (min-height: 641px) {
    .saga-play .dialog { transition: opacity 0.38s ease; }
    .saga-play .dialog.away { opacity: 0; transition: none; }
}
/* The speaker cue arrives WITH the window rather than 0.38s ahead of it: an
   unfilled nameplate hanging over an actor with no speaker tab beneath it is
   precisely the "unexplained third speaker" the treatment exists to prevent.
   A transition, not an animation — `animation` is a shorthand and would
   clobber the bob that .saga-cue and .cue-masked .cue-caret already carry.
   setSpeakerCue drives it (it already forces the reflow for the clamp). */
.saga-cue { transition: opacity 0.38s ease; }

/* ============================================================
   THE WITHHELD SPEAKER — an unfilled name slot      (2026-08-22)

   A scene may refuse to name who is talking. Text alone could not carry it:
   the owner played a bare "???" tab and read it as an unexplained THIRD
   speaker rather than as a name being held back. So the refusal is drawn
   TWICE, as one repeated figure — glyphs floating in a box sized for a name,
   over a dashed rule you would write the name on. Once in the reading
   window's speaker tab, once over the figure's head, in the exact place the
   character nameplate would hang if the film would let him have one.

   NO HUE IS LOAD-BEARING. Four carriers, only one of which is colour:
   the tab changes SHAPE (one field becomes two, split by a solid rule); a
   DASHED RULE sits under an empty slot; the slot is visibly WIDER THAN ITS
   CONTENTS against the tight-fitting JAY / THE SIGNAL tabs everywhere else;
   and the words SOURCE and ??? say it outright. It survives greyscale, and
   it survives every animation being removed.

   Deliberately NOT here: corner brackets framing the figure. On the overlay
   layout the dialogue window covers the bottom of the picture, so a
   top-left/bottom-right pair loses one bracket entirely, and sizing them in
   percentages resolves width and height against different bases. The plate
   and the tab carry the whole meaning; a half-drawn frame is worse than none.
   ============================================================ */

/* ---- the speaker tab: a name, or a two-field readout with a blank ---- */
.dialog-tab[data-mask] {
    display: inline-flex;
    align-items: stretch;
    padding: 0;                       /* the two fields carry their own */
    overflow: hidden;                 /* clip the fields to the 5px radius */
    white-space: nowrap;
    /* the OUTER frame stays solid white, exactly like every other tab in the
       film: "unresolved" is said by the blank inside, never by damaging the
       chassis — a dashed border on a radius reads as breakage, not intent */
}
.dialog-tab .tab-kicker {
    position: relative;
    overflow: hidden;
    display: flex;
    align-items: center;
    padding: 3px 7px 2px;
    font-size: 8px;                   /* the house small size (.hud-btn) */
    letter-spacing: 0.08em;
    color: var(--glint);
    background: rgba(4, 4, 28, 0.72); /* its own inset ground: this cyan is a
                                         FIELD, never a word in the prose, so
                                         it cannot read as dialogue emphasis */
    border-right: 2px solid var(--frame);   /* the divider IS a second cue */
}
/* the carrier sweep: the instrument is tuning. Scoped to the kicker field so
   it never washes across the glyphs or touches their contrast. */
.dialog-tab .tab-kicker::after {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: linear-gradient(180deg,
        rgba(128, 232, 255, 0) 0 34%,
        rgba(128, 232, 255, 0.20) 46%,
        rgba(232, 250, 255, 0.34) 50%,
        rgba(128, 232, 255, 0.20) 54%,
        rgba(128, 232, 255, 0) 66% 100%);
    animation: cc-tune-roll 2.6s linear infinite;
}
/* rolls UP, the way a CRT loses vertical hold. 150% clears a box of any
   height at both ends; the lit band crosses in about 1s of the 2.6s cycle, so
   it is a sweep every couple of seconds, never a strobe. */
@keyframes cc-tune-roll {
    from { transform: translateY(150%); }
    to   { transform: translateY(-150%); }
}
/* A SLOT SIZED FOR A NAME. box-sizing is border-box globally (quest.css), so
   9.5em = 85.5px less 18px padding leaves a 67.5px content box holding about
   36px of glyphs — a box nearly twice its contents. This is the load-bearing
   "withheld, not missing" cue and it is pure geometry. */
.dialog-tab .tab-slot {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    min-width: 9.5em;
    padding: 3px 9px 2px;
    font-size: 9px;
    letter-spacing: 0.34em;           /* floats wider than a word would */
    color: var(--gold);
}
/* the blank you would write the name on. A STRAIGHT 2px dashed rule, so no
   engine's dash-around-a-corner-radius guesswork is involved. --gold-deep on
   --win-bot is 5.6:1, past the 3:1 a graphical object needs, and it stays
   quieter than the glyphs above it. */
.dialog-tab .tab-slot::before {
    content: '';
    position: absolute;
    left: 7px;
    right: 7px;
    bottom: 2px;
    border-top: 2px dashed var(--gold-deep);
}

/* ---- the stage half: an unfilled nameplate over the head ---- */
.saga-cue.cue-masked {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
    /* the PLATE holds still and only the caret bobs. A bordered plate hopping
       twice a second over a hero's head is a game HUD marker, not cinema. */
    animation: none;
}
.saga-cue.cue-masked .cue-caret { animation: saga-cue-bob 1.1s steps(2) infinite; }
.saga-cue .cue-caret { display: block; }
/* deliberately .saga-nameplate's own chassis at .np-name's size: this is the
   plate the character is being denied, shown empty */
.cue-slot {
    position: relative;
    display: block;
    min-width: 7.5em;
    padding: 4px 8px 5px;
    background: rgba(6, 6, 34, 0.94);
    border: 2px solid var(--frame);
    border-radius: 5px;
    font-size: 9px;
    letter-spacing: 0.34em;
    text-align: center;
    color: var(--gold);
    /* a hard edge under the glow so the glyphs stay crisp over a lit CRT
       wall, the brightest thing in this shot */
    text-shadow: 0 0 6px rgba(248, 204, 80, 0.75), 1px 1px 0 rgba(0, 0, 20, 0.85);
}
.cue-slot::before {
    content: '';
    position: absolute;
    left: 7px;
    right: 7px;
    bottom: 3px;
    border-top: 2px dashed var(--gold-deep);
}
/* a masked actor answers nothing on hover or tap, so it must not advertise
   that it will (dressActors attaches no identify handlers to it) */
.saga-actor.masked { cursor: default; }

/* screen readers get the sentence the picture is making */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
    /* removed, not paused: a bright band parked mid-field would read as a
       rendering artifact. Everything that carries meaning here is static
       paint, so nothing is lost. */
    .dialog-tab .tab-kicker::after { display: none; }
    .saga-cue.cue-masked .cue-caret { animation: none; }
}
