/* ==================================================================
   Lesson Player v2 — "Szlak" (Trail)
   Layout is a fixed 4-band shell: topbar / trail / stage / action bar.
   Nothing outside .stage ever moves or resizes => stable screen.
   ================================================================== */

/* ---------- Tokens & themes ---------- */
:root {
  --fs-base: 17px;
  --radius: 20px;
  --radius-sm: 12px;
  --shadow: 0 6px 24px rgba(20, 52, 59, .10);
  --shadow-soft: 0 2px 10px rgba(20, 52, 59, .08);
  /* 120px -> 175px (Darek, 2026-08-05: markers doubled back up to 76px after an
     earlier pass shrunk them to 38px to fix edge-clipping) -- paired with the
     path amplitude easing 0.24 -> 0.19 in renderTrail (core.js), together
     buying enough clearance at the S-curve's tightest point for the bigger
     marker's active-state (scale+glow) footprint without clipping again. */
  --trail-w: 175px;
  --speed: .25s;
  /* Desktop-only "looks like 125% Chrome zoom" default (Darek: the player
     already looked better run that way manually). Scoped to the
     min-width:901px media query below, not applied globally -- see the
     comment there for why (mobile has its own, separately-tuned sizing
     that this would blow up if applied everywhere). Referenced by every
     spot that uses a raw vh/vw for a hard fit-to-screen budget, which
     `zoom` does NOT compensate on its own (verified live) -- currently
     just .player-shell's height and the trail's centering calc. */
  --ui-scale: 1.25;
  /* Fixed "audio" identity color, deliberately theme-independent (like
     --good/--bad read as universal states regardless of theme, this blue
     reads as "narration audio" everywhere) -- used by every narration
     transport control (.pa-play, .tam-play, seek/volume sliders) so they
     always match each other regardless of which of the 4 themes or which
     photo-tint is active. Previously .tam-play/.pa-seek/the volume sliders
     hardcoded this blue directly while .pa-play used var(--accent) instead
     (the per-theme trail color) -- same control, two different colors
     depending on which transport you were looking at. */
  --audio-accent: #4684a9;
  --audio-accent-soft: #4a92bdb0;
  /* Outline that keeps the trail's untraveled tube + marker rings readable
     against the world photos -- see .trail-path-outline for the full why.
     Deliberately theme-independent (same reasoning as --audio-accent above):
     what it has to out-contrast is the PHOTO, which is the same pale artwork
     in every theme, not the theme's own surfaces. Only Kontrast overrides it
     (below) -- a pale blue is exactly the wrong move in the accessibility
     theme. Tune per theme with a single line there if the other three ever
     want their own hue. */
  --trail-outline: #7FB6C9;
  /* "Island" surface treatment: a subtle top-lit vertical gradient (light
     catching a raised bevel) + the per-theme --island-shadow (an inset
     highlight + a soft floating drop shadow) below. References --surface
     generically, so it works unmodified across all 4 themes -- only the
     shadow color/opacity needs to differ per theme (see theme blocks). */
  --island-fill: linear-gradient(180deg, color-mix(in srgb, var(--surface) 100%, white 7%) 0%, var(--surface) 55%, color-mix(in srgb, var(--surface) 100%, black 3%) 100%);
}
/* The 1.25x desktop zoom above assumes real spare vertical pixels to
   spend -- verified live (Darek, 2026-08-05: "FHD + 150% Windows scale
   is too crammed"): at 150% OS scaling a 1920x1080 screen reports a
   genuine ~1280x720 viewport (unlike CSS `zoom`, OS-level DPI scaling
   in a scale-aware browser DOES shrink window.innerWidth/innerHeight
   together with everything else -- there's no way to detect or fight
   the OS setting from CSS, and there doesn't need to be: it behaves
   like a real, smaller viewport). Measured live at 1280x720: with the
   1.25x bump on, .task-card already used 529 of the 720px budget; with
   it off, the SAME content only needs 423px -- 25% back, which is
   exactly the margin that turns "fits, barely" into "comfortable" once
   a lesson has a few more answer pills than this one did. Below this
   height, skip the bump entirely and render at native 1x instead of
   scaling everything up 25% into a budget that doesn't have it to
   spare. Deliberately keyed on height alone (ANDed with the existing
   width gate below, not replacing it) -- width was never the tight
   axis here, .stage already fits its 900px comfortably down to 901px
   wide; it's vertical room that a shorter/scaled window runs out of
   first. 800px clears the common FHD@125% case (864px, the look this
   zoom was originally added to match) while catching FHD@150% (720px)
   and similar. */
@media (min-width: 901px) and (max-height: 799px) {
  :root { --ui-scale: 1; }
}

html[data-fontsize="1"] { --fs-base: 15px; }
html[data-fontsize="2"] { --fs-base: 17px; }
html[data-fontsize="3"] { --fs-base: 19px; }
html[data-fontsize="4"] { --fs-base: 22px; }
html[data-fontsize="5"] { --fs-base: 25px; }

/* Theme: Szlak (default, misty teal morning) */
html[data-theme="szlak"] {
  /* Kursor narracji: jasny */
  --kursor-ink: #000000;
  --bg: #ECF5F3;
  --contour: rgba(14, 124, 110, .09);
  --surface: #FFFFFF;
  --surface-2: #F2F8F6;
  --ink: #14343B;
  --muted: #5B7A80;
  --line: #D6E7E3;
  --accent: #0E7C6E;
  --accent-ink: #FFFFFF;
  --gold: #F2B33D;
  --gold-soft: #FBEBC9;
  --good: #2E9E6B;
  --good-soft: #DDF2E7;
  --bad: #BC7B82;
  --bad-soft: #F6E4E3;
  --hl: #FFF3CE;
  --island-shadow: 0 1px 0 rgba(255,255,255,.85) inset, 0 10px 26px -12px rgba(20,52,59,.20), 0 2px 8px rgba(20,52,59,.10);
  --island-shadow-sm: 0 1px 0 rgba(255,255,255,.85) inset, 0 5px 14px -8px rgba(20,52,59,.22);
}

/* Theme: Zmierzch (dusk on the trail) */
html[data-theme="zmierzch"] {
  /* Kursor narracji: ciemny -- tu maksymalny kontrast to BIEL, nie czern */
  --kursor-ink: #FFFFFF;
  --bg: #101E22;
  --contour: rgba(120, 205 ,190, .07);
  --surface: #182B30;
  --surface-2: #14252A;
  --ink: #E7F2EF;
  --muted: #8FB0AC;
  --line: #24424663;
  --accent: #37B8A4;
  --accent-ink: #08211D;
  --gold: #E8B44C;
  --gold-soft: #3D3419;
  --good: #4CC98F;
  --good-soft: #16382A;
  --bad: #E3A8AE;
  --bad-soft: #3A262A;
  --hl: #4A3F16;
  --shadow: 0 6px 24px rgba(0,0,0,.35);
  --shadow-soft: 0 2px 10px rgba(0,0,0,.3);
  --island-shadow: 0 1px 0 rgba(255,255,255,.07) inset, 0 12px 28px -12px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
  --island-shadow-sm: 0 1px 0 rgba(255,255,255,.07) inset, 0 6px 16px -8px rgba(0,0,0,.5);
}

/* Theme: Pergamin (warm, book-like) */
html[data-theme="pergamin"] {
  /* Kursor narracji: jasny */
  --kursor-ink: #000000;
  --bg: #F6EEDF;
  --contour: rgba(146, 104, 44, .10);
  --surface: #FFFBF2;
  --surface-2: #F4EBDB;
  --ink: #3E2F1C;
  --muted: #8A7658;
  --line: #E4D6BC;
  --accent: #8A5A24;
  --accent-ink: #FFF9EE;
  --gold: #D99A2B;
  --gold-soft: #F5E3BD;
  --good: #4F7D3A;
  --good-soft: #E4EFDA;
  --bad: #B37E78;
  --bad-soft: #F0DDD6;
  --hl: #F7E7B0;
  --island-shadow: 0 1px 0 rgba(255,255,255,.8) inset, 0 10px 26px -12px rgba(90,60,20,.22), 0 2px 8px rgba(90,60,20,.12);
  --island-shadow-sm: 0 1px 0 rgba(255,255,255,.8) inset, 0 5px 14px -8px rgba(90,60,20,.24);
}

/* Theme: Kontrast (accessibility, max legibility) */
html[data-theme="kontrast"] {
  --bg: #000000;
  --contour: transparent;
  --surface: #101010;
  --surface-2: #1B1B1B;
  --ink: #FFFFFF;
  --muted: #E0E0E0;
  --line: #6B6B6B;
  --accent: #FFD400;
  --accent-ink: #000000;
  --gold: #FFD400;
  --gold-soft: #333000;
  --good: #47E68C;
  --good-soft: #0E3320;
  --bad: #FF9FAA;
  --bad-soft: #3A1E22;
  --hl: #4A4000;
  /* Black, not the default pale blue: the world photo stays available in this
     theme too (nothing disables it -- see initWorldBackground), and against
     pale watercolor art black is the only outline that holds maximum contrast,
     which is the whole point of Kontrast. */
  --trail-outline: #000000;
  --shadow: none;
  --shadow-soft: none;
  --island-shadow: none;
  --island-shadow-sm: none;
}

/* ---------- Legacy variable aliases ----------
   The bundled includes (modal_quick_create_lesson.php, modal_apps_library.php,
   offline_activity_modal.php, ...) carry their own self-contained inline CSS
   from the old lesson-player.css era, styled entirely via custom properties
   that file used to define at :root. This shell never defines those names,
   so every one of those properties silently resolved to nothing -- borders,
   backgrounds and text color all fell through to browser defaults, which is
   why the quick-create-lesson modal rendered as unstyled overlapping text.
   Alias the old names to this shell's tokens once, here, instead of patching
   each bundled file's CSS individually; resolves dynamically per active
   theme since these are var() lookups, not fixed values. */
:root {
  --white-bg: var(--surface);
  --light-gray: var(--surface-2);
  --border-color: var(--line);
  --text-dark: var(--ink);
  --text-muted: var(--muted);
  --primary-color: var(--accent);
  --card-bg-default: var(--surface);
  --card-border: var(--line);
  --modal-tile-bg: var(--surface-2);
  --page-bg: var(--bg);
  /* --font-ui deliberately NOT aliased here -- it should come from the
     platform brand-kit layer (style.css, imported in lesson_player.php's
     head) so this shell and every bundled modal that reads var(--font-ui)
     follow platform-wide font changes. The var() fallback on each
     individual usage covers the case where that layer fails to load. */
}

/* ---------- Base ---------- */
* { box-sizing: border-box; }
/* Hard rule: never a visible scrollbar anywhere in the lesson player --
   every overflow:auto/scroll region (reading-body, chunk-viewport,
   task-body, chat-log, settings-tray, clip-embedded-body, passage modal,
   ...) stays fully scrollable, just chromeless. Previously only the
   trail's own scroll strip did this per-element; broadened globally
   after the reading-body sheet visibly showed one. */
* {
  scrollbar-width: none;
  -ms-overflow-style: none;
}
*::-webkit-scrollbar { display: none; width: 0; height: 0; }
/* Guarantee the `hidden` attribute always wins. Without this, any class
   rule that sets `display` on the same element (e.g. .zoom-overlay,
   .tool-btn, .passage-audio-row) beats the browser's [hidden] default —
   author styles outrank the UA stylesheet regardless of source order —
   so toggling `hidden` in JS silently does nothing and the element stays
   visible. Found live: the zoom overlay rendered full-screen and
   unclosable on every page load. */
[hidden] { display: none !important; }
html {
  font-size: var(--fs-base);
}
/* Desktop-only ~125% scale, matching the manual Chrome zoom the team
   already preferred. NOT applied below 901px -- mobile's own layout
   (single column, bottom trail bar, .chunk-viewport's own font-size
   override, etc.) is independently tuned for a cramped viewport, and a
   blanket zoom there made the reading tile disproportionately tall/
   narrow (more text wrap, no extra width to absorb it). A first attempt
   applied `zoom` unconditionally and broke the desktop layout too:
   verified live (getBoundingClientRect + window.innerWidth) that
   Chromium's `zoom` recomputes layout for percentage/auto-margin sizing
   (e.g. .stage still centers correctly) but explicit vh/vw units and
   window.innerWidth/innerHeight stay pinned to the REAL unzoomed
   viewport -- unlike real Ctrl/Cmd+ page zoom, which shrinks those too.
   .player-shell's `height:100dvh` is exactly that kind of raw unit, so
   its budget didn't grow with the rest of the zoomed content and the
   shell overflowed downward, pushing the action bar off-screen with
   `overflow:hidden` on body blocking any way to reach it. Fixed by
   compensating that one spot with var(--ui-scale) (below) instead of
   abandoning `zoom` altogether -- the trail's own centering calc already
   does the same. */
@media (min-width: 901px) {
  html { zoom: var(--ui-scale); }
  /* Cancels this zoom's own contribution to the reading passage's font
     size (see the long comment on .chunk-viewport's font-size below) --
     --ui-scale is already 1 here on short viewports (the height media
     query further down resets it), so this correctly becomes a no-op
     exactly where zoom itself is also a no-op. */
  .chunk-viewport { font-size: calc(clamp(1rem, .85rem + 1vw, 1.25rem) / var(--ui-scale)); }
}
body {
  margin: 0;
  /* Platform brand-kit's mobile CSS (layered import above) reserves 80px
     at the bottom of every page for its own fixed bottom nav via
     `@media(max-width:768px){body{padding-bottom:80px}}` -- this shell has
     no such nav (the action-bar is its own thing) and never set this
     property itself, so that layered rule quietly won by default and
     pushed the whole page 80px past the viewport on mobile. One
     unlayered declaration is enough to always outrank it regardless of
     specificity (that's what makes it "layered" in the first place). */
  padding-bottom: 0;
  /* Follows the platform brand kit's --font-ui (style.css, loaded as a
     lower-priority layer above) -- falls back to this shell's own choice
     if that variable is ever unavailable. Colors/layout stay independent
     by design; only typography unifies with the rest of the platform. */
  font-family: var(--font-ui, "Atkinson Hyperlegible", system-ui, sans-serif);
  color: var(--ink);
  background-color: var(--bg);
  /* Signature: topographic contour background */
  background-image:
    radial-gradient(ellipse 900px 620px at 8% -12%, var(--contour) 0 2px, transparent 2px 44px),
    radial-gradient(ellipse 1100px 780px at 108% 118%, var(--contour) 0 2px, transparent 2px 52px);
  overflow: hidden;
  transition: background-color var(--speed), color var(--speed);
  position: relative;
}
/* ZAZNACZENIE TEKSTU. Bez tej reguly caly odtwarzacz uzywal domyslnego zaznaczenia
   systemu: w Edge/Chrome na Windows to nasycony niebieski `Highlight` PLUS wymuszony
   bialy `HighlightText`. W trybie wyprawy tekst czytanki ma wlasna biala poswiate
   (`text-shadow` na `.reading-chunk`), wiec bialy napis z bialym halo na mocnym
   niebieskim rozmywal sie do nieczytelnosci -- zglosil to Darek zrzutem z Edge.
   Zaznaczanie NIE jest tu przypadkowe: to nim uruchamia sie Lupa (tlumaczenie,
   schowek, nowa lekcja), wiec uczen zaznacza tekst caly czas.

   Kolor podajemy JAWNIE razem z tlem -- przegladarka narzuca swoj `HighlightText`
   dokladnie wtedy, gdy autor go nie ustawi, i to on robil z ciemnego tekstu bialy.
   Arkusz ladowany jest wylacznie przez `lesson_player.php`, wiec goly `::selection`
   nie wychodzi poza odtwarzacz. */
::selection {
  background: color-mix(in srgb, var(--accent) 26%, transparent);
  color: var(--ink);
}
/* W wyprawie tekst stoi na zdjeciu i ma wlasny kolor -- zaznaczenie musi go
   zachowac, inaczej wracamy do napisu w kolorze motywu na cudzym tle. */
.player-shell[data-immersive="1"] ::selection {
  background: color-mix(in srgb, var(--accent) 30%, transparent);
  color: var(--imm-ink);
}

/* Per-subject photo backdrop -- default-on (see initWorldBackground in
   core.js), a fixed layer behind everything, never touching layout.
   --world-photo is unset when a lesson's subject has no art yet, so this
   paints nothing and the plain themed look shows through untouched.
   Full opacity (Darek, 2026-08): was flat opacity:.55, which blended with
   --bg showing through behind it (z-index:-1) and washed out every image
   to a pale, faded version of the source art -- these are already fairly
   soft watercolor/pastel pieces, no extra fade needed on top. */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  background-image: var(--world-photo, none);
  background-size: cover;
  background-position: center;
  pointer-events: none;
}
/* Soft-focus wash over the LEFT gutter only, edge-of-screen to the card
   (Darek, 2026-08-06). A same-day attempt at this widened it to BOTH side
   gutters, reasoning that a photo/blurred split looked lopsided -- wrong
   call, reverted: the right side's art is the deliberately "beautiful
   structured background," not a legibility problem, and only the left
   has real UI (.trail) sitting on top of it that needs the help.

   Width sized to the exact gutter PLUS a 120px overshoot into the card's
   own footprint (Darek, same day again: "cut-off visible... shouldn't
   look like this" -- sizing the box to exactly the gutter put backdrop-
   filter's own hard rectangular edge, and the color gradient's fade-to-
   transparent tail, right in the middle of open sky/art, visible as a
   seam between "blurred" and "sharp" well before the card actually
   started -- small mismatches between this calc and .stage's real
   rendered edge (subpixel/zoom rounding) always show up as exactly that
   kind of visible seam, no matter how precisely the two calcs are
   written to match. Overshooting past the card's edge sidesteps needing
   that precision at all -- the extra 120px lands under the opaque card,
   where it's invisible regardless of any small drift. The gradient's
   fade-to-transparent (color AND, via mask-image, the blur itself --
   background-gradient alone only fades the TINT, backdrop-filter's blur
   strength doesn't taper with it, so without the mask the blur edge was
   still a hard cut even where the color faded) happens over the last
   150px of that wider box -- comfortably inside the 120px overshoot, so
   only a small, smooth taper is ever visible in the actual gutter, not
   an abrupt line. Docked-trail breakpoint only (>=1425px, matching
   .trail's own 1424px cutoff below) -- under that the trail is the
   in-flow progress bar and there's no left gutter to wash. */
/* 2026-08-09: removed entirely -- per Darek, no blur anywhere over the
   background art, this gutter wash (backdrop-filter: blur(4px) over the
   left/trail gutter) included. The backgrounds read fine without it. */
/* ---------- Chunk-generation loader ----------
   Full-screen splash shown only while a lesson opened directly (never
   previewed first, see lib_reading_chunks.php) is missing its AI chunk
   plan and the player is fetching+storing one on demand -- a few seconds
   at most, then this is gone for good for that lesson. */
.chunk-loader {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: var(--bg);
}
.chunk-loader-card {
  max-width: 360px;
  width: 100%;
  text-align: center;
  padding: 2rem 1.6rem;
  background: var(--island-fill);
  border-radius: var(--radius);
  box-shadow: var(--island-shadow);
  border: 2px solid var(--line);
}
html[data-theme="kontrast"] .chunk-loader-card { background: var(--surface); box-shadow: none; }
.chunk-loader-spinner {
  width: 44px; height: 44px;
  margin: 0 auto 1.1rem;
  border-radius: 50%;
  border: 4px solid var(--line);
  border-top-color: var(--accent);
  animation: chunk-loader-spin 1s linear infinite;
}
@keyframes chunk-loader-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .chunk-loader-spinner { animation: none; }
}
.chunk-loader-title { margin: 0 0 .5rem; font-size: 1.1rem; font-weight: 700; }
.chunk-loader-meta { display: flex; justify-content: center; gap: .4rem; margin-bottom: 1rem; }
.chunk-loader-msg { margin: 0; font-size: .92rem; color: var(--muted); min-height: 2.6em; }

/* Coarse warm/cool/sage tile wash while a photo backdrop is active, bucketed
   from the manifest's hand-authored "tint" field (see bgTintBucket in
   core.js) -- cheap and safe, no pixel analysis needed. Every island
   surface (not just the two main cards) shifts toward the active photo
   backdrop's dominant tone. Angle changed 180deg -> 305deg (Darek,
   2026-08) -- the per-bucket COLORS here were never the problem (only
   .answer-btn's own hue-per-bucket attempt was, see its own comment for
   that saga); this system's 3 hues stay as they were, just angled
   differently. */
html[data-bg-tint="warm"] .task-card, html[data-bg-tint="warm"] .reading-card,
html[data-bg-tint="warm"] .topbar, html[data-bg-tint="warm"] .helper-panel { background: linear-gradient(305deg, color-mix(in srgb, var(--surface) 94%, #c9863a 6%) 0%, color-mix(in srgb, var(--surface) 82%, #c9863a 18%) 100%); }
html[data-bg-tint="warm"] .action-bar { background: linear-gradient(305deg, color-mix(in srgb, var(--surface) 88%, #c9863a 12%) 0%, color-mix(in srgb, var(--surface) 76%, #c9863a 24%) 100%); }
html[data-bg-tint="cool"] .task-card, html[data-bg-tint="cool"] .reading-card,
html[data-bg-tint="cool"] .topbar, html[data-bg-tint="cool"] .helper-panel { background: linear-gradient(305deg, color-mix(in srgb, var(--surface) 94%, #4a7ab0 6%) 0%, color-mix(in srgb, var(--surface) 82%, #4a7ab0 18%) 100%); }
html[data-bg-tint="cool"] .action-bar { background: linear-gradient(305deg, color-mix(in srgb, var(--surface) 88%, #4a7ab0 12%) 0%, color-mix(in srgb, var(--surface) 76%, #4a7ab0 24%) 100%); }
html[data-bg-tint="sage"] .task-card, html[data-bg-tint="sage"] .reading-card,
html[data-bg-tint="sage"] .topbar, html[data-bg-tint="sage"] .helper-panel { background: linear-gradient(305deg, color-mix(in srgb, var(--surface) 94%, #5a8f5a 6%) 0%, color-mix(in srgb, var(--surface) 82%, #5a8f5a 18%) 100%); }
html[data-bg-tint="sage"] .action-bar { background: linear-gradient(305deg, color-mix(in srgb, var(--surface) 88%, #5a8f5a 12%) 0%, color-mix(in srgb, var(--surface) 76%, #5a8f5a 24%) 100%); }
html[data-theme="kontrast"][data-bg-tint] .task-card, html[data-theme="kontrast"][data-bg-tint] .reading-card,
html[data-theme="kontrast"][data-bg-tint] .topbar, html[data-theme="kontrast"][data-bg-tint] .action-bar, html[data-theme="kontrast"][data-bg-tint] .helper-panel { background: var(--surface); }

h1, h2, h3, .btn-primary, .task-kicker, .intro-title, .finish-title {
  font-family: var(--font-display, "Bricolage Grotesque", "Atkinson Hyperlegible", sans-serif);
  /* Variable font: drive axes, never pin a static weight -- but this is a
     no-op for --font-display's actual current value, Baloo Paaji, which is
     only self-hosted at a single STATIC weight 400 (no variable range at
     all), so font-variation-settings' "wght" axis has nothing to grab onto
     for it. Same root cause the platform-wide 800->100 sweep's own commit
     message flagged: the real weight has to be a literal font-weight
     declaration, not an axis setting, for a static face. Explicit 100 here
     to match that platform-wide pass -- lesson-player-v3.css never
     hardcoded font-weight:800 anywhere, so the sweep (a literal 800->100
     string swap across ~410 components) had nothing to find-replace here
     and skipped this file entirely. Covers h1 (.lesson-title) and h2
     (.reading-title, "Tekst lekcji") via this shared selector. */
  font-variation-settings: "wght" 640, "wdth" 88, "opsz" 40;
  font-weight: 100;
}

button { font: inherit; color: inherit; cursor: pointer; }
button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
  border-radius: 6px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}

/* ---------- Shell: fixed bands ---------- */
/* Trail lives as a fixed-position rail outside the grid entirely now (see
   .trail below) -- it used to reserve its own grid column, which pushed
   the actually-centered content (stage/action-bar) off-center from the
   viewport's real middle. Single column, stage/action-bar stack in it. */
.player-shell {
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr auto;
  grid-template-areas: "stage" "actionbar";
  height: 100dvh;
}
@media (min-width: 901px) {
  /* Compensates the desktop-only zoom above -- 100dvh is a raw viewport
     unit, which `zoom` does NOT shrink the way it does percentage/auto-
     margin sizing, so this budget needs to be pre-shrunk by the same
     factor zoom will then visually scale back up. Without this the shell
     rendered ~125% too tall for the real screen and the action bar
     (Next/Prev) ran off the bottom with no way to reach it. */
  .player-shell { height: calc(100dvh / var(--ui-scale)); }
}
.stage { grid-area: stage; }
/* z-index above the reading-body sheet (55) and the trail's mobile
   drawer (60 -- see below) on purpose -- the exit route must never be
   coverable by an overlay above it. */
.action-bar { grid-area: actionbar; position: relative; z-index: 62; }
/* The lesson is over: the bar has nothing left to do. LP.finishLesson hid
   its two buttons but left the island itself -- background, border, shadow
   -- as an empty white slab under the results (Darek, screenshot). Set by
   LP.finishLesson; applies in normal and immersive alike.
   The TRAIL deliberately stays: it is the record of the route just
   completed, not a control, and Darek's screenshot shows it reading fine
   beside the results. */
.player-shell.is-finished .action-bar { display: none; }
/* Grid items default to min-width/min-height:auto (content-based), not 0
   -- so a deliberately-large child (the trail's JS-computed scroll strip)
   forces the grid TRACK itself to grow to fit it, blowing out the whole
   page even though every descendant has its own overflow:hidden/auto.
   Must apply to every direct child, both axes. */
.player-shell > * { min-width: 0; min-height: 0; }

/* ---------- Lesson title/grade/subject + exit, embedded at the top of
   whichever card is showing (reading-card or task-card) instead of a
   separate floating topbar badge above the stage -- see .card-lesson-head
   in reading-card/task-card markup. Reuses .topbar-title/.lesson-title/
   .lesson-meta/.chip class names from the old topbar badge unchanged. ---------- */
/* Real 2-column grid (Darek, 2026-08, screenshot-flagged): was flex with
   .topbar-title hard-capped to max-width:220px and .media-corner-btn
   floated via position:absolute -- on a wide card that left a huge dead
   gap between the (artificially narrow) title and the 52px thumbnail
   nowhere near it, backwards from what the space is actually for (titles
   can run long, the thumbnail is the thing that only needs a small
   fixed cell). Title column takes all remaining space; thumbnail column
   is auto-sized to the button's own content (collapses to ~0 when the
   button is [hidden] -- an auto track's size comes from its content, and
   display:none content contributes none). Row height driven by whichever
   is taller -- title capped to 2 lines (see .lesson-title) keeps that in
   the same ballpark as the thumbnail's fixed 52px, so the whole row still
   reads as "max two rows" the way a plain single-line title + badges did. */
.card-lesson-head {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: start;
  column-gap: .7rem;
  margin-bottom: .7rem;
  /* .reading-card itself carries NO padding at all -- every section inside
     it (.reading-head, .chunk-viewport, ...) supplies its own, same
     convention as here. The grid rewrite dropped .topbar-title's old
     margin-top:1rem/margin-left:1rem (read as leftover cruft from the
     pre-grid layout) without realizing THAT was the row's only source of
     top/left spacing -- with it gone the title sat flush against the
     card's edge. Restored as real padding on the row itself instead of a
     child's margin, matching .reading-head's own top/side values. */
  padding: .8rem 1.1rem 0;
}
/* Hidden -- duplicated the action-bar's own back-style button visually
   (two "<-" circles reading as the same affordance even though this one
   exited the whole lesson and the action-bar's steps back one exercise).
   Browser back remains the way to leave a lesson entirely. */
.card-exit-btn { display: none; }
/* Opens the media overlay -- only relevant below 1800px now (the photo
   floats fixed above that instead, see #mediaZone's own rules below,
   and hides this button too -- below 1800px there's no persistent
   image block at all, just this click-to-open button). A real
   grid cell now (see .card-lesson-head above), not position:absolute --
   that reserved no flow space of its own, so a short title left nothing
   underneath the floating thumbnail but the next row ("Cały tekst" etc.),
   and anchoring drifted between .reading-card/.card-lesson-head across
   two prior attempts depending on which ancestor's box the corner was
   supposed to hug. A grid cell can't drift or get squeezed out by
   something below it -- it just IS the column, always. Shows the first
   media item as an actual background-image thumbnail (set in JS) instead
   of a bare icon -- an icon-only square read as "still loading," not as
   a deliberate preview. */
.media-corner-btn {
  /* LOAD-BEARING: .media-corner-badge below is position:absolute anchored to
     bottom:-6px/right:-6px, i.e. it hangs off THIS button's corner. Without a
     position here the button is static, the badge escapes to the nearest
     positioned ancestor -- #reading-card -- and lands 6px outside ITS corner,
     where overflow:hidden plus a 20px radius clip it down to a green sliver
     poking out of the bottom-right of the lesson text. That is what it did in
     production until 2026-09-04, on every lesson with more than one media
     item (the only case the badge is shown at all), and it read as a render
     artefact rather than a control, which is why it went unnoticed. */
  position: relative;
  width: 52px; height: 52px;
  border-radius: 12px;
  border: 2px solid var(--line);
  background: var(--surface-2) center/cover no-repeat;
  box-shadow: var(--island-shadow-sm);
  display: grid; place-items: center;
  cursor: pointer;
}
.media-corner-fallback-icon { color: var(--accent); font-size: 1.3rem; }
/* Hidden the moment a real thumbnail image loads (see JS) -- stays as
   the fallback for a video-only gallery, which has no still to show. */
.media-corner-btn.has-thumb .media-corner-fallback-icon { display: none; }
.media-corner-badge {
  position: absolute;
  bottom: -6px; right: -6px;
  width: 20px; height: 20px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-ink);
  display: grid; place-items: center;
  font-size: .65rem;
  border: 2px solid var(--surface);
}
.topbar-title { min-width: 0; }
.lesson-title {
  margin: 0;
  font-size: 1.06rem;
  line-height: 1.2;
  /* Cap to 2 lines (Darek: "keep it at max two rows including image
     thumbnail") -- titles are free text and can run long; without a
     clamp a long one would keep growing the whole row taller than the
     thumbnail column ever needs to be. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  white-space: normal;
  overflow-wrap: break-word;
}
.lesson-meta { display: flex; gap: .4rem; margin-top: .15rem; }
.chip {
  font-size: .72rem;
  padding: .1rem .55rem;
  border-radius: 99px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--muted);
  white-space: nowrap;
}
.chip-level { background: transparent; /* was var(--gold-soft) -- CMO pass: flattened, no pill fill */ border-color: transparent; color: var(--ink); font-weight: 700; }
/* Split mode (matching/gapped) already hides .task-head entirely -- the
   lesson-head sits as its own sibling now (not nested inside task-head),
   so it needs the same treatment explicitly, or it'd show twice at once
   (once in the reading-card above, once here). Also hidden in plain
   'task' mode (Darek, 2026-08-05, overflow report): the title/level/
   subject chips are redundant once a student is mid-question -- they
   already know which lesson this is -- and reclaiming that row's height
   is the first line of defense against an answer list (esp. multiple
   choice with several long options) growing past the card and forcing a
   scrollbar, which must never happen. See also the --fit-scale shrink
   mechanism below for the second line of defense. */
.player-shell[data-mode="split"] .task-card .card-lesson-head,
.player-shell[data-mode="task"] .task-card .card-lesson-head { display: none; }
/* Assistant peek handle: docked at the screen's right edge, mostly
   off-screen, instead of living inside the action bar -- the one entry
   point for chat/settings/teacher-tools/clipboard/songs should always be
   reachable in exactly the same place, not competing for space with
   whatever the action bar happens to be showing for a given step.
   Breathing + glow only kick in while she's actually engaged
   (drawer open / thinking / speaking) -- no always-on idle animation, it
   would be a distraction during a reading task.

   Same read as journey.css's .jn-dock .jn-oracle: the whole uncropped
   character art, peeking in from the edge at a 45°-ish lean with most of
   her own pixels pushed off-canvas via translateX (not a clip-path crop),
   not a circular medallion of just the face. Swings fully into view on
   hover/focus so she's still easy to grab precisely. */
.assistant-handle {
  position: fixed;
  /* Upper third, not dead-center -- centered was sitting right over the
     task card's own content on shorter viewports. */
  top: 33%;
  right: 14px;
  /* 2026-08-10 (Darek): the 2026-08-08 125px peek window clipped the
     character exactly at its own vertical midpoint (.assistant-handle-face
     is 250px tall) -- a hard horizontal line landing right at the waist,
     reading as an amputated bust rather than a figure peeking in. Restored
     to the full 250px so the whole body is on screen (matches this block's
     own header comment above: "the whole uncropped character art... not a
     circular medallion of just the face"); she now naturally extends
     toward/past the viewport's bottom edge instead of being cut short.
     overflow:hidden still crops the SIDES via translateX below, just no
     longer the bottom. align-items:start keeps her pinned to the top of
     the box so the extra height only ever extends the visible bottom,
     never repositions the head. */
  width: 101px;
  height: 250px;
  overflow: hidden;
  transform-origin: 50% 82%;
  /* translateX is a % of her own box width (like journey's dock). Was 72%
     (only ~6% of the box actually on-screen at rest, measured live --
     effectively invisible, not a "peek") -- per Darek (repeatedly), that
     read as "the avatar is too small" even after the character itself was
     rescaled to properly fill its own canvas. 48% puts ~77% of the
     rotated box on-screen at rest instead, verified live via
     getBoundingClientRect() against the viewport edge -- still docked at
     the edge and swings fully into view on hover, just not reduced to a
     sliver by default.
     2026-08-10: bumped 48% -> 115% now that the box is 250px tall (see
     height above) instead of the 125px it was 48% was tuned against.
     transform-origin sits near the BOTTOM (50% 82%), so rotating -42deg
     swings the TOP of the box sideways by roughly (distance from origin to
     top) * sin(42deg) -- doubling the box height roughly doubled that
     distance (125*0.82=102.5px -> 250*0.82=205px), so the head swings
     ~(205-102.5)*sin(42deg) =~ 68px further into view than the 48% value
     was ever tuned for -- which is exactly the "not hidden enough" Darek
     flagged live. Pushing translateX out by that same ~68px (48.5px ->
     ~116.5px, i.e. 48% -> 115% of the 101px box width) puts the head back
     roughly where 48% used to land it, while the newly-visible legs -- much
     closer to the origin, so they swing far less -- stay tucked near the
     edge on their own. Computed from the transform math, not verified
     live (this environment's Browser tool can't screenshot local files
     with external image sources) -- check on a real device and nudge
     further if the head still isn't hidden enough at rest. */
  transform: translateY(-50%) translateX(115%) rotate(-42deg);
  z-index: 65;
  flex: none;
  border: none;
  padding: 0;
  background: none;
  display: grid;
  justify-items: center;
  align-items: start;
  filter: drop-shadow(0 8px 14px rgba(20, 52, 59, .35));
  transition: transform .35s cubic-bezier(.4, 0, .2, 1), filter var(--speed);
  cursor: pointer;
}
.assistant-handle:hover,
.assistant-handle:focus-visible {
  transform: translateY(-50%) translateX(0) rotate(0deg);
}
/* 2026-08-09: NOT here -- per Darek, this peek handle is too small for the
   lesson background to meaningfully hide edge raggedness (that's the
   drawer's chat panel, see #chatTabView::before further down); this box
   stays on the flat page background it already sits on. */
/* The whole figure, uncropped -- background-size:contain (not the old
   150%-zoomed circular crop) so the full character fits the box instead
   of showing only a swatch of the face. */
.assistant-handle-face {
  position: relative;
  width: 100%;
  /* Full uncropped-character height -- 2x the visible 125px peek window
     above (which has overflow:hidden), so only the top half (bust)
     actually renders. center/top anchor puts the head at the very top of
     this element, which align-items:start then pins to the top of the
     visible box. */
  height: 250px;
  /* 2026-08-09: elder/sibyl retired -- every subject without its own
     dedicated world (and previously every dark-theme view too, via the
     swap this replaces) now defaults to the universal sci-fi figure
     instead. Per-world overrides below still win via source order. */
  background: url('https://cdn.eduhero.pl/oracle/oracle-uniwersalne-v3.webp') center top / contain no-repeat;
}
/* Per-subject oracle override: Historia gets its own illustrated figure
   (Martynka's first per-world asset) regardless of theme -- wins over the
   theme-swap above via source order (see initOracleWorld in core.js for
   how/when data-oracle-world gets set). More per-world figures slot in
   here the same way as they arrive; elder/sibyl stays the default until
   they do. */
html[data-oracle-world="historia"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-historia-v3.webp'); }
/* 2026-08-08: 17 more per-world figures (R2-hosted, unlike the local
   historia art above) -- same override mechanism, one full-body image per
   world. All share the same 325x860 source canvas; see .mascot-figure
   below for the shared aspect-ratio override. */
html[data-oracle-world="angielski"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-angielski-v3.webp'); }
html[data-oracle-world="niemcy"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-niemcy-v3.webp'); }
html[data-oracle-world="hiszpanski"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-hiszpanski-v3.webp'); }
html[data-oracle-world="francja"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-francja-v3.webp'); }
html[data-oracle-world="polski"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-polski-v3.webp'); }
html[data-oracle-world="gr-jezykowe"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-jezykowe-v3.webp'); }
html[data-oracle-world="matematyka"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-matematyka-v3.webp'); }
html[data-oracle-world="fizyka"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-fizyka-v3.webp'); }
html[data-oracle-world="chemia"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-chemia-v3.webp'); }
html[data-oracle-world="biologia"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-biologia-v3.webp'); }
html[data-oracle-world="geografia"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-geografia-v3.webp'); }
html[data-oracle-world="informatyka"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-informatyka-v3.webp'); }
html[data-oracle-world="gr-kulturowe"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-kulturowe-v3.webp'); }
html[data-oracle-world="gr-spoleczne"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-spoleczne-v3.webp'); }
html[data-oracle-world="biznes"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-biznes-v3.webp'); }
html[data-oracle-world="art"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-art-v3.webp'); }
html[data-oracle-world="uniwersalne"] .assistant-handle-face { background-image: url('https://cdn.eduhero.pl/oracle/oracle-uniwersalne-v3.webp'); }
/* Drawer open: retract further out of the way (its own close button is
   the way back, not this) -- same intent as before, just expressed as an
   extra translateX push instead of a negative `right`, since the peek
   position is now transform-driven. */
.assistant-handle.is-drawer-open { transform: translateY(-50%) translateX(160%) rotate(-42deg); filter: none; }
.assistant-handle[aria-expanded="true"]:not(.is-drawer-open) {
  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent) 55%, transparent)) drop-shadow(0 8px 14px rgba(20, 52, 59, .35));
}
/* 2026-08-08: song-availability + clipboard-count indicator, permanently
   visible (not hover-only) so either is readable at a glance without
   opening the drawer. A FIXED-position sibling of .assistant-handle, not
   a child of it -- the handle itself is rotated/translated for the
   peeking-in-at-an-angle effect, and a badge nested inside that swings
   around with the box, ending up far from where the character visually
   reads as being (the old .assistant-handle-badge this replaces had
   exactly that problem). Whole bubble hides itself via JS
   (syncAssistantBubble in lesson-player-v3-features.js) when there's no
   song AND the clipboard is empty. */
.assistant-speech-bubble {
  position: fixed;
  /* 2026-08-09: moved up+left and above the avatar in stacking order (was
     z-index:64, one below .assistant-handle's 65 -- the avatar could visually
     cover the bubble wherever their boxes overlapped).
     2026-08-10: top 24%->20% (slightly higher) and right 85px->4px (Darek,
     tested live) -- both tuned against .assistant-handle's own edge
     position, which moved on this same date (taller box + translateX
     115%, see above) and left the bubble overlapping the head instead of
     sitting beside it. */
  top: 20%;
  right: 4px;
  z-index: 70;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: .3rem;
  pointer-events: none;
}
.assistant-speech-bubble-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: .3rem;
  background: var(--surface);
  border: 2px solid var(--line);
  border-radius: 99px;
  padding: .3rem .65rem;
  font-size: .78rem;
  font-weight: 700;
  color: var(--ink);
  box-shadow: var(--shadow-soft);
  white-space: nowrap;
}
/* 2026-08-09: a real speech-bubble tail (rotated-square notch) pointing
   right toward the avatar, so each pill actually reads as "she's saying
   this" instead of a floating badge with no visual connection to the
   character. Darek's asked for a proper hand-illustrated bubble asset
   matching the characters' art style -- this CSS-drawn tail is the
   interim version; swap the item backgrounds for an illustrated art
   asset here once one exists, same file structure (one image per pill,
   or one shared bubble shape behind the whole stack). */
.assistant-speech-bubble-item::after {
  content: '';
  position: absolute;
  right: -6px;
  top: 50%;
  width: 11px;
  height: 11px;
  transform: translateY(-50%) rotate(45deg);
  background: var(--surface);
  border-right: 2px solid var(--line);
  border-bottom: 2px solid var(--line);
  border-radius: 0 0 3px 0;
  z-index: -1;
}
.assistant-bubble-prompt::after { background: var(--accent); border-color: var(--accent); }
#assistantBubbleClipCount {
  background: var(--accent);
  color: var(--accent-ink);
  border-radius: 99px;
  padding: 0 .4rem;
  min-width: 1.1rem;
  text-align: center;
}
/* 2026-08-08: unprompted "want a song?" pill (see #assistantBubblePrompt,
   initSongPromptBubble in features.js) -- the only bubble item that's
   actually a <button>, so it needs its own reset + pointer-events:auto to
   override the container's pointer-events:none (that :none exists so the
   informational pills below don't eat clicks meant for the page behind
   them; this one deliberately opts back in). */
.assistant-bubble-prompt {
  pointer-events: auto;
  cursor: pointer;
  font: inherit;
  color: var(--accent-ink);
  background: var(--accent);
  border: 2px solid var(--accent);
}
.assistant-bubble-prompt:hover,
.assistant-bubble-prompt:focus-visible { filter: brightness(1.08); }
@keyframes bubble-pop-in {
  0% { opacity: 0; transform: scale(.6) translateY(6px); }
  55% { opacity: 1; transform: scale(1.1) translateY(-3px); }
  100% { opacity: 1; transform: scale(1) translateY(0); }
}
.assistant-bubble-prompt.is-bouncing-in,
.assistant-speech-bubble-item.is-announcing {
  animation: bubble-pop-in .5s cubic-bezier(.34, 1.56, .64, 1);
}
@keyframes bubble-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.08); }
}
/* Song-ready celebration: the pill itself pulses a few times (its content
   is swapped to an emoji + "gotowa!" message for a few seconds by
   celebrateSongReady() in features.js, then reverts to the steady-state
   icon-only pill), AND the handle swings fully into view like a hover,
   without requiring an actual hover -- so a student who isn't even
   looking at the corner still catches the motion. */
.assistant-speech-bubble-item.is-announcing {
  animation: bubble-pop-in .5s cubic-bezier(.34, 1.56, .64, 1), bubble-pulse 1s ease-in-out 1s 3;
  border-color: var(--accent);
}
/* Swings fully into view (same end-state as :hover) and back out again on
   its own, no hover needed -- a self-contained animation rather than
   combining oracle-breathe's own `transform` keyframes with a static
   translateX(0) override, which would have silently lost the "swing into
   view" part the moment the animation's own transform took over. */
@keyframes oracle-peek-alert {
  /* 2026-08-10: rest state kept in sync with .assistant-handle's own
     translateX(115%) (was 48%, stale after the box height/offset fix
     above) -- otherwise this animation snapped back to the OLD resting
     position at 0%/100%, undoing the edge-alignment fix for its own
     4.5s duration every time it played. */
  0%, 100% { transform: translateY(-50%) translateX(115%) rotate(-42deg); }
  12%, 88% { transform: translateY(-50%) translateX(0) rotate(0deg); }
  50% { transform: translateY(-50%) translateX(0) rotate(0deg) scale(1.05); }
}
.assistant-handle.is-peeking-alert { animation: oracle-peek-alert 4.5s cubic-bezier(.4, 0, .2, 1) 1; }
.assistant-handle.is-pondering,
.assistant-handle.is-speaking { animation: oracle-breathe 1.6s ease-in-out infinite; }
@keyframes oracle-breathe {
  0%, 100% { transform: translateY(0) scale(1); }
  50% { transform: translateY(-2px) scale(1.06); }
}
@media (prefers-reduced-motion: reduce) {
  .assistant-handle.is-pondering,
  .assistant-handle.is-speaking { animation: none; }
}
/* Emoji glyph is now just an a11y/no-image fallback, sat behind the portrait. */
.avatar-face { display: none; }

/* Reveal-passage handle: only shown while matching/gapped-text has taken
   over the passage panel for the exercise itself -- opens the read-only
   overlay with the image/audio/text that panel isn't showing right now. */
.reveal-passage-btn {
  flex: none;
  width: 44px; height: 44px;
  border-radius: 50%;
  border: 2px solid var(--line);
  background: var(--surface-2);
  color: var(--muted);
  font-size: 1.2rem;
  display: grid; place-items: center;
  /* Same 3D bezel as .btn-quiet -- see the comment there. */
  box-shadow: inset 0 1px 0 rgba(255,255,255,.65), inset 0 -1.5px 3px rgba(15,37,64,.1), 0 6px 12px -4px rgba(15,37,64,.28);
  transition: transform var(--speed), border-color var(--speed);
}
html[data-theme="zmierzch"] .reveal-passage-btn,
html[data-theme="kontrast"] .reveal-passage-btn {
  box-shadow: inset 0 1px 0 rgba(255,255,255,.15), inset 0 -1.5px 3px rgba(0,0,0,.35), 0 6px 12px -4px rgba(0,0,0,.4);
}
.reveal-passage-btn:hover { border-color: var(--accent); color: var(--ink); transform: scale(1.06); }
/* Split mode (matching/gapped-text): this toggles the passage panel
   between the exercise and the passage text (toggleRevealPassage,
   core.js) -- pressed state shows which one is currently showing. */
.reveal-passage-btn[aria-pressed="true"] { border-color: var(--accent); background: var(--good-soft); color: var(--ink); }

/* Mobile twin of the peeking .assistant-handle -- there's no spare edge
   margin to peek from on a phone-width screen, but the action bar has an
   open 4th slot, so the drawer opens from there instead. Plain round
   action-bar icon instead of the ridged-grip capsule (which only makes
   sense peeking off an edge).
   Dedicated HEAD-ONLY crops (Darek, screenshot-flagged "malformed avatar"
   2026-08-05), not the desktop handle's full-body art -- this circle is
   only 44px, and the old approach (the SAME full-body image, just zoomed
   180% and offset to `center 10%`/`center 4%` per character to fake a
   face-crop) produced a genuinely bad crop for characters whose face
   isn't at exactly that offset (verified: the default elder's actual
   face landed off-center/partially cropped at those coordinates). Head
   crops are pre-composed square headshots (see assets/img/journey/oracle/
   oracle-*-head.webp) -- no more per-character zoom/position tuning
   needed, `cover` centers correctly for all of them by construction. */
.assistant-handle-mobile {
  display: none;
  width: 44px; height: 44px;
  border-radius: 50%;
  border: 2px solid var(--gold);
  /* 2026-08-10: center top->20%, not dead-center -- Darek flagged the head
     itself getting clipped in this 44px circle. These are square headshot
     crops but the face sits in the upper portion of their own canvas, not
     centered top-to-bottom; a plain `cover`+`center` crop was taking equal
     slivers off top and bottom, and the top slice was cutting into the
     hairline/forehead. Biasing toward the top keeps the whole face in
     frame instead. */
  background: var(--gold-soft) url('https://cdn.eduhero.pl/oracle/oracle-uniwersalne-head-v3.webp') center 20% / cover no-repeat;
  /* Same 3D bezel as .btn-quiet/.reveal-passage-btn -- see the comment on .btn-quiet. */
  box-shadow: inset 0 1px 0 rgba(255,255,255,.5), inset 0 -1.5px 3px rgba(15,37,64,.15), 0 6px 12px -4px rgba(15,37,64,.28);
  position: relative;
  cursor: pointer;
}
html[data-oracle-world="historia"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-historia-head-v3.webp'); }
html[data-oracle-world="angielski"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-angielski-head-v3.webp'); }
html[data-oracle-world="niemcy"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-niemcy-head-v3.webp'); }
html[data-oracle-world="hiszpanski"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-hiszpanski-head-v3.webp'); }
html[data-oracle-world="francja"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-francja-head-v3.webp'); }
html[data-oracle-world="polski"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-polski-head-v3.webp'); }
html[data-oracle-world="gr-jezykowe"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-jezykowe-head-v3.webp'); }
html[data-oracle-world="matematyka"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-matematyka-head-v3.webp'); }
html[data-oracle-world="fizyka"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-fizyka-head-v3.webp'); }
html[data-oracle-world="chemia"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-chemia-head-v3.webp'); }
html[data-oracle-world="biologia"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-biologia-head-v3.webp'); }
html[data-oracle-world="geografia"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-geografia-head-v3.webp'); }
html[data-oracle-world="informatyka"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-informatyka-head-v3.webp'); }
html[data-oracle-world="gr-kulturowe"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-kulturowe-head-v3.webp'); }
html[data-oracle-world="gr-spoleczne"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-spoleczne-head-v3.webp'); }
html[data-oracle-world="biznes"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-biznes-head-v3.webp'); }
html[data-oracle-world="art"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-art-head-v3.webp'); }
html[data-oracle-world="uniwersalne"] .assistant-handle-mobile { background-image: url('https://cdn.eduhero.pl/oracle/oracle-uniwersalne-head-v3.webp'); }
.assistant-handle-mobile[aria-expanded="true"] {
  border-color: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent), var(--shadow-soft);
}
.assistant-handle-mobile-badge {
  position: absolute;
  top: -4px; right: -4px;
  width: 18px; height: 18px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-ink);
  display: grid; place-items: center;
  font-size: .6rem;
  border: 2px solid var(--bg);
}
@media (max-width: 900px) {
  .assistant-handle-mobile { display: grid; place-items: center; }
  .assistant-handle { display: none; }
  /* 2026-08-10: the desktop song/clipboard pill stack is positioned
     relative to the peeking .assistant-handle (top:20%; right:4px), which
     doesn't exist on mobile -- it was rendering stranded in the middle of
     the reading card instead. The mobile avatar already has its own
     equivalent, #assistantHandleMobileBadge (toggled in loadLessonSongs,
     features.js) sitting directly on the action-bar icon -- just hide this
     stack outright rather than trying to reposition it twice. */
  .assistant-speech-bubble { display: none; }
}

/* ---------- Passage reveal modal ---------- */
.passage-modal {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: rgba(10, 22, 25, .6);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(1rem + env(safe-area-inset-top)) calc(1rem + env(safe-area-inset-right)) calc(1rem + env(safe-area-inset-bottom)) calc(1rem + env(safe-area-inset-left));
}
.passage-modal-content {
  position: relative;
  width: min(640px, 100%);
  max-height: 100%;
  overflow-y: auto;
  /* Was flat var(--surface) -- every other surface in this shell
     (.task-card/.reading-card/.action-bar/...) gets the --island-fill
     top-lit gradient + --island-shadow raised-tile treatment, so a plain
     flat card here read as visually broken/inconsistent next to them
     ("colors are all wrong" -- Darek). Same island treatment, same
     tint-wash when a photo backdrop is active (see the data-bg-tint rule
     below), for parity with the rest of the player. */
  background: var(--island-fill);
  border-radius: var(--radius);
  box-shadow: var(--island-shadow);
  padding: 1.2rem;
}
html[data-bg-tint] .passage-modal-content { background: linear-gradient(305deg, color-mix(in srgb, var(--surface) 94%, #dfecbe 6%) 0%, color-mix(in srgb, var(--surface) 82%, #dfecbe 18%) 100%); }
html[data-theme="kontrast"] .passage-modal-content { background: var(--surface); box-shadow: none; }
.passage-modal-close {
  position: absolute;
  top: .8rem; right: .8rem;
  width: 40px; height: 40px;
  border-radius: 50%;
  border: none;
  background: var(--surface-2);
  font-size: 1rem;
  z-index: 1;
}
/* ---------- Song overlay: moved to assets/css/song-player.css ----------
   (shared with the standalone piosenka.php page -- lesson_player.php now
   links that file right after this one, so the cascade is unchanged). */

/* ---------- Trail (vertical, left rail) ---------- */
/* Fixed outside the grid (not a grid column) so it never takes layout
   width away from the centered stage/action-bar -- floats over the
   ambient background instead. Mobile media query below overrides this
   into the off-canvas peeking drawer it already was. */
.trail {
  position: fixed;
  top: 0; bottom: 0;
  /* Center in the gutter between the viewport's left edge and .stage's
     own left edge (stage is centered independently via margin:0 auto,
     see below), rather than pinning flush to left:0 -- the gutter is
     (100vw - stage max-width)/2 per side, so its own center is a quarter
     of that difference in from the edge. Only meaningful once the trail
     is actually docked (>1150px, see breakpoints below); the sub-900px
     bottom-bar layout resets `left` back to 0/full-width itself.
     100vw is divided by --ui-scale to match .stage's own EFFECTIVE
     (zoom-shrunk) layout width above the 901px breakpoint where the
     desktop zoom is active -- `100vw` itself stays pinned to the real
     unzoomed viewport under `zoom` (unlike real page-zoom), but .stage's
     margin:auto centering (a percentage-based layout, not a raw viewport
     unit) DOES resolve against the effective/zoomed width, so this calc
     has to divide manually to land on the same reference frame .stage
     already uses, or the two drift out of sync. */
  left: max(0px, calc((100vw / var(--ui-scale) - 900px) / 4 - var(--trail-w) / 2));
  width: var(--trail-w);
  z-index: 40;
  /* Variant G (Darek, 2026-08-06): dropped the gradient wash + blur Variant F
     added here -- the world background image already reads as blurred on
     the left edge on its own, so the wash was redundant weight on top of it. */
  background: transparent;
  overflow: hidden;
}
/* Stage is max-width:900px centered -- its own margin only clears the
   trail's width (120px/side) once the EFFECTIVE (post-zoom) viewport is
   >= 900+2*120=1140px, i.e. raw width >= 1140*1.25=1425 (--ui-scale is
   1.25x for this entire >=901px range -- see :root). Below that, .trail
   is instead the thin progress bar (see the max-width:1424px media
   query further down) -- there USED to be an off-canvas "peeking
   drawer" state for 901-1424px here (S-curve trail, hidden by default,
   sliding in briefly via .is-peeking on progress), dropped entirely
   (Darek, screenshot-flagged 2026-08-05): it's a position:fixed overlay
   by construction, and .stage's own margin only reserves gutter room
   for the trail in its DOCKED state -- so whenever the drawer actually
   peeked into view in that range it had nowhere reserved to go and sat
   on top of the card/action-bar underneath it instead. "once
   disappeared always should show as a progress bar" (Darek) -- no more
   transient overlay state in this range at all, same always-visible
   thin bar phones already got. */
.trail-scroll {
  position: relative;
  width: 100%;
  /* Without this, height is auto (shrinks to content) -- and content
     height is exactly what renderTrail() (core.js) is trying to MEASURE
     via clientHeight to decide how tall to make the content, a circular
     dependency. On the very first render (before any marks/SVG have a
     height yet) clientHeight came back near-zero, so H fell back to
     segs.length*96 -- a short, wrong, and then permanently-stuck height
     for lessons with few segments (looked "shrunk" vs. lessons with
     enough segments that segs.length*96 happened to exceed the viewport
     and masked the bug). Fixed height from the parent (.trail is
     position:fixed top:0/bottom:0, a definite height) breaks the cycle. */
  height: 100%;
  overflow-x: hidden;
  overflow-y: auto;
  scrollbar-width: none;
}
.trail-scroll::-webkit-scrollbar { display: none; }
.trail-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  min-height: 100%;
}
/* Glossy white "silicone tube" connector (Darek, 2026-08-06: reference image --
   thick rounded pale tube between the marker rings, not a road). Same stacked
   strokes sharing the same `d` (see renderTrail in core.js) as the previous
   road design, just restyled: the dashed centerline (road markings) is gone --
   nothing in the reference reads as lane markings.

   Legibility (Darek, 2026-08-06, mockup preview/trail_legibility_mockup.html):
   the untraveled tube used to carry its own two-layer raised drop-shadow and
   nothing else, so it separated from the backdrop by LUMINANCE alone -- and the
   world photos are pale watercolor pieces whose subjects are literally white
   objects (the Spanish guitar/church/fan, etc). Wherever the S-curve crossed one
   the tube vanished; the traveled --accent half was never affected, which is the
   tell that this was a white-on-white collision, not a "busy background" problem.
   Fixed with an outline stroke UNDER the tube (--trail-outline), so contrast comes
   from HUE, which those regions have none of, and the photo stays untouched.

   Follow-up (Darek, 2026-08-06): outline alone still read as hard to follow
   live, so this moved from mockup variant E to variant F -- the wash and
   halo rejected above are back, but as an ADDITION on top of the outline
   rather than a replacement for it. The outline still does the actual
   white-on-white separation work (a wash/halo can't, per the reasoning
   above); the wash (on .trail itself) and halo (here + .trail-mark) just
   add legibility lift now that there's a non-white line underneath them to
   lift. See .trail's background/backdrop-filter and this rule's filter. */
.trail-svg .trail-path-shadow {
  fill: none;
  stroke: color-mix(in srgb, var(--ink) 30%, transparent);
  stroke-width: 24;
  stroke-linecap: round;
  opacity: .2;
}
.trail-svg .trail-path-outline {
  fill: none;
  stroke: var(--trail-outline);
  stroke-width: 20;
  stroke-linecap: round;
  opacity: .85;
}
.trail-svg .trail-path-bg {
  fill: none;
  stroke: color-mix(in srgb, var(--surface) 92%, white);
  stroke-width: 13;
  stroke-linecap: round;
  /* Variant F: a soft white halo back on top of the outline (Darek,
     2026-08-06 -- outline alone still wasn't enough). Halo alone was
     rejected in the mockup because a white glow can't separate a white
     tube from a white photo subject -- but it doesn't have to carry that
     job here, the outline already does. It's just adding lift/legibility
     on top, same as the .trail-mark rings below. */
  filter: drop-shadow(0 0 5px rgba(255,255,255,.85)) drop-shadow(0 2px 4px rgba(15,37,64,.25));
}
.trail-svg .trail-path-done {
  fill: none;
  stroke: var(--accent);
  stroke-width: 13;
  stroke-linecap: round;
  filter: drop-shadow(0 0 5px rgba(255,255,255,.6)) drop-shadow(0 1px 2px rgba(15,37,64,.25));
  transition: stroke-dashoffset .6s ease;
}
.trail-marks {
  position: relative;
  display: block;
  margin: 0; padding: 0;
  list-style: none;
  height: 100%;
}
.trail-mark {
  position: absolute;
  transform: translate(-50%, -50%);
  display: grid;
  place-items: center;
  /* 38px -> 76px (Darek, 2026-08-05: "the whole container should be twice as
     big"). The 38px size was a deliberate shrink (see git history) to fix
     edge-clipping against .trail's old 120px width -- that clearance problem
     is solved differently now (--trail-w widened 120->175px + the S-curve's
     amplitude eased 0.24->0.19 in renderTrail, core.js) rather than by
     keeping the marker small, so it's safe to grow back up.
     Fully transparent + backdrop-blur ("try it") instead of the old solid
     --surface fill + solid border -- same frosted-glass language as .lc-play
     elsewhere (flashcards_library.css): a thin translucent border for
     definition instead of a flat opaque circle, so the marker reads as
     floating over the path rather than a solid disc sitting on it. */
  /* 76px -> 61px (Darek, 2026-08-05: "reduce size by 20%" after seeing it
     live) -- comfortably inside the clearance --trail-w/amplitude were
     already tuned for at 76px, so no further layout changes needed here. */
  width: 61px; height: 61px;
  border-radius: 50%;
  /* Same white-on-white collision the connector had (see .trail-path-outline):
     --line at 70% is a pale neutral, invisible over the photos' white subjects.
     Matches the connector's outline hue so ring and tube read as one object,
     and the fill goes up 30% -> 72% because a nearly-transparent disc let the
     photo's own detail run straight through the icon behind it. */
  border: 3px solid var(--trail-outline);
  background: color-mix(in srgb, var(--surface) 72%, transparent);
  backdrop-filter: blur(8px) saturate(150%);
  -webkit-backdrop-filter: blur(8px) saturate(150%);
  font-size: 1rem;
  padding: 0;
  /* Variant F halo ring, matches the path's (see .trail-path-bg above) --
     same "lift on top of the outline, not instead of it" reasoning. */
  box-shadow: 0 0 0 4px rgba(255,255,255,.5), 0 4px 12px rgba(15,37,64,.28);
  transition: border-color var(--speed), transform var(--speed), background var(--speed);
}
.trail-mark[data-state="done"] {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--good-soft) 85%, transparent);
}
.trail-mark[data-state="active"] {
  border-color: var(--gold);
  background: color-mix(in srgb, var(--gold-soft) 90%, transparent);
  transform: translate(-50%, -50%) scale(1.22);
  box-shadow: 0 0 0 6px color-mix(in srgb, var(--gold) 25%, transparent), 0 0 0 10px rgba(255,255,255,.45), 0 6px 16px rgba(15,37,64,.32);
}
/* 28px -> 65px, sized to the regrown .trail-mark above (Darek, 2026-08-05:
   "icon itself should be about 85% of" the 76px container). Now that the
   marker is transparent + backdrop-blur instead of a solid circle, there's
   no hard border edge to bleed past -- the icon just needs to sit inside
   the circle's visual bounds, which 85% comfortably does. Kept the same
   layered "floating above the surface" drop-shadow treatment as the lesson/
   game/nav icons elsewhere (see flashcards_library.css/header.css), scaled
   up with the icon. */
.trail-icon-img {
  display: block;
  width: 52px; height: 52px;
  object-fit: contain;
  pointer-events: none;
  filter: drop-shadow(0 2px 2px rgba(15,37,64,.2)) drop-shadow(0 7px 11px rgba(15,37,64,.32));
}
/* "Player marker" (Darek, 2026-08-05: "the player icon should be the
   same size as the journey markers") -- full-size (same .trail-icon-img
   size as any type icon), REPLACING the active stop's own type icon
   rather than a small corner badge layered over it (a 26px badge read as
   tiny/insignificant next to a 56px marker -- now 38px, see above, but
   the "full-size, not a corner badge" call still holds at the smaller
   scale). Both icons are always in the
   DOM (see the mark's own markup in renderTrail, core.js) as two
   separate wrapper spans, swapped by CSS alone via [data-state="active"]
   -- no JS innerHTML swapping needed, and the original type icon is
   never lost/needs restoring when the student moves on. */
.trail-mark .trail-player-icon { display: none; }
.trail-mark[data-state="active"] .trail-type-icon { display: none; }
.trail-mark[data-state="active"] .trail-player-icon { display: block; }
.trail-mark .mark-label {
  /* Vertical trail is too narrow a column for per-marker text -- world
     identity is legible at a glance from the icon + position alone. */
  display: none;
  position: absolute;
  top: calc(100% + 2px);
  font-size: .62rem;
  color: var(--muted);
  white-space: nowrap;
  pointer-events: none;
}

/* ---------- Stage ---------- */
/* Single vertically-stacked card, always -- journey_player's layout, not
   the old two-column desktop / stacked-mobile split. Reading and task
   content share the same column and swap via data-mode instead of
   sitting side by side. */
.stage {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: auto minmax(0, 1fr);
  gap: .8rem;
  padding: .5rem 1.1rem 1rem;
  min-height: 0;
  max-width: 900px;
  width: 100%;
  margin: 0 auto;
  overflow: hidden;
}

/* Reading collapses to a peek sheet above the task by default. The old
   always-visible "Pokaż tekst lekcji" trigger bar cost a whole row for a
   job the action bar's reveal-passage icon now covers -- kept in the DOM
   (aria-expanded state still tracked, see initHelperBasics/go()) but
   never shown. */
.reading-panel { order: 0; flex: none; }
.reading-peek { display: none; }
html[data-theme="kontrast"] .reading-peek { background: var(--surface); box-shadow: none; }
.reading-body {
  display: none;
  position: fixed;
  /* Matches the card's own horizontal footprint -- centered across the
     FULL viewport, not offset by the trail's width. That offset made
     sense back when the trail was a reserved grid column pushing the
     content area's start point right by var(--trail-w); it's a fixed/
     floating overlay now (see .trail), so the content area -- and this
     sheet -- are centered on the whole viewport regardless of it. */
  left: 0; right: 0;
  max-width: calc(900px - 2.2rem);
  margin: 0 auto;
  /* Stops well above the action bar -- MUST stay reachable so there's
     always a way out, not just the sheet's own close button (a student
     who can't find the X and can't see the bar behind the sheet is
     genuinely stuck). */
  bottom: 104px;
  top: max(12vh, 90px);
  z-index: 55;
  background: var(--bg);
  border-radius: 22px;
  border: 3px solid var(--accent);
  padding: .8rem .8rem .8rem;
  box-shadow: 0 12px 40px rgba(0,0,0,.25);
  overflow-y: auto;
}
@media (max-width: 900px) {
  /* Trail is an off-canvas drawer here (doesn't occupy layout width), so
     the card runs edge-to-edge minus .stage's own padding -- match that,
     not the desktop trail-column offset. */
  .reading-body { left: 0; right: 0; max-width: calc(100% - 1.8rem); }
}
.reading-panel.is-open .reading-body { display: flex; }
.reading-sheet-close {
  position: absolute;
  top: .6rem; right: .6rem;
  width: 40px; height: 40px;
  border-radius: 50%;
  border: none;
  background: var(--surface);
  box-shadow: var(--shadow-soft);
  font-size: 1rem;
  z-index: 1;
}
.reading-panel.is-open .reading-peek { border-color: var(--accent); }

/* During reading steps the panel is inline, not a sheet. */
.player-shell[data-mode="reading"] .reading-peek { display: none; }
.player-shell[data-mode="reading"] .reading-body {
  display: flex;
  position: static;
  padding: 0;
  border: none;
  box-shadow: none;
  border-radius: 0;
  background: transparent;
  /* The base rule's max-width/margin (sized+centered for the fixed-
     position SHEET variant of this element) otherwise leak through here
     too, since neither is touched by the properties above -- shrank the
     inline reading card noticeably narrower than the action bar below it
     even though both are meant to match .reading-panel's already-correct
     width. */
  max-width: none;
  margin: 0;
}
.player-shell[data-mode="reading"] .reading-panel { flex: 1; min-height: 0; }
.player-shell[data-mode="reading"] .task-panel { display: none; }
/* Sheet-only affordance -- reading/split modes show the passage inline
   with nothing to "close" back to. */
.player-shell[data-mode="reading"] .reading-sheet-close,
.player-shell[data-mode="split"] .reading-sheet-close { display: none; }
/* .stage's 2nd row (grid-template-rows: auto minmax(0,1fr)) exists for the
   task-panel -- with it hidden, that row still reserves its own empty
   track and the reading-card stops short of the action-bar with a dead
   gap below it. Collapse to a single row so reading-panel/reading-card's
   own flex:1 actually has the full height to stretch into. */
.player-shell[data-mode="reading"] .stage { grid-template-rows: minmax(0, 1fr); }

/* Task mode's reveal-passage: #readingBody gets physically relocated
   INTO #taskCard by JS (see openReadingSheet/closeReadingSheet in
   core.js) instead of floating as a separately-positioned overlay --
   it's the same box as the task card, so it's automatically the right
   size/position with no matching math to get wrong. Normal task-card
   content hides while it's showing; .reading-card's own island chrome
   (background/border/shadow) is stripped since the task-card underneath
   it already draws that -- otherwise it'd be a card inside a card. */
.task-card.is-showing-passage > .task-head,
.task-card.is-showing-passage > .task-instructions,
.task-card.is-showing-passage > .task-body,
.task-card.is-showing-passage > .task-feedback { display: none; }
/* The task-card's own lesson-head (title/level/subject) stays as the
   header for whatever's showing inside the card -- the reading-card's
   own copy (nested inside it now too) would just duplicate that line.
   Hides only the title/exit-button CONTENT, not the whole row anymore --
   #btnMediaCorner now lives inside this same .card-lesson-head (see its
   own comment), and hiding the entire row was collateral-hiding it too:
   whenever a student revealed the passage from inside a task (the one
   narrow-screen path to the media button at all, since the wide floating
   panel isn't there), the thumbnail vanished completely with nothing
   left to open the gallery. Keeping the row (empty of title text, just
   holding the button) costs a small reserved band above the passage in
   that context, but the button staying reachable matters more. */
#taskCard .reading-card > .card-lesson-head > .topbar-title,
#taskCard .reading-card > .card-lesson-head > .card-exit-btn { display: none; }
#taskCard #readingBody {
  display: flex;
  position: static;
  padding: 0;
  border: none;
  box-shadow: none;
  border-radius: 0;
  background: transparent;
  flex: 1;
  min-height: 0;
}
#taskCard .reading-card {
  background: transparent;
  border: none;
  box-shadow: none;
  border-radius: 0;
}

/* Matching/gapped-text: both panels needed at once (the passage panel
   holds the definitions/gapped text students interact with, not just
   reference material) -- keep both visible, stacked, instead of the
   peek-sheet used for plain reading. */
.player-shell[data-mode="split"] .stage { grid-template-rows: minmax(0, 1fr) minmax(0, auto); }
.player-shell[data-mode="split"] .reading-peek { display: none; }
.player-shell[data-mode="split"] .reading-body {
  display: flex;
  position: static;
  padding: 0;
  border: none;
  box-shadow: none;
  border-radius: 0;
  background: transparent;
  /* Same base-rule max-width/margin leak as the reading-mode override above. */
  max-width: none;
  margin: 0;
}
.player-shell[data-mode="split"] .reading-panel { flex: 1; min-height: 0; }
.player-shell[data-mode="split"] .task-panel { flex: none; max-height: 34vh; }
.player-shell[data-mode="split"] .task-card { padding: .7rem .8rem; }
.player-shell[data-mode="split"] .task-head { display: none; }
/* Polecenie ZOSTAJE widoczne w trybie split (bylo schowane razem z .task-head).
   Nauczycielka ELC (2026-09-24): przy "Dopasuj pary" i lukach uczen nie widzial,
   co ma zrobic -- panel u gory mowi tylko "Dopasuj pary", a tresc polecenia
   z edytora (np. jezyk, w ktorym odpowiadac) nie trafiala na ekran wcale. */
.player-shell[data-mode="split"] .task-instructions { margin: 0 0 .5rem; }
/* Matching/gapped-text content and the task-card below it used to have
   different horizontal padding (1.2rem vs .8rem), so their visible
   content edges didn't line up even though the outer cards were the
   same width -- read as "different width" even though they weren't. */
.player-shell[data-mode="split"] .chunk-viewport { padding: .3rem .8rem .6rem; }
/* The passage panel's chunk-viewport centers/caps plain reading chunks
   (see .chunk-viewport above), but split mode repurposes that same
   element for the matching/gapped-text exercise content itself, which
   must stretch full width, not shrink-center like short reading text. */
#chunkViewport .match-defs,
#chunkViewport .gapped-passage { align-self: stretch; max-width: none; }
/* ...tyle ze rozciagalo sie WYLACZNIE dziecko, a caly lancuch nad nim
   (`#readingCard` > `#readingBody`) jest zwezany do tresci przez
   `.reading-panel { align-items: center }`. Intencja z komentarza wyzej nie
   dochodzila wiec do skutku: zmierzone 2026-09-11 na rusztowaniu (1878x913,
   dopasowywanie), panel czytanki mial **1078 px**, a plansza z definicjami
   **496 px** -- 582 px czystego tla obok cwiczenia, przy definicjach zwezonych
   do jednej kolumny tekstu. Karta z bankiem slow pod spodem miala przez ten czas
   1071 px, wiec dwa pojemniki tego samego cwiczenia mialy dwie rozne szerokosci.
   Po rozciagnieciu obie konczy `max-width` karty czytanki, czyli krawedzie
   pokrywaja sie dokladnie -- o to samo chodzi w komentarzu o wyrownaniu wyzej. */
.player-shell[data-mode="split"] #readingCard,
.player-shell[data-mode="split"] #readingBody { align-self: stretch; }

.task-panel { order: 1; min-height: 0; }

/* ---------- Reading panel ---------- */
.reading-panel {
  min-height: 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  position: relative;
}
.reading-body {
  /* display is controlled by the universal sheet/inline rules above
     (default none, shown via .is-open or data-mode="reading"/"split") --
     this just supplies the inner flex layout once it IS shown. */
  min-height: 0;
  flex-direction: column;
  gap: .8rem;
  flex: 1;
}
/* #mediaZone (the reading card's passage media) has two homes
   (redesigned 2026-08-14 -- third rewrite tonight, see git log for the
   two before this: a side-by-side flex column sharing width with the
   card, and before that a fixed corner that fought .stage's centering
   directly. Neither was right. Darek's actual spec, stated plainly
   after seeing the column version live: the image is NEVER part of the
   reading passage / action bar's own width -- it lives entirely outside
   that box, in the screen's real estate above the assistant avatar, and
   ONLY drops back to a click-to-open thumbnail when there genuinely
   isn't room for that out there. Two states:
   - >=1800px: position:fixed against the viewport, top-right, outside
     .stage's 900px box entirely -- the card and action bar never know
     it exists, never resize for it. 1800px is not arbitrary: it's the
     width where a 900px card + this panel's 240px + its own 10px offset
     + a real gutter all genuinely fit without .stage's natural centering
     margin running out -- verified empirically (swept viewport widths
     live; the site's zoom:1.25 makes hand-calculated thresholds
     unreliable, learned that twice already tonight).
   - <1800px: the ORIGINAL <900px fallback (small thumbnail button in
     .card-lesson-head, opens #mediaOverlay on click -- see
     openMediaOverlay in core.js) now simply extended to cover this
     whole range instead of stopping at 900px. No new "incorporated
     into the reading pane" tier -- that's what the rejected column
     version was. Below 1800px there is no persistent image block at
     all, just the click-to-open button, exactly like it already worked
     below 900px before any of tonight's changes. */
.media-zone { flex: none; }
#mediaZone:not(.in-overlay) { display: none; }

/* A VIDEO LESSON OPTS OUT OF ALL OF THAT. Everything above is written for
   the passage photo -- an illustration that belongs outside the reading
   column and drops to a click-to-open thumbnail when there is no room. A
   video is the opposite: it is the lesson itself, the transcript beside it
   is the supporting material, and hiding it behind a thumbnail (or floating
   it as a 240px corner panel on a wide screen) is exactly the "still a
   small preview in the corner" Darek reported. So for a video the player is
   an ordinary in-flow block at the top of the reading column, at every
   width, and the thumbnail button that would open it in an overlay has
   nothing left to do. data-media-kind is set by initVideoLesson.
   Specificity beats the base and the 1800px rules on its own (one more
   attribute + a class), so this does not depend on source order. */
.player-shell[data-media-kind="video"] #mediaZone:not(.in-overlay):not([hidden]) {
  display: block;
  /* STICKY, not static. Darek: "once I start the video the lyrics container
     stretches and introduces a scrollbar, pushing the video up out of view
     partially... the video should sit stably in its container while the
     captions... scroll up." #chunkViewport is bounded now (see its own
     rule), so it was tempting to assume the whole video+card block always
     fits the viewport and the page would never need to scroll for
     captions -- wrong on an ordinary browser window: video (full width,
     16:9) + card chrome + the 340px caption box easily exceeds a typical
     window's visible height once toolbars/tabs are accounted for, so the
     PAGE itself still scrolls to bring a later caption's scrollIntoView
     target into view -- and with the video merely static (in normal
     flow), that page scroll carries the video up and off-screen with it.
     Sticky keeps it pinned regardless. This is safe to bring back here
     (it was removed from the immersive version of this rule last round)
     specifically BECAUSE captions are now bounded: the failure sticky
     caused before was unbounded caption text scrolling THROUGH the
     video's rendered box, which cannot happen any more since
     #chunkViewport never grows past its own small window. */
  position: sticky;
  top: .6rem;
  width: 100%;
  /* Darek, on a 1080p panel at 125% Windows scaling: "captions in normal
     mode are too small, the video too big. make compromises."
     A width-only cap is what caused it. 900px of 16:9 is 506px TALL, and
     that machine has ~690 CSS px of height once Windows scaling and this
     shell's own desktop zoom (--ui-scale 1.25) are both applied -- so the
     player alone claimed three quarters of the window and the caption box
     was pushed off the fold. The third term caps the player by HEIGHT:
     72vh of width is 40.5vh of picture at 16:9, which leaves the captions,
     the card chrome and the action bar room to coexist without scrolling.
     Width still wins on short-and-wide windows, height on tall-and-narrow
     ones -- min() picks whichever binds first. */
  max-width: min(760px, 100%, 52vh);
  margin: 0 auto .9rem;
  right: auto;
  z-index: 3;
}
.player-shell[data-media-kind="video"] .media-corner-btn { display: none; }
/* The reading card sits under the player instead of sharing a row with it,
   so it must not also try to fill the column's whole height. */
.player-shell[data-media-kind="video"] .reading-body {
  justify-content: flex-start;
  /* Same trap as the immersive reading-body overflow fix below, hitting
     NORMAL mode instead: .reading-body's base rule (its "sheet" form) is
     overflow-y:auto, so it is its own scroll container regardless of
     immersive state. #chunkViewport is now ALSO independently scrollable
     (see its own rule) -- two nested scrollers fighting over the same
     content is what gave a live measurement (lesson 4486, normal mode)
     #readingCard squeezed to 75px by the flex layout (mediaZone's natural,
     non-shrinking height ate readingBody's own bounded box) with
     #chunkViewport's rect landing ABOVE its own parent's top edge --
     genuinely inconsistent geometry, not a rendering artifact. Video
     content is meant to overflow the PAGE here, not a clipped inner box:
     the player is always visible by being first in flow, and the caption
     window scrolls itself. */
  overflow: visible;
  /* flex:1 (base) makes .reading-body GROW/SHRINK to fill whatever
     .reading-panel allocates it -- a fixed share of the page, not "as tall
     as my content needs". With overflow:visible alone the box stays that
     same bounded size and .reading-card (also flex:1 below) still gets
     squeezed into the leftover after the video's own non-shrinking height
     -- measured live: readingCard computed to 75px while #chunkViewport,
     its own non-shrinking child, needed 274px, and the two disagreed
     enough that #chunkViewport's rect landed ABOVE #readingCard's own top
     edge. flex:none makes this take its REAL total height (video + card),
     and the page scrolls past that like any normal block of content. */
  flex: none;
}
.player-shell[data-media-kind="video"] .reading-card {
  flex: none;
  overflow: visible;
}

/* ===== NORMAL-MODE VIDEO LIGHTBOX (#btnRevealPassage, .video-expanded) =====
   The non-immersive twin of the expanded corner player further down this
   file, and it exists for a bug normal mode had all along: on an exercise
   step base CSS hides .reading-panel, so the video is off screen but STILL
   PLAYING (display:none does not unload an iframe), and the one control that
   could bring it back -- #btnRevealPassage -- went through openReadingSheet(),
   which relocates #readingBody into .task-card. Moving an iframe reloads its
   document, so the way back to the video was also the thing that destroyed
   it. The class swaps the same node to a centred lightbox WITHOUT touching
   the DOM, so playback runs straight through (Darek: "when switching it
   should not affect playback").

   Scoped :not([data-immersive="1"]) so the immersive rules further down keep
   sole ownership of that mode -- one class, but each mode states its own
   geometry rather than one inheriting the other's and drifting.

   .reading-panel has to be raised as well as .reading-body: it carries
   z-index:20 and is therefore a STACKING CONTEXT, so a z-index on the child
   alone is trapped inside it and the exercise paints straight over the
   "lightbox" -- the same trap this file already documents for the immersive
   copy and for the dock. */
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded .reading-panel {
  z-index: 62;
}
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded[data-mode="task"] .reading-body,
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded[data-mode="split"] .reading-body {
  /* display is re-declared because base leaves .reading-body at display:none
     for task/split, and a position:fixed element inside a display:none
     ancestor is not rendered at all (measured 0x0 when the immersive version
     of this was first written). The ancestor is what has to reappear. */
  display: block;
  position: fixed;
  top: 50%; left: 50%; right: auto; bottom: auto;
  transform: translate(-50%, -50%);
  width: min(900px, 92vw);
  max-width: min(900px, 92vw);
  height: auto;
  margin: 0; padding: 0; border: none;
  overflow: hidden;
  border-radius: 16px;
  z-index: 63;
  /* The scrim is this element's own spread shadow rather than a separate
     overlay node: nothing to insert, nothing to clean up, and it cannot
     desynchronise from the state that owns it. */
  box-shadow: 0 0 0 100vmax rgba(8, 15, 20, .62),
              0 30px 80px -20px rgba(0, 0, 0, .55);
}
/* Only the player travels; the transcript, the tools and the sheet's own
   close button stay behind with the hidden card. */
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded[data-mode="task"] .reading-body > .reading-card,
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded[data-mode="split"] .reading-body > .reading-card,
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded[data-mode="task"] .reading-body > .reading-sheet-close,
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded[data-mode="split"] .reading-body > .reading-sheet-close { display: none; }
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded #mediaZone:not(.in-overlay):not([hidden]) {
  position: static;
  width: 100%;
  max-width: none;
  margin: 0;
}
/* The way back out. Base keeps .video-expand-btn at display:none and the
   reveal rules that show it are immersive-scoped, so without this the
   lightbox would have no close control in normal mode at all -- and unlike
   immersive there is no second toggle nearby. Always opaque: it is the exit,
   not a hover affordance. */
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded .video-expand-btn {
  display: grid;
  place-items: center;
  position: absolute;
  top: 6px; right: 6px;
  width: 34px; height: 34px;
  border: none;
  border-radius: 9px;
  background: rgba(8, 15, 20, .72);
  color: #fff;
  font-size: 1rem;
  cursor: pointer;
  opacity: 1;
  z-index: 2;
}
.player-shell[data-media-kind="video"]:not([data-immersive="1"]).video-expanded .video-expand-btn:hover {
  background: rgba(8, 15, 20, .9);
}
/* Darek: "the heading transkrypcja is not necessary" -- the video and the
   caption window underneath it already say what this is without a label
   sitting between them. Hides the <h2> only; .reading-tools (audio/etc.)
   stays, it just no longer has a title next to it. */
.player-shell[data-media-kind="video"] #readingTitle { display: none; }
/* Darek: "the title and level and subject badges could all be in the same
   lyrics container appended to the top, so they don't reserve their own
   separate row." initVideoLesson relocates #lessonTitleReading's own
   .topbar-title into #chunkViewport (first child, ahead of the
   transcript) -- .card-lesson-head has nothing left to show for video
   (its other two children, .card-exit-btn and .media-corner-btn, are
   already hidden here), so the row itself goes too, reclaiming the
   vertical space it used to reserve permanently above the caption box. */
.player-shell[data-media-kind="video"] .card-lesson-head { display: none; }
/* Arrows only for video (Darek: "no dots, just arrows"). The dots index
   chunks -- a handful of tiles -- and a transcript is routinely 14+ lines,
   so rendering one dot per caption would be a scrollbar cosplaying as
   navigation. The arrows themselves stay and seek caption-to-caption; see
   LP.seekCaption in core.js. */
.player-shell[data-media-kind="video"] #chunkDots { display: none; }
/* Once inside #chunkViewport this is no longer a grid cell sharing a row
   with the (hidden) media-corner button -- plain block flow, with a
   divider so it doesn't visually run straight into the first caption
   line once both are scrolling past together. */
.player-shell[data-media-kind="video"] #chunkViewport > .topbar-title {
  padding-bottom: .6rem;
  margin-bottom: .6rem;
  border-bottom: 1px solid var(--line);
}
/* Immersive already strips every other piece of this chrome (trail,
   .reading-head, .card-lesson-head itself) for a deliberately minimal
   screen -- the relocated title/chips would be the one piece of that
   chrome sneaking back in via a different parent. Wasn't asked for here;
   kept out rather than guessed at. */
.player-shell[data-immersive="1"] #chunkViewport > .topbar-title { display: none; }
/* Wyprawa/Skupienie moves to its action-bar twin for video lessons -- see
   the markup comment on #btnImmersiveVideo. The original stays in the DOM
   (still does the exact same job for every OTHER lesson type) but is
   pointless here: it sat right against the video ("in the way") and only
   existed while the reading step's card was on screen, i.e. never during
   an exercise. */
.player-shell[data-media-kind="video"] .reading-tools #btnImmersive { display: none; }
/* Darek: "the next button (action bar) is now asymmetrical. the action bar
   should be perfectly centered always." Widening the grid to fit a 4th
   column was exactly wrong -- the whole reason #btnPrimary sits in a FIXED
   grid column (see the "Fixed 5-column grid" comment above .action-bar) is
   so it stays dead-centre no matter which OTHER icons are showing for a
   given step; adding a column changes what "centre" even is; e.g. on the
   reading step (no reveal-passage icon yet) the bar had back + primary +
   [empty] + immersive-video, primary sitting at column 2 of 4 -- visibly
   left of centre, not the middle of anything.

   #btnImmersiveVideo does not join that grid at all. It floats OUTSIDE it,
   the same way #assistant-handle already floats beside the whole shell
   (position:fixed, no grid participation) -- anchored to .action-bar's own
   box (which is position:relative, see its base rule) so it always sits
   just past the pill's right edge regardless of viewport width, without
   ever being a grid item #btnPrimary's centring has to account for. */
.player-shell[data-media-kind="video"] #actionBar #btnImmersiveVideo {
  position: absolute;
  top: 50%;
  left: 100%;
  transform: translateY(-50%);
  margin-left: .6rem;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  padding: 0;
}
@media (max-width: 900px) {
  /* Off the right edge of a phone-width bar there is no room to spare --
     drop it inside the bar's own right padding instead, still absolute
     (still outside the grid), riding along the TOP of the bar rather than
     vertically centred so it clears the primary disc beneath it. */
  .player-shell[data-media-kind="video"] #actionBar #btnImmersiveVideo {
    left: auto;
    right: .3rem;
    top: -2.4rem;
    transform: none;
    width: 38px;
    height: 38px;
  }
}
/* Darek: "in focus mode the focus mode icon is still there but weirdly
   placed and doesn't work." Its sibling #btnImmersive is hidden the moment
   immersive is on because its WHOLE ROW (.reading-head) is (base CSS,
   "can only ever turn the mode ON" -- #btnImmersiveExit further down is the
   way back out). #btnImmersiveVideo never got the same treatment: nothing
   hid it, so it stayed anchored to .action-bar's own box -- which every
   OTHER control in this bar abandons in immersive (see #btnPrimary etc,
   all position:fixed against the dock slots) while this one alone did not
   -- rendering it disconnected from wherever the real dock now is. And
   clicking "enter immersive" while already immersive is a confusing no-op,
   which is what read as "doesn't work". Same fix as its sibling: gone the
   moment the mode is already on. */
.player-shell[data-immersive="1"][data-media-kind="video"] #actionBar #btnImmersiveVideo {
  display: none;
}

@media (min-width: 1800px) {
  #mediaZone:not(.in-overlay):not([hidden]) {
    display: block;
    position: fixed;
    top: 12px;
    right: 10px;
    width: 240px;
    z-index: 40;
    margin: 0;
  }
  #mediaZone:not(.in-overlay) .media-carousel-slide img,
  #mediaZone:not(.in-overlay) .media-carousel-slide video { height: 160px; }
  /* The corner thumbnail button is redundant once the photo is already
     floating on its own, always-visible, no click needed. */
  .media-corner-btn { display: none; }
  /* Darek, 2026-08-14: dead-center on the right edge, not upper-third --
     specifically so the floating photo panel above (top:12px, ~220px
     tall) has the whole top of the edge to itself instead of competing
     with the avatar for the same territory. 50% + the existing
     translateY(-50%) in .assistant-handle's own transform is the
     correct way to center this regardless of the rotate(-42deg) peek
     transform's effect on the final bounding box -- percentage-plus-
     transform centering resolves correctly in layout before the box's
     own rotation skews its rendered edges, so no rotation-aware pixel
     math is needed here. (The original upper-third choice, and this
     tier's since-removed max(33%,355px) floor, existed only to dodge
     the photo panel or the task-card on short viewports -- with the
     avatar now centered lower, on a normal-height screen, neither
     collision applies at this width.) */
  .assistant-handle { top: 50%; }
  /* Verified live, not estimated -- the old -90px gap between this and
     the avatar's own top (max(20%,265px) vs max(33%,355px)) doesn't
     carry over cleanly to a 50% anchor, since the rotate(-42deg) peek
     transform skews how far the box's rendered top edge sits from its
     nominal `top` position (measured ~317px of visual top-edge offset
     against a 450px anchor at 900px viewport height, not the ~158px a
     plain half-height translateY would predict). -90px still left the
     two boxes overlapping by ~53px live; -145px clears with a real
     ~12px gap, confirmed at two different viewport heights (900 and
     1080 -- gap holds constant since both anchors are the same
     percentage of viewport height). */
  .assistant-speech-bubble { top: calc(50% - 145px); }
}
.media-overlay-content { width: min(560px, 100%); }
#mediaOverlayHost .media-carousel-slide img,
#mediaOverlayHost .media-carousel-slide video { height: 220px; }
.media-figure {
  margin: 0;
  position: relative;
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
  box-shadow: var(--shadow-soft);
}
.media-figure img, .media-figure video { display: block; width: 100%; max-height: 220px; object-fit: cover; }
.media-figure figcaption { padding: .4rem .8rem; font-size: .8rem; color: var(--muted); }
.media-zoom-btn {
  position: absolute; top: .6rem; right: .6rem;
  width: 42px; height: 42px;
  border-radius: 50%;
  border: none;
  background: color-mix(in srgb, var(--surface) 85%, transparent);
  font-size: 1.05rem;
}
.caption-line {
  padding: .5rem .8rem;
  min-height: 2.2em;
  font-size: .95rem;
  background: var(--surface-2);
}
.caption-listbox { padding: .3rem .8rem .6rem; font-size: .85rem; color: var(--muted); }
.caption-list { margin: .3rem 0 0; padding-left: 1.2rem; }

.reading-card {
  position: relative; /* stacking/overlay context for this card's own children */
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--island-fill);
  border-radius: var(--radius);
  box-shadow: var(--island-shadow);
  border: 2px solid var(--line);
  overflow: hidden;
}
html[data-theme="kontrast"] .reading-card { background: var(--surface); box-shadow: none; }
.reading-head {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: .7rem 1.1rem .4rem;
}
.reading-title { margin: 0; font-size: .95rem; color: var(--muted); letter-spacing: .04em; text-transform: uppercase; }
.reading-tools { display: flex; gap: .4rem; }
.tool-btn {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  border: 2px solid var(--line);
  background: var(--surface-2);
  border-radius: 99px;
  padding: .35rem .8rem;
  font-size: .85rem;
}
.tool-btn[aria-pressed="true"] { border-color: var(--accent); background: var(--good-soft); }

.chunk-viewport {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: .8rem 1.2rem;
  line-height: 1.65;
  scroll-behavior: smooth;
  /* Grows to fill whatever space .reading-card has (flex:1/min-height:0
     up the chain) and scrolls its own content when the passage doesn't
     fit -- the page itself never scrolls. Scrollbar hidden (still fully
     scrollable via wheel/touch/keyboard) so a long passage doesn't show
     a visible scroll track sitting inside the frosted tile. */
  scrollbar-width: none;
  /* Showcase the current chunk -- centered both axes, not pinned to the
     top-left of whatever space happens to be available. Fluid, not a
     mobile/desktop toggle (Darek, 2026-08-07: "the font size changes
     from device to device... make it look the same") -- the old rule
     was font-size:1.25rem here plus a flat 1rem override below 900px,
     which combined with html's own zoom:var(--ui-scale) (desktop-only
     1.25x, applied ONLY inside the min-width:901px block below -- see
     the .stage comment near the top of this file) into a hard cliff
     right at the 900/901px boundary: mobile rendered at a true 1rem,
     desktop at 1.25rem*1.25zoom = ~1.56rem -- text jumped ~50% larger
     crossing one pixel of viewport width, and mobile itself never grew
     smoothly from phone to tablet width either. clamp() now grows the
     size continuously with viewport width instead of jumping; the
     desktop zoom's own contribution is cancelled separately, in the
     min-width:901px block below (NOT here -- --ui-scale is a flat 1.25
     in :root regardless of width, only actually APPLIED via zoom at
     901px+, so dividing by it unconditionally here would incorrectly
     shrink mobile text that was never zoomed in the first place). */
  font-size: clamp(1rem, .85rem + 1vw, 1.25rem);
  display: flex;
  flex-direction: column;
  justify-content: safe center;
  align-items: center;
}
.chunk-viewport::-webkit-scrollbar { display: none; }
/* Paged mode (default): each chunk its own frosted-glass tile -- same
   visual language as the answer buttons and the play button -- instead
   of plain floating text that didn't read as "content." Exactly one
   .is-current tile visible at a time (showChunk, core.js). */
.reading-chunk {
  display: none;
  animation: chunkIn .3s ease;
  width: 100%;
  max-width: 62ch;
  padding: 1.5rem 1.7rem;
  border-radius: 1.1rem;
  border: 1.5px solid color-mix(in srgb, var(--accent) 18%, var(--surface) 82%);
  background: linear-gradient(160deg, color-mix(in srgb, var(--surface) 80%, var(--accent) 5%) 0%, color-mix(in srgb, var(--surface) 60%, transparent) 100%);
  backdrop-filter: blur(10px) saturate(140%);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  box-shadow: var(--island-shadow-sm);
}
html[data-theme="kontrast"] .reading-chunk {
  background: var(--surface);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  border-width: 2px;
}
.reading-chunk.is-current { display: block; }
/* "Cały tekst" (toggleFullText, core.js) -- ONE continuous tile holding
   the whole passage, not a stack of separately-bordered chunk cards.
   #chunkViewport itself becomes the tile (border/background/blur moved
   here from .reading-chunk); each .reading-chunk inside goes back to
   being plain flowing content -- no border/background/radius/padding/
   fade-in of its own -- so consecutive chunks read as one piece of text,
   just still individually wrapped in a <p>/<article> in the DOM (needed
   so showChunk can resume paging exactly where it left off when toggled
   back). max-width:62ch stays on each chunk (not moved to the tile) so
   the text column keeps a readable line length even though the tile
   itself spans the full width of .reading-card. */
.chunk-viewport.is-full-text {
  padding: 1.5rem 1.7rem;
  border-radius: 1.1rem;
  border: 1.5px solid color-mix(in srgb, var(--accent) 18%, var(--surface) 82%);
  background: linear-gradient(160deg, color-mix(in srgb, var(--surface) 80%, var(--accent) 5%) 0%, color-mix(in srgb, var(--surface) 60%, transparent) 100%);
  backdrop-filter: blur(10px) saturate(140%);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  box-shadow: var(--island-shadow-sm);
}
html[data-theme="kontrast"] .chunk-viewport.is-full-text {
  background: var(--surface);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  border-width: 2px;
}
.chunk-viewport.is-full-text .reading-chunk {
  display: block;
  animation: none;
  padding: 0;
  border: none;
  background: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  border-radius: 0;
}
.reading-chunk p { margin: .5em 0 .9em; }
.reading-chunk i { color: var(--muted); }
@keyframes chunkIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

/* Question-linked highlights inside text */
.reading-chunk span[id] { border-radius: 4px; transition: background .3s; }
.reading-chunk span[id].hl-flash {
  background: var(--hl);
  box-shadow: 0 0 0 4px var(--hl);
  animation: hlPulse 1.6s ease 2;
}
@keyframes hlPulse { 50% { box-shadow: 0 0 0 9px var(--hl); } }

/* TTS word-follow (narration) */
.reading-chunk .tts-word { border-radius: 3px; }
.reading-chunk .tts-word.is-spoken { background: var(--gold-soft); }

.chunk-nav {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .6rem;
  padding: .5rem .6rem .8rem;
  min-width: 0;
}
.chunk-arrow {
  flex: none;
  width: 46px; height: 46px;
  border-radius: 50%;
  border: 2px solid var(--line);
  background: var(--surface-2);
  font-size: 1.1rem;
}
.chunk-arrow:disabled { opacity: .35; cursor: default; }
/* Dots row is the one flexible zone -- on a wide passage with many parts,
   it scrolls itself horizontally (own thin scroll, not the page) instead
   of squeezing the two arrow buttons down to ovals to make room. */
.chunk-dots {
  display: flex;
  gap: .45rem;
  flex: 1 1 auto;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
  justify-content: center;
  padding: 4px 0;
}
.chunk-dots::-webkit-scrollbar { display: none; }
.chunk-dot { flex: none; }
@media (max-width: 360px) {
  .chunk-nav { gap: .4rem; padding: .5rem .4rem .8rem; }
  .chunk-arrow { width: 38px; height: 38px; font-size: 1rem; }
}
.chunk-dot {
  width: 12px; height: 12px;
  border-radius: 50%;
  border: none;
  padding: 0;
  background: var(--line);
  transition: transform var(--speed), background var(--speed);
}
.chunk-dot.is-current { background: var(--accent); transform: scale(1.35); }
.chunk-dot.is-read { background: color-mix(in srgb, var(--accent) 45%, var(--line)); }

/* Selection toolbar */
.text-toolbar {
  position: fixed;
  z-index: 60;
  display: flex;
  gap: 2px;
  background: var(--ink);
  border-radius: 12px;
  padding: 4px;
  box-shadow: var(--shadow);
}
.text-toolbar button {
  border: none;
  background: transparent;
  color: var(--bg);
  font-size: .82rem;
  padding: .45rem .7rem;
  border-radius: 9px;
  white-space: nowrap;
}
.text-toolbar button:hover { background: color-mix(in srgb, var(--bg) 18%, transparent); }

/* ---------- Task panel ---------- */
.task-panel { min-height: 0; min-width: 0; display: flex; flex-direction: column; }
.task-card {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--island-fill);
  border-radius: var(--radius);
  box-shadow: var(--island-shadow);
  border: 2px solid var(--line);
  /* Extra top clearance for the absolutely-positioned hint/counter
     badges (see .task-head below) so instructions/question text never
     renders underneath them. */
  padding: 2.6rem 1.2rem 1.1rem;
}
/* Odstep u gory rezerwuje miejsce na plakietki .task-head (podpowiedz, licznik),
   ale one bywaja PUSTE: w lekcjach recznych licznik nie ma tresci (qTotal=1),
   a kicker jest ukryty. Zmierzone na kroku 9 lekcji 6506 przy 348x661:
   .task-head ma 8px wysokosci, a karta rezerwowala 54px -- 46px szlo w nic
   na kazdym kroku, przy glowie pytania scisnietej do 114px. Wartosc ustawia
   LP.fitCardPadding z FAKTYCZNEJ wysokosci plakietek, wiec lekcje, ktore je
   maja, nadal dostaja pelny odstep. Rezerwa w var() to stara wartosc. */
.task-card { padding-top: var(--task-head-clearance, 2.6rem); }

html[data-theme="kontrast"] .task-card { background: var(--surface); box-shadow: none; }
/* Exercise-type kicker ("Pytania Prawda/Fałsz") stays removed -- not
   information a student needs mid-question. Hint and step counter now
   float as absolute corner badges instead of a reserved header row, so
   they cost zero task-card height (top-right/top-left respectively). */
.task-card { position: relative; }
.task-head {
  position: absolute;
  top: .8rem; left: .8rem; right: .8rem;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: .6rem;
  pointer-events: none;
  z-index: 2;
}
.task-head > * { pointer-events: auto; }
.task-kicker { display: none; }
/* DOM order is kicker/actions/counter, but the actions group (hint +
   always-available audio transport) belongs in the top-right corner
   specifically -- flex order swaps it past the counter without touching
   markup. */
.task-head-actions { order: 2; display: flex; align-items: center; gap: .4rem; }
.task-counter { order: 1; margin: 0; font-size: .78rem; font-weight: 700; color: var(--muted); padding-top: .5rem; }
.task-instructions { margin: .2rem 0 .7rem; font-size: .88rem; color: var(--muted); }
/* Each exercise wrapper (.q-*) now owns its own internal layout instead
   of the whole task-body centering everything as one block -- that let a
   long question visibly shove a short answer list to a different Y
   position than a short question did. See the per-type rules below. */
.task-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
.task-body > * { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; }
/* Shrink-to-fit (Darek, 2026-08-05): a long multiple-choice list (several
   options, each 2+ lines) can outgrow the card and force a scroll, which
   must never happen. LP.fitTaskBody() (core.js, runs after every question
   render) measures #taskBody's actual overflow and lowers --fit-scale in
   small steps until content fits or a legibility floor is hit -- driving
   the exercise content's own font-size/padding/spacing down via calc(),
   not a CSS transform (which wouldn't shrink the box #taskBody measures
   against, i.e. wouldn't actually reduce the overflow it's meant to fix).
   overflow-y:auto above stays only as a last-resort safety net once the
   floor is reached, not the primary mechanism. */
.task-body { --fit-scale: 1; }
/* "Already answered, can't change it" lock (Darek, 2026-08-05,
   screenshot-flagged: disabled buttons still looked clickable, and
   .disabled has no effect on non-form elements like gapped-text's .gap
   span anyway -- see the JS comment on where this class gets added, LP.go
   in core.js). Generic .is-locked, not scoped to .task-body specifically
   -- matching/gapped-text can render into #chunkViewport instead when the
   passage panel is repurposed for them, a second real container that
   needs the exact same treatment. pointer-events:none is the actual
   enforcement, catching every element regardless of type; the dimming
   makes it READ as locked at a glance, not just behave that way.
   Question/passage text also dims, not just the answers -- reads as
   "this step is done, you're just passing back through it" rather than
   one answer specifically being greyed out. */
.is-locked {
  pointer-events: none;
  filter: grayscale(.5);
  opacity: .62;
}

/* Question text gets the same frosted-glass tile treatment as the reading
   passage's chunks -- it's content the same way the passage is, not a
   bare line floating over the card background. */
.q-text {
  font-size: calc(1.25rem * var(--fit-scale, 1));
  line-height: 1.5;
  margin: calc(.2rem * var(--fit-scale, 1)) 0 calc(1.2rem * var(--fit-scale, 1));
  padding: calc(1.3rem * var(--fit-scale, 1)) calc(1.5rem * var(--fit-scale, 1));
  border-radius: 1.1rem;
  border: 1.5px solid color-mix(in srgb, var(--accent) 18%, var(--surface) 82%);
  background: linear-gradient(160deg, color-mix(in srgb, var(--surface) 80%, var(--accent) 5%) 0%, color-mix(in srgb, var(--surface) 60%, transparent) 100%);
  backdrop-filter: blur(10px) saturate(140%);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  box-shadow: var(--island-shadow-sm);
}
html[data-theme="kontrast"] .q-text {
  background: var(--surface);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  border-width: 2px;
}
/* TF / MC: the question text is the flexible zone (vertically centers
   its own line within whatever space is left), the answer group below
   it is flex:none -- so it naturally ends up sitting at the bottom edge
   of the card every time, regardless of how long the question text is,
   instead of the whole block shifting position between questions. */
.q-truefalse .q-text,
.q-choice .q-text { flex: 1; display: flex; align-items: center; justify-content: center; text-align: center; }
/* Jedyny potomek .q-text w pytaniach TF/MC -- patrz komentarz przy 'true-false'
   w lesson-player-v3-exercises.js: bez niego spany zdan z makeTranslatable staja
   sie osobnymi elementami flex i tresc pytania rozpada sie na pudelka. */
.q-text-line { display: block; }
.tf-row, .choice-list { flex: none; }

/* Cloze: centered both axes within its available space. */
.q-cloze { justify-content: center; align-items: center; }

/* Matching / gapped-text / open-ended: natural top-down flow. */
.q-matching, .q-gapped, .q-open { justify-content: flex-start; }

/* Answer buttons (TF + MC) -- frosted-glass floating tiles, same visual
   language as the action-bar's play button (backdrop blur, translucent
   fill, soft deep shadow) instead of the flat solid-grey bars these used
   to be. */
.answer-btn {
  display: block;
  width: 100%;
  text-align: left;
  padding: calc(.85rem * var(--fit-scale, 1)) calc(1.1rem * var(--fit-scale, 1));
  margin-bottom: calc(.6rem * var(--fit-scale, 1));
  /* CMO design-review pass (2026-08): rest-state tint moved off the
     accent-tinted glass to a fixed soft-green wash, independent of
     theme/accent -- "this is a pickable option" reads the same in all 4
     themes. Round 2 (Darek, 2026-08): tried shifting the green's hue per
     active photo-tint bucket (warm/cool/sage) so it wouldn't clash against
     a tinted tile -- backfired live, the "cool" variant read as blue
     against blue photos and the "warm" variant read as khaki against
     peach, i.e. picking a hue PER bucket just moved the mismatch instead
     of fixing it. Round 3: one fixed pale khaki-green (#dfecbe) at the
     same 305deg angle the tile-tint system below also uses (angle only --
     that system's own 3 warm/cool/sage colors are unrelated and unchanged)
     -- no per-bucket branching left here to get wrong, reads consistently
     across every theme and every photo. */
  border: 1.5px solid #dfecbe8f;
  border-radius: var(--radius-sm);
  background: linear-gradient(305deg, #dfecbe8c 0%, #dfecbe38 100%);
  backdrop-filter: blur(10px) saturate(140%);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  box-shadow: var(--island-shadow-sm);
  font-size: calc(1.05rem * var(--fit-scale, 1));
  font-weight: 600;
  transition: transform .14s ease, border-color .14s ease, background .14s ease, box-shadow .14s ease;
}
/* Kontrast: flat and solid on purpose -- blur/translucency reduces
   legibility, the opposite of what this theme exists for. */
/* Tekst odpowiedzi wysrodkowany. Bazowe text-align: left zostawialo krotkie
   odpowiedzi ("Tak", "Hawajskich") przyklejone do lewej krawedzi szerokiego
   przycisku -- przy dwoch kolumnach widac to jeszcze mocniej. Dotyczy listy
   odpowiedzi we WSZYSTKICH typach lekcji, zeby nie rozjechaly sie miedzy soba. */
.choice-list .answer-btn { text-align: center; }

html[data-theme="kontrast"] .answer-btn {
  background: var(--surface-2);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  border-width: 2px;
}
.answer-btn:active { transform: translateY(1px); box-shadow: none; }
/* translateY only, never translateX/scale -- either would push a
   100%-width button past its container's edge and force a stray
   horizontal scrollbar (hard rule: never a scrollbar in the player). */
.answer-btn:hover {
  transform: translateY(-2px);
  border-color: var(--accent);
  background: linear-gradient(160deg, color-mix(in srgb, var(--surface) 74%, var(--accent) 10%) 0%, color-mix(in srgb, var(--surface) 55%, transparent) 100%);
}
.answer-btn.is-picked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--surface-2)); }
.answer-btn.is-correct { border-color: var(--good); background: var(--good-soft); }
.answer-btn.is-wrong   { border-color: var(--bad);  background: var(--bad-soft); }
.answer-btn:disabled   { cursor: default; }
/* TF: centered tiles, a bit bigger than a plain text bar since they're
   the whole decision on screen, but not oversized tap targets. */
.tf-row { display: grid; grid-template-columns: 1fr 1fr; gap: calc(.7rem * var(--fit-scale, 1)); }
.tf-row .answer-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Zostaje po wersji z dwoma spanami (glif + napis). Dzis w przycisku jest
     JEDEN element, wiec odstep nic nie robi -- ale nie kosztuje, a wraca do gry,
     gdyby napis kiedys wrocil. */
  gap: .4em;
  text-align: center;
  font-size: calc(1.1rem * var(--fit-scale, 1));
  margin-bottom: 0;
  padding: calc(1.1rem * var(--fit-scale, 1)) calc(.6rem * var(--fit-scale, 1));
}
/* PRAWDA/FALSZ TO DZIS SAME KCIUKI, BEZ NAPISOW (Darek, 2026-09-14).
   Napisy "Poprawne"/"Niepoprawne" niosly tyle samo co gest, a zabieraly cala
   szerokosc przycisku -- przy `--fit-scale` na telefonie schodzily do dwoch linii.
   Glif zostaje jedyna trescia, wiec musi urosnac: przy 1.1rem bazowego pisma
   ikona w rozmiarze tekstu wygladala jak znaczek przy pustym przycisku.

   DOSTEPNOSC JEST TU CZESCIA ZMIANY, nie dodatkiem: przycisk bez tekstu nie ma
   nazwy dostepnej, wiec czytnik ekranu przeczytalby "przycisk". Stad `aria-label`
   (i `title` dla myszy) w `lesson-player-v3-exercises.js`, a sama ikona ma
   `aria-hidden` -- inaczej czytnik podaje nazwe dwa razy.

   TO SA TE SAME DLONIE, CO NA SZLAKU (Darek, 2026-09-14: "could we use the hands
   from the trail path icon, not some generic ones?"). Kciuk kreskowy z Phosphora byl
   poprawny i zupelnie obcy wobec reszty ekranu -- szlak, znacznik gracza i kafle
   etapow to rysunki 3D. `assets/img/trail/true-false.webp` niesie OBIE dlonie w jednym
   kadrze, wiec zostaly z niego wyciete po SPOJNYCH OBSZARACH kanalu alpha, a nie
   prostokatem: ramki obu dloni nachodza na siebie po przekatnej (zmierzone: 50..149
   i 110..206 w poziomie), wiec zwykly kadr zabralby kawalek sasiada.

   Zrodlo ma ~100 px na dlon przy ikonie rysowanej na ~42 px, czyli ponad dwukrotny
   zapas na ekrany o duzej gestosci. Wysokosc w `em`, nie w px -- przycisk skaluje sie
   przez `--fit-scale` i ikona ma isc za nim. */
.tf-row .answer-btn .tf-icon {
  height: 2.4em;
  width: auto;
  display: block;
  /* Rysunki maja wlasny cien; `flex` rodzica centruje je w przycisku. */
  pointer-events: none;
}

/* Matching */
/* TWO CONTAINERS, STACKED -- definitions on top, terms underneath (Darek:
   "we can't fit terms and definitions in the same container... a separate
   top container for definitions and a separate bottom container for the
   terms (which are usually short)").

   This is the fallback path: matching normally hands its definitions to the
   READING panel via usePassagePanelFor, which is where the two-container
   arrangement comes from in the first place. That routing is refused for
   any lesson without a reading step and for EVERY video lesson
   (`if (!S.hasReadingStep || S.data.videoUrl) return false`), and those
   lessons fell back to two side-by-side columns crammed into one box --
   at 390px that is a 45%-wide column of full-sentence definitions next to
   a 45%-wide column of one-word terms, which is what the screenshot shows.
   Stacking gives the same two-container reading on the one path that could
   not have it.

   Definitions take the full width because they are sentences; terms wrap as
   a compact row beneath, the same treatment .match-terms.terms-only already
   gets when the split DOES happen -- so both routes now look alike. */
.match-cols { display: grid; grid-template-columns: 1fr; gap: .75rem; }
.match-cols > .match-defs { order: 1; }
.match-cols > .match-terms { order: 2; }
/* Each column is a real container of its own -- that is the whole point of
   the report. NOT display:contents or any other structural dissolve: that is
   the exact mutation that once merged the two columns into one flex pool and
   let a tap land on the wrong pill (see the immersive matching note). */
.match-cols > .match-col {
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: .6rem;
}
.match-cols > .match-terms {
  flex-flow: row wrap;
  gap: .5rem;
  justify-content: center;
}
.match-cols > .match-terms .match-item {
  width: auto;
  flex: 0 0 auto;
  min-height: 44px;
}
/* --- PLANSZA DOPASOWANIA WYPELNIA KARTE ------------------------------------
   Zgloszenie Darka 2026-09-06 (zrzut z 1920x1080): "this doesn't use all space
   available". Zmierzone: pole zadania 1023x794, plansza 537 px, czyli **258 px
   pustki** pod terminami, a definicje pisane 15,6 px -- na ekranie w klasie to
   nieczytelne. Na 1280x800 tej pustki nie ma (plansza przekraczala pole o 38 px),
   wiec problem widac dopiero na duzym ekranie, czyli tam, gdzie sie prezentuje.

   Rosna WIERSZE `li`, a nie `.match-item`: `mk()` w rendererze buduje
   `li > button.match-item`, wiec dziecmi kolumny sa elementy listy i to im trzeba
   dac `flex`. Ustawienie `flex` na samym przycisku nie robi nic -- sprawdzone,
   definicje zostawaly przy 73 px.

   Uklad DWOCH POJEMNIKOW (definicje u gory, terminy pod spodem) zostaje nietkniety.
   To jest decyzja Darka z 2026-08-25, zapisana w komentarzu nizej: "we can't fit
   terms and definitions in the same container". Tu zmienia sie tylko to, ile
   miejsca zajmuja -- nie to, jak sa rozlozone. */
.q-matching > .match-cols {
  flex: 1 1 auto;
  min-height: 0;
  grid-template-rows: minmax(0, 1fr) auto;
}
/* Kontrakt planszy (Darek, 2026-09-06): OBA pojemniki zawsze widoczne, WSZYSTKIE
   terminy zawsze widoczne we wlasnym pojemniku, a przewijac sie moga wylacznie
   definicje. Wiersz terminow jest w siatce `auto`, wiec bierze dokladnie tyle, ile
   potrzebuje, i nie ma jak sie skurczyc; definicje dostaja reszte (`minmax(0, 1fr)`)
   i dopiero one przewijaja sie w srodku, gdy nawet po zmniejszeniu pisma sie nie
   miesci. Zmierzone w wyprawie: 5 terminow w kadrze, definicje bez przewijania
   przy oknie 1000, z przewijaniem przy 800. */
.match-cols > .match-defs { min-height: 0; overflow-y: auto; }
/* `1 0 auto`, nie `1 1 auto`: wiersz ROSNIE, zeby wypelnic pojemnik, ale NIGDY nie
   kurczy sie ponizej wlasnej tresci. Przy `shrink: 1` flex sciskal `li` do 51 px,
   a `.match-item` ma `min-height: 58px`, wiec przycisk wystawal 7 px (przy oknie 900
   az 18 px) POZA swoj wiersz -- odstep miedzy kafelkami ma 7,5 px, wiec kafelki
   nachodzily na siebie i plansza wygladala na sciśnieta. Gdy definicje sie nie
   miesczą, ma sie przewijac pojemnik, a nie deformowac kafelek. */
.match-cols > .match-defs > li { flex: 1 0 auto; display: flex; min-height: 0; }
.match-cols > .match-defs > li > .match-item { height: 100%; }
/* Pismo rosnie z szerokoscia ekranu, tak jak na slajdzie narracyjnym. Zmierzone:
   15,6 -> 23,8 px przy 1920 (29,8 px po `zoom: 1.25`) i 15,6 -> 21,0 px przy 1280.
   `--fit-scale` jest tu konieczne, nie ozdobne: przy 1280 piec definicji w tym
   pismie NIE MIESCI sie w pojemniku i zmniejszanie musi je scisnac z powrotem. */
.match-cols .match-item {
  font-size: calc(clamp(.92rem, .82rem + .55vw, 1.4rem) * var(--fit-scale, 1));
}

/* --- DWA PASTELE: definicje chlodne, terminy cieple ---
   Darek: "separating the terms from the definitions with a different color, subtly
   different pastel shades". Domieszka liczona z tokenow motywu (`--accent`,
   `--gold`), a nie wpisana szesnastkowo -- inaczej Zmierzch i Kontrast dostalyby
   jasne plamy z motywu Szlak. Udzialy sa jednocyfrowe, wiec kontrast tekstu
   zostaje ten sam co na `--surface-2`.

   `:not(.is-picked)` jest load-bearing: `.match-item.is-picked` (zaznaczony termin
   na zloto) ma nizsza specyficznosc niz `.match-cols > .match-terms .match-item`,
   wiec bez tego wyjatku tint zjadlby jedyny sygnal "wybralem ten termin". */
.match-cols > .match-defs { background: color-mix(in srgb, var(--accent) 5%, var(--surface-2)); }
.match-cols > .match-terms { background: color-mix(in srgb, var(--gold) 12%, var(--surface-2)); }
.match-cols > .match-terms .match-item:not(.is-picked):not(.is-used) {
  background: linear-gradient(180deg,
      color-mix(in srgb, var(--gold) 10%, var(--surface-2)) 0%,
      color-mix(in srgb, var(--gold) 6%, var(--surface-2)) 100%);
}
/* USUNIETE 2026-08-25. Bylo tu wysrodkowanie planszy dopasowania w wyprawie
   (Darek: "we have so much space, we should try to center the content").
   Efekt na zywym ekranie byl zly: "matching pairs cannot work like this, we
   can't fit terms and definitions in the same container - before your work
   here it was correct - a separate top container for definitions and a
   separate bottom container for the terms... revert that state."

   Wiec plansza wraca w calosci do bazy. Wlasciwy uklad dla dopasowania to
   dwa OSOBNE pojemniki (definicje u gory, krotkie terminy pod nimi), a nie
   jeden wysrodkowany blok -- to jest to, co robi tryb `split` przez
   usePassagePanelFor, i czego lekcje wideo nie dostaja, bo `usesPassagePanel`
   jest dla nich wymuszone na false. Rozwiazanie tamtego jest wieksze niz
   zmiana wyrownania i czeka na osobna decyzje; do tego czasu ZERO regul
   wyprawy na dopasowaniu -- tak jak dla .match-cols/.match-col, ktore maja
   ten sam kontrakt z tego samego powodu. */
.match-col { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .55rem; }
/* Terms-only column (definitions moved to the passage panel): compact
   wrapping chip row instead of a tall vertical stack, so the task panel
   never needs to scroll. */
.match-terms.terms-only { flex-flow: row wrap; gap: .5rem; justify-content: center; }
.match-terms.terms-only .match-item { width: auto; min-height: 44px; flex: 0 0 auto; }
/* Definitions repurposing the passage panel: dropzone-first layout,
   term gets appended as a tag once paired (see .pair-tag). */
#chunkViewport .match-defs { padding: 0; }
#chunkViewport .match-item { cursor: pointer; }
.match-item {
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  background: linear-gradient(180deg, color-mix(in srgb, var(--surface-2) 100%, white 5%) 0%, var(--surface-2) 100%);
  box-shadow: var(--island-shadow-sm);
  padding: .6rem .75rem;
  font-size: .92rem;
  min-height: 58px;
  display: flex;
  align-items: center;
  transition: border-color .12s, background .12s, opacity .12s, box-shadow .12s, transform .1s;
  width: 100%;
  text-align: left;
}
html[data-theme="kontrast"] .match-item { background: var(--surface-2); box-shadow: none; }
.match-item:active:not(:disabled) { transform: translateY(1px); box-shadow: none; }
.match-item.is-picked { border-color: var(--gold); background: var(--gold-soft); }
/* Term, once used: dims out of the way (its result is now readable over on
   the definition it was paired to -- no reason to keep it prominent). */
.match-item.is-used { opacity: .5; border-style: dashed; cursor: default; }
/* Definition, once paired: stays fully legible -- it's now showing the
   actual result inline, not just a static reference item. */
.match-item.is-paired { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--surface-2)); }
.match-item.is-correct { border-color: var(--good); background: var(--good-soft); opacity: 1; }
.match-item.is-wrong   { border-color: var(--bad); opacity: 1; }
.match-item-text { display: block; }
.match-result { font-weight: 700; color: var(--accent); }
.match-item.is-correct .match-result { color: var(--good); }
.match-item.is-wrong .match-result { color: var(--bad); }

/* Gapped text -- same frosted-glass tile as .q-text/.cloze-line/.reading-chunk;
   when the passage panel is repurposed for this exercise (usePassagePanelFor),
   the markup lands directly in #chunkViewport with no .reading-chunk wrapper
   around it, so the tile has to live on .gapped-passage itself to cover both
   that case and the plain in-card fallback. */
.gapped-passage {
  font-size: 1.1rem;
  line-height: 2.4;
  padding: 1.3rem 1.5rem;
  border-radius: 1.1rem;
  border: 1.5px solid color-mix(in srgb, var(--accent) 18%, var(--surface) 82%);
  background: linear-gradient(160deg, color-mix(in srgb, var(--surface) 80%, var(--accent) 5%) 0%, color-mix(in srgb, var(--surface) 60%, transparent) 100%);
  backdrop-filter: blur(10px) saturate(140%);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  box-shadow: var(--island-shadow-sm);
}
html[data-theme="kontrast"] .gapped-passage {
  background: var(--surface);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  border-width: 2px;
}
/* Background/border tinted off --accent rather than plain --surface-2 --
   --surface-2 sits only a few % off --surface in every theme, which is
   also what the frosted tile's own gradient is built from, so an empty
   gap all but disappeared into its own tile. The accent tint (present in
   every theme, unlike a hardcoded color) plus a visible border keeps it
   distinguishable regardless of which theme/background is active. */
.gap {
  display: inline-block;
  min-width: 3em;
  line-height: 1;
  border: 1.5px solid color-mix(in srgb, var(--ink) 25%, transparent);
  border-radius: .65em;
  text-align: center;
  padding: calc(.3em - 1.5px) calc(.6em - 1.5px);
  margin: .2em .15em;
  background: color-mix(in srgb, var(--ink) 10%, var(--surface-2));
  transition: background .12s, box-shadow .12s;
  cursor: pointer;
}
.gap.is-target { box-shadow: 0 0 0 3px var(--gold); background: var(--gold-soft); }
.gap.is-filled { font-weight: 700; background: color-mix(in srgb, var(--accent) 22%, var(--surface-2)); border-color: var(--accent); }
/* Checked state (Darek, screenshot-flagged): border-only, no fill --
   a checked gap is still tagged .is-filled (never removed at check-time,
   see fillGap/checkGapped in exercises.js), whose own accent background
   would otherwise show through since these come first in source order
   at equal specificity; explicit `background: transparent` here is what
   actually wins over it, not just the border-color/-width below. Same
   faint color-mix treatment on both sides now (was a solid --good-soft
   fill on correct vs. an already-faint mixed border on wrong -- the two
   states read as different intensities of feedback for no reason). */
.gap.is-correct { background: transparent; border-width: 2px; border-color: color-mix(in srgb, var(--good) 45%, transparent); }
.gap.is-wrong   { background: transparent; border-width: 2px; border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
/* Darek: "wordbank also used to be in its own bottom container between the
   gapped text and action bar... both must be visible at once", then, after a
   sticky attempt: "still in focus mode wordbank overlaps the gapped text...
   when answered it moves out of view completely."

   Sticky was the wrong mechanism and the second report shows exactly why.
   `position: sticky; bottom: 0` resolves against the nearest SCROLLING
   ancestor. In normal mode that is #taskBody and it behaved. In immersive
   #taskBody is `overflow: visible` (see its rule), so the bank skipped it and
   stuck to .stage instead -- parking itself in the middle of the passage. And
   once the answers were in, dropping it back to static let the now much taller
   passage push it clean off the bottom of the page.

   No sticky anywhere now. The exercise is a bounded flex COLUMN: the passage
   takes the free space and scrolls inside itself, the bank is a fixed row at
   the bottom of that column. Both are visible by construction, at any passage
   length, before and after answering -- there is no scroll position and no
   ancestor lookup involved, so neither mode can drift from the other. */
.word-bank {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: .5rem;
  margin-top: 1rem;
  flex: none;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: .6rem;
}
html[data-theme="kontrast"] .word-bank { background: var(--surface); }
/* The passage yields to the bank and scrolls internally. min-height:0 is
   required: a flex item's automatic minimum is its CONTENT, so without it the
   box refuses to shrink below the full passage and pushes the bank out
   regardless of any overflow setting. */
.q-gapped > .gapped-passage {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
.bank-chip.is-picked { border-color: var(--gold); background: var(--gold-soft); box-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 30%, transparent); }
.bank-chip {
  border: 2px solid var(--line);
  border-radius: 99px;
  background: linear-gradient(180deg, color-mix(in srgb, var(--surface) 100%, white 6%) 0%, var(--surface) 100%);
  padding: .45rem .95rem;
  font-size: .98rem;
  box-shadow: var(--island-shadow-sm);
  transition: transform .1s, opacity .12s, box-shadow .12s;
}
html[data-theme="kontrast"] .bank-chip { background: var(--surface); box-shadow: none; }
.bank-chip:hover { transform: translateY(-2px); border-color: var(--accent); }
.bank-chip:active { transform: translateY(1px); box-shadow: none; }
.bank-chip.is-used { opacity: .25; pointer-events: none; }

/* Cloze -- same frosted-glass tile as .q-text (TF/MC question text) so the
   sentence reads as a content surface instead of bare text floating over
   the card background. */
.cloze-line {
  font-size: 1.15rem;
  /* Was 3 -- excessive once a sentence wraps to 2+ lines, reading as huge
     gaps between them rather than just "room for the input box." */
  line-height: 2.2;
  text-align: center;
  padding: 1.3rem 1.5rem;
  border-radius: 1.1rem;
  border: 1.5px solid color-mix(in srgb, var(--accent) 18%, var(--surface) 82%);
  background: linear-gradient(160deg, color-mix(in srgb, var(--surface) 80%, var(--accent) 5%) 0%, color-mix(in srgb, var(--surface) 60%, transparent) 100%);
  backdrop-filter: blur(10px) saturate(140%);
  -webkit-backdrop-filter: blur(10px) saturate(140%);
  box-shadow: var(--island-shadow-sm);
}
html[data-theme="kontrast"] .cloze-line {
  background: var(--surface);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  border-width: 2px;
}
/* Sized/shaped like .gap (gapped-text) for visual consistency between the
   two fill-in exercise types -- max-width caps growth at the tile's own
   edge so a long typed answer scrolls inside the input instead of pushing
   the card into a horizontal overflow. */
/* Same accent-tinted background/border as .gap -- plain --surface-2 barely
   differs from the tile's own --surface-based gradient, so an empty input
   was nearly invisible against its own frosted card. */
.cloze-input {
  display: inline-block;
  font: inherit;
  line-height: 1;
  border: 1.5px solid color-mix(in srgb, var(--ink) 25%, transparent);
  /* Fallback only -- growInput() (exercises.js) sets an inline width sized
     to the correct answer as soon as the input exists. Without an explicit
     `width` here (min-width alone doesn't set one), a plain <input> falls
     back to the browser's default intrinsic size (~20 characters wide)
     any time JS hasn't run yet. */
  width: 3em;
  min-width: 3em;
  max-width: 100%;
  box-sizing: border-box;
  border-radius: .65em;
  padding: calc(.3em - 1.5px) calc(.6em - 1.5px);
  margin: .2em .15em;
  background: color-mix(in srgb, var(--ink) 10%, var(--surface-2));
  text-align: center;
  color: inherit;
  vertical-align: middle;
}
.cloze-input:focus {
  outline: none;
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 16%, var(--surface-2));
}
/* Same border-only checked treatment as .gap.is-correct/.is-wrong above --
   kept in sync so the two fill-in exercise types give identical feedback. */
.cloze-input.is-correct { background: transparent; border-width: 2px; border-color: color-mix(in srgb, var(--good) 45%, transparent); }
.cloze-input.is-wrong   { background: transparent; border-width: 2px; border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
.cloze-hint { font-size: .88rem; color: var(--muted); margin-top: .8rem; }

/* Open-ended */
.open-input {
  width: 100%;
  font: inherit;
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: inherit;
  padding: .7rem .9rem;
  resize: vertical;
}
/* The generic button/input/textarea:focus-visible rule above draws a 3px
   outline offset 2px outside the border box -- fine for most controls, but
   this textarea sits flush against #taskBody's own left/right inner edge
   (width:100%, no side gap of its own), and #taskBody only sets
   overflow-y:auto -- per the CSS overflow spec, leaving overflow-x at its
   visible default while overflow-y is non-visible computes overflow-x to
   auto too (both axes clip once either one isn't 'visible'), so the
   outline's horizontal portion gets clipped by that now-real overflow-x
   while the vertical portion has room and stays visible (Darek,
   screenshot-flagged: "border only top/bottom, left/right absent").
   Sidestepped the same way .cloze-input:focus already does -- swap the
   offset outline for a same-geometry border-color change (no added
   layout box, nothing to clip), thinner/subtler than the accent-solid
   outline it replaces. */
.open-input:focus-visible {
  outline: none;
  border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
}
.open-sample { margin-top: .9rem; font-size: .92rem; color: var(--muted); }

/* Feedback -- correct/wrong is now signalled on the primary button itself
   (see .btn-primary-round.is-feedback-*), not a colored praise/apology
   tile here. This box only ever shows up when there's actual explanation
   content to read (see LP.setFeedback in exercises.js), so it's neutral,
   not green/red, and stays out of the way entirely the rest of the time. */
.task-feedback {
  flex: none;
  /* No max-height/scroll clamp -- when it sits above the answer row (see
     LP.setFeedback), it should just take the room it needs; the question
     tile above it (flex:1) is what yields, and task-body itself already
     scrolls (see .task-body) as a last resort for a very long explanation. */
  margin: .7rem 0;
  border-radius: var(--radius-sm);
  padding: .5rem .7rem;
  font-size: .85rem;
  background: var(--surface-2);
  border: 2px solid var(--line);
  animation: chunkIn .25s ease;
}
.feedback-msg { display: none; }
.feedback-explain { margin: 0; font-size: .95em; }
.feedback-explain:empty { display: none; }

/* Lives in the fixed-height task-head row (not after the variable-height
   task-body) so a tall answer list can never push it off-card or force
   an overflow. Icon-only always now (kicker/counter removed alongside it
   left it the one thing in that row, and its own label was redundant
   with the "?" affordance a bulb icon already reads as). */
.hint-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 2px solid var(--line);
  background: var(--surface-2);
  color: var(--accent);
  font-size: 1rem;
  border-radius: 50%;
  width: 36px; height: 36px;
  padding: .4rem;
}
.hint-label { display: none; }

/* Always-available audio transport, next to the hint bulb -- so lesson
   narration stays one tap away even while answering a question, not just
   while the reading sheet is open. Drives the SAME <audio> element as the
   reading sheet's player (see initTaskAudioMini in features.js), it's a
   remote control, not a second player. */
.task-audio-mini {
  display: flex; align-items: center; gap: 1px;
  border: 2px solid var(--line);
  background: var(--surface-2);
  border-radius: 999px;
  padding: 2px;
}
.tam-btn {
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px;
  border: none; border-radius: 50%;
  background: transparent; color: var(--accent);
  font-size: .85rem;
  cursor: pointer;
  transition: background .12s;
}
.tam-btn:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.tam-play { background: #ffffff00; color: var(--audio-accent); }
.tam-play:hover { background: var(--audio-accent); color: #fff; }
/* Song icon lives outside the pill -- it's a different track entirely
   (the lesson's saved song, not its narration), play-only, no seek. */
.tam-song-btn {
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  width: 36px; height: 36px;
  border: 2px solid var(--line);
  background: var(--surface-2);
  color: var(--gold);
  border-radius: 50%;
  font-size: 1rem;
  cursor: pointer;
}
.tam-song-btn.is-playing { border-color: var(--gold); background: var(--gold-soft); }
/* Lektor slajdu w trakcie nagrywania: przycisk pulsuje i nie przyjmuje klikniec. */
.tam-lektor-btn.is-busy { pointer-events: none; animation: tamLektorPuls 1s ease-in-out infinite; }
@keyframes tamLektorPuls { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { .tam-lektor-btn.is-busy { animation: none; opacity: .6; } }

/* Intro / goal / finish steps */
.step-intro, .step-finish { text-align: center; padding: 1.2rem .4rem; }
.intro-wave, .finish-flag { font-size: 3.2rem; margin: 0 0 .4rem; animation: bob 2.4s ease-in-out infinite; }
@keyframes bob { 50% { transform: translateY(-7px); } }
.intro-title, .finish-title { font-size: 1.55rem; margin: 0 0 .3rem; }
.intro-sub { color: var(--muted); margin: 0 0 1.1rem; }
.intro-plan { list-style: none; margin: 0 auto; padding: 0; max-width: 330px; text-align: left; }
.intro-plan li {
  display: flex; align-items: center; gap: .6rem;
  padding: .45rem .2rem;
  border-bottom: 1px dashed var(--line);
  font-size: .95rem;
}
.intro-plan li::before { content: attr(data-icon); font-size: 1.15rem; }
.step-goal { text-align: center; padding: 2rem .8rem; }
/* Slajd narracyjny lekcji RECZNEJ. Rozni sie od zwyklego .step-goal trzema rzeczami
   i kazda wynika ze zgloszenia Darka (2026-09-04, blok przyrody):

   1. PUDELKO PRZYTULA TRESC (`flex: 0 0 auto` w bloku `body.no-reading` nizej), zamiast
      rozciagac sie na cala wysokosc karty razem z wlasnym `justify-content: center`.
      Bez tego krotki slajd wisial w powietrzu, a dlugi zaczynal sie nie tam, gdzie oko
      go szuka.
   2. KONSUMUJE --fit-scale. `LP.fitTaskBody()` zmniejsza tresc, gdy przelewa sie poza
      karte, ale robi to przez calc() w regulach, ktore o tej zmiennej wiedza. Zwykly
      .step-goal ma sztywne rozmiary, wiec dla slajdu mechanizm nie dzialal wcale
      i dluzszy tekst byl po prostu ucinany na dole.
   3. WYROWNANIE DO LEWEJ. Slajd niesie kilka akapitow wyjasnienia, a nie jedno haslo;
      wysrodkowana proza ma rwana krawedz i czyta sie ja gorzej.

   Osobna klasa, a nie zmiana `.step-goal`, bo tamten selektor obsluguje takze kompas
   kroku w trybie wyprawy -- patrz notka "Tryb wyprawy" w CLAUDE.md. */
.step-goal--slide {
  justify-content: center;
  align-items: center;
  text-align: center;
  overflow-y: auto;
  padding: calc(1.1rem * var(--fit-scale, 1)) calc(1rem * var(--fit-scale, 1));
  /* Prezentacja idzie na ekran w klasie, a nie do czytania z bliska (Darek, 2026-09-06),
     wiec krotki slajd stoi w PIONOWYM SRODKU karty, a nie przy jej gornej krawedzi.
     Zmierzone na 1280x800: tresc 229 px w polu 524 px, czyli 295 px pustki pod spodem.

     `margin-block: auto`, a NIE `justify-content` na #taskBody: auto-margines dziala na
     tym jednym elemencie i nie rusza ukladu pozostalych krokow. Dokladanie wlasnosci
     `display`/`justify-content` do wspolnego rodzica to ta sama pomylka, ktora trzy razy
     jednego dnia odslonila elementy chowane warunkowo -- patrz "Tryb wyprawy" w CLAUDE.md.

     To ODWRACA punkt 1 wylacznie dla SLAJDU KROTKIEGO. Auto-margines wchlania sam wolny
     obszar, wiec przy dlugim slajdzie schodzi do zera i tekst zaczyna sie u gory dokladnie
     tak jak dotad -- zmierzone: krok ze zdjeciem, 416 px tresci w 538 px, gora 0 px. */
  margin-block: auto;
}
/* 62ch, nie odziedziczone 34ch. Tamta szerokosc jest dla JEDNOZDANIOWEGO kompasu kroku
   w trybie wyprawy; na kilku akapitach dawala kolumne 330 px w karcie szerokiej na 860
   (zmierzone). Ta sama miara co `.reading-chunk`, czyli to, czym w tym odtwarzaczu jest
   akapit do czytania. */
.step-goal--slide .goal-text {
  /* Wieksze pismo z tego samego powodu: slajd czyta sie z drugiego konca sali, a nie
     z odleglosci reki. Rosnie z szerokoscia ekranu, wiec telefon nie dostaje liter
     wielkosci projektora: zmierzone 18,0 -> 19,7 px przy 390 px i 18,0 -> 27,7 px przy
     1280 px (na desktopie dochodzi jeszcze `zoom: 1.25` z `html`, czyli ~34,6 px).

     `rem` jest tu load-bearing: `html { font-size: var(--fs-base) }`, wiec regulator
     "A+ / A-" w szufladzie asystenta dalej te litery powieksza. Wartosc bezwzgledna
     w px odcielaby go po cichu.

     Slajd dluzszy niz karta nie jest problemem: `--fit-scale` z `LP.fitTaskBody()` zjezdza
     do 0,58 i wciaz konczy powyzej dawnych 18 px (zmierzone: 0,84 -> 23,2 px). */
  font-size: calc(clamp(1.15rem, 0.95rem + 0.9vw, 2.1rem) * var(--fit-scale, 1));
  line-height: 1.62;
  max-width: 62ch;
  margin: 0 auto;
}
.goal-icon { font-size: 2.6rem; margin: 0 0 .6rem; }
.goal-text { font-size: 1.05rem; line-height: 1.6; max-width: 34ch; margin: 0 auto; color: var(--muted); }
.finish-stats { font-size: 1.05rem; }

/* ---------- Action bar ---------- */
/* Fixed 5-column grid (exit / blank / next / reveal-passage / assistant)
   instead of flex space-between -- with flex, the primary button's actual
   center shifts depending on which side buttons are hidden/shown for a
   given step (an empty 5th slot used to read as "off-center" even though
   nothing was technically wrong). A dedicated middle column keeps it
   dead-center regardless of the two side icons' visibility. */
.action-bar {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: center;
  justify-items: center;
  gap: .6rem;
  /* padding: .55rem .9rem; -- dropped: with the play button now a bare
     floating icon instead of a chip filling the bar, this padding pinched
     the bar in around it and threw off centering/alignment against the
     card above. */
  /* Without an explicit cap, the grid track sizing algorithm inflates
     this auto-sized row to help accommodate .trail's tall content (it
     spans all 3 rows in the grid-template-areas) -- pushed the row well
     past what its own content needs and threw off vertical centering of
     everything inside it. Same fix already used for the topbar. */
  max-height: 100px;
  /* The 72px play disc is the tallest thing in here, and the bar had no
     padding at all (dropped with the old chip design), so the disc ran
     flush into the bar's top/bottom edges. Breathing room on both sides,
     plus a min-height so the grid row can't be squeezed back under
     disc + padding and reintroduce the same touching edges. Vertical only --
     the old rule's side padding is what pinched the bar in around the disc
     and is still deliberately absent. */
  padding: .55rem 0;
  min-height: calc(72px + 1.1rem);
  /* background: var(--island-fill); -- dropped along with the padding
     above: this fill is what was still reading as a light disc/bar behind
     the now-bare play icon once the icon's own backing (.btn-primary-round)
     was stripped. box-shadow is kept so the bar still separates from the
     page. */
  box-shadow: var(--island-shadow-sm);
  border-radius: var(--radius-sm);
  /* Match the CARD's actual rendered width, not .stage's own outer box --
     .stage is max-width:900px but (border-box) that 900px already
     includes its own 1.1rem side padding, so the card inside only ever
     renders at 900px - 2.2rem. Matching plain 900px here left the bar
     visibly wider than the card above it. */
  max-width: calc(900px - 2.2rem);
  margin: 0 auto calc(.6rem + env(safe-area-inset-bottom));
  box-sizing: border-box;
  width: calc(100% - 1.2rem);
}
html[data-theme="kontrast"] .action-bar { background: var(--surface); border: 2px solid var(--line); box-shadow: none; }
/* Fixed slots -- when reveal-passage is [hidden] (display:none), grid
   auto-placement would otherwise shuffle the assistant handle left into
   its column and shift everything, un-centering the primary button
   again. Pinning every cell to its own column number keeps the layout
   stable regardless of which optional icons are shown for a given step. */
#actionBar #btnBack { grid-column: 1; }
/* ph-sign-out's default artwork points its arrow rightward, INTO the door --
   reads backwards for an exit action sitting on the left/start side of the
   bar. Mirrored so the arrow points left, matching the exit-the-flow
   direction. Applied to all three exit buttons (action bar + the two
   currently-hidden .card-exit-btn fallbacks) for consistency. */
#btnBack i, .card-exit-btn i { display: inline-block; transform: scaleX(-1); }
#actionBar #btnPrimary { grid-column: 2; }
#actionBar #btnRevealPassage { grid-column: 3; }

/* CZWARTY PRZYCISK: lekcja RECZNA ma wlasne wejscie w tryb wyprawy (#btnImmersiveManual),
   bo nie ma paska czytania, w ktorym tamto wejscie siedzi w lekcji z czytanka. Przy trzech
   kolumnach (wyjscie / play / tekst) nie ma dla niego celi, wiec auto-uklad wypychal go
   do DRUGIEGO WIERSZA -- czyli pod pasek, za jego krawedz. Zgloszenie Darka 2026-09-15,
   zrzut ekranu; ujawnilo sie dopiero, gdy #btnRevealPassage przestal byc w Prezentacji
   ukryty i zajal trzecia kolumne.

   Piec kolumn i JAWNE przypisania, bo `grid-column` bez `grid-row` nie wystarcza: guzik
   stoi w markupie NA KONCU, a kursor auto-ukladu minal juz kolumne 2 w pierwszym wierszu
   i schodzi do drugiego (zmierzone: y=700 przy pasku 611-711). Play zostaje w kolumnie 3,
   czyli dokladnie na srodku -- to jest twardy wymog tego paska, opisany wyzej.

   `:has()` zawezа zmiane do paskow, ktore ten guzik naprawde maja: lekcja z czytanka
   zostaje przy trzech kolumnach i nic sie w niej nie przesuwa. */
.action-bar:has(> #btnImmersiveManual:not([hidden])) { grid-template-columns: repeat(5, 1fr); }
.action-bar:has(> #btnImmersiveManual:not([hidden])) #btnBack { grid-column: 1; }
.action-bar:has(> #btnImmersiveManual:not([hidden])) #btnImmersiveManual { grid-column: 2; grid-row: 1; }
.action-bar:has(> #btnImmersiveManual:not([hidden])) #btnPrimary { grid-column: 3; }
.action-bar:has(> #btnImmersiveManual:not([hidden])) #btnRevealPassage { grid-column: 4; }
/* Mobile: the floating-island detachment (margin + rounded corners) costs
   real vertical space this shell can't spare there. Placed in its own
   block immediately after the base rule above (not an earlier
   max-width:900px block elsewhere in the file) so it actually wins --
   equal specificity means source order decides. */
@media (max-width: 900px) {
  /* Symmetric top/bottom padding -- padding-bottom alone (the safe-area
     inset) padded only one side of a bar whose own height is driven by the
     play button, so the button ended up sitting visibly high against the
     bar's top edge instead of centered in it. Mirroring the same value on
     top keeps it centered whatever the inset resolves to (0 on desktop). */
  /* PODLOGA NA WYPADEK ZEROWEGO WCIECIA (Darek, 2026-09-21, zrzut z iPada:
     "the next button needs some padding at the bottom"). `env(safe-area-inset-bottom)`
     to NIE jest "odstep od krawedzi ekranu" -- to wysokosc strefy zarezerwowanej
     przez system (pasek gestow). Na iPadzie z przyciskiem Home, w oknie Split View
     i wszedzie bez `viewport-fit=cover` rozwiazuje sie do ZERA, a wtedy caly odstep
     to samo .55rem (~9 px) i ikony siedza na krawedzi. `max(...)` daje minimum
     niezaleznie od urzadzenia i nic nie zmienia tam, gdzie wciecie jest realne.
     Lustro gora/dol z tamtej wersji zdjete 2026-09-30 -- patrz nizej. */
  /* WCIECIE ZASTEPUJE DOLNY ODSTEP, A NIE SIE DO NIEGO DOKLADA (Darek, 2026-09-30,
     zrzut z iPhone'a: "too much padding in iphones in the action bar"). Poprzednia
     wersja brala wciecie DWA RAZY i jeszcze lustrzanie na gore: przy 34 px paska
     gestow wychodzilo 42,8 px nad dyskiem i 42,8 px pod nim, czyli pasek 157,6 px --
     prawie piata czesc ekranu. Lustro mialo sens przy braku wciecia (dysk
     wysrodkowany), ale strefa paska gestow nie jest "odstepem pod dyskiem", tylko
     miejscem zarezerwowanym przez system -- natywne paski iOS stawiaja kontrolki
     NAD nia, a nie na srodku calosci. Stad:
       - gora: staly odstep 1.1rem (tyle, ile iPad dostal 2026-09-21);
       - dol: WIEKSZE z dwojga -- ten sam odstep albo wciecie, nigdy ich suma.
     Bez wciecia (iPad z Home, Split View, desktop): 17,6 / 17,6 = 107,2 px, czyli
     bez zmian wzgledem 2026-09-21. Z wcieciem 34 px: 17,6 / 34 = 123,6 px zamiast
     157,6 px, a dysk konczy sie dokladnie na granicy strefy bezpiecznej.
     `--ab-inset` jest osobna zmienna, zeby dalo sie podstawic wciecie w przegladarce
     bez iPhone'a (Chrome nie emuluje env(safe-area-inset-*)). */
  .action-bar {
    margin: 0; width: 100%; border-radius: 0;
    --ab-luz: 1.1rem;
    --ab-inset: env(safe-area-inset-bottom, 0px);
    --ab-dol: max(var(--ab-luz), var(--ab-inset));
    padding-top: var(--ab-luz);
    padding-bottom: var(--ab-dol);
    /* `max-height: 100px` z reguly bazowej MUSI isc za tym paddingiem, inaczej
       przycina pasek i caly ten odstep nie ma gdzie powstac (72 px dysku + 2x
       ~17,6 px to juz 107 px). Cap zostaje -- jest tam po to, zeby algorytm
       siatki nie rozdymal wiersza pod wysoka `.trail` -- tylko liczony jest
       z tych samych skladnikow co wysokosc paska. */
    max-height: calc(72px + var(--ab-luz) + var(--ab-dol));
    min-height: calc(72px + var(--ab-luz) + var(--ab-dol));
  }
  /* 5 columns (not 4) so the primary button can sit in the true center
     column (3) with the extra mobile-handle slot added on -- 4 equal
     columns has no center column at all, which put primary off-center
     (it stayed pinned to column 2, the desktop center-of-3, no longer
     the center of anything). Column 2 is intentionally left empty: back/
     reveal/helper don't have a natural 4th symmetric partner, so an
     unconditionally-centered primary (the hard requirement) wins over
     forcing perfect left/right icon symmetry too. Same equal-specificity/
     source-order rule as above, has to live in THIS block (not the
     earlier one near .assistant-handle-mobile) to actually win over the
     repeat(3,1fr)/column-pin base rules above. */
  .action-bar { grid-template-columns: repeat(5, 1fr); }
  #actionBar #btnPrimary { grid-column: 3; }
  #actionBar #btnRevealPassage { grid-column: 4; }
  #actionBar #btnHelperMobile { grid-column: 5; }
}
.btn {
  border-radius: 99px;
  border: 2px solid transparent;
  font-weight: 700;
  transition: transform .12s, box-shadow .12s, opacity .12s;
  /* The platform brand-kit's style.css (layered import) has its own
     generic reusable .btn class with margin-bottom:1rem for its own
     stacked-button-list use case -- same class name, same bug shape as
     the earlier body padding-bottom leak. This is the exact "margin at
     the bottom of the next button" artifact reported live. */
  margin-bottom: 0;
}
.btn:active { transform: scale(.97); }
/* Same neutral fill as .reveal-passage-btn (border-color:var(--line),
   background:var(--surface-2)) -- these two used to look like different
   button families (transparent vs filled) even though both are plain
   circular icon controls in the same bar. */
/* 3D bezel: an inset top highlight + inset bottom shadow (reads as a convex disc)
   plus a soft outer shadow for lift -- same box-shadow language as the frosted play
   discs (.lc-play / .btn-primary-round), scaled down for this 44px button and
   applied on top of its own existing fill instead of adding a new disc behind it. */
.btn-quiet {
  background: var(--surface-2);
  border-color: var(--line);
  color: var(--muted);
  width: 44px; height: 44px;
  flex: none;
  padding: 0;
  font-size: 1.2rem;
  display: grid; place-items: center;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.65), inset 0 -1.5px 3px rgba(15,37,64,.1), 0 6px 12px -4px rgba(15,37,64,.28);
}
html[data-theme="zmierzch"] .btn-quiet,
html[data-theme="kontrast"] .btn-quiet {
  box-shadow: inset 0 1px 0 rgba(255,255,255,.15), inset 0 -1.5px 3px rgba(0,0,0,.35), 0 6px 12px -4px rgba(0,0,0,.4);
}
.btn-primary {
  background: var(--island-fill);
  /* --accent-ink is white/near-white in 3 of 4 themes -- correct against a
     solid var(--accent) fill (every other --accent-ink usage in this file
     pairs it that way), but --island-fill is a light gradient off
     var(--surface), so white-on-white text was invisible whenever this
     class rendered an actual text label (the round play button never hit
     this -- it's icon-only, no text node). */
  color: var(--ink);
  box-shadow: var(--island-shadow-sm);
}
/* Kontrast keeps a solid var(--accent) fill here (not the light
   island gradient), so it needs the ink meant to contrast against
   THAT -- --accent-ink, not --ink. */
html[data-theme="kontrast"] .btn-primary { background: var(--accent); color: var(--accent-ink); box-shadow: none; }
.btn-primary:hover { transform: translateY(-2px); }
.btn-primary:active { transform: translateY(1px); box-shadow: var(--island-shadow-sm); }
/* Frosted glass disc restored, sized back down to the original 72px circle --
   backdrop-blur fill + border + layered outer shadow is what makes the icon read as
   floating above the action bar. This button is also .btn-primary, whose own
   background: var(--island-fill) + box-shadow: var(--island-shadow-sm) would otherwise
   show through as a second, mismatched pill-shaped fill behind the round disc. */
.btn-primary-round {
  flex: none;
  width: 72px; height: 72px;
  padding: 11px;
  border-radius: 50%;
  /* grid-template-columns: 1fr is load-bearing, not decorative -- a plain <span>
     (see .btn-primary-icon) has no intrinsic size, so without an explicit track
     the implicit auto column collapses to 0 and the icon vanishes. The old <img>
     masked this because a replaced element always contributes a non-zero
     intrinsic size to the grid sizing algorithm even under width:100%. */
  display: grid; grid-template-columns: 1fr; place-items: center;
  background: color-mix(in srgb, var(--surface) 45%, transparent);
  backdrop-filter: blur(12px) saturate(150%);
  -webkit-backdrop-filter: blur(12px) saturate(150%);
  border: 1.5px solid color-mix(in srgb, var(--surface) 65%, transparent);
  box-shadow: 0 10px 28px -10px rgba(20, 52, 59, .4), 0 2px 8px rgba(20, 52, 59, .15);
  transition: box-shadow .3s ease, border-color .3s ease;
}
html[data-theme="zmierzch"] .btn-primary-round,
/* WORDED first-press state (Darek: "on the first load the next button should
   be replaced by a badge with text Cwiczenia and an arrow right, make it
   nice" -- then, on seeing the first attempt: "you can't just reuse a round
   icon to make a pill").

   The first attempt DID just reuse it: it kept the chip's glass fill and only
   widened the box, so in immersive -- where #btnPrimary carries an ID-level
   rule with `border-radius: 50% !important` and a fixed square size -- the
   circle stayed put and the label spilled out of it. This is a pill in its
   own right: solid accent, its own paired ink, its own shadow, no glass, no
   circle underneath.

   accent/accent-ink is the platform's guaranteed-contrast pair (same one
   .btn-primary and .nav-btn use). That matters most in immersive, where the
   backdrop is an arbitrary hero image: an opaque accent pill is legible over
   anything, which the translucent haze chip could only promise after the
   wash arithmetic documented further down. */
.btn-primary-round.is-badge {
  width: auto;
  height: auto;
  /* Ciszej (Darek, 2026-09-11: "the Cwiczenia button should be more subtle").
     Zmniejszone jest to, co krzyczalo -- rozmiar, grubosc pisma i KOLOROWA
     poswiata; wypelnienie zostaje krycie accentowe, bo w trybie skupienia
     pigulka lezy na dowolnym zdjeciu i to ono gwarantuje czytelnosc (patrz
     uzasadnienie w komentarzu wyzej -- szklana wersja byla juz odrzucona). */
  min-height: 2.9rem;
  padding: 0 1.15rem;
  border-radius: 999px;
  grid-template-columns: auto;
  background: var(--accent);
  border: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
}
/* The play glyph goes: the arrow already carries "forward", and two arrows on
   one pill is noise. */
.btn-primary-round.is-badge .btn-primary-icon { display: none; }
.btn-primary-badge {
  display: inline-flex;
  align-items: center;
  gap: .6rem;
  font-family: var(--font-display, var(--font-ui, inherit));
  font-weight: 700;
  font-size: .98rem;
  letter-spacing: .01em;
  color: var(--accent-ink);
  white-space: nowrap;
}
.btn-primary-badge i { font-size: 1.1em; transition: transform var(--speed); }
.btn-primary-round.is-badge:hover .btn-primary-badge i { transform: translateX(4px); }

html[data-theme="kontrast"] .btn-primary-round {
  box-shadow: 0 10px 28px -10px rgba(0, 0, 0, .6), 0 2px 8px rgba(0, 0, 0, .3);
}
/* .btn-primary:active re-asserts box-shadow: var(--island-shadow-sm) at higher
   specificity (a class + pseudo-class beats a single class), so it has to be
   cancelled here explicitly too or the disc's own shadow disappears on press. */
.btn-primary-round:active { box-shadow: 0 10px 28px -10px rgba(20, 52, 59, .4), 0 2px 8px rgba(20, 52, 59, .15); }
html[data-theme="zmierzch"] .btn-primary-round:active,
html[data-theme="kontrast"] .btn-primary-round:active {
  box-shadow: 0 10px 28px -10px rgba(0, 0, 0, .6), 0 2px 8px rgba(0, 0, 0, .3);
}
/* Correct/wrong signal: a brief colored ring around the disc that fades on its own,
   toggled by lesson-player-v3-exercises.js (see the comment there). */
.btn-primary-round.is-feedback-good {
  border-color: var(--good);
  box-shadow: 0 0 0 6px color-mix(in srgb, var(--good) 40%, transparent), 0 10px 28px -10px rgba(20, 52, 59, .4);
}
.btn-primary-round.is-feedback-bad {
  border-color: var(--bad);
  box-shadow: 0 0 0 6px color-mix(in srgb, var(--bad) 40%, transparent), 0 10px 28px -10px rgba(20, 52, 59, .4);
}
/* Same masked-icon recipe as lesson_library's .lc-play-icon (icon_play_mask.png,
   an alpha-only silhouette of the play glyph) tinted via background-color instead
   of shipping a separately-colored webp per state -- matches the icon used on the
   lesson tiles themselves. Sits inside the frosted disc above (its 11px padding is
   what shrinks this from 72px down to a 50px icon), so no drop-shadow of its own --
   the disc's own box-shadow is what sells the floating look. The "Sprawdź"
   (check-answer) state keeps its own fixed blue rather than following the theme
   accent, sampled from the old icon_play_check.webp so the color doesn't shift. */
.btn-primary-icon {
  display: block;
  width: 100%; height: 100%;
  background-color: var(--accent);
  -webkit-mask-image: url('assets/img/brand/icon_play_mask.png');
  mask-image: url('assets/img/brand/icon_play_mask.png');
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  transition: transform .5s ease, background-color .2s ease;
}
.btn-primary-icon.is-check { background-color: #1D88C8; }
/* Kontrast fills the disc itself with var(--accent) (see the .btn-primary
   override above), so tinting the glyph with --accent too would paint yellow
   on yellow -- invisible. Measured, not guessed: the glyph resolved to
   #FFD400 on a #FFD400 disc. --accent-ink (black here) is the ink meant to
   contrast against that fill, and the check state keeps a BLUE (so the
   green/blue state signal survives) dark enough to clear 4.5:1 on yellow --
   the stock #1D88C8 only manages ~2.8:1 there. */
html[data-theme="kontrast"] .btn-primary-icon { background-color: var(--accent-ink); }
html[data-theme="kontrast"] .btn-primary-icon.is-check { background-color: #0B4A6F; }
/* Desktop-only playful hover: a full spin on the icon itself. Gated to real hover
   devices so a touch "tap" doesn't fire a spin that never gets to finish before the
   next screen loads. */
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
  .btn-primary-round:hover .btn-primary-icon { transform: rotate(360deg); }
}
.btn-primary:disabled { opacity: .45; box-shadow: none; cursor: default; }
.btn-quiet:disabled { opacity: .3; cursor: default; }

/* ---------- Helper panel & drawers ---------- */
/* Full-height right-edge drawer -- consolidates theming/settings/audio/
   chat/teacher-tools/clipboard behind the one assistant-handle peek tab,
   instead of three separate topbar icons plus a small floating panel. */
.helper-panel {
  position: fixed;
  top: 0; right: 0; bottom: 0;
  z-index: 70;
  width: min(300px, 100vw);
  max-height: none;
  background: var(--island-fill);
  border-left: 2px solid var(--line);
  box-shadow: var(--island-shadow);
  display: flex;
  flex-direction: column;
  animation: drawerIn .25s ease;
  transition: background .3s ease, box-shadow .3s ease;
}
/* Briefly shows the chosen world photo itself right after changing it
   (mobile only, where the drawer covers the full width and there's no
   other way to see the effect without closing it first) -- see
   applyBgChoice in core.js, which adds this for 3s then removes it.
   NOT literal background:transparent -- the drawer sits on top of real
   page content (the reading/task card), not directly over the world
   photo, so punching a transparent hole through it exposed whatever
   text happened to be underneath instead, overlapping unreadably with
   the drawer's own content. Rendering the photo AS the drawer's own
   background is deterministic regardless of what's behind it. The dark
   scrim keeps the drawer's own text a little legible during the peek;
   full legibility isn't the point, seeing the photo/tint choice is. */
@media (max-width: 900px) {
  /* !important -- the html[data-bg-tint="..."] .helper-panel rules above
     (specificity 0,2,1: html + attribute + class) outrank a plain
     .helper-panel.is-bg-preview (0,2,0) regardless of source order, so a
     tinted background silently won every time this fired under a tint. */
  .helper-panel.is-bg-preview {
    background:
      linear-gradient(rgba(10, 15, 20, .35), rgba(10, 15, 20, .55)),
      var(--world-photo, var(--island-fill)) !important;
    background-size: cover !important;
    background-position: center !important;
    box-shadow: none !important;
  }
}
html[data-theme="kontrast"] .helper-panel { background: var(--surface); box-shadow: none; }
.helper-head { flex: none; display: flex; align-items: center; gap: .5rem; padding: 1rem 1rem .6rem; }
/* Redundant now that the full-body oracle mascot lives inside the chat
   log itself (see .chat-mascot below) -- one character presence, not two. */
.helper-owl { display: none; }
/* min-width:0 + ellipsis -- a flex item's default min-width:auto refuses
   to shrink below its own text's intrinsic width, so "Ustawienia" (wider
   than the leftover space once the header's ~5 icon buttons and the
   narrower 300px drawer are accounted for) pushed the close button and
   others out of the panel entirely instead of just truncating. */
.helper-hello { flex: 1; min-width: 0; margin: 0; font-weight: 700; font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gear-btn, .helper-close, .drawer-close {
  flex: none;
  width: 36px; height: 36px;
  border-radius: 50%;
  border: none;
  background: var(--surface-2);
  color: var(--muted);
  font-size: 1.05rem;
  display: grid; place-items: center;
}
.gear-btn[aria-expanded="true"] { background: var(--gold-soft); color: var(--ink); }
/* Main view (chat + clipboard) and settings view are mutually exclusive --
   the gear swaps one out for the other entirely, so the drawer only ever
   shows one decision surface at a time (ADHD/low-friction requirement:
   a wall of every setting at once reads as chaos). */
.helper-main {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
/* Chat / clipboard sub-tabs: mutually exclusive, each full-height within
   .helper-main so neither has to share scroll space with the other
   (that sharing is what used to wedge the clipboard's action buttons
   against its own peeking-oracle background). */
/* 2026-08-09: border-bottom added so the tab bar reads as a distinct
   header strip above the chat backdrop (see #chatTabView::before below) --
   matches .chat-form's own border-top at the OTHER end of that backdrop,
   so it's visually bounded top and bottom by a matching line. */
.helper-tabs { flex: none; display: flex; gap: .4rem; padding: 0 1rem .6rem; border-bottom: 2px solid var(--line); margin-bottom: .6rem; }
.helper-tab {
  flex: 1;
  min-height: 40px;
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--muted);
  font: inherit;
  font-weight: 700;
  font-size: .78rem;
  display: flex; align-items: center; justify-content: center; gap: .3rem;
}
.helper-tab.is-active { border-color: var(--accent); background: var(--good-soft); color: var(--ink); }
.helper-tab-view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
/* 2026-08-09: the lesson's OWN background image behind the chat panel --
   literally --world-photo, the exact same variable/value body::before
   paints on the main page, unconditionally (NOT a separate "backdrop"
   concept, and NOT gated to a "photo" vs "cape" manifest style -- an
   earlier version gated this to "photo"-style images only, which left
   ~1/3 of lessons (every "cape"-style background) with nothing at all
   behind the avatar; per Darek, repeatedly, this needed to just be the
   real lesson background, full stop). Spans from the .helper-tabs
   border-bottom above down to .chat-form's existing border-top below,
   i.e. the full visible area behind the scrolling message log. Same
   cover/center sizing as body::before, full opacity, zero blur.
   #chatTabView (not .chat-log) so it's a fixed panel background that
   does NOT scroll away with the message history -- .chat-log is the only
   scrolling element, this wraps it. position:relative + z-index:0 turns
   #chatTabView into its own stacking context so the -1 z-index stays
   scoped here (paints above the panel's own flat background, below
   .chat-log/.chat-mascot/.chat-form, all normal z-index:auto content)
   instead of escaping into the wider .helper-panel stacking context. */
#chatTabView { position: relative; z-index: 0; }
#chatTabView::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  /* 2026-08-10: right center, not center -- these lesson-background images
     mostly keep their subject off to one side with an empty middle, so a
     dead-center crop behind the chat panel (a narrow vertical strip) landed
     on that empty gap far more often than not. Right shows the side that's
     actually populated. */
  background: var(--world-photo, none) right center / cover no-repeat;
}
/* Settings view: full-height, one accordion category open at a time. */
.settings-tray { flex: 1; min-height: 0; overflow-y: auto; padding: .2rem 1rem 1rem; }
.acc-item { border-top: 1px dashed var(--line); }
.acc-item:first-child { border-top: none; }
.acc-head {
  width: 100%;
  display: flex;
  align-items: center;
  gap: .6rem;
  padding: .85rem .1rem;
  border: none;
  background: none;
  font: inherit;
  font-weight: 700;
  font-size: .87rem;
  color: var(--ink);
  text-align: left;
}
.acc-head span { flex: 1; }
.acc-head .ph:first-child { color: var(--accent); font-size: 1.15rem; }
.acc-caret { color: var(--muted); transition: transform var(--speed); }
.acc-item.is-open .acc-caret { transform: rotate(180deg); }
.acc-body { display: none; padding: 0 .1rem .9rem; }
.acc-item.is-open .acc-body { display: block; }
.acc-body .helper-group:first-child { border-top: none; padding-top: 0; }
.helper-group { padding: .55rem 0; border-top: 1px dashed var(--line); }
.helper-label { margin: 0 0 .45rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.theme-row { display: flex; gap: .6rem; }
.theme-dot {
  width: 42px; height: 42px;
  border-radius: 50%;
  border: 3px solid var(--line);
  padding: 0;
}
.theme-dot.is-active { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); }
.t-szlak    { background: linear-gradient(135deg, #ECF5F3 50%, #0E7C6E 50%); }
.t-zmierzch { background: linear-gradient(135deg, #101E22 50%, #37B8A4 50%); }
.t-pergamin { background: linear-gradient(135deg, #F6EEDF 50%, #8A5A24 50%); }
.t-kontrast { background: linear-gradient(135deg, #000 50%, #FFD400 50%); }
.font-row { display: flex; align-items: center; gap: .8rem; }
.font-row button {
  flex: 1;
  min-height: 44px;
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-weight: 700;
}
#fontSizeIndicator { color: var(--accent); letter-spacing: 2px; font-size: .8rem; }
.helper-wide-btn {
  display: flex;
  align-items: center;
  gap: .5rem;
  width: 100%;
  min-height: 46px;
  margin-bottom: .5rem;
  padding: .5rem .8rem;
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-size: .95rem;
}
.helper-wide-btn[aria-pressed="true"] { border-color: var(--accent); background: var(--good-soft); }
.clip-count {
  margin-left: auto;
  background: var(--accent);
  color: var(--accent-ink);
  border-radius: 99px;
  font-size: .75rem;
  padding: .05rem .5rem;
}

.drawer {
  position: fixed;
  top: 0; right: 0; bottom: 0;
  z-index: 80;
  width: min(380px, 100vw);
  background: var(--surface);
  border-left: 2px solid var(--line);
  box-shadow: var(--shadow);
  display: flex;
  flex-direction: column;
  animation: drawerIn .25s ease;
}
@keyframes drawerIn { from { transform: translateX(30px); opacity: 0; } to { transform: none; opacity: 1; } }
.drawer-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: .9rem 1rem;
  border-bottom: 2px solid var(--line);
  font-weight: 700;
}
.chat-log { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: .6rem; }
/* Full-body oracle mascot inside the chat log itself (see .pokeChatMascot in
   core.js) -- stands tall below the greeting, shrinks to a small peeking
   corner presence once real conversation starts, gives a little squeezed
   bounce + "aaauuu" bubble each time a new message lands near her. */
.chat-mascot {
  position: relative;
  align-self: center;
  width: 132px;
  flex: none;
  transition: width var(--speed), opacity var(--speed), align-self var(--speed), margin var(--speed);
}
.chat-mascot.is-crowded {
  align-self: flex-end;
  position: sticky;
  bottom: 2px;
  width: 46px;
  opacity: .55;
  margin: -.6rem 0 -.2rem auto;
}
.mascot-figure {
  width: 100%;
  /* 2026-08-09: elder/sibyl retired -- default is now the same universal
     sci-fi figure used for every other subject without a dedicated world
     (see .assistant-handle-face for the fuller note), so the default
     aspect-ratio matches its 325x860 canvas instead of elder's 300x533. */
  aspect-ratio: 325 / 860;
  background-image: url('https://cdn.eduhero.pl/oracle/oracle-uniwersalne-v3.webp');
  background-size: contain;
  background-position: bottom center;
  background-repeat: no-repeat;
}
html[data-oracle-world="historia"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-historia-v3.webp'); }
html[data-oracle-world="historia"] .mascot-figure,
html[data-oracle-world="angielski"] .mascot-figure,
html[data-oracle-world="niemcy"] .mascot-figure,
html[data-oracle-world="hiszpanski"] .mascot-figure,
html[data-oracle-world="francja"] .mascot-figure,
html[data-oracle-world="polski"] .mascot-figure,
html[data-oracle-world="gr-jezykowe"] .mascot-figure,
html[data-oracle-world="matematyka"] .mascot-figure,
html[data-oracle-world="fizyka"] .mascot-figure,
html[data-oracle-world="chemia"] .mascot-figure,
html[data-oracle-world="biologia"] .mascot-figure,
html[data-oracle-world="geografia"] .mascot-figure,
html[data-oracle-world="informatyka"] .mascot-figure,
html[data-oracle-world="gr-kulturowe"] .mascot-figure,
html[data-oracle-world="gr-spoleczne"] .mascot-figure,
html[data-oracle-world="biznes"] .mascot-figure,
html[data-oracle-world="art"] .mascot-figure,
html[data-oracle-world="uniwersalne"] .mascot-figure { aspect-ratio: 325 / 860; }
html[data-oracle-world="angielski"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-angielski-v3.webp'); }
html[data-oracle-world="niemcy"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-niemcy-v3.webp'); }
html[data-oracle-world="hiszpanski"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-hiszpanski-v3.webp'); }
html[data-oracle-world="francja"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-francja-v3.webp'); }
html[data-oracle-world="polski"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-polski-v3.webp'); }
html[data-oracle-world="gr-jezykowe"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-jezykowe-v3.webp'); }
html[data-oracle-world="matematyka"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-matematyka-v3.webp'); }
html[data-oracle-world="fizyka"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-fizyka-v3.webp'); }
html[data-oracle-world="chemia"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-chemia-v3.webp'); }
html[data-oracle-world="biologia"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-biologia-v3.webp'); }
html[data-oracle-world="geografia"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-geografia-v3.webp'); }
html[data-oracle-world="informatyka"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-informatyka-v3.webp'); }
html[data-oracle-world="gr-kulturowe"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-kulturowe-v3.webp'); }
html[data-oracle-world="gr-spoleczne"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-gr-spoleczne-v3.webp'); }
html[data-oracle-world="biznes"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-biznes-v3.webp'); }
html[data-oracle-world="art"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-art-v3.webp'); }
html[data-oracle-world="uniwersalne"] .mascot-figure { background-image: url('https://cdn.eduhero.pl/oracle/oracle-uniwersalne-v3.webp'); }
.chat-mascot.is-squished .mascot-figure { animation: mascot-squish .5s ease; }
.mascot-bubble {
  position: absolute;
  top: -8px; left: 50%;
  transform: translate(-50%, -100%) scale(.6);
  background: var(--surface);
  border: 2px solid var(--line);
  border-radius: 99px;
  padding: .2rem .7rem;
  font-size: .8rem;
  font-weight: 700;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity .15s, transform .15s;
}
.chat-mascot.is-squished .mascot-bubble { opacity: 1; transform: translate(-50%, -120%) scale(1); }
@keyframes mascot-squish {
  0% { transform: scaleY(1) scaleX(1); }
  30% { transform: scaleY(.72) scaleX(1.18); }
  60% { transform: scaleY(1.08) scaleX(.94); }
  100% { transform: scaleY(1) scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
  .chat-mascot.is-squished .mascot-figure { animation: none; }
}
.chat-msg { max-width: 85%; padding: .55rem .85rem; border-radius: 14px; font-size: .95rem; line-height: 1.45; }
.chat-msg.from-user { align-self: flex-end; background: var(--accent); color: var(--accent-ink); border-bottom-right-radius: 4px; }
.chat-msg.from-bot  { align-self: flex-start; background: var(--surface-2); border-bottom-left-radius: 4px; }
/* 2026-08-09: opaque background added -- #chatTabView::before's backdrop
   (above) covers this element's box too (it's a sibling of .chat-log
   inside #chatTabView, both within the same inset:0 area), so without
   this the backdrop would bleed past the border-top into the input
   row's own padding instead of visually stopping at that line. */
.chat-form { display: flex; gap: .5rem; padding: .8rem; border-top: 2px solid var(--line); box-sizing: border-box; background: var(--island-fill); }
.chat-form input {
  flex: 1 1 auto;
  min-width: 0;
  font: inherit;
  border: 2px solid var(--line);
  border-radius: 99px;
  padding: .55rem 1rem;
  background: var(--surface-2);
  color: inherit;
  box-sizing: border-box;
}
.chat-form button {
  width: 44px; height: 44px;
  flex: none;
  border-radius: 50%;
  border: none;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 1.05rem;
}
@media (min-width: 380px) { .chat-form button { width: 48px; height: 48px; font-size: 1.1rem; } }
.chat-form button:disabled { opacity: .4; }
/* Persona picker directly under the input -- a one-tap thing you change
   mid-conversation, not a one-time setup step buried in Settings. */
/* Persona + language pickers share one row -- language is the compact
   flag-icon twin (chat-lang-select below), not another full-width select. */
/* Sits directly below .chat-form, both painted over #chatTabView::before's
   world-photo backdrop -- .chat-form covers its own strip with a solid
   --island-fill background, but this row had none, so the photo bled
   through underneath the pickers while the input above it stayed opaque.
   Same fill (no border between them) makes the two read as one continuous
   opaque footer instead of a see-through gap. */
.chat-picker-row { display: flex; align-items: center; gap: .4rem; margin: 0; padding: 0 .8rem .8rem; background: var(--island-fill); }
.chat-picker-row .chat-persona-select { flex: 1; min-width: 0; width: auto; margin: 0; }
.chat-lang-select {
  flex: none;
  font: inherit;
  font-size: 1rem;
  text-align: center;
  border: 2px solid var(--line);
  border-radius: 99px;
  padding: .3rem .4rem;
  background: var(--surface-2);
  color: var(--muted);
}
.chat-persona-select {
  display: block;
  width: calc(100% - 1.6rem);
  margin: 0 .8rem .8rem;
  font: inherit;
  font-size: .85rem;
  border: 2px solid var(--line);
  border-radius: 99px;
  padding: .4rem .8rem;
  background: var(--surface-2);
  color: var(--muted);
}

/* Embedded chat bubbles (replaces ai_chat_modal.css's own classes, now
   that chat lives directly in the drawer instead of a separate modal). */
.message-row { display: flex; }
.message-row.user { justify-content: flex-end; }
.message-row.bot { justify-content: flex-start; }
.message-bubble {
  max-width: 88%;
  padding: .55rem .8rem;
  border-radius: 14px;
  font-size: .85rem;
  line-height: 1.4;
  background: var(--surface-2);
}
.message-row.user .message-bubble { background: var(--accent); color: var(--accent-ink); border-bottom-right-radius: 4px; }
.message-row.bot .message-bubble { border-bottom-left-radius: 4px; }
.message-content { display: block; }
.message-controls { margin-top: .3rem; }
.tts-action-btn {
  display: flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; flex: none;
  border: none; border-radius: 50%; cursor: pointer;
  background: var(--surface); color: var(--muted);
  transition: background .15s, color .15s;
}
.tts-action-btn svg { width: 16px; height: 16px; }
.tts-action-btn:hover { background: color-mix(in srgb, var(--accent) 16%, var(--surface)); color: var(--accent); }
.tts-action-btn.ready, .tts-action-btn.playing { background: var(--accent); color: var(--accent-ink); }
.tts-action-btn.loading { opacity: .5; pointer-events: none; }
.timestamp { margin-top: .2rem; font-size: .68rem; opacity: .6; }
.typing-dots { letter-spacing: 2px; animation: chunkIn .3s ease; }

.clip-list { list-style: none; margin: 0; padding: 1rem; overflow-y: auto; display: flex; flex-direction: column; gap: .5rem; }
.clip-list li {
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: .55rem .8rem;
  font-size: .92rem;
}
.clip-empty { color: var(--muted); text-align: center; border: none !important; background: none !important; }

/* ---------- Zoom overlay ---------- */
/* Rebuilt from scratch (old version was just a fixed scale(2) with no pan
   and no real viewport-fit math). .zoom-stage is the fit-to-viewport frame
   (safe-area aware); the img is scaled/panned inside it via JS-driven
   transform, clamped so it can never be dragged fully off-screen. */
.zoom-overlay {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: rgba(10, 22, 25, .92);
  display: flex;
  overflow: hidden;
  touch-action: none;
  padding: calc(1rem + env(safe-area-inset-top)) calc(1rem + env(safe-area-inset-right)) calc(1rem + env(safe-area-inset-bottom)) calc(1rem + env(safe-area-inset-left));
  box-sizing: border-box;
}
.zoom-stage {
  flex: 1;
  min-width: 0; min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.zoom-stage img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: var(--radius-sm);
  cursor: grab;
  user-select: none;
  -webkit-user-drag: none;
  transform-origin: center center;
  will-change: transform;
}
.zoom-stage img.is-zoomed { cursor: grab; }
.zoom-stage img.is-dragging { cursor: grabbing; }
.zoom-close {
  position: absolute;
  top: calc(1rem + env(safe-area-inset-top)); right: calc(1rem + env(safe-area-inset-right));
  width: 48px; height: 48px;
  border-radius: 50%;
  border: none;
  background: var(--surface);
  font-size: 1.1rem;
  z-index: 1;
}
.zoom-controls {
  position: absolute;
  bottom: calc(1rem + env(safe-area-inset-bottom));
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: .4rem;
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  border-radius: 99px;
  padding: .35rem;
  box-shadow: var(--shadow);
}
.zoom-ctrl-btn {
  width: 42px; height: 42px;
  border-radius: 50%;
  border: none;
  background: var(--surface-2);
  font-size: 1.15rem;
  font-weight: 700;
}
.zoom-ctrl-reset { width: auto; padding: 0 .8rem; font-size: .78rem; }
/* Paging arrows and the position counter for a multi-image set. Both are
   revealed by JS (zoomSyncNav) only when there is more than one picture, so
   nothing here fires for the single-image case. Positioned against the
   overlay rather than the stage: the stage shrinks to the fitted picture, so
   arrows anchored to it would slide inwards on a narrow photo and sit on top
   of the image instead of beside it. */
.zoom-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 48px; height: 48px;
  border-radius: 50%;
  border: none;
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  font-size: 1.15rem;
  display: grid;
  place-items: center;
  box-shadow: var(--shadow);
  z-index: 1;
}
.zoom-nav.prev { left: calc(.5rem + env(safe-area-inset-left)); }
.zoom-nav.next { right: calc(.5rem + env(safe-area-inset-right)); }
/* Kept visible rather than hidden at the ends: an arrow that vanishes moves
   its neighbour, and the reader loses the target they were aiming at. */
.zoom-nav:disabled { opacity: .35; }
.zoom-counter {
  position: absolute;
  top: calc(1rem + env(safe-area-inset-top));
  left: 50%;
  transform: translateX(-50%);
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  border-radius: 99px;
  padding: .3rem .85rem;
  font-size: .8rem;
  font-weight: 700;
  box-shadow: var(--shadow);
  z-index: 1;
}

/* ---------- FX layer ---------- */
.fx-layer { position: fixed; inset: 0; z-index: 100; pointer-events: none; overflow: hidden; }
.fx-burst {
  position: absolute;
  font-size: 1.4rem;
  animation: burst 1s ease-out forwards;
}
@keyframes burst {
  from { transform: translate(0, 0) scale(.6); opacity: 1; }
  to   { transform: translate(var(--dx), var(--dy)) scale(1.3); opacity: 0; }
}

/* ---------- Responsive: tablet & phone ----------
   The single-column stacking + reading/split panel-swap rules are now
   universal (see .stage/.reading-panel/.reading-peek etc. above) -- this
   block is just genuine responsive scaling: narrower trail, smaller
   type/padding, tighter grids. */
@media (max-width: 900px) {
  /* Symbolic side inset, not real padding -- the card was losing most of
     the viewport width to padding it didn't need, and hiding the
     per-subject photo backdrop that padding was meant to reveal. */
  .stage { padding: .4rem .4rem .6rem; gap: .5rem; }
  .task-card { padding: 2.2rem .7rem .6rem; }
  .task-card { padding-top: var(--task-head-clearance, 2.2rem); }
  .q-text { font-size: 1.05rem; margin-bottom: .7rem; }
  .task-instructions { font-size: .82rem; margin: .15rem 0 .6rem; }
  .match-cols { gap: .5rem; }
  .match-item { font-size: .82rem; padding: .5rem .6rem; min-height: 52px; }
  /* Revealed passage (openReadingSheet relocates #readingBody into
     #taskCard -- see core.js) has no exercise controls to leave room
     for, just the reading text -- .task-card's .7rem side padding above
     is sized for badges/buttons that aren't there in this state, so on
     phones it was wasting width the passage itself could use (Darek,
     2026-08-07). */
  .task-card.is-showing-passage { padding-left: .35rem; padding-right: .35rem; }
  .task-card.is-showing-passage .chunk-viewport { padding-left: .6rem; padding-right: .6rem; }
}

/* Passage header ("TEKST LEKCJI" + audio tools) icon-only, pulled out of
   the 900px block above into its own wider one (Darek, 2026-08-06:
   "consider iconifying the buttons earlier") -- the toolbar can run out
   of room for full labels before the phone-width 900px threshold, so
   this is a separate, wider cutoff. Independent of #mediaZone's own
   breakpoint above (2026-08-14 redesign) -- the reading card never
   shares its width with the image now (the photo either floats fully
   outside .stage at >=1800px, or doesn't show inline at all below it),
   so this threshold doesn't need to track that one. */
@media (max-width: 1799px) {
  .reading-head { flex-wrap: wrap; }
  .tool-label { display: none; }
  .tool-btn { padding: .4rem; width: 36px; height: 36px; justify-content: center; }
}

/* Trail: reduced to a thin progress bar (Darek, 2026-08-05) sitting
   in-flow BETWEEN the task card and the action bar. Threshold is
   1424px, NOT the 900px phone breakpoint above -- this used to be a
   narrower <=900px-only treatment, with the 901-1424px tablet range
   instead getting the desktop S-curve trail as an off-canvas "peeking"
   drawer (position:fixed, briefly slides in on progress then back out).
   Screenshot-flagged (Darek, 2026-08-05): that peeking drawer is a
   floating OVERLAY by construction -- .stage's own margin only clears
   room for the trail in its DOCKED state (see the `left` calc on
   .trail's base rule, and the 1425px math in the comment right above
   that media query), so whenever the drawer actually peeks into view in
   this range it has nowhere reserved to go and just sits on top of the
   card/action-bar underneath it. Fix (Darek): "once disappeared always
   should show as a progress bar" -- i.e. no more transient overlay
   state at all in this range, just the same always-visible thin bar
   phones already get, all the way up to the point the full trail can
   actually dock without overlapping anything (1425px, matching
   renderTrail's/updateTrail's own (max-width:1424px) JS checks). */
@media (max-width: 1424px) {
  .player-shell {
    grid-template-rows: 1fr auto auto;
    grid-template-areas: "stage" "progress" "actionbar";
  }
  .trail {
    grid-area: progress;
    position: static;
    width: 100%;
    height: auto;
    z-index: auto;
    background: transparent;
    box-shadow: none;
    transform: none;
    transition: none;
    pointer-events: auto;
    display: block;
    /* Sized to the ACTION BAR below it, not to the shell -- the folded bar
       shares that band, so a plain width:100% here ran it out past the
       bar's (and the card's) own edges on both sides (Darek,
       screenshot-flagged 2026-08-16). Same three values .action-bar uses,
       and side padding dropped to 0 so the rail's ends line up with the
       bar's outer edges exactly. */
    max-width: calc(900px - 2.2rem);
    width: calc(100% - 1.2rem);
    margin: 0 auto;
    box-sizing: border-box;
    padding: .3rem 0 .1rem;
  }
  .trail-scroll {
    position: relative;
    width: 100%;
    height: 6px;
    overflow: visible;
    border-radius: 999px;
    background: var(--line);
  }
  .trail-svg, .trail-marks { display: none; }
  .trail-progress-fill {
    position: absolute;
    inset: 0;
    width: 0%;
    border-radius: 999px;
    background: var(--accent);
    transition: width .5s ease;
  }
  /* Kropki przystankow -- tylko nauczyciel/admin (renderTrail dodaje je
     wylacznie wtedy). Kropka 12px na pasku 6px, a trafienie 28px: przycisk
     jest wiekszy niz to, co rysuje, zeby dalo sie w niego trafic palcem.
     Wiecej miejsca w pionie (.has-dots), bo kropka wystaje poza pasek. */
  .trail.has-dots { padding: .7rem 0 .55rem; }
  .trail-dots { position: absolute; inset: 0; }
  .trail-dot {
    position: absolute;
    top: 50%;
    width: 28px; height: 28px;
    margin: 0; padding: 0;
    transform: translate(-50%, -50%);
    border: 0;
    background: transparent;
    cursor: pointer;
  }
  .trail-dot::before {
    content: "";
    position: absolute;
    left: 50%; top: 50%;
    width: 12px; height: 12px;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    background: var(--surface);
    /* --muted, nie --line: kropka stoi NA pasku w kolorze --line i z tym samym
       obrysem byla niewidoczna (zmierzone na zrzucie). */
    border: 2px solid var(--muted);
    box-sizing: border-box;
    transition: transform .15s ease, background .2s ease, border-color .2s ease;
  }
  .trail-dot[data-state="done"]::before { background: var(--accent); border-color: var(--accent); }
  .trail-dot[data-state="active"]::before { background: var(--surface); border-color: var(--accent); width: 16px; height: 16px; border-width: 3px; }
  .trail-dot:hover::before, .trail-dot:focus-visible::before { transform: translate(-50%, -50%) scale(1.3); }
}
@media (max-width: 900px) {
  .trail.has-dots { padding: .7rem .9rem .55rem; }
}
.trail-dots { display: none; }
@media (max-width: 1424px) { .trail-dots { display: block; } }

/* Phones: .action-bar drops its floating-island inset (full width, no
   margin, square corners -- see its own max-width:900px block), so the
   folded trail has to follow it back out to full width or it would now
   read as NARROWER than the bar. Has to live AFTER the 1424px block
   above -- equal specificity, source order decides. */
@media (max-width: 900px) {
  .trail {
    max-width: none;
    width: 100%;
    padding: .3rem .9rem .1rem;
  }
}

@media (max-width: 480px) {
  .lesson-meta { display: none; }
  .tf-row { grid-template-columns: 1fr; }

  /* Task-head overlap (Darek, screenshot-flagged, 336px-wide DevTools
     capture): .task-counter ("Pytanie X z Y") + .task-head-actions (audio
     transport pill + song button, sometimes + hint bulb too) sit on ONE
     flex row (see .task-head) inside a fixed 2.2rem top clearance (see
     .task-card's mobile padding) -- at this width that combined row can
     run wider than the card's own content area, and since .task-head is
     position:absolute it doesn't push .task-instructions down when that
     happens; the two silently overlap instead. Two-part fix: shrink the
     actions cluster's own icons (less likely to need to wrap at all) AND
     let the row wrap onto a 2nd line as a safety net if it still doesn't
     fit, with enough EXTRA top clearance reserved for that 2nd line.
     Od 2026-09-05 wartosc liczy LP.fitCardPadding z FAKTYCZNEJ wysokosci
     .task-head, wiec drugi wiersz akcji podnosi odstep sam, a pusty licznik
     go nie rezerwuje. 3.6rem zostaje jako rezerwa, gdyby JS nie zdazyl. */
  .task-card { padding-top: var(--task-head-clearance, 3.6rem); }
  .task-head { flex-wrap: wrap; row-gap: .3rem; }
  .tam-btn { width: 24px; height: 24px; }
  .tam-song-btn, .hint-btn { width: 30px; height: 30px; }
}

/* ==================================================================
   V3 PRODUCTION ADDITIONS — everything below supports the real
   feature set ported from the old player (teacher tools, modals,
   vocab clipboard, media, transcript, audio, legacy widgets).
   ================================================================== */

@keyframes spin { 100% { transform: rotate(360deg); } }

/* ---------- Topbar tool buttons (chat / teacher) ---------- */
.topbar-tool-btn {
  width: 44px; height: 44px;
  border: 2px solid var(--line);
  border-radius: 50%;
  background: var(--surface-2);
  font-size: 1.25rem;
  display: grid; place-items: center;
  flex: none;
  transition: transform var(--speed), border-color var(--speed);
}
.topbar-tool-btn:hover { border-color: var(--accent); transform: scale(1.06); }

/* ---------- Teacher menu ---------- */
.teacher-anchor { position: relative; }
.teacher-menu-popup {
  display: none;
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  z-index: 200;
  min-width: 220px;
  background: var(--surface);
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
  padding: .4rem;
}
.teacher-menu-popup.active { display: block; }
.tm-item {
  display: flex; align-items: center; gap: .5rem;
  width: 100%;
  border: none; background: none;
  text-align: left;
  padding: .55rem .7rem;
  border-radius: 8px;
  font-size: .92rem;
}
.tm-item:hover { background: var(--surface-2); }
.tm-divider { border-top: 1px dashed var(--line); margin: .3rem 0; }

/* ---------- Helper panel additions ---------- */
.helper-scroll { flex: 1; overflow-y: auto; min-height: 0; }
.helper-group-launch { display: flex; flex-direction: column; gap: .4rem; padding-top: 0; border-top: none; }
.helper-launch-btn i { font-size: 1.1rem; flex: none; }
.helper-select, .bgm-row select {
  width: 100%;
  font: inherit;
  font-size: .88rem;
  padding: .45rem .6rem;
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: inherit;
}
#tts-cost-hint { display: block; margin-top: 5px; font-size: .75rem; color: var(--muted); }
.bgm-row { display: flex; gap: 8px; align-items: center; margin-top: .5rem; }
.bgm-row select { flex: 1; width: 0; min-width: 0; }
.bgm-row button {
  width: 40px; height: 40px; flex: none;
  border-radius: 50%; border: none;
  background: var(--accent); color: var(--accent-ink);
  display: flex; align-items: center; justify-content: center;
}
.bgm-volume-row { display: flex; align-items: center; gap: 10px; margin-top: .5rem; font-size: .8rem; color: var(--muted); }
.bgm-volume-row input { flex: 1; accent-color: var(--audio-accent-soft); }

/* ---------- Passage audio ---------- */
/* Themed transport, not the browser's native <audio controls> bar (that
   renders as unstyled OS chrome -- ignores the theme entirely). Drives
   the real #passageAudioPlayer element, which stays controls-less and
   invisible; this is the only UI for it. */
.passage-audio-row { flex: none; padding: .2rem 1rem .5rem; display: flex; align-items: center; gap: .6rem; }
.passage-audio-row audio { display: none; }
.pa-play {
  flex: none;
  width: 34px; height: 34px;
  border-radius: 50%;
  border: none;
  background: var(--audio-accent);
  color: #fff;
  display: grid; place-items: center;
  font-size: .95rem;
  cursor: pointer;
}
.pa-time { flex: none; font-size: .74rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.pa-seek { flex: 1; min-width: 0; accent-color: var(--audio-accent-soft); }
/* Volume lives in a popover rather than inline: the row already carries
   play + two timestamps + the seek bar, and a second inline slider would
   squeeze .pa-seek (flex:1) below a draggable width on a phone. There is
   no separate mute button -- dragging to 0 mutes, and .pa-vol-btn's glyph
   swaps to ph-speaker-slash to say so. */
.pa-vol { flex: none; position: relative; display: flex; }
.pa-vol-btn {
  width: 30px; height: 30px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: var(--muted);
  display: grid; place-items: center;
  font-size: 1rem;
  cursor: pointer;
}
.pa-vol-btn:hover,
.pa-vol-btn[aria-expanded="true"] { color: var(--audio-accent); }
.pa-vol-pop {
  position: absolute;
  bottom: calc(100% + 6px);
  right: -4px;
  z-index: 30;
  padding: .5rem .65rem;
  border-radius: 10px;
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: 0 8px 22px rgba(0, 0, 0, .18);
  display: flex; align-items: center;
}
.pa-vol-slider { width: 110px; accent-color: var(--audio-accent-soft); }
.passage-audio-row.is-stale .pa-play,
.passage-audio-row.is-stale .pa-vol-btn,
.passage-audio-row.is-stale .pa-seek { opacity: .4; pointer-events: none; }
/* .audio-cost-badge removed 2026-08-19 -- the cost moved off the player face
   into the generate/regenerate confirmation dialog. The class still appears in
   the legacy lesson_player.js, which this stylesheet is not loaded alongside. */

/* ---------- Media zone: passage media carousel + video ---------- */
.media-zone { flex: none; }
/* Frosted glass (Darek, 2026-08-06: "in sync with the rest of the page" --
   same literal backdrop-filter language as .trail/.trail-mark's wash+halo
   above, not the flat --island-fill card look .reading-card/.task-card
   use). Real padding around the slide is what reveals the frosted frame --
   object-fit:contain already leaves letterbox bars around non-square
   photos (unlike a full-bleed cover crop, which would paint over its own
   container's background/blur entirely), so the frost shows both there
   and in this outer margin. Trimmed from 7px to 3px (2026-08-14, Darek:
   the panel is already small and fixed-size, so 14px total was a real
   chunk of it spent on frame instead of photo) -- kept nonzero rather
   than 0 so the frost is still visible at all, per the reasoning above. */
.media-carousel {
  position: relative;
  border-radius: var(--radius);
  overflow: hidden;
  padding: 3px;
  background: color-mix(in srgb, var(--surface) 45%, transparent);
  backdrop-filter: blur(14px) saturate(150%);
  -webkit-backdrop-filter: blur(14px) saturate(150%);
  border: 1px solid color-mix(in srgb, var(--surface) 60%, white 40%);
  box-shadow: var(--shadow);
}
.media-carousel-slides { display: flex; transition: transform .3s ease; }
.media-carousel-slide { flex: 0 0 100%; }
.media-carousel-slide img, .media-carousel-slide video {
  /* Small persistent thumbnail, not a banner -- glanceable, never
     competes for the vertical space the passage/exercises need. contain
     (not cover) -- a fixed 96px strip forcing width:100% via cover was
     cropping the top/bottom off illustrations that aren't roughly square
     (a wide horizontal scene lost most of itself to the crop). */
  display: block; width: 100%; height: 96px; object-fit: contain;
  background: color-mix(in srgb, var(--surface-2) 55%, transparent);
  border-radius: calc(var(--radius) - 3px);
  cursor: zoom-in;
}
.media-carousel-nav {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 38px; height: 38px;
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--surface) 60%, white 40%);
  background: color-mix(in srgb, var(--surface) 55%, transparent);
  backdrop-filter: blur(10px) saturate(150%);
  -webkit-backdrop-filter: blur(10px) saturate(150%);
  font-size: 1rem;
  display: flex; align-items: center; justify-content: center;
}
.media-carousel-nav.prev { left: .5rem; }
.media-carousel-nav.next { right: .5rem; }
.media-carousel-nav:disabled { opacity: .3; }
/* Task-panel media zone: the same carousel, but given the full column on
   the intro (reading) screen instead of a cramped strip above the passage
   -- see the hasMedia swap in go(). Bigger + uncropped since there's
   nothing else competing for space yet. */

/* ---------- Video lesson (YouTube + transcript) ---------- */
.video-figure { margin: 0; border-radius: var(--radius); overflow: hidden; background: #000; }
.video-figure .yt-holder { position: relative; width: 100%; aspect-ratio: 16 / 9; }
.video-figure .yt-holder iframe { position: absolute; inset: 0; width: 100%; height: 100%; }
#transcript-container { font-size: .95rem; line-height: 1.6; }
/* Captions are the lesson's text for a video, not a sidenote -- but the base
   rule above sizes them at .95rem, SMALLER than #chunkViewport's own
   clamp(1rem,...) for ordinary passage text. Darek: "captions in normal mode
   are too small." Sized on the same principle as the passage: a clamp that
   grows with the viewport, capped so a wide monitor does not turn two lines
   into a headline. Immersive keeps its own larger scale -- .transcript-line
   carries a font-size there, which beats this container-level inherit. */
.player-shell[data-media-kind="video"] #transcript-container {
  font-size: clamp(1.02rem, .92rem + .42vw, 1.28rem);
  line-height: 1.62;
}
/* Darek: "the captions should be centered vertically and no background."
   Dimming the non-active lines (not a background box on the active one) is
   what actually reads as "which line is this" -- a tinted rectangle behind
   pale text is what made this look "dimmed, as if behind an overlay" even
   once the active line WAS legible; opacity alone is a lighter touch that
   also unifies with the immersive treatment below instead of needing a
   second visual language for the same idea. Hover keeps a faint background
   as the only remaining one -- it is the sole cue that a line is clickable
   (jumps the video to that timestamp), unrelated to the active/inactive
   state this replaces. */
.transcript-line {
  cursor: pointer;
  border-radius: 8px;
  padding: .25rem .5rem;
  margin: .15rem 0;
  opacity: .55;
  transition: opacity .15s, background .15s;
}
.transcript-line:hover { background: var(--surface-2); opacity: .8; }
.transcript-line.active { opacity: 1; }
/* Video lessons only (data-media-kind, set by initVideoLesson) -- NOT text
   reading, which still uses the beat rail / .stage scroll for a passage of
   unknown, often much greater, length. Bounded so a scrolled caption can
   never render above or through the video: the video sits in normal flow
   right above this box (in.reading-body order the transcript ALWAYS
   follows #mediaZone), so anything this box could show is already below
   the video's own bottom edge -- there is no coordinate left for a line to
   collide with it. JS keeps the active line centred within it via a plain
   scrollIntoView -- once this is the nearest scrolling ancestor, the
   browser resolves that itself. */
.player-shell[data-media-kind="video"] #chunkViewport {
  /* Base gives this `flex: 1; min-height: 0;` to grow into whatever the
     reading card has left over. That is exactly wrong here and it does not
     fail loudly -- it collapses SILENTLY: `overflow-y: auto` resolves the
     flex item's automatic minimum size to 0 per spec, so with flex-grow
     fighting the video (which now takes its own full natural height above
     it) for the same column, the measured box came back 27px tall against
     a 546px scrollHeight -- max-height is a CEILING on a flex-grow box, not
     a floor, and flex-grow just never bid that high. `flex: none` plus an
     explicit `height` (not max-height) makes this a fixed-size box that
     does not negotiate for space at all. */
  flex: none;
  /* Grew with the player's shrink above -- this is the "compromise" half. */
  height: min(27vh, 340px);
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
  /* Base also gives this `justify-content: safe center` -- built for a
     PAGED text chunk, which usually fits the box and only rarely needs the
     "safe" escape hatch. A 14-line transcript is the opposite: it is
     ALWAYS taller than this window, so centering is never the safe path,
     it is the ONLY path -- and centered overflow with no "start" edge is
     not reliably reachable by scrolling at all (measured live: scrollHeight
     1074 vs a 274px box, yet neither scrollIntoView nor forcing
     `scrollTop = 100` directly moved it even 1px). Plain flex-start gives
     this a normal, unambiguous top edge to scroll from; JS already
     provides the "centre the active line" effect via scrollIntoView, so
     nothing is lost by not centering the CONTAINER's content too. */
  justify-content: flex-start;
}

/* ---------- Passage highlight verdict colors (parity with old) ----------
   ZAZNACZENIE MA WYSOKOSC PISMA, NIE PROSTOKAT DOKLEJONY SPANEM (Darek,
   2026-09-21: "the highlight is ugly, not full height of the font").
   `box-shadow: 0 0 0 3px` rysowal ramke wokol *pudelka zawartosci* spana, ktore
   jest nizsze niz wiersz -- stad pasek urwany w polowie liter, z twarda krawedzia
   i innej szerokosci na kazdym fragmencie zawiniecia. Zamiast tego wlasny padding
   w `em` (idzie za rozmiarem pisma) + `box-decoration-break: clone`, zeby kazdy
   fragment zawinietego zdania dostal pelne zaokraglenie, a nie polowe. */
.reading-chunk span[id].correct,
.reading-chunk span[id].incorrect {
  box-shadow: none;
  padding: .18em .22em;
  border-radius: .3em;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.reading-chunk span[id].correct { background: var(--good-soft); }
.reading-chunk span[id].incorrect { background: var(--bad-soft); }

/* ---------- Cloze (cloze-questions type) ---------- */
.fib-cue { font-size: .85rem; color: var(--muted); font-style: italic; margin-left: .25rem; white-space: nowrap; }
.correct-answer-display {
  display: block; font-size: .85rem; color: var(--good);
  font-weight: 700; margin-top: .2rem;
}
.fib-input-container { display: inline-block; text-align: center; margin: 0 .3em; }

/* ---------- Odwolanie od oceny luki ----------
   Celowo dyskretne: to wyjscie awaryjne dla odpowiedzi, ktorej klucz nie przewidzial,
   a nie druga sciezka zaliczenia. Nie deklaruje `display` -- wstawiamy je do
   .fib-input-container, ktory jest inline-block, a wymuszony display odkrywalby
   element takze tam, gdzie baza chowa go warunkowo (patrz notka o trybie wyprawy). */
.cloze-appeal-btn {
  font: inherit; font-size: .78rem; font-weight: 600;
  margin-top: .25rem; padding: .2rem .55rem;
  color: var(--ink); background: transparent;
  border: 1px dashed var(--line); border-radius: 999px;
  cursor: pointer; opacity: .8;
}
.cloze-appeal-btn:hover:not(:disabled) { opacity: 1; border-style: solid; }
.cloze-appeal-btn:disabled { cursor: progress; opacity: .55; }
.cloze-appeal-note { font-size: .78rem; font-weight: 700; margin-top: .25rem; }
.cloze-appeal-note.is-ok { color: var(--good); }
.cloze-appeal-note.is-no { color: var(--ink); opacity: .75; }
.correction-reveal { display: block; font-size: .8rem; color: var(--good); font-weight: 700; margin-top: .3rem; }

/* ---------- Open-ended: pole odpowiedzi zawsze w kadrze ----------
   Wiersz z textarea i przyciskami MA byc widoczny niezaleznie od tego, jak dlugie
   jest pytanie i czy pojawila sie juz ocena. Zgloszone 2026-09-04 na bloku przyrody:
   po ocenie trzeba bylo przewijac, zeby w ogole dosiegnac przyciskow -- a przy wyniku
   ponizej 2 to jedyna droga do poprawienia odpowiedzi, wiec brak dostepu do nich
   zamyka cwiczenie, a nie tylko utrudnia.

   .q-open wypelnia cala .task-body (patrz regula `.task-body > *`), wiec wystarczy
   rozdzielic role jego dzieci: tekst pytania, przykladowa odpowiedz i ocena moga sie
   kurczyc i przewijac WEWNATRZ siebie, a `.student-interaction-area` nie kurczy sie
   nigdy. Kolejnosc w DOM jest tu load-bearing -- wiersz odpowiedzi stoi ZARAZ za
   pytaniem, wiec to, co rosnie pod nim, nie ma jak go wypchnac z ekranu.
   `min-height` na pytaniu jest podloga, zeby przy bardzo ciasnym ekranie nie zostal
   z niego pasek zerowej wysokosci. */
.q-open { min-height: 0; }
.q-open > .q-text { flex: 0 1 auto; min-height: 3.2em; overflow-y: auto; }
.q-open > .student-interaction-area { flex: none; }
.q-open > .example-answer-reveal-area,
.q-open > .oe-feedback { flex: 0 1 auto; min-height: 0; overflow-y: auto; }

/* Wygaszenie ostatniej linii, gdy tresc pytania naprawde sie przewija -- ten sam
   zabieg co przy `.qhead.is-clipped`, bo bez niego tekst urywa sie w polowie znaku
   i wyglada na blad, a nie na "jest tego wiecej". Klase nadaje LP.fitTaskBody po
   pomiarze: staly gradient wygaszalby ostatnia linie takze wtedy, gdy wszystko sie
   miesci. */
.q-open > .q-text.is-clipped {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.6rem), transparent);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 1.6rem), transparent);
}

/* ---------- Open-ended controls ---------- */
.open-ended-controls { display: flex; align-items: center; gap: .5rem; margin-top: .7rem; flex-wrap: wrap; }
.oe-btn {
  width: 46px; height: 46px;
  border-radius: 50%;
  border: 2px solid var(--line);
  background: var(--surface-2);
  display: inline-flex; align-items: center; justify-content: center;
  transition: border-color .12s, background .12s;
}
.oe-btn:hover { border-color: var(--accent); }
.oe-btn svg { width: 22px; height: 22px; fill: currentColor; }
.oe-btn.oe-record { color: var(--bad); }
/* Same ph-fill ph-play glyph as .pa-play/.tam-play/the song button (Darek:
   the hand-drawn arrow SVG here didn't match) -- font icon, not an svg, so
   sized via font-size instead of the svg rule above. */
.oe-btn.oe-submit { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-size: 1.2rem; }
.oe-btn:disabled { opacity: .4; cursor: default; }
.recording-status { display: none; align-items: center; gap: 6px; font-size: .85rem; color: var(--muted); }
.recording-indicator {
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--bad);
  animation: recPulse 1s ease-in-out infinite;
}
@keyframes recPulse { 50% { opacity: .3; } }
.show-answer-btn {
  border: none; background: none;
  color: var(--accent);
  font-size: .9rem;
  text-decoration: underline; text-underline-offset: 3px;
  padding: .5rem .2rem;
  margin-top: .5rem;
}
.sample-answer { display: none; margin-top: .6rem; padding: .7rem .9rem; background: var(--surface-2); border-radius: var(--radius-sm); border: 1px dashed var(--line); }
.sample-answer.visible { display: block; }
.sample-answer-controls { margin-top: .4rem; }
/* OCENA MA KOLOR MOTYWU, NIE ZLOTY (Darek, 2026-09-21: "the color of the feedback
   background should be made more consistent with the theme of the lesson player").
   `--gold-soft` to barwa OSTRZEZENIA -- uzywaja jej `.tf-correction-box`, podswietlenie
   czytanego slowa i wybrany kafelek dopasowania. Ocena pojawia sie natomiast zawsze,
   takze przy odpowiedzi bezblednej, wiec bursztynowa plama na zielonej karcie czytala
   sie jak alarm i nie nalezala do zadnego motywu poza Szlakiem.
   Barwa liczy sie z `--accent`, wiec chodzi za motywem sama: w Zmierzchu i Kontrascie
   wychodzi ciemna (mieszamy z `--surface`, ktore jest tam ciemne), a nie jasna plama.
   `background: var(--surface-2)` stoi PRZED `color-mix` jako wartosc awaryjna dla
   przegladarki bez `color-mix` -- bez niej tlo zostaloby przezroczyste, czyli panel
   zniknalby zupelnie. Lewa krawedz w `--accent` niesie to, co wczesniej niosl kolor:
   "to jest osobny blok, przeczytaj go". */
.oe-feedback {
  display: none; margin-top: .8rem; padding: .7rem .9rem;
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  background: color-mix(in srgb, var(--accent) 9%, var(--surface));
  border-left: 4px solid var(--accent);
  color: var(--ink);
}
.oe-feedback .feedback-score { margin: 0 0 .3rem; font-weight: 700; }
.oe-feedback .feedback-text { margin: 0; font-size: .92rem; }
.record-btn-pulsing-green { animation: pulseGreen 1.2s infinite; }
@keyframes pulseGreen { 50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--good) 25%, transparent); } }

/* ---------- Explanation / correction boxes ---------- */
.tf-correction-box {
  margin-top: .9rem;
  padding: .8rem 1rem;
  background: var(--gold-soft);
  border-left: 4px solid var(--gold);
  border-radius: 8px;
  font-size: .95rem;
  line-height: 1.5;
  text-align: left;
  animation: chunkIn .3s ease;
}
.tf-correction-box strong.box-label {
  font-size: .78rem; text-transform: uppercase; letter-spacing: .5px;
  opacity: .8; display: block; margin-bottom: 6px;
}
/* A real button, not a dangling inline link -- and theme-aware (was a
   hardcoded #a855f7 purple that ignored the active theme entirely). */
#tf-ai-helper {
  display: flex;
  align-items: center;
  gap: .45rem;
  width: 100%;
  margin: .6rem 0 0;
  padding: .6rem .9rem;
  background: var(--surface-2);
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--accent);
  font: inherit;
  font-size: .85rem;
  font-weight: 600;
  cursor: pointer;
}
#tf-ai-helper:hover { border-color: var(--accent); }
#tf-ai-helper svg { flex: none; width: 18px; height: 18px; }

/* ---------- Lupa: pasek zaznaczenia (#selection-toolbar) ----------
   Wyglad byl do 2026-08-21 w calosci w stylach inline w
   handleTextSelection() (lesson-player-v3-features.js): #1f2937 tla,
   #c084fc i #3b82f6 na przyciskach. Cztery kolory spoza palety, wiec
   pasek wygladal identycznie we wszystkich czterech motywach i w
   Pergaminie/Kontrascie odcinal sie od reszty odtwarzacza.
   Teraz jest wyspa z tych samych tokenow co karty cwiczen. JS buduje
   sam szablon i NIE ustawia juz zadnego stylu poza pozycja. */
#selection-toolbar {
  position: fixed;
  z-index: 10000;
  display: none;
  flex-direction: column;
  gap: .25rem;
  max-width: min(340px, calc(100vw - 24px));
  padding: .5rem;
  background: var(--island-fill);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 16px;
  box-shadow: var(--island-shadow);
  font-size: .95rem;
  /* transform-origin sledzi dzióbek, zeby animacja wychodzila
     ze wskazanego slowa, a nie ze srodka paska */
  transform-origin: var(--st-origin, 50% 100%);
}
#selection-toolbar.is-open { display: flex; animation: st-in .16s cubic-bezier(.34, 1.4, .64, 1) both; }
@keyframes st-in {
  from { opacity: 0; transform: scale(.94) translateY(4px); }
  to   { opacity: 1; transform: none; }
}
/* Dziobek celujacy w zaznaczone slowo. Kwadrat obrocony o 45 stopni z
   obrysem na dwoch bokach -- pseudoelement, wiec nie wchodzi w flexa. */
#selection-toolbar::after {
  content: "";
  position: absolute;
  left: var(--st-caret, 50%);
  width: 12px; height: 12px;
  margin-left: -6px;
  background: var(--surface);
  border: 1px solid var(--line);
  transform: rotate(45deg);
}
#selection-toolbar[data-side="top"]::after    { bottom: -7px; border-top: 0; border-left: 0; }
#selection-toolbar[data-side="bottom"]::after { top: -7px; border-bottom: 0; border-right: 0; }
/* Android: pasek zadokowany przy krawedzi, z dala od natywnego paska zaznaczenia */
#selection-toolbar[data-side="dock"]::after { display: none; }

/* Zaznaczony fragment -- bez niego przy dluzszym zdaniu nie wiadomo,
   co poleci do schowka albo stanie sie tematem lekcji. */
.st-sel {
  font-size: .76rem;
  line-height: 1.35;
  color: var(--muted);
  padding: .1rem .45rem .25rem;
  margin-bottom: .15rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  border-bottom: 1px dashed var(--line);
}
.st-row { display: flex; align-items: center; gap: .25rem; }
.st-btn {
  appearance: none;
  border: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: .88rem;
  font-weight: 700;
  letter-spacing: -.01em;
  border-radius: 11px;
  padding: .5rem .6rem;
  min-height: 40px;
  display: inline-flex;
  align-items: center;
  gap: .38rem;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--speed) ease, color var(--speed) ease;
}
.st-btn svg { width: 17px; height: 17px; flex: none; }
.st-btn i   { font-size: 17px; line-height: 1; }
.st-btn:hover { background: var(--surface-2); }
.st-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.st-btn--primary {
  background: var(--accent);
  color: var(--accent-ink);
  border-radius: 999px;
  padding-inline: .85rem;
}
.st-btn--primary:hover { background: color-mix(in srgb, var(--accent) 100%, black 10%); color: var(--accent-ink); }
.st-btn--icon { padding-inline: .5rem; }
.st-btn[disabled] { opacity: .55; cursor: default; }

.st-label {
  display: block;
  font-size: .66rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--accent);
}
/* Ladowanie: szkielet zamiast napisu wcisnietego w przycisk -- pasek
   nie zmienia wtedy szerokosci i nie ucieka spod kursora. */
.st-loading { padding: .35rem .5rem .5rem; }
.st-loading .st-label { margin-bottom: .5rem; }
.st-skel {
  height: .62rem;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--surface-2) 0%, color-mix(in srgb, var(--accent) 22%, var(--surface-2)) 50%, var(--surface-2) 100%);
  background-size: 220% 100%;
  animation: st-skel 1.1s linear infinite;
}
.st-skel + .st-skel { margin-top: .4rem; width: 62%; }
@keyframes st-skel { from { background-position: 120% 0; } to { background-position: -120% 0; } }

.st-result { padding: .15rem .5rem .35rem; }
.st-result p {
  margin: .2rem 0 0;
  font-size: 1rem;
  line-height: 1.4;
  font-weight: 700;
  white-space: normal;
}
.st-foot {
  display: flex;
  align-items: center;
  gap: .25rem;
  border-top: 1px solid var(--line);
  padding-top: .3rem;
  margin-top: .1rem;
}
.st-foot .st-spacer { flex: 1; }
.st-note { padding: .35rem .5rem; font-size: .78rem; color: var(--muted); white-space: normal; }
.st-note b { color: var(--ink); }
.st-err { color: var(--bad); }

/* Kontrast: obrys niesie to, co w pozostalych motywach niesie cien. */
html[data-theme="kontrast"] #selection-toolbar,
html[data-theme="kontrast"] #selection-toolbar::after { border-width: 2px; }

@media (prefers-reduced-motion: reduce) {
  #selection-toolbar.is-open { animation: none; }
  .st-skel { animation: none; }
}

/* ---------- Legacy tooltips (tap-to-translate, "show terms" popover) ---
   Brought onto the island system 2026-08-21 -- was a plain inverted
   ink/bg box that never picked up a theme's own tokens, so it read as
   a leftover from before the v3 redesign next to the rest of the
   player. Same background/border/shadow trio as .lupa/#selection-toolbar
   (--island-fill, --line, --island-shadow), so it now reads as the same
   component family instead of an unrelated dark popup. */
#translation-tooltip, #info-tooltip {
  display: none;
  position: absolute;
  z-index: 10001;
  max-width: 320px;
  max-height: 60vh;
  overflow-y: auto;
  background: var(--island-fill);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: .6rem .8rem;
  font-size: .9rem;
  line-height: 1.4;
  box-shadow: var(--island-shadow);
}
#info-tooltip { position: fixed; }
html[data-theme="kontrast"] #translation-tooltip,
html[data-theme="kontrast"] #info-tooltip { border-width: 2px; }

.tt-text { color: var(--ink); }
.tt-text--loading { color: var(--muted); }
.tt-text--err { color: var(--bad); font-weight: 700; }
/* Reuses .st-btn from the selection toolbar (Lupa) rather than its own
   rules -- same play/speak affordance, same 40px touch target, same
   theme tokens. Sits in normal text flow here instead of a flex row,
   so it needs its own alignment/spacing on top of .st-btn's own. */
#translation-tooltip .st-btn { vertical-align: middle; margin-left: .5rem; }

/* ---------- Legacy modal frame (feedback / ai-edit / fcsets) ---------- */
.feedback-modal {
  position: fixed; inset: 0;
  z-index: 9000;
  background: rgba(10, 22, 25, .6);
  display: none;
  align-items: center; justify-content: center;
  padding: 1rem;
}
.feedback-modal-content {
  position: relative;
  width: min(560px, 100%);
  max-height: 88vh;
  overflow-y: auto;
  background: var(--surface);
  color: var(--ink);
  border: 2px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 1.3rem 1.4rem;
}
.feedback-close-btn {
  position: absolute; top: .7rem; right: 1rem;
  font-size: 1.6rem; cursor: pointer; color: var(--muted);
}
.feedback-modal textarea {
  width: 100%; min-height: 110px;
  font: inherit; color: inherit;
  background: var(--surface-2);
  border: 2px solid var(--line);
  border-radius: var(--radius-sm);
  padding: .6rem .8rem;
  margin: .6rem 0 .8rem;
  resize: vertical;
}
/* .btn-quiet's base rule (further down) is a fixed 44x44 icon disc --
   padding:0, no room for a text label. #ai-edit-cancel-btn ("Anuluj") is
   the only .btn-quiet in any of this legacy modal trio that carries text
   instead of a single <i> icon, so it's the only one that needs this.
   Same values .lp-dialog-actions already uses for the identical reason. */
#ai-edit-actions .btn-quiet { width: auto; height: auto; padding: .6rem 1.1rem; font-size: .95rem; }

/* ---------- Themed alert/confirm/prompt (replaces native dialogs) ---------- */
.lp-dialog { z-index: 10200; }
.lp-dialog-content { max-width: 420px; }
.lp-dialog-msg { margin: 0 0 1rem; white-space: pre-line; line-height: 1.5; }
.lp-dialog-input {
  display: block; width: 100%; font: inherit; color: inherit;
  background: var(--surface-2); border: 2px solid var(--line);
  border-radius: var(--radius-sm); padding: .6rem .8rem; margin-bottom: 1rem;
}
.lp-dialog-actions { display: flex; justify-content: flex-end; gap: .6rem; }
.lp-dialog-actions .btn-quiet { width: auto; height: auto; padding: .6rem 1.1rem; font-size: .95rem; }
.lp-dialog-actions .btn-primary { flex: none; min-height: 44px; padding: .6rem 1.3rem; font-size: .95rem; }

/* ---------- Editor modal (teacher iframe) ---------- */
#editor-modal {
  position: fixed; inset: 0;
  z-index: 9500;
  background: rgba(10, 22, 25, .7);
  display: none;
}
#editor-modal.show { display: block; }
.editor-modal-content { position: absolute; inset: 2.5vh 2.5vw; background: var(--surface); border-radius: var(--radius); overflow: hidden; }
#editor-iframe { width: 100%; height: 100%; border: none; }

/* ---------- Flashcard set cards (fcsets modal) ---------- */
.flashcard-set-card {
  display: flex; align-items: center; gap: .7rem;
  padding: .6rem .7rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  margin-bottom: .5rem;
  background: var(--surface-2);
}
.flashcard-set-card .avatar, .flashcard-set-card .initials-placeholder {
  width: 40px; height: 40px; border-radius: 50%;
  object-fit: cover; flex: none;
  display: flex; align-items: center; justify-content: center;
  color: #fff; font-weight: 700; font-size: .85rem;
}
.flashcard-set-details { flex: 1; min-width: 0; }
.flashcard-set-details .creator-name { font-weight: 700; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.flashcard-set-details .card-count { font-size: .78rem; color: var(--muted); }
.flashcard-set-actions { display: flex; gap: .3rem; }
.flashcard-set-actions .btn-icon {
  width: 38px; height: 38px;
  border-radius: 50%; border: 2px solid var(--line);
  background: var(--surface);
  display: inline-flex; align-items: center; justify-content: center;
  color: inherit;
}

/* ---------- Clipboard: its own full-height tab in the assistant drawer
   (see #clipTabView / .helper-tab-view) -- no oracle background of its
   own anymore, the mascot only ever lives in the chat tab. ---------- */
.clip-embedded-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: .5rem;
  padding: 0 1rem calc(.7rem + env(safe-area-inset-bottom));
}

/* ---------- Vocab clipboard drawer (legacy floating widget, unused —
   clipboard now renders inline via .clip-embedded above; kept only
   because .vocab-item/.vocab-list etc. below it are still shared) ---------- */
.vocab-drawer {
  position: fixed;
  right: 12px; bottom: calc(84px + env(safe-area-inset-bottom));
  z-index: 85;
  display: none;
  flex-direction: column;
  align-items: flex-end;
}
.vocab-drawer .vocab-handle {
  display: none; /* handle hidden: drawer opens from helper panel */
}
.vocab-drawer .vocab-content {
  display: none;
  width: min(340px, calc(100vw - 24px));
  max-height: min(60vh, 480px);
  background: var(--surface);
  border: 2px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: .8rem;
  flex-direction: column;
  gap: .5rem;
}
.vocab-drawer.open .vocab-content { display: flex; }
.vocab-header { display: flex; align-items: center; justify-content: space-between; }
.vocab-header h3 { margin: 0; font-size: 1rem; }
.vocab-close { border: none; background: var(--surface-2); border-radius: 50%; width: 32px; height: 32px; font-size: 1.1rem; }
.vocab-clear-btn { border: none; background: none; color: var(--bad); font-size: .8rem; display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
.vocab-list { overflow-y: auto; min-height: 40px; font-size: .9rem; color: var(--muted); display: flex; flex-direction: column; gap: .4rem; }
.vocab-item { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .5rem .7rem; }
.vocab-term-row { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.vocab-term { font-weight: 700; font-size: .92rem; color: var(--ink); }
.vocab-def { font-size: .82rem; color: var(--muted); margin-top: .15rem; }
.vocab-actions { display: flex; gap: .2rem; }
.vocab-action-btn { border: none; background: none; color: var(--muted); cursor: pointer; padding: 4px; border-radius: 6px; }
.vocab-action-btn:hover { background: var(--line); }
.vocab-action-btn.delete:hover { color: var(--bad); }
.vocab-edit-input {
  width: 100%; font: inherit; font-size: .88rem;
  border: 1px solid var(--line); border-radius: 8px;
  padding: .35rem .5rem; background: var(--surface); color: inherit;
}
.vocab-edit-actions { display: flex; justify-content: flex-end; gap: .3rem; margin-top: .4rem; }
/* ---------- Schowek: przyciski stopki (glina) ----------
   PIONOWO, NIE OBOK SIEBIE. Dwa przyciski w rzedzie mialy w tej szufladzie po
   ~140 px, wiec "1 Zestaw (27)" lamalo sie na dwie linie, a stan zajetosci
   ("Tlumaczenie AI (12s)...") na trzy. Kolumna daje kazdemu pelna szerokosc,
   wiec zawijanie znika z definicji, a nie przez dobranie krotszego napisu.
   .clay-label i tak ma `nowrap` + wielokropek -- napis stanu przychodzi z kodu
   i nie ma gwarancji dlugosci. */
.vocab-actions-col { display: flex; flex-direction: column; gap: .45rem; width: 100%; }

.vocab-export-btn, .vocab-ai-btn {
  display: flex; align-items: center; gap: .55rem;
  width: 100%; min-width: 0;
  border: none;
  border-radius: 16px;
  background: var(--accent); color: var(--accent-ink);
  font-weight: 800; font-size: .88rem;
  padding: .45rem .85rem .45rem .45rem;
  cursor: pointer;
  box-shadow:
    0 4px 0 rgba(0, 0, 0, .16),
    0 8px 18px rgba(0, 0, 0, .13),
    inset 0 1px 0 rgba(255, 255, 255, .32);
  transition: transform .08s ease, box-shadow .08s ease;
}
.vocab-export-btn:active, .vocab-ai-btn:active {
  transform: translateY(2px);
  box-shadow:
    0 2px 0 rgba(0, 0, 0, .16),
    0 4px 10px rgba(0, 0, 0, .12),
    inset 0 1px 0 rgba(255, 255, 255, .28);
}
.vocab-export-btn:disabled, .vocab-ai-btn:disabled {
  opacity: .78; cursor: progress; transform: none;
}

/* Wariant drugoplanowy -- "jeden dlugi zestaw" jest alternatywa, nie rada. */
.vocab-export-btn.is-secondary {
  background: var(--surface-2); color: var(--ink);
  box-shadow:
    0 3px 0 var(--line),
    0 6px 14px rgba(0, 0, 0, .08),
    inset 0 1px 0 rgba(255, 255, 255, .5);
}
.vocab-export-btn.is-secondary:active {
  box-shadow: 0 1px 0 var(--line), inset 0 1px 0 rgba(255, 255, 255, .4);
}

.clay-label {
  flex: 1; min-width: 0;
  text-align: left;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* Kafelek ikony: wytloczenie z gliny. Swiatlo od gory, cien od dolu, wiec znak
   siedzi w zaglebieniu zamiast lezec na plasko. */
.clay-ico {
  flex: none;
  width: 30px; height: 30px;
  border-radius: 11px;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 1.05rem; line-height: 1;
  /* Wartosc zapasowa PRZED color-mix: ~1% przegladarek go nie zna i wtedy po
     prostu pomija deklaracje (patrz notka o podlodze platformy w CLAUDE.md). */
  background: rgba(255, 255, 255, .24);
  background: color-mix(in srgb, var(--accent-ink) 22%, transparent);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .5),
    inset 0 -2px 3px rgba(0, 0, 0, .18),
    0 1px 2px rgba(0, 0, 0, .12);
}
.vocab-export-btn.is-secondary .clay-ico {
  color: var(--accent);
  background: var(--surface);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .7),
    inset 0 -2px 3px rgba(0, 0, 0, .10),
    0 1px 2px rgba(0, 0, 0, .08);
}
.clay-ico.is-spinning i { animation: spin 1s linear infinite; }

/* Kontrast ma `--shadow: none` swiadomie -- glina jest tam plaska i obrysowana,
   bo miekki cien na czystej czerni nie niesie zadnej informacji. */
html[data-theme="kontrast"] .vocab-export-btn,
html[data-theme="kontrast"] .vocab-ai-btn { box-shadow: none; border: 2px solid var(--ink); }
html[data-theme="kontrast"] .clay-ico { box-shadow: none; }

.vocab-clear-btn { white-space: nowrap; }

/* ---------- Song generator (drawer "song" view) ---------- */
/* 2026-08-09: folded-by-default toggle for the create-a-new-song form once
   the lesson already has a song -- see setSongFormFolded, features.js. */
.song-form-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .4rem;
  width: 100%;
  border: 2px dashed var(--line);
  border-radius: var(--radius-sm);
  background: none;
  color: var(--muted);
  font-weight: 700;
  font-size: .88rem;
  padding: .6rem .9rem;
  cursor: pointer;
}
.song-form-toggle:hover,
.song-form-toggle:focus-visible { border-color: var(--accent); color: var(--accent); }
#songFormGroup select.vocab-edit-input { appearance: auto; cursor: pointer; }
.song-cost-note { font-size: .78rem; color: var(--muted); margin: .6rem 0 .5rem; }
.song-lyrics-text { white-space: pre-wrap; font-size: .85rem; line-height: 1.5; max-height: 180px; overflow-y: auto; }
.song-status-text { font-size: .85rem; color: var(--muted); text-align: center; margin: .8rem 0; }
.song-tracks-list { display: flex; flex-direction: column; gap: .5rem; margin: .6rem 0; }
.song-track-item .vocab-term-row { margin-bottom: .2rem; }
.song-choose-btn { border: 1px solid var(--line); border-radius: 99px; padding: .3rem .7rem; font-size: .82rem; font-weight: 700; }

/* ---------- Finish screen ---------- */
.finish-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem; margin: .8rem 0; }
.finish-actions .nav-btn {
  border-radius: 99px;
  border: 2px solid transparent;
  background: var(--accent); color: var(--accent-ink);
  font-weight: 700; font-size: .9rem;
  padding: .55rem 1.1rem;
  text-decoration: none;
  display: inline-flex; align-items: center; gap: 5px;
  cursor: pointer;
}
.finish-actions .nav-btn.secondary { background: var(--surface-2); color: var(--ink); border-color: var(--line); }
.finish-actions .nav-btn.offline-btn { background: #ff9f43; color: #fff; }
.final-score-container { margin: .6rem auto; }
.final-score-container h3 { margin: 0 0 .2rem; font-size: 1rem; }
.score-display { font-size: 1.6rem; font-weight: 100; color: var(--accent); margin: .2rem 0; }
.score-display span { font-size: 1.1rem; }
#inline-apps-grid-wrapper { width: 100%; display: flex; justify-content: center; min-height: 0; overflow: hidden; }

/* Minimal finish shell: the oracle up front, activity suggestions tucked
   away behind one tap instead of a full row of buttons landing all at
   once -- "what's next" is a decision the student opts into, not a wall
   they're handed the moment they finish. */
.finish-minimal {
  text-align: center;
  padding: 1.5rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  /* Was 560px, which fits two game tiles per row and pushed the rest into a
     tall column (Darek: "widen the end screen modal to accommodate more
     games in a row"). The apps grid inside is
     repeat(auto-fit, minmax(180px, 1fr)), so every ~200px of extra width is
     one more tile per row. */
  max-width: min(1080px, 94vw);
  background: var(--surface);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  box-sizing: border-box;
}
/* The way out lives OUTSIDE the results card and stays put while the card
   scrolls -- Darek: "the 'zakończ i wróć do pulpitu' button should be taken
   outside of the modal, so it doesn't require scrolling through it to
   exit." It used to be the last child of .finish-minimal, i.e. below the
   score, the reveal, the actions row and the whole inline games grid.
   sticky rather than fixed: .finish-wrap is the scroller, so the bar rides
   its top edge without needing to know anything about the viewport or the
   immersive dock. */
.finish-exit-bar {
  position: sticky;
  top: 0;
  z-index: 3;
  align-self: flex-start;
  margin: 0 0 .8rem;
  padding: .5rem 1.1rem;
  border-radius: 99px;
  background: var(--surface);
  border: 2px solid var(--line);
  box-shadow: var(--island-shadow-sm);
  font-weight: 700;
  text-decoration: none;
}
.finish-exit-bar:hover { border-color: var(--accent); color: var(--ink); }
/* Guest-hijack finish card (LP.finishLesson, exercises.js) -- moved out
   of an inline `style=""` on 2026-08-23 specifically so the immersive
   override below can reach it without `!important`. Same look as
   .finish-minimal's own card, kept as a separate class because this one
   never carries the oracle/reveal-activities markup. */
.finish-guest-card {
  text-align: center;
  padding: 1.5rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  max-width: 850px;
  background: var(--surface);
  border-radius: 15px;
  box-sizing: border-box;
}
.finish-oracle {
  border: none;
  background: none;
  padding: 0;
  width: 128px;
  margin-bottom: .6rem;
  position: relative;
  cursor: pointer;
  transition: transform var(--speed);
}
.finish-oracle:hover { transform: translateY(-3px); }
.finish-oracle .mascot-figure { width: 100%; }
.finish-oracle-badge {
  position: absolute;
  top: -.3rem; right: -.2rem;
  font-size: 1.4rem;
  animation: finish-sparkle 1.8s ease-in-out infinite;
}
@keyframes finish-sparkle {
  0%, 100% { transform: scale(1) rotate(0deg); opacity: .85; }
  50% { transform: scale(1.15) rotate(8deg); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) { .finish-oracle-badge { animation: none; } }
.finish-title { margin: 0 0 .2rem; font-size: 1.5rem; }
.finish-sub { margin: 0 0 .3rem; font-size: .9rem; color: var(--muted); }
.finish-reveal-btn {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  margin-top: .9rem;
  padding: .55rem 1.2rem;
  border-radius: 99px;
  border: 2px solid var(--line);
  background: var(--surface-2);
  font-weight: 700;
  font-size: .92rem;
}
.finish-reveal-btn:hover { border-color: var(--accent); }
.finish-activities { width: 100%; margin-top: .6rem; }
.finish-exit-link {
  display: inline-block;
  margin-top: 1rem;
  font-size: .88rem;
  color: var(--muted);
  text-decoration: underline;
}

/* ---------- Listening challenge active ---------- */
.player-shell.listening-active .chunk-viewport,
.player-shell.listening-active .chunk-nav { display: none; }
.listening-note { padding: 1.2rem; text-align: center; color: var(--muted); font-size: .95rem; }

/* ---------- Grammar-mode: examples styling parity ---------- */
.grammar-mode .reading-chunk i { color: var(--muted); }

/* ---------- Selection toolbar (ported behavior keeps inline styles) ---------- */
#selection-toolbar button { font: inherit; }

/* ---------- No-reading lessons: task panel spans the stage ---------- */
body.no-reading .stage { grid-template-columns: 1fr; }
/* Lekcja bez czytanki ma w scenie dwa dzieci -- pusty .reading-panel i .task-panel --
   wiec siatka `auto minmax(0,1fr)` rozdzielala wysokosc miedzy nie i karta zadania
   konczyla sie WYSOKO nad paskiem akcji. Zmierzone przed zmiana: scena 772 px, karta
   623 px, czyli 138 px martwej przestrzeni pod karta; po zmianie karta ma 740 px
   i zostaje sam padding sceny. Jeden wiersz na pelna wysokosc, bo przy braku czytanki
   panel zadania jest jedynym, ktory cokolwiek pokazuje. Pustego panelu NIE ukrywamy --
   zmierzone, ze nie trzeba (wynik identyczny), a `display:none` na czyms, co bazowy
   arkusz pokazuje warunkowo, to dokladnie ten blad, przed ktorym ostrzega notka
   o trybie wyprawy. */
body.no-reading .stage { grid-template-rows: minmax(0, 1fr); }

/* Wejscie w tryb wyprawy dla lekcji RECZNEJ (patrz #btnImmersiveManual w markupie).
   Znika, gdy tryb juz jest wlaczony -- przycisk "wejdz" obok wlaczonego trybu zaprasza
   do klikniecia, ktore nic nie zmieni, a wyjscie ma swoj wlasny przycisk na scenie.
   Zmierzone: w trybie display:none, po wyjsciu wraca na prawa strone paska akcji. */
.player-shell[data-immersive="1"] #btnImmersiveManual { display: none; }
body.no-reading .task-panel { max-width: 900px; width: 100%; margin: 0 auto; }

/* ============ TRYB WYPRAWY / IMMERSIVE PRESENTATION MODE ============

   No cards, no tiles, no dark overlay: the lesson's own artwork is the
   stage, the text sits on nothing but softened background, and every
   control is a small thing floating above the picture.

   THE PALETTE IS MEASURED, NOT CHOSEN. All 60 journey backgrounds were
   sampled across the central band where text actually lands (blurred
   first, since that is how it is seen):

     central luminance   min 0.623   median 0.870   max 0.908
     -> every single one is LIGHT

   Which means the near-white ink the first two versions used scored a
   median 1.03:1 against them -- invisible. The dark slab those versions
   painted was not a legibility aid, it was compensation for the ink being
   the wrong colour. Dark ink needs no slab on 45 of the 60. For the other
   15 (darker corners in historia-4, niemiecki-2, hiszpanski-2) a sweep of
   white-wash strengths gives the smallest value that clears 4.5:1 on the
   WORST background: white at 0.45 with #0d1b1e ink -> worst case 5.11:1.

   So the wash is WHITE, and it is the minimum that guarantees the floor.
   White over pastel art reads as mist. Dark over pastel art reads as a
   rectangle sitting on a photo, which is exactly what it looked like.

   AN ORTHOGONAL FLAG, never a fifth data-mode value: it must compose with
   reading, split AND task.

   TWO RULES FOR ANYTHING ADDED HERE, both learned the hard way:

   1. NEVER declare `display` on an element the base stylesheet hides
      conditionally. Restyle, do not re-show. Forcing display:grid on
      .task-panel put the step-goal compass, its instruction text AND a
      second audio transport (#taskAudioMini lives in that panel) on top of
      the lesson; forcing it on .assistant-handle put two assistant entry
      points on a phone.

   2. NEVER re-parent lesson content. SELECTABLE_ZONES is a closed list;
      text rendered into a container outside it loses translation with no
      error and nothing visibly wrong. Everything below is presentation
      over the containers the player already built. */

/* ---------- 1. Ground and ink ----------
   The stage is a photograph, so its palette cannot come from the theme's
   paper tokens. --imm-* is the stage's own pair and is near-constant
   across themes on purpose: a pastel photo is a pastel photo in Pergamin
   as much as in Zmierzch. Kontrast is the exception, because the whole
   reason that theme exists is that its user needs more separation than a
   tasteful haze provides. */
:root {
  --imm-ink: #0d1b1e;
  --imm-haze: 255, 255, 255;
  --imm-haze-a: .46;
  --imm-blur: 26px;
}
html[data-theme="pergamin"] { --imm-haze: 253, 250, 242; }
html[data-theme="kontrast"] {
  --imm-ink: #000;
  --imm-haze-a: .88;
  --imm-blur: 10px;
}

/* ---------- 2. The stage ----------
   The artwork stays exactly as it is: sharp, full-bleed, undimmed. The
   contour texture goes, because it is the reading screen's signature and
   reads as UI drawn over a picture. */
body.immersive-on::before { opacity: 1; filter: none; }
body.immersive-on { background-image: none; }

/* z-index here is LOAD-BEARING and was the cause of a fully unreadable
   screen. The mask-image comment further down explains why the stacking
   context matters; the short version is 10 sits above the wash (2) and
   below every floating control (41+). Universal -- not scoped to reading --
   because a video lesson keeps its full-bleed player at z-index 1 through
   every step kind (immersiveSyncStage sets data-imm-media once per step,
   not per mode), so both panels need this regardless of what happens to
   their card below. */
.player-shell[data-immersive="1"] .stage { position: relative; z-index: 10; }
/* ...i musi dac sie przewinac. W trybie zadan tryb wyprawy CELOWO zostawia normalny
   uklad (patrz komentarz nizej: "Task and split modes deliberately do NOT get this"),
   ale scena zostawala przy tym `overflow: hidden` -- wiec gdy odpowiedzi nie miescily
   sie w kadrze, ostatnia byla po prostu UCINANA, bez mozliwosci dojechania do niej.
   Zmierzone na kroku "A jesli Wam nie wyszlo?" przy oknie 1000x680: scena 579 px przy
   tresci 606 px, czyli 27 px nie do zobaczenia; --fit-scale tego nie ratowal, bo
   .task-body nie raportuje przelewu (tresc wychodzi poza nia, zamiast ja rozpychac).
   Pasek przewijania i tak jest tu wygaszony regula ponizej, wiec nic nie przybywa na
   ekranie -- przybywa tylko dostep do wlasnej tresci. */
.player-shell[data-immersive="1"] .stage { overflow-y: auto; }

/* Full-bleed, scroll-driven layout: READING ONLY. Task and split modes
   deliberately do NOT get this (Darek: "keep the normal mode division into
   top and bottom sections, this is safer for mobile view responsiveness
   too") -- an exercise is interactive and needs the layout the rest of the
   platform already tests, not a picture-book treatment. This is why
   section 8 (exercises) further down is gone rather than patched again:
   restricting the layout override here is what actually delivers "normal
   division", and a parallel set of glass-pill exercise rules would just be
   a second thing to keep in sync with the real one. */
/* Panel czytanki jest ELEMENTEM SIATKI, a `align-items: safe center` (nizej)
   sprawia, ze taki element ma rozmiar TRESCI, nie wiersza -- wiec kafelek
   wyzszy od ekranu nie przewijal sie w swoim pojemniku (`#chunkViewport` ma
   `overflow-y: auto`), tylko rozpychal panel i przewijal CALA scene. Tak
   wyglada "chunking fails completely": zamiast kafelka na ekran czytelnik
   dostaje sciane tekstu. Zmierzone 2026-09-11 na lekcji 6235 (okno 1889x980):
   wiersz sceny 342 px, panel **1190 px**, kafelek konczyl sie 624 px pod dolna
   krawedzia okna. `max-height: 100%` nie rusza kafelkow krotkich -- te dalej sa
   wysrodkowane -- a wysokie zamyka w ekranie i oddaje przewijanie temu, kto ma
   je obsluzyc, razem z wygaszeniem ostatniej linii. */
.player-shell[data-immersive="1"][data-mode="reading"] .reading-panel { max-height: 100%; }
.player-shell[data-immersive="1"][data-mode="reading"] .stage {
  grid-template-columns: 1fr;
  max-width: none;
  /* `safe` is load-bearing. Plain `center` on a scroll container centres an
     over-tall item by pushing its TOP negative -- measured at -79px -- and
     nothing can scroll to negative coordinates, so the opening lines of a
     long exercise become permanently unreachable. `safe` falls back to
     start alignment exactly when the item overflows, which is the only case
     where centring loses content. */
  align-items: safe center;
  justify-items: center;
  /* Top clears the beat rail; bottom clears the floating controls. The
     stage is the scroll container (a panel measured 0px tall while its own
     card ran to 1105px, so panels cannot be trusted to bound anything, and
     padding does not clip), which is what keeps every lesson inside a box
     that sits entirely above the controls. */
  padding: clamp(3rem, 7vh, 4.2rem) clamp(3.4rem, 7vw, 6rem) clamp(7rem, 15vh, 9.5rem);
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: none;
  /* NO mask here, and that is the fix for the reported "no audio controls,
     no chunking". A mask applies to the element's WHOLE PAINTED SUBTREE,
     position:fixed descendants included -- and .chunk-nav and
     .passage-audio-row are descendants of .stage (verified in the DOM;
     #btnPrimary and #btnRevealPassage are not, they live in .action-bar).
     The mask ran opaque to 82% and to zero at 97%, so at the bottom of the
     screen it erased exactly the two controls the dock puts there: the
     transport sat at 85-91% of the viewport (mask alpha .80 down to .40)
     and the pager at 92-98% (.33 down to 0). They were rendered, hit-
     testable and reported at opacity 1 by getComputedStyle -- a mask is not
     opacity -- and simply not drawn. That is why they were invisible in the
     reading screenshot while the same controls were perfectly visible in
     the exercise one: the action bar is outside .stage.

     The fade itself is still wanted, so it moves to ::after below: an
     element with no descendants, inside the stage's own stacking context,
     under the dock. */
}
/* The lesson-complete screen replaces the stage's children wholesale
   (LP.finishLesson does `stage.innerHTML = ...finish-wrap...`) after the
   last question, so by then data-mode is whatever the last exercise step
   left it at -- normally "task" -- which no longer gets the rule above.
   .finish-wrap already carries its own inline `overflow-y:auto`, but that
   only engages if the box it sits IN has a bounded height for it to shrink
   inside of; the base (non-immersive) .stage rule is a content-sized grid
   row, so the card simply grew past the viewport and clipped instead of
   scrolling -- the reported "final screen doesn't scroll". Filling the
   stage's own (fixed, viewport-derived) box is what turns that inline
   overflow-y from dead weight into an actual scroller. The rest of this
   screen's immersive treatment is under "Lesson complete" near the bottom
   of this file. */
/* position:absolute over grid-row/column spanning ON PURPOSE -- spanning
   both of the base grid's rows and asking for height:100% measured
   453px against a 623px stage (verified in the harness): the "auto"
   reading-panel row has no other content to size it, so the browser's
   circular-dependency fallback (roughly its min-content, near 0) ate part
   of the height before the percentage was ever resolved, and 170px went
   missing. .stage already carries position:relative unconditionally (see
   above), so absolute positioning measures against the stage's actual box
   with no track-sizing algorithm in between -- verified full height. */
.player-shell[data-immersive="1"] .stage > .finish-wrap {
  position: absolute;
  inset: 0;
  min-height: 0;
  overflow-y: auto;
}
/* The bottom fade, as a layer instead of a mask. Fixed to the viewport, so
   it fades the passage where the SCREEN ends rather than where the scrolled
   content does -- which is also why it cannot simply be a mask moved onto
   #chunkViewport: that box scrolls with the text, so a mask on it fades the
   end of the passage, not the bottom of the window.

   IT HANGS OFF THE PANEL, NOT OFF .stage, and that distinction cost a round.
   `.reading-panel` carries `z-index: 20` (needed so the passage stays above
   a full-bleed video, which sits at z-index 1), which makes it a STACKING
   CONTEXT. The dock's z-index 45 is therefore scoped INSIDE the panel --
   from .stage's point of view the pager is part of one z-20 subtree, so a
   veil at z-40 on .stage paints over it. Verified, not reasoned: with
   pointer-events temporarily enabled on the veil, document.elementsFromPoint
   at the pager's centre returned the veil FIRST and #chunkDots second.
   Inside the panel the same 40 sits where it was meant to: above the text
   (z auto), below the dock (45) and below the artwork panel (44).

   READING MODE ONLY. Task and split fell out of this the same way they fell
   out of the .stage rule above -- once their panel gets its ordinary card
   back, fading toward the bottom just dims the top of an opaque box for no
   reason; there is no passage text under the dock there to protect.

   Tinted with the haze rather than a page colour: this mode has no paper,
   only the wash, so the honest "fade out" here is more of the same light. */
.player-shell[data-immersive="1"][data-mode="reading"] .reading-panel::after {
  content: '';
  position: fixed;
  left: 0; right: 0; bottom: 0;
  height: var(--imm-fade-h, 150px);
  z-index: 40;
  pointer-events: none;
  /* Reaches full strength EARLY. The first ramp (.92 at the very bottom,
     .72 at 45%) left the line sitting right above the transport at roughly
     39% white -- attenuated on paper, still plainly legible, and therefore
     still reading as text running under a control. The beat being read is
     centred in the band above the dock now (immScrollToBeat), so everything
     inside this gradient is text the reader is NOT on; it can dissolve
     properly. Opaque haze by 30% up, gone by the top. */
  background: linear-gradient(to top,
    rgba(var(--imm-haze), 1) 0%,
    rgba(var(--imm-haze), .98) 30%,
    rgba(var(--imm-haze), .78) 58%,
    rgba(var(--imm-haze), .34) 80%,
    rgba(var(--imm-haze), 0) 100%);
}
.player-shell[data-immersive="1"] .stage::-webkit-scrollbar { width: 0; height: 0; }

/* ---------- 3. Media ----------
   #mediaZone holds BOTH the passage-image carousel and the YouTube iframe
   (initVideoLesson writes .video-figure > .yt-holder into this same element),
   so the same rules reach either -- carousel arrows, LP.openZoom and the YT
   player keep working exactly as in normal mode.

   2026-08-23 (Darek): "video lessons don't use the timestamps in reading
   passage to display it as captions under a centered focused video player."
   This used to make #mediaZone full-bleed -- position:fixed, inset:0, the
   video AS the background, with the timestamped transcript floating as
   overlay text on top of it. That solved the previous complaint ("no
   text", full-bleed media painting over the passage) but traded it for a
   different one: a video is not a photo backdrop text can sit on top of,
   it is the thing being watched, and captions belong anchored under it,
   not scattered across it as centred overlay text.

   So a video lesson no longer gets full-bleed at all. #mediaZone stays a
   normal, bounded, IN-FLOW box -- same .video-figure/.yt-holder recipe
   base CSS already uses (rounded corners, 16:9, centred, capped width) --
   sitting above .reading-card in the same scrolling column the reading
   passage always occupies. #transcript-container (built by
   initVideoLesson, highlighted by updateTranscriptHighlight on the SAME
   real per-line timestamps a reading lesson can only estimate) renders
   directly below it, unmoved -- it was always there in the DOM order, the
   fixed positioning is what pulled the video out from beside it. */
/* Back to STICKY (was reverted to static last round -- see git history for
   the full back-and-forth if it comes up again). Darek's newest report
   pinned down the real shape of this: "once I start the video the lyrics
   container... push[es] the video up out of view partially... the video
   should sit stably in its container." Static seemed safe once captions
   were bounded to #chunkViewport's own small window, on the reasoning
   that video + chrome + a 340px box would rarely exceed the viewport --
   wrong on an ordinary browser window (toolbars/tabs eat real estate),
   so .stage still needs to scroll a little to bring the box fully on
   screen, and a merely-static video scrolls away with it. Sticky is what
   actually satisfies "sit stably".

   The trade being made: with a bounded box, the residual scroll .stage
   ever needs is small (tens of px, not the hundreds an unbounded 14-line
   transcript needed), so the flow/paint mismatch that made static
   necessary last round (a still-flowing sibling landing inside a frozen
   sticky element's rendered box) has a correspondingly small blast
   radius now, if it happens at all -- nothing like the multi-hundred-px
   collision the original bug reports were about. Static traded a small,
   rare residual risk for a much bigger, everyday one: the video visibly
   scrolling away on typical screens. Not a good trade. */
.player-shell[data-immersive="1"][data-imm-media="video"] #mediaZone:not(.in-overlay):not([hidden]) {
  display: block;
  position: sticky;
  top: var(--imm-video-top, 26px);
  width: 100%;
  max-width: min(900px, 88vw);
  margin: 0 auto clamp(1.2rem, 3vh, 2rem);
  z-index: 3;
}
/* The reveal-passage SHEET (video peeked mid-exercise via the corner panel)
   is a different scrolling context -- #readingBody relocates into
   .task-card there, not .stage's own column -- and is untouched by this
   round's fix. Keeps the original sticky behaviour rather than assuming it
   generalizes without checking. */
.player-shell[data-immersive="1"][data-imm-media="video"].imm-sheet #mediaZone:not(.in-overlay):not([hidden]) {
  position: sticky;
  top: var(--imm-video-top, 26px);
}
.player-shell[data-immersive="1"][data-imm-media="video"] #mediaZone .video-figure {
  border-radius: clamp(14px, 1.6vw, 24px);
  box-shadow: 0 20px 50px -20px rgba(0, 0, 0, .55);
}
/* Captions: the video is the focal point now, so the transcript is a quiet
   strip underneath it rather than a passage-sized block of text -- same
   dim/highlight recipe as before (opacity .32 -> 1 on .active/.is-active),
   just no longer doubling as overlay copy on a busy background. */
.player-shell[data-immersive="1"][data-imm-media="video"] #transcript-container {
  max-width: min(70ch, 90vw);
  margin: 0 auto;
}

/* ON AN EXERCISE STEP the video collapses into the same bottom-left corner
   panel an image lesson uses -- Darek: "once in exercises, the user should
   be able to come back to view the video; we can reuse the image preview
   container in the bottom left corner that we have for lesson images."
   Without this the video simply vanished for the rest of the lesson: the
   in-flow player above lives in .reading-panel, which task/split hide.

   The iframe goes pointer-events:none and the panel takes the click
   instead (wired in initImmersiveMode): a 140px YouTube frame is not
   something to operate, it is a way back to the full-size one, so the tap
   opens the reveal-passage sheet where the real player is. Excluded while
   that sheet is open (.imm-sheet) -- there the video is already full size
   in the card, and a second copy of it in the corner would be two live
   iframes of the same video on one screen. */
/* The corner box is .reading-body, NOT #mediaZone, and that is forced: on an
   exercise step base CSS leaves .reading-body at `display: none` (it is only
   shown for data-mode reading/split), and a position:fixed element inside a
   display:none ancestor is not rendered at all -- measured, the panel came
   back 0x0. So the ancestor has to be the thing that reappears, and it takes
   the corner geometry with it; #mediaZone then just fills it normally.

   Re-declaring `display` here is deliberate and is the one case the rule at
   the top of this section allows for: the state is pinned down to exactly
   immersive + video + an exercise step + not already showing the sheet, so
   it cannot leak into the states base CSS is hiding this for. Fixed
   positioning also keeps it out of .stage's grid, so the exercise below
   keeps every pixel it had. */
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet) .reading-body,
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet) .reading-body {
  display: block;
  position: fixed;
  top: auto; right: auto;
  bottom: var(--imm-dock-edge);
  left: var(--imm-dock-edge);
  width: clamp(120px, 13vw, 180px);
  max-width: none;
  height: auto;
  margin: 0;
  padding: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  z-index: 44;
  opacity: .85;
  cursor: pointer;
  transition: opacity var(--speed), transform var(--speed);
  /* Darek: "it should be scalable -- the user could grab the corner and
     make it bigger while doing exercises." Native resize rather than a
     hand-rolled drag: it is keyboard/AT-reachable and needs no pointer
     bookkeeping of our own. Two things make it behave here: `resize`
     requires a non-visible overflow (hence `hidden`, replacing the
     `visible` this rule used to set), and because the box is pinned by its
     BOTTOM-left corner, growing the height extends it UPWARD into the empty
     stage rather than off the bottom of the screen. Capped so it can never
     swallow the exercise it is meant to sit beside. */
  overflow: hidden;
  resize: both;
  min-width: clamp(120px, 13vw, 180px);
  max-height: 80vh;
}
/* EXPANDED: a centred, fully opaque lightbox -- still without moving any DOM
   node (relocating #readingBody reloads the iframe and kills playback, which
   is the whole reason this is a class and not openReadingSheet).

   Darek: "the expanded video is semi transparent. perhaps we should center it
   instead and make it fully opaque." Both were real. It stayed pinned to the
   bottom-left corner, so at 720px it simply overlapped the exercise; and the
   exercise pills in this mode are deliberately translucent glass, so they
   painted straight over it -- the video LOOKED semi-transparent when in fact
   the answers were on top of it. Raising it above them and centring it fixes
   the same complaint from both ends.

   The scrim is this element's own box-shadow spread to 100vmax rather than a
   separate overlay node: nothing extra to insert, nothing to clean up, and it
   cannot desynchronise from the state that owns it. (overflow:hidden clips
   children, never the element's own shadow.) */
/* .reading-panel carries z-index:20 and is therefore a STACKING CONTEXT, so
   the z-index:63 below is scoped INSIDE it and cannot rise above anything
   outside -- the exercise painted straight over the "lightbox" (the word bank
   sitting on top of the video in Darek's screenshot). Same trap this file
   already documents for the dock's z-index 45. The PANEL has to be raised
   too; raising only the child is what looks like it should work and doesn't. */
.player-shell[data-immersive="1"][data-imm-media="video"].video-expanded:not(.imm-sheet) .reading-panel {
  z-index: 62;
}
.player-shell[data-immersive="1"][data-imm-media="video"].video-expanded[data-mode="task"]:not(.imm-sheet) .reading-body,
.player-shell[data-immersive="1"][data-imm-media="video"].video-expanded[data-mode="split"]:not(.imm-sheet) .reading-body {
  top: 50%;
  left: 50%;
  bottom: auto;
  transform: translate(-50%, -50%);
  width: min(900px, 92vw);
  max-width: min(900px, 92vw);
  height: auto;
  opacity: 1;
  resize: none;
  cursor: default;
  border-radius: 16px;
  z-index: 63;
  box-shadow: 0 0 0 100vmax rgba(8, 15, 20, .62),
              0 30px 80px -20px rgba(0, 0, 0, .55);
}

/* The expand/collapse control, docked to the player it acts on. Only ever
   rendered in the two states where a corner player exists at all. */
.video-expand-btn { display: none; }
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet) .video-expand-btn,
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet) .video-expand-btn {
  display: grid;
  place-items: center;
  position: absolute;
  top: 6px; right: 6px;
  width: 30px; height: 30px;
  border: none;
  border-radius: 9px;
  background: rgba(8, 15, 20, .62);
  color: #fff;
  font-size: 1rem;
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--speed), background var(--speed);
  z-index: 2;
}
/* Revealed on hover/focus so it never sits on top of the picture unasked --
   but ALWAYS visible while expanded, because there it is the way back out.

   Touch has no hover state at all, so on a tablet -- wide enough to keep
   the corner panel (that's the >700px block above), but touch-only -- this
   opacity:0 default was PERMANENT: nothing short of a screen-reader focus
   could ever trigger it, so the only expand control that panel has was
   invisible. `(hover: none)` (true for touch, false for mouse/trackpad,
   independent of viewport width) always shows it there instead. */
.player-shell[data-immersive="1"][data-imm-media="video"]:not(.imm-sheet) .reading-body:hover .video-expand-btn,
.player-shell[data-immersive="1"][data-imm-media="video"]:not(.imm-sheet) .video-expand-btn:focus-visible,
.player-shell[data-immersive="1"][data-imm-media="video"].video-expanded:not(.imm-sheet) .video-expand-btn { opacity: 1; }
.player-shell[data-immersive="1"][data-imm-media="video"]:not(.imm-sheet) .video-expand-btn:hover { background: rgba(8, 15, 20, .85); }
@media (hover: none) {
  .player-shell[data-immersive="1"][data-imm-media="video"]:not(.imm-sheet) .video-expand-btn { opacity: 1; }
}
/* One button, two glyphs: whichever matches the CURRENT state is hidden, so
   the icon always shows the action rather than the status. */
.video-expand-btn .vx-in { display: none; }
.player-shell.video-expanded .video-expand-btn .vx-out { display: none; }
.player-shell.video-expanded .video-expand-btn .vx-in { display: block; }

/* :not(.video-expanded) is load-bearing, not tidiness. Darek: "the
   expanded video moves around as I try to collapse it so I can't." The
   expanded panel is centred with `transform: translate(-50%, -50%)`; this
   hover rule set `transform: translateY(-2px)`, and transform is a single
   property -- hovering REPLACED the centring outright, so the lightbox jumped
   half its own width and height across the screen the instant the pointer
   reached it, i.e. exactly while reaching for the collapse button. The lift
   is a corner-thumbnail affordance anyway; a centred lightbox has nothing to
   peek out of. */
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet):not(.video-expanded) .reading-body:hover,
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet):not(.video-expanded) .reading-body:hover {
  opacity: 1; transform: translateY(-2px);
}
/* Only the player travels to the corner -- the transcript, the audio tools
   and the sheet's own close button stay behind with the (hidden) card. */
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet) .reading-body > .reading-card,
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet) .reading-body > .reading-card,
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet) .reading-body > .reading-sheet-close,
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet) .reading-body > .reading-sheet-close { display: none; }
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet) #mediaZone:not(.in-overlay):not([hidden]),
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet) #mediaZone:not(.in-overlay):not([hidden]) {
  position: static;
  width: 100%;
  max-width: none;
  margin: 0;
}
/* :not(.video-expanded) matters. Deafening the iframe is right for the
   CORNER panel -- 140px of YouTube chrome is not operable, and the panel
   wants the tap for pause/play instead. Once expanded to a real-size
   lightbox the opposite is true: it is a video player at player size, and
   a student reaching for pause, the scrubber or the volume should get
   YouTube's own controls rather than a surface that swallows them. Clicks
   landing INSIDE an iframe never surface to this document, so handing them
   over cannot double-fire the panel's own pause handler. */
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet):not(.video-expanded) #mediaZone iframe,
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet):not(.video-expanded) #mediaZone iframe {
  pointer-events: none;
}
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet) #mediaZone .video-figure,
.player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet) #mediaZone .video-figure {
  border-radius: 12px;
  box-shadow: 0 6px 22px -10px rgba(13, 27, 30, .5);
}
/* Same reasoning as the image panel below: a phone has no spare corner.
   Hiding the panel does NOT pause the iframe -- it keeps playing behind the
   exercise with nothing on screen to reach it. #taskVideoMini (task-head,
   markup comment) is the phone-only way back: a tap-to-pause target plus an
   expand button, both reachable without a corner. */
@media (max-width: 700px) {
  .player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet) .reading-body,
  .player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet) .reading-body { display: none; }
  /* EXCEPT once #tvmExpand has asked for the lightbox: the rule above and
     this one match with equal specificity, so source order decides, and
     this one comes second -- it wins and re-shows the panel centred, same
     recipe as the desktop .video-expanded rule above (which cannot
     reach this far: it never sets `display`, so on its own it does nothing
     against the `display: none` right above). #btnVideoExpand -- inside
     this panel, opacity:1 while .video-expanded regardless of hover --
     is what closes it again; nothing new to wire for that. */
  .player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet).video-expanded .reading-body,
  .player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet).video-expanded .reading-body {
    display: block;
    position: fixed;
    top: 50%; left: 50%; right: auto; bottom: auto;
    transform: translate(-50%, -50%);
    width: min(92vw, 900px);
    max-width: 92vw;
    height: auto;
    margin: 0; padding: 0; border: none;
    opacity: 1;
    resize: none;
    cursor: default;
    border-radius: 16px;
    overflow: hidden;
    z-index: 63;
    box-shadow: 0 0 0 100vmax rgba(8, 15, 20, .62),
                0 30px 80px -20px rgba(0, 0, 0, .55);
  }
}

/* #taskVideoMini: hidden by default (same recipe as .video-expand-btn --
   JS unhides the `hidden` attribute unconditionally for any video lesson,
   this is what actually decides when it shows). Only ever needed where the
   corner panel and its own #btnVideoExpand are unreachable, i.e. the exact
   phone-width block above. */
.task-video-mini {
  display: none;
  align-items: center; gap: 1px;
  border: 2px solid var(--line);
  background: var(--surface-2);
  border-radius: 999px;
  padding: 2px;
}
.tvm-play { background: #ffffff00; color: var(--audio-accent); }
.tvm-play:hover { background: var(--audio-accent); color: #fff; }
@media (max-width: 700px) {
  .player-shell[data-immersive="1"][data-imm-media="video"][data-mode="task"]:not(.imm-sheet) #taskVideoMini,
  .player-shell[data-immersive="1"][data-imm-media="video"][data-mode="split"]:not(.imm-sheet) #taskVideoMini {
    display: flex;
  }
}

/* An image lesson keeps its artwork reachable without letting it near the
   text: a small panel in the BOTTOM-LEFT corner, still clickable through to
   LP.openZoom exactly as in normal mode. Overrides the base >=1800px corner
   float so the behaviour is the same at every width instead of appearing
   only on very wide screens.

   It used to sit top-right, and that was the placement Darek reported from
   a screenshot: the stage centres its content, so on an exercise step the
   question runs the full 84ch measure and the thumbnail landed ON the
   question text -- while also crowding #btnBack, which owns that corner.
   The bottom-left corner is the one piece of chrome-free screen left after
   the four corners were assigned: the dock (pager, transport, primary) is
   centred, so nothing else is there, and every mode already reserves that
   band as stage padding so no lesson text can reach it. */
.player-shell[data-immersive="1"][data-imm-media="image"] #mediaZone:not(.in-overlay):not([hidden]) {
  display: block;
  position: fixed;
  bottom: var(--imm-dock-edge);
  left: var(--imm-dock-edge);
  right: auto;
  top: auto;
  width: clamp(104px, 11vw, 160px);
  height: auto;
  margin: 0;
  z-index: 44;
  opacity: .8;
  transition: opacity var(--speed);
}
.player-shell[data-immersive="1"][data-imm-media="image"] #mediaZone:hover { opacity: 1; }
.player-shell[data-immersive="1"][data-imm-media="image"] #mediaZone .media-carousel-slide img,
.player-shell[data-immersive="1"][data-imm-media="image"] #mediaZone .media-carousel-slide video {
  height: clamp(70px, 7.5vw, 108px);
  width: 100%;
  object-fit: cover;
  cursor: zoom-in;
}
/* On a phone there is no spare width for a floating panel; the artwork is
   still reachable from the reading sheet in normal mode. */
/* The `:not([hidden])` is not decoration: without it this rule is one
   compound short of the one above and LOSES to it, so the panel kept
   showing on a phone -- where it lands on the dock (measured overlapping
   the pager by 44x46 at 375px). Specificity has to match, not merely read
   as more specific. */
@media (max-width: 700px) {
  .player-shell[data-immersive="1"][data-imm-media="image"] #mediaZone:not(.in-overlay):not([hidden]) { display: none; }
}

/* ---------- 4. Panels lose every trace of a card -- ALL MODES ----------
   NO `display` declaration -- see rule 1 in the header. The panel is
   already a flex column, so centring needs none.

   2026-08-23 (Darek, second round): "remove tiles borders and backgrounds
   so the items appear to float on the lesson background (blurred). This
   applies to ALL surfaces, including final screen." Widened from the
   reading-only version of this rule (previous round) back out to every
   mode -- the FIRST round's fix for "matching is screwed up" was never
   about the card losing its background; it was `.match-col {display:
   contents}` dissolving two columns into one shared pool (see section 8b
   below). Stripping the card back off does not reintroduce that, because
   nothing here touches `display`, `flex-direction` or `grid-template-
   columns` on anything -- those stay exactly what base CSS already sets,
   proven correct in the previous round (measured: two separated columns,
   zero overlap). This rule only ever removed paint, never layout. */
.player-shell[data-immersive="1"] .reading-panel,
.player-shell[data-immersive="1"] .task-panel {
  min-height: 0; min-width: 0; width: 100%;
  max-width: none;
  overflow: visible;
  background: none;
  justify-content: center;
  align-items: center;
  padding: 0;
}
.player-shell[data-immersive="1"] .reading-card,
.player-shell[data-immersive="1"] .task-card {
  background: none; border: none; box-shadow: none; border-radius: 0;
  overflow: visible;
}
/* PADDING ONLY ON .reading-card. .task-card's base `padding: 2.6rem 1.2rem
   1.1rem` is not decoration -- the 2.6rem top is the reserved clearance for
   .task-head, which is position:absolute at top:.8rem (the question counter
   on the left, hint/audio-mini on the right). Zeroing it, as this rule used
   to, dropped .task-instructions straight under those badges: on a 375px
   phone "Pytanie 1 z 4" and "Przeczytaj uwaznie fragment..." rendered on top
   of each other (Darek, screenshot). Nothing about the floating look needs
   that padding gone -- the card's PAINT is what had to go, not its spacing. */
.player-shell[data-immersive="1"] .reading-card { padding: 0; }
/* Width caps stay separate: the passage keeps its narrow reading measure,
   an exercise keeps the wide one it already had (Darek, first round:
   "everything should be wider, it has lots of room"). */
/* 96ch, NIE 60ch (2026-09-25). `ch` liczy sie z pisma KARTY (17 px), a tekst
   kafelka ma 33 px -- wiec "60ch" dawalo realnie ~38 znakow czytanki w wierszu
   (zmierzone na 10936 przy 1871x1000: zdanie z 38 znakami lamalo sie, karta 765 px,
   59% ekranu puste). 96ch to ~62 znaki tekstu przy pelnym pismie: 52 -> 34 wiersze
   na tej lekcji, 35% ekranu wolne. 90vw zostaje, wiec telefon (375) sie nie zmienia. */
.player-shell[data-immersive="1"][data-mode="reading"] .reading-card {
  max-width: min(96ch, 90vw);
  width: 100%;
}
/* .reading-body's BASE rule (its "sheet" incarnation, for the exercise-step
   overlay) carries its own overflow-y:auto, and nothing in the plain
   [data-mode="reading"] override above resets it -- only padding/border/
   background are touched there. Left alone, that turns #readingBody into a
   SECOND independent scroll container nested inside .stage, competing with
   it for the same content. .stage's own rule (above) documents itself as
   "the scroll container" for immersive reading, and immScrollElementIntoBand
   only ever calls stage.scrollTo -- so with the inner overflow active, a
   long passage clips and scrolls invisibly to the caption-sync code, and
   the video parked above it (position:sticky, whose containing block is
   THIS nearest scrolling ancestor, not .stage) never moves at all. Measured
   live on lesson 4486: mediaZone stayed at the same viewport y while .stage
   scrolled 400px underneath it, because .reading-body was quietly doing the
   actual scrolling. Reset here so .stage is unambiguously the only
   scroller, matching what the JS already assumes. */
.player-shell[data-immersive="1"][data-mode="reading"] .reading-body {
  overflow: visible;
}
.player-shell[data-immersive="1"][data-mode="split"] .reading-card,
.player-shell[data-immersive="1"][data-mode="task"] .task-card,
.player-shell[data-immersive="1"][data-mode="split"] .task-card {
  max-width: min(84ch, 92vw);
  width: 100%;
}
/* Still universal: the video full-bleed player and the beat rail exist
   regardless of mode (see the .stage z-index comment above), so both
   panels keep a stacking context of their own to stay above the video and
   under the rail/dock, whatever their card looks like. */
.player-shell[data-immersive="1"] .reading-panel,
.player-shell[data-immersive="1"] .task-panel {
  position: relative;
  z-index: 20;
}

/* ---------- 5. THE WASH: legibility as light, not as a panel ----------

   Three attempts attached a plate to the text container -- an ellipse, then
   a wide band -- and both read as a tile sitting on the picture, because a
   plate attached to a box inherits that box's edges no matter how it is
   feathered. There is no plate now. .imm-wash is a FIXED, viewport-sized
   layer with a radial mask, related to no box at all, so there is no edge
   anywhere for it to inherit. The mask attenuates the backdrop blur as well
   as the tint, so what is left is a soft pool of light in the middle of the
   screen: atmosphere, not a surface.

   The alpha is not a taste decision. White over a PURE BLACK pixel -- the
   worst any supplied image can present -- reaches 4.5:1 for --imm-ink at
   0.51 and 5.7:1 at 0.58 (ink luminance 0.00963, so the backdrop must reach
   0.218, which is 51% white). Journey backgrounds are all light and measured,
   so they need less; an arbitrary hero image gets the guaranteed value. */
.imm-wash { display: none; }
.player-shell[data-immersive="1"] .imm-wash {
  display: block;
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  /* The WHOLE screen, evenly, with no mask at all (Darek: "blur THE WHOLE
     BACKGROUND to keep the text visible"). A full-coverage layer is the one
     shape that cannot show an edge, which is also what finally settles the
     no-tiles problem -- there is nothing left to perceive as a panel. */
  background: rgba(var(--imm-haze), var(--imm-wash-a, .42));
  backdrop-filter: blur(var(--imm-blur)) saturate(108%);
  -webkit-backdrop-filter: blur(var(--imm-blur)) saturate(108%);
  transition: background-color .5s ease, backdrop-filter .5s ease;
}

/* WELCOME SCREEN: the artwork is the point, so it is shown SHARP and
   unwashed -- no blur, no tint, and no passage behind it. The card carries
   its own small glow, which is the only legibility this state needs. */
.player-shell[data-immersive="1"].imm-gated .imm-wash {
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
.player-shell[data-immersive="1"].imm-gated .reading-panel,
.player-shell[data-immersive="1"].imm-gated .task-panel { visibility: hidden; }
/* ...and never a second copy of the same picture in the corner. */
.player-shell[data-immersive="1"].imm-gated #mediaZone { display: none !important; }
/* The lesson's own artwork behind the wash, on the welcome screen and
   during narrated playback (Darek). A child layer of .imm-wash so it needs
   no extra element and can never end up above the wash that makes it
   legible. */
.player-shell[data-immersive="1"] .imm-wash::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  background-image: var(--imm-hero, none);
  background-size: cover;
  background-position: center;
  opacity: 0;
  transition: opacity .6s ease;
}
.player-shell[data-immersive="1"][data-imm-back="hero"] .imm-wash::before { opacity: 1; }
.player-shell[data-immersive="1"][data-imm-back="hero"] { --imm-wash-a: .58; }

/* The containers keep only breathing room. No background, no backdrop, no
   mask, nothing that can draw an edge. */
.player-shell[data-immersive="1"] .chunk-viewport,
/* In immersive .task-body sizes to its content (flex:none, see below), so a
   flex column inside it has no height to distribute and the passage would grow
   without limit -- taking the bank down with it. Bounding the exercise is what
   makes "passage scrolls, bank stays" work here the same way it does in normal
   mode, where #taskBody's own bounded height already supplies it. */
/* Wysokosc liczona OD OKNA, nie ulamkiem okna. `min(62vh, 620px)` bylo skalibrowane,
   zanim desktop dostal `zoom: 1.25`, i mierzone w innych jednostkach niz uklad: przy
   oknie 1000 px bank konczyl sie na 914, a pasek akcji zaczyna sie na 874 -- czyli
   wystawal pod niego (zrzut Darka). Chrom nad zadaniem i pasek pod nim maja stala
   wysokosc w pikselach, wiec ulamek okna rozjezdza sie razem z jego wysokoscia:
   zmierzone, ten sam `54vh` daje zapas +25 px przy oknie 1000 i **-33 px** przy 800.
   `calc(100vh - 460px)` trzyma zapas dodatni w obu: +25 i +82. */
/* Wartosc ZAPASOWA, na pierwsze malowanie i na wypadek braku JS. Prawdziwy limit
   liczy LP.fitImmersiveBoard() z odleglosci gora planszy -> gora paska akcji:
   sam CSS nie potrafi tego trafic, bo chrom nad i pod plansza ma wysokosc w px,
   ktora nie skaluje sie z vh, a na desktopie vh liczy sie BEZ html { zoom: 1.25 },
   podczas gdy uklad jest juz Z nim. Staly wzor zostawial 25 px luzu przy oknie
   1000 i 82 px pustki pod pojemnikiem hasel przy 800. */
.player-shell[data-immersive="1"] { --imm-zadanie-max: calc(100vh - 460px); }
.player-shell[data-immersive="1"] .q-gapped,
/* Pytanie otwarte dopisane 2026-09-10: w wyprawie karta NIE MA ograniczonej wysokosci
   (`.task-body` ma `overflow: visible` i `flex: none`, przewija sie `.stage`), wiec
   dlugie pytanie rozpychalo `.q-open` w nieskonczonosc. Zmierzone na rusztowaniu przy
   oknie 990: pytanie na 733 px wypychalo mikrofon na 1159 px, czyli 169 px POZA kadr --
   uczen nie mial czym odpowiedziec. Limit sprawia, ze przewija sie sama tresc pytania
   (ma na to wlasne `overflow-y: auto`), a wiersz z polem i mikrofonem zostaje na dole. */
.player-shell[data-immersive="1"] .q-open,
.player-shell[data-immersive="1"] .q-matching { max-height: var(--imm-zadanie-max); }
.player-shell[data-immersive="1"] .task-head { margin-bottom: 0; }
.player-shell[data-immersive="1"] .task-body {
  position: relative;
  max-height: none;
  overflow: visible;
  /* flex:none because overflow is visible here. As a flex:1 item the box kept
     the height the column handed it while the content spilled out of it, and
     .task-feedback -- laid out after the BOX, not after the content -- landed
     in the middle of the exercise ("Trafione luki: ..." straddling the text,
     Darek's screenshot). Sizing the box to its content puts every later
     sibling back below what it is commenting on. */
  flex: none;
  background: none;
  padding: clamp(1.2rem, 3vh, 2.2rem) clamp(1rem, 3vw, 2rem);
  /* Gora wyprawy byla pusta: naglowek kroku jest tu ukryty, a licznik pytan przy
     dopasowaniu i lukach nie ma tresci -- mimo to caly ten pas zajmowal miejsce nad
     poleceniem (Darek: "taller, there is space wasted above the instruction").
     Zmierzone: plansza zaczynala sie na 213 px, po odzyskaniu na 174 -- 39 px wraca
     do zadania. MUSI stac ZA skroconym `padding` powyzej, inaczej ten go nadpisuje
     (pierwsza wersja stala przed nim i nie robila nic -- zmierzone: 199 px). Dol
     zostaje bez zmian, bo pod zadaniem stoi pasek akcji. */
  padding-top: clamp(.4rem, 1vh, .8rem);
}
/* ---------- 6. Typography ---------- */
.player-shell[data-immersive="1"] .reading-chunk {
  /* .reading-chunk carries its OWN card in the base stylesheet -- border,
     18.7px radius, inset box-shadow, padding. That is correct when one
     tile is revealed at a time, and it is why "no tiles" survived two
     rounds of work: every earlier attempt overrode .reading-card and never
     the chunk itself, so in flow the reader got a stack of bordered cards.
     Measured on the real page, not the harness, which never had them. */
  background: none;
  border: none;
  box-shadow: none;
  border-radius: 0;
  padding: 0;
  margin: 0 0 1.4em;
  max-width: none;
  font-size: clamp(1.2rem, .95rem + 1.35vw, 1.95rem);
  line-height: 1.6;
  text-align: center;
  text-wrap: pretty;
  color: var(--imm-ink);
  /* A halo on the glyphs themselves, which is the one legibility device
     that cannot produce a rectangle. Backs up the wash where a long
     passage runs past its plateau. */
  text-shadow: 0 0 10px rgba(var(--imm-haze), .95), 0 0 22px rgba(var(--imm-haze), .7);
}
.player-shell[data-immersive="1"] .reading-chunk:last-child { margin-bottom: 0; }
.player-shell[data-immersive="1"] .reading-chunk p { margin: 0 0 .7em; }
.player-shell[data-immersive="1"] .reading-chunk p:last-child { margin-bottom: 0; }
/* Lista w wysrodkowanym tekscie: znacznik `outside` stoi przy LEWEJ krawedzi
   pudelka <li>, a tekst na srodku -- kropka odrywala sie od swojego wiersza
   (zrzut Darka), a szersza kolumna tylko to pogarsza. Pudelko przy tresci
   i wysrodkowane stawia kropke tuz przed tekstem; `inside` zjadaloby szerokosc
   i lamalo zdanie, ktore miescilo sie w wierszu. */
.player-shell[data-immersive="1"] .reading-chunk ul,
.player-shell[data-immersive="1"] .reading-chunk ol { padding-inline-start: 0; }
.player-shell[data-immersive="1"] .reading-chunk li { width: fit-content; margin-inline: auto; }

/* Marker underline, never a filled block. A background on a span wrapping
   four lines paints four full-width rectangles with hard edges, which is
   what the evidence highlights looked like on a real lesson. A gradient
   inking only the bottom third reads as a highlighter stroke, follows the
   text rather than boxing it, and cannot stack into slabs. */
/* ONLY once the student has answered. LP.jumpToHighlight adds .correct or
   .incorrect after a verdict and deliberately adds NOTHING for a hint jump,
   because -- in the base code's own words -- colouring the passage before
   the student has answered gives the answer away. Styling bare span[id] lit
   every evidence span in the passage at once and handed over every answer
   in the lesson. */
/* ZMIANA 2026-09-21 (Darek, zrzut z czytanki): powyzszy opis byl trafny co do
   *plaskiego tla*, ale sam gradient problemu nie rozwiazal -- bo bazowa regula
   `.reading-chunk span[id].correct/.incorrect` (sekcja "Passage highlight verdict
   colors") dokladala `box-shadow: 0 0 0 3px`, ktorego zadna z tych regul nie
   zerowala. Na ekranie wygrywala wiec NIE kreska markera, tylko obwodka wokol
   pudelka zawartosci spana: rozowy prostokat urwany w polowie wysokosci liter.
   Teraz: pelnowysokie, miekkie tlo z wlasnym paddingiem w `em` i `clone`, czyli
   to, o co Darek prosil wprost -- "not full height of the font". */
.player-shell[data-immersive="1"] .reading-chunk span[id].correct,
.player-shell[data-immersive="1"] .reading-chunk span[id].incorrect,
.player-shell[data-immersive="1"] .reading-chunk span[id].hl-flash {
  box-shadow: none;
  padding: .18em .22em;
  border-radius: .3em;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.player-shell[data-immersive="1"] .reading-chunk span[id].correct {
  background: color-mix(in srgb, var(--good) 28%, transparent);
}
.player-shell[data-immersive="1"] .reading-chunk span[id].incorrect {
  background: color-mix(in srgb, var(--bad) 24%, transparent);
}
.player-shell[data-immersive="1"] .reading-chunk span[id].hl-flash {
  background: color-mix(in srgb, var(--gold) 34%, transparent);
}

/* ---------- 7. The narration cursor ----------
   Only opacity moves, never layout, so nothing reflows as the audio
   advances. Beats already read or not yet reached stay on screen so the
   reader keeps a sense of place. */
/* The dim level is a VARIABLE on the shell, not a second opacity
   declaration on the chunk, and that is the whole point.

   Three rules used to compete for this one property -- the base dim, the
   estimated-mode dim, and read-without-narration -- and they had EQUAL
   specificity, so source order silently decided the winner. The
   estimated rule was last, so it beat both `.is-current` (the current beat
   was never highlighted on any lesson without exact timings, i.e. all of
   them until today) and `.imm-noaudio` (choosing "read without narration"
   hid the prompt and left the passage dimmed -- reported from the live
   site). Neither failure looked like a CSS problem; the text was simply
   faint.

   Set on a DIFFERENT element, the value cannot collide with the two
   opacity rules below, and `.is-current` wins over its sibling by having
   one more class rather than by being lucky about order. */
.player-shell[data-immersive="1"] { --imm-dim: .3; }

/* ---------- THE BOTTOM DOCK ----------
   Every control that is not a corner glyph is stacked on ONE centred axis
   at the bottom of the screen, in fixed slots counted up from the edge.

   Written as slots rather than per-control offsets because the failure this
   replaces was two controls independently claiming the bottom of the
   screen: the narration transport was pinned bottom-LEFT and the chunk
   pager bottom-CENTRE, and their boxes overlapped by 73px (measured, 961px
   viewport) -- the pager painting over the transport's seek tail and volume
   button, since both sit at z-index 45 and the pager is later in the DOM.
   That is the reported "no way to pause or seek" and "chunk navigation is
   inaccessible": neither control was hidden by the wash (which is z-index 2,
   far below both) -- they were sitting on top of each other.

   Slots also settle the symmetry complaint. Anything centred stays centred
   whether or not its neighbours are on screen, so the dock does not shift
   when narration ends and the transport goes, or when the primary hides
   under .imm-autopilot. */
.player-shell[data-immersive="1"] {
  --imm-dock-edge: clamp(10px, 1.6vh, 16px);
  --imm-dock-row: 46px;
  --imm-dock-gap: 8px;
  /* The dock's FLOOR is its own variable, separate from --imm-dock-edge,
     even though it defaults to exactly it. --imm-dock-edge is also the
     horizontal inset for every corner glyph, so raising the dock by
     overriding that would drag the corner controls inward too. Only the
     three bottom slots read this one. */
  --imm-dock-floor: var(--imm-dock-edge);
  --imm-dock-1: var(--imm-dock-floor);
  --imm-dock-2: calc(var(--imm-dock-floor) + var(--imm-dock-row) + var(--imm-dock-gap));
  --imm-dock-3: calc(var(--imm-dock-floor) + 2 * (var(--imm-dock-row) + var(--imm-dock-gap)));
  /* The next button and the narration transport share ONE rung, side by
     side, centred as a pair -- Darek: "make the next button available next
     to the audio controls so the user can skip the text entirely", and
     separately "the next button must be lower, so as not to overlap the
     text". Both come out of the same change: the button drops from rung 3
     (where it sat in the middle of the passage, with the fade too weak that
     high up to cover the text behind it) down beside the transport on rung
     2, and stops hiding itself during playback.

     The two halves shift off centre by half of the OTHER one's width, so
     the pair reads as centred no matter which is wider. --imm-pair-shift is
     zeroed by JS (.imm-transport-on, see immSyncDock) whenever the
     transport is not actually on screen, so the button re-centres alone
     instead of sitting off to one side next to nothing.

     The offsets are applied as MARGIN, not folded into the translateX that
     already centres each element. `transform: translateX(calc(-50% + var(
     --imm-pair-shift)))` parses to NOTHING once --imm-pair-shift contains a
     min(): measured, the primary's computed transform came back as the
     identity matrix, so the button sat at left:50% with no centring at all
     and overlapped the transport by 132px. A percentage and a min() in one
     calc inside transform is the combination that fails; margin-left takes
     the same value happily because it is a pure length. */
  --imm-transport-w: min(22rem, 88vw);
  --imm-primary-size: 3.9rem;
  /* Height as its own token, defaulting to the size, so the primary can stop
     being square without touching any of the arithmetic below. Everything
     that centres the button, offsets the transport beside it or reserves
     stage padding for it reads these two -- which is exactly why the badge
     re-points the VARIABLES rather than overriding width/height directly
     (the same lesson as the pair-shift bug: override the box without the
     variable and every offset computed from it starts lying). */
  --imm-primary-h: var(--imm-primary-size);
  --imm-pair-gap: .55rem;
  --imm-pair-shift: 0px;
  --imm-transport-shift: 0px;
  /* How tall the bottom fade is -- see .stage::after. Derived from the same
     slots as the dock so the text always thins out over the band the dock
     occupies, never in the middle of the reading area and never too late to
     stop a line colliding with a control. */
  --imm-fade-h: calc(var(--imm-dock-1) + 110px);
  /* Where a sticky video parks: clear of the 16px beat rail, with a little
     air. immBandTop() reads the element's real bottom edge rather than this
     number, so the two cannot drift apart. */
  --imm-video-top: 26px;
}
/* Set by immSyncDock() in lesson-player-v3-features.js, not by a selector:
   whether the transport is on screen depends on #passageAudioRow's `hidden`
   ATTRIBUTE (set by updatePassageAudioUI on a different element), on
   IMM.noAudio and on .imm-gated -- no combinator sees all three. */
.player-shell[data-immersive="1"].imm-transport-on {
  --imm-pair-shift: calc((var(--imm-transport-w) + var(--imm-pair-gap)) / 2);
  --imm-transport-shift: calc(0px - (var(--imm-primary-size) + var(--imm-pair-gap)) / 2);
}
.player-shell[data-immersive="1"][data-imm-sync="estimated"] { --imm-dim: .5; }

/* .chunk-viewport carries its OWN overflow-y:auto (base rule, needed for
   paged mode -- see its own comment) plus an immersive max-height cap
   (min(62vh,620px), the combined rule above shared with .q-gapped). In
   .imm-flow every beat renders at once and .stage is documented as "the
   scroll container" for immersive reading (see .reading-body's own
   overflow:visible override above, and immScrollElementIntoBand in
   features.js, which only ever calls stage.scrollTo) -- but nothing had
   made the SAME reset here, one level deeper. Measured live: with the cap
   still in place #chunkViewport silently absorbed the entire passage as
   its own scrollable overflow (scrollHeight 6405 vs a 446px clientHeight)
   while #stage's scrollHeight sat exactly equal to its clientHeight, i.e.
   nothing to scroll -- so stage.scrollTo() during narration moved nothing
   and the reader watched a static screen while the audio kept talking.
   Worse, showChunk() (core.js) unconditionally zeroes #chunkViewport's
   OWN scrollTop on every beat change (immGoToBeat -> LP.showChunk), which
   is exactly the right thing for paged mode's single tile but, once a
   reader had scrolled #chunkViewport by hand to catch up with the audio,
   snapped the passage back to its top on the very next beat -- reported by
   Darek as "sometimes just scrolls to the beginning when it's in the
   middle." Freeing this container the same way removes both symptoms at
   once: with nothing left to scroll here, that scrollTop reset becomes an
   inert no-op, and #stage's scrollHeight starts reflecting the real
   content again, which is what immScrollElementIntoBand already assumes. */
.player-shell[data-immersive="1"] .chunk-viewport.imm-flow {
  overflow: visible;
  max-height: none;
}

.player-shell[data-immersive="1"] .chunk-viewport.imm-flow .reading-chunk {
  display: block;
  /* animation:none is load-bearing. The base .reading-chunk rule carries
     `animation: chunkIn .3s ease` (opacity 0 -> 1), right when one tile is
     revealed at a time. In flow every beat renders at once, so all of them
     would run it together -- the whole passage flashing to full and then
     receding -- and an animated property outranks the specified one while
     it runs, so the cursor would fight it for 300ms. */
  animation: none;
  opacity: var(--imm-dim, .3);
  transition: opacity .5s ease;
}
.player-shell[data-immersive="1"] .chunk-viewport.imm-flow .reading-chunk.is-current { opacity: 1; }
/* DRUGA POWIERZCHNIA KURSORA: slajd lekcji RECZNEJ. Prezentacja nie ma kroku
   czytania, wiec kafelka `.reading-chunk` uczen nie oglada -- patrzy na slajd
   z transkryptem. Same wartosci zostaja JEDNE (lista selektorow, nie druga kopia
   regul), zeby kursor wygladal tak samo w obu miejscach. */
.player-shell[data-immersive="1"] .reading-chunk.is-current .lp-zdanie.imm-said,
.player-shell[data-imm-kursor="1"] .step-goal--slide .lp-zdanie.imm-said { opacity: .55; }
/* KURSOR NA KAFELKU CZYTANKI: ta sama regula co na slajdzie -- SAM KONTRAST,
   ZERO DEKORACJI (Darek, 2026-09-17: "the understroke is back, I thought we could
   use a different setup, like full color font for the sentence being read and faded
   font for the other text").

   Historia tego miejsca to trzecia proba i warto ja znac, zeby nie wrocic do drugiej:
   najpierw bylo TLO-CEGLA (prostokat 38% wiersza w akcencie), ktore przy zdaniu
   zawijajacym sie na dwa wiersze rysowalo dwie cegly roznej szerokosci -- "highlights
   are ugly". Potem PODKRESLENIE, ktore zawijanie znosi, ale samo w sobie rozprasza
   i na slajdzie zostalo juz zdjete. Teraz obie powierzchnie czytaja sie po JASNOSCI.

   Zdanie wypowiadane jest jedyne w pelnym kryciu i w kolorze akcentu; CALA reszta --
   takze ta jeszcze NIEwypowiedziana -- przygasza sie do .48. Przejscie idzie po kryciu,
   wiec kursor plynie miedzy zdaniami zamiast przeskakiwac. Nic tu nie zmienia, KTORY
   span jest oznaczony: logika kursora w features.js zostaje nietknieta. */
/* Kontrast exists because its user needs more separation than a tasteful
   line gives, so there the cursor is allowed to be emphatic. */
/* KURSOR NA SLAJDZIE: SAM KONTRAST, ZERO DEKORACJI (Darek, 2026-09-16: "the stroke
   distracts"). Podkreslenie zostaje na kafelku czytanki, gdzie zdanie ma jeden-dwa
   wiersze -- na slajdzie scenki kwestia zawija sie na TRZY i kreska robi sie plotem.
   Wraca tez wtedy ten sam zarzut, ktory zdjal stad tlo-cegle: dekoracja rysowana
   osobno na kazdym fragmencie zawiniecia wyglada na ragged.

   Dlatego tutaj czyta sie po JASNOSCI: wypowiadane zdanie jest jedyne w pelnym kryciu
   i w kolorze akcentu, cala reszta -- TAKZE jeszcze niewypowiedziana -- przygasza sie
   do .48. Poswiata jest bardzo slaba i sluzy oddzieleniu od tla, nie podkresleniu.
   Przejscie na kryciu, wiec kursor plynie miedzy zdaniami zamiast przeskakiwac. */
/* SLAJD CZYTA SIE TAK SAMO W OBU TRYBACH, wiec jego selektory wisza na
   `data-imm-kursor` (kursor jest zywy na tym kroku), a nie na `data-immersive`
   (jestesmy w wyprawie). Kafelek czytanki zostaje przy wyprawie: poza nia ma
   wlasna prezentacje i wlasne stronicowanie. Wartosci sa JEDNE -- lista
   selektorow, nie druga kopia regul. */
.player-shell[data-immersive="1"] .reading-chunk.is-current .lp-zdanie,
.player-shell[data-imm-kursor="1"] .step-goal--slide .lp-zdanie { opacity: .48; transition: opacity .4s ease; }
.player-shell[data-immersive="1"] .reading-chunk.is-current .lp-zdanie.imm-said,
.player-shell[data-imm-kursor="1"] .step-goal--slide .lp-zdanie.imm-said { opacity: .48; }
/* ZDANIE MOWIONE TO ZWYKLY TEKST, A NIE TEKST W KOLORZE (Darek, 2026-09-19:
   "highlight bez koloru, po prostu normalny font a reszta przygaszona -- to jest
   standard wszedzie na eduhero"). Kursor niesie wiec JEDNA informacje: ktore zdanie
   jest teraz czytane. Kolor akcentu znaczy na tej platformie co innego (element
   klikalny, stan aktywny), wiec uzyty tutaj mowilby o zdaniu cos, czego nie ma.

   Trzecia proba tego miejsca po tle-cegle i po podkresleniu -- patrz komentarz
   wyzej. Zostaje sama gra jasnoscia, bez ani jednej dekoracji.

   Kontrast jest swiadomym wyjatkiem (blok nizej): jego uzytkownik potrzebuje
   wiekszej roznicy niz krycie i tam kolor zostaje. */
/* ZDANIE MOWIONE BIERZE NAJMOCNIEJSZY TEKST MOTYWU, nie zwykly (Darek,
   2026-09-20: "the highlight is too weak, the text should be black"). `inherit`
   dawalo `--ink`, ktory w Szlaku jest przygaszona zielenia #14343B -- czytelna,
   ale wobec sasiadow na .48 roznica byla za mala, zeby czytac ja jako kursor.

   TO NADAL NIE JEST KOLOR. `--kursor-ink` to maksymalny kontrast wobec tla, czyli
   czern w motywach jasnych i biel w ciemnym -- decyzja z 2026-09-19 ("highlight
   bez koloru, po prostu normalny font a reszta przygaszona") zostaje w mocy, bo
   akcent na tej platformie znaczy "klikalne", a zdanie czytane klikalne nie jest.

   Token, a nie wpisana czern: w Zmierzchu czarne litery na ciemnym tle znikaja
   calkowicie. Wartosc awaryjna to `--ink`, wiec motyw, ktory tokenu nie definiuje,
   zachowuje sie dokladnie jak dotad. */
.player-shell[data-immersive="1"] .reading-chunk.is-current .lp-zdanie.imm-saying,
.player-shell[data-imm-kursor="1"] .step-goal--slide .lp-zdanie.imm-saying {
  text-decoration-line: none;
  opacity: 1;
  color: var(--kursor-ink, var(--ink));
  text-shadow: none;
}
/* Kontrast: ten sam mechanizm, mocniej -- jego uzytkownik potrzebuje wiekszej roznicy
   niz gra jasnoscia, a przygaszony tekst jest tam trudniejszy do czytania. */
html[data-theme="kontrast"] .player-shell[data-immersive="1"] .reading-chunk.is-current .lp-zdanie,
html[data-theme="kontrast"] .player-shell[data-imm-kursor="1"] .step-goal--slide .lp-zdanie { opacity: .62; }
html[data-theme="kontrast"] .player-shell[data-immersive="1"] .reading-chunk.is-current .lp-zdanie.imm-saying,
html[data-theme="kontrast"] .player-shell[data-imm-kursor="1"] .step-goal--slide .lp-zdanie.imm-saying {
  color: var(--accent); font-weight: 700; text-shadow: none;
}

/* PILOT AUDIO NIE ODJEZDZA Z TEKSTEM (Darek, 2026-09-16: "the audio controls cannot
   scroll with the text, cause they become unavailable at some point"). #taskAudioMini
   siedzi w naglowku karty, a w wyprawie przewija sie CALA karta -- zmierzone na scence:
   przy kwestii dwunastej pilot mial `y = -262`, czyli 262 px ponad krawedzia ekranu,
   i nie dalo sie ani zatrzymac, ani cofnac nagrania.

   Przypinamy do ekranu, jak kazde inne sterowanie w wyprawie -- to nie jest zmiana
   `display` ani przeniesienie tresci, czyli nie lamie zadnej z dwoch regul tego bloku.
   Podklad, bo tekst przewija sie POD pilotem i bez niego ikony gina na literach. */
.player-shell[data-immersive="1"] #taskAudioMini:not([hidden]) {
  position: fixed;
  top: .9rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 70;
  padding: .2rem .45rem;
  border-radius: 99px;
  background: color-mix(in srgb, var(--surface) 72%, transparent);
  backdrop-filter: blur(6px);
}

/* (Bylo tu drugie przejscie na `.translatable`, dublujace to z reguly kursora
   wyzej. Po rozdzieleniu klas `.translatable` znaczy juz tylko 'mozna mnie
   dotknac i przetlumaczyc', wiec na lekcji NIEjezykowej ta regula nie lapala
   niczego, a na jezykowej walczyla o te sama wlasnosc z regula kursora.
   Przejscie ma jedno miejsce: tam, gdzie ustawiane jest krycie.) */

/* Video transcript: driven by REAL timestamps (updateTranscriptHighlight),
   unlike narration, which is why it can afford a confident cursor. */
.player-shell[data-immersive="1"] #transcript-container { text-align: center; }
.player-shell[data-immersive="1"] .transcript-line {
  font-size: clamp(1.05rem, .9rem + 1vw, 1.6rem);
  line-height: 1.55;
  color: var(--imm-ink);
  opacity: .32;
  transition: opacity .35s ease;
  margin: .3em 0;
  cursor: pointer;
}
/* Darek: "in focus mode the captions seem dimmed as if behind an overlay."
   Root cause: updateTranscriptHighlight (core.js) toggles the class
   `active` -- always has -- but this rule was un-dimming `.active-line`/
   `.is-active`, neither of which anything in the codebase has ever set (one
   was a typo, the other belongs to unrelated theme-picker/tab elements
   entirely). So the active line stayed at the same opacity:.32 as every
   other line, permanently: not a subtle dim, the WHOLE transcript reading
   as uniformly washed out with nothing ever brightening. */
.player-shell[data-immersive="1"] .transcript-line.active { opacity: 1; }

/* ---------- 8b. Exercises: float too, but PAINT ONLY ----------
   2026-08-23, second round (Darek): "remove tile borders and backgrounds
   so the items appear to float on the lesson background (blurred). This
   applies to ALL surfaces." First round had deleted this section outright
   after matching, gapped-text and the word bank all broke at once --
   traced to `.match-col {display:contents}` dissolving two columns into
   one shared flex pool, so a tap could land on the wrong pill. That bug
   was NEVER about paint. It is safe to bring the haze look back as long
   as this section does the one thing the first version didn't: never
   touch `display`, `flex-direction`, or `grid-template-columns` on
   anything. Every selector below sets background/border/box-shadow/
   backdrop-filter/text-shadow ONLY -- the base stylesheet still owns
   every layout decision (two-column matching grid, TF grid, word-bank
   flex-wrap, all of it), so there is nothing left here that can dissolve
   a column again.

   Base's own styling was already partly translucent (blur + alpha
   gradients, e.g. `.answer-btn`'s pale khaki-green wash) -- it just sat
   on an OPAQUE card behind it. The card is what made it read as a tile
   (verified: computed background-image an opaque linear-gradient, no
   alpha in the stops). Removing the card in section 4 already does most
   of this work; what follows tops up the individual controls so they
   hold their own against the photo now that the card isn't backing them.

   The SECOND bug last round -- gapped-text and the word bank "also
   wrong" -- was not a layout problem at all. The class names never
   matched: gapped-text renders `.gap` and `.bank-chip`
   (lesson-player-v3-exercises.js ~446-479), not `.gap-slot` /
   `.word-bank-item`, which is what the deleted section actually
   targeted. It styled markup that does not exist and left the real
   elements to whatever the (soon card-less) base gave them -- a stale
   name from before the renderer was last touched, carried through
   unnoticed because nothing ever exercises CSS selectors for a match.
   Fixed here by using the real names. */
.player-shell[data-immersive="1"] .q-text {
  background: none;
  border: none;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  color: var(--imm-ink);
  text-shadow: 0 0 10px rgba(var(--imm-haze), .95), 0 0 20px rgba(var(--imm-haze), .65);
}
/* TAK/NIE I WYBOR W WYPRAWIE: PYTANIE W PIONOWYM SRODKU, ODPOWIEDZI ZAWSZE NA DOLE
   (Darek, 2026-09-21: "put the true false buttons stably at the bottom and center
   the question vertically and make the font bigger").

   Poza wyprawa dzialalo to juz samo: `.task-body > *` daje planszy `flex: 1`, wiec
   `.q-text { flex: 1 }` centrowalo pytanie, a `.tf-row { flex: none }` siadal na
   dole karty. W wyprawie `.task-body` ma `flex: none` i `overflow: visible` (rosnie
   do tresci, bo przewija sie cala karta), wiec plansza nie dostawala ZADNEJ wolnej
   wysokosci do rozdania -- pytanie i kciuki zbijaly sie pod naglowkiem, a caly dol
   ekranu zostawal pusty (zrzut). Podloga wysokosci przywraca te wolna przestrzen;
   `--imm-zadanie-max` to ta sama miara, ktorej uzywaja tu luki i dopasowanie, wiec
   plansza konczy sie nad paskiem akcji, a nie pod nim.

   Wieksze pismo dotyczy tylko tych dwoch typow: to jedno zdanie na calym ekranie,
   wiec 1.25rem czytalo sie jak podpis. Wciaz przez `--fit-scale`, zeby dlugie
   pytanie na telefonie miala jak zjechac. */
.player-shell[data-immersive="1"] .q-truefalse,
.player-shell[data-immersive="1"] .q-choice {
  /* `min` I `max` na tej samej wartosci -- obie strony sa tu potrzebne. Bez `min`
     odpowiedzi zbijaja sie pod pytaniem (stan sprzed 2026-09-21); bez `max` dluga
     tresc rozpycha plansze POD pasek akcji i kciuki wychodza z kadru -- zglosil
     Darek przy lekko powiekszonej przegladarce. Prawdziwa wartosc liczy
     `LP.fitImmersiveBoard()` POMIAREM (gora planszy -> gora paska akcji); wzor
     w `--imm-zadanie-max` jest tylko zapasem na pierwsze malowanie i nie wie ani
     o zoomie, ani o tym, ile miejsca zajal naglowek. */
  min-height: var(--imm-zadanie-max);
  max-height: var(--imm-zadanie-max);
  justify-content: space-between;
}
/* PRZY NIEDOBORZE MIEJSCA USTEPUJE PYTANIE, NIGDY ODPOWIEDZI. `.q-text` ma juz
   `flex: 1`, wiec to ono sie kurczy; `min-height: 0` jest konieczne, bo domyslne
   minimum elementu flex to jego TRESC -- bez tego pudelko nie zejdzie ponizej
   pelnego pytania i wypchnie kciuki tak samo jak przed `max-height`. Odwrotny
   podzial dawalby zadanie, ktorego nie da sie wykonac: ta sama zasada co przy
   pytaniu otwartym i wierszu z mikrofonem. */
.player-shell[data-immersive="1"] .q-truefalse > .q-text,
.player-shell[data-immersive="1"] .q-choice > .q-text {
  min-height: 0;
  overflow-y: auto;
  /* `clamp` zamiast stalej: regula jest bardziej szczegolowa niz mobilne
     `.q-text { font-size: 1.05rem }` w @media (max-width: 900px), wiec stala
     wartosc weszlaby tez na telefon, gdzie 1.8rem to trzy wiersze na zdanie. */
  font-size: calc(clamp(1.15rem, 2.6vw + .55rem, 1.8rem) * var(--fit-scale, 1));
  line-height: 1.4;
}
.player-shell[data-immersive="1"] .gapped-passage {
  background: none;
  border: none;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  color: var(--imm-ink);
  text-shadow: 0 0 10px rgba(var(--imm-haze), .95), 0 0 20px rgba(var(--imm-haze), .65);
}
/* Tekst z lukami w wyprawie: WIEKSZY I WYSRODKOWANY, bank slow BEZ KAFELKA
   (Darek, 2026-09-14). Dwie rzeczy, obie widoczne na jednym zrzucie.

   PISMO. `.gapped-passage` siedzialo na bazowym `1.1rem`, czyli 18,7 px, podczas
   gdy reszta tresci wyprawy ma tam 32 px -- cwiczenie bylo najdrobniejszym drukiem
   na ekranie, ktory ma byc widoczny z konca sali. Sufit jest ZMIERZONY, nie dobrany:
   przy tym pasazu i oknie 1536x729 wersja 1,45rem miesci sie co do piksela
   (275/275 bez przyciecia), 1,55rem juz przycina (360 przy 314 dostepnych), a
   skracanie interlinii tego nie ratuje -- 1,6rem/2,05 potrzebuje 323 przy 312.
   Interlinia 2,4 zostaje, bo to ona daje powietrze wokol luki w linii (`.gap` ma
   przy tym rozmiarze 38 px wysokosci).

   Dlatego clamp po WYSOKOSCI okna, nie po szerokosci: pasaz i tak jest wezszy niz
   scena (`max-width` sceny to 900 px), wiec szersze okno nie daje mu ani jednej
   linii wiecej, a wyzsze daje. Przy 729 px wysokosci wychodzi 24 px, czyli +28%
   i bez przyciecia; sufit 1,55rem trzyma sie ponizej progu, przy ktorym ten pasaz
   zaczynal sie przewijac. Dluzszy pasaz nadal przewija sie w sobie, jak dotad.

   WYSRODKOWANIE: pasaz w wyprawie nie ma juz ramki ani tla (regula wyzej), wiec
   tekst dosuniety do lewej wisial przy niewidocznej krawedzi. Reszta tresci
   wyprawy jest wysrodkowana.

   BANK SLOW: kafelek (biale tlo + ramka + cien) byl potrzebny tam, gdzie pasaz tez
   jest kafelkiem. W wyprawie pasaz kafelka NIE MA, wiec bank zostawal jedynym
   bialym prostokatem na scenie i czytal sie jak wklejony z innego trybu. Same
   zetony maja wlasne tlo (regula ponizej), wiec nic nie traci czytelnosci.
   `padding` zerujemy dopiero powyzej 700 px -- na telefonie bank jest wlasnym
   kontenerem przewijanym (`max-height: 34vh`) i wyplata w nim ma sens. */
.player-shell[data-immersive="1"] .gapped-passage {
  font-size: clamp(1.25rem, 1.05rem + .85vh, 1.55rem);
  text-align: center;
}
.player-shell[data-immersive="1"] .word-bank {
  background: none;
  border: none;
  box-shadow: none;
}
@media (min-width: 701px) {
  /* PADDING ZOSTAJE, ZNIKA TYLKO KAFELEK (poprawione 2026-09-21). `padding: 0`
     mialo zdjac wyglad kafelka -- ale kafelek robia `background` i `border`,
     zerowane wyzej, a padding jest jedynym miejscem, w ktorym miesci sie CIEN
     zetonow (`0 6px 22px -10px`). W wyprawie `.q-gapped` ma `overflow-y: auto`,
     czyli jest pudelkiem przewijanym i PRZYCINA wszystko poza swoim polem --
     wiec cien zetonow z brzegu banku byl ucinany rowno z krawedzia. Bez tla
     padding nie rysuje niczego, a nadmiar u dolu bierze na siebie ostatni rzad,
     ktory stoi najblizej krawedzi. */
  .player-shell[data-immersive="1"] .word-bank { padding: .5rem .75rem 1rem; }
}

.player-shell[data-immersive="1"] .answer-btn,
.player-shell[data-immersive="1"] .match-item,
.player-shell[data-immersive="1"] .bank-chip {
  background: rgba(var(--imm-haze), .5);
  border-color: transparent;
  backdrop-filter: blur(14px) saturate(115%);
  -webkit-backdrop-filter: blur(14px) saturate(115%);
  box-shadow: 0 6px 22px -10px rgba(13, 27, 30, .35);
  color: var(--imm-ink);
}
/* Wyjatki NIE sa ozdoba: `:not(:disabled)` podnosi te regule do specyficznosci
   PIECIU klas, czyli WYZEJ niz `.is-paired`/`.is-correct`/`.is-wrong` (cztery),
   ktore stoja nizej w pliku -- kolejnosc ich nie ratuje. Zmierzone na lekcji 8271:
   najechanie na sparowana definicje podmienialo pelne `--accent` na polprzezroczysta
   mgle, a `color: var(--accent-ink)` z `.is-paired` zostawal -- bialy tekst na jasnym
   tle. Wypelnione kafelki maja wlasny hover nizej: przyciemniaja to, co juz na nich
   jest, wiec kontrast tekstu nie moze sie zmienic. */
.player-shell[data-immersive="1"] .answer-btn:hover,
.player-shell[data-immersive="1"] .match-item:hover:not(:disabled):not(.is-picked):not(.is-paired):not(.is-correct):not(.is-wrong) {
  background: rgba(var(--imm-haze), .78);
}
/* Judged/selected states stay OPAQUE and solid-colored -- same reasoning
   as the reading passage's answer feedback in the earlier design: a
   translucent "correct" over a busy photo is the one moment ambiguity
   is unacceptable. Base already gives these color-mix()'d tints against
   --surface-2; swapping the mix target to the haze keeps the same hue
   language while working against a photo instead of a page. */
.player-shell[data-immersive="1"] .answer-btn.is-correct,
.player-shell[data-immersive="1"] .match-item.is-correct {
  background: var(--good); border-color: var(--good); color: #fff;
  backdrop-filter: none; -webkit-backdrop-filter: none;
}
.player-shell[data-immersive="1"] .answer-btn.is-wrong,
.player-shell[data-immersive="1"] .match-item.is-wrong {
  background: var(--bad); border-color: var(--bad); color: #fff;
  backdrop-filter: none; -webkit-backdrop-filter: none;
}
.player-shell[data-immersive="1"] .answer-btn.is-picked,
.player-shell[data-immersive="1"] .match-item.is-picked,
.player-shell[data-immersive="1"] .match-item.is-paired {
  background: var(--accent); border-color: var(--accent); color: var(--accent-ink);
  backdrop-filter: none; -webkit-backdrop-filter: none;
}
/* Etykiety stanu DZIEDZICZA kolor po wypelnieniu. Baza maluje je barwa znaczeniowa na
   jasnym kafelku (`.match-result` -> `--accent`, `.correction-reveal` -> `--good`),
   a wyprawa wypelnia ten sam kafelek DOKLADNIE TA SAMA barwa -- wiec napis znikal.
   Zmierzone na lekcji 8271: termin `rgb(14,124,110)` na tle `rgb(14,124,110)`, kontrast
   **1.00**; po sprawdzeniu odpowiedzi 1.51. Definicja obok miala 5.09, wiec kafelek
   wygladal na sprawny -- brakowalo wylacznie tego, co mowi, CO uczen polaczyl. */
/* Hover kafelka wypelnionego: samo przyciemnienie tej samej barwy. Tekst zostaje
   bialy i zostaje czytelny, a uczen nadal widzi, ze w kafelek da sie kliknac
   (sparowana definicje rozlacza sie ponownym klikiem). */
.player-shell[data-immersive="1"] .match-item.is-picked:hover:not(:disabled),
.player-shell[data-immersive="1"] .match-item.is-paired:hover:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 86%, black);
}
.player-shell[data-immersive="1"] .match-item.is-correct:hover:not(:disabled) {
  background: color-mix(in srgb, var(--good) 86%, black);
}
.player-shell[data-immersive="1"] .match-item.is-wrong:hover:not(:disabled) {
  background: color-mix(in srgb, var(--bad) 86%, black);
}
.player-shell[data-immersive="1"] .match-item .match-result,
.player-shell[data-immersive="1"] .match-item .correction-reveal { color: inherit; }
.player-shell[data-immersive="1"] .match-item .correction-reveal { opacity: .8; }
.player-shell[data-immersive="1"] .gap,
.player-shell[data-immersive="1"] .cloze-input {
  background: rgba(var(--imm-haze), .62);
  border-color: color-mix(in srgb, var(--imm-ink) 22%, transparent);
  color: var(--imm-ink);
}
.player-shell[data-immersive="1"] .gap.is-filled { background: color-mix(in srgb, var(--accent) 32%, transparent); }
/* Gapped text on a PHONE: the bank was eating the exercise.
   `.word-bank` is `flex: none` (base, and deliberately so -- see its own
   comment: sticky was tried and was worse), so it takes its natural height
   and the passage gets whatever is left. That is fine at 13 chips on a
   desktop row; on a 390px phone the same 13 chips wrap to seven rows and
   the passage was left with two lines to work in -- measured against
   Darek's screenshot, where one gap and one sentence fragment is the whole
   visible exercise.

   The bank keeps `flex: none` (it must never be pushed off screen, which
   is the failure the base comment is defending against) but gains a cap of
   its own and scrolls inside it. The passage gets a floor in the same
   breath: capping the bank alone would just hand a long passage all the
   slack back and reintroduce the opposite imbalance. Both are vh-based so
   they hold across phone sizes rather than assuming one chip count. */
@media (max-width: 700px) {
  .player-shell[data-immersive="1"] .word-bank {
    max-height: 34vh;
    overflow-y: auto;
    /* Chips are the tap targets -- shrinking the padding rather than the
       font keeps them legible while fitting ~2 more per row, which is what
       actually reduces the row count. */
    gap: .4rem;
    padding: .5rem;
  }
  .player-shell[data-immersive="1"] .word-bank .bank-chip {
    padding: .4rem .7rem;
  }
  .player-shell[data-immersive="1"] .q-gapped > .gapped-passage {
    min-height: 30vh;
    /* Base sets 2.4 for room around an inline gap; at phone width that is
       most of a line's worth of air per line, and the passage is the thing
       starved for room here. */
    line-height: 2;
    padding: 1rem 1.1rem;
  }
}
.player-shell[data-immersive="1"] .open-input,
.player-shell[data-immersive="1"] .open-ended-textarea,
.player-shell[data-immersive="1"] textarea.answer-input {
  background: rgba(var(--imm-haze), .62);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  border-color: transparent;
  color: var(--imm-ink);
}
.player-shell[data-immersive="1"] .task-feedback {
  background: rgba(var(--imm-haze), .72);
  backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
  border: none;
  color: var(--imm-ink);
}

/* ---------- 8c. Question count -- "how many left" ----------
   Was in the "what the stage hides" list; unhidden now (Darek: "we do
   need a question count so the student knows how many he has left").
   #taskCounter already carries "Pytanie X z Y" (core.js, set per step) --
   this is the ordinary in-exercise counter every non-immersive lesson
   already shows, not new markup. It sits inside .task-head, which is
   already position:absolute at the top of the (now card-less) task area,
   flex order 1 = left-aligned opposite the audio-mini/hint controls --
   the strategic spot was already chosen by the base layout, it just
   needs to stop being invisible and pick up the same floating-text
   treatment as everything else here. */
.player-shell[data-immersive="1"] .task-counter {
  color: var(--imm-ink);
  text-shadow: 0 0 8px rgba(var(--imm-haze), .95), 0 0 16px rgba(var(--imm-haze), .7);
  font-size: .85rem;
}

/* ---------- 8d. Lesson complete: float too, no kafelek ----------
   Darek: "this applies to ALL surfaces, including final screen." Same
   paint-only rule as 8b -- .finish-minimal/.finish-guest-card keep their
   own flex layout (centred column, gap, width cap) from base; only the
   opaque card (background/border-radius/box-shadow) goes, replaced with
   text-shadow on the headline the way every other floating heading here
   reads against the photo. */
.player-shell[data-immersive="1"] .finish-minimal,
.player-shell[data-immersive="1"] .finish-guest-card {
  background: none;
  box-shadow: none;
  border-radius: 0;
}
.player-shell[data-immersive="1"] .finish-title,
.player-shell[data-immersive="1"] .finish-sub,
.player-shell[data-immersive="1"] .final-score-container h3,
.player-shell[data-immersive="1"] .score-display {
  color: var(--imm-ink);
  text-shadow: 0 0 10px rgba(var(--imm-haze), .95), 0 0 20px rgba(var(--imm-haze), .65);
}
/* "Co dalej?" and the exit link are quiet controls, not headline text --
   the reveal button gets the same glass-pill family as everything else
   here instead of its flat --surface-2 fill; the exit link just needs
   the halo to stay legible, same recipe as the transcript/beat text. */
.player-shell[data-immersive="1"] .finish-reveal-btn {
  background: rgba(var(--imm-haze), .5);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  border-color: transparent;
  color: var(--imm-ink);
}
.player-shell[data-immersive="1"] .finish-exit-link {
  color: var(--imm-ink);
  text-shadow: 0 0 8px rgba(var(--imm-haze), .9);
}
/* .nav-btn stays a solid accent pill -- it is a call to action, not
   passage text, and the guest-hijack card in particular leans on its own
   gradient (indigo->pink) to read as the "primary" choice against the
   quieter secondary link beside it. Diluting that into haze glass would
   cost the one piece of hierarchy this screen has. */

/* ---------- 9. Controls: small things floating over a picture ---------- */
.player-shell[data-immersive="1"] .action-bar {
  background: none; border: none; box-shadow: none;
  backdrop-filter: none; -webkit-backdrop-filter: none;
  padding: 0 0 clamp(.8rem, 2.5vh, 1.6rem);
  pointer-events: none;
}
.player-shell[data-immersive="1"] .action-bar > * { pointer-events: auto; }

/* Round translucent chip, same family as the corner glyphs -- NOT the
   earlier glyph-only version, whose `filter: drop-shadow(0 2px 10px
   rgba(255,255,255,.85))` was the reported "square blur background": a
   drop-shadow follows the alpha mask's silhouette in principle, but at
   that blur radius over a masked icon it rendered as a soft, roughly
   rectangular halo rather than a shape that read as intentional. A
   `border-radius:50%` backing chip cannot have that failure mode --
   there is no ambiguity about what shape it is. */
.player-shell[data-immersive="1"] #btnPrimary {
  background: rgba(var(--imm-haze), .7) !important;
  backdrop-filter: blur(14px) saturate(115%);
  -webkit-backdrop-filter: blur(14px) saturate(115%);
  border: none !important;
  box-shadow: 0 6px 22px -10px rgba(13, 27, 30, .4);
  border-radius: 50% !important;
  /* The VARIABLE, so the dock's pair offsets (which subtract half of this)
     can never be computed from a different number than the box actually is. */
  width: var(--imm-primary-size); height: var(--imm-primary-h);
  padding: 0;
  display: grid; place-items: center;
  transition: opacity .4s ease, transform var(--speed), background-color var(--speed);
}
.player-shell[data-immersive="1"] #btnPrimary:hover { transform: scale(1.08); background: rgba(var(--imm-haze), .88) !important; }

/* The badge in immersive. Two problems the normal-mode rule alone cannot
   solve, both caused by the block above being ID-level:

   1. `border-radius: 50% !important` and the square size win against any
      class selector, which is why the first attempt rendered a circle with
      the label hanging outside it. Matched here at ID level too, and the
      radius has to answer !important with !important.
   2. The dock's geometry is all computed FROM --imm-primary-size: the
      button's own margin-left centring, the stage's bottom padding, and
      --imm-transport-shift, which offsets the narration transport beside
      it. Overriding width here would leave every one of those centring a
      3.9rem box that is really 12rem wide. So the SHELL re-points the
      variables instead (below) and this rule just consumes them, exactly as
      the round chip does -- one number, every offset follows.

   Fixed width rather than auto for the same reason: the offsets need a
   number CSS can do arithmetic with before layout, and the label is a
   constant string, so there is nothing to measure. */
.player-shell[data-immersive="1"] #btnPrimary.is-badge {
  border-radius: 999px !important;
  background: var(--accent) !important;
  box-shadow: 0 2px 8px rgba(13, 27, 30, .18);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  padding: 0 1.05rem;
  place-items: center;
}
.player-shell[data-immersive="1"] #btnPrimary.is-badge:hover {
  background: var(--accent) !important;
  transform: scale(1.02);
}
/* Ink is the accent's OWN pair here, not --imm-ink: the pill is opaque, so
   the hero image behind it is irrelevant and the guaranteed pairing applies
   unchanged. (--imm-ink exists for glyphs sitting on the translucent haze,
   where the backdrop is whatever the lesson's image happens to be.) */
.player-shell[data-immersive="1"] #btnPrimary.is-badge .btn-primary-badge { color: var(--accent-ink); }
/* The badge is shown on exactly the reading step (updatePrimaryForReading is
   its only caller), so the reading step is where the dock is told the primary
   is now a wide pill. Scoped to the shell so --imm-transport-shift -- which
   is declared on the shell and offsets the transport by half of this -- picks
   it up too; on the button it would not.

   min-width, and it must stay paired with the same 600px breakpoint in
   updatePrimaryForReading(), which is what stops .is-badge being set at all
   below it. Two halves of one decision: JS drops the word, this un-reserves
   its width. Miss this half and the button is still a 12rem box on a phone,
   drawn as a 204x58 oval by the .is-badge border-radius, with the transport
   shifted aside for a pill that is no longer there.

   Why phones opt out (Darek, 2026-09-02): the pill and the transport cannot
   share a row there. Measured at 375px -- 204px pill + 9px gap + 221px
   transport = 435px of content, so the pair hung 30px off BOTH edges,
   clipping the play button and the arrow. Shrinking the pill instead was
   measured and rejected: the transport's fixed parts (play, both times,
   volume) need ~180px on their own, so the seek bar -- the only flexible
   element in it -- collapsed from 47px to 6px. The round chip gives the
   transport its full width back at no cost to anything but the word, and the
   word is a first-press hint, not a control. */
@media (min-width: 601px) {
  .player-shell[data-immersive="1"][data-mode="reading"] {
    /* 10,5/2,9rem zamiast 12/3,4 -- ta sama para, o ktorej mowi komentarz wyzej:
       to ONA, a nie padding pigulki, ustala jej rozmiar w trybie skupienia, wiec
       samo sciszenie `.is-badge` nic by tu nie zmienilo. `--imm-transport-shift`
       liczy sie z tej wartosci, wiec sterowanie narracji przesuwa sie razem. */
    --imm-primary-size: 10.5rem;
    --imm-primary-h: 2.9rem;
  }
}
/* The glyph is tinted with --imm-ink here, NOT --accent, and that is the
   answer to "how do we handle exit to exercises if the image is dark".
   .btn-primary-icon tints an alpha mask via background-color, and the accent
   teal is mid-luminance: measured 1.28:1 over a dark hero image -- invisible,
   and unrescuable by any amount of wash, because raising the wash moves the
   backdrop AWAY from a mid-tone as fast as it moves it away from black.
   Dark ink on the light wash is guaranteed by the same arithmetic as the
   text: 5.4:1 over the darkest image that could ever be supplied. The chip's
   own background now does the same job the white halo used to, so the
   backdrop under the icon is always the SAME light haze regardless of what
   is behind it -- one guarantee instead of two overlapping ones. */
.player-shell[data-immersive="1"] #btnPrimary .btn-primary-icon {
  width: 2.15rem; height: 2.15rem;
  background-color: var(--imm-ink);
}
/* NOT hidden during playback any more. It used to go opacity:0 under
   .imm-autopilot, on the reasoning that narration advances the lesson and a
   "next" button competes with it -- but that also meant the one control
   that leaves the passage was unavailable for the entire length of the
   narration. Darek: "make the next button available next to the audio
   controls so the user can skip the text entirely." It now sits beside the
   transport (see the dock block) and stays there throughout, which is the
   whole point: skipping is a decision the reader is allowed to make while
   the audio is running, not only after it stops.
   .imm-autopilot is still set and still used -- see LP.immersiveSyncBackdrop
   and the beat cursor -- so nothing else changes. */

/* Corner glyphs: quiet at rest, solid on hover. */
.player-shell[data-immersive="1"] #btnBack,
.player-shell[data-immersive="1"] #btnRevealPassage,
.player-shell[data-immersive="1"] .imm-exit {
  background: rgba(var(--imm-haze), .5);
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
  border: none;
  color: var(--imm-ink);
  box-shadow: 0 4px 20px -10px rgba(13, 27, 30, .4);
  opacity: .62;
  transition: opacity var(--speed);
}
.player-shell[data-immersive="1"] #btnBack:hover,
.player-shell[data-immersive="1"] #btnRevealPassage:hover,
.player-shell[data-immersive="1"] .imm-exit:hover,
.player-shell[data-immersive="1"] #btnBack:focus-visible,
.player-shell[data-immersive="1"] .imm-exit:focus-visible { opacity: 1; }
/* Four corners, one inset, so the frame reads as deliberate rather than as
   four separate decisions: leave the mode (top-left), leave the lesson
   (top-right), the lesson artwork (bottom-left, see section 3) and the
   passage toggle (bottom-right). Everything else is on the centred dock. */
.player-shell[data-immersive="1"] #btnBack {
  position: fixed; top: var(--imm-dock-edge); right: var(--imm-dock-edge);
  width: 38px; height: 38px; border-radius: 50%;
  z-index: 47;
}
.imm-exit { display: none; }
.player-shell[data-immersive="1"] .imm-exit {
  display: grid; place-items: center;
  position: fixed; top: var(--imm-dock-edge); left: var(--imm-dock-edge);
  width: 38px; height: 38px; padding: 0;
  z-index: 47; border-radius: 50%;
  font-size: 1.05rem; cursor: pointer;
}
/* The passage toggle takes the fourth corner instead of riding the action
   bar's grid. In the bar it was the only thing left beside the primary --
   one icon hard right, nothing on the left -- which is the asymmetry Darek
   reported from the exercise screenshot. NO `display` here: core.js hides
   this button with the `hidden` attribute on steps that have no passage to
   reveal, and re-declaring display would show it on every one of them (the
   trap in rule 1 at the top of this section). */
.player-shell[data-immersive="1"] #btnRevealPassage {
  position: fixed;
  bottom: var(--imm-dock-edge);
  right: var(--imm-dock-edge);
  width: 38px; height: 38px;
  padding: 0;
  border-radius: 50%;
  place-items: center;
  z-index: 47;
}
/* Pressed = the passage is currently showing. The one control that swaps the
   two views has to say which of them is on screen; without it the icon looks
   identical in both states and the way back is a guess. */
.player-shell[data-immersive="1"] #btnRevealPassage[aria-pressed="true"] {
  background: var(--accent);
  color: var(--accent-ink);
  opacity: 1;
}

/* ---------- 9b. The reveal-passage sheet, on an exercise step ----------
   openReadingSheet() relocates #readingBody into .task-card. In this mode
   that container has no card of its own, so the passage simply becomes the
   screen -- which is what the icon promises: the text INSTEAD of the
   exercise, not on top of it. .task-body is excluded from the immersive
   display rule (see section 8) so the base stylesheet's `display: none`
   holds, and the pager and the primary move to their sheet slots above. */
.player-shell[data-immersive="1"] .task-card.is-showing-passage {
  justify-content: center;
}
.player-shell[data-immersive="1"] .task-card.is-showing-passage > .reading-body {
  width: 100%;
  max-width: min(96ch, 90vw); /* jak karta czytanki wyzej -- ta sama pulapka `ch` z pisma karty */
  margin: 0 auto;
}
/* Two ways out of the sheet is the design (see the markup comment on
   #btnReadingSheetClose), but here the second one is the corner toggle,
   which is always on screen and says which view it will show. A second
   glyph a few pixels away doing the same thing is noise, and this one is
   absolutely positioned against a card that no longer has edges. */
.player-shell[data-immersive="1"] .reading-sheet-close { display: none; }
/* ...except when there is no toggle to be the other way out -- a video
   lesson hides #btnRevealPassage entirely, and the sheet is reachable there
   from the corner video panel. Set by syncReadingSheetFlag (core.js), which
   is the only place that knows whether that button is available. Styled as
   a corner glyph rather than the sheet's own absolute X, because the card it
   would anchor to has no edges in this mode. */
.player-shell[data-immersive="1"].imm-sheet-needs-close .reading-sheet-close {
  display: grid;
  place-items: center;
  position: fixed;
  bottom: var(--imm-dock-edge);
  right: var(--imm-dock-edge);
  top: auto; left: auto;
  width: 38px; height: 38px;
  padding: 0;
  border-radius: 50%;
  z-index: 47;
  background: rgba(var(--imm-haze), .5);
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
  border: none;
  color: var(--imm-ink);
  box-shadow: 0 4px 20px -10px rgba(13, 27, 30, .4);
  cursor: pointer;
}

/* ---------- 10. Beat rail ----------
   Replaces the journey trail as the "how much is left" signal. Segments
   rather than a percentage, because the lesson has natural chapters and a
   bare bar throws that structure away -- and it doubles as the seek
   control. The BOX is inset past both corner glyphs, not merely padded: a
   padded full-width element still overlaps them as a box, and hit-testing
   uses boxes, so a seek target would sit under the exit control. */
.imm-rail { display: none; }
.player-shell[data-immersive="1"] .imm-rail {
  display: flex; gap: 4px;
  position: fixed; top: 0; left: 62px; right: 62px;
  height: 16px; padding: 7px 0 0;
  z-index: 41; align-items: flex-start;
}
.imm-rail-seg {
  flex: 1 1 0; height: 3px; padding: 0; border: none; border-radius: 2px;
  background: rgba(13, 27, 30, .16);
  cursor: pointer; position: relative; overflow: hidden;
}
.imm-rail-seg.is-done { background: rgba(13, 27, 30, .38); }
.imm-rail-seg.is-current { background: rgba(13, 27, 30, .24); }
/* Width is set from JS per frame, so no transition here -- one would lag
   behind the audio it is meant to report. */
.imm-rail-seg.is-current::after {
  content: ''; position: absolute; inset: 0 auto 0 0;
  width: var(--seg-fill, 0%); background: var(--accent);
}

/* ---------- 11. Narration transport ----------
   Centred, in dock slot 2 -- directly under the primary and directly above
   the pager, all three on one axis.

   It was bottom-LEFT before, to keep it clear of #btnPrimary, and that did
   avoid the primary. What it did not avoid was the pager, which is centred
   and wide: the two boxes overlapped by 73px, with the pager (later in the
   DOM, same z-index 45) painting over the transport's seek tail, total time
   and volume button. A reader who wanted to pause or scrub found a control
   that was half not there. Slots make that impossible by construction --
   see the dock block above. */
.player-shell[data-immersive="1"] .passage-audio-row {
  position: fixed;
  left: 50%;
  /* Same length-based centring as #btnPrimary above -- see the comment
     there for why translateX(-50%) is not trusted for this. */
  transform: none;
  bottom: var(--imm-dock-2);
  z-index: 45;
  width: var(--imm-transport-w);
  height: var(--imm-dock-row);
  box-sizing: border-box;
  margin: 0;
  margin-left: calc(var(--imm-transport-shift) - var(--imm-transport-w) / 2);
  padding: .25rem .55rem;
  background: rgba(var(--imm-haze), .55);
  backdrop-filter: blur(14px) saturate(115%);
  -webkit-backdrop-filter: blur(14px) saturate(115%);
  border: none;
  border-radius: 999px;
  color: var(--imm-ink);
  box-shadow: 0 4px 20px -10px rgba(13, 27, 30, .35);
  /* .55 was too faint to find. This is the only pause/seek control on a
     narrated reading step, so it is chrome the reader has to be able to
     READ, not chrome that has to stay out of the way. */
  opacity: .88;
  transition: opacity var(--speed);
}
.player-shell[data-immersive="1"] .passage-audio-row:hover,
.player-shell[data-immersive="1"] .passage-audio-row:focus-within { opacity: 1; }
/* The pill is a fixed 46px slot, so its contents must not be able to grow
   it -- a taller row would push its own top edge into the pager's slot. */
.player-shell[data-immersive="1"] .passage-audio-row .pa-play { flex: 0 0 auto; }
.player-shell[data-immersive="1"] .passage-audio-row .pa-seek { min-width: 0; }
.player-shell[data-immersive="1"] .pa-time { color: var(--imm-ink); font-size: .75rem; }
/* Narration reads the PASSAGE, so its transport has no business on an
   exercise step -- it was measured sitting on top of the answers. */
.player-shell[data-immersive="1"][data-mode="task"] .passage-audio-row,
.player-shell[data-immersive="1"][data-mode="split"] .passage-audio-row { display: none; }
/* ...ALE SLAJD Z NARRACJA TEZ JEST KROKIEM "task", a tam narracja jest cala
   trescia ekranu (Darek, 2026-09-19: pelny transport ma byc dostepny takze
   w wyprawie). Regula wyzej powstala, gdy narracja istniala wylacznie na kroku
   czytania -- w Prezentacji kroku czytania NIE MA, wiec chronila odpowiedzi
   cwiczen i przy okazji gasila jedyna kontrolke slajdu.

   Klase stawia `immSyncDock()`, bo warunek jest po stronie danych kroku
   (`krok.manual` + `details.audio_path`), a zaden kombinator CSS go nie widzi.
   To ta sama para co `.imm-transport-on`: jedna mowi "pokaz", druga "zrob mu
   miejsce obok przycisku Dalej". Bez tej drugiej oba pudelka centruja sie na 50%
   i zachodza na siebie -- to jest udokumentowana awaria z 2026-08-31. */
.player-shell[data-immersive="1"].imm-slajd-audio[data-mode="task"] .passage-audio-row {
  display: flex;
}
/* The gate and the transport are the same offer twice over. Set from JS,
   because .passage-audio-row lives inside the reading card while the gate
   is a child of the shell: no CSS combinator reaches from one to the other. */
.player-shell[data-immersive="1"].imm-gated .passage-audio-row { display: none; }

/* Centre gate. Vertically and horizontally centred in the VIEWPORT, not
   floated above the bottom edge -- there it covered the third paragraph of
   the passage. This is the moment the reader chooses how to take the lesson,
   so it gets the middle of the screen and the text waits behind it.

   The wrapper is inert (pointer-events:none) so it never intercepts a click
   meant for the text underneath; only the card takes pointer events. */
.imm-gate { display: none; }
.player-shell[data-immersive="1"] .imm-gate.is-on {
  display: grid;
  place-items: center;
  position: fixed;
  inset: 0;
  z-index: 46;
  pointer-events: none;
}
.player-shell[data-immersive="1"] .imm-gate-card {
  pointer-events: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .9rem;
  padding: 0;
  /* No plate at all now -- not even the soft glow this used to hold. The
     radial gradient below was sized only slightly larger than the card
     (120%/130%), so within the card's own bounds it never reached its own
     transparent rim: it read as a hard-edged translucent tile sitting on
     the artwork, not light (Darek, screenshot -- the box was plainly
     visible over a child's face). Each control now carries its own
     legibility instead of the group sharing one panel underneath it all:
     the primary stays an opaque accent pill, and the two alternatives
     below are small chips in the same haze the narration transport
     already floats on (.passage-audio-row), rather than underlined text
     with nothing behind it. */
  border: none;
  background: none;
  box-shadow: none;
  border-radius: 0;
  color: var(--imm-ink);
  max-width: min(24rem, 88vw);
  text-align: center;
  /* Deliberately NO entrance animation. chunkIn starts at opacity 0 and
     runs with fill-mode:both, which pins an element invisible for as long
     as the tab produces no frames -- a student switching away and back is
     exactly that condition. Everywhere else that self-heals once frames
     resume; here it would mean the one prompt that must never be invisible
     is invisible at the moment it is needed. A start prompt that simply
     appears is worth more than a fade. */
}
/* All three gate controls share one shape now -- same width, height,
   radius, font -- so the group reads as one aligned block instead of a
   big pill over two narrower, differently-sized ones (Darek: "nice and
   symmetrical"). Width is a shared constant, not each button's own
   content width, specifically so "Zaczynamy"/"Czytaj" don't end up
   narrower than "Tryb zwykły" -- checked against the two other primary
   labels this same button carries (S.data.audioPath ? 'Zaczynamy' :
   canGen ? 'Dodaj narrację lektora' : 'Czytaj lekcję', immSyncGate in
   features.js), the longest of which still sets on one line at this
   width. Only fill/blur distinguish the primary from the two
   alternatives -- weight, not size, is what says "this one is the
   invitation." */
.player-shell[data-immersive="1"] .imm-gate-primary,
.player-shell[data-immersive="1"] .imm-gate-alt {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .55rem;
  width: min(16.5rem, 82vw);
  box-sizing: border-box;
  padding: .8em 1.2em;
  border-radius: 999px;
  border: none;
  font: inherit;
  font-size: .97rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: transform var(--speed), filter var(--speed), background-color var(--speed);
}
.player-shell[data-immersive="1"] .imm-gate-primary {
  background: var(--accent);
  color: var(--accent-ink);
}
.player-shell[data-immersive="1"] .imm-gate-primary:hover { transform: translateY(-1px); filter: brightness(1.06); }
.player-shell[data-immersive="1"] .imm-gate-alts {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .5rem;
}
/* Deliberately quiet: these are always available, but they are the
   alternative, not the invitation. Quiet by fill (the same translucent
   chip the narration transport uses -- .passage-audio-row, same
   haze/blur/shadow values, already proven legible over a lesson photo),
   not by being smaller or by being unreadable text with nothing behind
   it. */
.player-shell[data-immersive="1"] .imm-gate-alt {
  background: rgba(var(--imm-haze), .55);
  backdrop-filter: blur(14px) saturate(115%);
  -webkit-backdrop-filter: blur(14px) saturate(115%);
  box-shadow: 0 4px 20px -10px rgba(13, 27, 30, .35);
  color: var(--imm-ink);
}
.player-shell[data-immersive="1"] .imm-gate-alt i { font-size: 1.05em; }
.player-shell[data-immersive="1"] .imm-gate-alt:hover { background: rgba(var(--imm-haze), .72); transform: translateY(-1px); }

/* The primary button on a reading step means SKIP TO THE QUESTIONS. With a
   start prompt on screen a reader reaches for the most button-looking thing
   to begin -- and would skip the entire passage (Darek). So while the gate
   is up, the one control that must not be pressed is not there.
   .imm-gated is set from JS alongside the gate itself. */
.player-shell[data-immersive="1"].imm-gated #btnPrimary { opacity: 0; visibility: hidden; pointer-events: none; }

/* While the reveal-passage sheet covers the exercise, #btnPrimary keeps
   whatever action the question was already on -- Sprawdź, Dalej, or Pomiń
   pytanie -- unrelated to "go back to the exercise" even though tapping it
   does end up closing the sheet as a side effect (LP.go always does).
   Darek, once he realised the round button was actually skipping the
   question rather than just dismissing the sheet: "let's not show it" --
   the sheet's own toggle (#btnRevealPassage, now iconified as a pencil
   while open) is the one control meant to do that job. Same treatment as
   .imm-gated above, for the same reason: hidden, not just unlabelled. */
.player-shell[data-immersive="1"].imm-sheet #btnPrimary { opacity: 0; visibility: hidden; pointer-events: none; }

/* Reading without narration: every beat at full strength, because the
   dimming exists to show which one is being SPOKEN. With nothing speaking,
   dimming seven eighths of the passage is just making it harder to read.
   What is left is the whole text on the haze, scrolled freely -- which is
   what the reading screen does anyway. */
/* The pager is available on EVERY reading step, not only after choosing to
   read without narration. Scoping it to that one branch left the ordinary
   flow state with no way through the passage at all: narration advances the
   beats only while it is playing, this lesson has none, and click-to-advance
   was removed because it fought tap-to-translate. That left a twelve-part
   passage navigable only by a 3px rail at the top of the screen.

   In flow it moves the lit beat; in paged mode it pages. Same control, same
   place, both times. */
/* The pager sits in dock slot 1, the bottom rung, on the same centred axis
   as the transport above it and the primary above that.

   The second selector is the reveal-passage state on an EXERCISE step: when
   #btnRevealPassage opens the reading sheet, openReadingSheet() relocates
   #readingBody -- pager included -- inside .task-card, where the pager falls
   back into normal flow and lands below the fold (measured at y=1000 in a
   910px viewport, i.e. unreachable). It is a descendant of .task-card in
   that state, which is what makes this reachable from one selector. */
.player-shell[data-immersive="1"][data-mode="reading"] .chunk-nav,
.player-shell[data-immersive="1"] .task-card.is-showing-passage .chunk-nav {
  display: flex;
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: var(--imm-dock-1);
  z-index: 45;
  max-width: min(26rem, 84vw);
  height: var(--imm-dock-row);
  box-sizing: border-box;
  flex-wrap: nowrap;
  gap: .9rem;
  align-items: center;
  justify-content: center;
  padding: .25rem .6rem;
  margin: 0;
  border-radius: 999px;
  background: rgba(var(--imm-haze), .55);
  backdrop-filter: blur(14px) saturate(115%);
  -webkit-backdrop-filter: blur(14px) saturate(115%);
  border: none;
  box-shadow: 0 5px 22px -10px rgba(13, 27, 30, .35);
}
/* Room for the pager underneath, and matching room in the stage so the
   passage stops above the primary rather than sliding behind it. */
/* The button is POSITIONED, not pushed. Padding on .action-bar does not move
   it reliably: its place comes from the shell grid row, so 81px of padding
   still left it overlapping the pager by 35px (measured twice). Fixing all
   three offsets to the viewport makes the stack deterministic -- pager at the
   bottom, primary above it, passage stopping above both. */
/* The primary is POSITIONED, not pushed, in EVERY mode -- not only while
   reading. Padding on .action-bar does not move it reliably: its place comes
   from the shell grid row, so 81px of padding still left it overlapping the
   pager by 35px (measured twice). Worse, in the bar it was not even centred
   on the screen: this mode makes #btnBack position:fixed, which takes it out
   of the bar's 3-column grid while the columns stay, so the button measured
   37px left of the viewport centre on an exercise step. Fixing it to the
   viewport removes both problems and is what makes the dock a single axis. */
/* Centred with LENGTHS, not with translateX(-50%). That transform never
   actually applied to this button -- measured, its computed transform is
   the identity matrix even though the rule is present and the element
   matches it -- so the button had been sitting half its own width right of
   centre all along, and every "centred" reading here was really "off by
   29px and nobody looked". Subtracting half its own width from the left
   offset does the same job with nothing to swallow it, and leaves the
   transform free for the hover scale (which does work, being unconditional). */
.player-shell[data-immersive="1"] #btnPrimary {
  position: fixed;
  left: 50%;
  transform: none;
  margin-left: calc(var(--imm-pair-shift) - var(--imm-primary-size) / 2);
  bottom: var(--imm-dock-1);
}
.player-shell[data-immersive="1"] #btnPrimary:hover { transform: scale(1.08); }
/* On a reading step (and in the reveal-passage sheet) the pager owns rung 1,
   so the button climbs to rung 2 -- the SAME rung as the transport, beside
   it. It used to climb two rungs to slot 3, which put it in the middle of
   the passage: measured on a 1052px-tall screen it sat at y=838 while the
   bottom fade only reaches full strength ~30% up its own height, so the
   line behind the button was still at nearly full ink. Darek, from that
   screenshot: "the next button must be lower, so as not to overlap the
   text. It should stick to the bottom."

   The -8px nudge centres the 62px button against the 46px transport pill,
   which are bottom-aligned otherwise. */
/* SLAJD Z NARRACJA TEZ MUSI PODNIESC PRZYCISK. Ta regula stawia przycisk na
   SZCZEBLU 2, obok paska -- a byla zawezona do kroku czytania. Pasek ma wlasny
   wyjatek dla slajdu (`.imm-slajd-audio[data-mode="task"]`, blok wyzej) i jechal
   na szczebel 2 sam, podczas gdy przycisk zostawal na szczeblu 1. Do tego
   `.imm-transport-on` odsuwal oba w POZIOMIE, wiec zamiast pary obok siebie
   wychodzil ukos: pasek wyzej i w lewo, przycisk nizej i w prawo, stykajace sie
   rogami. Zgloszenie Darka 2026-09-20: "navbar is disrupted by the audio player".

   To ta sama para co przy pokazywaniu paska: jedna regula mowi "pokaz", druga
   "zrob mu miejsce". Dolozenie tylko pierwszej bylo polowa roboty. */
.player-shell[data-immersive="1"][data-mode="reading"] #btnPrimary,
.player-shell[data-immersive="1"].imm-slajd-audio[data-mode="task"] #btnPrimary,
.player-shell[data-immersive="1"].imm-sheet #btnPrimary {
  bottom: calc(var(--imm-dock-2) - (var(--imm-primary-h) - var(--imm-dock-row)) / 2);
}
/* Room for the whole dock, so the passage stops above it instead of sliding
   behind. The tallest thing on the dock is now the paired rung (rung 2 plus
   the button's own box), not a third rung. */
.player-shell[data-immersive="1"][data-mode="reading"] .stage,
.player-shell[data-immersive="1"].imm-slajd-audio[data-mode="task"] .stage,
/* `--imm-primary-h`, NIE `--imm-primary-size`: pas ma oslaniac WYSOKOSC
   przycisku, a `-size` to jego szerokosc. Wszedzie indziej przycisk jest kolem
   i obie wartosci sa rowne (`--imm-primary-h: var(--imm-primary-size)`), wiec
   pomylka byla niewidoczna -- ale na kroku czytania przycisk jest PIGULKA
   i rezerwowalismy jej szerokosc. Zmierzone 2026-09-11 na lekcji 6235
   (okno 1889x980): dol sceny 272 px zamiast ~122, czyli **jedna trzecia
   wysokosci czytania** szla na oslone paska wysokiego na 46 px. */
.player-shell[data-immersive="1"].imm-sheet .stage { padding-bottom: calc(var(--imm-dock-2) + var(--imm-primary-h) + 1.4rem); }
/* +3rem past the top of the dock, not flush with it. The gradient only
   reaches full strength ~30% up its own height, so sizing it exactly to the
   dock leaves the topmost control sitting on the weak half of the ramp with
   near-full ink behind it -- which is what "overlapping text" looked like
   both times it was reported. The headroom puts the dock's own top edge at
   roughly the 50% mark, inside the near-opaque half. */
.player-shell[data-immersive="1"][data-mode="reading"],
.player-shell[data-immersive="1"].imm-slajd-audio[data-mode="task"],
.player-shell[data-immersive="1"].imm-sheet { --imm-fade-h: calc(var(--imm-dock-2) + var(--imm-primary-h) + 3rem); }
/* An exercise step has only the primary in the dock, but the artwork panel
   now lives in the bottom-left corner and is ~108px tall plus its own frame,
   so the reserved band has to clear the taller of the two. */
/* ...ale REZERWUJEMY GO TYLKO WTEDY, GDY TEN PANEL NAPRAWDE TAM STOI. Pas byl
   bezwarunkowy, a panel pokazuje sie wylacznie przy `data-imm-media="image"`
   (i nigdy ponizej 700px, gdzie regula nizej go chowa) -- wiec na kazdym kroku
   bez grafiki 165 px wysokosci sceny szlo na oslone czegos, czego nie ma.
   Zmierzone na lekcji 3267 przy oknie 1630x914, krok dopasowania w wyprawie:
   pojemnik definicji miał 360 px i PRZEWIJAL SIE (tresc 409 px), a pod paskiem
   hasel zostawalo 206 px pustki. Po zmianie: 547 px, bez przewijania, wszystkie
   piec definicji w kadrze, a pasek hasel siada 18 px nad paskiem akcji -- czyli
   "wordbank i terminy na dole, wiecej miejsca dla definicji" (Darek, 2026-09-07).
   Pasek akcji ma WLASNY wiersz w siatce powloki, wiec scena i tak nigdy nie
   wchodzi pod pływajacy przycisk glowny -- to nie jego dotyczyl ten pas. */
.player-shell[data-immersive="1"][data-mode="task"] .stage,
.player-shell[data-immersive="1"][data-mode="split"] .stage { padding-bottom: var(--imm-dock-edge); }
/* PAS 150 px NA PANEL GRAFIKI USUNIETY 2026-09-11 -- TEN PANEL SIE NIE RENDERUJE.
   Warunkiem byl `data-imm-media="image"`, czyli "lekcja ma grafike przy czytance",
   a nie "panel stoi w rogu". Panel stoi w `#mediaZone` wewnatrz `.reading-body`,
   a base CSS zostawia `.reading-body` na `display: none` poza trybami
   reading/split -- i element `position: fixed` w przodku `display: none` nie
   renderuje sie wcale. Naprawiono to WYLACZNIE dla `data-imm-media="video"`
   (regula przy `.reading-body` wyzej, z wlasnym uzasadnieniem); wariant obrazkowy
   takiej reguly nigdy nie dostal. Do tego `LP.usePassagePanelFor()` na
   dopasowaniu i tekscie z lukami CHOWA `#mediaZone` jawnie ("no image/audio
   competing for that space"), wiec w trybie split panel nie pojawia sie nawet
   przy zywej `.reading-body`.

   Zmierzone 2026-09-11 na lekcji 9195 (okno 1889x980, tryb skupienia): na kroku
   dopasowania `#mediaZone` ma `hidden` i pudelko 0x0, na kroku pytania otwartego
   `.reading-body` jest `display: none` i pudelko tez 0x0 -- w lewym dolnym rogu
   nie ma NICZEGO poza pelnoekranowa poswiata. A pas kosztowal 150 px z 683 px
   wysokosci sceny na KAZDYM kroku cwiczenia kazdej lekcji z grafika: definicje
   dopasowania mialy 267 px przy 395 px tresci (czyli 4 z 6 w kadrze), a pod
   bankiem slow zostawalo 207 px pustki. To samo widac bylo na pytaniu otwartym.

   Przywrocenie panelu (dolozenie wariantu obrazkowego reguly `.reading-body`)
   to osobna decyzja -- zmienia to, co uczen widzi. Wtedy pas trzeba wrocic
   RAZEM z nim, i bramkowac go tym, czy panel naprawde stoi, a nie sama
   obecnoscia grafiki w lekcji. */
/* ...and matching clearance at the TOP. An exercise step keeps the base
   .stage padding (0.5rem), which is nowhere near enough to clear the beat
   rail plus the two 38px corner glyphs floating over it -- on a 375px phone
   the exit glyph landed on top of the instruction text (Darek, screenshot:
   "in mobile view there are elements overflowing at the top"). Reading mode
   already reserved this; task/split lost it when they stopped taking the
   full-bleed stage rule. */
.player-shell[data-immersive="1"][data-mode="task"] .stage,
.player-shell[data-immersive="1"][data-mode="split"] .stage {
  padding-top: calc(var(--imm-dock-edge) + 44px);
}
.player-shell[data-immersive="1"] .chunk-arrow {
  background: none;
  border: none;
  color: var(--imm-ink);
  font-size: 1.05rem;
  line-height: 1;
  padding: .15em .45em;
  cursor: pointer;
  opacity: .75;
}
.player-shell[data-immersive="1"] .chunk-arrow:disabled { opacity: .25; cursor: default; }
.player-shell[data-immersive="1"] .chunk-dot {
  background: rgba(13, 27, 30, .22);
  border-color: transparent;
  width: 7px;
  height: 7px;
}
.player-shell[data-immersive="1"] .chunk-dots { gap: .35rem; }
.player-shell[data-immersive="1"] .chunk-dot.is-current { background: var(--accent); }
.player-shell[data-immersive="1"] .chunk-dot.is-read { background: rgba(13, 27, 30, .45); }


/* No opacity rule here on purpose -- immReadWithoutAudio() sets --imm-dim
   to 1 as an INLINE style, which beats every stylesheet rule regardless of
   how this file is later reordered. The failure being prevented is subtle
   enough (faint text, no error) that it should not depend on source order
   staying the way someone left it. */
/* ---------- 12. What the stage hides ----------
   .chunk-nav goes only because the beat rail replaces it AND narration
   advances beats on its own; a tap on the stage and the arrow keys are
   wired in LP.Immersive for the same reason.

   .task-counter is NOT here any more -- see section 8b. Darek: "we do
   need a question count so the student knows how many he has left."
   .task-kicker stays, but it costs nothing: base CSS already hides it
   unconditionally (`display:none` at all times, every mode -- see line
   ~1797), so this entry has been a no-op since before this mode existed. */
.player-shell[data-immersive="1"] .trail,
.player-shell[data-immersive="1"] .reading-head,

.player-shell[data-immersive="1"] .card-lesson-head,
.player-shell[data-immersive="1"] .task-kicker,
.player-shell[data-immersive="1"] #readingPeek,
.player-shell[data-immersive="1"] #tamSongBtn,
.player-shell[data-immersive="1"] #btnBgQuick,
.player-shell[data-immersive="1"] .assistant-speech-bubble,
.player-shell[data-immersive="1"] .media-corner-btn { display: none; }

/* ---------- 13. Assistant: reachable, as a quiet klamra ----------
   The lineage runs backwards: create_flashcards.php's .fc-drawer-handle was
   itself modelled on THIS button, so this is the shape coming home. The
   point is the MOTION -- the normal handle is a 101x250 illustration that
   breathes and peek-alerts, the most motion-heavy thing on screen.

   transform is overridden outright rather than composed with: the normal
   rule's translateX(115%) rotate(-42deg) is tuned against a 250px box and
   the swing scales with that height, so a 74px box would land somewhere
   neither value was tuned for. NO `display` -- base CSS switches it to none
   below 900px, where the same affordance lives in the action bar. */
.player-shell[data-immersive="1"] .assistant-handle {
  top: 50%; right: 0;
  width: 42px; height: 74px;
  overflow: visible;
  transform: translateY(-50%);
  border-radius: 18px 0 0 18px;
  background: rgba(var(--imm-haze), .5);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  border: none;
  box-shadow: none; filter: none;
  place-items: center;
  opacity: .5;
  z-index: 47;
}
.player-shell[data-immersive="1"] .assistant-handle:hover,
.player-shell[data-immersive="1"] .assistant-handle:focus-visible {
  transform: translateY(-50%); opacity: 1;
}
.player-shell[data-immersive="1"] .assistant-handle-face { display: none; }
.player-shell[data-immersive="1"] .avatar-face { font-size: 1.35rem; line-height: 1; }
.player-shell[data-immersive="1"] .assistant-handle,
.player-shell[data-immersive="1"] .chat-mascot { animation: none !important; }

/* PHONE: the avatar is the mobile twin of the klamra above (base CSS swaps
   them at 900px), and it was still riding the action bar's grid row while
   everything else had moved to the fixed dock -- so it sat visibly lower
   than the play button and off its baseline (Darek: "the avatar icon is not
   in line with the play button"). Pinned to the same rung the primary uses
   on an exercise step, in the bottom-right corner.

   NO `display` here: base CSS owns that (none above 900px, grid below), and
   re-declaring it would put the avatar on desktop too, where the klamra is
   already doing this job -- the exact trap in rule 1 at the top of this
   section. The reveal-passage glyph shares this corner, so it steps aside
   by exactly the avatar's width plus a gap; above 900px it takes the corner
   back on its own (see section 9). */
@media (max-width: 900px) {
  .player-shell[data-immersive="1"] #btnHelperMobile {
    position: fixed;
    right: var(--imm-dock-edge);
    bottom: var(--imm-dock-1);
    width: 42px; height: 42px;
    z-index: 47;
  }
  .player-shell[data-immersive="1"] #btnRevealPassage {
    right: calc(var(--imm-dock-edge) + 42px + .5rem);
  }
  /* ...and the sheet's own X, when it is standing in for a missing toggle
     on a video lesson, has to dodge the same avatar. */
  .player-shell[data-immersive="1"].imm-sheet-needs-close .reading-sheet-close {
    right: calc(var(--imm-dock-edge) + 42px + .5rem);
  }

  /* THE PAGER GETS RUNG 3 TO ITSELF ON A PHONE (Darek, 2026-09-04: "the back
     to exercises button overlaps the nav in mobile" -- and the avatar too).

     The slot system above stops centred rows colliding VERTICALLY, but rung 1
     carried both the pager (centred, width grows with the chunk count) and
     this corner stack (right-anchored, fixed) -- two different anchoring
     schemes on one rung, which slots do not model. So they met in the middle
     as soon as the pager got long enough. Measured at 398x1103:

       8 dots  -> pager 79..319 vs #btnRevealPassage 294..332   = 25px under
       13 dots -> pager 46..352 vs #btnHelperMobile  340..382   = 12px under

     A centred pager may only be 189px wide before it reaches the corner, and
     the pager is 143px of chrome plus ~12px per dot -- so it crosses that at
     about FOUR dots, and 97.5% of the 1302 lessons with a chunk plan have at
     least four. This was not an edge case; it was nearly every lesson on a
     narrow phone, and it became everyone's problem the day the mode stopped
     being gated to one account.

     Capping the pager instead would have squeezed the control students use
     most down to ~3 dots' worth of width, so it gets a rung of its own.
     It clears rung 2 rather than sharing it because rung 2 is the
     primary+transport PAIR, placed side by side with --imm-pair-shift /
     --imm-transport-shift; the transport alone is 221px wide and reaches
     x=310, so the pager cannot fit beside it, and splitting that pair would
     mean re-tuning both offsets to fix a row that is already correct.

     NOT --imm-dock-3: the rungs are 46px rows, but #btnPrimary is
     --imm-primary-size (3.9rem / 66px) and so stands ~20px proud of its own
     rung. Measured with dock-3, the pager still clipped it by 3px -- the
     same class of overlap this rule exists to remove, just smaller. Deriving
     the offset from the primary's real height is the idiom already used by
     the .imm-sheet fade below, and it stays correct if that size changes. */
  .player-shell[data-immersive="1"][data-mode="reading"] .chunk-nav,
  .player-shell[data-immersive="1"] .task-card.is-showing-passage .chunk-nav {
    bottom: calc(var(--imm-dock-2) + var(--imm-primary-h) + var(--imm-dock-gap));
  }
  /* The fade must still thin the text out over the WHOLE band the dock
     occupies -- that is what its definition is derived from. Left at the
     rung-1 height the pager would sit above the fade, its translucent pill
     over full-contrast text. */
  .player-shell[data-immersive="1"] {
    --imm-fade-h: calc(var(--imm-dock-2) + var(--imm-primary-h) + var(--imm-dock-gap) + 110px);
  }
}

/* ---------- 14. How confident the cursor may look ----------
   data-imm-sync is "exact" when the lesson carries timings captured at
   synthesis, "estimated" otherwise. In estimated mode the sentence cursor
   is already off in JS; this refuses to draw one even if that ever
   changes, and softens the dimming, because the point of that mode is that
   the reader can read AROUND the narration rather than being led by it. */
.player-shell[data-immersive="1"][data-imm-sync="estimated"] .imm-saying,
.player-shell[data-immersive="1"][data-imm-sync="estimated"] .imm-said {
  background: none; opacity: 1;
}


/* ---------- 15. Motion ----------
   The global prefers-reduced-motion block clamps every animation and
   transition to .01ms, so nothing here needs its own reduced-motion rules.
   What it does NOT cover is a keyframe whose `from` is opacity:0 run with
   fill-mode:both -- that pins the element at 0 BEFORE the animation starts
   and holds it there while no frames are produced. A backgrounded tab is
   not a test artifact: a student switching away and back is the same
   condition. Only .task-card keeps an entrance, and chunkIn ENDS at
   opacity 1, so even pinned it recovers the instant a frame is drawn. */
/* Opacity ONLY, and this is not a stylistic trim of chunkIn. That keyframe
   starts at `transform: translateY(8px)`, and ANY transform on an element
   makes it the containing block for its position:fixed descendants. With
   fill-mode `both` the transform is applied before the animation starts and
   stays applied for as long as the tab produces no frames -- so the reveal-
   passage sheet, which relocates #readingBody (pager and artwork panel
   inside it) into this card, had its "fixed" dock resolve against the card
   instead of the viewport: measured y=2669 in a 910px viewport, i.e. the
   pager left the screen entirely. A fade cannot do that. */
@keyframes immCardIn { from { opacity: 0; } to { opacity: 1; } }
.player-shell[data-immersive="1"] .task-card { animation: immCardIn .45s ease both; }
/* ...but NOT while the reveal-passage sheet is open. `fill-mode: both` keeps
   the animation in effect after it finishes, and an opacity animation in
   effect makes the element a stacking context for good -- so the pager,
   which the sheet relocates INSIDE this card, had its z-index 45 trapped
   here and painted under the panel's bottom fade at 40. Measured with
   elementsFromPoint: .task-panel (the veil) first, #chunkDots second.
   The sheet is a view swap, not an entrance; it does not need the fade. */
.player-shell[data-immersive="1"].imm-sheet .task-card { animation: none; }

/* ---------- 16. Settings toggle ---------- */
#btnImmersiveSetting[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }

/* A SHORT viewport, not a narrow one -- a phone held sideways, or a
   desktop window dragged down. The dock is three rungs plus the primary,
   which is ~180px: fine on an 812px phone (22%), oppressive at 450px
   (39%, measured). Shrinking the rungs rather than dropping one of them
   keeps every control reachable, which is the whole point of this pass. */
@media (max-height: 560px) {
  .player-shell[data-immersive="1"] {
    --imm-dock-row: 38px;
    --imm-dock-gap: 5px;
    --imm-dock-edge: 8px;
  }
  /* The VARIABLE, not width/height directly -- the pair offsets and the
     button's own centring are both computed from it, so overriding the box
     without it would un-centre the button on short screens. */
  .player-shell[data-immersive="1"] { --imm-primary-size: 3rem; }
  .player-shell[data-immersive="1"] #btnPrimary .btn-primary-icon { width: 1.7rem; height: 1.7rem; }
}

/* Darek: "in mobile view full screen view triggers an android overlay
   instructing the user how to exit full screen mode. this covers the chunk
   navs and the next button, so we can still have a margin of 4-5 rem from
   the bottom of the screen." That toast is drawn by the OS on top of the
   page -- nothing in CSS can move or measure it -- so the only fix is to
   keep the dock out of the band it occupies. Raises the FLOOR only, so the
   rungs keep their spacing relative to each other and the corner glyphs
   keep their own horizontal insets. Phone widths only, and only in
   immersive: this clearance is pure waste anywhere the OS is not drawing
   over us. */
@media (max-width: 900px) {
  /* The toast is TRANSIENT -- it is shown once on entering fullscreen and
     fades after a few seconds, while this clearance was permanent, so every
     lesson paid ~4.5rem of dead band for the rest of its length. Darek:
     "can we account for that warning and lower the action button after it
     disappears?"

     Nothing can observe the toast itself (it is an OS surface: no event, no
     media query, not in the DOM, invisible to elementFromPoint). What IS
     observable is the moment that CAUSES it -- `fullscreenchange` -- so the
     clearance is now driven off a class JS holds for a timeout after that
     event and then drops (immersiveHoldFsToast, features.js). The band is
     the same 4.5rem while the toast is up; afterwards the dock settles to a
     small floor that just clears the gesture bar.

     The DEFAULT stays the raised 4.5rem and JS opts OUT of it by adding
     .imm-fs-settled -- deliberately that way round rather than JS adding
     the clearance. If the script never runs (error, a cached older bundle,
     the class removed by a future edit) the fallback is then today's
     behaviour: a bit of wasted band, every control still reachable. The
     other polarity fails toward controls hidden under an OS toast, which is
     the bug this whole rule exists to prevent. */
  .player-shell[data-immersive="1"] { --imm-dock-floor: 4.5rem; }
  .player-shell[data-immersive="1"].imm-fs-settled { --imm-dock-floor: 1.5rem; }
  /* Only the floor animates, so the rungs keep their spacing relative to
     each other and nothing jumps except the group as a whole. */
  .player-shell[data-immersive="1"] #btnPrimary,
  .player-shell[data-immersive="1"] .passage-audio-row,
  .player-shell[data-immersive="1"] .chunk-nav {
    transition: bottom .45s ease;
  }
}
/* Someone who asked not to see motion should not get a dock that slides up
   the screen on its own a few seconds after the lesson starts. */
@media (prefers-reduced-motion: reduce) {
  .player-shell[data-immersive="1"] #btnPrimary,
  .player-shell[data-immersive="1"] .passage-audio-row,
  .player-shell[data-immersive="1"] .chunk-nav { transition: none; }
}

/* ---------- 17. Narrow screens ---------- */
@media (max-width: 600px) {
  /* 3.4rem of side padding is most of a phone. Trimmed to just clear the
     38px corner glyphs, which is all that padding defends against -- the
     assistant klamra is not shown at this width (base CSS, see 13).
     READING ONLY: task/split use the base .stage padding now (section 2),
     so this must not leak a reading-specific number onto their layout too. */
  .player-shell[data-immersive="1"][data-mode="reading"] .stage { padding-left: 1.6rem; padding-right: 1.6rem; }
  .player-shell[data-immersive="1"] .reading-chunk { font-size: clamp(1.05rem, 1rem + 1.2vw, 1.4rem); }
  /* The VARIABLE, not width -- --imm-pair-shift is computed from it, so
     overriding the width alone left the pair's offsets sized for the desktop
     transport: measured on a 375px phone the group was centred but ran from
     x=-15 to x=390, i.e. off BOTH edges at once. */
  .player-shell[data-immersive="1"] { --imm-transport-w: min(13rem, 60vw); }
}

/* ==================================================================
   SONDA (exercises.type = 'poll') + MEDIA PRZY POJEDYNCZYM PYTANIU
   Dopisane przy imporcie quizow z ktotyjestes / niedajsienabrac --
   patrz docs/IMPORT_starych_quizow.md.
   ================================================================== */

/* --- 1. Grafika pytania: MINIATURA OBOK TEKSTU, nie baner nad nim ---
   Zatwierdzona makieta (2026-09-03). Poprzednie podejscie stawialo grafike
   pelnej szerokosci NAD trescia i przegralo z arytmetyka: na telefonie
   375x812 #taskBody ma 595px, a sama tresc tego ekranu potrzebowala 738px --
   odpowiedzi wychodzily poza kadr i wymagaly przewijania, czego w tym
   odtwarzaczu byc nie moze.

   Zasada: KONTENER ODPOWIEDZI JEST NIETYKALNY. Ustepuje pasmo z grafika.

   Miniatura KADRUJE (cover) i tak ma byc. Zmierzone na 975 grafikach importu:
   proporcje od 0,48 (pionowy kadr z TikToka) do 13,63 (panorama), mediana 1,5.
   Zadna stala wysokosc nie obsluzy tego zakresu -- `contain` daje pionowemu
   kadrowi dwa biale marginesy, `cover` na pelnej szerokosci obcina srodek
   panoramy. Miniatura kadruje swiadomie, bo jest ZAPOWIEDZIA; caly obraz jest
   jedno tapniecie dalej w nakladce LP.openZoom (istnieje, karuzela juz ja
   podpina pod klikniecie w obrazek). */
/* ============================================================================
   PODZIAL WYSOKOSCI KROKU: odpowiedzi na dole, reszta dzieli sie tym, co zostalo.

   Trzy strefy, zawsze w tej samej kolejnosci i o tej samej roli:

     1. MEDIA   -- bierze CALA wolna wysokosc (flex: 1 1 auto). `contain`, wiec
                   nigdy nie kadruje: dostaje tyle, ile zostalo, i tyle pokazuje.
     2. TEKST   -- bierze dokladnie tyle, ile potrzebuje (flex: 0 0 auto).
                   Nigdy nie jest przycinany, bo to on niesie pytanie.
     3. ODPOWIEDZI -- nieskracalne i dociagniete do DOLU (margin-top: auto).
                   Uczen ma je zawsze w tym samym miejscu, niezaleznie od tego,
                   czy krok ma grafike, film, sam tekst, czy wszystko naraz.

   Dlaczego nie oplywanie tekstem (poprzednia wersja, float): pudelko akapitu
   rozciaga sie na CALA szerokosc takze wtedy, gdy jego wiersze omijaja obrazek
   -- omijaja go linie, nie pudelko. Cokolwiek maluje tlo tego akapitu, malowalo
   je WIERZCHEM na grafice; na zrzucie wygladalo to jak obrazek schowany pod
   kafelkiem tekstu. Zmierzone: .q-text zaczynal sie w tym samym x co grafika
   i mial pelna szerokosc kolumny.

   Dlaczego media rosna, a nie tekst: tekst ma naturalna wysokosc i wiecej mu
   nie potrzeba, a grafika zyskuje na kazdym dodatkowym pikselu. Odwrotny podzial
   (tekst rosnie) zostawial pod tekstem pusty pas, a obrazek byl w tym czasie
   przyciety -- dokladnie to widac bylo na slajdzie narracyjnym: kadr uciety
   z gory i z dolu, a pod spodem 200px pustki.
   ============================================================================ */

/* --- krok Z PYTANIEM: media + tekst w glowie, odpowiedzi pod nia --- */
body.no-reading .q {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}
body.no-reading .q > .qhead {
  display: flex;
  flex-direction: column;
  gap: calc(.55rem * var(--fit-scale, 1));
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;          /* ostatecznosc: gdy nawet po zmniejszeniu nie miesci */
}
body.no-reading .q > .task-flag { flex: 0 0 auto; }
/* margin-top: auto dociaga odpowiedzi do dolu takze wtedy, gdy krok nie ma
   grafiki i glowa nie wypelnia calej wysokosci. */
body.no-reading .q > .choice-list { flex: 0 0 auto; margin-top: auto; }

/* Na telefonie #taskBody stoi na flex: 0 0 auto, wiec NIE rozciaga sie do
   wysokosci karty i nie ma czego oddac mediom. Zmierzone na slajdzie kroku 19
   (375x812): karta 501px, taskBody 316px, grafika utknieta na podlodze 96px
   przy 154px tekstu -- czyli ~180px lezalo puste. Po rozciagnieciu taskBody
   ma 417px, a grafika 197px. Zawezone do body.no-reading, zeby nie ruszac
   lekcji z czytanka, gdzie ten sam kontener niesie inny uklad. */
@media (max-width: 900px) {
  body.no-reading #taskBody { flex: 1 1 auto; }
}

/* DWIE KOLUMNY ODPOWIEDZI, gdy tekst na to pozwala. Klase nadaje
   LP.layoutChoiceList po POMIARZE -- nie po liczbie znakow, bo o wysokosci
   decyduje zawijanie przy konkretnej szerokosci, a nie dlugosc napisu.
   Zmierzone na lekcji 6519 (379px, 21 ekranow z odpowiedziami): przyjete na 13,
   lacznie 810px odzyskanej wysokosci; "Tak/Nie" i "Opinia/Fakt" schodza ze 128px
   do 64px. Odrzucane tam, gdzie dwie kolumny wychodza WYZSZE (dlugie odpowiedzi
   zawijaja sie na wiecej linii) albo gdy zysk jest pozorny.
   Ostatnia nieparzysta odpowiedz bierze cala szerokosc -- inaczej zostaje sierota
   w polowie rzedu obok pustego miejsca. */
body.no-reading .choice-list.is-two-col {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: calc(.5rem * var(--fit-scale, 1));
  align-items: stretch;
}
body.no-reading .choice-list.is-two-col > :last-child:nth-child(odd) { grid-column: 1 / -1; }
body.no-reading .choice-list.is-two-col .answer-btn { height: 100%; }

/* --- media: biora wolna wysokosc, nigdy nie kadruja --- */
body.no-reading .qhead > .context-media-holder,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder {
  flex: 1 1 auto;
  /* Podloga 44px, nie 96px: gdy w glowie brakuje miejsca, ustepuje GRAFIKA,
     a nie tekst pytania. Zmierzone na kroku 9 lekcji 6506 (348x661): glowa
     dostawala 115px, z czego grafika brala 96, wiec na pytanie zostawalo 19px
     i uczen widzial pusty kafelek zamiast tresci. Grafika przy 44px jest
     nadal widoczna i klikalna do powiekszenia. */
  min-height: 44px;
  margin: 0;
  width: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
body.no-reading .qhead .media-carousel,
body.no-reading .qhead .media-carousel-slides,
body.no-reading .qhead .media-carousel-slide,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slides,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slide {
  height: 100%;
  width: 100%;
}
body.no-reading .qhead .media-carousel-slide img,
body.no-reading .qhead .media-carousel-slide video,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slide img,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slide video {
  width: 100%;
  height: 100%;
  max-height: none;
  object-fit: contain;       /* nigdy `cover`: kadr pionowy 1080x1920 tracil przy
                                nim gore i dol, a to zwykle tam jest tresc zrzutu */
  object-position: center;
  cursor: zoom-in;
}
/* Zaokraglone rogi: ostre katy przy zaokraglonych kartach i przyciskach
   wygladaja jak wklejone z innego interfejsu. */
body.no-reading .qhead .media-carousel-slide img,
body.no-reading .qhead .media-carousel-slide video,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slide img,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slide video {
  border-radius: var(--radius-sm, 12px);
}

body.no-reading .qhead .media-carousel-slide .yt-holder,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slide .yt-holder {
  height: 100%;
  width: 100%;
  aspect-ratio: auto;
}
/* W ukladzie dwoch kolumn wracamy do 16:9. Rozciagniety na cala kolumne iframe
   (zmierzone 454x507) i tak pokazuje film 16:9 -- tyle ze czarne pasy dorysowuje
   sobie YouTube. Przy wlasnym pudelku 454x255 tych pasow nie ma, a wolne miejsce
   w kolumnie zostaje tlem karty. */
@media (min-width: 901px) {
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:not(:has(.q)):has(> .step-goal--slide.has-text) > .context-media-holder .media-carousel-slide .yt-holder {
    height: auto;
    max-height: 100%;
    aspect-ratio: 16 / 9;
  }
}

/* --- SLAJD ZE ZDJECIEM: obraz i tekst DZIELA karte --- ------------------------
   Zgloszenie Darka 2026-09-06: "too much space for text and too little for image,
   they should cohabitate". Zmierzone na lekcji 8271, krok "Cisnienie i wiatr":
   pole zadania 1023x538, z czego grafika dostawala pasek **55 px** (zdjecie 940x627
   wyswietlane jako 1013x45), a tekst 607 px, czyli WIECEJ niz cala karta.

   Przyczyna nie byla w grafice: `.context-media-holder` ma `flex: 1 1 auto`, wiec
   bierze WOLNA wysokosc -- a tekst (`flex: 0 0 auto`) zabieral ja w calosci, zanim
   cokolwiek zostalo. Kolejnosc w kolumnie decydowala o wszystkim.

   Dlatego na szerokim ekranie slajd staje sie DWIEMA KOLUMNAMI. To jest uklad pod
   ekran w klasie: sprzet jest poziomy, a zdjecie w kolumnie 46% dostaje 464x516
   zamiast paska 45 px. Zmierzone warianty podzialu (ta sama lekcja, 1280x800):

     46/54 -> skala 0,68, pismo 18,8 px (23,5 px po `zoom: 1.25`), grafika 464 px
     40/60 -> skala 0,74, pismo 20,5 px (25,6 px),                  grafika 403 px
     34/66 -> skala 0,74, pismo 20,5 px -- czyli WEZSZA grafika nie kupuje juz nic

   Wybrane 46/54: tekst nadal czyta sie wieksza czcionka niz przed 2026-09-06
   (22,5 px na ekranie), a grafika rosnie dziesieciokrotnie. Dalsze zwezanie kolumny
   obrazu jest strata -- trzeci wiersz tabeli to pokazuje.

   `.has-text` jest bramka na slajd, ktorego CALA trescia jest zdjecie: bez tej klasy
   uklad zostaje jednokolumnowy i grafika ma cala karte. Zmierzone na produkcji
   2026-09-06: takich slajdow jest dzis ZERO (12 slajdow narracyjnych ze zdjeciem w 5
   lekcjach, wszystkie z tekstem), wiec to zabezpieczenie na przod, a nie naprawa
   istniejacego stanu -- pusty akapit zabralby polowe karty obrazkowi.

   `:not(:has(.q))` wyklucza PYTANIA i to jest tu najwazniejsze przy ocenie zasiegu:
   wiekszosc mediow w lekcjach recznych nosza wlasnie pytania (zmierzone: 607 blokow
   wielokrotnego wyboru, 242 sondy, 40 prawda/falsz), a te renderuja sie w `.qhead`
   i ta zmiana ich nie dotyka. */
/* TRYB WYPRAWY JEST Z TEGO UKLADU WYLACZONY (2026-09-06, wieczor). Tam #taskBody nie
   ma ograniczonej wysokosci, wiec `align-items: stretch` rozciagalo kolumne media do
   1572 px przy zdjeciu rysowanym na 410x273, a cale pole zadania roslo do 1668 px
   w oknie wysokim na 1000 -- czyli TEKST SLAJDU BYL UCINANY. Zmierzone na 1600x1000.
   Bez tej reguly wyprawa wraca do stanu sprzed tego dnia: kolumna, caly tekst, ale
   zdjecie o wysokosci 10 px (sam obrazek 0 px). To usterka starsza i szersza niz ta
   zmiana; wymiana "niewidoczne zdjecie" na "ucinany tekst" byla zla, wiec nie robimy
   jej do czasu osobnego przejscia po trybie wyprawy. */
@media (min-width: 901px) {
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:not(:has(.q)):has(> .context-media-holder):has(> .step-goal--slide.has-text) {
    flex-direction: row;
    align-items: stretch;
    gap: calc(1rem * var(--fit-scale, 1));
  }
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:not(:has(.q)):has(> .context-media-holder):has(> .step-goal--slide.has-text) > .context-media-holder {
    flex: 1 1 46%;
    min-width: 0;
    min-height: 0;
  }
  /* `margin-block: 0` cofa tu centrowanie w pionie z bloku `.step-goal--slide`: w rzedzie
     auto-margines jest OSIA POPRZECZNA, wiec zamiast wysrodkowac tekst blokowalby
     `align-items: stretch` i kolumna przestawalaby siegac dolu karty. Pion i tak jest
     wysrodkowany -- przez wlasne `justify-content: center` slajdu. */
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:not(:has(.q)):has(> .context-media-holder):has(> .step-goal--slide.has-text) > .step-goal--slide {
    flex: 1 1 54%;
    min-width: 0;
    min-height: 0;
    margin-block: 0;
  }
}
/* W PIONIE (telefon) dwie kolumny nie maja sensu, wiec grafika dostaje GWARANTOWANY
   udzial zamiast resztki: `flex-shrink: 0` przy bazie 30%. Zmierzone na 390x844:
   grafika 349x61 -> **349x234**, a tekst 579 -> 406 px i schodzi z 19,7 na 16,5 px
   (`--fit-scale` 0,84). Czyli telefon placi za to zdjecie jednym punktem pisma
   ponizej stanu sprzed 2026-09-06 -- na ekranie klasowym nie placi nic. */
@media (max-width: 900px) {
  body.no-reading #taskBody:not(:has(.q)):has(> .context-media-holder) > .context-media-holder {
    flex: 1 0 30%;
    min-height: 0;
  }
}

/* Gdy nawet po zmniejszeniu glowa musi sie przewijac, jej dolna krawedz jest
   wygaszana -- bez tego urwana linia czyta sie jak zepsuta tresc, a nie jak
   "jest tego wiecej". Klase nadaje LP.fitTaskBody po pomiarze. */
body.no-reading .q > .qhead.is-clipped {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent);
}

/* --- tekst: tyle, ile potrzebuje --- */
body.no-reading .qhead > .q-text {
  flex: 0 0 auto;
  min-width: 0;
  text-align: center;
}
body.no-reading #taskBody:not(:has(.q)) > .step-goal { flex: 0 0 auto; }

/* --- PYTANIE ZE ZDJECIEM: obraz i tresc DZIELA glowe (2026-09-14) -----------
   To ten sam uklad, ktory slajd narracyjny dostal 2026-09-06, przeniesiony na
   krok Z PYTANIEM. Tamta zmiana swiadomie omijala `.q` ("wiekszosc mediow nosza
   pytania, a te renderuja sie w .qhead i ta zmiana ich nie dotyczy") -- i przez
   to najliczniejsza grupa krokow zostala na pasmie nad tekstem.

   Zmierzone na lekcji 8254 (1440x900, grafika 940x627, pole zadania 1023 px):
   w kolumnie pudelko grafiki mialo 1014x153, wiec `object-fit: contain` rysowal
   z niej **229x153** -- reszta pasma to dwa biale marginesy po bokach. Obok
   tekstu to samo zdjecie ma 451x291 pudelka i **436x291** obrazu, czyli ponad
   pieciokrotnie wiecej powierzchni, przy tej samej wysokosci kroku i bez
   przewijania (`#taskBody` 503/503 przed i po).

   PODZIAL WYSOKOSCI ZOSTAJE BEZ ZMIAN: `.choice-list` ma nadal `margin-top: auto`,
   wiec odpowiedzi stoja na dole kroku niezaleznie od tego, czy glowa jest rzedem,
   czy kolumna. Ta regula dzieli wylacznie to, co zostalo NAD nimi.

   `justify-content: center` NA TEKSCIE, a nie `align-self: center` na kolumnie --
   to jest tu nieoczywiste i bylo zmierzone. Przy `align-self: center` dluzsze
   pytanie wychodzi poza glowe W OBIE STRONY (zmierzone: tekst na y=-168 przy
   glowie 99..400), a gora kontenera przewijanego jest nieosiagalna, wiec pierwsze
   linie pytania sa nie do przeczytania. Tekst centrowany WEWNATRZ wlasnej kolumny
   i przewijany w niej zaczyna sie od pierwszej linii (zmierzone: 433/215).

   Ponizej 901 px zostaje kolumna -- na telefonie dwie kolumny po ~170 px nie
   pokazuja ani zdjecia, ani zdania. */
@media (min-width: 901px) {
  body.no-reading .player-shell:not([data-immersive="1"]) .q > .qhead:has(> .context-media-holder):has(> .q-text) {
    flex-direction: row;
    align-items: stretch;
    gap: calc(1rem * var(--fit-scale, 1));
    /* `hidden`, nie odziedziczone `auto`: w rzedzie przewija sie juz sama kolumna
       tekstu, a drugi pasek przewijania na tej samej tresci tylko myli. */
    overflow: hidden;
  }
  body.no-reading .player-shell:not([data-immersive="1"]) .q > .qhead:has(> .context-media-holder):has(> .q-text) > .context-media-holder {
    flex: 1 1 46%;
    min-width: 0;
    min-height: 0;
  }
  body.no-reading .player-shell:not([data-immersive="1"]) .q > .qhead:has(> .context-media-holder):has(> .q-text) > .q-text {
    flex: 1 1 54%;
    min-width: 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  /* PYTANIE OTWARTE ZE ZDJECIEM: obraz po lewej, pytanie i pole odpowiedzi po
     prawej (Darek, 2026-09-24). Pytanie otwarte nie ma `.qhead` -- jego regul
     pilnuja selektory `.q-open > .q-text` (przewijanie tresci, podloga trzech
     wierszy, stan po ocenie, fitTaskBody), wiec grafika NIE jest przenoszona do
     glowy pytania, tylko `#taskBody` staje sie siatka dwoch kolumn. To ten sam
     ksztalt, ktory tryb wyprawy ma od 2026-09-10 (`data-uklad="material-media"`),
     wiec oba tryby wygladaja tak samo.
     Zmierzone na lekcji 12689 (1600x818, zoom 1,25): PRZED -- pudelko grafiki
     1023x15, czyli z zdjecia 940x627 widac bylo pasek. */
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .context-media-holder):has(> .q-open) {
    display: grid;
    grid-template-columns: minmax(0, 46fr) minmax(0, 54fr);
    grid-template-rows: minmax(0, 1fr);
    column-gap: calc(1.2rem * var(--fit-scale, 1));
  }
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder {
    grid-column: 1; grid-row: 1;
    min-height: 0; height: 100%; max-height: none; margin: 0;
    display: flex; align-items: center; justify-content: center; overflow: hidden;
  }
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel,
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel-slides,
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel-slide {
    height: 100%; max-height: 100%; width: 100%;
  }
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel-slide img,
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel-slide video {
    width: 100%; height: 100%; max-width: 100%; max-height: 100%; object-fit: contain;
  }
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .context-media-holder) > .q-open {
    grid-column: 2; grid-row: 1; min-width: 0; min-height: 0;
  }
}
/* Ponizej 901 px dwie kolumny nie maja sensu, ale ta sama grafika byla sciskana
   przez `.q-open` do paska (zmierzone na 375x812: 33 px). Dostaje stala czesc
   wysokosci; reszte karty i tak przewija sie jak dotad. */
@media (max-width: 900px) {
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder {
    flex: 0 0 auto; height: min(18vh, 170px); min-height: 0;
    display: flex; align-items: center; justify-content: center; overflow: hidden;
  }
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel,
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel-slides,
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel-slide {
    height: 100%; max-height: 100%; width: 100%;
  }
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:has(> .q-open) > .context-media-holder .media-carousel-slide img {
    width: 100%; height: 100%; max-height: 100%; object-fit: contain;
  }
}

/* --- TRYB WYPRAWY: obraz obok tresci, odpowiedzi na dole (2026-09-14) --------
   Uklad dwukolumnowy slajdu (2026-09-06) i glowy pytania (wyzej) omijal tryb
   wyprawy -- z powodu, ktory wtedy byl sluszny: `#taskBody` nie ma tam
   ograniczonej wysokosci, wiec `align-items: stretch` rozciagal kolumne media
   do 1572 px i TEKST SLAJDU BYL UCINANY. Ta wersja nie uzywa stretch: kolumny
   sa centrowane, a obraz ma wlasne proporcje z sufitem w `vh`.

   Zmierzone na lekcji 8254 w trybie wyprawy (okno 1745x777 ukladu, scena 900x676):

     slajd    przed: obraz 925x617, TEKST OD y=790 -- czyli pod krawedzia ekranu,
              scena przewijana 1004/676.
              po:    obraz 473x315 obok tekstu 531x464, scena 676/676.

     pytanie  przed: obraz 740x494 u gory, ODPOWIEDZI OD y=774 przy scenie 676 --
              uczen nie widzial przyciskow, na ktore ma odpowiedziec; 937/676.
              po:    obraz 473x315 obok pytania, odpowiedzi 483..622, scena 676/676.

   SCENA JEST TU POSZERZANA (1480/1180 px, jak przy `data-uklad`) i to jest polowa
   poprawki, nie kosmetyka: przy domyslnych 900 px sceny kolumna obrazu ma 324 px,
   wiec zdjecie schodzi do 324x216 i dwie kolumny kupuja glownie biale marginesy.
   Zmierzone oba warianty.

   PODZIAL 46/54 -- ten sam co poza wyprawa, i tu tez byl mierzony. Przy 52/48
   scena zaczyna sie przewijac (687/676), bo tekst w wezszej kolumnie rosnie
   szybciej, niz zyskuje obraz. Pisma NIE zmniejszamy: przy natywnych 32 px
   wyprawy podzial 46/54 miesci sie z zapasem (533 px tresci na ~540 dostepnych).

   ODPOWIEDZI NA DOLE: `.choice-list` ma `margin-top: auto` od dawna, ale w wyprawie
   nie mialo to zadnego skutku -- `#taskBody` jest tam `flex: none`, wiec kontener
   ma wysokosc tresci i nie ma z czego odepchnac. Dlatego `#taskBody` i `.q` dostaja
   `flex: 1 1 auto` WYLACZNIE w tym przypadku; wysokosc bierze sie wtedy z karty,
   a nie z wpisanej na sztywno liczby `vh` (probowane: 62vh dziala przy tym oknie,
   68vh juz przewija -- czyli byloby to dopasowanie do jednego ekranu).

   ZAKRES CELOWO WASKI: tylko `.q-choice` i `.q-truefalse`. Plansze `.q-matching`,
   `.q-gapped` i `.q-open` ma pod opieka `LP.fitImmersiveBoard` (`data-uklad`,
   `--imm-media-max`) i dwa niezalezne ukalady na jednym elemencie by sie pobily.

   Prog 1100 px UKLADU -- ten sam, ktorego uzywa `fitImmersiveBoard`. */
@media (min-width: 1100px) {
  /* 1. SLAJD NARRACYJNY */
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) { max-width: 1480px; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) .task-card { max-width: 1180px !important; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) #taskBody { flex-direction: row; align-items: center; gap: 1.5rem; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) #taskBody > .context-media-holder { flex: 1 1 46%; min-width: 0; height: auto; }
  /* `height: auto` w calym lancuchu karuzeli cofa `height: 100%` z ukladu
     jednokolumnowego -- tam kolumna miala narzucona wysokosc, tu ma ja dac obraz. */
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) .media-carousel,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) .media-carousel-slides,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) .media-carousel-slide { height: auto; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) .media-carousel-slide img {
    width: auto; height: auto; max-width: 100%; max-height: 60vh;
    display: block; margin: 0 auto;
  }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(#taskBody > .step-goal--slide.has-text):has(#taskBody > .context-media-holder):not(:has(.q)) #taskBody > .step-goal--slide { flex: 1 1 54%; min-width: 0; margin-block: 0; }

  /* 2. PYTANIE WYBORU ZE ZDJECIEM */
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder),
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) { max-width: 1480px; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .task-card,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .task-card { max-width: 1180px !important; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) #taskBody,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) #taskBody { flex: 1 1 auto; min-height: 0; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .q,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .q { flex: 1 1 auto; min-height: 0; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .q > .qhead,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .q > .qhead { flex-direction: row; align-items: center; gap: 1.5rem; overflow: hidden; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .q > .qhead > .context-media-holder,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .q > .qhead > .context-media-holder { flex: 1 1 46%; min-width: 0; height: auto; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .q > .qhead .media-carousel,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .q > .qhead .media-carousel { height: auto; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .q > .qhead .media-carousel-slides,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .q > .qhead .media-carousel-slides { height: auto; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .q > .qhead .media-carousel-slide,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .q > .qhead .media-carousel-slide { height: auto; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .q > .qhead .media-carousel-slide img,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .q > .qhead .media-carousel-slide img { width: auto; height: auto; max-width: 100%; max-height: 50vh; display: block; margin: 0 auto; }
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-choice > .qhead > .context-media-holder) .q > .qhead > .q-text,
  body.no-reading .player-shell[data-immersive="1"] .stage:has(.q-truefalse > .qhead > .context-media-holder) .q > .qhead > .q-text { flex: 1 1 54%; min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; overflow-y: auto; }
}

/* Znak "powieksz" -- bez niego grafika wyglada na czesc tla, a nie na cos,
   co da sie otworzyc. */
body.no-reading .qhead .media-carousel::after,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel::after {
  content: "\2922";
  position: absolute; right: 8px; bottom: 8px;
  width: 26px; height: 26px; border-radius: 50%;
  background: rgba(12,14,18,.72); color: #fff;
  font-size: .8rem; line-height: 26px; text-align: center;
  pointer-events: none;
}

/* DWA obrazki (20 ekranow importu): oba naraz, jeden nad drugim, bez przewijania.
   transform ustawia JS inline, wiec musi tu pasc !important -- to jedyny sposob
   zatrzymania przesuwania slajdow bez zmiany wspoldzielonej buildMediaCarousel(). */
body.no-reading .qhead .media-carousel-slides,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slides {
  flex-direction: column;
  gap: 4px;
  transform: none !important;
}
body.no-reading .qhead .media-carousel-slide,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slide { flex: 1 1 0; min-height: 0; }
/* Na szerokim ekranie dwa obrazki stoja OBOK SIEBIE. W kolumnie kazdy dostawal
   cala szerokosc i polowe wysokosci, czyli przy kadrze poziomym pas 1014x188
   z pustymi marginesami po bokach. W rzedzie ten sam material to 504x380 --
   obrazek dwa razy wyzszy przy tej samej wysokosci pudelka. Na telefonie
   zostaje kolumna: 165px na obrazek to za malo, zeby cokolwiek rozpoznac. */
@media (min-width: 901px) {
  body.no-reading .qhead .media-carousel-slides,
  body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-slides {
    flex-direction: row;
  }
}
/* ...ale NIE wtedy, gdy slajd stoi juz w dwoch kolumnach obok tekstu: tam kolumna
   media ma 464 px, wiec dwa obrazki obok siebie schodza do 224 px kazdy. Jeden pod
   drugim w tej samej kolumnie daja 464x256 -- zmierzone, dwa razy szerszy kadr.
   To jest poprawka regresji z tego samego dnia: przed podzialem na kolumny media
   mialy cala szerokosc karty i rzad byl wtedy wlasciwy. */
@media (min-width: 901px) {
  body.no-reading .player-shell:not([data-immersive="1"]) #taskBody:not(:has(.q)):has(> .step-goal--slide.has-text) > .context-media-holder .media-carousel-slides,
  /* to samo dotyczy glowy pytania stojacej w dwoch kolumnach -- patrz wyzej */
  body.no-reading .player-shell:not([data-immersive="1"]) .q > .qhead:has(> .context-media-holder):has(> .q-text) .media-carousel-slides {
    flex-direction: column;
  }
}

body.no-reading .qhead .media-carousel-nav,
body.no-reading #taskBody:not(:has(.q)) > .context-media-holder .media-carousel-nav { display: none; }

/* --- 2. YouTube w karuzeli ---
   Ten sam kadr 16/9 co .video-figure .yt-holder w lekcjach wideo, tyle ze w
   slajdzie karuzeli. Domyslnie rowna sie miniaturze (96px), zeby w lekcji z
   tekstem zachowac sie jak kazde inne medium; regula wyzej zdejmuje limit tam,
   gdzie tekstu nie ma. */
.media-carousel-slide .yt-holder {
  position: relative;
  width: 100%;
  height: 96px;
  aspect-ratio: 16 / 9;
  background: #000;
  border-radius: calc(var(--radius) - 3px);
  overflow: hidden;
}
.media-carousel-slide .yt-holder iframe {
  position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
}

/* --- 3. Przyciski sondy ---
   Wygladaja jak zwykle .answer-btn (dziedzicza cala baze), bo dla ucznia to
   nadal "wybierz jedno". Roznica pojawia sie dopiero PO glosie: zamiast
   zielono/czerwono wjezdza slupek z rozkladem. Zadnego is-correct/is-wrong --
   w sondzie nie ma czego oblac. */
.poll-btn { position: relative; overflow: hidden; }
.poll-fill {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  width: 0;
  background: color-mix(in srgb, var(--accent) 22%, transparent);
  transition: width .45s cubic-bezier(.22, .61, .36, 1);
  pointer-events: none;
  z-index: 0;
}
.poll-label, .poll-pct { position: relative; z-index: 1; }
.poll-pct {
  float: inline-end;
  margin-inline-start: .75rem;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--muted);
  opacity: 0;
  transition: opacity .3s ease .15s;
}
.poll-btn.has-tally .poll-pct { opacity: 1; }
/* Wlasny wybor zostaje czytelny takze wtedy, gdy inny wariant ma dluzszy slupek. */
/* Zglosone przez Darka: wlasny glos byl nie do odroznienia od pozostalych opcji.
   Sam kolor ramki w --accent ginal, bo tlo przyciskow sondy jest juz w tym samym
   odcieniu. Dokladamy grubszy pierscien, pogrubiona etykiete i znacznik przed
   trescia -- rozroznienie nie moze opierac sie wylacznie na kolorze. */
.poll-btn.is-chosen {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.poll-btn.is-chosen .poll-label { font-weight: 700; }
.poll-btn.is-chosen .poll-label::before {
  /* Znak wpisany DOSLOWNIE, nie jako sekwencja ucieczki. Pierwsze podejscie
     przeszlo przez skrypt, ktory sam interpretuje backslashe, i do pliku wjechal
     bajt 0x00 -- dokladnie ta pulapka, ktora opisuje notka o znakach sterujacych
     w CLAUDE.md. Literal nie ma jak sie tak zepsuc. */
  content: "✓ ";
  color: var(--accent);
  font-weight: 700;
}
.poll-btn.is-chosen .poll-fill {
  background: color-mix(in srgb, var(--accent) 38%, transparent);
}
/* Po oddaniu glosu przyciski sa disabled -- ale to nie jest stan bledu, wiec nie
   moga zgasnac tak jak zablokowane odpowiedzi w pytaniach ocenianych. */
.poll-btn:disabled { opacity: 1; cursor: default; }

@media (prefers-reduced-motion: reduce) {
  .poll-fill { transition: none; }
  .poll-pct { transition: none; }
}

/* --- 4. Plakietka pytania odwroconego ("zaznacz bledna odpowiedz") ---
   Sygnal strukturalny, nie kolorystyczny: pigulka na drodze wzroku miedzy
   pytaniem a lista odpowiedzi. Kolor --bad wystepuje TYLKO na niej i wzmacnia
   slowo "bledna", czyli opisuje CEL zadania. Same przyciski zostaja bez zmian --
   ich khaki wash to jedyny staly sygnal "to jest do wybrania" we wszystkich
   motywach, a uzaleznianie go od kontekstu juz raz sie nie udalo. */
.task-flag {
  align-self: center;
  display: inline-flex;
  align-items: center;
  gap: .45rem;
  margin: calc(.1rem * var(--fit-scale, 1)) 0 calc(.7rem * var(--fit-scale, 1));
  padding: calc(.3rem * var(--fit-scale, 1)) calc(.75rem * var(--fit-scale, 1));
  font-size: calc(.85rem * var(--fit-scale, 1));
  font-weight: 600;
  line-height: 1.25;
  border-radius: 999px;
  flex: none;
  text-align: center;
}
.task-flag-findfalse {
  color: var(--bad);
  border: 1.5px solid color-mix(in srgb, var(--bad) 55%, transparent);
  background: color-mix(in srgb, var(--bad) 10%, transparent);
}
.task-flag-findfalse strong { font-weight: 800; text-decoration: underline; text-underline-offset: 2px; }
.task-flag-mark {
  font-weight: 800;
  font-size: 1.05em;
  line-height: 1;
}
/* Kontrast: bez przezroczystosci, grubsza ramka -- ta sama zasada co .answer-btn. */
html[data-theme="kontrast"] .task-flag-findfalse {
  background: var(--surface-2);
  border-width: 2.5px;
  border-color: var(--bad);
}

/* --- Dlugi nielamliwy ciag w tresci czytanki nie moze rozpychac kolumny ---
   Zgloszone przez uzytkowniczke na lekcji 4701 (telefon): tekst byl obciety z
   lewej, "Danny:" wygladal jak "nny:". Przyczyna nie byla w ukladzie, tylko w
   TRESCI -- nauczycielka wkleila do edytora czytanki fragment obcej strony
   razem z surowym adresem `https://youtu.be/rgHYy90qj2w?si=e87Q-36_-OoZ3vy-`.
   48 znakow bez miejsca na zlamanie ustawia min-content szerzej niz telefon,
   wiec .chunk-viewport mial 357px przy 380px tresci; przy overflow:hidden
   nadmiar jest przycinany, a poniewaz karuzela przesuwa sie o szerokosc
   widoku, blad kumuluje sie z kazdym kolejnym kawalkiem.

   Zmierzone na zywej stronie przed wdrozeniem (wstrzykniecie stylu do body):
   scrollWidth 380 -> 357, nadmiar 23px -> 0.

   `anywhere`, nie `break-word`: tylko `anywhere` wplywa na wyliczanie
   min-content, a to wlasnie ono rozpycha tu kontener. Dotyczy 32 lekcji, w
   ktorych tresc zawiera jakikolwiek nielamliwy ciag 34+ znakow. */
.chunk-viewport,
.chunk-viewport *,
#transcript-container,
#transcript-container * { overflow-wrap: anywhere; }
/* === PUSTKA W ZADANIU: tresc ma ROSNAC, sterowanie ma stac przy dole ========
   ZNACZNIK-UKLAD-2026-09-10
   Zgloszenie Darka 2026-09-10, cztery zrzuty z okna 1881x990: "wasted space,
   terms should stick to the bottom leaving as much space for definitions as
   possible. same for gapped text. open-ended wastes space in the middle which
   moves the microphone icon out of viewport."

   ZMIERZONE na rusztowaniu (docs/tools/fixtures/lp3265.html?imm=1, 1881x990),
   bo z samego zrzutu nie widac, ktory pojemnik nie rosnie:

   | co                    | pojemnik | tresc | pustka |
   |-----------------------|----------|-------|--------|
   | definicje (#chunkViewport) | 617 px | 409 px | **207 px** |
   | pytanie otwarte (.q-open)  | 405 px | 347 px | 58 px, a pod karta dalsze 170 |

   Przyczyna jest w OBU przypadkach ta sama i nie jest nia brak miejsca:
   pojemnik dostaje pelna wysokosc, a jego dziecko ma `flex: 0 1 auto`, czyli
   NIE ROSNIE. `#chunkViewport` do tego centruje (`safe center`), wiec przy
   krotkiej tresci pustka rozklada sie po rowno nad i pod nia, a przy dlugiej
   dziecko sie KURCZY (`shrink: 1`) i tresc jest ucinana -- bez paska
   przewijania, bo `.gapped-passage` i `.match-defs` maja w panelu
   `overflow: visible`. Stad zrzut, na ktorym zdanie urywa sie w polowie,
   a 200 px nizej jest pusto.

   Dlatego `1 0 auto`, a nie `1 1 auto`: ROSNIE do pelnej wysokosci, ale NIGDY
   nie kurczy sie ponizej wlasnej tresci -- gdy tresci jest wiecej niz miejsca,
   przewija sie `#chunkViewport`, ktory ma na to `overflow-y: auto`. To ta sama
   zasada, ktora stoi juz przy `.match-cols > .match-defs > li`.

   Kafelki czytanki zostaja wysrodkowane -- to osobna, swiadoma decyzja
   `.chunk-viewport` i nie ma jej tu ruszac. Rosnie WYLACZNIE plansza cwiczenia
   przeniesiona do panelu przez `LP.usePassagePanelFor()`. */
/* `1 0 auto` (wersja z rana 2026-09-10) rosla poprawnie, ale NIE KURCZYLA sie, wiec
   przy szesciu definicjach plansza wylewala sie z `#chunkViewport`. Zmierzone: panel
   367 px, lista 394 px -- i tej roznicy NIE DALO SIE doscrollowac (`scrollTop` stal
   na zerze przy `justify-content: safe center`), czyli szosta definicja byla trwale
   nieosiagalna. Dlatego lista jest dzis WLASNYM scrollerem: `1 1 auto` + `min-height: 0`
   pozwala jej zejsc do wysokosci panelu, a `overflow-y: auto` daje realne przewijanie.
   Zgodne z decyzja z 2026-09-06: "definicje moga sie przewijac, hasla nie". */
#chunkViewport .match-defs,
#chunkViewport .gapped-passage {
    flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
}
#chunkViewport .match-defs.is-clipped,
#chunkViewport .gapped-passage.is-clipped {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2.2rem), transparent);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 2.2rem), transparent);
}
/* Sam pojemnik rosnacy nie wystarcza: bez tego lista miala juz 597 px, ale kafelki
   dalej po 73 px, a roznica byla PUSTA przestrzenia na jej koncu -- zmierzone po
   pierwszej wersji tej poprawki. To sa te same dwie linie, ktore w ukladzie w karcie
   stoja przy `.match-cols > .match-defs > li`; tam dotyczyly innego rodzica, wiec
   plansza przeniesiona do panelu nigdy ich nie dostawala. */
#chunkViewport .match-defs > li { flex: 1 0 auto; display: flex; min-height: 0; }
#chunkViewport .match-defs > li > .match-item { height: 100%; }

/* Pytanie otwarte: tresc bierze srodek i przewija sie u SIEBIE (ma juz na to
   `overflow-y: auto`), a pole odpowiedzi razem z mikrofonem jest dosuniete do
   dolnej krawedzi karty przez `margin-top: auto`. Bez tego dluga tresc pytania
   spycha mikrofon coraz nizej, az wyjezdza poza kadr -- czyli jedyny przycisk,
   ktorym uczen odpowiada, znika przy najdluzszych pytaniach, a wiec dokladnie
   tam, gdzie jest najbardziej potrzebny.

   Kolejnosc w kodzie sie NIE zmienia: sterowanie nadal stoi po tresci, wiec
   czytnik ekranu i klawiatura ida ta sama droga co dotad. */
/* PODLOGA, nie zero. Pierwsza wersja tej poprawki dawala `min-height: 0` i przy
   dlugim poleceniu tresc pytania kurczyla sie do PASKA -- zgloszenie Darka
   2026-09-10: "the extra instructions are not visible". Polecenie stoi w
   `#taskInstructions`, czyli POZA `.q-open`, wiec nie podlega limitowi wysokosci
   i to ono zjadalo karte, a jedynym elementem, ktory mogl ustapic, byla wlasnie
   tresc pytania. Kolejnosc ustepowania jest teraz jawna: najpierw kurczy sie pole
   odpowiedzi (do 3.5em, dalej sie w nim pisze), a tresc pytania nie schodzi ponizej
   5.5em i dopiero wtedy przewija sie u siebie. */
/* Wolne miejsce dostaje POLE ODPOWIEDZI, nie tresc pytania. Do 2026-09-11 bylo
   odwrotnie (`flex: 1 1 auto` na `.q-text`) i to jest cala pustka, ktora Darek
   zglosil jako "open-ended - so much space wasted": zmierzone na rusztowaniu
   (1878x913, tryb skupienia), jednolinijkowe pytanie "Nagraj swoja odpowiedz --
   maksymalnie 4 minuty" zajmowalo **213 px**, czyli ~150 px czystego tla, i o tyle
   samo spychalo w dol pole, mikrofon i przycisk przykladowej odpowiedzi. Wczesniej
   ten sam mechanizm wypychal mikrofon poza kadr.

   `0 1 auto` (a nie `0 0 auto`): dlugie pytanie nadal moze ustapic, bo ma wlasne
   `overflow-y: auto` i wygaszenie ostatniej linii -- ustepuje wiec tekst, ktory da
   sie doczytac przewijaniem, a nie sterowanie, ktorego nie da sie odzyskac wcale.

   ALE USTEPOWANIE MA PODLOGE, I TO JEST TRZECIE PODEJSCIE DO TEJ SAMEJ PODLOGI.
   `min-height: 0` (wersja z 2026-09-11) przeczy komentarzowi stojacemu dwa akapity
   wyzej, ktory od 2026-09-10 obiecuje, ze "tresc pytania nie schodzi ponizej 5.5em" --
   obietnica zostala w pliku, a regula ja zdjela. Regula z linii ~4188 daje 3.2em,
   ale przegrywa kolejnoscia. Skutek zglosil Darek 2026-09-21 zrzutem: po pojawieniu
   sie oceny tresc pytania jest przycieta w polowie liter.

   Zmierzone na `docs/tools/fixtures/q_open_scaffold.html` (karta 334 px, ten sam
   ksztalt co na zrzucie): bez oceny pytanie dostaje 116 px (4,33 wiersza), a po
   pojawieniu sie oceny **44 px, czyli 1,64 wiersza** przy 124 px tresci. Ocena
   (`flex: 0 0 auto`) i przycisk przykladu nie ustepuja, a pole odpowiedzi ma wlasna
   podloge -- wiec CALY nadmiar spada na jedno dziecko, i to akurat na te tresc,
   ktora uczen ma przed soba czytac.

   Podloga to TRZY WIERSZE, nie 5.5em: ma gwarantowac czytelny kadr, a nie cala
   tresc. Dluzsze pytanie nadal ustepuje i doczytuje sie przewijaniem. `lh` jest
   dokladne, `em` to wartosc awaryjna dla przegladarki bez `lh` (przy line-height
   1.45 trzy wiersze to ok. 4.4em). Nadmiar, ktory z podlogi powstaje, przewija
   `.task-body` -- a zmierzone `przewija_taskBody` bylo prawda JUZ przed ta zmiana,
   wiec podloga nie wprowadza przewijania, ktorego by tu nie bylo. */
.q-open > .q-text { flex: 0 1 auto; min-height: 4.4em; min-height: 3lh; }

/* PO WYSTAWIENIU OCENY TRESC PYTANIA NIE KURCZY SIE WCALE.
   Podloga trzech wierszy wyzej zdejmuje przyciecie w polowie liter, ale pytanie
   dluzsze niz trzy wiersze nadal jest wtedy obciete -- a Darek zglosil 2026-09-21
   wlasnie to, ze po ocenie nie widac pytania, do ktorego ocena sie odnosi.

   W tej chwili zadanie ucznia zmienia sie z "wpisz" na "przeczytaj": ma zestawic
   swoja odpowiedz, korekte i polecenie. Kurczenie czegokolwiek z tej trojki jest
   wiec praca przeciwko temu, co uczen wlasnie robi. Karta i tak sie juz wtedy
   przewija -- zmierzone na rusztowaniu PRZED ta zmiana: `przewija_taskBody` bylo
   `true`, gdy tylko pojawila sie ocena -- wiec nie zamieniamy "wszystko w kadrze"
   na "trzeba przewijac", tylko wydluzamy przewijanie, ktore juz istnialo. Do pola
   odpowiedzi uczen dociera ta sama droga co do oceny.

   `:has()` nie ma ok. 1% przegladarek (patrz notka o podlodze platformy w CLAUDE.md)
   -- tam zostaje podloga trzech wierszy, czyli stan sprzed tej zmiany. Degradacja,
   nie awaria. */
.q-open:has(> .oe-feedback.ocena-widoczna) > .q-text { flex: 0 0 auto; }
/* `.student-interaction-area` bylo `display: block`, wiec KURCZYLO SIE SAMO, a jego
   dzieci zostawaly w naturalnym rozmiarze i WYLEWALY SIE poza karte -- zmierzone
   2026-09-10 na rusztowaniu: pojemnik 136 px przy 220 px tresci, mikrofon 50 px pod
   dolna krawedzia karty i 15 px pod paskiem akcji. Kolumna flex sprawia, ze ustepuje
   to, co ma ustapic: pole odpowiedzi schodzi do swojej podlogi 3.5em (dalej sie w nim
   pisze), a wiersz z mikrofonem zostaje w karcie. Zmierzone po zmianie: pole 74 px
   zamiast 150 px, mikrofon nad paskiem akcji. */
.q-open > .student-interaction-area {
    flex: 1 1 auto;
    /* PODLOGA rowna temu, czego naprawde potrzebuja dzieci: pole 3.5em + przycisk
       46 px + odstepy. Przy `min-height: 0` flex kurczyl ten pojemnik PONIZEJ jego
       wlasnej tresci, a poniewaz ma `overflow: visible`, wiersz mikrofonu wychodzil
       na zewnatrz i NACHODZIL na przycisk przykladowej odpowiedzi. Zmierzone
       2026-09-10: pojemnik 71 px przy 117 px tresci. */
    min-height: calc(3.5em + 46px + 1.4rem);
    display: flex; flex-direction: column;
}
.q-open .open-input { flex: 1 1 auto; min-height: 3.5em; }
.q-open .open-ended-controls { flex: 0 0 auto; }

/* Ocena AI dopisuje sie POD sterowaniem, wiec przy karcie wypelnionej po brzegi
   konczyla pod dolna krawedzia kadru: Darek "can't read the ai's feedback, can't
   scroll down to it, it should scroll by itself to center it".

   Samo `block: 'center'` tego NIE zalatwia i jest to juz w tym pliku opisane przy
   przewijaniu narracji: srodek liczy sie wzgledem CALEGO scrollportu, razem z pasem,
   ktory zaslania staly pasek akcji -- wiec ocena wyladowalaby pod nim. `scroll-margin`
   wchodzi do pudelka, wzgledem ktorego liczy sie wyrownanie, wiec dolny margines
   wielkosci paska przesuwa ocene w GORE, do srodka pasma, ktore realnie widac.
   105 px to zmierzona wysokosc paska (91 px ukladu) plus oddech. */
.oe-feedback { scroll-margin-block-end: 105px; scroll-margin-block-start: 16px; }
/* Samo przewiniecie nie wystarczylo: ocena i tresc pytania siedza w jednym pudelku
   o ograniczonej wysokosci i KURCZYLY SIE OBIE. Zmierzone: ocena o tresci 139 px
   dostawala 55 px, czyli mniej niz polowe -- widac bylo naglowek i jedna linijke.
   Po sprawdzeniu odpowiedzi to ocena jest tym, co uczen ma przeczytac (tresc pytania
   ma juz za soba i ma wlasne przewijanie), wiec ocena NIE kurczy sie wcale, a limit
   45% pilnuje, ze przy bardzo dlugiej opinii nadal widac pytanie, do ktorego sie
   odnosi. */
/* Selektor MUSI byc `.q-open > .oe-feedback`, a nie samo `.oe-feedback`: dwie
   linie wyzej w tym pliku stoi juz `.q-open > .example-answer-reveal-area,
   .q-open > .oe-feedback { flex: 0 1 auto }`, czyli DWIE klasy z kombinatorem --
   pojedyncza klasa przegrywa z tym na specyficznosci i nie zmienia nic. Pierwsza
   wersja tej poprawki tak wlasnie przeszla bez zadnego skutku: `max-height` sie
   przyjelo (bo tamta regula go nie ustawia), a `flex` nie. */
/* `max-height: 45%` bylo tu bledem i zostalo zdjete 2026-09-10. Ograniczalo ocene
   do polowy karty, a poniewaz karta nie przewijala sie wcale, reszta dzieci i tak
   byla wypychana -- Darek: "after feedback is given it covers everything in its
   container". Dzis karta przewija sie (regula nizej), wiec ocena moze miec swoja
   naturalna wysokosc: nic nie znika, a `scroll-margin` + `block: center` same ja
   przynosza w kadr. */
.q-open > .oe-feedback { flex: 0 0 auto; }

/* Wiersz "Pokaz Przykladowa Odpowiedz" mial `flex: 0 1 auto` (regula ~4084), wiec
   przy ciasnej karcie kurczyl sie do 19 px z 46 -- napis byl przeciety w polowie
   wysokosci. Nie ma czego skracac w przycisku: albo jest, albo go nie ma. */
.q-open > .example-answer-reveal-area { flex: 0 0 auto; }

/* OSTATECZNOSC, nie pierwszy wybor. `--imm-zadanie-max` ogranicza karte, a jej
   dzieci maja `overflow: visible` -- wiec nadmiar byl UCINANY bez sladu zamiast
   sie przewijac. Kolejnosc jest taka: najpierw `--fit-scale` zmniejsza pismo
   (LP.fitTaskBody ma od 2026-09-10 karte w warunku nadmiaru), a dopiero gdy to
   nie wystarczy, karta sie przewija. Bez dolozenia karty do tamtego warunku samo
   `overflow-y: auto` bylo GORSZE niz nic: #taskBody przestawalo raportowac nadmiar,
   wiec zmniejszanie pisma nie odpalalo sie ani razu.

   `overscroll-behavior: contain` -- inaczej doscrollowanie karty do konca zabiera
   scenę i przewija cala wyprawe. */
.player-shell[data-immersive="1"] .q-open,
.player-shell[data-immersive="1"] .q-matching,
.player-shell[data-immersive="1"] .q-gapped {
    overflow-y: auto; overscroll-behavior: contain;
}

/* Ten sam gradient co przy `.qhead.is-clipped` i `.q-text.is-clipped`, tylko na
   panelu z definicjami / tekstem z lukami. Klase nadaje LP.oznaczPrzewijanie po
   pomiarze I przy kazdym przewinieciu, wiec gasnie, gdy czytelnik dojedzie do konca
   -- staly gradient wygaszalby ostatnia definicje takze wtedy, gdy nic nie zostalo. */
#chunkViewport.is-clipped {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2.2rem), transparent);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 2.2rem), transparent);
}

/* HIERARCHIA PISMA, gdy polecenie NIESIE TRESC (flaga z LP.markInstructionsWeight).
   Zmierzone przed zmiana na lekcji 9195: polecenie 14,96 px / #5b7a80, linia akcji
   21,25 px / #0d1b1e -- czyli scenariusz egzaminacyjny z czterema podpunktami byl
   drobnym szarym drukiem, a "nagraj swoja czesc" naglowkiem. Odwracamy: tresc
   dostaje rozmiar tekstu ciaglego i pelny kontrast, a linia akcji schodzi do roli
   krotkiej dyrektywy nad polem odpowiedzi.

   Zawezone do `[data-polecenie="rozbudowane"]`, wiec lekcje, w ktorych polecenie
   jest jednym zdaniem, wygladaja dokladnie jak dotad. */
.player-shell[data-polecenie="rozbudowane"] .task-instructions {
    font-size: 1rem; line-height: 1.55; color: var(--ink); margin-bottom: 1rem;
}
.player-shell[data-polecenie="rozbudowane"] .task-instructions ul {
    margin: .45rem 0 .6rem; padding-left: 1.35rem;
}
.player-shell[data-polecenie="rozbudowane"] .task-instructions li { margin: .18rem 0; }
/* GRAFIKA, KTORA JEST TRESCIA ZADANIA, NIE JEST MINIATURA.

   `.media-carousel-slide img` ma na stale `height: 96px` i jest to udokumentowana
   decyzja: w lekcji z czytanka ilustracja ma byc "glanceable", a nie zabierac
   wysokosc tekstowi. Dla zadania maturalnego 2 ta sama regula jest wprost sprzeczna
   z poleceniem "opisz ilustracje": zmierzone na 9195 -- plik 940x627 renderowal sie
   jako ~180x96 px w kontenerze szerokim na 930 px, czyli 3% wykorzystanej powierzchni.

   Zawezone do `speaking-exercise`, wiec ilustracje w cwiczeniach czytania zostaja
   miniaturami jak dotad. Chcac to poszerzyc, dopisz kolejny typ tutaj -- nie ruszaj
   reguly bazowej, bo ona obsluguje cala reszte platformy. */
/* Sam obrazek nie wystarczy: `.media-carousel` ma `overflow: hidden`, a pojemnik
   dostawal wysokosc z flexa. Zmierzone: karuzela 190 px przy zdjeciu 409 px, czyli
   219 px kadru bylo obciete -- w tym zdjeciu akurat cala akcja przy butach. */
#taskBody[data-ex-type="speaking-exercise"] .context-media-holder,
#taskBody[data-ex-type="speaking-exercise"] .context-media-holder .media-carousel,
#taskBody[data-ex-type="speaking-exercise"] .context-media-holder .media-carousel-slides,
#taskBody[data-ex-type="speaking-exercise"] .context-media-holder .media-carousel-slide {
    flex: 0 0 auto; height: auto; min-height: 0;
}
#taskBody[data-ex-type="speaking-exercise"] .context-media-holder .media-carousel-slide img,
#taskBody[data-ex-type="speaking-exercise"] .context-media-holder .media-carousel-slide video {
    /* `width: auto` + oba maksima, a NIE `width: 100%`: przy rozciagnietej na cala
       szerokosc ramce i skroconej wysokosci `object-fit` musialby dokladac biale pasy
       albo kadrowac -- zmierzone przy pierwszym podejsciu: pudelko 1014x512 przy pliku
       940x627, czyli zdjecie przycinane u dolu, gdzie w tym kadrze jest cala akcja.
       Element o wlasnych proporcjach nie ma tego problemu w ogole. */
    width: auto; height: auto;
    max-width: 100%; max-height: var(--imm-media-max, min(46vh, 420px));
    display: block; margin: 0 auto; object-fit: contain;
}

/* Rytm pionowy przy poleceniu niosacym tresc: mniej powietrza miedzy blokami znaczy
   mniej przewijania. Zmierzone na 9195: same odstepy miedzy poleceniem, linia akcji
   i polem odpowiedzi zajmowaly ok. 90 px. */
.player-shell[data-polecenie="rozbudowane"] .task-instructions { margin-bottom: .6rem; }
.player-shell[data-polecenie="rozbudowane"] .q-open > .q-text { margin: .1rem 0 .5rem; }

/* Limit liczy LP.fitImmersiveBoard: polecenie dostaje tyle, ile zostaje po
   zarezerwowaniu podlogi dla planszy. Wlasciwosc jest ustawiana TYLKO wtedy, gdy
   polecenie naprawde sie nie miesci -- inaczej `none` i zadnego przewijania nie ma. */
.player-shell[data-immersive="1"][data-polecenie="rozbudowane"] .task-instructions {
    max-height: var(--imm-polecenie-max, none);
    overflow-y: auto; overscroll-behavior: contain;
    /* `flex: 0 0 auto` jest tu KONIECZNE i nieoczywiste: element w kolumnie flex ma
       automatyczne minimum rowne tresci TYLKO przy `overflow: visible`. Samo dodanie
       `overflow-y: auto` zeruje to minimum, wiec polecenie zamiast przewijac sie
       w swoich 243 px zapadlo sie do 46 px -- zostawala pierwsza linia. Zmierzone
       2026-09-10 przy pierwszym podejsciu do tej poprawki. */
    flex: 0 0 auto;
}
.task-instructions.is-clipped {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.6rem), transparent);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 1.6rem), transparent);
}

/* Podloga tresci pytania obowiazuje TAKZE tutaj. Ta regula ma wyzsza specyficznosc,
   wiec `min-height: 0` zdejmowalo ja z powrotem dokladnie w tych lekcjach, w ktorych
   polecenie niesie tresc -- czyli tam, gdzie o miejsce jest najciasniej.
   Uzasadnienie podlogi: przy `.q-open > .q-text` wyzej w tym pliku. */
.player-shell[data-polecenie="rozbudowane"] .q-open > .q-text {
    font-size: .95rem; line-height: 1.45; font-weight: 600;
    min-height: 4.4em; min-height: 3lh;
}

/* ====================== DWIE KOLUMNY NA SZEROKIM EKRANIE =====================
   Flage `data-uklad` nadaje LP.fitImmersiveBoard (prog 1100 px UKLADU). Sa dwa
   ksztalty, bo material siedzi raz w `#taskInstructions`, a raz w `#taskBody`:

     material-polecenie -- zadania 1 i 3: polecenie z trescia po lewej, praca po prawej
     material-media     -- zadanie 2: grafika po lewej, pytanie i pole po prawej

   Nigdy oba naraz: przy grafice polecenie jest jednym zdaniem i zostaje nad
   kolumnami jako podpis.

   `minmax(0, ...)` w obu kolumnach jest KONIECZNE -- domyslne `auto` ma minimum
   rowne tresci, wiec dluzsze zdanie rozpychaloby kolumne zamiast sie zawinac. */
.player-shell[data-uklad="material-polecenie"] .task-card {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
    grid-template-rows: auto minmax(0, 1fr) auto;
    column-gap: 1.8rem;
    align-items: stretch;
}
.player-shell[data-uklad="material-polecenie"] .task-card > .task-head { grid-column: 1 / -1; grid-row: 1; }
.player-shell[data-uklad="material-polecenie"] .task-card > .task-instructions {
    grid-column: 1; grid-row: 2; margin-bottom: 0; min-height: 0; max-height: none;
    overflow-y: auto; overscroll-behavior: contain;
}
.player-shell[data-uklad="material-polecenie"] .task-card > .task-body { grid-column: 2; grid-row: 2; min-height: 0; }
.player-shell[data-uklad="material-polecenie"] .task-card > .task-feedback { grid-column: 1 / -1; grid-row: 3; }

.player-shell[data-uklad="material-media"] #taskBody {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    column-gap: 1.8rem;
    align-items: start;
}
/* Grafika ma w tej kolumnie tyle wysokosci, ile daje wiersz -- limit z
   `--imm-media-max` dotyczyl UKLADU PIONOWEGO, gdzie zabierala miejsce pracy. */
.player-shell[data-uklad="material-media"] #taskBody > .context-media-holder {
    grid-column: 1; margin-bottom: 0;
}
.player-shell[data-uklad="material-media"] #taskBody[data-ex-type="speaking-exercise"] .media-carousel-slide img,
.player-shell[data-uklad="material-media"] #taskBody[data-ex-type="speaking-exercise"] .media-carousel-slide video {
    /* 50vh, nie 62: przy 62 zdjecie mialo 552 px ukladu przy ~459 px wiersza,
       wiec scena i tak zaczynala sie przewijac -- czyli dwie kolumny nie usuwaly
       przewijania, tylko je zmniejszaly. Zmierzone 2026-09-10. */
    max-height: var(--imm-media-max, min(50vh, 440px));
}
.player-shell[data-uklad="material-media"] #taskBody > :not(.context-media-holder) { grid-column: 2; }

/* W dwoch kolumnach karta nie jest juz waskim paskiem posrodku ekranu. Scena ma
   na stale `max-width: 900px` -- sluszne dla jednej kolumny tekstu, ale to wlasnie
   ono zostawialo na ekranie 1504 px ukladu ~600 px czystego tla. */
.player-shell[data-uklad] .stage { max-width: 1480px; }
/* `!important` swiadomie: karta ma gdzies w tym arkuszu wlasne `max-width` (685 px
   ukladu), ktorego nie znajduje ani grep, ani CSSOM -- zmierzone tak, ze dopiero
   inline `max-width: none !important` poszerzylo ja z 1071 na 1803 px. Zamiast
   zgadywac, ktora regula to jest, przebijam ja jawnie w JEDNYM, waskim trybie.
   1180 px ukladu to dwie kolumny po ~560 px -- szerzej tekst czyta sie gorzej,
   wiec nie oddajemy calej sceny. */
.player-shell[data-uklad] .task-card {
    max-width: 1180px !important;
    padding-left: 1.8rem; padding-right: 1.8rem;
}
/* Glowa jest `position: absolute`, wiec jej wiersz w gridzie liczyl sie na 35 px
   przy realnych 44 -- licznik "Pytanie 1 z 2" wchodzil na pierwsza linie polecenia
   o 12 px. Odstep wierszy zdejmuje to bez zgadywania wysokosci glowy. */
.player-shell[data-uklad="material-polecenie"] .task-card { row-gap: 1rem; }

/* `.task-head` jest `position: absolute`, wiec w gridzie jego wiersz mial zerowa
   wysokosc i licznik "Pytanie 1 z 2" NACHODZIL na pierwsza linie polecenia
   (zmierzone: glowa 189..233, polecenie od 177). W dwoch kolumnach wraca do biegu",
   czyli zajmuje swoj wiersz. */
/* `top`/`left`/`right` sa czescia pozycjonowania bezwzglednego; przy `relative`
   przesuwaly glowe o 12,8 px w dol i do srodka wlasnego wiersza, czyli dokladaly
   pusty pas nad trescia. W biegu glowa ma stac na krawedzi swojego wiersza. */
.player-shell[data-uklad] .task-card > .task-head { position: relative; top: 0; left: 0; right: 0; }
.player-shell[data-uklad] .task-card { padding-left: 1.8rem; padding-right: 1.8rem; }

/* ---- Stronicowanie slajdu narracji ----------------------------------------
   Pelne uzasadnienie przy `STRONICOWANIE SLAJDU NARRACJI` w
   lesson-player-v3-features.js. Tutaj tylko to, co wynika z ukladu:

   `display: block` na kwestii jest tym, co zamienia sciane tekstu w AKAPITY --
   granica kwestii istniala w DOM-ie od zawsze, ale jako `<br>` w jednym ciagu.
   Klasa `lp-poza-strona` chowa akapity spoza biezacej strony; to ten sam wzorzec
   co `.reading-chunk` na kroku czytania, gdzie schowanie jest stanem domyslnym.

   WYROWNANIE DO LEWEJ WLACZA SIE DOPIERO PRZY STRONICOWANIU. Krotki slajd ma byc
   wysrodkowany (decyzja z 2026-09-06: prezentacja idzie na ekran w klasie), ale
   kilkanascie kwestii dialogu wysrodkowanych to tekst, ktorego nie da sie czytac
   -- kazdy wiersz zaczyna sie gdzie indziej. Atrybut stawia JS wylacznie wtedy,
   gdy stron naprawde jest wiecej niz jedna. */
.step-goal--slide .goal-text[data-eh-kwestie="1"] .lp-kwestia { display: block; }
/* STRONICOWANY SLAJD ZACZYNA SIE OD GORY. `margin-block: auto` centruje slajd
   w pionie i dla krotkiego slajdu jest wlasciwe (decyzja 2026-09-06: prezentacja
   idzie na ekran w klasie) -- ale przy stronicowaniu domyka petle sprzezenia:
   im mniej tresci na stronie, tym nizej zaczyna sie tekst, tym mniejszy budzet
   pionowy, tym mniej tresci. Zmierzone: 7 -> 18 stron przy niezmienionym oknie.
   Przy okazji tekst przestaje skakac w pionie przy kazdym przewroceniu. */
.step-goal--slide[data-eh-strony="1"] { margin-block: 0; justify-content: flex-start; }
.step-goal--slide[data-eh-strony="1"] .goal-text { text-align: left; }
.step-goal--slide[data-eh-strony="1"] .lp-kwestia + .lp-kwestia { margin-top: .75em; }
/* CHOWAMY ZDANIA, NIE TYLKO AKAPITY -- i to jest cala roznica miedzy strona,
   ktora sie miesci, a strona, ktora tylko TAK LICZY. Granica strony przechodzi
   miedzy zdaniami, wiec akapit bywa pokazany w polowie; regula na samym akapicie
   zostawiala na ekranie caly blok. Zmierzone na 11058 po pierwszym wdrozeniu:
   pole przewijania 501 px, strona liczona na 424 px, a `scrollHeight` nadal 2917. */
.step-goal--slide .lp-zdanie.lp-poza-strona { display: none; }
.step-goal--slide .lp-kwestia.lp-poza-strona { display: none; }

/* `hidden` musi wygrac z `display: flex` -- inaczej pasek zostaje na ekranie
   przy slajdzie, ktory stron nie ma. */
.lp-strony[hidden] { display: none !important; }
.lp-strony {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .6rem;
  margin-top: 1rem;
  font-size: .82rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.lp-strony-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  padding: 0;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: transparent;
  color: inherit;
  cursor: pointer;
  font-size: 1rem;
  line-height: 1;
}
.lp-strony-btn:disabled { opacity: .35; cursor: default; }


/* Popraw wymowe fragmentu narracji (initNarrationEdit) */
.narr-overlay { position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; padding: 16px; }
.narr-card { background: var(--surface, #fff); color: var(--ink, #1f2937); border: 1px solid var(--line, #e5e7eb); border-radius: 14px; width: min(640px, 100%); max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 20px 50px rgba(0,0,0,.25); }
.narr-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--line, #e5e7eb); }
.narr-x { background: none; border: 0; font-size: 1.6rem; line-height: 1; color: inherit; cursor: pointer; }
.narr-body { padding: 14px 18px; overflow-y: auto; }
.narr-hint { margin: 0 0 12px; font-size: .9rem; opacity: .8; }
.narr-row { display: flex; align-items: center; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--line, #e5e7eb); }
.narr-row.is-edited .narr-txt { font-style: italic; }
.narr-txt { flex: 1; font-size: .95rem; }
.narr-play, .narr-fix { flex-shrink: 0; width: 34px; height: 34px; border-radius: 50%; border: 1px solid var(--line, #e5e7eb); background: transparent; color: var(--accent, #4f46e5); cursor: pointer; }
.narr-edit { width: 100%; box-sizing: border-box; font: inherit; padding: 10px; border-radius: 10px; border: 1px solid var(--line, #e5e7eb); background: transparent; color: inherit; }
.narr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.narr-actions button, .narr-undo { padding: 8px 14px; border-radius: 10px; border: 1px solid var(--line, #e5e7eb); background: transparent; color: inherit; cursor: pointer; font: inherit; }
.narr-apply { background: var(--accent, #4f46e5) !important; color: #fff !important; border-color: transparent !important; }
.narr-apply:disabled { opacity: .45; cursor: default; }
.narr-audio { width: 100%; }
.narr-undo { margin-top: 12px; }
