/* 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); }

/* 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 "missing media" report: an advisory list (not an error), so each
   row carries a quiet warning left-accent rather than alarm colour. */
.export-missing {
  list-style: none;
  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 {
  padding: var(--space-3) var(--space-4);
  border-left: 3px solid var(--warning);
  line-height: 1.5;
}
.export-missing li + li { border-top: 1px solid var(--border-subtle); }

/* 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: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-visual .card {
  max-width: 12rem; margin: 0; font-size: .875rem;
  color: var(--text-secondary); text-align: center;
}
.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; }
/* 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; }
.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; }
.outline__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-5);
  border-bottom: 1px solid var(--border-subtle);
}
.outline__title { margin: 0; }
.outline__results { margin-left: auto; }

/* 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; }
.outline-tree > ul > .outline-node { margin-bottom: 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; }
.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); }

/* 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 D9's reset link 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]` (line ~645 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. padding-inline-end reserves the overlaid reset
   link's space; see the measured value recorded at the declaration. */
/* padding-inline-end MEASURED 2026-08-19 against Inter (document.fonts.check
   ('12.8px Inter') === true, not a fallback face): "Zacznij od nowa" renders
   98.578125px at .8rem (en "Start fresh" 63.59375px), plus the reset link's
   own margin-left: var(--space-2) clearance (~13.4px), rounded up.
   Re-measure if either translation changes. */
summary.outline-node__head {
  cursor: pointer; list-style: none;
  padding-inline-end: 7.5rem;
}
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:746-753) 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 auto; 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
   (~line 583 above): if that rule's specificity ever ties this one's (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);
}

.badge--done { margin-left: auto; background: var(--success-subtle); color: var(--success);
  border-color: color-mix(in srgb, var(--success) 45%, var(--border-default)); }

/* --- Practice-state reset controls --- */
.lesson-unit__reset,
.outline__reset { flex: none; }
.outline-node__reset {
  grid-column: 2;
  grid-row: 1;
  justify-self: end;
  align-self: baseline;
  margin-left: var(--space-2);
  font-size: .8rem;
  color: var(--text-secondary);
  text-decoration: underline;
}
.outline-node__reset:hover { color: var(--danger); }

/* Disabled while a tag filter is active (§5): Collapse all would hide every
   match. .btn sets cursor:pointer and .btn--ghost:hover sets a fill, neither
   reset by the UA and neither guarded with :not(:disabled) — without these two
   rules the disabled control looks and behaves like a live one, which is the
   "visibly does nothing" affordance the disable was chosen to avoid. */
.outline__toggle-all:disabled { opacity: .5; cursor: default; }
.outline__toggle-all:disabled:hover { background: transparent; }

/* 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; }

@media (max-width: 640px) {
  .outline__results { margin-left: 0; }
}

/* ── 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; }

/* 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__badge{display:inline-flex;vertical-align:middle;margin-inline-start:.25rem;color:var(--text-tertiary)}
.analytics__colhead--unmeasured{opacity:.72}
.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}

/* --- 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-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}
.breakdown-unit__title.is-done{color:var(--text-secondary)}
.breakdown-unit__review{font-size:.8rem}

/* 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:#fff}
.pill--submitted{background:var(--surface-sunken);color:var(--text-secondary);
  border:1px solid var(--border-strong)}
.pill--awaiting{background:#f5b942;color:#3a2a00}
.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; }

/* 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__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);
   these rules support the reveal + print sides. */
@media print {
  .reveal-armed .slide > .lesson-block:has(> .lesson-block__body > [data-reveal-gate]) ~ .lesson-block,
  .reveal-armed [data-tab-panel] > .tabs__child:has(> [data-reveal-gate]) ~ .tabs__child,
  .reveal-armed .spoiler__children > .spoiler__child:has(> [data-reveal-gate]) ~ .spoiler__child,
  .reveal-armed .callout__children > .callout__child:has(> [data-reveal-gate]) ~ .callout__child,
  .reveal-armed .ba__panel > .ba__child:has(> [data-reveal-gate]) ~ .ba__child {
    display: revert !important;
  }
  /* A fill-table gate's marked node IS the student's work, not a control, so it
     must survive printing. The other three families are buttons/forms/cyclers. */
  [data-reveal-gate]:not([data-filltablegate]) { display: none !important; }
}

/* 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:22 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); }

/* 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; }
}


/* ── 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:10 gives h1-h4
   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:6 sets body{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. */
