/* libli app shell + primitive components. */

/* --- Scrollbars: thin + token-coloured, app-wide (every scrollable element) ---
   Firefox/standard via scrollbar-width/-color; Chromium/WebKit via the pseudo-
   elements. Muted --border-strong thumb at rest, brand --primary on hover; the
   transparent border + padding-box clip slims the thumb and rounds it. */
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
  background: var(--border-strong); border-radius: 999px;
  border: 2px solid transparent; background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--primary); }
*::-webkit-scrollbar-corner { background: transparent; }

/* Hidden icon sprite (shared partial, included on builder/editor/media pages).
   Must stay OUT of normal flow: its host (e.g. .builder) is a CSS grid, and an
   in-flow 0x0 <svg> would still consume a grid cell and break the columns. */
.icon-sprite { position: absolute; width: 0; height: 0; }

.app-header {
  position: relative;  /* anchors the mobile nav dropdown */
  display: flex; align-items: center; gap: var(--space-4);
  padding: var(--space-3) var(--space-5);
  background: var(--surface-raised); border-bottom: 1px solid var(--border-default);
}
.brand { display: inline-flex; align-items: baseline; gap: 2px; font-weight: 700;
  font-size: 1.25rem; color: var(--text-primary); text-decoration: none; }
.brand__dot { color: var(--accent); }
.brand__logo { height: 28px; width: auto; }
.app-header__spacer { flex: 1; }
.app-header__cluster { display: flex; align-items: center; gap: var(--space-3); }
.app-main { max-width: 960px; margin: 0 auto; padding: var(--space-8) var(--space-5); }

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-4); border-radius: var(--radius-sm);
  border: 1px solid transparent; background: var(--primary); color: var(--text-inverse);
  font-weight: 500; cursor: pointer; text-decoration: none;
}
.btn[hidden] { display: none; }  /* the [hidden] attr must beat .btn's display */
.btn:hover { background: var(--primary-hover); }
.btn:active { background: var(--primary-active); }
.btn--ghost { background: transparent; color: var(--text-primary);
  border-color: var(--border-strong); }
.btn--ghost:hover { background: var(--surface-sunken); }
.btn--icon { padding: var(--space-2); border-radius: var(--radius-full);
  background: transparent; color: var(--text-secondary); border-color: var(--border-default); }
.btn--small { padding: var(--space-1) var(--space-3); font-size: .875rem; font-weight: 500; }
/* Explicit primary modifier. `.btn` is already primary by default, so this is an
   intentional alias for the many templates that spell out `btn btn--primary`. */
.btn--primary { background: var(--primary); color: var(--text-inverse); border-color: transparent; }

/* Destructive action. Equal specificity to .btn, so it sits AFTER the base rule;
   hover/active also set the danger background or .btn:hover/.btn:active (0,2,0)
   would repaint it primary-blue. --danger token: light #A8392E / dark #E57373. */
.btn--danger { background: var(--danger); color: #fff; border-color: transparent; }
.btn--danger:hover, .btn--danger:active { background: var(--danger); filter: brightness(0.92); }
/* A disabled button must look disabled -- nothing did, so a locked quiz Check read as
   live. The gallery / tabs arrows' look. pointer-events: none also keeps every
   variant's :hover / :active repaint off it. */
.btn:disabled { opacity: .45; pointer-events: none; cursor: default; }

/* Action-button row for forms/confirm pages. Global (was previously defined only in
   accounts/people.css, leaving course_form + the transfer templates unstyled). */
.form__actions {
  display: flex;
  gap: var(--space-3);
  margin-top: var(--space-4);
  flex-wrap: wrap;
}

/* Export pre-flight reports: BOTH lists on export_preview.html -- missing media, and
   counts over this instance's transfer caps. Advisory lists (not errors), so each row
   carries a quiet warning left-accent rather than alarm colour: neither blocks the
   export, and a cap breach may not even be a breach on the instance being imported
   into. Sharing one treatment is deliberate -- two differently-styled advisory lists
   stacked on one page would imply a severity difference that does not exist. */
.export-preflight h2 {
  /* reset.css sets `* { margin: 0 }`, so without this the section titles butt
     straight against the h1 and against the list above them. Mirrors the
     `.public-page h2` precedent rather than styling `.manage h2`, which every
     manage page would inherit. */
  margin: var(--space-6) 0 var(--space-2);
}
.export-missing,
.export-limits {
  list-style: none;
  /* reset.css zeroes margin but NOT padding, so a `ul` keeps its UA
     padding-inline-start (~40px). Inside this bordered card that renders as an
     empty gutter with the warning accent floating inset instead of flush to the
     card edge. */
  padding: 0;
  margin: var(--space-4) 0 var(--space-6);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--surface-raised);
}
.export-missing li,
.export-limits li {
  padding: var(--space-3) var(--space-4);
  border-left: 3px solid var(--warning);
  line-height: 1.5;
}
.export-missing li + li,
.export-limits li + li { border-top: 1px solid var(--border-subtle); }
/* The env var name. `code` inherits the page font stack otherwise, which makes an
   ALL-CAPS underscore identifier read as prose mid-sentence. */
.export-limits code {
  font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
  font-size: 0.9em;
  padding: 0.1em 0.35em;
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--text-secondary);
  white-space: nowrap;
}

/* Import upload drop target. Progressive enhancement — the native file input inside
   stays fully usable (and required) without JS; import_dropzone.js adds drag/drop. */
.dropzone {
  margin-top: var(--space-2);
  padding: var(--space-5);
  border: 2px dashed var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface-sunken);
  text-align: center;
  transition: border-color .15s ease, background .15s ease;
}
.dropzone.is-over { border-color: var(--primary); background: var(--primary-subtle); }
.dropzone input[type="file"] { display: block; margin: 0 auto var(--space-2); }
.dropzone__hint { margin: 0; color: var(--text-secondary); font-size: .875rem; }
.dropzone__file { margin: var(--space-2) 0 0; font-weight: 500; color: var(--text-primary); }
.dropzone__file:empty { display: none; }
@media (prefers-reduced-motion: reduce) { .dropzone { transition: none; } }

/* Inline icon convention: ALL UI icons are single-colour line SVGs that inherit the
   text colour via currentColor (never multicolour emoji). Sized to the surrounding
   text (1em) so they match weight/size wherever they sit. viewBox="0 0 24 24". */
.icon {
  width: 1em; height: 1em; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.8;
  stroke-linecap: round; stroke-linejoin: round;
  vertical-align: -0.125em;
}

.badge {
  display: inline-block;
  padding: 0 var(--space-2);
  border-radius: var(--radius-full);
  background: var(--surface-sunken);
  color: var(--text-secondary);
  border: 1px solid var(--border-default);
  font-size: .75rem;
  font-weight: 600;
  line-height: 1.6;
  vertical-align: middle;
}
.badge--open {
  background: var(--primary-subtle);
  color: var(--primary);
  border-color: color-mix(in srgb, var(--primary) 40%, var(--border-default));
}

/* Unit kind markers (spec 2026-08-12). The flex item of a rail row is the
   .unit-kind WRAPPER, not the <svg class="icon"> inside it — .icon { flex: none }
   governs .icon only when .icon is itself a flex item. inline-flex + gap are what
   this rule genuinely buys: they space glyph from word where the word is visible
   (the drawer). Deleting the whole rule does NOT narrow the glyph — the wrapper's
   automatic minimum is its 1em min-content either way — so there is no
   glyph-width mutant for it.
   .unit-kind-chip's declarations are inert forward-defence for a future
   multi-word translation: both shipping labels are single unbreakable words, so
   a flex: 0 1 auto chip's shrink target is a min violation and it is frozen at
   its full width regardless. Neither carries a mutant. */
.unit-kind { display: inline-flex; align-items: center; gap: var(--space-1); flex: none; }
.unit-kind-chip { flex: none; white-space: nowrap; }

form p, .form-row { margin-bottom: var(--space-4); }
label { display: block; font-weight: 500; margin-bottom: var(--space-1);
  color: var(--text-secondary); }
input[type=text], input[type=email], input[type=password], input[type=url], select, textarea {
  width: 100%; padding: var(--space-2) var(--space-3);
  background: var(--surface-sunken); color: var(--text-primary);
  /* border-strong (not -default): a form control needs a rim that reads against
     BOTH its own sunken fill (#15130F) and the card behind it (#2C2925), which the
     dark ramp's middle rung is not cut for. Since the ramp was re-cut against
     --surface-raised, -default would now clear the card (1.74:1) — -strong is kept
     because it also separates the field from its own fill and keeps controls a step
     above ordinary dividers in both themes. */
  border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
}
/* Textareas fill their column (width:100% above) and may only grow VERTICALLY —
   never wider than their container. The browser default `resize: both` would let
   a full-width textarea (e.g. the extended-response answer box) be dragged past
   the column and break the layout. Components with their own resize handling
   (.code-field) override this at higher specificity. */
textarea { resize: vertical; }
/* Review page: the student's answer is shown read-only (not the live question
   widget). A quiet eyebrow label + a quoted block that preserves the student's
   line breaks. */
.review__answer-label { margin: var(--space-3) 0 var(--space-1); font-size: .72rem;
  font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-tertiary); }
.review__answer { padding: var(--space-2) var(--space-3); background: var(--surface-sunken);
  border-left: 3px solid var(--border-strong); border-radius: var(--radius-sm);
  white-space: pre-wrap; }
.review__answer--empty { border-left-color: var(--border-default); color: var(--text-tertiary);
  font-style: italic; white-space: normal; }

/* Number inputs (e.g. the review "marks awarded" field) are sized to content,
   not stretched to full width — but still themed so they aren't white in dark mode. */
input[type=number] {
  padding: var(--space-2) var(--space-3);
  background: var(--surface-sunken); color: var(--text-primary);
  border: 1px solid var(--border-strong); border-radius: var(--radius-sm);  /* see text-input note above */
}
.errorlist { list-style: none; padding: 0; margin: var(--space-1) 0 0;
  color: var(--danger); font-size: .875rem; }
.helptext { color: var(--text-tertiary); font-size: .875rem; }

.checkbox-list {
  max-height: 16rem; overflow-y: auto;
  border: 1px solid var(--border-default); border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-3); background: var(--surface-sunken);
}
.checkbox-list ul { list-style: none; margin: 0; padding: 0; }
.checkbox-list label, .checkbox-list li { display: flex; align-items: center; gap: var(--space-2);
  font-weight: 400; color: var(--text-primary); padding: var(--space-1) 0; margin: 0; }
.checkbox-list input[type=checkbox] { width: auto; margin: 0; }
/* author display:flex above beats the [hidden] UA rule — re-assert it for the
   roster filter, which hides non-matching items via the hidden attribute (labels
   for the student picker, <li> rows for the teacher CheckboxSelectMultiple). */
.checkbox-list label[hidden], .checkbox-list li[hidden] { display: none; }

/* Live "Added: N" count shown beside each picker's legend. */
.roster > legend { width: 100%; }
.roster__selected { font-weight: 400; color: var(--text-tertiary); font-size: .875rem; }

/* Roster picker filter bar (cohort dropdown + name search). */
.roster-filter { display: flex; flex-wrap: wrap; align-items: flex-end;
  gap: var(--space-2) var(--space-3); margin-bottom: var(--space-2); }
.roster-filter__field { display: flex; flex-direction: column; gap: var(--space-1);
  font-weight: 500; color: var(--text-secondary); font-size: .875rem; }
.roster-filter__field select, .roster-filter__field input { width: auto; }
.roster-filter__count { margin: 0 0 0 auto; color: var(--text-tertiary);
  font-size: .875rem; align-self: center; }
/* The add-all control. Its own class, NOT .roster-filter__field: that class's
   author display:flex would outrank the UA [hidden] rule the same way
   .checkbox-list label's does above, so with JS off the control would render
   visible and post nothing. Re-assert [hidden] explicitly, same pattern. */
.roster-filter__all { display: flex; align-items: center; gap: var(--space-1);
  font-weight: 500; color: var(--text-secondary); font-size: .875rem; }
.roster-filter__all[hidden] { display: none; }
@media (max-width: 640px) {
  .roster-filter__field { width: 100%; }
  .roster-filter__field select, .roster-filter__field input { width: 100%; }
  .roster-filter__count { margin-left: 0; }
}

.card {
  max-width: 28rem; margin: var(--space-8) auto; padding: var(--space-6);
  background: var(--surface-raised); border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);
}

.alert { padding: var(--space-3) var(--space-4); border-radius: var(--radius-sm);
  border: 1px solid var(--border-default); margin-bottom: var(--space-3); }
.alert--success { background: var(--success-subtle); border-color: var(--success); }
.alert--warning { background: var(--warning-subtle); border-color: var(--warning); }
.alert--error, .alert--danger { background: var(--danger-subtle); border-color: var(--danger); }
.alert--info { background: var(--primary-subtle); border-color: var(--primary); }

.avatar { display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; border-radius: var(--radius-full);
  background: var(--primary-subtle); color: var(--primary-active); font-weight: 600;
  font-size: .8rem; }

.menu { position: relative; }
.menu__panel {
  position: absolute; right: 0; top: calc(100% + 6px); min-width: 11rem;
  /* z-index lifts the dropdown above page content — without it, positioned content
     (e.g. the builder rows) paints over the panel AND intercepts its clicks, so
     "Log out" looks see-through and can't be tapped. Matches .app-nav's z-index:40. */
  z-index: 50;
  background: var(--surface-raised); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-md); padding: var(--space-2);
}
.menu__panel[hidden] { display: none; }
.menu__item { display: block; width: 100%; text-align: left; padding: var(--space-2) var(--space-3);
  background: transparent; border: 0; border-radius: var(--radius-sm); color: var(--text-primary);
  text-decoration: none; cursor: pointer; }
.menu__item[hidden] { display: none; }  /* .menu__item's display:block beats the UA [hidden] rule */
.menu__item:hover { background: var(--surface-sunken); }

.lang-switch { display: inline-flex; gap: var(--space-1); }
.lang-switch button { background: transparent; border: 0; padding: 2px 6px;
  border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer; }
.lang-switch button[aria-current="true"] { color: var(--primary); font-weight: 600; }

@media (max-width: 640px) {
  .app-header { flex-wrap: wrap; gap: var(--space-2); }
  .app-main { padding: var(--space-5) var(--space-4); }
}

/* --- Landing page --- */
.landing-hero {
  display: grid; gap: var(--space-8);
  max-width: 640px; margin: var(--space-10) auto;
  text-align: center;
}
.landing-hero h1 {
  font-size: clamp(1.75rem, 4vw, 2.5rem);
  font-weight: 700; letter-spacing: var(--heading-letter-spacing);
  color: var(--text-primary); margin: 0;
}
.eyebrow {
  font-size: .75rem; font-weight: 600; letter-spacing: .08em;
  text-transform: uppercase; color: var(--accent);
}
.lead {
  font-size: 1.0625rem; color: var(--text-secondary); margin: 0;
}
.cta {
  display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3);
}
.landing-visual {
  display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-4);
}
.landing-footer {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-4);
  padding: var(--space-4) var(--space-5);
  border-top: 1px solid var(--border-subtle);
  color: var(--text-tertiary); font-size: .875rem;
}

@media (max-width: 640px) {
  .landing-hero { margin: var(--space-8) auto; }
  .cta { flex-direction: column; align-items: center; }
}

/* Primary top-bar nav (authenticated). Inline on desktop; on mobile it collapses
   behind a hamburger into a dropdown panel anchored to the header (see ≤640px). */
.app-nav { display: inline-flex; align-items: center; gap: var(--space-2); }
.app-nav__link {
  padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm);
  color: var(--text-primary); text-decoration: none; font-weight: 500;
}
.app-nav__link:hover { background: var(--surface-sunken); }
.app-nav__toggle { display: none; font-size: 1.1rem; line-height: 1; }

/* Admin dropdown: a <button> trigger styled like a nav link, with a caret. */
.app-nav__admin-trigger {
  background: transparent; border: 0; cursor: pointer; font: inherit;
  display: inline-flex; align-items: center;
}
.app-nav__caret { font-size: .7em; margin-left: .3rem; opacity: .85; }
.menu__sep {
  display: block; height: 1px; margin: var(--space-2) calc(-1 * var(--space-2));
  background: var(--border-subtle);
}

@media (max-width: 640px) {
  .app-nav__toggle { display: inline-flex; }
  .app-nav {
    position: absolute; top: calc(100% + 6px); right: var(--space-4); z-index: 40;
    display: none; flex-direction: column; align-items: stretch; min-width: 12rem;
    padding: var(--space-2); background: var(--surface-raised);
    border: 1px solid var(--border-default); border-radius: var(--radius-md);
    box-shadow: var(--shadow-md);
  }
  .app-nav[data-open] { display: flex; }
  /* Flatten the Admin dropdown inline into the hamburger panel: dissolve the
     wrapper, hide its trigger, and render its items as a static stacked list. */
  .app-nav__admin { display: contents; }
  .app-nav__admin > [data-menu-trigger] { display: none; }
  .app-nav__admin .menu__panel,
  .app-nav__admin .menu__panel[hidden] {
    position: static; display: flex; flex-direction: column; min-width: 0;
    background: none; border: 0; box-shadow: none; padding: 0; z-index: auto;
  }
}

/* Dashboard course list */
.dash-list { list-style: none; padding: 0; margin: 0; }
.dash-list li { margin: var(--space-2) 0; }
.dash-list a { color: var(--accent); text-decoration: none; font-weight: 500; }
.dash-list a:hover { text-decoration: underline; }
/* A row pairing a primary target link with a secondary action (Teaching:
   course title -> Analytics). Two visual tiers so the action does not read as a
   second peer link. `.dash-list .dash-row__action` deliberately outranks
   `.dash-list a` so the muted colour wins -- a bare `.subtle-link` loses to it. */
.dash-list li.dash-row { display: flex; align-items: baseline;
  justify-content: space-between; gap: var(--space-3); }
.dash-list .dash-row__action { flex: none; color: var(--text-secondary);
  font-weight: 500; font-size: .85rem; }
.dash-list .dash-row__action:hover,
.dash-list .dash-row__action:focus-visible { color: var(--accent); text-decoration: underline; }
/* The title flexes and must be allowed to shrink below its content width, else
   an unbreakable title overflows the flex row at phone width (same recipe as
   `.outline-unit__title`). */
.dash-list .dash-row__title { min-width: 0; overflow-wrap: anywhere; }
/* "Last edited" card (Studio): the whole card is the link via the
   `.dash-recent__link::after` overlay, so no `overflow: hidden` on `.dash-recent`
   (it would clip the focus ring). Focus is drawn on the card, not the inner link,
   and only for keyboard focus: `:focus-within` would also match the focus a tap
   gives the link, leaving the ring on after Back. */
.dash-recent {
  position: relative;
  margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4);
  background: var(--surface-raised);
  border: 1px solid var(--border-subtle); border-radius: var(--radius-md);
}
.dash-recent__label { margin: 0 0 var(--space-1); font-size: .8125rem;
  font-weight: 600; color: var(--text-secondary); }
.dash-recent__link { color: inherit; text-decoration: none; }
.dash-recent__link::after { content: ""; position: absolute; inset: 0; }
.dash-recent__link:focus-visible { outline: none; }
.dash-recent:has(.dash-recent__link:focus-visible) { outline: 2px solid var(--primary); outline-offset: 2px; }
.dash-recent:hover .dash-recent__unit { text-decoration: underline; }
.dash-recent__unit { display: block; min-width: 0; overflow-wrap: anywhere;
  font-weight: 600; color: var(--text-primary); }
.dash-recent__course { display: block; min-width: 0; overflow-wrap: anywhere;
  font-size: .875rem; color: var(--text-secondary); }
/* Panel footer actions (Studio): a primary CTA + a quiet link, gap-separated
   instead of whitespace-jammed. */
.dash-actions { display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--space-2) var(--space-4); margin-top: var(--space-4); }

/* Grouping list rows */
.card-list { list-style: none; margin: var(--space-4) 0 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--space-2); }
.card-list li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-3);
  padding: var(--space-3) var(--space-4); background: var(--surface-raised);
  border: 1px solid var(--border-default); border-radius: var(--radius-sm); }
.card-list li > a:first-child, .card-list li > span:first-child { font-weight: 500; }
.row-actions { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.row-actions form { display: inline; margin: 0; }
@media (max-width: 640px) {
  .card-list li { flex-direction: column; align-items: stretch; }
  .row-actions { margin-left: 0; width: 100%; }
}

/* Shared utility text states (batch 5). .empty-state was referenced by builder,
   editor, preview and media templates but never defined; .muted is relocated here
   from editor.css (the only prior definition) to always-loaded app.css. NOTE the
   blast radius: .muted is used by 11 templates, and SIX outside the authoring scope
   render it unstyled today because they don't load editor.css — the student
   quiz_results.html + course_results.html (load courses.css only) and the four
   grouping pages group_list/my_groups/group_detail/collection_detail (load app.css
   only). This relocation intentionally fixes that latent gap; the tertiary/.85rem
   muted look is the desired appearance on all of them (verified in Step 4). */
.empty-state { color: var(--text-tertiary); font-style: italic; padding: var(--space-4) 0; margin: 0; }
.muted { color: var(--text-tertiary); font-size: .85rem; }

/* Navigable context: a link sitting in a muted line (e.g. the course title on the
   grouping pages). Inherits its parent's tertiary colour so it stays in the muted
   tier rather than competing with the accent-coloured action links, and earns its
   affordance on hover -- mirroring .dash-card__title one tier down. `color: inherit`
   overrides reset.css's `a { color: var(--accent) }`; the class also out-specifies
   it, so it must not be dropped onto the parent element instead. */
.subtle-link { color: inherit; text-decoration: none; }
.subtle-link:hover,
.subtle-link:focus-visible { color: var(--text-secondary); text-decoration: underline; }

/* Grouping hub + detail pages (my_groups / group_detail / collection_detail):
   a section subhead (reset.css zeroes all heading margins). Rosters reuse the
   global .course-list hairline list; the owner is tagged with the shared .badge. */
.subhead { font-size: 1.05rem; font-weight: 600; color: var(--text-primary);
  margin: var(--space-8) 0 var(--space-2); }

/* ── Self-enrol catalog (Phase 3b) ─────────────────────────────────────────
   "The shelf": a calm scannable grid of course cards. Signature = a slim teal
   spine on each card's left edge (rebrands via --primary). Restraint elsewhere. */
.catalog { max-width: 64rem; margin: 0 auto; }
.catalog__title { margin-bottom: var(--space-5); }

/* filter toolbar */
.catalog__filters {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-3);
  padding: var(--space-3) var(--space-4); margin-bottom: var(--space-6);
  background: var(--surface-sunken);
  border: 1px solid var(--border-subtle); border-radius: var(--radius-md);
}
.catalog__field { display: flex; flex-direction: column; gap: var(--space-1); margin: 0; }
.catalog__field > span { font-size: .75rem; font-weight: 600; color: var(--text-tertiary); }
.catalog__field select, .catalog__field input { min-width: 11rem; }
/* Filter-bar search boxes. `type=search` is not in the global form-control rule
   above, so without this the field keeps the UA's white fill under dark mode's
   near-white text (same fix as .builder__filter in builder.css). Scoped per
   filter bar, not added to the global list: a global `input[type=search]`
   (0,1,1) would beat `.input` on the media picker / link dialog. Values mirror
   the global rule; `width` is NOT copied — each bar owns its own sizing. */
.catalog__field input[type="search"], .manage__field input[type="search"],
.roster-filter input[type="search"], .alloc-grid__filter-field input[type="search"] {
  padding: var(--space-2) var(--space-3);
  background: var(--surface-sunken); color: var(--text-primary);
  border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
.catalog__filters .btn { margin-left: auto; }

/* card grid */
.catalog__grid {
  list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-4);
  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
}
.catalog__card {
  position: relative; display: flex; flex-direction: column;
  padding: var(--space-5); padding-left: calc(var(--space-5) + 4px);
  background: var(--surface-raised);
  border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  transition: box-shadow .15s ease, transform .15s ease, border-color .15s ease;
}
.catalog__card::before {            /* the spine — the one signature accent */
  content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
  background: var(--primary); border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}
.catalog__card:hover {
  box-shadow: var(--shadow-md); transform: translateY(-2px);
  border-color: var(--border-default);
}
.catalog__eyebrow {
  font-size: .7rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
  color: var(--text-tertiary); margin: 0 0 var(--space-1);
}
.catalog__card .catalog__name { font-size: 1.1rem; line-height: 1.3; margin: 0 0 var(--space-2); }
.catalog__meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
  margin: 0 0 var(--space-3); }
.catalog__snippet {
  color: var(--text-secondary); font-size: .9rem; line-height: 1.5; margin: 0 0 var(--space-4);
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.catalog__card .row-actions { margin-top: auto; margin-left: 0; }   /* pin action to baseline */
.catalog__empty { text-align: center; color: var(--text-tertiary); padding: var(--space-10) var(--space-4); }

/* overview detail (shared by the modal fragment and the no-JS full page) */
.catalog-detail__name { margin-top: 0; }
.catalog-detail__overview { color: var(--text-secondary); line-height: 1.6;
  margin: var(--space-3) 0 var(--space-5); }
.catalog-detail .row-actions { margin-left: 0; }

/* modal (was previously unstyled) */
.modal {
  position: fixed; inset: 0; z-index: 50; display: flex; align-items: center;
  justify-content: center; padding: var(--space-4); background: var(--surface-overlay);
}
.modal[hidden] { display: none; }   /* override the flex above when closed */
.modal__panel {
  position: relative; width: 100%; max-width: 32rem; max-height: 85vh; overflow-y: auto;
  background: var(--surface-raised); border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg); padding: var(--space-6);
}
.modal__close { position: absolute; top: var(--space-2); right: var(--space-3); }
@media (prefers-reduced-motion: no-preference) {
  .modal__panel { animation: modal-in .16s ease-out; }
  @keyframes modal-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
}

@media (max-width: 640px) {
  .catalog__filters .btn { margin-left: 0; width: 100%; }
  .catalog__field select, .catalog__field input { min-width: 0; width: 100%; }
  .catalog__field { width: 100%; }
}

/* ── Course outline (syllabus) ─────────────────────────────────────────────
   A calm table-of-contents: a nested left-rule tree, a clear part/chapter/
   section type hierarchy, and unit rows that hover and check off when done. */
.outline { max-width: 52rem; margin: 0 auto; }
/* Course head (spec 3.1). DOM order is title, progress, resume card / start button,
   actions -- the phone order. Desktop lifts the actions into the top row's right
   column with grid areas, bottom-aligned with the progress row.
   row-gap is 0 ON PURPOSE: rows are spaced by margin-top on the optional blocks, so
   an absent progress block or next step leaves an empty, gapless track -- the actions
   then sit on the title's bottom and no trailing gap opens under the head. */
.outline__head {
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "title actions" "progress actions" "next next";
  row-gap: 0; column-gap: var(--space-4);
  padding-bottom: var(--space-4); margin-bottom: var(--space-5);
  border-bottom: 1px solid var(--border-subtle);
}
.outline__title { grid-area: title; margin: 0; overflow-wrap: anywhere; }
/* Bar and label on ONE row, so the progress block's bottom is the bar row's bottom. */
.outline__progress {
  grid-area: progress; margin-top: var(--space-3);
  display: flex; align-items: center; gap: var(--space-3);
}
/* Track: --border-subtle read 1.08:1 on the light page, so a new student's empty
   bar looked missing. Half --text-tertiary is 1.7:1 light / 2.1:1 dark, and the fill
   still clears it (2.6:1 / 3.7:1). Course bar only: the card bars sit on the card. */
.outline__bar {
  flex: 0 1 20rem; height: 8px; border-radius: 999px;
  background: color-mix(in srgb, var(--text-tertiary) 50%, var(--surface-base));
}
.outline__fill {
  display: block; height: 100%; border-radius: inherit; background: var(--glance-fill);
}
/* flex: none -- flex-shrink is basis-weighted, so beside the 20rem bar a shrinkable
   label would wrap on a phone while the bar still had room to give. */
.outline__progress-label {
  flex: none; max-width: 100%; font-size: .875rem; color: var(--text-secondary);
}
.outline__head > .resume { grid-area: next; margin-top: var(--space-3); margin-bottom: 0; }
.outline__start { grid-area: next; justify-self: start; margin-top: var(--space-3); }
@media (prefers-reduced-motion: no-preference) {
  .outline__start-arrow { transition: transform .15s ease; }
  .outline__start:hover .outline__start-arrow { transform: translateX(3px); }
}
/* align-items: center, not baseline: a .btn led by an <svg class="icon"> takes its
   baseline from the svg's bottom edge, which rode the icon buttons ~2px off the "⋯". */
.outline__actions {
  grid-area: actions; align-self: end;
  display: flex; align-items: center; gap: var(--space-2);
}
/* Phone: one column in DOM order; the two buttons share the row equally and the
   "⋯" stays a fixed 44px square. */
@media (max-width: 640px) {
  .outline__head {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "title" "progress" "next" "actions";
  }
  .outline__start { justify-self: stretch; }
  .outline__actions { margin-top: var(--space-3); }
  .outline__actions > .btn { flex: 1 1 0; min-width: 0; min-height: 44px; }
  .outline__actions > .outline-menu { flex: none; }
}

/* Resume card: the one thing a returning student should be able to hit without
   reading the tree. Ground it on --surface-raised and cut the border against THAT,
   never against the page base. Width comes from .outline (max-width: 52rem). */
.resume {
  position: relative;
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: var(--space-1) var(--space-2);
  /* Right padding clears the absolutely-positioned chevron so a long title never
     runs under it. */
  padding: var(--space-4); padding-right: var(--space-10);
  margin-bottom: var(--space-5);
  background: var(--surface-raised);
  border: 1px solid var(--border-subtle); border-radius: var(--radius-md);
  /* The signature, and the page's own vocabulary turned up: the outline tree marks
     nesting with a left hairline rule (.outline-node__group > ul). A thicker, primary
     one reads as a bookmark clipped INTO that tree rather than a banner floating above
     it -- and it gives the card a visible edge in dark mode, where --border-subtle
     against --surface-raised is nearly imperceptible. */
  border-left: 3px solid var(--primary);
  text-decoration: none; color: inherit;
}
.resume__eyebrow {
  flex-basis: 100%; font-size: .8125rem; font-weight: 600;
  letter-spacing: .02em; text-transform: uppercase; color: var(--text-secondary);
}
/* Own line: on a shared baseline the path read as a prefix to the title
   ("Part One > Chapter One  Getting started with fractions" as one run-on line). */
.resume__path { flex-basis: 100%; font-size: .875rem; color: var(--text-secondary); }
.resume__sep { margin: 0 var(--space-1); }
/* Steps above the outline's part headings (1.35rem/600) so the page's primary control
   is not out-shouted by the section headings beneath it; still below the h1. */
.resume__title { font-size: 1.35rem; font-weight: 700; color: var(--text-primary); }
.resume__arrow {
  position: absolute; right: var(--space-4); top: 50%;
  transform: translateY(-50%); color: var(--text-secondary);
}
.resume:hover { border-color: var(--border-strong); border-left-color: var(--primary); }
.resume:hover .resume__title { text-decoration: underline; }
.resume:hover .resume__arrow { color: var(--primary); }
@media (prefers-reduced-motion: no-preference) {
  .resume__arrow { transition: transform .15s ease, color .15s ease; }
  .resume:hover .resume__arrow { transform: translateY(-50%) translateX(3px); }
}
.resume:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

.outline-tree ul { list-style: none; margin: 0; padding: 0; }
/* Root rhythm (spec 3.2) is card-only: consecutive root unit rows sit flush, like
   unit rows inside a card, and a run of them gets the same --space-6 before the
   next card. */
.outline-tree > ul > .outline-node--card { margin-bottom: var(--space-6); }
.outline-tree > ul > .outline-node--unit + .outline-node--card { margin-top: var(--space-6); }
/* nested levels get a hairline guide rule. Re-pointed, not doubled: the <ul>
   now lives inside the <details>, and neither surviving branch renders a <ul>
   as a child of the <li> (the childless arm has no children by definition, and
   a unit row has no nested list), so the old form would be dead. */
.outline-node__group > ul { margin-top: var(--space-2); padding-left: var(--space-4);
  border-left: 1px solid var(--border-subtle); }

/* structural headings, by depth */
.outline-node__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
.outline-node__title { color: var(--text-primary); font-weight: 600; }
/* Zero basis: a long title wraps BESIDE its chevron instead of dropping below it,
   and the rollup chip keeps its place at the row's end. */
.outline-node__head > .outline-node__title { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
.outline-node--part > .outline-node__head .outline-node__title,
.outline-node--part > .outline-node__group > .outline-node__head .outline-node__title { font-size: 1.35rem; }
.outline-node--chapter > .outline-node__head .outline-node__title,
.outline-node--chapter > .outline-node__group > .outline-node__head .outline-node__title { font-size: 1.1rem; }
.outline-node--section > .outline-node__head .outline-node__title,
.outline-node--section > .outline-node__group > .outline-node__head .outline-node__title {
  font-size: .75rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase;
  color: var(--text-tertiary);
}

/* rollup chips, pushed to the right of a heading */
.rollup { margin-left: auto; font-size: .75rem; font-weight: 600; color: var(--text-secondary);
  background: var(--surface-sunken); border: 1px solid var(--border-default);
  border-radius: var(--radius-full); padding: 0 var(--space-2); line-height: 1.6; white-space: nowrap; }
.rollup + .rollup { margin-left: var(--space-2); }   /* a 2nd chip shouldn't re-push */
.rollup--additional { color: var(--accent); }
/* A depth-1 row's count + chip wrapper: invisible to layout until the phone rule. */
.outline-row__meta { display: contents; }

/* unit rows */
.outline-unit {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm);
  color: var(--text-primary); text-decoration: none;
  transition: background .12s ease;
}
.outline-unit:hover { background: var(--surface-sunken); }
.outline-unit:hover .outline-unit__title { text-decoration: underline; }
/* The chip can add ~90px to a 390px row. `flex: 1` is `1 1 0%` with no
   min-width: 0, so the title's automatic minimum is its min-content and it
   cannot shrink below its longest word; the anchor has the same exposure one
   level up. `anywhere` (not `break-word`) for house consistency with
   .unit-crumbs__label's @media print rule in courses.css — with min-width: 0 co-applied the two are indistinguishable
   and no test can separate them, so do NOT write an anywhere→break-word mutant.
   Both min-width: 0 declarations are inert forward-defence against unbreakable
   atoms and carry no mutant (see the plan's Task 3 falsification note). */
.outline-unit__title { flex: 1; min-width: 0; overflow-wrap: anywhere; }
/* completion is signalled by the ✓ badge alone; done rows read a touch quieter */
.outline-unit--done .outline-unit__title { color: var(--text-secondary); }

/* Internal-link landing cue. The id lives on the <li> (the scroll target) but the
   highlight must not: a non-unit <li> contains its whole descendant subtree, so
   `li:target` would tint most of the page. The two branches of _outline_node.html
   render different row elements, hence two selectors.
   scroll-margin-top is for breathing room, NOT sticky chrome -- .app-header is
   position: relative and .outline sits in the normal .app-main flow. */
.outline-node { scroll-margin-top: var(--space-4); }
.outline-node:target > .outline-node__head,
.outline-node:target > .outline-node__group > .outline-node__head,
.outline-node:target > .outline-unit {
  background: var(--surface-sunken);
  border-radius: var(--radius-sm);
  box-shadow: 0 0 0 2px var(--primary);
}

/* Container rows become a two-column grid so the "⋯" menu sits back on the
   head row. The <details> MUST span 1 / -1: it contains the entire descendant
   subtree, so confining it to column 1 would narrow every nested row and the
   loss would COMPOUND per level (~220px at depth 3 on mat-pp). Grid, not
   position: absolute — an absolutely positioned link overlaps a wrapped title.
   Specificity is capped at (0,1,0) on purpose: `.outline-node[hidden]` is
   (0,2,0) and MUST keep winning, or a filtered-out container stops hiding.
   Do not scope these selectors more tightly — AND this block
   must stay declared ABOVE `.outline-node[hidden]` (the unit-row block below). If
   this selector's specificity ever ties (0,2,0), it is source order — the
   later rule wins a tie, not specificity alone — that keeps `[hidden]`
   winning. Move this block below `[hidden]`, or raise it past (0,2,0),
   and a filtered-out container stops hiding. MEASURED (Task 8): a
   (0,2,0) variant left in its current position does NOT break hiding;
   only clearing (0,2,0) outright or reordering does. */
.outline-node--part,
.outline-node--chapter,
.outline-node--section {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline;
}
.outline-node__group { grid-column: 1 / -1; grid-row: 1; }

/* Group summary. display:flex (inherited from .outline-node__head) already drops
   the list-item box, so list-style + ::-webkit-details-marker are belt-and-braces
   against a future display change — neither is independently falsifiable, so do
   NOT write a test against them. */
summary.outline-node__head { cursor: pointer; list-style: none; }
summary.outline-node__head::-webkit-details-marker { display: none; }
summary.outline-node__head:hover { background: var(--surface-raised); }
summary.outline-node__head:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }

/* Explicit size: the type scale lives on .outline-node__title, not on the head,
   so the head is 1rem at every level and .icon's 1em would render an identical
   16px chevron beside a .75rem uppercase section title. Constant across levels
   by choice — the disclosure is chrome. align-self: center because the head is
   align-items: baseline and a replaced element's baseline is its bottom margin
   edge, which would sit the chevron visibly high. Explicit colour because the
   rail's currentColor reasoning does NOT transfer: the rail's micro-type rule
   targets its head, ours targets the title span. */
.outline-node__head > .outline-node__chevron {
  width: .8rem; height: .8rem; align-self: center; color: var(--text-tertiary);
}
/* Full direct-child chain, NOT `[open] .outline-node__chevron`: the tree is
   recursive, so a CLOSED section sits inside an OPEN chapter and a descendant
   selector would paint it open too. */
.outline-node__group[open] > .outline-node__head > .outline-node__chevron { transform: rotate(90deg); }
/* Gated, not cancelled. A media query adds no specificity, so a `reduce` override
   at the same selector merely TIES and source order decides; at a lower
   specificity it loses outright — which is why the rail's chevron
   (courses.css, `.unit-tree__chevron`) animates for reduced-motion users
   today. Do not
   "harmonise" back to that form. */
@media (prefers-reduced-motion: no-preference) {
  .outline-node__head > .outline-node__chevron { transition: transform 120ms ease; }
}

/* a unit row lays its title link + tag chips + tag affordance + notes badge out
   inline on one line; the title link grows so the affordances tuck to the right */
.outline-node--unit {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1) var(--space-2);
}
/* min-width: 0 here for the same reason as .outline-unit__title above. */
.outline-node--unit > .outline-unit { flex: 1 1 0; min-width: 0; }
/* The [hidden] attr (UA display:none) must beat .outline-node--unit's display:flex —
   the tag filter hides non-matching unit rows via [hidden]; without this they'd stay
   visible (only empty containers collapsed). Same gotcha as .btn[hidden]/.dnd__rows[hidden].
   Must stay declared BELOW the .outline-node--part/--chapter/--section grid rule
   (the container-row grid block above): if its specificity ever ties this (0,2,0), it is
   THIS rule being later in source order that keeps it winning. */
.outline-node[hidden] { display: none; }
/* quiet per-row tag affordance, brightening on hover/focus */
.outline-unit__edit-tags {
  display: inline-flex; align-items: center;
  text-decoration: none; opacity: .5; font-size: .95rem; line-height: 1;
  padding: 0 var(--space-1); border-radius: var(--radius-sm);
}
.outline-unit__edit-tags:hover, .outline-unit__edit-tags:focus-visible {
  opacity: 1; background: var(--surface-sunken);
}

/* Alignment ONLY is shared: .badge--done also carries the green fill, which must
   never reach "not completed". courses.css's `.unit-tree__check` resets the margin. */
.badge--done, .badge--todo { margin-left: auto; }
.badge--done { background: var(--success-subtle); color: var(--success);
  border-color: color-mix(in srgb, var(--success) 45%, var(--border-default)); }
.badge--todo { background: transparent; color: var(--text-secondary); }

/* ── Outline cards, rows and rings (spec 3.2, 3.4, 3.5, 3.8) ───────────────
   Every top-level container is a raised card. The card <li> keeps the container
   grid above (the "⋯" menu in column 2, row 1) and sets NO display of its own, so
   `.outline-node[hidden]` keeps winning. No overflow, transform, filter or
   isolation anywhere on a card: a last card's open menu panel must spill below it.
   --card-line is the height of the title's first line; the badge, chevron, bar,
   count cell and menu are all aligned to it, never to a wrapped title's centre. */
.outline-node--card {
  --card-line: 1.75rem; --card-pad-block: var(--space-2); --card-menu: 32px;
  background: var(--surface-raised);
  border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
  padding: var(--space-5);
}
.outline-node--card > .outline-menu {
  margin-top: calc(var(--card-pad-block) + (var(--card-line) - var(--card-menu)) / 2);
}
/* The card's OWN list: the card edge is the boundary, so no guide rule and no
   indent here; both start one level down (a chapter's <ul>). */
.outline-node--card > .outline-node__group > ul {
  margin-top: var(--space-3); border-left: 0; padding-left: 0;
}
/* Every summary inside a card gets room for the hover tint; the "⋯" reservation
   below is added on top of it. The block padding matches the unit rows'. */
.outline-node--card summary.outline-node__head {
  padding: var(--space-2); border-radius: var(--radius-sm);
}
.outline-node--card .outline-unit { padding-inline: var(--space-2); }

/* Card head grid, wide (>= 900px): exactly five tracks -- badge | title (the ONLY
   flexible track) | bar 12.5rem | count cell | chevron. The meta wrapper is
   display: contents here, so the bar and the count cell fill tracks 3 and 4; the
   "+N" chip shares the count's cell, so it never adds a track. The chevron is
   placed on the LAST track explicitly, so a card with no meta keeps its chevron
   lined up with its neighbours'. */
.outline-node--card > .outline-node__group > summary.outline-node__head {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) 12.5rem minmax(7.5rem, max-content) auto;
  align-items: start; column-gap: var(--space-3); row-gap: var(--space-2);
  padding-block: var(--card-pad-block);
}
.outline-card__num {
  grid-column: 1; grid-row: 1;
  display: inline-flex; align-items: center; justify-content: center;
  min-width: var(--card-line); height: var(--card-line); padding: 0 var(--space-1);
  border-radius: var(--radius-sm);
  background: var(--primary-subtle); color: var(--primary);
  font-size: .875rem; font-weight: 700; font-variant-numeric: tabular-nums;
}
.outline-node--card > .outline-node__group > summary > .outline-node__chevron {
  grid-column: -2; grid-row: 1; align-self: start;
  margin-top: calc((var(--card-line) - .8rem) / 2);
}
.outline-card__meta { display: contents; }
.outline-card__bar {
  grid-column: 3; grid-row: 1; display: block; min-width: 0;
  height: 6px; margin-top: calc((var(--card-line) - 6px) / 2);
  border-radius: var(--radius-full); background: var(--border-subtle);
}
.outline-card__fill {
  display: block; height: 100%; border-radius: inherit; background: var(--glance-fill);
}
.outline-card__countcell {
  grid-column: 4; grid-row: 1;
  display: inline-flex; align-items: center; gap: var(--space-2);
  min-height: var(--card-line); white-space: nowrap;
}
/* Card titles beat EVERY kind-based title rule, including the (0,4,0) section
   micro-type `.outline-node--section > .outline-node__group > .outline-node__head
   .outline-node__title`: this selector is (0,5,0), so the win is specificity, not
   source order. It resets what those rules set, so a course whose roots are
   sections gets normal card titles. */
.outline-tree .outline-node--card > .outline-node__group > .outline-node__head > .outline-node__title {
  grid-column: 2; grid-row: 1; min-width: 0; overflow-wrap: anywhere;
  font-size: 1.25rem; font-weight: 600; line-height: var(--card-line);
  text-transform: none; letter-spacing: normal; color: var(--text-primary);
}
.outline-node--card > .outline-node__group > summary .outline-count__long { white-space: nowrap; }

/* Tablet and phone (< 900px, the card head only): two rows ONLY when the meta
   wrapper exists. Row 1 = badge, title, chevron; row 2 = the meta, spanning under
   the title (bar minmax(0, 1fr) + count cell at its end). A card without meta has
   no second grid row at all, so no empty band and no row-gap. */
@media (max-width: 899.98px) {
  .outline-node--card > .outline-node__group > summary.outline-node__head {
    grid-template-columns: auto minmax(0, 1fr) auto;
  }
  .outline-card__meta {
    grid-column: 2 / -1; grid-row: 2;
    display: grid; grid-template-columns: minmax(0, 1fr) max-content;
    align-items: center; column-gap: var(--space-3);
  }
  .outline-card__bar { grid-column: 1; grid-row: 1; margin-top: 0; }
  .outline-card__countcell { grid-column: 2; grid-row: 1; min-height: 0; }
}

/* "⋯" reservation (spec 3.7): only a head whose <li> HAS a menu reserves the
   button's width plus a gap, on top of the in-card --space-2 padding. Replaces
   PR #397's `:has(> .outline-node__reset)` 7.5rem rule. */
.outline-node:has(> .outline-menu) > .outline-node__group > summary.outline-node__head {
  padding-inline-end: calc(32px + var(--space-2) + var(--space-2));
}

/* Count pair. Rows (depth >= 1) and the childless arm always show the short "X/Y"
   (their long form is .visually-hidden in the markup). Card summaries ONLY swap:
   the long form shows from 641px; on a phone it is visually hidden with the
   .visually-hidden declarations -- never display: none, it stays in the a11y tree. */
.outline-count {
  flex: none; font-size: .8125rem; color: var(--text-secondary);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.outline-node--card > .outline-node__group > summary .outline-count { font-size: .875rem; }
@media (min-width: 641px) {
  .outline-node--card > .outline-node__group > summary .outline-count__short { display: none; }
}

/* Type scale (spec 3.8), by POSITION, not kind. A depth-1 row (a chapter, or a
   section directly under a part) is 15px/500 in the primary colour. The selector
   is (0,6,1) / (0,5,1), above the (0,4,0) kind-based rules, so specificity -- not
   source order -- makes a depth-1 section read like a chapter row. */
.outline-node--card > .outline-node__group > ul > .outline-node > .outline-node__group > .outline-node__head .outline-node__title,
.outline-node--card > .outline-node__group > ul > .outline-node > .outline-node__head .outline-node__title {
  font-size: .9375rem; font-weight: 500;
  text-transform: none; letter-spacing: normal; color: var(--text-primary);
}
.outline-node--card > .outline-node__group > ul > .outline-node > .outline-node__group > .outline-node__head.is-current .outline-node__title {
  font-weight: 600;
}
/* Depth-1 and root-level unit rows share the depth-1 scale. :where() keeps this
   at (0,2,0) so the is-current weight below wins on specificity. */
:where(.outline-tree > ul, .outline-node--card > .outline-node__group > ul) > .outline-node--unit > .outline-unit > .outline-unit__title {
  font-size: .9375rem; font-weight: 500;
}
.outline-unit.is-current > .outline-unit__title { font-weight: 600; }

/* Hover inside a card: the default head hover paints --surface-raised, which IS
   the card, so it would vanish. A half-sunken tint differs from both the card and
   the full --surface-sunken of the highlighted row. */
.outline-node--card summary.outline-node__head:hover,
.outline-node--card .outline-unit:hover {
  background: color-mix(in srgb, var(--surface-sunken) 50%, var(--surface-raised));
}
/* Highlighted row (D5): the full --surface-sunken and NO hover repaint. Declared
   after the hover rules it ties. A unit row paints its band on the <li> so its tag
   chips, tag-edit icon and notes badge sit inside it; the <a> stays transparent. */
.outline-node--card summary.outline-node__head.is-current { background: var(--surface-sunken); }
.outline-node--unit:has(> .outline-unit.is-current) {
  background: var(--surface-sunken); border-radius: var(--radius-sm);
}
.outline-unit.is-current:hover,
.outline-node--card .outline-unit.is-current:hover { background: transparent; }

/* Status rings and the quiz icon: one 20x20 leading slot, so titles align across
   rows inside and outside cards. Track --border-default, arc / done fill
   --glance-fill (D9), tick --text-inverse (light tick on the darkened amber, dark
   tick on the light amber). */
.outline-ring { display: inline-flex; flex: none; width: 20px; height: 20px; align-self: center; }
.outline-ring > svg { display: block; overflow: visible; }
.outline-ring__track { fill: none; stroke: var(--border-default); stroke-width: 2.5; }
.outline-ring__arc { fill: none; stroke: var(--glance-fill); stroke-width: 2.5; stroke-linecap: round; }
.outline-ring__fill { fill: var(--glance-fill); }
.outline-ring__tick {
  fill: none; stroke: var(--text-inverse); stroke-width: 2;
  stroke-linecap: round; stroke-linejoin: round;
}
.outline-quiz-icon { flex: none; width: 20px; height: 20px; color: var(--primary); }
/* D6: the "QUIZ" label IS the shared kind chip, restyled ONLY under the outline row
   -- _resume_card.html and the unit articles render the same partial and keep
   their look. Uppercase in CSS, so the "Quiz" msgid is untouched. */
.outline-unit .unit-kind-chip--quiz {
  background: var(--primary-subtle); color: var(--primary);
  border-color: color-mix(in srgb, var(--primary) 40%, var(--border-default));
  text-transform: uppercase; letter-spacing: .04em;
}

/* Phone (<= 640px): card padding and title step down; D7 makes every head and
   unit row >= 44px (desktop keeps the denser rows), and the card head's padding
   centres a one-line title in a 44px row level with the 44px "⋯" square. */
@media (max-width: 640px) {
  .outline-node--card {
    --card-line: 1.5rem; --card-pad-block: calc((44px - 1.5rem) / 2); --card-menu: 44px;
    padding: var(--space-4);
  }
  .outline-tree .outline-node--card > .outline-node__group > .outline-node__head > .outline-node__title {
    font-size: 1.125rem;
  }
  .outline-node:has(> .outline-menu) > .outline-node__group > summary.outline-node__head {
    padding-inline-end: calc(44px + var(--space-2) + var(--space-2));
  }
  .outline-node--card > .outline-node__group > summary .outline-count__long {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap;
  }
  .outline-node__head { min-height: 44px; align-content: center; }
  .outline-unit { min-height: 44px; }
  /* Depth-1 rows: the count and "+N" chip take their own line under the title,
     which keeps the full width (beside both it had ~95px at 360px). Ring and
     chevron stay on the title's row; the status word is absolutely positioned. */
  .outline-node--card > .outline-node__group > ul > .outline-node > .outline-node__group > summary.outline-node__head {
    display: grid; grid-template-columns: 20px minmax(0, 1fr) auto;
    align-items: center; column-gap: var(--space-2); row-gap: 0;
  }
  .outline-node--card > .outline-node__group > ul > .outline-node > .outline-node__group > summary > .outline-ring { grid-column: 1; grid-row: 1; }
  .outline-node--card > .outline-node__group > ul > .outline-node > .outline-node__group > summary > .outline-node__title { grid-column: 2; grid-row: 1; }
  .outline-node--card > .outline-node__group > ul > .outline-node > .outline-node__group > summary > .outline-node__chevron { grid-column: 3; grid-row: 1; }
  .outline-row__meta {
    grid-column: 2; grid-row: 2;
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
  }
  .outline-row__meta > .rollup { margin-left: 0; }
}

/* Forced colours, like the glance bars: tracks become a CanvasText outline, fills
   Highlight. The rings are inline SVG, out of reach of the box rules, so they opt
   out of adjustment and restate each part in system colours -- done / in progress
   / not started stay distinguishable (the status words are screen-reader-only). */
@media (forced-colors: active) {
  .outline__bar, .outline-card__bar { background: transparent; border: 1px solid CanvasText; }
  .outline__fill, .outline-card__fill { forced-color-adjust: none; background: Highlight; }
  .outline-ring { forced-color-adjust: none; }
  .outline-ring__track { stroke: CanvasText; }
  .outline-ring__arc { stroke: Highlight; }
  .outline-ring__fill { fill: Highlight; }
  .outline-ring__tick { stroke: Canvas; }
}

/* --- Practice-state reset controls --- */
.lesson-unit__reset { flex: none; }

/* "⋯" menus (spec 3.7): a <details class="outline-menu"> holding the reset link. It is
   never .outline-node__group, so the tree's partition logic ignores it. A container's
   menu is the <details>' SIBLING, grid-placed back onto the head row; the course menu
   sits in .outline__actions and is not grid-placed. The panel is OUT OF FLOW, so
   opening a menu changes no row height -- and nothing up the tree may set overflow
   hidden/clip or a stacking context, or a last card's panel is clipped. */
.outline-menu { position: relative; }
.outline-node > .outline-menu { grid-column: 2; grid-row: 1; justify-self: end; align-self: start; }
.outline-menu__button {
  display: flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; border-radius: var(--radius-sm);
  color: var(--text-secondary); font-weight: 700; line-height: 1;
  cursor: pointer; list-style: none; user-select: none;
}
.outline-menu__button::-webkit-details-marker { display: none; }
.outline-menu__button:hover,
.outline-menu[open] > .outline-menu__button { background: var(--surface-sunken); color: var(--text-primary); }
.outline-menu__button:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
/* Beside the bordered ghost buttons the course "⋯" wears the same border. */
.outline__actions .outline-menu__button { border: 1px solid var(--border-strong); }
.outline-menu__panel {
  /* right: 0 grows the panel leftwards from the button, never past the viewport. */
  position: absolute; top: 100%; right: 0; z-index: 50;
  min-width: 11rem; padding: var(--space-1);
  background: var(--surface-raised); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-md);
}
.outline-menu__item {
  display: flex; align-items: center; width: 100%;
  padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm);
  font-size: .875rem; color: var(--text-primary); text-decoration: none; white-space: nowrap;
}
.outline-menu__item:hover { background: var(--surface-sunken); color: var(--danger); }
.outline-menu__item:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
@media (max-width: 640px) {
  .outline-menu__button { width: 44px; height: 44px; }
  .outline-menu__item { min-height: 44px; }
}
/* Unit page: rendered INACTIVE until this viewer has stored practice state (an <a>
   with aria-disabled and no href). NOT pointer-events:none -- that would suppress the
   title tooltip. So the two fills that would repaint it as live must be neutralised
   here: .btn--ghost:hover's, and .btn:active's primary fill, which .btn--ghost never
   overrides and which native :disabled blocks only on real buttons. Specificity
   (0,3,0) beats .btn:active (0,2,0). */
.lesson-unit__reset[aria-disabled="true"] { opacity: .45; cursor: default; }
.lesson-unit__reset[aria-disabled="true"]:hover,
.lesson-unit__reset[aria-disabled="true"]:active { background: transparent; }

/* List header (spec 3.3): "Działy · N" and the expand/collapse toggle, a plain
   text button in the link colour. */
.outline-list__head {
  display: flex; align-items: baseline; gap: var(--space-3);
  margin-bottom: var(--space-3);
}
.outline-list__count { font-size: .875rem; font-weight: 600; color: var(--text-secondary); }
.outline__toggle-all {
  display: inline-flex; align-items: center; margin-left: auto;
  padding: var(--space-1) 0; background: none; border: 0;
  font-size: .875rem; font-weight: 500; color: var(--accent); cursor: pointer;
}
.outline__toggle-all:hover { text-decoration: underline; }
.outline__toggle-all:focus-visible {
  outline: 2px solid var(--primary); outline-offset: 2px; border-radius: var(--radius-sm);
}
/* Disabled while a tag filter is active (§5): Collapse all would hide every match.
   The base rule's cursor: pointer and the hover underline are not reset by the UA on
   :disabled -- without these two rules the disabled control reads as a live one, the
   "visibly does nothing" affordance the disable was chosen to avoid. */
.outline__toggle-all:disabled { color: var(--text-tertiary); cursor: default; }
.outline__toggle-all:disabled:hover { text-decoration: none; }
/* The [hidden] attribute must beat the two author display rules above (the
   .btn[hidden] gotcha): the toggle stays hidden until outline_tree.js reveals it, and
   the header carries hidden on a course with no cards. Keep BELOW both rules. */
.outline-list__head[hidden],
.outline__toggle-all[hidden] { display: none; }

/* Suppresses the chevron animation until outline_tree.js finishes restoring the
   stored fold state, so the restore is an instant state change rather than a
   wave of animating chevrons. This rule is the ONLY thing the class does: with
   JS off it is never removed, so it must never hide or reposition anything. */
.outline-tree--booting .outline-node__head > .outline-node__chevron { transition: none; }

/* ── Manage: course index (the control desk) ───────────────────────────────
   The authoring counterpart to the catalog's "shelf": not a storefront grid
   but an operational ledger — hairline-divided rows, each a compact identity
   plus a tabular "spec line" (status+scope · language · updated · owner). The
   status leads as the scan signal, reusing the established badge vocabulary;
   the boldness is spent on the console-like spec strip (tabular-nums so dates
   and codes align down the column). Everything else stays quiet. */
.manage { max-width: 52rem; margin: 0 auto; }
.manage__head {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4);
  padding-bottom: var(--space-4); margin-bottom: var(--space-2);
  border-bottom: 1px solid var(--border-subtle);
}
.manage__title { margin: 0; }
.manage__count { color: var(--text-tertiary); font-size: .875rem;
  font-variant-numeric: tabular-nums; }
.manage__head .btn { margin-left: auto; }

/* Subject filter on the manage course list (dropdown + clear link). */
.manage__filters {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-2) var(--space-3);
  margin: 0 0 var(--space-5);
}
.manage__field { display: flex; flex-direction: column; gap: var(--space-1); margin: 0; }
.manage__field > span { font-size: .75rem; font-weight: 600; color: var(--text-tertiary); }
.manage__field select { min-width: 11rem; }
.manage__filter-clear {
  align-self: center; color: var(--primary); font-weight: 600; text-decoration: none;
}
.manage__filter-clear:hover { text-decoration: underline; }

/* "used by N courses" count links to the filtered course list — keep it muted
   (inherits the spec row colour), reveal affordance on hover. */
.subject-list__count { color: inherit; text-decoration: none; }
.subject-list__count:hover { text-decoration: underline; }

.course-list { list-style: none; margin: 0; padding: 0; }
.course-list__item {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3) var(--space-4);
  padding: var(--space-4) var(--space-2);
  border-bottom: 1px solid var(--border-subtle);
  transition: background .12s ease;
}
.course-list__item:hover { background: var(--surface-sunken); }
.course-list__main { flex: 1 1 16rem; min-width: 0;
  display: flex; flex-direction: column; gap: var(--space-1); }
.course-list__subject {
  font-size: .7rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
  color: var(--text-tertiary);
}
.course-list__title { margin: 0; font-size: 1.05rem; font-weight: 600;
  color: var(--text-primary); line-height: 1.3; }
/* the spec strip — a tabular console line of operational metadata */
.course-list__spec {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-2);
  margin: var(--space-1) 0 0;
  font-size: .8rem; color: var(--text-tertiary); font-variant-numeric: tabular-nums;
}
.course-list__sep { color: var(--border-strong); }
.course-list__actions { margin-left: auto; display: flex; gap: var(--space-2); }

.manage__empty {
  text-align: center; color: var(--text-tertiary);
  border: 1px dashed var(--border-default); border-radius: var(--radius-lg);
  padding: var(--space-10) var(--space-4); margin-top: var(--space-6);
}
.manage__empty p { margin: 0 0 var(--space-4); }

@media (max-width: 640px) {
  .manage__head .btn { margin-left: 0; }
  .course-list__actions { margin-left: 0; width: 100%; }
  .course-list__actions .btn { flex: 1; }
}

/* Dashboard / my-courses card grid */
.dash-cards { list-style: none; margin: var(--space-5) 0 0; padding: 0;
  display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); }
.dash-card { display: flex; flex-direction: column; gap: var(--space-2);
  padding: var(--space-5); background: var(--surface-raised);
  border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm); transition: box-shadow .15s ease, transform .15s ease; }
.dash-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
.dash-card__title { font-size: 1.05rem; font-weight: 600; color: var(--text-primary); text-decoration: none; }
.dash-card__title:hover { text-decoration: underline; }
.dash-card__actions { margin-top: auto; }
.dash-card__actions a { color: var(--accent); text-decoration: none; font-weight: 500; font-size: .9rem; }
.dash-card__actions a:hover { text-decoration: underline; }

/* Course glance bars (templates/courses/_course_glance.html). The track is
   deliberately light (owner decision D9); the fill carries the information. */
.glance { display: grid; grid-template-columns: max-content 1fr; align-items: center;
  column-gap: var(--space-2); row-gap: var(--space-1); margin-top: var(--space-1); }
.glance__row { display: contents; }
.glance__label { font-size: .75rem; color: var(--text-secondary); white-space: nowrap; }
.glance__track { display: flex; height: 6px; border-radius: 999px; overflow: hidden;
  background: var(--border-subtle); }
.glance__fill, .glance__dot { display: block; height: 6px; border-radius: 999px;
  background: var(--glance-fill); }
.glance__fill { min-width: 6px; }
.glance__dot { width: 6px; flex: none; }

/* Landing sample cards: look like a .dash-card at rest, but static (no hover lift,
   not a link) -- they advertise, they do not navigate. */
.glance-card { width: 14rem; margin: 0; padding: var(--space-4); text-align: left;
  background: var(--surface-raised); border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
.glance-card__title { margin: 0; font-size: .95rem; font-weight: 600;
  color: var(--text-primary); }
@media (forced-colors: active) {
  .glance__track { background: transparent; border: 1px solid CanvasText; }
  .glance__fill, .glance__dot { forced-color-adjust: none; background: Highlight; }
}

/* Home dashboard panels */
.dash-grid { display: grid; gap: var(--space-4); margin-top: var(--space-5);
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); }
.dash-panel { padding: var(--space-5); background: var(--surface-raised);
  border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
.dash-panel__title { margin: 0 0 var(--space-3); font-size: 1rem; font-weight: 600; color: var(--text-primary); }
.dash-browse { margin-top: var(--space-6); }

/* Analytics matrix (Phase 3c-ii) */
.analytics__controls{display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem}
.analytics__legend{display:flex;gap:1rem;flex-wrap:wrap;list-style:none;padding:0;margin:0 0 1rem;font-size:.8rem;color:var(--text-secondary)}
.analytics__swatch{display:inline-block;width:.9em;height:.9em;border-radius:3px;vertical-align:middle;margin-right:.3em}
.analytics{--ahead-h:2.4rem}
/* A capped 2-D scroll viewport so the header rows + Student/Overall columns can
   freeze (position:sticky) while the cells scroll under them (freeze panes).
   padding-bottom reserves clearance so the horizontal scrollbar never overlaps
   the last (Average) row when the table is wide but short (no vertical scroll). */
.analytics__scroll{max-height:72vh;overflow:auto;padding-bottom:1.1rem}
/* border-collapse:separate (not collapse) so sticky cells keep their borders on
   scroll; single 1px grid via right+bottom per cell + a top/left table edge. */
.analytics__matrix{border-collapse:separate;border-spacing:0;width:100%;font-size:.85rem;
  border-top:1px solid var(--border-default);border-left:1px solid var(--border-default)}
.analytics__matrix th,.analytics__matrix td{border-right:1px solid var(--border-default);
  border-bottom:1px solid var(--border-default);padding:.4rem .6rem;text-align:center}
.analytics__rowhead{text-align:left;font-weight:600;white-space:nowrap}
.analytics__cell--empty{color:var(--text-secondary)}
.analytics__overall,.analytics__avg{font-weight:700}
/* The "Average" footer reads as a summary, not another student: a firm divider
   above it and an uppercase eyebrow label in the first column. */
.analytics__matrix tfoot td{border-top:2px solid var(--text-tertiary)}
.analytics__avg .analytics__rowhead{text-transform:uppercase;letter-spacing:.05em;
  font-size:.72rem;color:var(--text-secondary)}
/* Segmented control: the two options read as one connected "pick one of two"
   toggle — a joined pill with a filled active segment and outlined/muted inactive
   segments — instead of separate filled buttons (the default .btn is primary-filled,
   which made active vs inactive indistinguishable). */
.analytics__toggle{display:inline-flex;border:1px solid var(--border-strong);
  border-radius:var(--radius-sm);overflow:hidden}
.analytics__toggle .btn{margin:0;border:0;border-radius:0;background:transparent;
  color:var(--text-secondary);box-shadow:none}
.analytics__toggle .btn+.btn{border-left:1px solid var(--border-strong)}
.analytics__toggle .btn:not(.is-active):hover{background:var(--surface-sunken);
  color:var(--text-primary)}
.analytics__toggle .btn.is-active{background:var(--primary);color:var(--text-inverse)}
.analytics__toggle .btn.is-active:hover{background:var(--primary-hover)}
.analytics__rowhead input[type="checkbox"]{margin-right:.4rem;vertical-align:middle}
.analytics__count{font-size:.8rem;color:var(--text-secondary)}
/* Subset action bar: sits below the matrix, tied to the row checkboxes it acts on.
   Hidden by JS until a student is checked; always visible for no-JS users. */
.analytics__subset{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap;margin-top:.75rem}
.analytics__subset[hidden]{display:none}
.analytics__subset-hint{font-size:.8rem;color:var(--text-secondary)}
/* Analytics bands config (Task 8) — 3-col grid so the middle "from N%" track is
   sized to the widest row and every colour control lines up across rows. */
.analytics-bands__form{max-width:34rem}
/* Fixed middle track (not auto): each row is its own grid, so a shared width is
   the only way the "from 0%" row's colour control lines up with the input rows'. */
.analytics-bands__row{display:grid;grid-template-columns:6rem 8.5rem 1fr;
  gap:.5rem 1rem;align-items:center;margin-bottom:.55rem}
.analytics-bands__label{font-weight:600}
.analytics-bands__min{display:inline-flex;align-items:baseline;gap:.35rem;
  white-space:nowrap;color:var(--text-secondary);font-size:.9rem}
.analytics-bands__min input[type=number]{box-sizing:border-box;width:4.25rem;text-align:center}
.analytics-bands__pct{color:var(--text-tertiary)}
.analytics-bands__color{display:flex;align-items:center;gap:.6rem}
.analytics-bands__color input[type=text]{box-sizing:border-box;width:8.5rem}
.analytics-bands__picker{width:2.5rem;height:2.1rem;flex:0 0 auto;padding:0;
  border:1px solid var(--border-strong);border-radius:var(--radius-sm);
  background:var(--surface-sunken);cursor:pointer}
.analytics-bands__err{margin:-.15rem 0 .5rem}
.analytics-bands__actions{display:flex;gap:.5rem;margin-top:1.25rem}

/* --- Analytics drill-down (Phase 3c-iii-a) --- */
/* Nested header: a shallow leaf column rowspans down, so top-align its title to
   line up with same-depth siblings (a part next to an expanded part). Expanded
   spanning cells sit above their children with a subtle sunken band. */
.analytics__matrix thead th{vertical-align:top}
.analytics__group{background:var(--surface-sunken)}
.analytics__group-title{font-weight:600}
.analytics__expand{text-decoration:none;color:inherit;border-bottom:1px dotted currentColor}
.analytics__expand:hover{color:var(--primary)}
.analytics__collapse{margin-left:.4rem;text-decoration:none;color:var(--text-secondary);
  font-weight:700;line-height:1}
.analytics__collapse:hover{color:var(--text-primary)}
.analytics__caption{margin:.25rem 0 .5rem;font-size:.85em}

/* --- Freeze panes: frozen header rows + Student & Overall columns --- */
/* Header: every header cell sticks to the top of the scroll box (its per-row `top`
   offset is set inline so nested rows stack). Opaque bg so cells scroll under it. */
.analytics__matrix thead th{position:sticky;z-index:3;box-sizing:border-box;
  height:var(--ahead-h);white-space:nowrap;background:var(--surface-raised)}
.analytics__matrix thead th.analytics__group{background:var(--surface-sunken)}
/* Student (first) column and Overall (last) column freeze horizontally. */
/* Left-align the Student column (header, rows, Average) so the row checkboxes
   line up at a fixed x and are easy to click — beats the .analytics__matrix td
   center rule on specificity. */
.analytics__matrix .analytics__rowhead{position:sticky;left:0;z-index:2;
  text-align:left;background:var(--surface-raised)}
.analytics__matrix .analytics__overall{position:sticky;right:0;z-index:2;
  background:var(--surface-raised)}
/* The header corners are both a frozen row AND a frozen column -> above both. */
.analytics__matrix thead th.analytics__rowhead,
.analytics__matrix thead th.analytics__overall{z-index:4}
/* Depth cue: a soft shadow on the frozen left/right column edges. */
.analytics__matrix .analytics__rowhead{box-shadow:2px 0 4px -2px rgba(0,0,0,.18)}
.analytics__matrix .analytics__overall{box-shadow:-2px 0 4px -2px rgba(0,0,0,.18)}
/* Row hover: a translucent overlay tints the whole row (frozen cells included)
   without hiding the band colours, so a wide row is easy to track across. */
.analytics__matrix tbody tr:hover td,
.analytics__matrix tbody tr:hover th{box-shadow:inset 0 0 0 9999px rgba(127,127,127,.16)}
.analytics__matrix tbody tr:hover .analytics__rowhead{font-weight:700}
/* Phones: the two frozen columns plus nowrap headers left the scores a sliver
   too narrow to show a number (the value sat under the frozen Overall column).
   So on a narrow screen: the Student column narrows and wraps names (still
   frozen -- whose row it is must stay readable); Overall stops pinning to the
   right and scrolls as the last column (right:auto, NOT position:static: its
   header cell must still stick to the top); long column titles are cut with
   an ellipsis (full title in the title="" tooltip) so a score column is not
   15rem wide. Header height
   is untouched: titles still sit on ONE line, which the stacked sticky header
   rows (top: --ahead-h * row) depend on. */
@media (max-width: 640px) {
  .analytics__matrix .analytics__rowhead{white-space:normal;width:7.5rem;min-width:7.5rem}
  .analytics__matrix .analytics__overall{right:auto;box-shadow:none}
  .analytics__coltitle{display:inline-block;max-width:7.5rem;overflow:hidden;
    text-overflow:ellipsis;vertical-align:bottom}
}

/* --- Narrow score columns (desktop) --- */
/* A register-like grid: every score column is 6.5rem, whatever its title. Two
   things kept columns wide: nowrap titles (a column was as wide as its title on
   one line) and width:100%, which stretched a few columns across the page. So
   on desktop the table sizes to its columns, and a leaf title wraps onto at
   most TWO lines (full title in the title="" tooltip). Words are never split
   (break-word, NOT anywhere: "Statystyk|a" read badly), so a column with one
   long word grows just enough for it. The header rows are
   pinned at top = row * --ahead-h, so --ahead-h grows to fit two lines: a
   title taller than its row would overlap the next pinned row. Phones keep the
   one-line ellipsis (see the 640px block above) and the 2.4rem rows. */
@media (min-width: 641px) {
  .analytics{--ahead-h:3.5rem}
  .analytics__matrix{width:auto}
  .analytics__matrix thead th.analytics__colhead:not(.analytics__group){
    width:6.5rem;min-width:6.5rem;white-space:normal}
  .analytics__matrix thead th.analytics__colhead .analytics__expand{display:flex;
    align-items:flex-end;gap:.2rem;border-bottom:0}
  .analytics__matrix thead th.analytics__colhead:not(.analytics__group) .analytics__coltitle{
    display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;
    overflow:hidden;overflow-wrap:break-word;text-align:left}
  .analytics__expand .analytics__coltitle{text-decoration:underline dotted;
    text-underline-offset:.2em}
  /* An opened section's title spans its columns. It WRAPS (the nowrap one-line
     title is what used to widen them: its min-content was the whole title),
     and the ✕ follows it -- not pushed to the far end of a wide section, where
     it hides under the frozen Overall column. */
  .analytics__grouphead{display:flex;align-items:flex-start;
    gap:.4rem;white-space:normal;text-align:left}
  .analytics__grouphead .analytics__group-title{min-width:0;display:-webkit-box;
    -webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;
    overflow-wrap:anywhere}
  .analytics__grouphead .analytics__collapse{margin-left:0;flex:none}
}

/* --- Vertical headers (desktop toggle) --- */
/* A register-style option to the two-line titles above: leaf column titles
   rotate to read bottom-to-top, so a column is only as wide as its values.
   Only the LAST header row grows tall (to its longest title, capped at 9rem):
   the rows above hold opened sections (horizontal, --ahead-h tall), and every
   pinned row sits at top = row * --ahead-h, so the offsets stay right. A leaf
   from a shallower row rowspans down into the tall row. Toggled like full screen (button +
   localStorage); desktop only, and the button is hidden on a phone. */
.analytics__vertical[hidden]{display:none}
.analytics__vertical[aria-pressed="true"]{background:var(--primary);
  border-color:var(--primary);color:var(--text-inverse)}
@media (min-width: 641px) {
  .analytics--vertical .analytics__matrix thead th.analytics__colhead:not(.analytics__group){
    width:auto;min-width:0;vertical-align:bottom;text-align:center;padding:.4rem .5rem}
  .analytics--vertical .analytics__matrix thead th.analytics__colhead:not(.analytics__group) .analytics__expand,
  .analytics--vertical .analytics__matrix thead th.analytics__colhead:not(.analytics__group) > .analytics__coltitle{
    writing-mode:vertical-rl;transform:rotate(180deg);display:inline-flex;align-items:center}
  .analytics--vertical .analytics__matrix thead th.analytics__colhead:not(.analytics__group) .analytics__coltitle{
    display:inline-block;-webkit-line-clamp:unset;line-clamp:none;max-inline-size:9rem;
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  /* STACKED maths (superscripts, fractions, roots, accents -- KaTeX builds them
     all on a .vlist) comes apart when rotated, so such a title stays horizontal
     in the two-line layout. Simple maths (pi, y, a+b=c) has no .vlist and
     turns with the rest. */
  .analytics--vertical .analytics__matrix thead th.analytics__colhead:not(.analytics__group):has(.vlist){
    width:6.5rem;min-width:6.5rem;vertical-align:top;text-align:left;padding:.4rem .6rem}
  .analytics--vertical .analytics__matrix thead th.analytics__colhead:not(.analytics__group):has(.vlist) .analytics__expand,
  .analytics--vertical .analytics__matrix thead th.analytics__colhead:not(.analytics__group):has(.vlist) > .analytics__coltitle{
    writing-mode:horizontal-tb;transform:none;display:flex}
  .analytics--vertical .analytics__matrix thead th.analytics__colhead:not(.analytics__group):has(.vlist) .analytics__coltitle{
    display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;max-inline-size:none;
    white-space:normal}
}
@media (max-width: 640px) {
  .analytics__vertical{display:none}
}

/* --- Full-screen mode (desktop) --- */
/* The analytics section covers the whole window (site header included) so a
   wide matrix gets the screen, not a 52rem column. Toggled by the header button
   and remembered in localStorage (see analytics_matrix.html). Desktop only: on
   a phone the class is inert and the button hidden. The section is a flex
   column so the table's scroll box takes all the height left under the
   controls, instead of its usual 72vh. */
.analytics__fullscreen[hidden]{display:none}
/* .manage__head .btn is margin-left:auto (push the actions right); only the
   FIRST visible action may take the auto margin, or the free space splits. */
.manage__head .analytics__fullscreen:not([hidden]) ~ .btn{margin-left:0}
@media (min-width: 641px) {
  .analytics--full{position:fixed;inset:0;z-index:45;max-width:none;margin:0;
    padding:var(--space-4) var(--space-5);background:var(--surface-base);
    display:flex;flex-direction:column;overflow:hidden}
  .analytics--full > form{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
  .analytics--full .analytics__scroll{flex:1 1 auto;min-height:0;max-height:none}
}
@media (max-width: 640px) {
  .analytics__fullscreen{display:none}
}

/* --- Export panel (Task 9) --- */
/* <details>/<summary> dropdown, matching the .menu pattern: the summary is a
   ghost-button trigger, the form floats as a raised panel underneath it so
   opening it doesn't reflow the controls/table below. */
.analytics__export{position:relative}
.analytics__export summary{list-style:none;cursor:pointer}
.analytics__export summary::-webkit-details-marker{display:none}
.analytics__export-form{position:absolute;right:0;top:calc(100% + 6px);z-index:20;
  width:20rem;max-width:calc(100vw - 2rem);
  display:flex;flex-direction:column;gap:var(--space-3);
  background:var(--surface-raised);border:1px solid var(--border-default);
  border-radius:var(--radius-md);box-shadow:var(--shadow-md);padding:var(--space-4)}
.analytics__export-form fieldset{margin:0;padding:var(--space-2) var(--space-3);
  border:1px solid var(--border-subtle);border-radius:var(--radius-sm);
  display:flex;flex-direction:column;gap:var(--space-1)}
.analytics__export-form legend{padding:0 .3rem;font-size:.75rem;font-weight:600;
  text-transform:uppercase;letter-spacing:.05em;color:var(--text-tertiary)}
.analytics__export-form fieldset label{display:flex;align-items:center;gap:.4rem;
  font-weight:400;font-size:.9rem}
/* The checkbox row wraps its helper text onto its own line, indented to sit
   under the label (not the checkbox), via flex-basis:100% on the .muted span. */
.analytics__export-numbers{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;font-size:.9rem}
.analytics__export-numbers .muted{flex-basis:100%;margin-left:1.5rem}
.analytics__export-form .row-actions{margin:0;flex-wrap:wrap}
@media (max-width: 640px) {
  .analytics__export-form{position:static;width:auto;max-width:none;
    margin-top:var(--space-2);box-shadow:none}
}

/* Per-student breakdown tree (reuses the outline visual language) */
.breakdown__tree,.breakdown-node ul{list-style:none;margin:0;padding:0}
.breakdown__view{margin:0 0 var(--space-3)}
.breakdown-node ul{margin-left:1rem;padding-left:.75rem;border-left:1px solid var(--border-default)}
.breakdown-node__head{display:flex;align-items:baseline;gap:.6rem;
  margin:.5rem 0 .25rem;font-weight:600}
.breakdown-unit{display:flex;align-items:center;gap:.6rem;padding:.25rem 0}
/* No colour on .is-done: a finished lesson must not look dimmer than an unfinished
   one. The class stays in the markup as the row's completion record. */
.breakdown-unit__review{font-size:.8rem}
/* The one clickable thing on the page reads as a link without hovering. */
.breakdown-unit__link{color:var(--accent);text-decoration:underline;text-underline-offset:2px}
.breakdown-unit__link:hover,.breakdown-unit__link:focus-visible{text-decoration-thickness:2px}
/* Scoped: .pill is global and also sits in the per-question .answers__status strip. */
.breakdown-unit .pill{margin-left:auto}

/* Student page, Results view: one table with section sums (results-table spec §2.4).
   The student page links no page stylesheet, so every rule is here. Padding and
   indentation come in three bands with EQUAL selectors, so source order decides:
   the unconditional rules, then the 640px block, then the 480px block. The depth
   rules are (0,3,0) and beat the (0,1,1) padding shorthand wherever they sit; each
   depth ADDS to the base inline padding. Padding and indentation are the only
   per-band changes at 640px; the 480px block also shrinks the type and lets
   pills wrap. */
.results-table th,.results-table td{padding:.375rem .5rem;border-top:1px solid var(--border-subtle)}
.results-table .results-table__title.results-table__d0{padding-inline-start:.5rem}
.results-table .results-table__title.results-table__d1{padding-inline-start:1.5rem}
.results-table .results-table__title.results-table__d2{padding-inline-start:2.5rem}
.results-table .results-table__title.results-table__d3{padding-inline-start:3.5rem}
@media (max-width:640px){
  .results-table th,.results-table td{padding:.375rem .5rem}
  .results-table .results-table__title.results-table__d0{padding-inline-start:.5rem}
  .results-table .results-table__title.results-table__d1{padding-inline-start:1rem}
  .results-table .results-table__title.results-table__d2{padding-inline-start:1.5rem}
  .results-table .results-table__title.results-table__d3{padding-inline-start:2rem}
}
@media (max-width:480px){
  .results-table{font-size:.875rem}
  .results-table th,.results-table td{padding:.25rem .25rem}
  .results-table .results-table__title.results-table__d0{padding-inline-start:.25rem}
  .results-table .results-table__title.results-table__d1{padding-inline-start:.75rem}
  .results-table .results-table__title.results-table__d2{padding-inline-start:1.25rem}
  .results-table .results-table__title.results-table__d3{padding-inline-start:1.75rem}
  /* Only here does a pill wrap: in a width:1% column it breaks at every space, which
     above this width would be three lines where one fits. The smaller radius keeps
     a wrapped pill from turning into an oval that touches its text. */
  .results-table .pill{font-size:.7rem;padding:.05rem .3rem;white-space:normal;
    border-radius:.5rem;text-align:center}
}
/* position:relative is defensive, no A/B claimed: removing it leaves the T8b e2e test
   green. It makes this scroller the containing block of KaTeX's absolutely positioned
   .katex-mathml, which a static scroller could let escape and widen the page if the formula sat beyond the viewport. */
.results-table-wrap{overflow-x:auto;position:relative}
.results-table{width:100%;border-collapse:collapse;background:var(--surface-raised)}
/* overflow-wrap:anywhere breaks text titles; it cannot split a single-run
   formula (the vendored `.katex .base{display:inline-block; white-space:
   nowrap}` makes it one atomic inline box), whose width sets the title
   column's min-content -- the table then scrolls in .results-table-wrap,
   never the page. A MULTI-run formula (a top-level relation or binary
   operator splits it into several `.base` runs) wraps internally instead,
   as KaTeX intends. */
.results-table .results-table__title{overflow-wrap:anywhere}
.results-table .results-table__status{width:1%;white-space:nowrap;text-align:start}
/* nowrap here is defensive: „812,5/960,5" and „100%" have no break opportunity. */
.results-table .results-table__num{width:1%;white-space:nowrap}
/* td, not a bare class: a (0,2,0) class rule would right-align the thead cells too
   and leave the thead rule below with nothing to do. */
.results-table td.results-table__num{text-align:right}
/* A <th> is bold and centred by default (reset.css has no th rule): override by
   SPECIFICITY, never by order. */
.results-table tbody th{text-align:start;font-weight:normal}
.results-table tbody .results-table__section th,
.results-table tbody .results-table__section td{font-weight:600}
.results-table thead th{font-weight:600;text-align:start}
.results-table thead th.results-table__num{text-align:right}
/* --surface-base differs from --surface-raised in BOTH themes; --surface-sunken is
   lighter than the page in light mode and would not read as a tint. */
.results-table__section{background:var(--surface-base)}
/* On the CELLS and 2px wide: in border-collapse the wider border wins over the next
   row's 1px border-top; a same-width row border would lose to the cell border. */
.results-table__total > th,.results-table__total > td{border-bottom:2px solid var(--border-strong)}
/* The base .pill--none colour (--text-tertiary) fails AA, and the phone pill is smaller. */
.results-table .pill--none{color:var(--text-secondary)}
/* Scoped to the table: the Progress view's inline Review link is unchanged (O5). */
.results-table .breakdown-unit__review{display:block;margin-top:.25rem}
.results-table-empty{color:var(--text-secondary)}

/* --- Per-question answers page (analytics drill-down, spec PR 5) --- */
/* The number is the visible qnum, so the list draws no marker. Muted text uses
   the secondary token: the tertiary one fails AA at body size. */
.answers__status{display:flex;flex-wrap:wrap;align-items:center;gap:.6rem;margin:0 0 1rem}
.answers__heading{min-width:0}
.answers__student{margin:0 0 .15rem;font-size:.875rem;color:var(--text-secondary)}
.answers .manage__head .btn{flex-shrink:0}
/* Scoped by class AND width: .manage__head is shared and wraps on purpose, and below
   641px app.css cancels the button's auto-push precisely because the header wraps. */
@media (min-width:641px){.answers .manage__head{flex-wrap:nowrap}}
.answers__score{font-size:1.25rem;font-weight:700;font-variant-numeric:tabular-nums}
.answers__percent{font-variant-numeric:tabular-nums;color:var(--text-secondary)}
.answers__score + .answers__percent::before{content:"·";margin-right:.6rem;
  color:var(--text-tertiary)}
.answers__count,.answers__attempts,.answers__muted,.answers__expected{color:var(--text-secondary)}
.answers__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1rem}
.answers__item{border:1px solid var(--border-default);border-radius:var(--radius-md);
  padding:.75rem 1rem;background:var(--surface-raised)}
/* The teacher's card edge mirrors the student page's tinted verdict panel. */
.answers__item.is-correct{border-left:4px solid var(--success)}
.answers__item.is-partial{border-left:4px solid var(--warning)}
.answers__item.is-incorrect{border-left:4px solid var(--danger)}
.answers__head{display:flex;gap:.5rem;align-items:baseline}
.answers__qnum{font-weight:600}
.answers__verdict{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin:.5rem 0}
.answers__part{display:flex;flex-wrap:wrap;align-items:baseline;gap:.25rem .6rem;
  padding:.2rem 0;overflow-wrap:anywhere}
.answers__label{font-weight:500;min-width:6rem}
.answers__part--answer .answers__given{white-space:pre-wrap}
.answers__glyph--correct{color:var(--success)}
.answers__glyph--incorrect{color:var(--danger)}
.answers__gap{font-weight:600}
.answers .dragimage__stage{max-width:32rem;margin:.5rem 0}
/* Choice options (spec §5.1). width:100% — without it the table shrink-wraps
   inside the flex .answers__part. Marker columns are as wide as their header. */
.answers__options{width:100%;table-layout:auto;border-collapse:collapse;margin:.25rem 0}
.answers__options th{font-size:.75rem;font-weight:600;color:var(--text-secondary);
  text-align:left;padding:.15rem .5rem}
.answers__options td{padding:.2rem .5rem;border-top:1px solid var(--border-subtle);
  vertical-align:baseline}
.answers__options .answers__options-mark{width:1%;white-space:nowrap;text-align:center}
.answers__options-text{overflow-wrap:anywhere}
.answers__options-caption{caption-side:top;text-align:left;font-size:.8rem;
  color:var(--text-secondary);padding:0 .5rem .2rem}
.answers__option.is-correct .answers__options-mark{color:var(--success)}
.answers__option.is-wrong .answers__options-mark{color:var(--danger)}
.answers__option.is-missed .answers__options-mark{color:var(--warning)}
/* Multi-part questions (spec §5.2). .answers__given-cell groups the given value and
   its glyph; outside the grid, above 640px, it is transparent to the flex row. */
.answers__given-cell{display:contents}
.answers__header-row{font-size:.75rem;font-weight:600;color:var(--text-secondary)}
.answers__parts--columned{display:grid;grid-template-columns:auto 1fr 1fr;
  column-gap:.6rem;row-gap:.35rem;align-items:baseline}
.answers__parts--columned .answers__part{display:contents}
.answers__parts--columned .answers__header-row{display:contents}
.answers__parts--columned .answers__label,
.answers__parts--columned .answers__given-cell,
.answers__parts--columned .answers__expected{overflow-wrap:anywhere}
.answers__parts--columned .answers__given-cell{display:flex;flex-wrap:wrap;
  align-items:baseline;gap:.25rem .6rem}
@media (min-width:641px){
  .answers__parts--columned .answers__expected-label{display:none}
}
@media (max-width:640px){
  .answers__part{flex-direction:column;align-items:flex-start}
  .answers__label{min-width:0}
  /* The header words collapse; each row's sr-only label still carries the meaning.
     These are reset.css's nine .sr-only declarations, copied: CSS cannot add a class
     at a breakpoint, and hiding in THIS direction never has to out-rank .sr-only.
     The th.answers__options-mark half out-ranks the width:1% marker-column rule. */
  .answers__options th,.answers__options th.answers__options-mark{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
    overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
  .answers__parts--columned{display:block}
  .answers__parts--columned .answers__part{display:flex;flex-direction:column}
  .answers__given-cell{display:flex;flex-wrap:wrap;align-items:baseline;gap:.25rem .6rem} /* the glyph stays on its value's line */
  .answers__parts--columned .answers__header-row{display:none}
}

/* Status pills */
.pill{display:inline-block;padding:.1rem .55rem;border-radius:999px;font-size:.75rem;
  font-weight:600;white-space:nowrap}
.pill--scored{background:var(--primary);color:var(--text-inverse)}
.pill--submitted{background:var(--surface-sunken);color:var(--text-secondary);
  border:1px solid var(--border-strong)}
.pill--awaiting{background:var(--warning-subtle);color:var(--text-primary);
  border:1px solid var(--warning)}
.pill--progress{background:var(--surface-sunken);color:var(--text-secondary);
  border:1px solid var(--border-strong)}
.pill--none{background:transparent;color:var(--text-tertiary);
  border:1px dashed var(--border-default)}

/* Notifications list (Phase: notifications slice 1) */
.notif-page { max-width: 46rem; margin: 0 auto; padding: 1.5rem 1rem; }
.notif-page__head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.notif-list { list-style: none; margin: 1rem 0 0; padding: 0; }
.notif-row { display: flex; align-items: center; gap: .75rem; padding: .75rem .5rem; border-bottom: 1px solid var(--border-strong); }
.notif-row__body { flex: 1; }
.notif-row__link { color: var(--primary); text-decoration: none; }
.notif-row__link:hover { text-decoration: underline; }
.notif-row--unread { background: var(--surface-raised); }
.notif-row--unread .notif-row__body { font-weight: 600; }
.notif-empty { color: var(--text-secondary); margin-top: 2rem; }
.notif-pager { display: flex; gap: 1rem; align-items: center; margin-top: 1rem; }

/* Nav unread badge (Phase: notifications slice 1) */
.nav-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 1.25rem; height: 1.25rem; padding: 0 .35rem; margin-left: .35rem; border-radius: 999px; background: var(--primary); color: var(--on-primary, #fff); font-size: .75rem; font-weight: 600; }

/* Notification bell dropdown (Phase: notifications slice 3) */
.bell { position: relative; }
.bell__trigger { position: relative; display: inline-flex; }
/* Overlap the badge on the top-right of the bell icon (the base .nav-badge is
   tuned for an inline text link, so reset its inline margin here). */
.bell .nav-badge { position: absolute; top: -.25rem; right: -.25rem; margin: 0; }
.notif-menu { width: 20rem; max-width: calc(100vw - var(--space-4));
  max-height: min(28rem, calc(100vh - 5rem)); overflow-y: auto; padding: 0; }
.notif-menu__head { display: flex; align-items: center; justify-content: space-between;
  gap: .5rem; padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--border-subtle); }
.notif-menu__title { font-weight: 600; margin: 0; }
.notif-menu__list { list-style: none; margin: 0; padding: 0; }
.notif-menu__row { display: flex; align-items: baseline; justify-content: space-between;
  gap: .5rem; padding: var(--space-2) var(--space-3); text-decoration: none;
  color: var(--text-primary); border-bottom: 1px solid var(--border-subtle); }
a.notif-menu__row:hover { background: var(--surface-sunken); }
/* Unread tint must contrast with the panel's own --surface-raised background,
   so use the brand-subtle token (NOT --surface-raised, which would be invisible,
   and NOT --surface-sunken, which the hover state already owns). */
.notif-menu__row--unread { background: var(--primary-subtle); }
.notif-menu__row--unread .notif-menu__body { font-weight: 600; }
.notif-menu__body { flex: 1; }
.notif-menu__time { color: var(--text-secondary); font-size: .75rem; white-space: nowrap; }
.notif-menu__seeall { display: block; padding: var(--space-2) var(--space-3);
  color: var(--primary); text-decoration: none; font-weight: 600; }
.notif-menu__seeall:hover { text-decoration: underline; }
.notif-menu__empty { color: var(--text-secondary); padding: var(--space-3); margin: 0; }
/* PWA C2 bell invite: one row above the list; push.js unhides it. */
.notif-menu__push { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--border-subtle); }
.notif-menu__push[hidden] { display: none; }
.notif-menu__push-text { flex: 1 1 0; min-width: 0; }
.notif-menu__push-error { flex-basis: 100%; margin: 0; color: var(--danger); }
.notif-menu__push-error[hidden] { display: none; }

/* Tags & notes hub tab bar (shared by the overview + my_tags pages) */
.tnhub__tabs {
  display: flex;
  gap: var(--space-2);
  margin: var(--space-3) 0 var(--space-4);
  border-bottom: 1px solid var(--border-subtle);
}
.tnhub__tab {
  padding: var(--space-2) var(--space-3);
  color: var(--text-secondary);
  border-bottom: 2px solid transparent;
  text-decoration: none;
  margin-bottom: -1px;
}
.tnhub__tab:hover { color: var(--text-primary); }
.tnhub__tab.is-on {
  color: var(--text-primary);
  border-bottom-color: var(--primary);
  font-weight: 600;
}

/* Reveal gate — the "Show more" progressive-disclosure affordance. A centred
   pill (full radius, distinct from the square form .btn) with a soft primary
   tint that fills in on hover; the chevron signals there is more below. All
   colours are theme tokens, so light/dark are correct by construction. */
.reveal-gate {
  display: flex;
  width: fit-content;
  margin: var(--space-4) auto;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  font: inherit;
  font-weight: 600;
  line-height: 1;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.reveal-gate:hover {
  background: var(--primary);
  color: var(--text-inverse);
  border-color: transparent;
}
.reveal-gate:active { background: var(--primary-active); }
.reveal-gate:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Chevron nudges down on hover — hints "click to drop the content in below".
   A one-way gate (consumed on click) has no expanded state to rotate into, so
   the motion lives on hover, not on a toggle. */
.reveal-gate__chevron { width: 1em; height: 1em; flex: none; transition: transform .15s ease; }
.reveal-gate:hover .reveal-gate__chevron { transform: translateY(2px); }
@media (prefers-reduced-motion: reduce) {
  .reveal-gate__chevron { transition: none; }
  .reveal-gate:hover .reveal-gate__chevron { transform: none; }
}

/* Spoiler — self-contained show/hide disclosure (native <details>). */
.spoiler { margin: var(--space-4) 0; }
.spoiler__toggle {
  display: inline-flex;
  width: fit-content;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  font: inherit;
  font-weight: 600;
  line-height: 1;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
  border-radius: var(--radius-full);
  cursor: pointer;
  list-style: none; /* remove default marker (Firefox) */
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.spoiler__toggle::-webkit-details-marker { display: none; } /* Safari/Chrome */
.spoiler__toggle:hover { background: var(--primary); color: var(--text-inverse); border-color: transparent; }
.spoiler__toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.spoiler__toggle::after {
  content: "";
  width: .5em;
  height: .5em;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform .15s ease;
}
/* `> .spoiler__toggle`, never a descendant: since the depth-3 lift a spoiler may
   contain another spoiler, and a descendant selector would let an OPEN outer spoiler
   flip the state affordances of a CLOSED inner one. */
.spoiler[open] > .spoiler__toggle::after { transform: rotate(-135deg); }
@media (prefers-reduced-motion: reduce) {
  .spoiler__toggle::after { transition: none; }
}
/* Two-way toggle: show the author/"Reveal" label when collapsed, "Hide" when open
   (native <details>, no JS). Both labels ship in the DOM; [open] swaps which shows.
   Scoped with `>` for the nested-spoiler reason above: with a descendant selector a
   CLOSED spoiler inside an OPEN one reads "Hide" instead of its own reveal label. */
.spoiler__label--hide { display: none; }
.spoiler[open] > .spoiler__toggle .spoiler__label--show { display: none; }
.spoiler[open] > .spoiler__toggle .spoiler__label--hide { display: inline; }
/* Revealed panel reads as an aside bound to its toggle (not loose lesson prose):
   an indented, brand-ruled block distinct from the reveal-gate's bodyless divider.
   Two shapes get the SAME treatment: the legacy body-only spoiler (`.spoiler__body`)
   and the element-based one, whose children share the `.spoiler__children` wrapper.
   The element path had no styling at all, so it lost both the rule and the indent.

   The wrapper carries NO vertical margin and is deliberately not a `flow-root`: the
   children's own margins keep collapsing through it, so the box hugs the content
   exactly (first child's top to the last child's bottom, matching the body case) and
   the spoiler's total height is unchanged — measured 154px before and after. */
.spoiler__body,
.spoiler > .spoiler__children {
  padding-left: var(--space-4);
  border-left: 2px solid color-mix(in srgb, var(--primary) 30%, transparent);
}
.spoiler__body {
  margin: var(--space-3) 0 var(--space-1) var(--space-3);
}

/* COMBINED SHAPE (body AND children, newly possible): the two 2px rules must read
   as ONE continuous line. The gap has TWO symmetric sources -- `.spoiler__body` has
   no bottom padding or border, so its own last child's margin-bottom collapses
   THROUGH it and survives a margin-bottom:0 on the body itself; and the wrapper is
   deliberately not a flow-root, so the first child's margin-top collapses through
   it too.
   These rules MUST stay below the shared `.spoiler__body, .spoiler >
   .spoiler__children` block above: test_spoiler_css.py splits the concatenated CSS
   on the FIRST occurrence of `.spoiler > .spoiler__children`. */
.spoiler__body:has(+ .spoiler__children) { margin-left: 0; margin-bottom: 0; }
.spoiler__body:has(+ .spoiler__children) > :last-child { margin-bottom: 0; }
.spoiler__body + .spoiler__children > .spoiler__child:first-child > :first-child { margin-top: 0; }

/* Reveal gate — button hide guards (author display must never beat [hidden]) */
.reveal-gate[hidden] { display: none !important; }
.lesson-block[hidden], .tabs__child[hidden], .ba__child[hidden] { display: none !important; }

/* Pre-hide is emitted render-blocking in the lesson <head> (see lesson_unit.html).
   PRINT: deliberately NO print rule here. Owner decision D8 (spec
   2026-10-11-print-layout-for-units): gated content prints exactly as the screen
   shows it. The pre-hide is not media-scoped, so blocks the student has not
   revealed stay hidden on paper too, and an unconsumed gate (a "Show more"
   button, a fill gate's blanks + Confirm, a switch gate's cycler + Confirm, a
   gated fill-table) prints as the marker that more content waits behind it.
   A consumed gate prints as the screen shows it too: a "Show more" button
   hides itself ([hidden]); a done fill or switch gate keeps its wrapper and
   shows the student's locked answers without Confirm, and so does the paper.
   A print rule that reverted the pre-hide and hid every gate used to live
   here; it printed answers the student had not reached.
   courses/tests/test_filltable_gate_print.py guards against its return; the
   before/after print block in courses.css follows the same rule (D8). */

/* Fill gate — a fill-the-blank(s) question wrapped in the same reveal-gate
   mechanics as "Show more", but consumed by a correct Confirm instead of a
   click. The container just adds a little breathing room around the blanks
   form; the interesting states live on the .question__blank-input elements
   fillgate.js already mutates (is-wrong / is-correct) and on the button /
   feedback text next to them. */
.fillgate { margin: var(--space-4) 0; }

/* Confirm button: same pill shape + soft-primary-fills-on-hover treatment as
   .reveal-gate, so the two gate families read as one family, not a bespoke
   .btn. */
.fillgate__confirm {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
  padding: var(--space-2) var(--space-4);
  font: inherit;
  font-weight: 600;
  line-height: 1;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.fillgate__confirm:hover {
  background: var(--primary);
  color: var(--text-inverse);
  border-color: transparent;
}
.fillgate__confirm:active { background: var(--primary-active); }
.fillgate__confirm:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.fillgate__confirm[hidden] { display: none !important; }

/* Blank-input verdict states. fillgate.js toggles these classes (never removes
   is-correct once set, since a correct blank is then locked read-only); scoped
   under [data-fillgate] so the plain quiz/lesson blank inputs elsewhere are
   untouched. */
[data-fillgate] .question__blank-input.is-correct {
  border-color: var(--success);
  background: var(--success-subtle);
}
[data-fillgate] .question__blank-input.is-correct:read-only {
  color: var(--text-secondary);
  cursor: default;
  /* Release the editable box's fixed 8ch width so the now-revealed answer shows
     in full: fillgate.js sets the input's `size` to its value length on lock, so
     width:auto lets it fit the content; min-width keeps a short answer as wide as
     the surrounding editable boxes. */
  width: auto;
  min-width: 8ch;
}
[data-fillgate] .question__blank-input.is-wrong {
  border-color: var(--danger);
  background: var(--danger-subtle);
}

/* Wrong-attempt message: error-toned, small, sits right under the blanks. */
.fillgate__feedback {
  margin: var(--space-2) 0 0;
  color: var(--danger);
  font-size: .875rem;
}
.fillgate__feedback[hidden] { display: none !important; }

/* "Done" affordance once every blank is correct and Confirm has been removed —
   a faint success-tinted left bar, matching the .question__feedback-panel
   pattern rather than inventing a new one. */
.fillgate--done {
  padding-left: var(--space-3);
  border-left: 3px solid var(--success);
}

/* Choose & confirm gate (switchgate) — same reveal-gate family as fillgate;
   the inline cycling "Choose ▾" widget is the new bit (fillgate has plain
   text inputs instead). */
.switchgate { margin: var(--space-4) 0; }

/* The inline cycling chip — the one genuinely new affordance in the gate family.
   It reads as a tap-to-cycle token: a small radius (deliberately NOT the full
   pill of the .btn-like Confirm, so a choice-in-progress token reads apart from
   the commit action), in the family's soft-primary language. Two states, told
   apart with :has() on whichever ring child is visible (no JS/markup needed):
   an "empty" dashed+muted state that invites a choice, and a solid soft-primary
   "committed" state once a real option shows. */
.switchgate__cycler {
  display: inline-flex;
  align-items: baseline;
  vertical-align: baseline;
  gap: var(--space-1);
  padding: 0.12em var(--space-2);
  font: inherit;
  font-weight: 600;
  line-height: 1.2;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
/* Committed chips carry a persistent ▾ so it's clear the choice can still be
   cycled; the empty state's ▾ already lives in the placeholder text. */
.switchgate__cycler:has(.switchgate__option:not([hidden]))::after {
  content: "\25BE";
  font-size: .8em;
  line-height: 1;
  color: color-mix(in srgb, var(--primary) 70%, transparent);
}
/* Empty (placeholder visible): dashed + muted — "nothing chosen yet, tap me". */
.switchgate__cycler:has(.switchgate__placeholder:not([hidden])) {
  font-weight: 500;
  color: var(--text-secondary);
  background: var(--surface-sunken);
  border-style: dashed;
  border-color: var(--border-strong);
}
/* Fill-on-hover, matching .reveal-gate / .fillgate__confirm. */
.switchgate__cycler:hover:not(:disabled) {
  background: var(--primary);
  color: var(--text-inverse);
  border-color: transparent;
}
.switchgate__cycler:hover:not(:disabled)::after { color: var(--text-inverse); }
.switchgate__cycler:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Locked after a correct Confirm — quiet, success-tinted to echo the --done bar. */
.switchgate__cycler:disabled {
  cursor: default;
  color: var(--text-primary);
  background: var(--success-subtle);
  border-style: solid;
  border-color: color-mix(in srgb, var(--success) 40%, transparent);
}
.switchgate__cycler:disabled::after { display: none; }

/* Confirm button: same pill shape + soft-primary-fills-on-hover treatment as
   .fillgate__confirm/.reveal-gate, so all three gate widgets read as one
   family (copied verbatim from .fillgate__confirm). */
.switchgate__confirm {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
  padding: var(--space-2) var(--space-4);
  font: inherit;
  font-weight: 600;
  line-height: 1;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.switchgate__confirm:hover {
  background: var(--primary);
  color: var(--text-inverse);
  border-color: transparent;
}
.switchgate__confirm:active { background: var(--primary-active); }
.switchgate__confirm:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* REQUIRED: display:inline-flex above beats the UA [hidden] rule at equal
   specificity -- the confirm button would show before JS arms it (and in the
   no-JS state), contradicting the fail-open/e2e assertions (the same
   .btn[hidden] gotcha .fillgate__confirm[hidden] guards against). */
.switchgate__confirm[hidden] { display: none !important; }

/* Wrong-attempt message: error-toned, small, sits inline next to the cycler. */
.switchgate__feedback {
  margin-left: var(--space-2);
  color: var(--danger);
  font-size: .875rem;
}
.switchgate__feedback[hidden] { display: none !important; }

/* "Done" affordance once the correct choice is confirmed and Confirm has been
   removed — same success-tinted left bar as .fillgate--done. */
.switchgate--done {
  padding-left: var(--space-3);
  border-left: 3px solid var(--success);
}

/* Standalone utility: visually hides content while keeping it in the a11y
   tree. Introduced by the switchgate cycler's describedby hint (Task 5 of
   the choose-and-confirm plan); not gate-specific, so it lives here as a
   plain utility rather than under a .switchgate/.fillgate namespace. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* Choose & confirm editor: the fixed-row options list (radio + text per row). */
.el-editor__option-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: var(--space-1) 0;
}
.el-editor__option-row input[type="text"] { flex: 1; }

/* The image size preset control. No global fieldset *appearance* rule exists --
   reset.css's `fieldset` rule only zeroes `min-inline-size`, so without this
   the radios ship
   with the UA border and zero spacing next to the flat .el-editor labels above
   them. Tokens only — no literal colours. */
.size-presets {
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-3);
  margin: var(--space-2) 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  align-items: center;
}
.size-presets > legend { color: var(--text-secondary); padding-inline: var(--space-1); }
.size-presets label { display: inline-flex; align-items: center; gap: var(--space-1); }
/* The "Float right" checkbox under the fieldset lines up with its radios: inset by the
   fieldset's 1px border + inline padding (keep in step with .size-presets above). */
.float-right-toggle { margin-inline-start: calc(1px + var(--space-3)); }

/* Switch grid self-check — a grid of static + cycler-bearing lines checked as a
   whole (NOT a reveal gate: nothing hides/reveals, no marks recorded). The
   cycler chip and Confirm button reuse the switchgate/gate-family visual
   language verbatim so all cycling widgets read as one family. */
.switchgrid { margin: var(--space-4) 0; }
.switchgrid__prompt { font-weight: 600; margin: 0 0 var(--space-2); }
.switchgrid__line { margin: var(--space-1) 0; line-height: 2; }

/* Cycler chip: same tap-to-cycle token as .switchgate__cycler (always shows a
   committed option here, never an empty placeholder state). */
.switchgrid__cycler {
  display: inline-flex;
  align-items: baseline;
  vertical-align: baseline;
  gap: var(--space-1);
  padding: 0.12em var(--space-2);
  font: inherit;
  font-weight: 600;
  line-height: 1.2;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.switchgrid__cycler::after {
  content: "\25BE";
  font-size: .8em;
  line-height: 1;
  color: color-mix(in srgb, var(--primary) 70%, transparent);
}
.switchgrid__cycler:hover:not(:disabled) {
  background: var(--primary);
  color: var(--text-inverse);
  border-color: transparent;
}
.switchgrid__cycler:hover:not(:disabled)::after { color: var(--text-inverse); }
.switchgrid__cycler:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Per-cycler feedback after Confirm, before the next attempt. */
.switchgrid__cycler.switchgrid--correct {
  color: var(--text-primary);
  background: var(--success-subtle);
  border-color: color-mix(in srgb, var(--success) 40%, transparent);
}
.switchgrid__cycler.switchgrid--incorrect {
  color: var(--text-primary);
  background: var(--danger-subtle);
  border-color: color-mix(in srgb, var(--danger) 40%, transparent);
}
.switchgrid__cycler.switchgrid--locked {
  cursor: default;
  color: var(--text-primary);
  background: var(--success-subtle);
  border-style: solid;
  border-color: color-mix(in srgb, var(--success) 40%, transparent);
}
.switchgrid__cycler.switchgrid--locked::after { display: none; }

/* Options: only the current one is ever visible; the rest sit [hidden] inside
   the same button, cycled client-side. */
.switchgrid__option { }

/* Confirm button: same pill shape as .switchgate__confirm/.fillgate__confirm so
   the whole gate/self-check family reads as one visual language. */
.switchgrid__confirm {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
  padding: var(--space-2) var(--space-4);
  font: inherit;
  font-weight: 600;
  line-height: 1;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.switchgrid__confirm:hover {
  background: var(--primary);
  color: var(--text-inverse);
  border-color: transparent;
}
.switchgrid__confirm:active { background: var(--primary-active); }
.switchgrid__confirm:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Author display:inline-flex must never beat [hidden] once the grid locks — same
   guard as .switchgate__confirm/.fillgate__confirm (the .btn[hidden] gotcha). */
.switchgrid__confirm[hidden] { display: none !important; }

.switchgrid__summary {
  margin-top: var(--space-2);
  font-weight: 600;
}
.switchgrid__summary[hidden] { display: none !important; }
.switchgrid__summary.switchgrid--success { color: var(--success); }
.switchgrid__summary.switchgrid--retry { color: var(--danger); }

/* ============================================================================
   Switch grid AUTHORING editor (manage/editor/_edit_switchgrid.html).
   A three-level nesting the DOM already encodes: line > cycler > option.
   The layout makes that containment spatial rather than decorative — cyclers
   HANG off the stem on a left rule, so "this {{choice}} → this cycler" reads at
   a glance. Depth alternates raised/sunken through the levels so the hierarchy
   holds in BOTH themes (a fill-only scheme collapses in one or the other):
     editor card (raised) → line (sunken) → cycler (raised) → input (sunken).
   Reuses the gate-family vocabulary (primary-subtle, dashed "add" affordance,
   --danger removes) so this editor reads as part of the same system. Base
   .el-editor grid/gap, .el-editor__label/__hint and .field-error come from
   courses/css/editor.css; only the switchgrid-scoped rules live here. NO
   invented colors — tokens only (tokens.css). ============================= */

/* Line stack: even vertical rhythm between line cards. */
.el-editor--switchgrid [data-lines] {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

/* Line card = the sunken "tray" a whole line lives on (stem + its cyclers). */
.el-editor--switchgrid .el-editor__line {
  padding: var(--space-3);
  background: var(--surface-sunken);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
}

/* Head row: the stem is the source of truth, so it takes the full width and
   the remove-× sits quietly at the top-right. align-items:flex-start keeps the
   × pinned to the first line of a growing stem, not vertically centered on it. */
.el-editor--switchgrid .el-editor__line-head {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
}
/* Stem: lifted to the raised surface so it's the brightest thing on the line —
   the field the whole block derives from. Slightly heavier text reinforces it. */
.el-editor--switchgrid .el-editor__line-head [data-stem] {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-weight: 500;
  background: var(--surface-raised);
}

/* Cyclers hang off the stem: indented and tied to a left rule so the
   marker → cycler containment is unmistakable. Collapses entirely for a
   static (zero-marker) line, leaving just the stem. */
.el-editor--switchgrid .el-editor__cyclers {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-top: var(--space-3);
  padding-left: var(--space-4);
  border-left: 2px solid var(--border-strong);
}
.el-editor--switchgrid .el-editor__cyclers:empty { display: none; }

/* Cycler = a raised tile popping off the sunken line, one per {{choice}}. */
.el-editor--switchgrid .el-editor__cycler {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-3);
  background: var(--surface-raised);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
}
/* "Cycler N" eyebrow: encodes the marker's left-to-right position (meaningful
   order), set as a quiet tracked label — the position matters, the chrome
   shouldn't shout. */
.el-editor--switchgrid .el-editor__cycler-label {
  font-size: .68rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .05em;
  color: var(--text-tertiary);
}

/* Option list inside a cycler. Reuses the shared .el-editor__option-row
   (radio + text + ×) from the choose-and-confirm editor; here the rows are
   gap-stacked, so drop the shared per-row margin to avoid doubled spacing. */
.el-editor--switchgrid .el-editor__options {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.el-editor--switchgrid .el-editor__option-row { margin: 0; }
.el-editor--switchgrid .el-editor__option-row input[type="radio"] {
  flex: 0 0 auto;
  width: auto;
  margin: 0;
}
/* Sunken option inputs read as fields inside the raised cycler tile — the
   fourth and innermost depth step. */
.el-editor--switchgrid .el-editor__option-row input[type="text"] {
  margin: 0;
  background: var(--surface-sunken);
}

/* Remove (×): small, secondary, right-aligned. Quiet until wanted, then clearly
   destructive on hover/focus. Shared by the line-head × and the option-row ×. */
.el-editor--switchgrid .el-editor__remove {
  flex: 0 0 auto;
  display: inline-grid;
  place-items: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  font-size: 1.15rem;
  line-height: 1;
  color: var(--text-tertiary);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
}
/* inline-grid overrides the [hidden] attribute, so hide explicitly at the min (JS toggles it) */
.el-editor--switchgrid .el-editor__remove[hidden] { display: none; }
.el-editor--switchgrid .el-editor__remove:hover {
  color: var(--danger);
  background: var(--danger-subtle);
  border-color: color-mix(in srgb, var(--danger) 32%, transparent);
}
.el-editor--switchgrid .el-editor__remove:focus-visible {
  outline: 2px solid var(--danger);
  outline-offset: 1px;
}

/* Switchgate reuses switchgrid's x component, but every rule for it is scoped to
   .el-editor--switchgrid, so a bare class inherits nothing. Duplicated under a
   scope rather than promoted to an unscoped rule: promoting would restyle
   switchgrid as a side effect, and an unscoped .el-editor__remove[hidden] ties on
   specificity with the block above, leaving the outcome to source order. */
.el-editor--switchgate .el-editor__remove {
  flex: 0 0 auto;
  display: inline-grid;
  place-items: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  font-size: 1.15rem;
  line-height: 1;
  color: var(--text-tertiary);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
}
/* inline-grid overrides the [hidden] attribute, so hide explicitly (JS toggles it) */
.el-editor--switchgate .el-editor__remove[hidden] { display: none; }
.el-editor--switchgate .el-editor__remove:hover {
  color: var(--danger);
  background: var(--danger-subtle);
  border-color: color-mix(in srgb, var(--danger) 32%, transparent);
}
.el-editor--switchgate .el-editor__remove:focus-visible {
  outline: 2px solid var(--danger);
  outline-offset: 1px;
}

/* "Add option": a quiet dashed affordance so a grid full of cyclers isn't a wall
   of solid buttons. Echoes the gate family's dashed "nothing here yet" pill.
   align-self keeps it hugging its text (no overlap with the ×'d rows above — the
   old collision bug came from a full-width button landing under the controls). */
.el-editor--switchgrid [data-add-option] {
  align-self: flex-start;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px dashed color-mix(in srgb, var(--primary) 40%, transparent);
}
.el-editor--switchgrid [data-add-option]:hover {
  color: var(--text-inverse);
  background: var(--primary);
  border-color: transparent;
}
.el-editor--switchgrid [data-add-option]::before,
.el-editor--switchgrid .el-editor__add-line::before {
  content: "+";
  margin-right: var(--space-1);
  font-weight: 700;
}
/* "Add line": the one prominent, top-level action — left the solid .btn primary
   fill and anchored to the start so it never stretches across the form. It's a
   direct child of the .el-editor GRID (editor.css), so justify-self (inline axis),
   NOT align-self, is what keeps it hugging its content. */
.el-editor--switchgrid .el-editor__add-line { justify-self: start; }

/* All-blank-cycler submit guard message (JS-appended): a field error, left-barred
   in --danger so it clearly flags the offending cycler tile it sits in. */
.el-editor--switchgrid .el-editor__inline-error {
  margin: var(--space-1) 0 0;
  padding-left: var(--space-2);
  border-left: 2px solid var(--danger);
  font-weight: 500;
}

/* ============================================================================
   Guess the number — a numeric-input self-check. NOT a reveal gate (nothing
   hides/reveals): the input + Check button are spliced INLINE into the
   author's stem prose at the {{token}}, most often right after KaTeX-rendered
   math ("\(201^2=\) [input] [Check]"). Reuses the gate family's feedback
   vocabulary (--danger/--success + *-subtle) and Confirm-button shape so it
   reads as part of the same system as .fillgate/.switchgate, not a bespoke
   widget. The always-present live-region wrapper around the hint/success
   pair is a plain grouping node -- deliberately unstyled. */
.guessnumber { margin: var(--space-4) 0; }

/* Inline baseline alignment -- the one genuinely new problem here. KaTeX's
   inline mode already sets its own vertical-align so the math's baseline
   lands ON the surrounding line's text baseline; that part is free. What
   is NOT free is the input/button: a bare form control's CSS baseline is
   its bottom border edge, not the baseline of the glyphs/label inside it,
   so at the browser default (vertical-align: baseline) the whole control
   hangs below the math/prose baseline it sits next to by roughly its own
   bottom padding + border. Both controls share that padding/border here,
   so both get the SAME deliberate correction: raise by exactly that amount
   (--space-1 padding + the 1px border) rather than leave it to the default
   or eyeball a magic number. */
.guessnumber input[data-guess-input],
.guessnumber button[data-guess-check] {
  vertical-align: calc(var(--space-1) + 1px);
}

.guessnumber input[data-guess-input] {
  display: inline-block;
  width: 8ch; /* matches .question__blank-input's inline-numeric-answer width */
  margin: 0 0.2em;
  padding: var(--space-1) var(--space-2);
  font: inherit;
  color: var(--text-primary);
  background: var(--surface-sunken);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  transition: border-color .15s ease, background .15s ease;
}
.guessnumber input[data-guess-input]:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

/* Check button: same pill shape + soft-primary-fills-on-hover treatment as
   .fillgate__confirm/.switchgate__confirm, so all the self-check "commit"
   buttons read as one family. It starts [hidden] server-side (armed by JS
   once live) -- REQUIRED display:inline-flex above beats the UA [hidden]
   rule at equal specificity, same .btn[hidden] gotcha as the gate family. */
.guessnumber button[data-guess-check] {
  display: inline-flex;
  align-items: center;
  margin: 0 0 0 0.2em;
  padding: var(--space-1) var(--space-3);
  font: inherit;
  font-weight: 600;
  line-height: 1;
  color: var(--primary);
  background: var(--primary-subtle);
  border: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.guessnumber button[data-guess-check][hidden] { display: none !important; }
.guessnumber button[data-guess-check]:hover {
  background: var(--primary);
  color: var(--text-inverse);
  border-color: transparent;
}
.guessnumber button[data-guess-check]:active { background: var(--primary-active); }
.guessnumber button[data-guess-check]:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

/* Wrong attempt: red tint on the input. Colour never carries the state
   alone -- this always ships alongside [data-guess-hint]'s directional text,
   never in place of it (guessnumber.js only shows the tint without hint text
   when the answer was unparseable, which is itself communicated by the
   verdict never turning green). */
.guessnumber input[data-guess-input].is-wrong {
  border-color: var(--danger);
  background: var(--danger-subtle);
}
.guessnumber [data-guess-hint] {
  margin: var(--space-2) 0 0;
  color: var(--danger);
  font-size: .875rem;
}
.guessnumber [data-guess-hint][hidden] { display: none !important; }

/* Success: input locks read-only + green-tinted, matching
   [data-fillgate] .question__blank-input.is-correct. */
.guessnumber input[data-guess-input].is-correct {
  border-color: var(--success);
  background: var(--success-subtle);
}
.guessnumber input[data-guess-input].is-correct:read-only { color: var(--text-secondary); cursor: default; }
.guessnumber [data-guess-success] {
  margin: var(--space-2) 0 0;
  color: var(--success);
}
.guessnumber [data-guess-success][hidden] { display: none !important; }

/* "Done" affordance once solved -- same success-tinted left bar as
   .fillgate--done / .switchgate--done. */
.guessnumber.guessnumber--done {
  padding-left: var(--space-3);
  border-left: 3px solid var(--success);
}

/* ── Horizontally scrollable box: edge affordance ────────────────────────────
   A clipped table gives no hint that more exists sideways — the overlay
   scrollbars this app runs under paint nothing when idle and reserve no layout
   space (measured: offsetHeight - clientHeight == 0), so the scrollbar cannot be
   the affordance. Shade the edge that has content beyond it instead.

   The shading must sit ABOVE the cells, not behind them: the grids give their
   header row and zebra rows opaque backgrounds, which cover anything painted in
   the scroll container's own background layer (an inset box-shadow there shows
   over transparent body cells and vanishes under the header — worse than
   nothing). Hence the wrapper: .scroll-x does not scroll, so its absolutely
   positioned pseudo-elements stay pinned to the visible edges while the content
   moves under them.

   Class names match the .tabs__bar scroller (tabs.js), which already toggles
   is-scroll-start / is-scroll-end with the same meaning: the side that has
   scrolled-away content. */
.scroll-x { position: relative; }
.scroll-x::before,
.scroll-x::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1.5rem;
  z-index: 1;
  pointer-events: none;   /* never swallow a click on a cell or a checkbox */
  opacity: 0;
  transition: opacity .15s ease;
}
.scroll-x::before {
  left: 0;
  background: linear-gradient(to right, var(--scroll-edge), transparent);
}
.scroll-x::after {
  right: 0;
  background: linear-gradient(to left, var(--scroll-edge), transparent);
}
.scroll-x.is-scroll-start::before { opacity: 1; }
.scroll-x.is-scroll-end::after { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .scroll-x::before, .scroll-x::after { transition: none; }
}

/* The same affordance on the block axis, for a box that clips DOWNWARD -- the
   slideshow stage, whose fixed height can cut a slide mid-control. It earns the
   signal more than the horizontal case does: a clipped table at least shows half
   a column, whereas a slide can end on whitespace between blocks and read as
   finished when it is not.

   Same wrapper contract and the same reason for it: the shaded edges must stay
   pinned while the content moves under them, so they belong to a box that does
   not itself scroll. .slideshow-stage already is one (the .slide inside it is
   the scroller), so it wears .scroll-y directly rather than gaining a wrapper.

   is-scroll-top / is-scroll-bottom mean "there is content past this edge",
   matching is-scroll-start / is-scroll-end on the inline axis. */
.scroll-y { position: relative; }
.scroll-y::before,
.scroll-y::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 2rem;
  z-index: 1;
  pointer-events: none;   /* never swallow a click on an answer field or button */
  opacity: 0;
  transition: opacity .15s ease;
}
.scroll-y::before {
  top: 0;
  background: linear-gradient(to bottom, var(--scroll-edge), transparent);
}
.scroll-y::after {
  bottom: 0;
  background: linear-gradient(to top, var(--scroll-edge), transparent);
}
.scroll-y.is-scroll-top::before { opacity: 1; }
.scroll-y.is-scroll-bottom::after { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .scroll-y::before, .scroll-y::after { transition: none; }
}
/* A print sheet has no scrolling to signal, and a gradient over the text is noise. */
@media print {
  .scroll-y::before, .scroll-y::after { display: none; }
  /* .scroll-x too: is-scroll-end is set from the SCREEN layout and print never
     re-runs scroll_affordance.js, so a shade could paint over the last column. */
  .scroll-x::before, .scroll-x::after { display: none; }
}


/* ── Publish/obligatory confirm-strip shared parts (Task 12) ────────────────
   These four selectors live HERE, not in builder.css, because the partials
   that emit them (_flag_strip_counts.html, _flag_strip_actions.html) are
   included by BOTH callers: the in-row strip on the builder page, and
   templates/courses/manage/node_confirm_flag.html -- the no-JS interstitial,
   which declares no extra_css block and therefore loads reset/tokens/app only. A rule in builder.css would style the strip and leave the interstitial
   bare. tests/test_publish_styles.py pins this. */
.flag-strip__counts { color: var(--text-secondary); }
.flag-strip__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: flex-start; }
/* The Hide button and its warning are one unit: the warning explains what that
   specific button is about to do, so they stack together and the warning must
   not drift alongside the Publish button in the wrap. */
.flag-strip__hide-action { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
/* A column flex item stretches by default, so without this the Hide button
   spans the full width of the warning below it -- a full-bleed danger bar,
   which reads as far more alarming than the sibling Publish button beside it
   and breaks the pairing with it. */
.flag-strip__hide-action > .btn { align-self: start; }

/* The no-JS interstitial only. reset.css zeroes every margin and this page has
   no .manage__head to establish rhythm, so the <h1>, the counts line, the form
   and the Cancel link would otherwise butt together. Attribute-scoped so
   node_confirm_delete.html (the page this one was modelled on, which has the
   same shape) is untouched -- a rhythm change there is a separate decision. */
.manage[data-flag-strip] > * + * { margin-top: var(--space-4); }

/* LOUD: the established warning treatment, i.e. .alert--warning's palette with
   a rail instead of a full border, so it reads as a note attached to the
   button above rather than a second page-level alert. */
.flag-strip__quiz-warning {
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-sm);
  background: var(--warning-subtle);
  border-inline-start: 3px solid var(--warning);
  color: var(--text-primary);
  max-width: 34rem;
}
.flag-strip__quiz-warning > * + * { margin-top: var(--space-2); }   /* reset.css zeroes margins */
/* QUIET: archived is not deleted, so the box STAYS -- only the amber goes.
   --text-secondary, never --text-tertiary: the latter fails AA at body size
   (see tests/test_text_colour_css.py and the design-language notes). */
.flag-strip__quiz-warning--quiet {
  background: var(--surface-sunken);
  /* The loud variant needs no outline: its amber fill defines the box on its
     own. --surface-sunken does not -- measured in both themes it sits within a
     few percent of --surface-base, so without a rim the quiet note loses its
     edges and stops reading as a bounded note at all, which is more than
     "quieter". The rim is what keeps "archived is not deleted" true. */
  border: 1px solid var(--border-default);
  border-inline-start: 3px solid var(--border-strong);
  color: var(--text-secondary);
}

/* --- Maths in node titles ([data-math-title], spec 2026-08-10) -------------- */
/* THE OVERRIDING INVARIANT: app.css is base.html:46; every katex.min.css link
   lands in {% block extra_css %} at :49 or later, so THE VENDOR STYLESHEET
   ALWAYS LOADS AFTER OURS on every page in this change. At equal specificity
   KaTeX wins, so each rule below is strictly MORE specific than the vendor rule
   it overrides -- the specificity comments are the check to re-apply whenever a
   value here is corrected.

   These live ONLY here, never in courses.css: seven of the twelve templates that
   newly gain KaTeX rendering link no courses.css at all, and a copy there could
   never match anything these do not already match. */

/* The vendored rule is `.katex{font:normal 1.21em KaTeX_Main,…;line-height:1.2}`
   -- a font SHORTHAND, so it resets every unset font longhand. Restoring only
   font-size leaves a maths run at `normal` weight inside a bold title, visibly
   lighter than the prose beside it. line-height is deliberately NOT inherited:
   the vendor's 1.2 is measured harmless on every title surface -- see the
   clamp note below, which is also why no clamp rule follows it. */
[data-math-title] .katex {                              /* (0,2,0) > (0,1,0) */
  font-size: inherit; font-weight: inherit; font-style: inherit;
}
/* math.js's INLINE_DELIMS maps \[ to display:true, and the vendored
   `.katex-display{display:block;margin:1em 0;text-align:center}` would then turn
   a display-maths TITLE into a centred block with 1em margins inside a nav
   button, a breadcrumb <li> or a tree row.

   THE WRAPPER RULE IS THE LOAD-BEARING ONE: a display:block wrapper is still a
   block-level box, so inside an <h1> that also holds words it splits the inline
   content into anonymous block boxes and renders on three lines. `margin: 0`
   removes the gaps but NOT the line break -- an inline-block child cannot make a
   block parent join the surrounding line box. Once the wrapper is inline-block
   it establishes its own formatting context, so a display:block .katex inside it
   is harmless.

   Display maths is forced inline EVERYWHERE, including the <h1>s where nothing
   is clipped and a centred block would technically fit: a title is a single line
   of prose by definition, and an author writing \[…\] in one is reaching for
   emphasis, not a standalone equation block. One rule, one behaviour. */
[data-math-title] .katex-display {                      /* (0,2,0) > (0,1,0) */
  display: inline-block; margin: 0; text-align: inherit;
}
/* DEFENSIVE, not load-bearing -- see above. It neutralises the vendor's
   text-align:center (a no-op inside a shrink-to-fit box anyway).

   white-space is DELIBERATELY NOT OVERRIDDEN, so the vendor's
   `white-space: nowrap` survives. A formula must not break mid-formula, and an
   <h1> is white-space:normal -- so `white-space: inherit` here would hand the
   formula back exactly the wrapping the vendor rule exists to prevent.
   (KNOWING DEVIATION FROM THE SPEC: §3's code block lists
   `white-space: inherit` while its own prose says nowrap is "which we in fact
   want to keep for a formula". The prose is right and the declaration was
   wrong; the test below pins the prose. Record this in the PR body.)

   The child combinator is REQUIRED: the vendor's `.katex-display>.katex` is
   (0,2,0), identical to `[data-math-title] .katex`, so reaching (0,3,0) is the
   only way to win without relying on source order. */
[data-math-title] .katex-display > .katex {             /* (0,3,0) > (0,2,0) */
  display: inline-block; text-align: inherit;
}

/* NO line-height / vertical-align CLAMP IS WARRANTED ON ANY TITLE SURFACE --
   MEASURED 2026-08-11, and the reason generalises, so read this before adding
   one. An earlier version of this file carried a clamp here
   (.analytics__matrix thead th, .breakdown-unit__title, .breakdown-node__title)
   and a matching one in courses.css (.unit-foot__navtitle, .unit-tree__label,
   .unit-tree__grouptitle, .unit-crumbs__label). Both were removed: an A/B on
   the real pages -- forcing the vendor's line-height:1.2 back onto the same
   .katex boxes -- moved every surface by AT MOST 0.19px:

     .breakdown-unit__title  +0.19    .unit-crumbs__label      +0.15
     .unit-tree__label       +0.06    everything else           0.00
     (fractions: 0.00 on ALL seven -- the strut dominates)

   THE MECHANISM, so it is not re-derived from scratch: every one of those
   surfaces inherits line-height 1.5 (measured 19.68/13.12, 19.2/12.8,
   18.72/12.48, 20.4/13.6, 24/16 -- all exactly 1.5), which already EXCEEDS the
   vendor's 1.2. The vendor value is never the taller one there, so clamping it
   changes nothing. `vertical-align: baseline` computed `baseline` with AND
   without the declaration -- the vendored stylesheet sets vertical-align on no
   .katex/.katex-display selector and baseline is the initial value, so that
   half was inert by construction.

   THE ONE PLACE THE MECHANISM IS REAL: reset.css's `h1, h2, h3, h4` rule
   gives them
   line-height:1.15, which is BELOW the vendor's 1.2 -- so on .lesson-unit__title
   (an <h1>) a clamp would actually bite. It is deliberately left unclamped, on
   the same footing as the .katex-display neutralisation and the five-line group
   clamp: a tall construct keeps its own height rather than being deformed to
   fit one text line.

   0.19px is the same delta this change already rejects as rendering noise for
   .result-row__title (49.2 vs 49.0) and .outline-unit__title -- one standard,
   applied consistently. If you think a surface needs a clamp, A/B it against
   the rule's ABSENCE first; measuring only with the rule present proves nothing.

   The analytics sticky header was the stated worry and is measured safe:
   .analytics{--ahead-h:2.4rem} with `thead th{position:sticky;
   height:var(--ahead-h);white-space:nowrap}` positions each header row at
   top:calc(var(--ahead-h) * counter0), so a cell taller than 2.4rem would
   desynchronise every sticky row beneath it. Measured 38.39px against a
   38.4px --ahead-h with a maths title present, clamp or no clamp: the explicit
   `height` caps it either way. (A long maths title still WIDENS the nowrap
   column instead of wrapping -- unchanged by any of this, and unrelated to
   line-height.)

   The .outline-unit__title measurement that started all this is recorded
   immediately below, and reaches the same conclusion by the same method. */

/* THE ORIGIN CASE. Task 11 measured .outline-unit__title (2026-08-10) and found
   NO rule is warranted there either -- an earlier version of this file shipped
   one, justified by a real but MISREAD measurement, and it was withdrawn.

   A plain maths-titled row (e.g. \(\sum_{i=1}^{n} a_i b_i\)) measures 24.0px,
   identical to a maths-free row. A row whose title carries a fraction
   (\(\frac{p}{q}\)) measures 32.1px, and a \[...\] display integral 39.6px --
   but an A/B against a same-specificity override that RESTORES the vendor's
   line-height:1.2 reproduces the IDENTICAL 32.1px / 39.6px. The growth is not
   caused by line-height at all (reset.css's `body` rule sets line-height:1.5, a
   24px box at 16px, which already EXCEEDS the vendor's 1.2 * 16px = 19.2px --
   line-height was never the taller value here) -- it is the fraction's own
   strut height, which line-height cannot touch. `vertical-align:baseline` is
   equally inert: the vendor sets vertical-align on no .katex/.katex-display
   selector, and baseline is the initial value regardless.

   Accepted, same as the .katex-display neutralisation and the five-line
   group clamp elsewhere in this file: a tall construct keeps its own height
   rather than being deformed to fit a single text line. */

/* --- Inline maths must not shed its trailing punctuation ------------------- */
/* REPORTED on a real unit: "...w jednej kolumnie to \(18\)." rendered with the
   full stop alone on the next line, although the author typed no space before
   it.

   THE CAUSE IS NOT WHITESPACE. KaTeX types a formula into `.katex .base`, which
   the vendored sheet makes `display: inline-block` -- an ATOMIC INLINE, which
   the line breaker treats as an object replacement character and is free to
   break after, punctuation or not. REPRODUCED at 264px with a plain
   `<span style="display:inline-block">18</span>.` and NOT reproduced with a
   plain `<b>18</b>.`, so the inline-block is the whole mechanism.

   Chromium resolves that boundary against the atomic inline's PARENT INLINE, not
   against the common ancestor of the two runs. MEASURED, same probe, sweeping
   the width from 420px down to 70px:
     * U+2060 WORD JOINER inserted between the formula and the stop -- STILL
       BREAKS (so the fix cannot be a character);
     * `white-space: nowrap` on a wrapper AROUND `.katex` plus
       `white-space: normal` back on `.katex` -- STILL BREAKS;
     * `white-space: nowrap` on `.katex` itself -- never breaks.
   `word-break: keep-all` and `line-break: strict` on the paragraph were also
   measured, and neither moves the break at all.

   THE GUARD IS LOAD-BEARING. KaTeX deliberately emits SEVERAL `.base` runs for a
   long inline formula, breaking at relations and binary operators, so that it
   can wrap inside the prose column; a blanket nowrap turns those into one
   unbreakable run -- MEASURED 570px of horizontal overflow out of a 300px
   paragraph on a 23-run formula. `:not(:has(.base ~ .base))` matches only the
   formulas that have NO internal break opportunity, where nowrap can therefore
   change nothing except the boundary this rule exists to close. Re-measured on
   the guarded rule: the 23-run formula keeps `white-space: normal`, wraps
   internally exactly as before, and overflows by 0px.

   A FOLLOWING SPACE STILL BREAKS -- checked, and it is the reason this is safe
   to ship globally: the space belongs to the prose element, whose own
   `white-space` still governs it. Same probe with " oraz kolejne slowa" after
   the formula broke at 295px with and without this rule.

   KNOWN LIMIT: a MULTI-run inline formula followed directly by punctuation is
   left alone, and still sheds it. Closing that needs the last `.base` and the
   punctuation inside one nowrap box, which means moving author text into
   KaTeX's output; not worth it until a real unit shows the shape.

   Specificity is (0,2,0) -- `.katex` plus the `:has()` argument, which `:not()`
   passes through -- against the vendor's (0,1,0) `.katex`, so the invariant at
   the head of this section holds. The vendor's only `white-space` declarations
   are `.katex .base` and `.katex-display > .katex`, both already `nowrap`, so
   this rule never contradicts one. */
.katex:not(:has(.base ~ .base)) { white-space: nowrap; }   /* (0,2,0) > (0,1,0) */

/* Public pages (privacy, getting-started). Prose column matching the 46rem
   content cap used elsewhere; no new design tokens. */
.public-page {
  max-width: 46rem; margin: var(--space-8) auto; padding: 0 var(--space-5);
  color: var(--text-primary);
}
.public-page h1 { margin-bottom: var(--space-2); }
/* tokens.css defines 1-6, 8, 10 only: var(--space-7) is UNDEFINED and would
   make the whole declaration invalid, silently dropping the margin. */
.public-page h2 { margin-top: var(--space-8); margin-bottom: var(--space-2); }
/* A `###` subsection: less air above than an h2, and only a little more than
   the h2's own bottom margin when it opens the section. */
.public-page h3 { margin-top: var(--space-6); margin-bottom: var(--space-2); }
.public-page h2 + h3 { margin-top: var(--space-3); }
/* reset.css's `* { margin: 0 }` leaves paragraphs and list items touching, with
   only line-height between them: a multi-paragraph section reads as one solid
   block. Restores paragraph rhythm; `.public-page h2` above supplies the space
   above a heading, so a plain bottom margin here is enough between a
   paragraph and the next one OR the next heading. */
.public-page p { margin-bottom: var(--space-4); }
.public-page ul, .public-page ol {
  margin-bottom: var(--space-4);
  padding-left: var(--space-5);
}
.public-page li + li { margin-top: var(--space-1); }
.public-page table {
  width: 100%; border-collapse: collapse; margin: var(--space-4) 0;
}
.public-page th, .public-page td {
  border: 1px solid var(--border-subtle); padding: var(--space-2);
  text-align: left; vertical-align: top;
}
/* Pricing plan comparison (emitted by the pricing_plans token, after nh3 --
   PUBLIC_PAGE_TAGS has no `h3`, so a markdown-authored version of this would
   be stripped). Deliberately NOT the raised-card-with-shadow treatment this
   replaced: every other element on this page is plain type set on the page
   ground, so three identical rounded boxes read as an imported widget rather
   than part of the document. Instead the three plans borrow the page's OWN
   table rule -- a hairline above and below the whole group (like
   `.public-page table`), with a single hairline between adjacent columns, the
   way a printed price list is set. A fixed 3-column grid, not auto-fit: there
   are always exactly three real plans (the open tier is not a card, see
   .pricing-cards__above below), so the column count is known, not
   content-driven. Selectors below need `.public-page` prefixed on to override
   the bare `.public-page ul` / `.public-page li + li` rules above, regardless
   of source order: those are (0,1,1) and (0,1,2), and TWO classes always
   outrank one no matter how many bare elements the weaker selector adds --
   unlike the table/scroller pair further up this file, this is not a
   specificity tie. */
.public-page .pricing-cards {
  list-style: none; padding-left: 0; margin: var(--space-4) 0 0;
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
  border-top: 1px solid var(--border-default);
  border-bottom: 1px solid var(--border-default);
}
.public-page .pricing-cards__item { margin-top: 0; }
.pricing-cards__item {
  padding: var(--space-5);
  border-left: 1px solid var(--border-default);
}
/* A rule only belongs BETWEEN two things -- the first column in the row gets
   none, since the group's own border-top/bottom already frames the row. */
.pricing-cards__item:first-child { border-left: none; }
/* Below the same width the rest of this sheet stacks at (the shared
   `max-width: 640px` breakpoint of `.app-header`, `.landing-hero` and
   `.roster-filter__field`), the grid collapses to one column: a left rule
   would then sit between vertically stacked items and cut a line THROUGH the
   middle of each row, not separate them, so the rule direction flips. */
@media (max-width: 640px) {
  .public-page .pricing-cards { grid-template-columns: 1fr; }
  .pricing-cards__item { border-left: none; border-top: 1px solid var(--border-default); }
  .pricing-cards__item:first-child { border-top: none; }
}
.pricing-cards__band {
  margin: 0 0 var(--space-2); font-size: 1rem; font-weight: 600;
  color: var(--text-secondary);
}
/* tabular-nums so the figures line up when a viewer compares the three
   columns at a glance -- a legibility device for THIS comparison, not a
   typeface change (still Inter). */
.pricing-cards__price {
  margin: 0 0 var(--space-4); font-size: 1.5rem; font-weight: 700;
  color: var(--text-primary); font-variant-numeric: tabular-nums;
}
/* Deliberately its own line, always -- not a wrap this width happens to
   trigger. "10 800 PLN / year" does not fit a column without wrapping, and an
   accidental wrap breaks right after the slash; a block period line breaks in
   the one place that reads well, at every width.

   --text-tertiary at this size is a documented AA failure in the LIGHT theme
   (#8A8477 on #F4F1EA measures ~3.3:1 -- tests/test_text_colour_css.py's own
   note; 13-14px text needs 4.5:1, and none of this block's sizes clear the
   18.66px-bold / 24px-regular bar for the "large text" 3:1 floor instead).
   The SAME token measures ~4.76:1 in dark (against #1A1816) and would pass
   there alone -- moved to --text-secondary anyway so this rule does not
   silently flip between compliant and not depending on theme. */
.pricing-cards__period {
  display: block; margin-top: var(--space-1); font-size: .85rem;
  font-weight: 400; color: var(--text-secondary);
}
.public-page .pricing-cards__bounds { padding-left: 0; margin-bottom: 0; }
.pricing-cards__bounds {
  list-style: none; display: flex; flex-direction: column; gap: var(--space-3);
}
/* Overrides the bare `.public-page li + li` margin above (0,1,2) -- this is
   (0,2,1) -- so the flex `gap` on the bounds list is the only vertical space,
   not gap PLUS a leftover margin on top of it. */
.public-page .pricing-cards__bounds li { margin-top: 0; }
/* Label stacked over value, never an inline "Label: value" run: the Polish
   labels (e.g. "Wsparcie") are long enough that the pair wraps mid-phrase at
   a column's width, and a stacked pair has no such failure mode.

   Label and value were --text-tertiary/--text-secondary (a two-step dimness
   ramp); tertiary fails AA at this size in light mode (see .pricing-cards__period
   above), so both step up one rung -- secondary/primary -- to keep the SAME
   relative distinction rather than converging on one colour. Weight carries
   the rest of that distinction (label lighter, value medium) so the pair
   still reads apart even for a viewer who cannot see the colour difference. */
.pricing-cards__bound { display: flex; flex-direction: column; gap: var(--space-1); }
.pricing-cards__label {
  font-size: .8125rem; font-weight: 400; color: var(--text-secondary);
}
.pricing-cards__value {
  font-size: .9rem; font-weight: 500; color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
/* Same AA fix as .pricing-cards__period above -- tertiary at .875rem fails in
   light mode. */
.pricing-cards__above { font-size: .875rem; color: var(--text-secondary); margin-top: var(--space-3); }
.public-page__notice {
  border: 1px solid var(--border-subtle); border-left-width: 4px;
  background: var(--surface-sunken); padding: var(--space-4);
  border-radius: var(--radius-sm);
}
.public-footer {
  display: flex; gap: var(--space-4); justify-content: center;
  padding: var(--space-4) var(--space-5);
  border-top: 1px solid var(--border-subtle);
  color: var(--text-secondary); font-size: .875rem;
}

/* == Print page frame (spec 2026-10-11-print-layout-for-units) == */
/* Global on purpose: every page extending base.html prints with safe margins and
   without nav chrome or flash messages. The per-unit running header lives in
   templates/courses/_print_page_header.html. Appended LAST so it follows every
   `.app-header`, `.app-header__cluster` and `.app-main` screen rule, including the
   max-width: 640px block (print on small paper matches it). Every hide is
   !important so position and specificity never decide it. */
@page { margin: 15mm; }
@media print {
  /* Paper stays white when "Background graphics" is on (the screen body is beige). */
  html, body { background: #fff !important; }
  /* 10pt, not the screen's 16px (12pt): a screen size read at arm's length is
     large on paper. Inter's tall x-height reads like most fonts' 11pt; below
     10pt a formula's scripts get hard to read. Every type size is rem/em, so
     all text and maths scale together; the px spacing tokens do not. */
  html { font-size: 10pt; }
  /* The spacing tokens are px, so they kept their screen size while the text
     shrank: at 0.75 the gaps follow it down (owner, 2026-10-20). After
     tokens.css, so these win the :root tie on source order. */
  :root {
    --space-1: 3px; --space-2: 6px; --space-3: 9px; --space-4: 12px;
    --space-5: 15px; --space-6: 18px; --space-8: 24px; --space-10: 30px;
  }
  .app-header__spacer, .app-header__cluster { display: none !important; }
  .app-header { padding: 0 0 var(--space-4); background: none; border: 0; }
  .app-main { padding: 0; max-width: none; }
  /* Only marked flash messages: content alerts that are direct children of
     .app-main (e.g. support's "Not emailed" warning) must keep printing. */
  .alert--flash { display: none !important; }
}
