/* ============================================================================
   Screen-type layout styles. One block per renderer.
   Currently: pptSlide (slideStage) — a PowerPoint-rendered image as the stage,
   with React-driven caption, character, hotspot, choice, and feedback overlays.
   Visual composition lives in PowerPoint; this file only positions the
   interactive/accessible overlays on top of it. Do not restyle the artwork.
   ============================================================================ */

/* --- stage: fills the CoursePlayer content zone --- */
.ppt-stage { position: absolute; inset: 0; overflow: hidden; background: var(--surface-stage); }
/* Decision 0.19 (sizes pass, 2026-09-15): the narrative stage keeps its AUTHORED composition, 1920 × 821
   (geometry.chrome.stage-h), CENTRED in the zone between the chrome's bands — never stretched to it — so no
   authored percentage (character slots, bubble boxes, hotspots) moves when a theme shortens the bottom bar.
   In classic the zone IS 821 (1080 − 108 − 151): top computes to 0 and height to the layer's own — the
   same box `inset: 0` gave (measured: the box probe, 2026-09-15). RYDCO's bar is 108, the zone 864: the
   stage sits 21.5 px down. The flow shell's rule below (higher specificity) re-flows the stage and wins. */
.mv-screen-layer > .ppt-stage { top: calc((100% - var(--chrome-stage-h)) / 2); bottom: auto; height: var(--chrome-stage-h); }
.ppt-bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; display: block; background: var(--navy-d8); }

/* Responsive mode (tablet/phone): a NARRATIVE slide keeps its fixed 1920x821 composition
   (CLAUDE.md — only template layouts reflow). The ResponsiveShell content area is portrait,
   so without this the absolutely-inset stage stretches to the viewport and the WHOLE scene —
   background, characters (which fill their wrapper 100%), and bubble positions — distorts
   vertically. Aspect-lock the stage to a proportional band instead: everything then scales
   together, and each pose canvas fills its wrapper at the correct aspect (no distortion). */
html.responsive .ppt-stage, body.responsive .ppt-stage {
  position: relative; inset: auto; width: 100%; height: auto; aspect-ratio: var(--chrome-stage-ratio);
}

/* ============================================================================
   Comic two-shot layout — responsive (tablet/phone) NARRATIVE scenes only. Comic panel with both
   characters' heads on top (speaker grows to ~2/3, their line types on in step with the clip),
   the live scene below, inside the ResponsiveShell chrome. Desktop renders the full stage instead.
   ============================================================================ */
.ppt-comic-layout {
  display: flex; flex-direction: column; min-height: 0;
  height: calc(100dvh - var(--rc-header-h, var(--chrome-flow-header-h)) - var(--chrome-flow-footer-h));   /* fill between the header and bottom bar */
}
/* top: signal-blue comic panel. The bottom scene gets the larger share of the height —
   the heads + bubble read fine smaller, and the live scene is what the learner watches. */
.ppt-comic {
  /* Avatar sizes for the NARRATIVE panel (ComicPanel / Observe). Declared here so the pinned
     circles, their inset, and the scroller's fade band all read ONE definition instead of three
     copies of the same clamp. Inert for ComicThread and the Tabs modal, which size their square
     heads from --comic-head-size below and never reference these. */
  /* THE bottom gap between the newest chip and the floor of the chat column. ONE value for all
     seven chat screens — see the note below; it is consumed by .ppt-comic-live (ComicPanel,
     Observe, the conversation modal) AND by .cvo-thread-scroll (the decision screen, which does
     not use .ppt-comic-live). Change it here and nowhere else. */
  --comic-chip-gap: .9vh;
  --av-listen: clamp(34px, min(12.2vw, 9.6vh), 68px);
  --av-speak:  clamp(48px, min(17.8vw, 14vh), 96px);
  flex: 1 1 45%; min-height: 0; position: relative; overflow: hidden;
  display: flex; align-items: stretch; gap: 2vw; padding: 1.4vh 3vw 2.2vh;
  background:
    var(--grad-halftone) 0 0 / 13px 13px,
    var(--grad-comic-plate);
  border-bottom: var(--border-w4) solid var(--bar-deep);
}
/* Each side is a grid: the bubble occupies the flexible top row, head + name pin to the bottom.
   The top row is minmax(0, 1fr) — capped at its fair share, NOT allowed to grow with the bubble —
   so a long (multi-line) speaker line can never push that head lower than the silent listener's.
   Both heads stay bottom-aligned regardless of line length. */
.ppt-comic-side {
  /* The VISIBLE head frame size is part of the shared speaker state — not left to the flex side's
     width, which the old `min(90%, 190px)` head cap defeated (on tablet both sides exceed 211px, so
     both heads stayed 190px even as the speaker's side grew: the layout grew, the frame didn't).
     Listener size by default; the speaker override below enlarges it, transitioned in lock-step with
     the side's flex-grow so intro-01, Observe, and intro-02 share ONE size+motion model. */
  --comic-head-size: clamp(105px, 23vw, 150px);
  flex: 1 1 0; min-width: 0;
  display: grid; grid-template-rows: minmax(0, 1fr) auto auto; justify-items: center; row-gap: var(--space-p6);
  transition: flex-grow var(--dur-t400) var(--ease-b-3-7-3-1);
}
.ppt-comic-side.is-speaking { --comic-head-size: clamp(150px, 32vw, 210px); flex-grow: 2; }  /* speaker ~2/3 + bigger head */
/* square comic panel: bold black outer line + crisp white inner keyline. Width-sized (capped) so
   the speaker's wider panel gives a bigger head; height follows via aspect-ratio. */
/* head + its floating bubble; pinned to the bottom of the side by the grid's 1fr top row */
.ppt-comic-headwrap {
  grid-row: 2; position: relative; width: 100%;
  display: flex; justify-content: center; align-items: flex-end;
}
.ppt-comic-head {
  /* width is the plain custom-property length (NOT min(100%, …)): Chrome won't interpolate a
     `width` transition whose value is a min()/clamp() mixing a percentage with a var, so the head
     froze at the listener size. A bare var() animates cleanly; max-width:100% keeps the overflow
     guard without entering the transition path. */
  width: var(--comic-head-size); max-width: 100%; aspect-ratio: 1; border-radius: var(--radius-sm); overflow: hidden; position: relative;
  background: var(--grad-comic-disc);
  border: var(--border-w5) solid var(--black); box-shadow: 0 8px 18px var(--ink-deep-a40);
  transition: width var(--dur-t400) var(--ease-b-3-7-3-1), filter var(--dur-t350) var(--ease-default), opacity var(--dur-t350) var(--ease-default);
}
/* white inner keyline drawn on a layer BENEATH the portrait, so the shoulders paint OVER its
   bottom edge (the intended "superimposed in front" look). It used to live in the head's own
   `box-shadow: inset` — but a positioned/transformed portrait paints above that shadow while an
   un-transformed one does not, so a flipped head (Sue) covered the keyline and a non-flipped one
   (Loretta) had the keyline render on top of her shoulders. Splitting it out + z-index makes the
   layering identical for every head, flipped or not. */
.ppt-comic-head::before {
  content: ""; position: absolute; inset: 0; border-radius: inherit; z-index: var(--z-l0); pointer-events: none;
  box-shadow: inset 0 0 0 3px var(--white);
}
.ppt-comic-head img { position: absolute; inset: 0; z-index: var(--z-l1); width: 100%; height: 100%; object-fit: contain; object-position: center bottom; display: block; }
/* flip on the IMG, not the head: the frame is symmetric so it looks identical, but it frees the head's
   own transform for the branch-expand translateX (Loretta's right-edge lock) without clobbering her flip. */
.ppt-comic-head.flip img { transform: scaleX(-1); }
.ppt-comic-side:not(.is-speaking) .ppt-comic-head { filter: saturate(.7) brightness(.82); opacity: var(--alpha-90); }
.ppt-comic-name {
  grid-row: 3; margin-top: var(--space-p6); font-weight: var(--fw-extrabold); letter-spacing: var(--track-title); color: var(--text-on-navy); text-transform: uppercase;
  font-size: var(--type-comic-name); text-shadow: 0 1px 0 var(--bar-deep), 0 2px 4px var(--ink-deep-a40);
}
/* speaker's bubble: fills the flexible top row, pinned to its bottom so its tail sits just above the head */
.ppt-comic-bubble {
  /* floats above the head (out of flow) so it can't affect head alignment; centered over the head,
     ~2/3-panel wide via viewport units so a long line wraps to few lines instead of pushing down */
  position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
  width: min(62vw, 440px);
  background: var(--surface-card); color: var(--ink); border: var(--border-comic) solid var(--bar-deep); border-radius: var(--radius-r15); padding: var(--space-p8) var(--space-p12);
  font-weight: var(--fw-bubble); line-height: var(--lh-title); font-size: var(--type-comic-bubble);
  box-shadow: 0 6px 16px var(--ink-deep-a35); text-align: left;
}
.ppt-comic-bubble::after  { content: ""; position: absolute; top: 100%; left: var(--space-p34); width: 0; height: 0; border: 11px solid transparent; border-top-color: var(--bar-deep); }
.ppt-comic-bubble::before { content: ""; position: absolute; top: calc(100% - 4px); left: var(--space-p37); width: 0; height: 0; border: 8px solid transparent; border-top-color: var(--surface-card); z-index: var(--z-l1); }
.ppt-comic-side:last-child .ppt-comic-bubble::after  { left: auto; right: var(--space-p34); }
.ppt-comic-side:last-child .ppt-comic-bubble::before { left: auto; right: var(--space-p37); }
.ppt-comic-caret { display: inline-block; width: var(--size-p2); height: 1.05em; margin-left: var(--space-p1); background: var(--signal); vertical-align: text-bottom; animation: comicBlink var(--dur-t1000) var(--ease-step) infinite; }
@keyframes comicBlink { 50% { opacity: 0; } }
/* Conversation Decision comic panel: heads row on top, then the current line, then (ask phase) the
   choices as a vertical tappable stack. Column layout + scroll so a long stack never overflows. */
.cvo-comic { flex-direction: column; align-items: stretch; gap: 1.2vh; overflow-y: auto; padding: 1.4vh 4vw 2vh; }
.cvo-comic-heads { flex: 0 0 auto; display: flex; justify-content: center; gap: 6vw; }
.cvo-comic-heads .ppt-comic-side { flex: 0 1 auto; grid-template-rows: auto auto; }
.cvo-comic-heads .ppt-comic-head { width: min(26vw, 112px); }
.cvo-comic-body { flex: 1 1 auto; display: flex; flex-direction: column; gap: 1.1vh; }
.cvo-comic-say {
  background: var(--surface-card); color: var(--ink); border: var(--border-comic) solid var(--bar-deep); border-radius: var(--radius-r14); padding: var(--space-p9) var(--space-p13);
  font-weight: var(--fw-semibold); line-height: var(--lh-subtitle); font-size: var(--type-comic-say); box-shadow: 0 5px 14px var(--ink-deep-a30);
}
.cvo-comic-who { color: var(--signal); text-transform: uppercase; letter-spacing: var(--track-caption); }
.cvo-comic-options { display: flex; flex-direction: column; gap: 1vh; margin-top: .4vh; }
.cvo-comic-option {
  min-height: var(--hit-target); text-align: left; background: var(--surface-card); color: var(--ink);
  border: var(--border-comic) solid var(--bar-deep); border-radius: var(--radius-r14); padding: var(--space-p10) var(--space-p14);
  font-weight: var(--fw-bubble); line-height: var(--lh-snug); font-size: var(--type-comic-say);
  cursor: pointer; box-shadow: 0 3px 0 var(--bar-deep-a55); transition: box-shadow var(--dur-t150) var(--ease-default), border-color var(--dur-t150) var(--ease-default);
}
/* PULSES LIKE ITS DESKTOP COUNTERPART (author 2026-07-31: every clickable speech bubble in the
   course signals the same way). On desktop the Conversation Decision's choice bubbles already
   pulsed (.cvo-choice, cvoPulse); these are the same choice on a phone, and they sat static —
   the one inconsistency in the affordance. Its own keyframe because these are comic-styled
   boxes, not transparent bubble art: the pulse rides ON TOP of the 3px comic drop-shadow rather
   than replacing it, which a shared keyframe would have done. */
.cvo-comic-option { animation: cvoComicPulse var(--dur-pulse) var(--ease-in-out) infinite; }
@keyframes cvoComicPulse {
  0%, 100% { box-shadow: 0 3px 0 var(--bar-deep-a55), 0 0 0 2px var(--signal-a20); }
  50%      { box-shadow: 0 3px 0 var(--bar-deep-a55), 0 0 0 7px var(--signal-a45); }
}
/* A running animation outranks a static declaration, so hover/focus must STOP it or their
   own ring never appears. Same reason the reduced-motion rule below turns it off. */
.cvo-comic-option:hover, .cvo-comic-option:focus-visible {
  animation: none;
  border-color: var(--signal); box-shadow: 0 0 0 3px var(--signal-a45); outline: none;
}
@media (prefers-reduced-motion: reduce) {
  .cvo-comic-option { animation: none; box-shadow: 0 3px 0 var(--bar-deep-a55), 0 0 0 3px var(--signal-a35); }
}
.cvo-comic-option:active { transform: translateY(1px); box-shadow: 0 1px 0 var(--bar-deep-a55); }

/* A decision on the narrative comic panel: the panel renders as normal (pinned chat circles +
   the message column) and the choices stack BELOW it while the clock is held at the decision.
   Column + scroll so a long stack never overflows the panel.
   The old `.ppt-comic-decide .ppt-comic-heads` rule was removed 2026-08-08 with the head row it
   sized — ComicPanel no longer renders one. This is NOT the Conversation Decision (intro-02),
   which is ComicThread and keeps its full-size heads. */
.ppt-comic-decide { flex-direction: column; gap: 1.1vh; overflow-y: auto; }
/* ── narrative comic panel: CHAT CIRCLES + a message column (intro-01 / Observe) ──────────────
   REDESIGNED 2026-08-08 (author). This panel used to be two large square head frames sitting on
   the floor of the scroller with the live line floating ABOVE the speaker's head. On a 360x646
   phone the speaker's frame alone was 150px of a 222px panel — `clamp(150px, 32vw, 210px)` is
   sized off WIDTH only, so the 150px floor won on a narrow screen — leaving the bubble ~30px to
   grow up into when one line needs 36px. Every line clipped (measured in
   documentation/template-breakpoint-matrix.md §4).

   Now: the speakers are small circles PINNED in the panel's top corners, out of the message flow
   entirely, and the lines are ordinary chips in a bottom-anchored column. Consequences worth
   knowing before editing:
     * The 2026-07-15 ruling (a long line must never push the speaker's head below the listener's)
       holds by CONSTRUCTION — nothing in the column can move an avatar.
     * The live chip is IN FLOW, so it contributes to scrollHeight and the scroller's existing
       stick-to-bottom chase carries it into view. The absolute bubble never could: a scroll
       container's scrollable overflow does not extend past its block-start edge.
     * .ppt-comic-side / -head / -headwrap / -name / -bubble are DELIBERATELY untouched below —
       ComicThread (the Conversation Decision) and the Tabs conversation modal still use them.
   ──────────────────────────────────────────────────────────────────────────────────────────── */
.ppt-comic-log { display: flex; flex-direction: column; padding: 0; }   /* the scroller owns the panel, flush under the header */

/* Avatar sizes. Sized off min(vw, vh) with a floor, NOT width alone — the width-only clamp is
   exactly what broke the old heads on a short viewport (CLAUDE.md §33, question 2). At 360x646
   these resolve to the author's 64 / 44; the vh term only bites on a landscape phone. Kept in a
   custom property because Chrome will not interpolate a width transition whose value mixes a
   percentage with a var (the bug that once froze the old head at the listener size). */
.ppt-comic-avatars {
  position: absolute; z-index: var(--z-l3); top: var(--space-p10); left: var(--space-p12); right: var(--space-p12);
  display: flex; align-items: flex-start; justify-content: space-between;
  pointer-events: none;   /* must never eat the drag that scrolls the history */
}
.ppt-comic-av {
  --av-size: var(--av-listen);
  /* CLIPPED to the circle. The head is meant to FILL the frame — that is how both files were
     composed — and Loretta's afro spills 45% past her own circle where Sue's does not. Clipping
     crops that spill and both heads read at the same size. The alternative, scaling her to
     contain the hair, was tried on 2026-08-08 and rejected by the author: it shrank her FACE and
     left her forehead on the centre point. Hair is not the subject. */
  position: relative; overflow: hidden; width: var(--av-size); height: var(--av-size);
  border-radius: var(--radius-circle); flex: 0 0 auto;
  /* the supplied art is a circle-CLIPPED cutout with no background of its own, so the comic disc
     is drawn here, behind it (author's "permanent comic book background behind the circle") */
  background:
    var(--grad-halftone-fine) 0 0 / 7px 7px,
    var(--grad-comic-disc-av);
  box-shadow: 0 0 0 3px var(--white), 0 0 0 6px var(--bar-deep), 0 6px 14px var(--ink-deep-a45);
  filter: saturate(.7) brightness(.82); opacity: var(--alpha-90);           /* the listener treatment, unchanged from the old heads */
  transition: width var(--dur-t400) var(--ease-b-3-7-3-1), height var(--dur-t400) var(--ease-b-3-7-3-1),
              filter var(--dur-t350) var(--ease-default), opacity var(--dur-t350) var(--ease-default);
}
.ppt-comic-av.is-speaking { --av-size: var(--av-speak); filter: none; opacity: 1; }
/* 568/352 = the registration constant from authoring/scripts/build_avatars.py: every avatar is
   normalized so its clip circle is 352px across a 568px canvas, centred. That is what lets ONE
   rule size both characters identically despite their source files disagreeing (Loretta's circle
   is 81% of her canvas and sits 45px low; Sue's is 99.7% and dead centre). Change one, change
   both. overflow stays visible so hair that breaks the circle still breaks it. */
.ppt-comic-av img {
  position: absolute; left: 50%; top: 50%; width: 161.36%; height: 161.36%;
  transform: translate(-50%, -50%); display: block; pointer-events: none;
}
@media (prefers-reduced-motion: reduce) { .ppt-comic-av { transition: none; } }

/* the whole panel is one bottom-anchored SCROLLER: finished lines stack above, the line being
   spoken is the last chip at the bottom. overflow-y auto + a top auto-margin pin it to the bottom
   and let older lines slide up (scroll up to re-read). */
.ppt-comic-scroll {
  --comic-top: calc(var(--av-speak) + 18px);   /* the pinned-avatar band + clearance */
  flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  display: flex; flex-direction: column;
  /* thin, not hidden: a phone draws an overlay scrollbar anyway, but a Windows laptop at <=1024
     gets the classic one with arrow buttons drawn straight over the halftone. Kept visible
     because it is the only cue that there IS history above to scroll back to. */
  scrollbar-width: thin; scrollbar-color: var(--white-a42) transparent;
  /* NO fade mask. One was tried on 2026-08-08 — history dissolving as it rose behind the circles
     — and the author rejected it: a chip level with an avatar sat at partial opacity and read as
     half-erased text rather than as a message passing behind a head. The circles are opaque and
     sit above the column, so a line simply goes UNDER them ("I'm fine with that"). Do not
     reintroduce a mask here; the top padding below is what keeps the oldest line readable. */
}
.ppt-comic-scroll > :first-child { margin-top: auto; }
/* padding-top clears the pinned circles: it is what lets the learner scroll the OLDEST line out
   from under them and read it. Without it the first thing said could never be fully uncovered. */
.ppt-comic-history-log {
  display: flex; flex-direction: column; gap: .8vh;
  padding: var(--comic-top) 4vw .4vh;
}
/* the live line — same chip as the history, so a finished line does not change appearance when it
   joins the log. Side padding matches the history column. */
.ppt-comic-live { padding: 0 4vw var(--comic-chip-gap); }
.ppt-comic-live:first-child { padding-top: var(--comic-top); }   /* no history yet: keep it clear of the avatars */
.ppt-comic-live .cvo-th-bubble { animation: none; }              /* it grows by typing, it does not pop in */

/* WHO IS TALKING (author 2026-08-08). A chip was allowed to run the full width of the panel, so a
   left-hand line reached all the way under the right-hand avatar and the speaker became
   ambiguous. Two fixes, together: cap the width so a chip can never cross to the other side, and
   give every chip a tail pointing at its speaker's circle. `.cvo-th-bubble` is SHARED with the
   Conversation Decision's thread, so both are scoped to .ppt-comic-scroll — which only ComicLog
   mounts (ComicThread has its own .cvo-thread-scroll) — and leave that screen alone. */
/* max-width 100%, NOT a second cap. `.cvo-th-line` is already capped at 82%, which at 360px lands
   the chip's far edge at 285px — the speaking circle's inner edge is 284px, so the row cap alone
   stops a chip running under the opposite avatar exactly as intended. A 72% cap on the bubble sat
   on TOP of that and compounded to ~59%, so chips barely crossed the halfway point (author
   2026-08-08: "they should be expanding at least to the left edge of Sue's head circle"). */
.ppt-comic-scroll .cvo-th-bubble { position: relative; max-width: 100%; }
.ppt-comic-scroll .cvo-th-line.is-left,
.ppt-comic-scroll .ppt-comic-live.is-left  { justify-content: flex-start; }
.ppt-comic-scroll .cvo-th-line.is-right,
.ppt-comic-scroll .ppt-comic-live.is-right { justify-content: flex-end; }
/* the tail: an outer triangle in the chip's border colour, with a smaller white one inset over it
   by the 3px border — the same two-triangle construction the stage bubbles use */
.ppt-comic-scroll .cvo-th-line .cvo-th-bubble::before,
.ppt-comic-scroll .cvo-th-line .cvo-th-bubble::after {
  content: ""; position: absolute; width: 0; height: 0; pointer-events: none;
}
.ppt-comic-scroll .cvo-th-line .cvo-th-bubble::before { top: var(--space-p12); border: 8px solid transparent; }
.ppt-comic-scroll .cvo-th-line .cvo-th-bubble::after  { top: var(--space-p14); border: 6px solid transparent; }
.ppt-comic-scroll .cvo-th-line.is-left .cvo-th-bubble::before { left: var(--space-n16); border-right-color: var(--bar-deep); }
.ppt-comic-scroll .cvo-th-line.is-left .cvo-th-bubble::after  { left: var(--space-n11); border-right-color: var(--surface-card); }
.ppt-comic-scroll .cvo-th-line.is-right .cvo-th-bubble::before { right: var(--space-n16); border-left-color: var(--bar-deep); }
.ppt-comic-scroll .cvo-th-line.is-right .cvo-th-bubble::after  { right: var(--space-n11); border-left-color: var(--surface-card); }
/* Observe's hinge question is this chip as a real button: it is the only way forward there. */
button.cvo-th-bubble { cursor: pointer; font: inherit; text-align: left; }
button.cvo-th-bubble:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--signal-a55); }
.cvo-decision { display: flex; flex-direction: column; gap: 1vh; flex: 0 0 auto; }
/* WCAG 1.4.3 (2026-09-06): white at <=14px on the Signal-Blue halftone is 2.88:1; navy on the
   same fill is 5.13:1. The text-shadow was the previous mitigation - it helps perceptually but
   WCAG credits no shadow, and a dark shadow under dark text only muddies it, so it goes.
   VERIFIED SAFE FOR DESKTOP BY DRIVING IT, not by reading the CSS: `.ppt-stage > .cvo-decision`
   below positions this over the scene ARTWORK, where navy could be unreadable and the white +
   shadow pairing was probably right. It turns out that case does not exist - on desktop the
   Conversation Decision renders its options as speech bubbles and this heading is never
   rendered at all. It appears only in the comic layout, always on the halftone. */
.cvo-decision-head { font: var(--fw-extrabold) var(--type-comic-decision-head)/var(--lh-title) var(--font-head);
  letter-spacing: var(--track-title); text-transform: uppercase; color: var(--text-heading); text-align: center;
  margin-bottom: .3vh; text-shadow: none; }
/* desktop (non-comic): the choices float over the stage, centered near the bottom */
.ppt-stage > .cvo-decision { position: absolute; left: 50%; bottom: 6%; transform: translateX(-50%);
  width: min(78%, 900px); z-index: var(--z-stage-ui); }

/* ============================================================================
   Conversation Decision as a CHAT-FLOW (tablet/phone) — the Question of Consequences.
   A messaging thread: each dialogue line is a name-labelled chat bubble on its speaker's
   side (Sue LEFT, tinted navy = "your reply"; Loretta RIGHT, white), the newest typing on
   just above a PERSISTENT head row pinned to the BOTTOM (intro-01's shared shift+grow — NO
   rise). The scroll owns overflow, so the active line can never clip; older turns scroll up
   and stay re-readable. The decision = Sue's reply chips at the bottom of the chat, above the
   heads. Replaces the old floor-pane / rise / peek / history-lift machinery (2026-07-20
   redesign; that CSS is archived in documentation/archive/comic-thread-rise-choreography/).
   ============================================================================ */
/* the chat panel takes the larger share (60%); the scene keeps 40% so its figures stay fully visible.
   Scoped to .cvo-thread so intro-01/Observe (ComicPanel) keep their 45/55 split. */
.ppt-comic.cvo-thread { flex: 1 1 60%; flex-direction: column; align-items: stretch; gap: 0; padding: 0; }
.cvo-thread + .ppt-comic-scene { flex: 1 1 40%; }

/* the scrolling thread — bottom-anchored; the active line sits just above the reply band */
.cvo-thread-scroll {
  flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  /* bottom padding is the SHARED --comic-chip-gap, not a local 1vh: this screen does not use
     .ppt-comic-live, so without this it silently drifts away from the other six. */
  display: flex; flex-direction: column; gap: 1vh; padding: 1.4vh 4vw var(--comic-chip-gap);
}
/* bottom-anchor while it fits; once it overflows the top stays reachable — scroll UP to re-read the
   whole conversation. (JS keeps it pinned to the bottom as the active line types, unless you scroll up.) */
.cvo-thread-scroll > :first-child { margin-top: auto; }
/* the pinned-circle clearance goes on the LOG, not the scroller: inside the scrollable content it
   scrolls away, so it costs no panel height. On the scroller it is padding on a flex item and
   becomes a floor under its height — which squeezed the reply band until the third option clipped
   off the bottom (measured at 360x646, 2026-08-08). Same reason .ppt-comic-history-log carries it. */
.cvo-thread-log { display: flex; flex-direction: column; gap: 1vh; padding-top: var(--comic-top); }

/* each turn: a name label + a chat bubble, aligned to (and sized against) its speaker's side */
.cvo-th-line { display: flex; flex-direction: column; max-width: 82%; }
.cvo-th-line.is-left  { align-self: flex-start; align-items: flex-start; }
.cvo-th-line.is-right { align-self: flex-end;   align-items: flex-end; }
.cvo-th-name {
  margin: 0 var(--space-p8) var(--space-p3); font: var(--fw-extrabold) var(--type-comic-th-name)/var(--lh-solid) var(--font-head);
  letter-spacing: var(--track-title); text-transform: uppercase; color: var(--incorrect-pink);
  text-shadow: 0 1px 0 var(--bar-deep-a50);
}
.cvo-th-bubble {
  background: var(--surface-card); color: var(--ink); border: var(--border-comic) solid var(--bar-deep); border-radius: var(--radius-r15);
  padding: var(--space-p8) var(--space-p12); font-weight: var(--fw-bubble); line-height: var(--lh-bubble-th); font-size: var(--type-comic-th-bubble);
  box-shadow: 0 4px 12px var(--ink-deep-a30); animation: cvoThIn var(--dur-t300) var(--ease-standard) both;
}
@keyframes cvoThIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

/* the reply chips — their OWN fixed band between the scrolling chat and the heads (a sibling of the
   scroll, NOT inside it), so the full choice set + the "Choose your response" callout are always visible
   and can never clip off the top. */
.cvo-thread-chips { flex: 0 0 auto; padding: .6vh 4vw .4vh; }
/* ENTRANCE: when the decision opens the callout fades in and the three options SLIDE UP from below,
   staggered (like the old gate reveal) — at the same moment the heads shrink (their shared width
   transition), so the two read as one move. fill-mode `backwards` (NOT forwards): the option holds its
   from-state during its stagger delay, then after settling reverts to its natural opacity — so the pick
   resolve below (chosen highlights / others dim) still takes effect instead of being pinned opaque. */
.cvo-thread-chips .cvo-decision-head { animation: cvoChipHead var(--dur-pop) var(--ease-default) backwards; }
.cvo-thread-chips .cvo-comic-option {
  animation: cvoChipIn var(--dur-t460) var(--ease-b-3-7-3-1) backwards,
             cvoComicPulse var(--dur-pulse) var(--ease-in-out) infinite;   /* entrance THEN pulse, composed */
}
.cvo-thread-chips .cvo-comic-option:nth-child(2) { animation-delay: var(--dur-t90); }   /* child 1 = the callout */
.cvo-thread-chips .cvo-comic-option:nth-child(3) { animation-delay: var(--dur-t175); }
.cvo-thread-chips .cvo-comic-option:nth-child(4) { animation-delay: var(--dur-t260); }
@keyframes cvoChipHead { from { opacity: 0; } to { opacity: 1; } }
@keyframes cvoChipIn { from { opacity: 0; transform: translateY(48px); } to { opacity: 1; transform: none; } }
/* EXIT on pick — plays inside the ~1.75s pick→answer window, before the band unmounts (renderConvDecision
   tags .is-resolved + is-chosen/is-unchosen): the chosen chip briefly highlights then slides UP + fades,
   the unchosen fade + settle down, the callout fades. forwards fill is fine — the whole band is leaving. */
.cvo-decision.is-resolved .cvo-decision-head { animation: cvoChipFade var(--dur-t400) var(--ease-default) forwards; }
.cvo-thread-chips .cvo-comic-option.is-unchosen { pointer-events: none; animation: cvoChipOutUn var(--dur-t500) var(--ease-default) forwards; }
.cvo-thread-chips .cvo-comic-option.is-chosen {
  pointer-events: none; border-color: var(--signal); box-shadow: 0 0 0 3px var(--signal-a50);
  animation: cvoChipOutChosen var(--dur-t800) var(--ease-b-3-7-3-1) forwards;
}
@keyframes cvoChipFade { to { opacity: 0; } }
@keyframes cvoChipOutUn { to { opacity: 0; transform: translateY(10px); } }
@keyframes cvoChipOutChosen { 25% { transform: translateY(0); opacity: 1; } to { opacity: 0; transform: translateY(-46px); } }

/* DURING THE CHOICE, both circles shrink. This is the 2026-07-20 ruling kept, not reversed: while the
   learner is choosing, neither speaker is emphasised and the reply band needs the room. It is also
   load-bearing for readability here — this panel keeps only ~86px of scroller once three options and
   the callout are laid out, which a full-size 64px circle band cannot clear, so the QUESTION (the one
   line that must be read) ran under Loretta's circle. Overriding --comic-top with it keeps the log's
   clearance in step; the default derives from --av-speak, and nothing is speaking while deciding. */
.cvo-thread.is-deciding .ppt-comic-av { --av-listen: clamp(28px, 9vw, 46px); }
.cvo-thread.is-deciding .ppt-comic-scroll { --comic-top: calc(clamp(28px, 9vw, 46px) + 14px); }
/* REAL clearance for the question has to be padding on the SCROLLER, not on the log. The log is
   bottom-anchored by `margin-top: auto`, so its own padding-top travels down with it and sits above
   the first chip wherever that lands — it buys scroll-back headroom, never a clear band at the top.
   Padding here is a floor under the scroller's height, which is exactly what is wanted while the
   question must stay readable, and the panel takes the extra height below rather than the reply
   band losing it. */
.cvo-thread.is-deciding .cvo-thread-scroll { padding-top: var(--comic-top); }
.cvo-thread.is-deciding .cvo-thread-log { padding-top: 0; }
/* the scene yields ~8% while the learner is choosing: it is static at that moment, and this is what
   pays for the clearance above without the third reply option clipping off the bottom. */
.ppt-comic.cvo-thread.is-deciding { flex: 1 1 68%; }
.cvo-thread.is-deciding + .ppt-comic-scene { flex: 1 1 32%; }

/* The persistent BOTTOM head row was removed 2026-08-08 (author, option B): this screen now pins the
   same chat circles as intro-01/Observe in the panel's top corners, so every responsive dialogue
   screen reads alike. Its rules went with it — `.cvo-thread-heads`, the grid-row overrides that
   dropped `.ppt-comic-side`'s bubble row, and the `.is-deciding` head-shrink. `.is-deciding` is still
   set on the panel; it just no longer resizes anything, because during the choice neither circle is
   emphasised anyway (`speaker` is null while deciding, in ComicThread). */

@media (prefers-reduced-motion: reduce) {
  .cvo-th-bubble,
  .cvo-thread-chips .cvo-decision-head,
  .cvo-decision.is-resolved .cvo-decision-head,
  .cvo-thread-chips .cvo-comic-option,
  .cvo-thread-chips .cvo-comic-option.is-unchosen,
  .cvo-thread-chips .cvo-comic-option.is-chosen { animation: none; }
  .cvo-thread-chips .cvo-comic-option.is-unchosen { opacity: var(--alpha-40); }   /* still show the resolve, just no motion */
}

/* (The dev-mode one-shot-reveal overrides that used to live here are gone: dev mode no longer freezes
   one-shot animations at all — templates.css now pauses only the perpetual loops — so the comic reveals
   play normally under dev mode and need no override.) */

/* bottom: the scene, aspect-locked and cover-cropped to the character band; bubbles hidden here.
   The crop keys on the CHARACTERS, not the stage's geometric center: --comic-focus-x (set inline
   per scene from the character-wrapper union, ppt-slide.js → comicSceneFocusX) is the % of stage
   width that should sit at the visible center. A per-scene `comicFocusShift` (JSON) can nudge
   it for compositional framing. Translating the stage by -focus puts that point at the center. */
.ppt-comic-scene { flex: 1 1 55%; min-height: 0; position: relative; overflow: hidden; background: var(--navy-d2); }
body.responsive .ppt-comic-scene .ppt-stage, html.responsive .ppt-comic-scene .ppt-stage {
  position: absolute; inset: auto; top: 50%; left: 50%;
  transform: translate(calc(-1 * var(--comic-focus-x, 50%)), -50%);
  /* height 108% cover-crops to the character band; --comic-zoom (per-scene JSON) zooms in
     further so a scene with closely-placed characters fills the band instead of leaving gaps. */
  width: auto; height: calc(108% * var(--comic-zoom, 1)); aspect-ratio: var(--chrome-stage-ratio);
}
.ppt-comic-scene .dlg-bubble { display: none; }      /* bubbles live in the panel; the hidden <audio> still plays */
@media (prefers-reduced-motion: reduce) { .ppt-comic-side, .ppt-comic-head { transition: none; } }

/* --- character layers: two wrappers so a pose swap can never move the character (§30.18) ---
   .ppt-char-motion  OUTER: canonical wrapper geometry (union of the deck's authored picture
                     boxes, from characterSlots) + the whole-character entrance transform.
   .ppt-char-slot    INNER: fixed at inset:0 — geometrically stable, never moves on a swap.
   .ppt-pose         every normalized pose canvas, filling the slot exactly. NO object-fit /
                     object-position: each PNG is already composited onto the wrapper, and the
                     canvas aspect equals the wrapper aspect, so 100%x100% is pixel-exact. */
.ppt-char-motion { position: absolute; z-index: var(--z-stage-character); pointer-events: none; filter: drop-shadow(0 18px 16px var(--black-a35)); }
.ppt-char-slot { position: absolute; inset: 0; }
.ppt-pose { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

/* --- live prop layers (`object:<name>` in the deck; authoring/scripts/export_props.py) ---
   Static furniture is baked into the background picture, so these are only the props that
   animate or are interactive. Geometry and rotation come from the deck; the exit beat adds its
   own transform inline. `depth` decides z-index relative to the character group (2 = behind). */
.ppt-prop { position: absolute; display: block; pointer-events: none;
            filter: drop-shadow(0 12px 12px var(--black-a32)); }

/* Character entrance is clock-driven (translateX set inline from currentTime in
   ppt-slide.js), so it replays on scrub/restart. Reduced motion snaps to rest.
   .ppt-beat is exempt: under reduced motion the exit beat does not animate, it hard-cuts to its
   END FRAME — and that frame is a transform, so stripping it would undo the beat entirely. */
@media (prefers-reduced-motion: reduce) {
  .ppt-char-motion:not(.ppt-beat) { transform: none !important; }
}

/* --- dialogue captions (accessibility). Always in the DOM for screen readers;
       visible bar toggled by the player's CC control. --- */
.ppt-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

/* VISUALLY HIDDEN, BUT NOT WHEN IT HAS FOCUS (author report 2026-07-29: "it shows up when
   I'm hitting tab").

   The transcript is a <details>, and a <summary> is a real interactive control — so it is in
   the tab order, correctly: a screen-reader user needs to reach it. But hidden at 1x1 with
   clip:rect(0 0 0 0), a SIGHTED keyboard user tabs onto a stop they cannot see, with no focus
   ring and nothing to explain where they are. That is WCAG 2.4.7 (Focus Visible), and it is
   the same defect class as the closed chrome panels and the invisible Submit button found in
   the NVDA pass — reachable but invisible.

   So it un-hides itself while focus is inside: the standard skip-link treatment. Nothing
   changes for the mouse, nothing changes for a screen reader, and the keyboard stop becomes
   a visible, readable control. Constrained in height because an expanded transcript is the
   whole scene's script. */
.ppt-sr:focus-within {
  position: absolute; top: 12px; left: 12px; z-index: var(--z-transcript);
  /* max-content, not auto: absolutely-positioned shrink-to-fit collapsed the box to ~36px
     and wrapped the label one word per line. */
  width: max-content; height: auto; max-width: min(70ch, 78%); max-height: 60vh;
  margin: 0; padding: 12px 16px; overflow: auto;
  clip: auto; clip-path: none; white-space: normal;
  background: var(--surface-stage); color: var(--text-on-navy);
  border: var(--border-w2) solid var(--white); border-radius: var(--radius-card);
  box-shadow: var(--shadow-nav);
  font-family: var(--font-sans); font-size: var(--type-body); line-height: var(--lh-prose);
}
.ppt-sr:focus-within summary { cursor: pointer; font-weight: var(--fw-bold); padding: 2px 0; }
.ppt-captions {
  position: absolute; left: 50%; bottom: var(--space-p24); transform: translateX(-50%);
  width: min(78%, 1180px); z-index: var(--z-stage-ui); display: flex; flex-direction: column; gap: var(--space-p8);
  background: var(--navy-d9-a86); border: var(--border-hairline) solid var(--on-navy-border);
  border-radius: var(--radius-card); padding: var(--space-p16) var(--space-p22); color: var(--text-on-navy);
  font-family: var(--font-sans); box-shadow: var(--shadow-nav);
  animation: mvFadeInSlow var(--dur-t180) var(--ease-default);
}
.ppt-caption-line { font-size: var(--type-lead); line-height: var(--lh-text); margin: 0; }

/* Comic mode (tablet/phone): the caption box is a SIBLING of the cropped stage (see
   ppt-slide.js → PptSlideView), so it positions against the visible scene area and its edges
   are never cropped. It shows ONE line, sized in real px — the stage here is fluid, not
   transform-scaled like desktop, so the desktop 20px would read oversized on a phone. Capped
   to ~40% of the scene so the scene it captions is never buried. */
.ppt-comic-scene > .ppt-captions {
  left: var(--space-p8); right: var(--space-p8); bottom: var(--space-p8); width: auto; transform: none;
  max-height: 40%; overflow-y: auto; padding: var(--space-p8) var(--space-p12); gap: var(--space-p4);
  border-radius: var(--radius-card);
}
.ppt-comic-scene > .ppt-captions .ppt-caption-line { font-size: var(--type-comic-caption); line-height: var(--lh-bubble); }
.ppt-comic-scene > .ppt-captions .ppt-caption-speaker { margin-right: var(--space-p6); }
/* TEMPLATE-PAGE caption track (renderers/captions.js, 2026-08-01). Template pages are
   responsive documents, not the fixed 1920x821 stage, so the track pins above the bottom
   chrome instead of positioning inside a stage.
   Capped and scrollable per the author: it must never bury the page it captions, so it takes
   at most ~half the viewport and scrolls inside that cap when a long line overflows — the
   same treatment the comic-mode track already had (`.ppt-comic-scene > .ppt-captions`). */
.ppt-captions--track {
  position: fixed; left: 50%; right: auto; transform: translateX(-50%);
  bottom: calc(var(--rc-footer-h, var(--chrome-flow-footer-h)) + 12px);
  width: min(78%, 1180px);
  max-height: 45vh; overflow-y: auto; overscroll-behavior: contain;
  /* Z-ORDER IS DELIBERATE AND NARROW (author 2026-08-01: captions "should be part of the player
     area", so an open menu covers them).
       40  narration / feedback modal scrims — captions must sit ABOVE these, or a modal that is
           speaking would hide its own captions
       50  THIS
       60  CourseMenu / ResourcesPanel drawers — these must cover the captions
       70  the fixed chrome header
     It was 60, which tied with the drawers; the track is portalled to <body> and therefore comes
     later in document order, so it won the tie and painted over an open menu. */
  z-index: var(--z-captions);
}
@media (max-width: 1024px /* bp:tablet */) {
  /* tablet/phone: full-bleed within a small inset, and a tighter cap so more of the scene
     stays visible. Scrolling inside the box is what compensates for the smaller height. */
  .ppt-captions--track {
    left: var(--space-p8); right: var(--space-p8); width: auto; transform: none;
    max-height: 40vh; padding: var(--space-p8) var(--space-p12); gap: var(--space-p4);
  }
  .ppt-captions--track .ppt-caption-line { font-size: var(--type-comic-caption); line-height: var(--lh-bubble); }
}
/* CC BUTTON ON-STATE (author 2026-08-01). Captions being on was only signalled by a subtle
   change inside the glyph, which is hard to read at a glance — and it matters now that captions
   are being tested against mute. ON draws a light-navy rounded rectangle behind the icon.
   Light navy from the palette (--surface-stage, the raised-panel step) rather than a new colour,
   with a signal-blue hairline so it reads as an active control rather than a disabled one.
   `aria-pressed` carries the same state for assistive tech, so this is not colour-alone. */
/* !important is REQUIRED here, not laziness: the desktop CC button carries the bundle's inline
   `style: ghost` (background + border set on the element), and an inline style beats any class
   rule. Measured — without it aria-pressed flipped correctly while the background stayed
   transparent. The alternative is moving the on-state into the bundle's inline style object,
   which would put a piece of course styling back inside vendored code. */
/* ONE TREATMENT FOR BOTH BOTTOM-BAR TOGGLES (author 2026-08-01: "these need to match").
   The CC button and the settings gear sit side by side, so their "active" square has to be the
   same shape and the same colour. The gear previously used a plain white wash while CC used the
   light-navy + signal-blue square, and side by side they read as two different controls.
   Both now share: same 10px radius, same hover wash, same light-navy ON square with a
   signal-blue hairline. `aria-pressed` (CC) and `aria-expanded` (gear) are the respective
   state attributes, so neither relies on colour alone. */
.mv-cc-btn, .set-cog {
  border-radius: var(--radius-button) !important;
  border: none !important;
  transition: background var(--dur-fast);
}
/* Hover only for devices that HAVE hover. On touch, :hover sticks after a tap until the
   next tap elsewhere — a stuck wash nearly the same shade as the ON square, which read as
   "is it on or not?" at tablet (author, 2026-08-02). Real pointers keep the wash;
   touch gets none, so the only filled state a finger can produce is the real ON state. */
@media (hover: hover) {
  .mv-cc-btn:hover, .set-cog:hover { background: var(--white-a08) !important; }
}
/* The active square is the LIGHT NAVY FILL ONLY — no outline (author 2026-08-01: "I want both
   of them to just look like the light navy square around the gear... I don't want" the signal
   blue outline). State is still carried by aria-pressed / aria-expanded, so dropping the border
   does not make this colour-alone. */
.mv-cc-btn[aria-pressed="true"], .set-cog[aria-expanded="true"], .set-cog.is-open {
  background: var(--surface-stage) !important;
}
/* ON-state underline (author 2026-08-02): the navy square alone sat too close to the hover
   wash to read as "captions are on", so the ON state additionally carries a signal-blue
   underline bar beneath the glyph — the captions-on convention learners know from video
   players. The gear gets the same mark when its panel is open, so the two controls keep
   reading as one family (the 2026-08-01 matching rule). Hover never shows it, so hover and
   ON cannot be confused at any width. */
/* `position: relative` here exists ONLY to give the ::after underline a containing block, so it
   must not apply to a CC button that is ALREADY positioned. The responsive chrome's `.rc-cc`
   (player.css) is `position: absolute`, centred on the bottom row with translate(-50%,-50%) — and
   screens.css loads after player.css, so an unscoped `relative` won at equal specificity and
   dropped the button into normal flow, where the translate lifted it 22px up onto the scrubber
   (measured, tablet + phone). It is absolute, which is already a containing block for ::after. */
.mv-cc-btn:not(.rc-cc), .set-cog { position: relative; }
.mv-cc-btn[aria-pressed="true"]::after, .set-cog[aria-expanded="true"]::after,
.set-cog.is-open::after {
  content: ""; position: absolute; left: 24%; right: 24%; bottom: var(--space-p4); height: var(--size-p3);
  border-radius: var(--radius-r2); background: var(--signal);
}
/* `:not(.rc-cc)` (2026-09-13): the responsive CC button also carries this class, and the shell's
   own `.rc-bottom .rc-cc:focus-visible` white outline (player.css) outranks this rule's
   `outline: none` but not its box-shadow — so it drew BOTH rings. The responsive control keeps
   the white outline its bottom-row siblings use; only the desktop button gets the token halo. */
.mv-cc-btn:not(.rc-cc):focus-visible { outline: none; box-shadow: var(--focus-ring); }

.ppt-caption-speaker { font-weight: var(--fw-extrabold); color: var(--signal); margin-right: var(--space-p8); text-transform: uppercase; letter-spacing: var(--track-caption); }

/* --- hybrid speech bubbles: exact PowerPoint shell (PNG) + live animated text overlay --- */
.dlg-bubble {            /* positioned to the PowerPoint shape box; carries the pop-in */
  position: absolute; z-index: var(--z-stage-ui);
  animation: mvBubbleIn var(--dur-t300) var(--ease-standard) both;
}
.dlg-shell {             /* the exact PPT bubble shell (shape + tail, no text) */
  position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none;
}
.dlg-text {              /* live text, clipped to the original PowerPoint text-box bounds */
  position: absolute; overflow: hidden; display: flex; align-items: center; justify-content: center;
  text-align: center; user-select: none;
  font-family: var(--font-sans); font-weight: var(--fw-semibold);
  line-height: var(--lh-title); color: var(--text-on-bubble);   /* was --ink (2026-09-17): the shell is the deck's pixels, so the words take a role a dark theme can set — classic aliases ink */
}
.dlg-fit { display: block; width: 100%; text-align: center; overflow-wrap: break-word; }
@media (prefers-reduced-motion: reduce) { .dlg-bubble { animation: none !important; } }

/* Conversation Decision (memory: conversation-decision-design). The choice bubbles are real PPT
   shells (same .dlg-bubble hybrid) clustered around the answerer; they PULSE a faint signal-blue
   glow to read as clickable. The container is click-through so only the choices catch the pointer. */
.ppt-conversation { position: absolute; inset: 0; pointer-events: none; z-index: var(--z-stage-ui); }
.cvo-choice { pointer-events: auto; cursor: pointer; }
.cvo-choice .dlg-shell { animation: cvoPulse var(--dur-pulse) var(--ease-in-out) infinite; }
.cvo-choice:hover .dlg-shell, .cvo-choice:focus-visible .dlg-shell {
  animation: none; filter: drop-shadow(0 0 12px var(--signal-a95));
}
.cvo-choice:focus-visible { outline: none; }
@keyframes cvoPulse {
  0%, 100% { filter: drop-shadow(0 0 1px var(--signal-a25)); }
  50%      { filter: drop-shadow(0 0 9px var(--signal-a85)); }
}
@media (prefers-reduced-motion: reduce) {
  .cvo-choice .dlg-shell { animation: none; filter: drop-shadow(0 0 5px var(--signal-a70)); }
}
/* Phase poses (Conversation Decision) crossfade on the PICK — event-driven, so a CSS transition is
   correct here (nothing to scrub); clock-driven poses never carry this class. */
.ppt-pose-phase { transition: opacity var(--dur-t333) var(--ease-default); }

/* Local play / replay control — this is the conversation's "play button" (the screen registers no
   global media). Prominent + centered when idle; a small corner replay once it has started. */
.cvo-play {
  position: absolute; left: 50%; bottom: 3%; transform: translateX(-50%);
  z-index: var(--z-stage-play); pointer-events: auto; cursor: pointer;
  width: var(--size-p56); height: var(--size-p56); border-radius: var(--radius-circle); border: none;
  background: var(--signal); color: var(--text-on-accent); font-size: var(--type-h3); line-height: var(--lh-solid);
  display: flex; align-items: center; justify-content: center; box-shadow: 0 6px 18px var(--ink-deep-a40);
}
.cvo-play.is-replay { left: auto; right: 2%; transform: none; width: var(--size-p38); height: var(--size-p38); font-size: var(--type-copy); background: var(--bar-deep-a78); }
.cvo-play:hover { filter: brightness(1.08); }
.cvo-play:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--signal-a55), 0 6px 18px var(--ink-deep-a40); }
.cvo-comic-play {
  align-self: center; margin-top: .6vh; min-height: var(--size-p40); padding: var(--space-p8) var(--space-p18); cursor: pointer;
  border: none; border-radius: var(--radius-r999); background: var(--signal); color: var(--text-on-accent);
  font-weight: var(--fw-bold); font-size: var(--type-comic-play); box-shadow: 0 4px 12px var(--ink-deep-a35);
}
.cvo-comic-play:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--signal-a50); }

/* --- seekable narration timeline (footer) --- */
.media-scrubber { position: absolute; top: var(--chrome-scrub-offset); left: 0; right: 0; height: var(--chrome-scrub-strip); cursor: pointer; z-index: var(--z-l2); touch-action: none; }
.media-scrubber-track { position: absolute; left: 0; right: 0; top: 0; height: var(--chrome-scrub-track); background: var(--white-a16); }
.media-scrubber-fill { position: absolute; left: 0; top: 0; height: var(--chrome-scrub-track); background: var(--signal); }
.media-scrubber-thumb { position: absolute; top: var(--space-n4); width: var(--chrome-scrub-strip); height: var(--chrome-scrub-strip); border-radius: var(--radius-circle); background: var(--signal); transform: translateX(-50%); box-shadow: 0 1px 4px var(--black-a45); }
.media-scrubber:hover .media-scrubber-thumb { transform: translateX(-50%) scale(1.18); }
.media-scrubber:focus-visible { outline: none; }
.media-scrubber:focus-visible .media-scrubber-thumb { box-shadow: 0 0 0 4px var(--signal-a60); }
/* observe-only (rewind) scrubbing: the not-yet-watched stretch AHEAD of the high-water mark
   is dimmed and can't be seeked into (forward scrubbing is capped there; only backward works).
   `left` is set inline to the watched fraction; the region runs to the track end. */
.media-scrubber-locked { position: absolute; right: 0; top: 0; height: var(--chrome-scrub-track);
  background: var(--grad-hatch-locked); }
/* the decision-gate lock: an amber notch marking where the learner must decide */
.media-scrubber-lock { position: absolute; top: var(--space-n4); width: var(--size-p3); height: var(--size-p13); border-radius: var(--radius-r2); z-index: var(--z-l3);
  background: var(--marker-yellow); box-shadow: 0 0 0 1.5px var(--surface-chrome); transform: translateX(-50%); }

/* --- settings menu: gear button + audio/transition panel ---------------------
   Replaces the old `.vol` hover-to-open speaker slider (removed 2026-08-01 with
   VolumeControl in player.js). Shared by the desktop chrome and ResponsiveShell, so
   these rules are the only place the control is styled. Colours, radii and the focus
   ring all come from the player's existing tokens — no new visual vocabulary. */
.set-wrap { position: relative; display: inline-flex; align-items: center; justify-content: center; }

/* The rounded-rectangle background is BACK; the rotation is not (author 2026-08-01).
   These are independent properties, so the button can acknowledge the pointer exactly like
   the rest of the chrome while still staying visually upright when clicked — the tilt was
   the part that was wrong, not the hover box.
   `transform: none` is asserted on every interactive state rather than simply omitted, so a
   later `:active { transform: scale(...) }` copied from the other chrome buttons cannot
   quietly reintroduce a press-tilt. */
.set-cog {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--size-p46); height: var(--size-p46); border: none; border-radius: var(--radius-button); background: transparent;
  color: var(--signal); cursor: pointer;
  transition: background var(--dur-fast);
}
/* Hover / open backgrounds now come from the SHARED rule with .mv-cc-btn (see the block by
   .ppt-caption-speaker) so the gear and the CC button cannot drift apart again. Only the
   pressed wash stays local, because CC has no equivalent momentary state. */
/* `!important` (2026-09-13): the shared hover wash above is `!important`, so on a pointer device
   the press (hover + active at once) never showed — the rule was unreachable. Same weight, later.
   Scoped to the CLOSED cog: while the panel is open the button wears the navy open-state fill
   (the rule above), and a press must not flash the white wash over it (judge finding, same day). */
.set-cog:not([aria-expanded="true"]):not(.is-open):active { background: var(--on-navy-track) !important; }
.set-cog, .set-cog:hover, .set-cog:active, .set-cog.is-open,
.set-cog:hover:active, .set-cog[aria-expanded="true"] { transform: none; }
.set-cog:focus-visible { outline: none; box-shadow: var(--focus-ring); }

.set-panel {
  position: absolute; bottom: 100%; right: 0; margin-bottom: var(--space-p10);
  width: var(--size-p300); max-width: calc(100vw - 32px);
  background: var(--bar-deep-a98); border: var(--border-hairline) solid var(--on-navy-track);
  border-radius: var(--radius-option); padding: var(--space-p14); box-shadow: 0 8px 22px var(--black-a45);
  opacity: 0; pointer-events: none; z-index: var(--z-settings-panel);
  /* The panel is a RESULT (decision 0.17, 2026-09-14): its fade is its own profile, meta.motion.settings-panel —
     the close phase here, the open phase on .is-open. Classic: 130 ms ease both ways, as shipped. */
  transition: opacity var(--mo-settings-panel-close-travel) var(--mo-settings-panel-close-curve);
  font: var(--fw-semibold) var(--type-label)/var(--lh-text-tight) 'Archivo', system-ui, sans-serif; color: var(--text-on-navy);
  display: flex; flex-direction: column; gap: var(--space-p12);
}
.set-panel.is-open { opacity: 1; pointer-events: auto; transition: opacity var(--mo-settings-panel-open-travel) var(--mo-settings-panel-open-curve); }

.set-row { display: flex; flex-direction: column; gap: var(--space-p6); }
.set-row-label { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-p10); cursor: pointer; }
.set-row-name { font-weight: var(--fw-bold); }
.set-row-value { font-variant-numeric: tabular-nums; opacity: var(--alpha-75); font-weight: var(--fw-bold); }

/* 44px of vertical hit area on touch without a 44px-tall visual bar: the padding is
   part of the control, so the thumb stays easy to grab on a phone. */
.set-slider {
  width: 100%; margin: 0; padding: var(--space-p12) 0; background: transparent;
  accent-color: var(--signal); cursor: pointer; touch-action: none;
}
.set-slider:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }

/* Every row is [mute icon][slider]. The mute sits to the LEFT of its own slider, so the
   control that silences a channel is adjacent to the one that sets its level. */
.set-row-controls { display: flex; align-items: center; gap: var(--space-p10); }
.set-row-controls .set-slider { flex: 1 1 auto; min-width: 0; }

/* ICON ONLY — no caption. The state is carried by the glyph, aria-pressed, the accessible
   name and the tooltip (author 2026-08-01: no persistent visible text). 44px tap target. */
.set-mute {
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
  width: var(--hit-target); height: var(--hit-target); padding: 0;
  border: none; background: transparent; cursor: pointer;
  color: var(--signal);
}
.set-mute:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
/* Muted uses the palette's own INCORRECT/warning red (tokens.css --incorrect), not a colour
   invented for this control. The earlier amber was not in the style guide at all — the course
   has never used yellow outside the radiation ILLUSTRATION tokens, which are artwork, not UI.
   State is not carried by colour alone: the glyph itself changes to a crossed-out speaker. */
.set-mute.is-muted { color: var(--incorrect); }
/* Silenced only because MASTER is muted: dimmed, still showing its own un-muted glyph, so it
   never claims this button is the one holding the channel down. */
.set-mute.is-by-master { opacity: var(--alpha-45); }
.set-slider.is-silent { opacity: var(--alpha-45); }

/* Carousel Transition: a real checkbox (role=switch) with the pill drawn over it. The
   input keeps its own focus ring and keyboard behaviour; only its box is hidden. */
.set-switch-row { padding-top: var(--space-p2); border-top: var(--border-hairline) solid var(--white-a12); }
.set-switch { display: flex; align-items: center; justify-content: space-between; gap: var(--space-p10); cursor: pointer; min-height: var(--hit-target); }
.set-switch-controls { position: relative; display: inline-flex; align-items: center; gap: var(--space-p8); }
.set-switch-input { position: absolute; inset: 0; width: var(--size-p46); height: var(--size-p26); margin: 0; opacity: 0; cursor: pointer; }
.set-switch-pill {
  position: relative; display: inline-block; width: var(--size-p46); height: var(--size-p26); border-radius: var(--radius-r13);
  background: var(--on-navy-rule); border: var(--border-hairline) solid var(--white-a28);
  transition: background var(--dur-t140), border-color var(--dur-t140);
}
.set-switch-knob {
  position: absolute; top: var(--space-p2); left: var(--space-p2); width: var(--size-p20); height: var(--size-p20); border-radius: var(--radius-circle);
  background: var(--surface-card); transition: transform var(--dur-t140) var(--ease-standard);
}
.set-switch-input:checked + .set-switch-pill { background: var(--signal); border-color: var(--signal); }
.set-switch-input:checked + .set-switch-pill .set-switch-knob { transform: translateX(20px); }
.set-switch-input:focus-visible + .set-switch-pill { box-shadow: var(--focus-ring); }
.set-switch-state { min-width: var(--size-p26); font-weight: var(--fw-bold); opacity: var(--alpha-85); }

/* Pass-through slot (ResponsiveShell puts the captions toggle here). */
.set-extra-row { padding-top: var(--space-p10); border-top: var(--border-hairline) solid var(--white-a12); }
.set-pill-btn {
  display: flex; align-items: center; gap: var(--space-p10); width: 100%; min-height: var(--hit-target);
  padding: var(--space-p8) var(--space-p12); border: var(--border-hairline) solid var(--white-a20); border-radius: var(--radius-sm);
  background: var(--white-a08); color: var(--text-on-navy); cursor: pointer;
  font: var(--fw-bold) var(--type-label)/var(--lh-solid) 'Archivo', system-ui, sans-serif; text-align: left;
}
.set-pill-btn[aria-pressed="true"] { background: var(--signal-a28); border-color: var(--signal); }
.set-pill-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* Narrow screens: the panel is anchored to the gear, which sits in the bottom bar, so
   it must never push past either viewport edge. */
@media (max-width: 600px /* bp:phone */) {
  /* Centre the panel on the VIEWPORT, not on the cog (2026-09-13). The cog sits at 50% + 54px
     (.rc-gear-wrap, player.css); centred on it, a 300px panel's right edge measured 399px on a
     390px viewport — 9px clipped by html { overflow-x: hidden }. Shifting the centre back by the
     cog's own 54px offset puts the panel at 45–345px on a 390 screen. */
  .set-panel { width: min(300px, calc(100vw - 24px)); right: 50%; transform: translateX(calc(50% - 54px)); }
}

@media (prefers-reduced-motion: reduce) {
  .set-switch-knob, .set-panel { transition: none; }
}

/* --- interactive overlays: hotspots + choice buttons --- */
.ppt-overlay { position: absolute; inset: 0; z-index: var(--z-stage-overlay); }
.ppt-hotspot {
  position: absolute; border: var(--border-w2) dashed var(--signal-a90); background: var(--signal-a10);
  border-radius: var(--radius-sm); cursor: pointer; min-width: var(--hit-target); min-height: var(--hit-target);
}
.ppt-hotspot:focus-visible, .ppt-choice:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* choice panel: readable HTML buttons over the slide */
.ppt-choices {
  position: absolute; right: var(--space-p40); top: 50%; transform: translateY(-50%); z-index: var(--z-stage-ui);
  width: 40%; max-width: var(--size-p640); display: flex; flex-direction: column; gap: var(--space-p13);
  background: var(--navy-a92); border: var(--border-hairline) solid var(--on-navy-border);
  border-radius: var(--radius-card); padding: var(--space-p28); animation: mvSlide var(--dur-base) var(--ease-standard);
}
.ppt-prompt { font-family: var(--font-sans); font-weight: var(--fw-extrabold); font-size: var(--type-h2); color: var(--text-on-navy); line-height: var(--lh-title); margin: 0 0 var(--space-p6); }
.ppt-choice {
  display: flex; align-items: center; gap: var(--space-p14); width: 100%; text-align: left; cursor: pointer;
  min-height: var(--hit-target); padding: var(--space-p16) var(--space-p18); border-radius: var(--radius-option);
  border: var(--border-control) solid var(--border); background: var(--surface-card); color: var(--ink);
  font-family: var(--font-sans); font-weight: var(--fw-semibold); font-size: var(--type-body-md); transition: background var(--dur-fast) var(--ease-default), border-color var(--dur-fast) var(--ease-default);
}
.ppt-choice:hover:not(:disabled) { background: var(--signal-wash); }
.ppt-choice[aria-pressed="true"] { border-color: var(--signal); border-width: var(--border-active); }
.ppt-choice[data-state="correct"] { border-color: var(--correct); background: var(--correct-wash); }
.ppt-choice[data-state="incorrect"] { border-color: var(--incorrect); background: var(--incorrect-wash); }
.ppt-choice .ppt-letter { flex: 0 0 auto; width: var(--size-p30); height: var(--size-p30); border-radius: var(--radius-circle); display: flex; align-items: center; justify-content: center; font-weight: var(--fw-extrabold); background: var(--page); color: var(--text-heading); }
.ppt-choice:disabled { cursor: default; }

/* inline (non-modal) feedback — icon + label + text, never color alone */
.ppt-feedback {
  display: flex; gap: var(--space-p12); align-items: flex-start; margin-top: var(--space-p6); padding: var(--space-p14) var(--space-p16);
  border-radius: var(--radius-option); border: var(--border-w2) solid; animation: mvPop var(--dur-pop) var(--ease-overshoot);
}
.ppt-feedback[data-kind="correct"] { border-color: var(--correct); background: var(--correct-wash); }
.ppt-feedback[data-kind="incorrect"] { border-color: var(--incorrect); background: var(--incorrect-wash); }
.ppt-feedback .ppt-fb-badge { flex: 0 0 auto; width: var(--size-p28); height: var(--size-p28); border-radius: var(--radius-circle); color: var(--text-on-navy); font-weight: var(--fw-extrabold); display: flex; align-items: center; justify-content: center; }
.ppt-feedback[data-kind="correct"] .ppt-fb-badge { background: var(--correct); }
.ppt-feedback[data-kind="incorrect"] .ppt-fb-badge { background: var(--incorrect); }
.ppt-fb-title { font-weight: var(--fw-extrabold); color: var(--text-on-navy); font-size: var(--type-copy); margin-bottom: var(--space-p2); }
.ppt-fb-body { color: var(--white-a85); font-size: var(--type-small); line-height: var(--lh-text-loose); }

/* ============================================================================
   The timeline conversation modal, responsive body (renderers/conversation-modal.js).
   The intro-01 treatment in modal form (author 2026-08-08): the comic panel with its pinned chat
   circles and scrolling transcript on top, the staged scene beneath, 50/50. Before this the modal
   had NO scene on a phone at all — the staged figures were computed desktop-only — so it was the
   one dialogue surface in the course showing talking heads over nothing.
   ============================================================================ */
/* `.tab-conv-modal` is a ROW flex container with `align-items: flex-start; justify-content: center`
   — sized for the desktop staged composition, which is one centred child. Dropped in as-is, this
   layout became a flex item that shrink-wrapped to WIDTH 0 and was never stretched vertically, so
   the split could not bind and the scene band sat outside the modal's clip (measured 2026-08-08:
   layout w=0, comic and scene both h=554 inside a 554 box). Fixed HERE rather than by changing the
   modal rule, which desktop still depends on. */
.tab-conv-layout {
  display: flex; flex-direction: column; min-height: 0;
  flex: 1 1 100%; align-self: stretch; width: 100%;
}
/* 50/50, unlike the screen-level panels (45/55 narrative, 60/40 decision): a modal is already
   trimmed top and bottom by its own chrome, so neither half can afford to be the small one. */
/* templates.css:3169 still carries the OLD modal body — `position: absolute; inset: 0; flex: none`
   plus 84px of bottom padding for the floating finish button — from when the comic panel WAS the
   whole modal. An absolutely positioned flex item is out of flow, so it left the column entirely
   and the scene band took the full height (measured: both children h=554 in a 554 box). Unwound
   here rather than edited there, because that block is the modal's own CSS and this is the one
   selector that knows about the split. */
.tab-conv-layout .tab-conv-comic {
  position: relative; inset: auto; flex: 1 1 50%; min-height: 0;
  padding: 0; border-bottom: var(--border-w4) solid var(--bar-deep); border-radius: 0;
}
.tab-conv-layout .tab-conv-scene { flex: 1 1 50%; min-height: 0; }

/* THE LIVE CHIP'S BOX IS SIZED BY THE FINISHED LINE, NOT THE REVEALED SLICE (author-reported
   regression, 2026-08-08). Replacing the old floating bubble — which had a FIXED width, so text
   simply typed into a stable box — with an in-flow chip meant the chip shrink-wrapped to whatever
   had been revealed so far: the box grew a word at a time and re-wrapped under the text, which
   reads as the box lagging behind and stacking early.
   The fix is a hidden copy of the WHOLE line that does the sizing, with the revealed text laid
   over it. Inset uses the same custom properties as the padding so the two can never drift. */
.cvo-th-bubble { --chip-pad-x: 12px; --chip-pad-y: 8px; }
.cvo-th-measure { visibility: hidden; }
.cvo-th-shown {
  position: absolute; left: var(--chip-pad-x); right: var(--chip-pad-x);
  top: var(--chip-pad-y); bottom: var(--chip-pad-y);
}

/* MENU TEXT sized to the chrome's course title (author 2026-08-08: "about the same size as the DOT
   course-title text ... it shouldn't be any bigger than that"). That title is .rc-title-course,
   14px. The row label carries an INLINE font-size from the design bundle, so this needs
   `!important` to reach it — the same reason the cardsMaxWidth neutralisation does.
   Split by breakpoint because the author asked for the hook, even though both tiers land on 14px
   today: the cap is "no bigger than the header title", and the header title does not grow. */
@media (max-width: 1024px /* bp:tablet */) {
  #rc-menu-drawer div.cm-focusable[role="button"] > span { font-size: var(--type-caption) !important; line-height: var(--lh-snug) !important; }
}
@media (max-width: 600px /* bp:phone */) {
  #rc-menu-drawer div.cm-focusable[role="button"] > span { font-size: var(--type-caption) !important; }
  /* PHONE ONLY: pack the rows (author 2026-08-08 — "plenty of room for them to be closer
     together ... a one line break in between each at a minimum ... uniform spacing").
     Measured before: 16px padding + 4px margin = 36px between one title and the next, roughly two
     line-heights. 9px padding leaves 22px ≈ 1.25 lines, and the gap is padding-driven so it stays
     IDENTICAL whether a title runs to one line or three.
     9px is the floor, not a taste call: the row's status badge is 36px, so this lands the row at
     54px — still clear of the 44px WCAG 2.5.5 touch minimum. Do not go below it. Padding is an
     inline style from the design bundle, hence !important. */
  /* `div.cm-focusable[role="button"]`, NOT `.cm-focusable`: the "Start over" control is a real
     <button> carrying the same class, and the bare selector shrank it to 34px — under the touch
     minimum this rule is careful to respect for the rows themselves. */
  #rc-menu-drawer div.cm-focusable[role="button"] { padding: var(--space-p9) var(--space-p16) !important; }
}

/* "Start over" — its own control, its own class (`cm-reset`, added to the bundle 2026-08-08).
   It shared only `cm-focusable` with the menu ROWS, so a row rule caught it and shrank it; giving
   it a distinct handle is what stops that recurring. 44px is the WCAG 2.5.5 touch target, and it
   was 30px on its own long before any of this. Padding stays the bundle's; min-height does the
   work, so the pill keeps its shape. Applied at the responsive tiers, where touch is assumed. */
@media (max-width: 1024px /* bp:tablet */) {
  .cm-reset { min-height: var(--hit-target); display: inline-flex; align-items: center; }
}

/* Observe's panel carried `padding-bottom: 1.1vh` (templates.css:2539) so the old head row's NAME
   label "rested midway on the dotted line". The circles replaced the heads and took the names with
   them, so that padding now only pushes the newest chip 7px further off the floor than every other
   chat screen — 17px against a uniform 10px everywhere else (measured 2026-08-08).
   Blast radius: `.obs-comic-panel` has ONE call site (observe.js, inside the ctx.responsive
   branch) and ONE definition. Desktop Observe is `obs-desktop3` and never sees this class. The
   panel's height is `flex: 0 0 <fixed>`, so zeroing the padding moves nothing below it. */
.ppt-comic-log.obs-comic-panel { padding-bottom: 0; }

/* ── Jump-to-newest (author-specced 2026-08-09, built same day) ─────────────────────────────
   The way back down after scrolling up to re-read: following NEVER yanks a learner who has
   scrolled away ("it would be disruptive for the autofocus to pull it back down"), so this
   button is the return path. Rendered by BOTH scroller implementations (comic-log.js and
   ComicThread's own copy — the §4a pair), only while scrolled away from the bottom. */
/* STRADDLES the chat/scene boundary (author 2026-08-09, third placement revision: right →
   center → straddling the edge). The slot is absolute on the PANEL's bottom edge — it cannot be
   sticky inside the scroller any more, because a scroller clips its own content and the button
   must poke below the panel. Two supports, both load-bearing:
   1. .ppt-comic-log and .cvo-thread open the base .ppt-comic `overflow: hidden` — safe from the
      flex-sizing trap (comic-bubble-clip-audit §6) because .ppt-comic carries an explicit
      `min-height: 0`, which is the exact defense that incident established.
   2. The same two classes take z-index 2: the scene is a LATER sibling with a transform (its own
      stacking context), so without this the button's lower half paints UNDER the scene. */
.ppt-comic-log, .ppt-comic.cvo-thread { overflow: visible; z-index: var(--z-l2); }
.ppt-comic-jump-slot {
  position: absolute; left: 0; right: 0; bottom: 0; height: 0; z-index: var(--z-l5);
  display: flex; justify-content: center;   /* bottom-center (author, marked screenshot) */
  pointer-events: none;                     /* the slot spans the panel width — only the button itself may take taps */
}
.ppt-comic-jump {
  pointer-events: auto; cursor: pointer;
  /* -50%, not +50%: a zero-height flex slot lays its child out DOWNWARD from the boundary
     line, so the un-translated button already hangs fully into the scene; pulling it back by
     half its own height centres it on the edge */
  transform: translateY(-50%);
  min-width: var(--size-p64); min-height: var(--hit-target);                /* WCAG 2.5.5 touch target */
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--text-on-navy); background: var(--navy-l3);                 /* the author's "light navy" — the palette's lighter navy */
  border: var(--border-w2) solid var(--white); border-radius: var(--radius-r14);      /* rounded rect, NOT a circle (author) */
  box-shadow: 0 0 0 2px var(--bar-deep), 0 6px 14px var(--ink-deep-a40);
  /* slow ambient pulse the whole time it is visible — deliberately ~3s against the 1.8s
     clickable-bubble pulse, so it reads as "here when you want it", not urgent. It still
     speaks the course's affordance language: pulsing means tappable. */
  animation: comicJumpPulse var(--dur-t3000) var(--ease-in-out) infinite;
}
.ppt-comic-jump:focus-visible { outline: none; animation: none; box-shadow: 0 0 0 3px var(--signal-a60); }
.ppt-comic-jump:active { transform: translateY(-50%) translateY(1px); }
@keyframes comicJumpPulse {
  0%, 100% { box-shadow: 0 0 0 2px var(--bar-deep), 0 6px 14px var(--ink-deep-a40); }
  50%      { box-shadow: 0 0 0 2px var(--bar-deep), 0 0 0 8px var(--signal-a35), 0 6px 14px var(--ink-deep-a40); }
}
@media (prefers-reduced-motion: reduce) {
  .ppt-comic-jump { animation: none; box-shadow: 0 0 0 2px var(--bar-deep), 0 0 0 4px var(--signal-a30), 0 6px 14px var(--ink-deep-a40); }
}
