/* ============================================================================
   MVPhysics Course Player — styles
   Kept as a separate stylesheet (not inline) for clean SCORM / cmi5 packaging.
   Motion tokens (see guidelines/motion.html): Fast 120 · Base 220 ·
   Pop 320 overshoot. Standard ease cubic-bezier(.2,.7,.3,1);
   overshoot ease cubic-bezier(.34,1.56,.64,1).
   ============================================================================ */

* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0A1023; overflow: hidden; }
a { color: #00A3DD; }
a:hover { color: #008EC1; }

#stage { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }
#frame { transform-origin: center center; box-shadow: 0 30px 80px rgba(0,0,0,0.55); }

/* --- Responsive mode (at/below RESPONSIVE_MAX in player.js) ---
   Above the breakpoint the course is a fixed 1920×1080 stage scaled to fit (aspect preserved).
   At/below it — tablets, phones — we abandon the fixed canvas: fit() clears the transform and
   flags <html> + <body>, and these rules make the document scroll and every fixed-canvas ancestor
   (html, body, stage, frame, player root) flow fluidly so the layout reflows to the viewport.
   The base `html,body{height:100%;overflow:hidden}` is the thing that otherwise clips the tall
   flowed player, so it must be undone here. */
html.responsive { height: auto; overflow-x: hidden; overflow-y: auto; }
body.responsive { height: auto; min-height: 100%; overflow: visible; }
body.responsive #stage { position: static; inset: auto; display: block; height: auto; min-height: 100vh; }
body.responsive #frame { width: 100%; height: auto; min-height: 100vh; box-shadow: none; transform: none !important; }
body.responsive #root { width: 100% !important; max-width: 100% !important; height: auto !important; min-height: 100vh !important; transform: none !important; }
/* The CoursePlayer root ships position:absolute for the fixed canvas — put it back in flow
   (relative, so it stays the context for its menu/resources overlays) and make it fluid + fill
   the viewport, so its header→content→footer flex column flows top-to-bottom. EXCLUDE the
   visually-hidden .ppt-sr live regions — forcing 100vh on them injects a huge empty block —
   and the .mv-skip-link, for the same reason plus a worse one: this rule's
   `position: relative !important; inset: auto !important` would drag the off-canvas link back
   into the layout, so it would sit visible above the course at all times instead of only when
   focused. Both are chrome utilities, not layout children. */
body.responsive #root > *:not(.ppt-sr):not(.mv-skip-link) {
  width: 100% !important; max-width: 100% !important;
  height: auto !important; min-height: 100vh !important;
  position: relative !important; transform: none !important; inset: auto !important;
}

/* ================= Course-owned responsive chrome (tablet & phone) =================
   Rendered by chrome-responsive.js at/below 1024px INSTEAD of the vendored CoursePlayer.
   Header + bottom bar are fixed to the VIEWPORT (not the canvas), so the Menu/References
   controls hug the real screen edges and stay put while the page scrolls. */
/* --rc-footer-h: static fallback only (96px, the old hardcoded guess) — chrome-responsive.js
   measures the REAL .rc-bottom height (it varies: media screens gain a scrubber row) and
   overrides this via inline style the instant it mounts, so the fallback is visible for at
   most one frame before the accurate value takes over. */
.rc-shell { display: block; width: 100%; min-height: 100vh; background: #0A1023; --rc-header-h: 56px; --rc-footer-h: 96px; }

/* --- sticky top header (viewport-anchored) --- */
/* z-index 70 keeps the header (and its Menu/References buttons) ABOVE the open drawers
   (vendored overlay = z-index 60), so the header's own button is the single, persistent
   close control — see the drawer block below. */
.rc-header {
  position: fixed; top: 0; left: 0; right: 0; z-index: 70;
  height: var(--rc-header-h); display: flex; align-items: center; gap: 8px;
  padding: 0 12px;                       /* controls land within ~12px (< 15px) of the edges */
  background: var(--navy, #132554);
  box-sizing: border-box;
  transform: translateZ(0);              /* own layer — steadier repaint while zooming/scrolling */
}
/* course-progress bar along the header's bottom edge — replaces the old static blue border. The
   dim track spans the full width; the signal-blue fill grows with the current page of the course. */
.rc-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255,255,255,0.16); overflow: hidden; }
.rc-progress-fill { height: 100%; background: var(--blue, #00A3DD); transition: width .35s cubic-bezier(.3, .7, .3, 1); }
/* Menu / References: borderless, transparent, signal-blue icon buttons — identical to desktop,
   symbols only (no text). Each toggles to a blue × when its drawer is open. */
.rc-icon-btn {
  flex: 0 0 auto; min-width: 44px; min-height: 44px;   /* touch target */
  display: inline-flex; align-items: center; justify-content: center;
  padding: 6px; margin: 0; border: 0; background: transparent; cursor: pointer;
}

/* NO TAP HIGHLIGHT, ANYWHERE (author 2026-07-31). Mobile browsers paint their own pressed
   state — a translucent blue RECTANGLE over the element's box — which on a round control like
   the Play button appears as a square haze bearing no relation to the button's shape, and on a
   speech-bubble button as a block over the artwork. Every control here already has an authored
   :active state (the Play button scales, nav buttons darken); the platform default was drawing
   on top of it. Suppressing it makes touch show the SAME pressed state as desktop, which is the
   requirement. It removes no feedback — the authored :active is what remains.
   Kept as one rule rather than per-component so a new button cannot reintroduce it. */
button, a, [role="button"], summary, label, input, select, textarea,
.rc-icon-btn, .mv-skip-link {
  -webkit-tap-highlight-color: transparent;
}

/* NO LONG-PRESS CONTEXT MENU ON CONTROLS OR ARTWORK (author 2026-07-31: press-and-hold at
   responsive width was opening the context menu, as though right-clicked). Touch — and Chrome's
   touch EMULATION at narrow widths, which is how this is usually seen on a desktop — treats a
   long press as a right-click: on an image it offers "save image", on a button it offers a text
   callout. Neither is a course affordance, and on a control the learner is simply still holding
   down a button.
   SCOPED ON PURPOSE. `user-select: none` is applied to CONTROLS ONLY, never to content: the
   Facility Area Limits panel's DPM values must stay selectable text (approved screen S4-05's
   accessibility requirement), and a blanket rule would have silently broken that. Images get
   the callout suppressed but keep normal selection behaviour around them. */
button, [role="button"], summary, .rc-icon-btn, .mv-skip-link {
  -webkit-touch-callout: none;
  /* NO `user-select: none` HERE. It was in this rule for a few hours on 2026-07-31 and BROKE
     every modal button: in Blink, an element whose text cannot be selected becomes a native
     DRAG source instead, so press-and-hold on "Review the scenario" dragged a translucent ghost
     to the right and the click never fired on release. -webkit-touch-callout alone suppresses
     the long-press callout, which is all this rule was ever for.
     -webkit-user-drag belts it: a control is never something you drag. */
  -webkit-user-drag: none;
}
img { -webkit-touch-callout: none; -webkit-user-drag: none; }
.rc-icon-btn:focus-visible { outline: 3px solid var(--blue, #00A3DD); outline-offset: 2px; border-radius: 8px; }
.rc-menu { margin-right: 2px; }
.rc-ref { margin-left: auto; }         /* pinned to the right edge */
.rc-pin { flex: 0 0 auto; display: flex; align-items: center; height: 34px; }   /* wraps the vector PinMark (was a 2MB PNG) */
.rc-logo { flex: 0 0 auto; display: flex; align-items: center; height: 34px; }
.rc-logo svg { height: 28px; width: auto; }
/* Titles take the middle, shifted right off the brand, and TRUNCATE before touching a control
   (min-width:0 + ellipsis: the title shrinks/ellipsises, it never overlaps a button). A run-time
   collision check (chrome-responsive.js) additionally drops the wide tablet logo when the title
   would still be squeezed, so the title/controls stay readable (§5 lets a strictly-necessary
   responsive adjustment keep controls visible). */
.rc-titles { flex: 1 1 auto; min-width: 0; overflow: hidden; margin: 0 8px 0 12px; }
.rc-shell.rc-cramped .rc-logo { display: none; }
.rc-title-course { color: #fff; font: 800 14px/1.15 system-ui, sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rc-title-sub { color: rgba(255,255,255,0.72); font: 500 12px/1.2 system-ui, sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* phone: the label text on the buttons is hidden to save room (icon only); tablet keeps labels */
.rc-shell.is-phone .rc-header .rc-btn-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.rc-shell.is-tablet .rc-pin { display: none; }    /* tablet keeps the full logo, not the pin */
/* Tablet: centre the branding + course/scene title as a group between the edge buttons, instead of
   hugging the left. References already has margin-left:auto (right edge); giving the logo
   margin-left:auto too makes the two auto-margins split the free space equally, centring the
   logo+titles group. Titles stop flex-growing so the group stays compact, and their text centres. */
.rc-shell.is-tablet .rc-logo { margin-left: auto; }
.rc-shell.is-tablet .rc-titles { flex: 0 1 auto; text-align: center; }
/* phone: center the course/module title in the header. The menu+pin (left) are wider than the
   References icon (right), so flex-centering lands off-center; take the title out of flow and
   center it on the header instead, clamped so it never reaches the flanking controls. */
.rc-shell.is-phone .rc-titles {
  position: absolute; left: 50%; transform: translateX(-50%);
  text-align: center; margin: 0; max-width: calc(100% - 180px);
}

/* --- scrolling content: clear the fixed header + bottom bar --- */
.rc-content { padding-top: var(--rc-header-h); padding-bottom: var(--rc-footer-h, 96px); min-height: 100vh; box-sizing: border-box; }
.rc-stage { display: block; }

/* --- sticky bottom control bar (viewport-anchored) --- */
.rc-bottom {
  position: fixed; bottom: 0; left: 0; right: 0; z-index: 50;
  background: var(--navy, #132554); border-top: 1px solid rgba(255,255,255,0.14);
  padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px));
  box-sizing: border-box;
  transform: translateZ(0);              /* own layer — steadier repaint while zooming/scrolling */
}
.rc-scrub { position: relative; height: 6px; border-radius: 3px; background: rgba(255,255,255,0.16); margin: 4px 0 8px; cursor: pointer; }
.rc-scrub:focus-visible { outline: 3px solid var(--blue, #00A3DD); outline-offset: 3px; }
.rc-scrub-fill { height: 100%; border-radius: 3px; background: var(--blue, #00A3DD); }
/* observe-only (rewind) scrubbing: the not-yet-watched stretch ahead of the high-water mark,
   dimmed (forward seeking is capped there; only backward scrubbing works) */
.rc-scrub-locked { position: absolute; top: 0; bottom: 0; right: 0; border-radius: 0 3px 3px 0;
  background: repeating-linear-gradient(45deg, rgba(255,255,255,0.06) 0 4px, rgba(255,255,255,0.18) 4px 8px); }
/* the decision-gate lock: an amber notch marking where the learner must decide */
.rc-scrub-lock { position: absolute; top: 50%; width: 3px; height: 15px; border-radius: 2px; z-index: 3;
  transform: translate(-50%, -50%); background: #FFD23F; box-shadow: 0 0 0 1.5px #0E1C42; }
/* Fixed layout at EVERY size (absolute, not flex): Play/Pause/Restart is always dead-centre, Back
   hugs the left, Continue the right. The media controls sit CLOSE to Play (like the desktop CC /
   volume icons): Captions just left of Play, volume/mute or the phone cog just right of it —
   centres at 50% ± 54px (Play half 26 + 6 gap + control half 22). */
.rc-bottom-row { position: relative; min-height: 52px; }
/* Prev / Play / Next are the EXACT desktop controls (same SVG icons, same colors): circular
   buttons — Back is a bare white arrow on transparent; Play + Continue are navy icons on the
   signal-blue circle. */
.rc-bottom-row .rc-nav, .rc-bottom-row .rc-play {
  position: absolute; top: 50%; width: 52px; height: 52px; border-radius: 50%;
  border: 0; padding: 0; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.rc-prev { left: 0;   transform: translateY(-50%); background: transparent; }               /* Back: bare white arrow */
.rc-next { right: 0;  transform: translateY(-50%); background: var(--blue, #00A3DD); }        /* Continue: navy arrow on blue */
.rc-play { left: 50%; transform: translate(-50%, -50%); background: var(--blue, #00A3DD); }   /* Play: always centred */
.rc-bottom-row .rc-nav:disabled { opacity: 0.4; cursor: default; }

/* Media controls flanking Play — transparent, signal-blue icons like the desktop CC/volume. */
.rc-cc, .rc-vol {
  position: absolute; top: 50%; width: 44px; height: 44px; border: 0; background: transparent;
  padding: 0; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.rc-cc  { left: calc(50% - 54px); transform: translate(-50%, -50%); }   /* Captions: left of Play (tablet) */
.rc-vol { left: calc(50% + 54px); transform: translate(-50%, -50%); }   /* volume/mute: right of Play (tablet) */

/* Phone media cog — NOT a button box: just a bold signal-blue cog, right of Play. */
.rc-gear-wrap { position: absolute; top: 50%; left: calc(50% + 54px); transform: translate(-50%, -50%); }
.rc-cog { border: 0; background: transparent; padding: 6px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }

.rc-bottom .rc-nav:focus-visible, .rc-bottom .rc-play:focus-visible,
.rc-bottom .rc-cc:focus-visible, .rc-bottom .rc-vol:focus-visible, .rc-bottom .rc-cog:focus-visible { outline: 3px solid #fff; outline-offset: 2px; border-radius: 8px; }
/* Hover is a POINTER-only affordance — gated so it never appears (or sticks) on touch, which is
   the one intended desktop/mobile difference. Focus + :active feedback stay on all devices. */
@media (hover: hover) {
  .rc-prev:not(:disabled):hover { background: rgba(255,255,255,0.12); }
  .rc-next:not(:disabled):hover, .rc-play:hover { filter: brightness(1.08); }
  .rc-cc:hover, .rc-vol:hover, .rc-cog:hover { filter: brightness(1.18); }
  .rc-icon-btn:hover svg { opacity: 0.82; }
}
/* Pressed (:active) feedback — every button, all devices (touch included, matching the note
   above). The universal press is filter-based, NOT transform-based: chrome buttons carry
   positioning transforms (translateY(-50%) etc.) that a global transform would clobber. */
button:not(:disabled):active { filter: brightness(0.86); }
/* Circular chrome controls also compress slightly — each restates its positioning transform. */
.rc-prev:not(:disabled):active { background: rgba(255,255,255,0.16); transform: translateY(-50%) scale(0.93); }
.rc-next:not(:disabled):active { transform: translateY(-50%) scale(0.93); }
.rc-play:active { transform: translate(-50%, -50%) scale(0.93); }
.rc-cc:active, .rc-vol:active { transform: translate(-50%, -50%) scale(0.9); }
.rc-cog:active { transform: scale(0.9); }
/* Popover uses the player-chrome colour (--navy), so it reads as part of the chrome rather than a
   white card. A subtle light border + the shadow separate it from the navy bottom bar it sits over. */
.rc-gear-panel {
  position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 60;
  min-width: 200px; max-width: 78vw; max-height: 50vh; overflow-y: auto;
  background: var(--navy, #132554); color: #fff; border: 1px solid rgba(255,255,255,0.16);
  border-radius: 12px; padding: 12px; box-shadow: 0 12px 30px rgba(10,16,35,0.5);
  display: none;
}
.rc-gear-panel.is-open { display: block; }
.rc-gear-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font: 700 13px/1.2 system-ui, sans-serif; margin-bottom: 10px; }
.rc-gear-row input[type=range] { flex: 1 1 auto; accent-color: var(--blue, #00A3DD); }
/* Popover toggles carry the SAME icons as the desktop/tablet controls (CC, speaker, muted speaker),
   with a text label beside them. Translucent pills on the navy panel, white text, signal-blue icons. */
.rc-gear-toggle { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 8px 12px; margin-top: 8px; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; background: rgba(255,255,255,0.08); font: 700 13px/1 system-ui, sans-serif; color: #fff; cursor: pointer; }
.rc-gear-toggle svg { flex: 0 0 auto; }
.rc-gear-toggle[aria-pressed="true"] { background: rgba(0,163,221,0.28); border-color: var(--blue, #00A3DD); }
/* captions on/off is shown by the pill highlight + label, so the CC icon stays full signal blue */

/* ============================================================================
   Menu & References drawers (tablet & phone).
   The shell reuses the vendored CourseMenu / ResourcesPanel overlays, which each
   ship (a) their OWN close × and (b) a fixed ~560px desktop width. The course owns
   both problems here — WITHOUT touching the bundle:

   1. Single persistent close control. The header's Menu/References button is one
      element that toggles icon -> × in place (same box, same center). The header is
      z-index 70 (above the z-index-60 overlay), so that button is the visible close;
      each panel's built-in × is hidden. No second button, no second tab stop, and
      the close center equals the trigger center (they are the same element).

   2. Genuine responsive sizing (no transform:scale, no fixed desktop width). Each
      panel is re-sized in vw/px and pushed below the fixed header so its own title
      and its internal overflow-y:auto list are fully visible; the list owns the
      scroll and the last item stays reachable. Desktop (CoursePlayer) is untouched. */

/* The vendored overlay root is position:absolute; in a scrolling responsive page it
   would scroll away with the document — pin it to the viewport, full-bleed. */
#rc-menu-drawer > div, #rc-ref-drawer > div { position: fixed !important; inset: 0 !important; }

/* Hide each panel's built-in × (the header button is the one persistent close). */
#rc-menu-drawer button[aria-label="Close menu"],
#rc-ref-drawer  button[aria-label="Close resources"] { display: none !important; }

/* Panel = the overlay's 2nd child (1st is the backdrop). Sit it below the header and
   run it to the viewport bottom so the panel's own flex column (fixed title + progress,
   then the overflow-y:auto list) sizes correctly and the list scrolls internally. */
#rc-menu-drawer > div > div:nth-child(2),
#rc-ref-drawer  > div > div:nth-child(2) {
  top: var(--rc-header-h) !important; bottom: 0 !important; height: auto !important;
}
#rc-menu-drawer > div > div:nth-child(2) {                 /* menu: left-anchored */
  left: 0 !important; right: auto !important;
  width: min(56vw, 430px) !important; max-width: calc(100vw - 30px) !important;
}
#rc-ref-drawer > div > div:nth-child(2) {                  /* references: right-anchored */
  left: auto !important; right: 0 !important;
  width: min(52vw, 420px) !important; max-width: calc(100vw - 30px) !important;
}
/* Keep the last list item clear of the phone home indicator. */
#rc-menu-drawer > div > div:nth-child(2) > div:last-child,
#rc-ref-drawer  > div > div:nth-child(2) > div:last-child {
  padding-bottom: env(safe-area-inset-bottom, 0px) !important;
}

/* Phone (<= 600px): narrower panels, tighter edge gap. */
@media (max-width: 600px) {
  #rc-menu-drawer > div > div:nth-child(2),
  #rc-ref-drawer  > div > div:nth-child(2) {
    width: min(88vw, 360px) !important; max-width: calc(100vw - 24px) !important;
  }
}

/* While a drawer is open the underlying page must not scroll behind it. */
html.rc-drawer-open, body.rc-drawer-open { overflow: hidden !important; }

@media (prefers-reduced-motion: reduce) {
  .rc-gear-panel, .rc-header, .rc-bottom { transition: none; }
}

/* ============================================================================
   Carousel slide-projector transition (desktop navigation).
   A PERMANENT screen stack lives inside the stage wrapper: normally it holds one
   layer (the active screen); during a transition it holds two (departing below,
   incoming above). The stack reproduces the stage's flex geometry so a screen
   renders identically inside a layer as it did as a direct stage child.
   Timings are 2750ms total — the length of the projector recording slices
   (media/ui/projector-advance-*.mp3); boundaries 614/1719/2544ms = 22.32/62.5/92.5%,
   with the 62.5% seat placed where the recording's real seat clunk lands.
   The 62.5% (~1719ms) seat point MUST match ui-sounds.js carouselAdvance seatDelayMs and
   the player.js SEAT_MS narration-hold constant.

   Overlap translucency: per the author's spec the incoming slide is the TOP
   layer and see-through where it overlaps the outgoing slide. Rather than
   washing out the whole slide (the lower 70% would show stage background), a
   MASK makes only the incoming slide's top ~30% band translucent — during
   peek/travel that band IS the overlap, and during tail-off the old slide's
   remainder shows through the same band. The mask is a 200%-tall gradient whose
   translucent band slides up and away during the settle beat (mask-position is
   animatable), so the slide reaches full opacity smoothly. */
.mv-screen-stack {
  position: relative; flex: 1 1 auto; min-width: 0; min-height: 0;
  display: grid; overflow: hidden;
}
.mv-screen-layer {
  grid-area: 1 / 1; min-width: 0; min-height: 0;
  display: flex; flex-direction: column; position: relative;
}
.mv-screen-layer--active { z-index: 2; }
.mv-screen-layer--departing {
  z-index: 1; pointer-events: none; user-select: none; will-change: transform;
  animation: mvCarouselOutgoing 2750ms linear forwards;
}
/* THE DEPARTING SLIDE IS A FREEZE-FRAME — NOTHING INSIDE IT MAY STILL BE ANIMATING
   (perf fix 2026-07-31, author-reported stutter).

   The departing layer is inert by design: pointer-events off, a frozen ctx whose clock is
   pinned and whose set/play are NOOPs. But CSS knows none of that, so every decorative loop
   inside the outgoing screen kept ticking while the layer slid away — and there are a lot of
   them: `.scn-hot-pulse` (mvPulse) and `.scn-hot-dot` (mvTwinkle) on EVERY hotspot,
   `.scn-obs-dot`, `.ppt-comic-caret`, `.cvo-comic-option`, `.tab-conv-*`, the welcome wisps.
   A Click-to-Reveal screen with five hotspots carries ELEVEN infinite animations.

   That is the expensive part: a layer whose descendants repaint every frame cannot be cached
   as one composited texture, so the parent's own transform animation stops being a cheap
   compositor job and starts costing a full raster per frame — which is exactly when frames get
   dropped and the slide appears to jump to its -70% seat position instead of travelling there.

   `animation-play-state: paused` rather than `animation: none`: paused freezes each loop at the
   value it currently holds, so the freeze-frame looks identical to the frame the learner was
   just looking at. `animation: none` would snap every pulse back to its start value and pop.
   Scoped to DESCENDANTS (`*`), never the layer itself — the layer's own mvCarouselOutgoing must
   keep running. */
.mv-screen-layer--departing *,
.mv-screen-layer--departing *::before,
.mv-screen-layer--departing *::after {
  animation-play-state: paused !important;
  transition: none !important;
}
.mv-screen-layer--incoming {
  z-index: 2; will-change: transform;
  /* THE MASK ANIMATION NOW EXISTS ONLY FOR THE 7.5% IN WHICH IT ACTUALLY MOVES
     (perf fix 2026-07-31). It used to be declared as a full 2750ms animation whose keyframes
     held `0 0` from 0% all the way to 92.5% and only then travelled — so for 92.5% of the
     transition the browser was ticking an animation that changed nothing. mask-position is NOT
     a compositable property in Blink: a layer with an ACTIVE mask animation is re-rastered on
     the main thread every frame, full-screen, for the whole transition.
     Same visual, exactly: static `0 0` until 2544ms (92.5%), then the travel. `forwards` holds
     the end value, and the base `mask-position: 0 0` below covers the pre-delay window. */
  animation: mvCarouselIncoming 2750ms linear forwards,
             mvCarouselMask      206ms linear 2544ms forwards;
  /* THE BAND FADES OUT DOWNWARD — IT IS NOT A FLAT SLAB (author-directed 2026-08-07).
     It used to hold 0.58 uniformly for the whole 30%, then cut to opaque. Two things
     came from that, and one gradient fixes both.

     1. THE SLIDE APPEARED TO HAVE TWO EDGES. What reads as the departing slide's
        bottom edge is only where it slips behind this layer and starts showing at
        42%; its REAL edge is 246px further down, where the flat band stopped dead.
        The eye finds that second hard edge and reads the whole 246px as a slab
        hanging below the slide. Fading it out means there is no second edge to find
        — the slide just dissolves into this one.
     2. THE PARKED BAND STOPPED MATTERING. At the seat this layer stops, so the band
        stops, while the departing slide keeps accelerating — measured, the gap opens
        from -70% to -100% across the last third. With a ramp, the part left stranded
        is the BOTTOM of the band, which is already at or near fully opaque, while
        full strength stays at the seam where the slide actually still is. The
        artifact is not chased away, it is moved somewhere it cannot be seen — which
        is why this is preferred over animating mask-position through the tail (that
        was built, measured at 825ms of main-thread raster, and reverted unshipped).

     Alpha runs --carousel-band-alpha at the seam to fully opaque by the band's lower
     edge, with the midpoint weighted high so it falls off fast near the top, the way
     a real shadow does. Stops are halved because mask-size is 200% and this layer
     only ever sees the gradient's top half, so 17.5% == 35% of the stage.
     >>> SUPERSEDED 2026-08-07, SAME DAY: the ramp above was built, looked at, and
     REJECTED by the author — "I don't want the transparency to be a gradient. I want
     it to be, like, a flat fifty five percent." It is described here rather than
     deleted because the two problems it solved are real and DO come back with a flat
     band (the second hard edge, and the parked band being at full strength instead of
     its faint end). The author saw both and prefers flat; that is the call. The
     trailing shadow now carries the sense of the slide's edge that the ramp was
     compensating for. Do not reinstate the ramp without asking.

     THE 5% EXTENSION DOES NOT SURVIVE THE SWITCH TO FLAT, so the band goes back to
     30%. Past 30% there is no departing slide behind this layer and the band washes
     against bare stage. Under the ramp that region sat at alpha 0.957 — a 4.3% wash,
     invisible. Flat, it would be a hard-edged 45% wash strip.
     THE BAND AND THE OVERLAP ARE THE SAME NUMBER (30%). Move one without the other
     and you either crop the departing slide to a sliver (tried 2026-08-07, reverted)
     or smear the band onto bare stage. Stops are halved because mask-size is 200% and
     this layer only sees the gradient's top half, so 15%/17% == the band's 30%/34%.
     STILL STATIC — no animation, nothing to reprint. */
  --carousel-band-alpha: 0.55;
  -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,var(--carousel-band-alpha)) 0%, rgba(0,0,0,var(--carousel-band-alpha)) 15%, #000 17%, #000 100%);
          mask-image: linear-gradient(to bottom, rgba(0,0,0,var(--carousel-band-alpha)) 0%, rgba(0,0,0,var(--carousel-band-alpha)) 15%, #000 17%, #000 100%);
  -webkit-mask-size: 100% 200%; mask-size: 100% 200%;
  -webkit-mask-position: 0 0; mask-position: 0 0;
}
/* Easing (author-directed 2026-07-26): three distinct mechanical gestures.
   1. PEEK   — the new slide DECELERATES into its 30% overlap (strong ease-out).
   2. TRAVEL — both slides ease in AND out together: slow engage, slow seat.
      The travel curve MUST be identical on both keyframes — same segment, same
      bezier — or the 30% overlap would drift apart mid-travel.
   3. TAIL   — the old slide's exit starts slow and ACCELERATES off the top
      (ease-in), like the tray flicking it into the take-up slot. */
@keyframes mvCarouselOutgoing {
  0%      { transform: translateY(0); }
  22.32%  { transform: translateY(0); animation-timing-function: cubic-bezier(.55, .05, .25, 1); }
  62.5%  { transform: translateY(-70%); animation-timing-function: cubic-bezier(.65, .05, .85, .35); }
  92.5%, 100% { transform: translateY(-100%); }
}
@keyframes mvCarouselIncoming {
  0%      { transform: translateY(100%); animation-timing-function: cubic-bezier(.15, .5, .3, 1); }
  22.32%  { transform: translateY(70%); animation-timing-function: cubic-bezier(.55, .05, .25, 1); }
  62.5%, 100% { transform: translateY(0); }
}
/* Runs only across the final 7.5% (delayed 2544ms above), so it is a plain from/to now —
   the old 0%/92.5% hold is expressed by the delay instead of by dead keyframe time. */
@keyframes mvCarouselMask {
  from { -webkit-mask-position: 0 0;    mask-position: 0 0; }
  to   { -webkit-mask-position: 0 100%; mask-position: 0 100%; }
}
/* Reduced motion: no animation (the JS guard also skips carousel setup entirely —
   this is belt-and-suspenders so a stray departing layer could never animate). */
@media (prefers-reduced-motion: reduce) {
  .mv-screen-layer--departing, .mv-screen-layer--incoming {
    animation: none;
    -webkit-mask-image: none; mask-image: none;
  }
}

/* ---- trailing shadow on the departing slide (author-directed 2026-08-07) ----
   PURELY ADDITIVE. Nothing above changes: the mask band, its 0.58 alpha, all three
   beziers, the 2750/1719 contract and the 92.5% mask delay are untouched.

   WHAT IT FIXES. The mask band is anchored to the INCOMING layer, so at the seat
   the incoming stops and the band stops with it while the outgoing keeps
   accelerating away — measured: the gap between them opens from -70% to -100%
   across the last third. The author's report was exact: "at first it looks like the
   shadow is right with it, and then the actual outgoing slide disappears much
   faster than the shadow."

   WHY THE BAND ITSELF WAS NOT SIMPLY MOVED ONTO THE DEPARTING LAYER. That was
   tried on 2026-08-07 and REVERTED, and the reason is worth keeping: the band has
   to live on whichever layer is on top, so moving it means putting the DEPARTING
   layer on top — and an opaque departing slide covers the incoming one during the
   whole peek, so you can no longer watch the new slide rise into view. The author
   caught it immediately: "I want the slide to slide in from the bottom, not just
   appear on the bottom." The incoming layer being on top is load-bearing, exactly
   as the original plan specified. So the band stays where it is, and a real
   trailing shadow is added alongside it instead.

   CONSTANT GAP, CONSTANT THICKNESS. The strip is a ::after pinned below the
   element's own bottom edge; mvCarouselShadow only ever TRANSLATES (no scale), so
   neither number can change across the transition. Both are variables below.

   IT RIDES THE OUTGOING KEYFRAMES, THEN OUTLIVES THEM. 0 -> 92.5% is a
   character-for-character copy of mvCarouselOutgoing, so it cannot drift from the
   slide while the slide exists. At 92.5% the slide HOLDS at -100% and this does
   not — that hold is what decouples them, no extra machinery needed.

   >>> THE TWO KEYFRAME BLOCKS MUST AGREE THROUGH 92.5%. If mvCarouselOutgoing's
   curves or boundaries ever change, change them here too, or the shadow will drift
   off the slide exactly the way the mask band already does. <<<

   WHY THE TAIL IS LINEAR AND NOT AN EASE-IN. The strip hangs (gap + thickness)
   below the slide, so it is still on screen when the slide is clear, and must
   travel that much further: -(100 + (gap + thickness) / 8.21)% on the 821 stage,
   = -108% at 25px + 41px. An ease-in here would START AT ZERO VELOCITY and stall
   the strip dead at 92.5% — the exact artifact being removed. It does not need
   one: cubic-bezier(.65,.05,.85,.35) exits at (1-.35)/(1-.85) = 4.33x its own
   average speed, so the tail is already travelling ~1.29 px/ms, and the remaining
   66px takes ~51ms = 1.85% of the timeline. A LINEAR 92.5% -> 94.35% stub covers
   66px in 50.9ms = 1.30 px/ms — velocity-continuous to within 0.5%, so the
   handoff is seamless. Recompute BOTH the 94.35% and the -108% if either variable
   below changes; they are derived from them, not free. */
.mv-carousel-shadow {
  --carousel-shadow-gap: 25px;         /* constant vertical distance from the slide */
  --carousel-shadow-thickness: 41px;   /* constant vertical extent — 5% of the 821 stage
                                          (author-directed 2026-08-07: "extend the shadow
                                          a little bit more, maybe like five percent more").
                                          Was 25px. This is THE BAND YOU SEE AT THE BOTTOM,
                                          not the overlap mask — they are different things
                                          and the request was briefly applied to the wrong
                                          one. Changing it moves the two derived numbers in
                                          mvCarouselShadow below; they are not free. */
  --carousel-shadow-alpha: 0.35;       /* darkest at the top, falling to 0 downward —
                                          the gradient the author asked for lives HERE */
  grid-area: 1 / 1;                    /* same cell => same box height as the layers,
                                          which is what the translateY %s are relative to */
  position: relative;
  z-index: 3;                          /* above the incoming layer (2), which is on top */
  pointer-events: none;
  will-change: transform;
  animation: mvCarouselShadow 2750ms linear forwards;
}
.mv-carousel-shadow::after {
  content: ""; position: absolute; left: 0; right: 0;
  top: calc(100% + var(--carousel-shadow-gap));
  height: var(--carousel-shadow-thickness);
  /* darkest against the slide's trailing edge, falling away downward */
  background: linear-gradient(to bottom,
    rgba(0, 0, 0, var(--carousel-shadow-alpha)) 0%, rgba(0, 0, 0, 0) 100%);
}
@keyframes mvCarouselShadow {
  /* --- identical to mvCarouselOutgoing through 92.5% --- */
  0%      { transform: translateY(0); }
  22.32%  { transform: translateY(0); animation-timing-function: cubic-bezier(.55, .05, .25, 1); }
  62.5%  { transform: translateY(-70%); animation-timing-function: cubic-bezier(.65, .05, .85, .35); }
  /* --- the slide holds here; the shadow carries its exit velocity off the edge --- */
  92.5%  { transform: translateY(-100%); }
  94.35%, 100% { transform: translateY(-108%); }
}
@media (prefers-reduced-motion: reduce) {
  .mv-carousel-shadow { animation: none; display: none; }
}

/* Tablet/phone carousel (author 2026-07-27) — ONE new, purely additive rule.
   Reuses .mv-screen-layer/.mv-screen-layer--departing/--incoming and the
   mvCarouselOutgoing/Incoming/Mask keyframes above completely UNCHANGED — same
   timing, same easing, same sound sync. The only thing responsive needs that
   desktop gets for free from its fixed 1920 canvas: a fixed-size window for the
   translateY(±100%) keyframes to read as "one screen's worth" of travel. Scoped
   under .rc-shell so it can never affect desktop's CoursePlayer tree (no .rc-shell
   ancestor there), and gated in player.js on departing.wasResponsive so the class
   only ever appears on a responsive-originated transition. --rc-header-h/96px
   mirror the existing .rc-content clearance values (player.css, ~line 102) —
   reused, not redefined. */
.rc-shell .mv-screen-stack--carousel {
  position: fixed;
  top: var(--rc-header-h); bottom: 96px; left: 0; right: 0;
  z-index: 30;   /* above normal scrolled content; below .rc-bottom (50) / .rc-header (70) */
}

/* --- keyframes --- */
@keyframes mvModalIn { 0% { opacity: 0; transform: translateY(14px) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes mvPopIn   { 0% { opacity: 0; transform: scale(0.85); } 100% { opacity: 1; transform: scale(1); } }
/* Canonical feedback pop — overshoot. Used by every feedback surface, modal or inline. */
@keyframes mvPop     { 0% { opacity: 0; transform: scale(0.6); } 60% { opacity: 1; transform: scale(1.08); } 100% { opacity: 1; transform: scale(1); } }
/* Scenario choice-column entrance — base slide from the right. */
@keyframes mvSlide   { 0% { opacity: 0; transform: translateX(28px); } 100% { opacity: 1; transform: translateX(0); } }
/* was a 1-0-1 dip: consumers swap the img src at animation START, so the new image
   flashed at full opacity before fading (author bug report 2026-07-26). A plain
   fade-IN swaps cleanly; every consumer (and the bundle's inline reference to this
   keyframe name) is fixed here in one place. */
@keyframes mvImageFade { 0% { opacity: 0; } 100% { opacity: 1; } }
@keyframes mvPulse   { 0% { transform: scale(0.55); opacity: 0.75; } 100% { transform: scale(2.4); opacity: 0; } }
@keyframes mvTwinkle { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }
@keyframes mvFadeInSlow { from { opacity: 0; } to { opacity: 1; } }
@keyframes gsIn      { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: none; } }
@keyframes gsInBack  { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: none; } }

/* --- Sequence (DragSequence) --- */
.seq-focusable:focus-visible { outline: 3px solid #00A3DD; outline-offset: 2px; }
.seq-cta { transition: background 140ms ease; }
.seq-cta:not(:disabled):hover { background: #008ec2 !important; }
.seq-cta:not(:disabled):active { background: var(--signal-pressed, #007BA6) !important; transform: translateY(1px); }
.seq-card[aria-pressed="true"] { position: relative; }

/* --- GuidedStepper --- */
.gs-focusable:focus-visible { outline: 3px solid #00A3DD; outline-offset: 2px; border-radius: 12px; }
.gs-panel { animation: gsIn 200ms cubic-bezier(.2,.7,.3,1); }
.gs-panel[data-dir="-1"] { animation-name: gsInBack; }
.gs-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* Projector-advance choreography (author 2026-07-26): two phases of 370ms — the
   same contract as the accordion, timed to the advance-click clips (ui-sounds.js;
   GS_PHASE_MS in the bundle's stepper is the same number, change together).
   Phase 1: .gs-depart fades the old panel while .gs-carriage — the blue
   "current" ring made physical — travels the dot row (pitch 48px = 44px dot +
   4px gap; --gs-c-from/--gs-c-to set inline per flight). Midpoint: the step
   commits, the target dot lights blue. Phase 2: .gs-arrive fades the new panel
   in. Reduced motion never enters the choreography (instant path in go()),
   and the !important override below covers the classes regardless. */
.gs-panel.gs-depart, .gs-panel.gs-depart[data-dir="-1"] { animation: gsDepart 370ms ease-in-out both; }
.gs-panel.gs-arrive, .gs-panel.gs-arrive[data-dir="-1"] { animation: gsArrive 370ms ease-in-out both; }
@keyframes gsDepart { from { opacity: 1; } to { opacity: 0; } }
@keyframes gsArrive { from { opacity: 0; } to { opacity: 1; } }
.gs-carriage {
  position: absolute; top: 50%; left: 0; width: 30px; height: 30px; margin-top: -15px;
  border-radius: 50%; pointer-events: none;
  background: rgba(0, 163, 221, 0.30);
  border: 2px solid #0E1C42;
  box-shadow: 0 0 0 4px rgba(0, 163, 221, 0.22);
  animation: gsCarriage 370ms ease-in-out both;
}
@keyframes gsCarriage { from { transform: translateX(var(--gs-c-from, 0px)); } to { transform: translateX(var(--gs-c-to, 0px)); } }

/* --- CategoryMatch — non-modal (inline, per-row) feedback: overshoot pop --- */
.cm-focusable:focus-visible { outline: 3px solid #00A3DD; outline-offset: 2px; border-radius: 10px; }
.cm-fb { animation: mvPop 320ms cubic-bezier(.34,1.56,.64,1); }

/* Compact legend (after submit) — hover/focus reveals each category's definition */
.cm-legend-item { position: relative; display: inline-flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 700; cursor: help; outline: none; }
.cm-legend-item:focus-visible { outline: 3px solid #00A3DD; outline-offset: 3px; border-radius: 6px; }
.cm-legend-tip {
  position: absolute; top: calc(100% + 10px); left: 50%; transform: translateX(-50%) translateY(4px);
  width: 230px; background: #fff; color: #16203A; border: 1px solid #DCE2EC; border-radius: 10px;
  padding: 11px 13px; font-size: 13px; font-weight: 500; line-height: 1.45; text-align: left;
  text-transform: none; letter-spacing: normal; box-shadow: 0 12px 30px rgba(19,37,84,0.24);
  opacity: 0; visibility: hidden; transition: opacity 140ms ease, transform 140ms ease; z-index: 30; pointer-events: none;
}
.cm-legend-tip::before {
  content: ""; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
  border: 7px solid transparent; border-bottom-color: #fff;
}
.cm-legend-item:hover .cm-legend-tip,
.cm-legend-item:focus-visible .cm-legend-tip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }

/* --- Scenario slide — choice column entrance + inline (non-modal) feedback chip --- */
.sc-choice { animation: mvSlide 220ms cubic-bezier(.2,.7,.3,1); }
.sc-fb { animation: mvPop 320ms cubic-bezier(.34,1.56,.64,1); }

/* --- Reduced motion: replace entrances/pops with a brief fade (per motion guideline) --- */
@media (prefers-reduced-motion: reduce) {
  .gs-panel, .gs-panel[data-dir="-1"], .cm-fb, .sc-choice, .sc-fb { animation: mvFadeInSlow 120ms ease !important; }
  #frame [style*="animation"] { animation: mvFadeInSlow 120ms ease !important; }
}

/* --- Skip link — WCAG 2.4.1 Bypass Blocks (Level A) -------------------------------
   The first focusable element on the page, jumping past the chrome (menu, resources,
   back, play/pause, captions, timeline, next) straight into the stage. That chrome is
   identical on all 40 screens, so without this a keyboard learner tabs through it every
   single time.

   Off-canvas until focused rather than `display:none` or `visibility:hidden` — those
   remove an element from the tab order entirely, which would make a skip link that can
   never be reached. It sits above the fixed-canvas scale transform (z-index) so it is
   visible on desktop and responsive alike, and is positioned `fixed` so it does not
   shift the layout it is skipping. */
.mv-skip-link {
  position: fixed; top: 8px; left: -9999px; z-index: 9999;
  padding: 12px 20px; border-radius: 8px;
  background: var(--signal, #00A3DD); color: #fff; text-decoration: none;
  font: var(--fw-bold, 700) 15px/1 var(--font-ui, 'Archivo', system-ui, sans-serif);
  box-shadow: 0 6px 24px rgba(19, 37, 84, 0.35);
}
.mv-skip-link:focus { left: 8px; outline: 3px solid #fff; outline-offset: 2px; }
