/* Shared stylesheet for both sites, selected by data-site="a2b"|"mpp" on <html>. One file,
   no preprocessor, no build step (BUILD.md P0-04 / P3-11). Sections:
   1. Reset & base    2. Tokens (P3-11)   3. Motion & focus (P3-10)   4. Typography
   5. Layout primitives & chrome (P3-14/15/16/19)   6. Buttons
   7. Legacy content-box contract (P3-12)   8. Tables incl. rwd-table (P3-12)
   9. Images & captions (P3-12/P3-18)   10. Vocab trainer hooks (P3-01..P3-07)
   11. Language list & TOC (P3-12)   12. mpp list-overview (P3-12)
   13. Site search (P3-17)   14. 404/410 (P3-19)   15. Print (P3-08)
   Classes intentionally left unstyled (harmless, no visual effect either way):
   `wp-image-<id>` (WordPress attachment-id class, cosmetic no-op), `not-lazy` (a lazy-load
   opt-out marker with no rebuild-side lazy-loader to opt out of), and the two `fancybox-iframe`
   / `{width:...,height:...}` occurrences on mpp's vokabeltrainer download page (dead lightbox
   JS hook - no fancybox ships in the rebuild, and the literal `{...}` string is not valid CSS
   class syntax to begin with). See tools/check_style_coverage.py IGNORE_CLASSES. */

/* ---------- 1. Reset & base ---------- */

*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  margin: 0;
  font-family: var(--font-sans);
  font-size: var(--font-size-base);
  line-height: 1.5;
  color: var(--text);
  background: var(--surface);
  /* Short pages (404/410/search, measured: 500-600px of bare background under the footer
     at 1440x1200) left the footer floating mid-screen. The only flex items are the header,
     #main-content and the footer - the skip link is absolutely positioned and the script
     tags are display:none - so the middle one takes the slack. */
  min-block-size: 100dvh;
  display: flex;
  flex-direction: column;
}

#main-content { flex: 1 0 auto; }

h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd {
  margin-block: 0 var(--space-3);
}

img, svg, video, audio { max-width: 100%; }
img, svg { height: auto; }

.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  white-space: nowrap;
  clip-path: inset(50%);
}

a { color: var(--brand-ink); }
a:not(:hover):not(:focus-visible) { text-decoration-thickness: from-font; }

table { border-collapse: collapse; width: 100%; }

button, input, select, textarea { font: inherit; color: inherit; }

/* ---------- 2. Tokens (P3-11) ---------- */

:root {
  /* shared across both brands */
  --ok: #9dce2c;
  --err: #fe1a00;
  /* --ok/--err are the legacy brand green and red, kept only for the `.button_green` /
     `.button_red` legacy classes. The quiz verdict deliberately uses neither, nor the
     pale tints that went with them (the yellow-green in particular): it is a white card
     with one coloured edge, like every other panel here. --ok-ink is the same green the
     widget's own `buttoncolor="green"` uses rather than a desaturation of --ok. */
  --ok-ink: #2e7d32;
  --err-ink: #b3261e;
  --info-tint: var(--surface-alt);
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --radius: 0.25rem;
  --radius-lg: 0.5rem;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.15);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.18);
  --focus-ring: 0.1875rem solid var(--focus);
  --focus-ring-offset: 0.125rem;
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 1rem;
  --space-4: 1.5rem;
  --space-5: 2.5rem;
  /* P8-16: the legacy `.top30px`/`.top60px` spacer utilities below hardcoded a raw px value
     each - the last "ad-hoc margins" in the file. Named as their own scale members (not
     folded into the 1..5 sequence, which is already pinned by other rules at 4/8/16/24/40px
     and isn't in strict size order with these) so every margin in the stylesheet now traces
     to a --space-* custom property instead of a bare number; pixel values are unchanged. */
  --space-6: 1.875rem;
  --space-7: 3.75rem;
  /* ONE content width, used by prose and by every component that is not a card grid.
     There used to be three bands - prose 72ch (621px), tables/media/trainer 52rem (832px)
     and the container itself 72rem (1152px) - so a single lesson page presented three
     different right edges within one screenful and read as ragged. 42rem is ~78
     characters at the body size, i.e. still inside the reading measure the 72ch band was
     protecting, and wide enough for a two-column vocab table and a 16:9 video. */
  --measure: 42rem;
  --header-h: 3.75rem;
  --z-header: 100;
  --z-overlay: 200;

  /* One content grid for the whole page. `--content-pad` is what the full-bleed bands
     (header, breadcrumbs) use so their content lines up with `main`'s and the footer's,
     which get there via `max-inline-size` + `margin-inline: auto` + `--gutter` instead.
     Before this, a 1440px page had four different left edges: header 16px, breadcrumbs
     16px, main 421px (a centred 72ch ribbon) and footer 137px. */
  --content-max: 64rem;
  --gutter: var(--space-3);
  --content-pad: max(var(--gutter), calc((100% - var(--content-max)) / 2 + var(--gutter)));
}

:root[data-site="a2b"] {
  --brand: #0396f7;
  --brand-ink: #0279c8;
  --brand-on-dark: #28a8fc;
  /* Was the legacy theme's `menu_highlight_background` #a0ce4e - a green that belonged to
     the ebook download button, not to a2b's identity, and which could never clear 3:1 on
     any light surface. Aliased to --brand (owner, 2026-08-02) rather than copied, so the
     nav's current-page underline is definitionally the signature blue and cannot drift. */
  --accent: var(--brand);
  --surface: #ffffff;
  --surface-alt: #efefef;
  --info-tint: #e7f6ff;
  --text: #595959;
  --text-nav: #333333;
  --footer-bg: #363839;
  --footer-bg-deep: #282a2b;
  /* darkened from the original footer_text_color #8c8989 (3.4:1) to clear AA (4.51:1) */
  --footer-text: #a0a0a0;
  --focus: var(--brand-ink);
  --font-size-base: 16px;
  /* A hairline that reads as a divider, and a zebra tint light enough that a control
     sitting on a striped row still reads as a control. `--surface-alt` served as both
     before, which is exactly why the audio buttons vanished on every second table row. */
  --border: #e1e4e6;
  --zebra: #f7f8f9;
}

:root[data-site="mpp"] {
  --brand: #d91c36;
  --brand-ink: #d91c36;
  --brand-on-dark: #e74259;
  --accent: #E93320;
  --surface: #ffffff;
  --surface-alt: #f3f3f3;
  --text: #333333;
  --text-nav: #333333;
  --footer-bg: #171717;
  --footer-bg-deep: #222222;
  --footer-text: #bfbfbf;
  --focus: var(--brand-ink);
  --font-size-base: 15px;
  --border: #e3e3e3;
  --zebra: #f8f8f8;
  /* mpp inherited the shared fallback `--info-tint: var(--surface-alt)`, which is also
     what `--zebra` is derived from - so a table row's hover state was indistinguishable
     from its resting stripe. Its own brand tint, like a2b's. */
  --info-tint: #fdeef1;
}

/* ---------- 3. Motion & focus (P3-10) ---------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

:focus { outline: none; }
:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
  border-radius: var(--radius);
}

.skip-link {
  position: absolute;
  inset-block-start: -3rem;
  inset-inline-start: var(--space-2);
  z-index: var(--z-overlay);
  background: var(--surface);
  color: var(--text-nav);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius);
  box-shadow: var(--shadow-md);
}
.skip-link:focus-visible { inset-block-start: var(--space-2); }

/* ---------- 4. Typography ---------- */

/* P3-09: self-hosted Noto subsets, unicode-range scoped so the browser only ever fetches
   the ONE subset a page's actual glyphs need (never all of them) - no third-party origin,
   no CSP exception, exactly the "per-script Noto subsets loaded on demand" the done-when
   bar asks for. `--font-noto` (used by `.lang-non-latin` below) resolves to a Noto face
   only where a real subset file exists here.
   Covered: Cyrillic, Greek, Arabic (incl. fa), Devanagari, Armenian, Georgian, Khmer,
   Thai. Deliberately NOT self-hosted: Japanese, Korean, Chinese - Google serves CJK as
   ~100 chunked files per language (no single-subset form exists), every major OS ships
   CJK system fonts, and committing ~300 font files for universally-installed scripts
   fails the maintenance principle. CJK falls through to `var(--font-sans)` by design.
   Files fetched once from Google Fonts (OFL-licensed, freely redistributable) and
   committed here - never fetched at request time from any third party. */
@font-face {
  font-family: "Noto Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/shared/fonts/notosans-cyrillic-400.woff2") format("woff2");
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: "Noto Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/shared/fonts/notosans-greek-400.woff2") format("woff2");
  unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
@font-face {
  font-family: "Noto Sans Arabic";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/shared/fonts/notosansarabic-arabic-400.woff2") format("woff2");
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1,
    U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74,
    U+FE76-FEFC;
}
@font-face {
  font-family: "Noto Sans Devanagari";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/shared/fonts/notosansdevanagari-devanagari-400.woff2") format("woff2");
  unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC,
    U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
@font-face {
  font-family: "Noto Sans Armenian";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/shared/fonts/notosansarmenian-armenian-400.woff2") format("woff2");
  unicode-range: U+0308, U+0530-058F, U+2010, U+2024, U+25CC, U+FB13-FB17;
}
@font-face {
  font-family: "Noto Sans Georgian";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/shared/fonts/notosansgeorgian-georgian-400.woff2") format("woff2");
  unicode-range: U+0589, U+10A0-10FF, U+1C90-1CBA, U+1CBD-1CBF, U+205A, U+2D00-2D2F, U+2E31;
}
@font-face {
  font-family: "Noto Sans Khmer";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/shared/fonts/notosanskhmer-khmer-400.woff2") format("woff2");
  unicode-range: U+1780-17FF, U+19E0-19FF, U+200C-200D, U+25CC;
}
@font-face {
  font-family: "Noto Sans Thai";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/shared/fonts/notosansthai-thai-400.woff2") format("woff2");
  unicode-range: U+02D7, U+0303, U+0331, U+0E01-0E5B, U+200C-200D, U+25CC;
}
/* One family list serves every script: only the face whose unicode-range matches a
   page's actual glyphs ever fetches; the unused @font-face entries cost nothing. */
:root { --font-noto: "Noto Sans", "Noto Sans Arabic", "Noto Sans Devanagari",
    "Noto Sans Armenian", "Noto Sans Georgian", "Noto Sans Khmer", "Noto Sans Thai"; }

/* P8-16: a deliberate weight scale, not the UA default's uniform bold on every level.
   Reuses the two emphasis weights already established elsewhere in this file (700 for
   .site-footer__col h2 and the nav's current-page indicator, 600 for .readMore and the
   quiz feedback) rather than inventing a third value. */
h1 { font-size: clamp(1.75rem, 1.4rem + 1.5vw, 2.5rem); line-height: 1.15; font-weight: 700; }
h2 { font-size: clamp(1.4rem, 1.2rem + 0.9vw, 1.875rem); line-height: 1.2; font-weight: 700; }
h3 { font-size: clamp(1.15rem, 1.05rem + 0.5vw, 1.375rem); line-height: 1.3; font-weight: 600; }
h4, h5, h6 { font-size: 1.05rem; line-height: 1.35; font-weight: 600; }

/* A heading belongs to what follows it, so the space above it has to be bigger than the
   space below it. Every heading used to carry margin on the BOTTOM only (the reset above),
   which made a new section's heading exactly as separated from the previous paragraph as
   two paragraphs are from each other - measured on the grammar pages: `p -> h3` 11px,
   `p -> p` 11px, and a `<div> -> h2` boundary at 0px. Nothing on the page said where a
   section started. The step down the levels is the same --space-* scale P8-16 put the
   rest of the rhythm on. Scoped to `main` so the footer's and the search panel's own
   heading rules stay untouched. */
main h2 { margin-block-start: var(--space-5); }
/* --space-6 is 30px: between 4 and 5 in size despite the name, because it was named for the
   legacy `.top30px` spacer. Three distinct steps, all clearly above the 16px paragraph gap
   so every level of heading actually starts a group. */
main h3 { margin-block-start: var(--space-6); }
main :where(h4, h5, h6) { margin-block-start: var(--space-4); }
main :where(h1, h2, h3, h4, h5, h6) { margin-block-end: var(--space-2); }
main h1 { margin-block-end: var(--space-3); }
/* A subheading directly under its own heading is part of the same break, not a second one.
   `:is()` rather than `:where()`: this has to outrank the per-level rules above. */
main :is(h1, h2, h3, h4, h5) + :is(h2, h3, h4, h5, h6) { margin-block-start: var(--space-2); }
/* An `<h2>` straight after the page's `<h1>` is a standfirst, not a section title - all 7
   in the corpus are a full sentence ("Select the language you'd like to learn - it's all
   for free:"), and at the section-title weight it competed with the `<h1>` above it. */
main h1 + h2 { font-weight: 400; }
/* Opening a padded box or a page with a gap above the first line is the one case where
   that break is wrong - the container's own padding or margin already provides it. */
main :where(h1, h2, h3, h4, h5, h6):first-child { margin-block-start: 0; }
/* German compounds routinely outrun a narrow column on their own: "Buchstabenkombinationen"
   wants 337px inside the 288px column a 320px viewport leaves, and pushed the whole
   document 33px wide on /de/polnisches-alphabet-aussprache/. Body prose reflows on its own
   spaces, so only headings need this. */
main :where(h1, h2, h3, h4, h5, h6) { overflow-wrap: break-word; }

/* Every page uses the same container, so `main`'s content edge is the same as the
   header's, the breadcrumbs' and the footer's at every width. The reading measure moved
   off the container and onto the prose itself (below), which is what makes that possible:
   P8-14's `:has()` pair widened only the two page shapes that carried a grid or a table,
   so a prose page still centred a 72ch ribbon in a 1425px viewport with 421px of dead
   space either side and no edge shared with anything else on the page. */
main {
  max-inline-size: var(--content-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
  padding-block: var(--space-5) var(--space-7);
  display: flow-root;
}
main.wide { max-inline-size: none; }
/* ONE text column, and it is the default rather than an opt-in list. Naming the elements
   that get the reading measure left every legacy wrapper out of it, and those wrappers
   carry text directly: measured at 1440px, a single page presented right edges at 849,
   889, 1017 and 1209 because `.boxContent50`'s bare text node, a 800px `<img>` and a
   `.contentBoxes` row were each sized by something different. Capping every section
   instead means a new legacy class cannot reintroduce a fourth width. */
main > * { max-inline-size: var(--measure); }
/* The card grid is the one component the wide container exists for - it reads as its own
   band rather than as a run-on of the text column - so a section carrying one spans the
   container. `:where()` keeps this at `main > *`'s specificity; it wins by order. */
main > :where(.language_list, :has(.language_list)) { max-inline-size: none; }
/* The other band is the two-column page header, in the shape the recovered markup kept it
   in: a row whose second column is nothing but the page image (the hubs and indexes; on
   lesson pages normalise.py rule 7 has already lifted that image into `.content-head`,
   which is floated into the same band below). It spans the container so the copy beside
   the image keeps the text column's width instead of being squeezed to ~390px by it.
   The second relative selector is the shape a2b's 101 hub pages actually use -
   `<div class="boxContent50"><p><img></p></div>` - which an `img:only-child` test misses,
   so the band stayed at the reading measure and its image stopped short of the card
   grid's right edge below it. */
main > .contentBoxes:has(> .boxContent > .boxContent50 > :is(img, picture):only-child,
                         > .boxContent > .boxContent50 > p:only-child > :is(img, picture):only-child) {
  max-inline-size: none;
}
/* Inside such a band, the reading matter still belongs to the text column. */
main :where(p, h1, h2, h3, h4, h5, h6, blockquote, dl, table, figure,
            .toc_list, #toc_container, .youtube-facade, .video-wrapper, .elastic-video,
            .vocab-picker, .download-row, .boxInfo) {
  max-inline-size: var(--measure);
}
main :where(ul, ol):not(.language_list) { max-inline-size: var(--measure); }
/* P8-04: recovered markup floats an image inside a heading (mpp's step images are
   `<h3><img class="alignright">...`), so the float escapes the heading box and the next
   heading rides up beside it. Prose still wraps around the float; only headings clear. */
h1, h2, h3, h4, h5, h6 { clear: both; }
/* Every in-page anchor on both sites is a heading id (the generated TOC, the vocab
   section links). Without this the sticky header lands on top of the heading you just
   jumped to. */
:where(h1, h2, h3, h4, h5, h6, [id]) { scroll-margin-block-start: calc(var(--header-h) + var(--space-3)); }

.lang-non-latin { font-family: var(--font-noto, var(--font-sans)); }

/* ---------- 5. Layout primitives & chrome ---------- */

.site-header {
  background: var(--surface);
  border-block-end: 1px solid var(--border);
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding-inline: var(--content-pad);
  block-size: var(--header-h);
}
/* P8-10: sticky + the scroll-elevation shadow apply to both sites now - a2b had this,
   mpp did not, and there is no reason for the two to diverge. */
.site-header {
  position: sticky;
  inset-block-start: 0;
  z-index: var(--z-header);
  box-shadow: none;
  transition: box-shadow 0.15s ease;
}
/* `.site-header--elevated` is toggled by trainer.js::initHeaderElevation() once the page
   has scrolled past the header itself - a static header at the top of the page has
   nothing to be elevated above. */
.site-header--elevated { box-shadow: var(--shadow-sm); }
@media (max-width: 47.9375em) {
  .site-header { position: static; }
}

.site-header__logo { display: flex; align-items: center; flex-shrink: 0; }
.site-header__logo-full, .site-header__logo-mark { display: flex; align-items: center; block-size: 100%; }
/* P8-13: the mark replaces the full lockup in the mobile header only. It deliberately does
   NOT also swap in on scroll: the scrolled header is exactly as wide as the unscrolled one,
   so swapping there bought nothing and shifted the whole nav 84px left mid-scroll. */
.site-header__logo-mark { display: none; }
/* P8-10: a fixed inline-size (not a fixed block-size) is what makes a2b's and mpp's
   lockups read as the same size - their viewBoxes are not the same aspect ratio
   (188.68x56.69 vs 180.56x57.71), so matching block-size left mpp's ~6% narrower. */
.site-header__logo-full svg { inline-size: 7.5rem; block-size: auto; color: var(--brand); }
.site-header__logo-mark svg { inline-size: 2.25rem; block-size: auto; color: var(--brand); }
@media (max-width: 47.9375em) {
  .site-header__logo-full { display: none; }
  .site-header__logo-mark { display: flex; }
}

.site-nav { display: flex; align-items: center; gap: var(--space-3); flex: 1 1 auto; min-inline-size: 0; }
.site-nav__panel { display: flex; align-items: center; gap: var(--space-3); flex: 1 1 auto; min-inline-size: 0; }
.site-nav__list { display: flex; list-style: none; margin: 0; padding: 0; gap: var(--space-1); }
.site-nav__list a {
  color: var(--text-nav);
  text-decoration: none;
  padding-inline: var(--space-3);
  border-radius: var(--radius);
  min-block-size: 44px;
  display: flex;
  align-items: center;
}
/* A real hover surface rather than a colour-only change - at 44px tall the link is a
   target, and nothing showed where its edges were. */
.site-nav__list a:hover, .site-nav__disclosure > summary:hover { background: var(--surface-alt); }
/* P8-05: child-scoped, not descendant. The <details> language panel lives INSIDE
   .site-nav__list, so a bare descendant selector also decorated the current language
   inside the dropdown. */
.site-nav__list a:hover, .site-nav__list > li > a[aria-current="page"] { color: var(--brand-ink); }
.site-nav__list > li > a[aria-current="page"] {
  font-weight: 700;
  /* Still not load-bearing - aria-current and the colour change above carry the state -
     but --accent is a2b's brand blue now, so it does clear the 3:1 graphical-object bar
     the old green (1.84:1) never could. */
  border-block-end: 3px solid var(--accent);
}
.site-nav__disclosure .language_list a:hover { background: var(--surface-alt); }
/* The panel's own current item keeps a visual state, just not the top-level decoration. */
.site-nav__disclosure .language_list a[aria-current="page"] { font-weight: 700; }

.site-nav__toggle {
  display: none;
  background: none;
  border: 0;
  min-block-size: 44px;
  min-inline-size: 44px;
  cursor: pointer;
}
.site-nav__close {
  display: none;
  background: none;
  border: 0;
  min-block-size: 44px;
  min-inline-size: 44px;
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
  align-self: flex-end;
}
/* P8-10: the search field anchors to the right of the primary nav list instead of
   sitting immediately after it with the rest of the header empty. Above the nav
   breakpoint it is collapsed behind `.site-search__toggle`: an open field cost 208-224px
   of a band capped at --content-max, which is why mpp's nav list was compressing its own
   labels (`Alphabet & Pronunciation` wanted 223px and got 194) even at 1440px.
   trainer.js::initSearchToggle() owns the `hidden` attribute, and only sets it once it
   knows the toggle is on screen - with no JS the field simply stays open. */
.site-search { margin-inline-start: auto; }
.site-search__toggle {
  display: none;
  margin-inline-start: auto;
  background: none;
  border: 0;
  color: var(--text-nav);
  border-radius: var(--radius);
  min-block-size: 44px;
  min-inline-size: 44px;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
}
.site-search__toggle:hover { background: var(--surface-alt); color: var(--brand-ink); }
.site-search__icon { inline-size: 1.25rem; block-size: 1.25rem; }
@media (min-width: 64em) {
  .site-search__toggle { display: flex; }
  /* Anchored to `.site-header` (positioned since P8-10) rather than to the toggle, for
     the same reason the language dropdown is: the toggle sits at the end of the band, so
     a panel anchored to it would open past the right edge. */
  .site-search {
    position: absolute;
    inset-inline-end: var(--space-3);
    inset-block-start: 50%;
    translate: 0 -50%;
    z-index: var(--z-overlay);
    inline-size: min(24rem, calc(100% - (2 * var(--space-3))));
    margin-inline-start: 0;
  }
  .site-search .site-search__input { max-inline-size: none; }
}
@media (max-width: 63.9375em) {
  .site-nav__toggle { display: block; }
  .site-nav__close { display: flex; }
  .site-nav__panel {
    display: none;
    position: absolute;
    inset-inline: 0;
    inset-block-start: var(--header-h);
    z-index: var(--z-overlay);
    background: var(--surface);
    flex-direction: column;
    align-items: stretch;
    box-shadow: var(--shadow-md);
    padding: var(--space-3);
    gap: var(--space-3);
    /* The panel is out of flow, so it cannot lengthen the document: anything past the
       viewport edge is unreachable by scrolling. That is invisible with five nav rows and
       fatal with a2b's 42-language list open (1328px tall). Cap it and let it scroll. */
    max-block-size: calc(100vh - var(--header-h));
    overflow-y: auto;
  }
  .site-nav[data-open="true"] .site-nav__panel { display: flex; }
  .site-nav__list { flex-direction: column; gap: 0; }
  /* Separated rows (P8-10) - the mobile panel used to be one unbroken list. */
  .site-nav__list > li { border-block-end: 1px solid var(--surface-alt); }
  .site-search { flex-shrink: 1; inline-size: 100%; margin-inline-start: 0; }
  .site-search__input { max-inline-size: none; inline-size: 100%; }
}

/* No `position: relative` here on purpose: the dropdown below is anchored to `.site-header`
   (the nearest positioned ancestor, sticky since P8-10), not to this list item. Anchoring it
   to the item put its left edge ~250px in, so a 46rem panel ran 172px past the viewport at
   768px - wide enough for four columns, not wide enough to place them. */
.site-nav__disclosure > summary {
  list-style: none;
  cursor: pointer;
  color: var(--text-nav);
  padding-inline: var(--space-3);
  border-radius: var(--radius);
  min-block-size: 44px;
  display: flex;
  align-items: center;
}
.site-nav__disclosure > summary::-webkit-details-marker { display: none; }
.site-nav__disclosure > summary::after { content: "\25BE"; margin-inline-start: var(--space-1); font-size: 0.75em; }
.site-nav__disclosure[open] > summary::after { content: "\25B4"; }
/* P8-11: the panel is `.lang-dropdown` (grid + the "All languages" footer row below it),
   not `.language_list` itself - the grid needs to sit inside the same floating box as
   that footer row rather than being the box.
   It spans the header band rather than being capped at 46rem: at 46rem/4 columns the 42
   languages made an 856px-tall panel, so on a 1280x800 laptop the "All languages" row
   landed at y=899 - off screen, and P8-11 had (correctly) removed the internal scroll
   that used to make it reachable. Six columns over the full band is 7 rows instead of 11.
   The max-block-size below is a backstop for short viewports only; it does not engage at
   any size the grid is designed for. */
.site-nav__disclosure .lang-dropdown {
  position: absolute;
  /* Absolute positioning resolves against the header's PADDING box, i.e. the full
     viewport - so the header's own `--content-pad` is what lines the panel's edges up
     with the nav, `main` and the footer instead of letting it span the whole window. */
  inset-inline: var(--content-pad);
  inset-block-start: 100%;
  z-index: var(--z-overlay);
  background: var(--surface);
  box-shadow: var(--shadow-md);
  padding: var(--space-3);
  max-block-size: calc(100vh - var(--header-h) - var(--space-4));
  overflow-y: auto;
}
.site-nav__disclosure .language_list { grid-template-columns: repeat(6, minmax(0, 1fr)); }
/* P8-02: the dropdown's cells are text (+ a flag icon) only - it must not inherit the
   card grid's column direction from section 11, nor its card surface. */
.site-nav__disclosure .language_list a {
  flex-direction: row;
  align-items: center;
  border: 0;
  background: none;
  padding: var(--space-1) var(--space-2);
  /* A row, not a card: the label sits beside its flag, so centring it inside the leftover
     space left every name starting at a different x. */
  text-align: start;
  /* Six columns is ~160px each, so a long German name ("Kambodschanisch") must break
     rather than paint outside its cell. */
  overflow-wrap: break-word;
}
/* P8-11/P8-13: a small fixed-size flag icon beside the language name, overriding the
   card grid's full-width `.language_list img` rule (higher specificity: two classes vs
   one class + a tag) rather than adding a second image contract. */
.lang-dropdown__flag { inline-size: 1.5rem; block-size: auto; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
/* "All languages" is a distinct footer row, not an identical grid cell (P8-11). */
.lang-dropdown__all {
  display: block;
  text-align: center;
  font-weight: 600;
  min-block-size: 44px;
  padding-block: var(--space-2);
  margin-block-start: var(--space-2);
  border-block-start: 1px solid var(--surface-alt);
}
@media (max-width: 63.9375em) {
  .site-nav__disclosure .lang-dropdown {
    position: static;
    box-shadow: none;
    /* Not `max-content` + `max-inline-size: none`: in the static mobile panel that sized the
       box to the widest two cells (409.5px at 390px) with nothing left to cap it. `auto`
       fills the panel instead, so the two columns divide the width they actually have. */
    inline-size: auto;
    max-block-size: none;
  }
  .site-nav__disclosure .language_list { grid-template-columns: repeat(2, 1fr); }
}

.site-search { flex-shrink: 1; min-inline-size: 0; }
.site-search__input {
  border: 1px solid var(--border);
  border-radius: 999px;
  padding-inline: var(--space-3);
  min-block-size: 44px;
  inline-size: 100%;
  max-inline-size: 14rem;
  background: var(--surface-alt);
}
.site-search__input:focus { background: var(--surface); }

.breadcrumbs {
  padding-block: var(--space-3) 0;
  padding-inline: var(--content-pad);
  font-size: 0.875rem;
  color: var(--text);
}
.breadcrumbs ol { list-style: none; display: flex; flex-wrap: wrap; margin: 0; padding: 0; gap: var(--space-1); }
.breadcrumbs li + li::before { content: "\203A"; margin-inline-end: var(--space-1); opacity: 0.55; }
.breadcrumbs a { color: var(--text); text-decoration: none; }
.breadcrumbs a:hover { color: var(--brand-ink); text-decoration: underline; }
/* The breadcrumb band already supplies the space under the header on the pages that
   carry it; without this the two paddings stack to 64px. */
.breadcrumbs + main { padding-block-start: var(--space-4); }

.pager {
  display: flex;
  justify-content: space-between;
  gap: var(--space-3);
  margin-block: var(--space-5);
  padding-block: var(--space-3);
  border-block-start: 1px solid var(--border);
}
.pager a { color: var(--brand-ink); text-decoration: none; }
.pager__next { margin-inline-start: auto; text-align: end; }

.site-footer { background: var(--footer-bg); color: var(--footer-text); }
.site-footer a { color: var(--brand-on-dark); text-decoration: none; }
/* P8-12: hover-only underlines - a link band this dense read as a wall of underscores
   otherwise. */
.site-footer a:hover { text-decoration: underline; }
.site-footer :focus-visible { outline-color: var(--brand-on-dark); }
.site-footer__links {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
  gap: var(--space-4) var(--space-3);
  padding-block: var(--space-5);
  padding-inline: var(--gutter);
  max-inline-size: var(--content-max);
  margin-inline: auto;
}
.site-footer__links ul { list-style: none; margin: 0; padding: 0; }
.site-footer__links li { margin-block-end: var(--space-2); }
/* P8-12: labelled columns - plain interface chrome, not recovered marketing copy (see
   chrome_data.FOOTER_GROUP_LABELS). */
.site-footer__col h2 {
  margin: 0 0 var(--space-2);
  font-size: 0.875rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--footer-text);
}
.site-footer__col--logo { display: flex; align-items: flex-start; justify-content: flex-end; }
.site-footer__logo { display: inline-flex; align-items: center; }
.site-footer__legal {
  background: var(--footer-bg-deep);
  padding: var(--space-3);
  font-size: 0.8125rem;
  text-align: center;
}
.site-footer svg { block-size: 1.75rem; inline-size: auto; color: var(--brand-on-dark); }

/* Sits in the footer's links band as a second grid column (one column at 320px, where
   auto-fit collapses). It used to share the header's nav panel with the nav list and the
   search field, which is the layout P3-14 already had to fight to keep inside 320px. */
.lang-switcher { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.lang-switcher a {
  text-decoration: none;
  min-block-size: 44px;
  display: flex;
  align-items: center;
  padding-inline: var(--space-1);
}
/* The current locale is the page you are already on: it stays a link (so the pair reads
   as one control and both hreflang hints survive) but is marked, not offered. */
.lang-switcher a[aria-current="true"] { font-weight: 700; }

/* ---------- 6. Buttons ---------- */

.button, .button_gray, .button_red, .button_green {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* `justify-content` only centres the label's box; a label that wraps needs this too. */
  text-align: center;
  min-block-size: 44px;
  padding-inline: var(--space-3);
  border-radius: var(--radius);
  text-decoration: none;
  font-weight: 600;
  border: 1px solid transparent;
  cursor: pointer;
}
.button, .button_gray { background: var(--surface-alt); color: var(--text-nav); border-color: var(--border); }
/* Recovered markup always nests the real link inside a `<p class="button">` wrapper
   (measured: 1,513 occurrences, and no `p.button` without one), so the pill surface was
   painted twice - one pill boxed inside another. The wrapper keeps its block role and
   sheds the surface. */
.button:has(> a) { background: none; border-color: transparent; padding-inline: 0; }
.button_red { background: var(--err); color: #fff; }
.button_green { background: var(--ok); color: #1a1a1a; }
.button:hover, .button_gray:hover { background: var(--brand); color: #fff; }

/* P8-07: primary/secondary/ghost, layered on top of the legacy `.button*` contract
   above (kept, not replaced - check_style_coverage.py requires every legacy class to
   keep a rule) rather than a second competing button system. `--brand-ink`, not
   `--brand`, is the primary fill: it is the token already measured AA against white
   text (check_contrast.py's "brand-ink text on surface" pair - the ratio is symmetric,
   so brand-ink as a *background* under white text clears the same bar). Each modifier
   is self-sufficient (own border-style/radius, not borrowed from a sibling class) since
   several of their real uses (quiz "next question", flashcard nav, .trainer__swap) carry
   no other `.button*` base class at all. */
.button--primary, .button--secondary, .button--ghost {
  border-style: solid;
  border-width: 1px;
  border-radius: var(--radius);
}
.button--primary { background: var(--brand-ink); color: #fff; border-color: var(--brand-ink); }
.button--primary:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
.button--secondary { background: var(--surface); color: var(--brand-ink); border-color: var(--brand-ink); }
.button--secondary:hover { background: var(--info-tint); }
/* Ghost is the tertiary tier, not an invisible one: `.trainer__swap` is the only control
   in its row, so with no border at all it read as a stray line of text. */
.button--ghost { background: none; color: var(--text-nav); border-color: var(--border); }
.button--ghost:hover { background: var(--surface-alt); }

/* ---------- 6b. Download row (P6-01) - a2b lesson pages, above the fold ---------- */

.download-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-block-end: var(--space-4);
}

/* ---------- 7. Legacy content-box contract (P3-12) - shared ---------- */

.contentBoxes { display: flex; flex-wrap: wrap; gap: var(--space-4); margin-block: var(--space-3); }
/* A card grid is a section of its own, not the next paragraph, so it needs more air under
   its heading than the 16px the grid's own margin gives it. */
main :where(h1, h2, h3, h4, h5, h6) + .contentBoxes { margin-block-start: var(--space-4); }
/* P8-02: a box with no width class shrink-wrapped to its content (measured: 256px inside
   a 589px row on /learn-languages/), so the flag grid inside it had to overflow to fit
   its columns. `:where()` keeps this at the same specificity as the explicit box classes
   below, and it is declared FIRST, so every one of those still wins. */
.contentBoxes > :where(*) { flex: 1 1 100%; min-inline-size: 0; }
.boxContent, .boxFullWidth { flex: 1 1 100%; }
/* P8-14: `.boxContent` never established a flex context of its own, so its
   `.boxContent50` children's flex-basis rules below never activated - two side-by-side
   50% columns (e.g. "previous lesson" / "next lesson") silently stacked to one column at
   every width instead of only below the 40em breakpoint (measured: /learn-languages/
   armenian/basic-nouns/, and the same shape recurs 812x across both sites). */
.boxContent { display: flex; flex-wrap: wrap; gap: var(--space-4); }
.boxContent50 { flex: 1 1 100%; min-inline-size: 0; }
@media (min-width: 40em) {
  .boxContent50 { flex: 1 1 calc(50% - var(--space-4)); }
  /* The other half of the two-column page header (see `.content-head`): where the image
     was NOT lifted out of the recovered markup it is still its own `.boxContent50`, and
     an even 50/50 split left a 256px flag adrift in a 548px column. The image column
     takes the width of the image and the copy takes the rest. */
  .boxContent50:has(> :is(img, picture):only-child,
                    > p:only-child > :is(img, picture):only-child) {
    flex: 0 1 auto;
    align-self: center;
  }
}
@media (min-width: 64em) {
  /* Once the row spans the container (see the header band in section 4), the image column
     takes exactly the part of it the text column does not use, so this header's copy ends
     on the same right edge as every other section on the page. Scoped to that row: in any
     other row the leftover would be negative and the column would collapse to nothing.
     The image is right-aligned inside the column and never stretched past its own size. */
  .contentBoxes:has(> .boxContent > .boxContent50 > :is(img, picture):only-child,
                    > .boxContent > .boxContent50 > p:only-child > :is(img, picture):only-child)
    > .boxContent > .boxContent50:has(> :is(img, picture):only-child,
                                      > p:only-child > :is(img, picture):only-child) {
    flex: 0 0 calc(100% - var(--measure) - var(--space-4));
    text-align: end;
  }
}
.boxContent10 { flex-basis: 10%; }
.boxContent33 { flex-basis: 33%; }
.boxContent66 { flex-basis: 66%; }
.boxContent90 { flex-basis: 90%; }

/* Padding on the inline axis indented the whole recovered body 16px relative to the
   template's own `<h1>`, which sits outside it. */
.contents { padding-block: var(--space-3); }
/* P8-15: this centred every block it wrapped, and what it wraps is overwhelmingly reading
   matter - a2b's lesson and hub intros, mpp's whole home body, the ergative-case footnote,
   the "audio is still being recorded" notice (measured: 2,242 blocks, 1,518 carrying a
   heading and 332 a full paragraph). Centred is now the exception, and it has to be asked
   for by a class combination that means it. */
.centeredText { text-align: start; }
/* The one deliberate exception in the corpus: the "open the vocabulary trainer" promo that
   closes 1,293 lesson pages - a heading, a thumbnail and one link, no reading matter. Its
   paragraph keeps auto margins so the measure cap above cannot shove it off the card's
   centre line. */
.boxContent.centeredText.top1em,
.boxContent50.centeredText.noBorder { text-align: center; }
.boxContent50.centeredText.noBorder p { margin-inline: auto; }
.boxContent.centeredText:not(.top1em) { text-align: start; }
.introText { font-size: 1.125rem; color: var(--text); }
.introTextTwoLine { font-size: 1.125rem; max-inline-size: 40ch; }
.homeText { font-size: 1.0625rem; }
.blogPost { max-inline-size: var(--measure); }
.highlight { background: var(--info-tint); padding: var(--space-1) var(--space-2); border-radius: var(--radius); }
.boxInfo { background: var(--info-tint); border-inline-start: 3px solid var(--brand); padding: var(--space-3); }

.padding1em { padding: var(--space-3); }
.paddingLeftRight1em { padding-inline: var(--space-3); }
.marginPadding0 { margin: 0; padding: 0; }
.noBorder { border: none; }
.borderBottom { border-block-end: 1px solid var(--surface-alt); }
.displayNone { display: none; }
.white { color: #fff; }
.indented { padding-inline-start: var(--space-4); }
.resetLineHeight { line-height: normal; }
.fullWidth, .full-width { inline-size: 100%; }
.fixedWidth { inline-size: fit-content; }
.no-style { list-style: none; padding-inline-start: 0; }

.top0px { margin-block-start: 0; }
.top1em { margin-block-start: var(--space-3); }
.top30px, .top-30px { margin-block-start: var(--space-6); }
.top60px { margin-block-start: var(--space-7); }
.bottom0px { margin-block-end: 0; }
.bottom1em { margin-block-end: var(--space-3); }

.belowTable { margin-block-start: var(--space-4); }
.brand { font-style: italic; }

.textAlignRight { text-align: end; }
.readMore { color: var(--brand-ink); font-weight: 600; text-decoration: none; }
.readMore::after { content: " \2192"; }

/* a2b-only width helpers, shared numeric scale with mpp's width10..50 (section 8) */
.boxContent10, .width10 { flex-basis: 10%; }

/* ---------- 8. Tables incl. rwd-table (P3-12) ---------- */

/* P8 review: 330 of the 761 generated tables carry NO class at all (measured: all of them
   on mpp, e.g. every vocabulary table on /polish-vocabulary-phrases/**), so every rule
   below - borders, zebra, header band, cell padding, the responsive behaviour - missed a
   third of the corpus and those tables rendered as bare UA-default grids. Nothing caught
   it because check_style_coverage.py audits the classes a page uses, and a table with no
   class has none to audit. The shared treatment therefore keys off the ELEMENT; the class
   rules below keep only what is genuinely specific to them. */
main table { margin-block: var(--space-2) var(--space-4); }
/* `:not(:empty)` because the recovered mpp markup contains genuinely empty spacer rows;
   a 44px floor on those turns each one into a blank striped band. */
main table :where(td, th) {
  text-align: start;
  padding: var(--space-2) var(--space-3);
  border-block-end: 1px solid var(--border);
}
main table :where(td, th):not(:empty) { min-block-size: 44px; }
main table thead { background: var(--surface-alt); color: var(--text-nav); }
/* The band has to be on the cell, not only on the `<thead>`: once the cell goes sticky it
   is the only part that travels, and a transparent one lets the rows it is meant to label
   scroll straight through it. */
main table thead th { background: var(--surface-alt); font-weight: 700; border-block-end: 2px solid var(--brand); }
main table tbody tr:nth-child(even) { background: var(--zebra); }
main table tbody tr:hover { background: var(--info-tint); }
/* The recovered vocabulary tables separate groups of entries with a row whose cells are
   all empty (e.g. the verb groups on /polish-vocabulary-phrases/important-polish-verbs/).
   Zebra striping plus a cell border rendered each one as a thin broken band across the
   table; the row means a gap, so it is drawn as one. */
main table tbody tr:not(:has(> :where(td, th):not(:empty))) { background: none; }
main table tbody tr:not(:has(> :where(td, th):not(:empty))) > :where(td, th) {
  border-block-end: 0;
  padding-block: var(--space-2);
}
@media (min-width: 48em) {
  /* Sticky header row on tall tables, docked under the site header - both sites' headers
     are sticky from this same breakpoint up (section 5, unified in P8-10), so below it
     there is nothing to dock under and above it a `0` offset parks the header row
     underneath the site header instead (measured on mpp: 60px of the row covered).
     Under `border-collapse: collapse` a border belongs to the table, not to the cell, so
     the brand rule below the header stays behind the moment the cell sticks; an inset
     shadow is painted by the cell itself and travels with it. */
  main table thead th {
    position: sticky;
    inset-block-start: var(--header-h);
    z-index: 1;
    box-shadow: inset 0 -2px 0 var(--brand);
  }
}
@media (max-width: 47.9375em) {
  /* An unclassed table has no `data-th` guarantee (measured: 171 of the 330 label every
     cell, 115 label some, 44 none), so it gets the scroll box rather than the card
     collapse - a card collapse on a partly-labelled table produces nameless rows. */
  main table:not([class]) { display: block; max-inline-size: 100%; overflow-x: auto; }
  main table:not([class]) :where(td, th) { overflow-wrap: break-word; }
}

.rwd-table td, .rwd-table th { text-align: start; padding: var(--space-2) var(--space-3); border-block-end: 1px solid var(--border); min-block-size: 44px; }
/* P8 review: a full-saturation brand band with white bold text was the loudest thing on a
   lesson page, above the lesson's own heading. A light band closed by a brand rule reads
   as a header without competing, and keeps the sticky version legible over scrolling rows
   (it has to stay opaque, so no tint-over-transparent here). */
.rwd-table thead { background: var(--surface-alt); color: var(--text-nav); }
.rwd-table thead th { background: var(--surface-alt); font-weight: 700; border-block-end: 2px solid var(--brand); }
/* P8-08: zebra + hover, inside the existing contract - no `display` touched anywhere in
   this block, so the mobile card collapse below and mpp's column-visibility set further
   down are both untouched. `--zebra`, not `--surface-alt`: the two were the same value,
   and `.audio-btn`'s surface is `--surface-alt`, so every button on an even row was
   invisible - the row of controls read as alternately present and missing. */
.rwd-table tbody tr:nth-child(even) { background: var(--zebra); }
.rwd-table tbody tr:hover { background: var(--info-tint); }

@media (max-width: 47.9375em) {
  /* P8-20: collapsing thead/tr/td to block/flex un-tables the layout, but a `<table>`
     element left at its default `display: table` still sizes itself to its widest
     un-wrapped cell content (here: a vocab word plus its audio button on one line),
     which is what was overflowing the viewport - the row/cell collapse alone never
     capped the table's own box. `display: block` + a width cap makes the table a
     normal block box that can't exceed its container; `flex-wrap` on the cell lets a
     word+button pair that's still too wide drop to a second line instead of forcing
     the row wider. */
  .rwd-table:not(.no-label) { display: block; max-inline-size: 100%; }
  .rwd-table:not(.no-label) tbody { display: block; }
  .rwd-table:not(.no-label) thead { display: none; }
  .rwd-table:not(.no-label) tr { display: block; margin-block-end: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-lg); }
  .rwd-table:not(.no-label) td {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: var(--space-3);
    border-block-end: 1px solid var(--border);
  }
  .rwd-table:not(.no-label) td[data-th]::before {
    content: attr(data-th);
    font-weight: 600;
    color: var(--text-nav);
    flex-shrink: 0;
  }
}

/* Cell padding, borders, the header band (including its sticky behaviour) and the
   zebra/hover pair all come from the element-keyed block at the top of this section now,
   so these two classes carry only what is theirs: their own scroll box below 48em.
   Keeping the duplicates here also gave every `.mediaTable`/`.responsiveTable` cell a
   (0,1,1) `text-align: start` that a legacy `.alignCenter` on the cell could only beat on
   source order. */


/* mpp column-visibility set: show progressively more columns as viewport grows */
.optional640 { display: none; }
.optional480 { display: none; }
.optional320 { display: none; }
@media (min-width: 20em) { .optional320 { display: table-cell; } }
@media (min-width: 30em) { .optional480 { display: table-cell; } }
@media (min-width: 40em) { .optional640 { display: table-cell; } }
.essential, .persist { display: table-cell; }
@media (max-width: 47.9375em) {
  /* P8-20 review: these two have no card collapse - mpp's legacy optional320/480/640 set
     above is their only responsive mechanism, and it is not enough on its own (the
     alphabet table still ran 114px past a 320px viewport, and hiding another column does
     not close it - measured: still 114px with all three optional sets off). Contain it in
     its own scroll box. `table-layout: fixed` was tried first and rejected: it fits the
     metric but shreds the cells, breaking "scarce" across three lines mid-word. */
  .mediaTable, .responsiveTable { display: block; max-inline-size: 100%; overflow-x: auto; }
  .mediaTable :where(td, th), .responsiveTable :where(td, th) { overflow-wrap: break-word; }
}

.width10 { inline-size: 10%; } .width15 { inline-size: 15%; } .width20 { inline-size: 20%; }
.width30 { inline-size: 30%; } .width33 { inline-size: 33%; } .width35 { inline-size: 35%; }
.width40 { inline-size: 40%; } .width50 { inline-size: 50%; }

/* These are legacy "lay this list out in N columns" markers, but the recovered markup
   also puts them on the things AROUND the list: 164 `.boxContent50 .list-overview`
   chapter cards, 108 headings and 12 paragraphs. A multi-column context there split a
   card's heading, image and buttons across three columns and cut section headings in
   half. Columns only mean anything on a list, so only a list gets them - `:where()`
   keeps the specificity the rest of the legacy contract expects. */
:where(ul, ol).list-overview { columns: 1; gap: var(--space-4); }
:where(ul, ol).list-overview20 { columns: 1; }
:where(ul, ol).list-overview40 { columns: 1; }
@media (min-width: 30em) {
  :where(ul, ol).list-overview20 { columns: 2; }
  :where(ul, ol).list-overview40 { columns: 2; }
}
@media (min-width: 48em) {
  :where(ul, ol).list-overview { columns: 3; }
  :where(ul, ol).list-overview40 { columns: 3; }
}
/* A column break through the middle of an entry reads as two unrelated fragments, and
   these lists are mostly one wrapper `<li>` holding a nested `<ul>` (the seven cases, the
   three tenses), so the break has to be avoided on every level, not just the outer one. */
:where(ul, ol)[class*="list-overview"] li { break-inside: avoid; }

/* A chapter card on mpp's hubs is a title, the chapter image and one or two call-to-action
   links, all loose inside one `<p>` (164 cards: 64 with both links, 76 with one, 24 with
   none). In inline flow each link ended wherever its own label did; as a row they divide
   the card's full width between them. */
.boxContent50.list-overview > p {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}
.boxContent50.list-overview > p > a:has(> img) { flex: 1 1 100%; }
.boxContent50.list-overview > p > a.button_gray { flex: 1 1 0; }

/* ---------- 9. Images & captions (P3-12/P3-18) ---------- */

.singleImage { display: block; margin-inline: auto; }
/* P8-02: a caption is its own block under the card image, never a flex sibling squeezed
   to a one-character sliver. */
.captionText {
  display: block;
  inline-size: 100%;
  font-size: 0.875rem;
  color: var(--text);
  margin-block-start: var(--space-1);
}
/* P8-15 review: `.captionText` only ever appears inside a `.language_list` card (measured:
   1,732 of 1,732), directly under that card's own heading, so it follows the heading's
   alignment. `.imageCaption` is not a caption at all - all 164 are the `<h4>` that titles a
   chapter card on mpp's hubs, above the image, so it is sized as a card title instead of
   being shrunk to caption size. */
.imageCaption { font-size: 1.25rem; font-weight: 700; text-align: center; }
.content-head figure { margin: 0; }
/* P8-17: a defensive height ceiling for the lesson/hero figure - real lesson images top
   out at 512x512 (image-manifest.json), well under this cap, so nothing currently
   shrinks; it exists so a future oversized source image can never dominate the
   viewport. `object-fit: contain` keeps it from cropping if the cap ever does engage. */
.content-head { margin-block-end: var(--space-4); }
/* The recovered page header is a two-column block: the lesson/chapter image on one side,
   the intro copy and its call to action on the other. normalise.py rule 7 lifts that
   image out of the recovered `.boxContent50` into `.content-head`, which left the image
   sitting alone on a full-width band with the intro stacked underneath it. Floating it
   back beside the copy restores the original shape without moving any markup. */
/* mpp's recovered header is TWO `.content-head` blocks - the figure, then the intro copy -
   so the second one must be free to flow beside the float, not cleared past it. Only a
   FURTHER figure (a mid-document one) clears. */
.content-head ~ .content-head:has(figure) { clear: both; }
@media (min-width: 64em) {
  /* Only the block that opens the page is the header image; mpp carries further
     `.content-head` figures mid-document (2 on /polish-alphabet-pronunciation/), and
     floating those too put two images side by side with the body copy squeezed between
     them.
     The width is the container's leftover once the text column has taken its measure, so
     the image sits in the band the text column does not use and the copy beside it keeps
     the SAME right edge as every other section on the page - the float does not narrow
     anything. Below 64em there is no leftover to put it in, so it stacks.
     The `:where(:not(...))` tail carries no specificity and is what check_layout.py's
     assertion 1 requires of every `float:` in this file - see the legacy `.alignLeft`
     rules above for the case it exists to prevent. */
  h1 + .content-head:has(figure):where(:not(tr):not(thead):not(tbody):not(tfoot)) {
    float: inline-end;
    inline-size: calc(100% - var(--measure) - var(--space-5));
    margin-inline-start: var(--space-5);
    margin-block-end: var(--space-4);
  }
  /* The image is the second block in source order, so floating it alone can only place it
     from the heading's bottom edge down: the band beside the heading stayed empty and the
     image ran past the intro copy into the first section. Floating the heading into the
     text column as well puts both at the top of the block, so heading, intro and image
     share one top edge. `:has(+ .content-head > figure)`, not a nested `:has()`, which is
     invalid and would drop the whole rule. */
  main > h1:has(+ .content-head > figure):where(:not(tr):not(thead):not(tbody):not(tfoot)) {
    float: inline-start;
    inline-size: var(--measure);
  }
  /* The block after the image is the one that has to start under the heading rather than
     beside it. A plain block would do that on its own (its line boxes shorten), but a2b's
     download row and every `.contentBoxes` are flex containers, and a flex container
     avoids floats as a whole box - between the two floats it was squeezed to 0px wide.
     Only the heading's float is cleared, so the copy still runs beside the image. */
  main > h1 + .content-head:has(figure) + * { clear: inline-start; }
  /* Scaled down to the column if the source is bigger, never stretched up to it: real
     header images run 311-720px wide (image-manifest.json) and an upscaled 311px source
     looks worse than one left at its own size. */
  h1 + .content-head figure img { max-inline-size: 100%; block-size: auto; }
  /* Only a full-width band can reach into the image's column, so only a band clears.
     Clearing every following section instead (which is what this rule used to do) left a
     column of dead space beside the image on every lesson page, because the section that
     would have filled it is capped at the text column and cannot overlap the float
     anyway. Headings already clear (P8-04). */
  main > :where(.language_list, :has(.language_list)) { clear: both; }
}
.content-head figure img { max-block-size: 36rem; object-fit: contain; border-radius: var(--radius-lg); }
main figcaption { font-size: 0.875rem; color: var(--text); }

/* P8-04: the legacy float classes are applied class-wide in recovered markup, including
   to a <tr> inside a <thead> on mpp's vocab pages - a floated row collapses the header
   band to content width. :where() keeps specificity at 0 so the rest of the legacy
   contract is unaffected. The rules stay (they are part of the legacy class contract
   check_style_coverage.py enforces); they just cannot reach table internals. */
.alignLeft:where(:not(tr):not(thead):not(tbody):not(tfoot)),
.alignleft:where(:not(tr):not(thead):not(tbody):not(tfoot)) {
  float: inline-start;
  margin-inline-end: var(--space-3);
}
/* Every one of the 1,316 uses of this class in the corpus is a `<td>`/`<th>` (mpp's six
   alphabet pages), and `display: block` takes a cell out of its row: three of the six
   columns stacked into a single anonymous block and the table lost its grid. The class
   means "centre this cell". */
.alignCenter { text-align: center; }
.alignright:where(:not(tr):not(thead):not(tbody):not(tfoot)) {
  float: inline-end;
  margin-inline-start: var(--space-3);
}
.link-subtitle { font-size: 0.875rem; color: var(--text); }

.video-wrapper, .elastic-video {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}
.video-wrapper iframe, .video-wrapper video,
.elastic-video iframe, .elastic-video video {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
}
.center50 { max-inline-size: 50%; margin-inline: auto; }

/* ---------- 10. Vocab trainer hooks (P3-01..P3-07) ---------- */

/* The word is what the page is for, and it was rendering at body size in the page's
   lowest-emphasis colour, i.e. quieter than the surrounding prose. */
.vocab_tables_cell { vertical-align: middle; font-size: 1.0625rem; color: var(--text-nav); }
/* The trainer's own generated table (renderTablesTrainer): `.rwd-table` carries the
   responsive behaviour, this carries the block's own spacing. */
.vocab_tables { inline-size: 100%; margin-block: var(--space-3); }
.vocab_inline_audio, .vocab_audio_icon, .audio-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-block-size: 44px;
  min-inline-size: 44px;
  cursor: pointer;
}
/* P8-06: one audio-button design regardless of origin - normalise.py's corpus buttons
   (4,896 of them, previously a bare unclassed <button> with a visible text label) and
   trainer.js's own rendered ones (previously a borderless 44x44 hit area whose only
   visual was a ~10px glyph)
   both carry this class now. The glyph itself stays on the pre-existing
   .vocab_audio_icon::before rule below and the hidden accessible label on the
   pre-existing .vocab_audio_text rule - both origins share them, nothing is duplicated. */
/* The hidden accessible label below is `position: absolute` with no offsets, so it needs
   a positioned ancestor of its own: without one it resolves against the initial
   containing block and escapes any scroll box it sits in - measured at 320px, the 64
   labels inside the alphabet table's horizontal scroller pushed the whole document 162px
   wide even though the table itself was correctly clipped. */
.audio-btn {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--brand-ink);
  padding-inline: var(--space-3);
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.audio-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
.audio-btn:active, .audio-btn[data-playing="true"] { background: var(--brand-ink); border-color: var(--brand-ink); color: #fff; }
/* Compact circular variant where a full pill would crowd the row: inside a vocab table
   cell next to a short word, or inside the trainer picker's own compact controls. A
   full pill remains the default everywhere else (inline prose, e.g. /questions/).
   The margin is symmetric because the two corpora put the button on opposite sides of
   the word - a2b renders `word <button>`, mpp renders `<button> word` - so neither a
   start- nor an end-margin alone separates both. `middle` because the cell's contents
   are mixed inline content, where a 44px control on the text baseline sits visibly low. */
.vocab_tables_cell .audio-btn, .vocab-picker .audio-btn,
.quiz__term .audio-btn, .quiz__verdict-body .audio-btn {
  padding-inline: 0;
  margin-inline: var(--space-1);
  vertical-align: middle;
}
/* Inside a sentence the pill's own fill and outline read as a second, competing object
   next to the word; the glyph alone does not. Only the RESTING state loses them - the
   `:not()`s are what keep `.audio-btn:hover` / `[data-playing]` (equal specificity, and
   declared earlier in this file) from being cancelled, which left a white glyph on a
   white background. The 44px target is untouched either way. */
.quiz__term .audio-btn:not(:hover):not(:active):not([data-playing="true"]),
.quiz__verdict-body .audio-btn:not(:hover):not(:active):not([data-playing="true"]) {
  background: transparent;
  border-color: transparent;
}
main table .audio-btn { margin-inline: var(--space-1); vertical-align: middle; }
.vocab_audio_icon::before { content: "\25B8"; font-size: 1.1em; }
.vocab_audio_text {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  white-space: nowrap;
  clip-path: inset(50%);
}
.vocab_tables_flag { inline-size: 32px; block-size: 32px; object-fit: contain; vertical-align: middle; }
.vocab_tables_gender { font-style: italic; color: var(--text); }

/* P3-17 click-to-load YouTube placeholder. No thumbnail is fetched: that would contact
   YouTube on load, which is the whole thing the facade exists to avoid. */
.youtube-facade {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 16 / 9;
  inline-size: 100%;
  margin-block: var(--space-3);
  background: var(--surface-alt);
  border-radius: var(--radius);
  /* P8-17: a fill colour alone read as a flat grey box with no elevation cue.
     `--shadow-md` (not `-sm`, already used one size up for the site's other floating
     panels: the nav dropdown, the search results list) reads as a raised card the same
     way those do, without needing a border that would be invisible against its own
     same-colour fill. */
  box-shadow: var(--shadow-md);
}
.youtube-facade__play {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-block-size: 44px;
  padding: var(--space-2) var(--space-3);
  border: none;
  border-radius: var(--radius);
  background: var(--brand);
  color: #ffffff;
  font: inherit;
  cursor: pointer;
  box-shadow: var(--shadow-sm);
}
.youtube-facade__play::before { content: "\25B6"; font-size: 1.1em; }
.youtube-facade__play:hover { background: var(--brand-ink); }
.youtube-facade > iframe {
  inline-size: 100%;
  block-size: 100%;
  border: 0;
  border-radius: var(--radius);
}
/* Applied to the text run itself, never to the <td>: `direction` on a cell also mirrors
   the responsive `data-th` label, the flex order and the inline audio button, which made
   those UI affordances jump sides row by row in a mixed LTR/RTL table. `unicode-bidi`
   isolates the run so a phrase's own punctuation resolves against its own direction. */
.vocab_tables_rtl { direction: rtl; unicode-bidi: isolate; }

.trainer {
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-3);
  /* The exercise is a panel, not a page section: stretched to the full container the
     four answer options laid out as four thin full-width columns. */
  max-inline-size: var(--measure);
}
.trainer__controls { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-block-end: var(--space-3); }
/* P3-10: every trainer control is a real tap target, not just whatever its text needs.
   P8-07: no default fill here any more - every button this renders now also carries one
   of the primary/secondary/ghost modifier classes (section 6), which supply it. A bare
   `.trainer__controls button` fallback would out-specificity-fight those modifiers
   (same 1-class-plus-tag weight, source order would decide, not the modifier). */
.trainer__controls button {
  min-block-size: 44px;
  padding-inline: var(--space-3);
  border-radius: var(--radius);
  cursor: pointer;
}
.trainer__progress { font-size: 0.875rem; color: var(--text); }
.trainer[hidden] { display: none; }

/* P3-03 flashcard deck. These classes are emitted by renderFlashcard() and had no rule
   at all, so the "card" rendered as a plain <li> bullet - see check_style_coverage.py,
   which only ever covered classes recovered from the legacy corpus. */
.trainer__deck { list-style: none; margin: 0 0 var(--space-3); padding: 0; }
.trainer__card {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  align-items: center;
  justify-content: center;
  min-block-size: 7rem;
  padding: var(--space-3);
  background: var(--surface-alt);
  border-radius: var(--radius-lg);
  text-align: center;
}
.trainer__card[hidden] { display: none; }
.trainer__front { font-size: 1.5rem; font-weight: 700; }
.trainer__back { font-size: 1.25rem; color: var(--brand-ink); }
.trainer__back[hidden] { display: none; }

/* P4-05 multiple-choice quiz (the standalone /tools/vocab widget and every hub/lesson
   context that resolves to it - see parseProps()'s mode default). It reads as an app
   panel, not a content box: its own title bar, a raised surface and a brand top edge.
   Answering advances the question on the same click and the verdict for the question
   just answered stays below the new one (the original widget's own behaviour), so the
   space under the options is always real content rather than reserved emptiness. */
.trainer--quiz {
  padding: 0;
  overflow: hidden;
  background: var(--surface-alt);
  box-shadow: var(--shadow-md);
  border-block-start: 3px solid var(--quiz-accent);
  /* `buttoncolor` is one of vocab.php's own query vars and the original configurator
     offered exactly these four. They are the legacy widget's palette, not site tokens,
     so they are literal here - each clears 4.5:1 on both sites' surfaces, which is why
     the same value can serve as both the UI accent and the ink for the asked word.
     Unset (the 2,766 blocks that name no colour) keeps the site's own brand, whose
     accent and ink are two different tokens because a2b's brand blue is not text-safe. */
  --quiz-accent: var(--brand);
  --quiz-ink: var(--brand-ink);
}
.trainer--quiz[data-button-color="blue"] { --quiz-accent: #0279c8; --quiz-ink: #0279c8; }
.trainer--quiz[data-button-color="red"] { --quiz-accent: #c2183a; --quiz-ink: #c2183a; }
.trainer--quiz[data-button-color="green"] { --quiz-accent: #2e7d32; --quiz-ink: #2e7d32; }
.trainer--quiz[data-button-color="gray"] { --quiz-accent: #4a4a4a; --quiz-ink: #4a4a4a; }
.trainer__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background: var(--surface);
  border-block-end: 1px solid var(--border);
}
.trainer__pair { margin: 0; font-weight: 700; font-size: 0.875rem; letter-spacing: 0.02em; }
.trainer__sound {
  min-block-size: 44px;
  padding-inline: var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--text-nav);
  cursor: pointer;
}
.trainer__sound[aria-pressed="true"] { border-color: var(--quiz-accent); color: var(--quiz-ink); }
.trainer__body { padding: var(--space-3); }
.quiz__question {
  font-size: 1.125rem;
  line-height: 1.35;
  margin-block: 0 var(--space-3);
}
/* The word being asked about, inside the question sentence - `inline-flex` keeps its
   audio button on the same baseline without turning the sentence itself into flex
   items (which would break normal line wrapping of the surrounding words). */
.quiz__term {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--quiz-ink);
  vertical-align: middle;
}
.quiz__options {
  list-style: none;
  margin: 0 0 var(--space-3);
  padding: 0;
  display: grid;
  gap: var(--space-2);
  /* One per row, at every width. Answers are phrases of very different lengths, so a
     multi-column grid produced ragged, unequal-looking targets - and the mobile layout
     was already a single column, so the two now agree. */
  grid-template-columns: 1fr;
}
.quiz__option {
  inline-size: 100%;
  min-block-size: 52px;
  padding: var(--space-2) var(--space-3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  font-weight: 600;
  color: var(--text-nav);
  text-align: start;
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.05s ease;
}
.quiz__option:hover { border-color: var(--quiz-accent); background: var(--info-tint); }
.quiz__option:active { transform: translateY(1px); }
@keyframes quiz-option-enter {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}
.quiz__options--enter > li { animation: quiz-option-enter 200ms ease-out both; }
.quiz__options--enter > li:nth-child(2) { animation-delay: 40ms; }
.quiz__options--enter > li:nth-child(3) { animation-delay: 80ms; }
.quiz__options--enter > li:nth-child(4) { animation-delay: 120ms; }
@media (prefers-reduced-motion: reduce) {
  .quiz__options--enter > li { animation: none; }
}
/* The verdict panel. It is a white card with a coloured start edge, not a tinted block:
   the same "neutral surface, one coloured edge" idiom as the panel's own top edge and as
   `.boxInfo`, so the exercise reads as one design instead of the legacy widget's
   yellow-green/pink status boxes. `--ok`/`--err` (the legacy brand green and red) are
   deliberately not used here at all - only their already-AA text inks. The verdict is
   never colour-only (WCAG 1.4.1): the title carries a check or cross glyph and the body
   spells the correct answer out in words. */
.quiz__feedback {
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border);
  border-inline-start: 3px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  margin-block-end: var(--space-2);
}
.quiz__feedback[data-quiz-result="correct"] { border-inline-start-color: var(--ok-ink); }
.quiz__feedback[data-quiz-result="wrong"] { border-inline-start-color: var(--err-ink); }
.quiz__verdict-title { margin: 0 0 var(--space-1); font-weight: 700; font-size: 0.9375rem; }
.quiz__feedback[data-quiz-result="correct"] .quiz__verdict-title { color: var(--ok-ink); }
.quiz__feedback[data-quiz-result="correct"] .quiz__verdict-title::before { content: "\2713\00a0"; }
.quiz__feedback[data-quiz-result="wrong"] .quiz__verdict-title { color: var(--err-ink); }
.quiz__feedback[data-quiz-result="wrong"] .quiz__verdict-title::before { content: "\2717\00a0"; }
.quiz__verdict-body { margin: 0; }
/* The two terms inside the verdict sentence: without this a phrase that ends in its own
   "?" runs straight into the connecting words. */
.quiz__asked { font-weight: 600; }
.quiz__answer { font-weight: 700; color: var(--quiz-ink); }
.trainer__controls button[hidden] { display: none; }
/* P3-06 in-page source-language swap (no navigation, URL unchanged). P8-07: moved to
   sit next to (after) the table/deck/quiz it operates on instead of above it - see
   mountOne() in trainer.js - so it now carries `margin-block-start`, not `-end`, and
   the ghost fill (section 6) rather than a hardcoded one. */
.trainer__swap {
  min-block-size: 44px;
  padding-inline: var(--space-3);
  margin-block-start: var(--space-2);
  border: none;
  border-radius: var(--radius);
  cursor: pointer;
}
/* P3-03 showdestlangroman: the romanisation shown beside a non-Latin form. */
.vocab_tables_roman { color: var(--text); font-size: 0.875em; }

/* P6-01 concept-pivot tables ("<word> in different languages"): the flag beside the
   language name. The file is 256px square (build_images.py caps flags there), so the
   size has to be pinned here as well as on the element. */
.vocab_tables_flag {
  inline-size: 32px;
  block-size: 32px;
  vertical-align: middle;
  margin-inline-end: var(--space-1);
}

/* P8-01 language + chapter picker (a2b language hubs and both standalone trainer pages).
   The legacy shortcode's own filter UI, restored - `hidefilters="no"` on all 85 blocks.
   P8-21: a hub's own controls are lesson-only (no language select - the hub page is
   already that language) plus a quiet link back to the language index; only the 2
   standalone trainer pages (their own recovered copy: "select a language and lesson
   using the drop-down menus below") keep both selects. Both cases use one compact ROW
   per field (label beside its select, not stacked above it) from 30em up, so the whole
   control strip stays inside the offset-to-exercise budget regardless of how many
   fields it holds. */
.vocab-picker { margin-block: var(--space-3); }
.vocab-picker__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* Left, like everything else in the content column - a centred control strip above a
     left-aligned exercise reads as floating and unrelated. */
  justify-content: flex-start;
  gap: var(--space-3);
  margin-block-end: var(--space-2);
}
.vocab-picker__field {
  display: flex;
  flex-direction: column;
  align-items: start;
  gap: var(--space-1);
  font-weight: 600;
  text-align: start;
  /* A <select> is as wide as its widest <option>, and the German chapter list contains
     "Hallo, Guten Tag und Auf Wiedersehen". A flex item's automatic minimum size is its
     min-content width, so `max-inline-size` alone still overflowed by 4px at 320px. */
  min-inline-size: 0;
  max-inline-size: 100%;
}
@media (min-width: 30em) {
  .vocab-picker__field { flex-direction: row; align-items: center; gap: var(--space-2); }
}
.vocab-picker__select {
  min-inline-size: 0;
  max-inline-size: 100%;
  min-block-size: 44px;
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  font-weight: 400;
}
.vocab-picker__select[data-vocab-picker-chapter] {
  /* `inline-size` (a DEFINITE value), not just `max-inline-size`, so the language +
     chapter fields fit on ONE row at the 30em+ breakpoint even with the longest real
     chapter name ("Hallo, Guten Tag und Auf Wiedersehen"). A <select>'s intrinsic/
     preferred width - what its flex-item ancestor uses for shrink-to-fit sizing - is
     based on its widest <option> text and ignores `max-inline-size` entirely (verified:
     capping only max-inline-size left the ancestor field ~90px too wide, still wrapping
     the row). A definite `inline-size` makes the used width no longer intrinsic, so the
     ancestor sizes correctly; `max-inline-size: 100%` still protects narrow viewports.
     Only the CHAPTER select needs this - the language select's own options are short
     enough that its natural intrinsic width never triggers the bug, and forcing it to
     the same 16rem would waste ~100px and reintroduce the wrap from the other side.
     The closed control clips overflowing text; the native dropdown list itself is not
     width-constrained by this. */
  inline-size: 16rem;
}
.vocab-picker__mount { min-block-size: 44px; }
/* The hub's quiet "all languages" link - a real tap target, not styled as a peer of the
   selects (it isn't one: it navigates away, the selects don't). */
.vocab-picker__all-languages {
  display: inline-flex;
  align-items: center;
  min-block-size: 44px;
  color: var(--brand-ink);
  text-decoration: none;
  font-weight: 600;
}
.vocab-picker__all-languages:hover { text-decoration: underline; }

/* The embeddable widget's own menu bar (vocab.php's "Show Menu Bar" option, i.e.
   `hidefilters=no` - the recovered default on every real link to it). Built at runtime
   by trainer.js's initVocabEmbed() from data baked onto the mount, so it never appears
   in a page's static markup and never fetches chapters.json. */
.vocab-menu {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-block-end: var(--space-2);
}
.vocab-menu__field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  min-inline-size: 0;
  max-inline-size: 100%;
}
.vocab-menu__label { font-size: 0.875rem; font-weight: 600; }
.vocab-menu__select {
  min-inline-size: 0;
  max-inline-size: 100%;
  min-block-size: 44px;
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

/* "Configure the Widget:" - the embed-code builder on mpp's 2 webmaster-facing widget
   pages. The heading is recovered; the form under it was plugin markup R1 never got, so
   until now the pages promised a configurator and shipped an empty box. */
.widget-config { margin-block: var(--space-3); }
.widget-config__fields {
  display: grid;
  gap: var(--space-3);
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  margin-block-end: var(--space-4);
}
.widget-config__field { display: flex; flex-direction: column; gap: var(--space-1); min-inline-size: 0; }
.widget-config__label { font-weight: 600; }
.widget-config__help { font-size: 0.8125rem; color: var(--text); }
.widget-config__control {
  min-inline-size: 0;
  max-inline-size: 100%;
  min-block-size: 44px;
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  font: inherit;
}
.widget-config__preview {
  inline-size: 100%;
  max-inline-size: 100%;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}
.widget-config__code {
  inline-size: 100%;
  min-block-size: 6rem;
  padding: var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-alt);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.8125rem;
}
.widget-config__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-block: var(--space-2); }
.widget-config__actions button { min-block-size: 44px; padding-inline: var(--space-3); border-radius: var(--radius); cursor: pointer; }
.widget-config__steps { margin-block: var(--space-2) var(--space-3); padding-inline-start: var(--space-4); }
.widget-config__note { background: var(--info-tint); padding: var(--space-2) var(--space-3); border-radius: var(--radius); }

/* /tools/vocab/embed/ - the chromeless page third parties iframe. It has no site header
   or footer, so it owns its own outer spacing; the attribution link is the only thing
   on the page besides the widget. */
.vocab-embed { padding: var(--space-2); }
.vocab-embed__by { margin-block: var(--space-1) 0; font-size: 0.8125rem; text-align: end; }

/* ---------- 11. Language list & TOC (P3-12) ---------- */

/* P8-02: `.language_list` serves two different things - the nav dropdown (text-only rows,
   overridden in section 5) and the a2b home / language-index / language-hub CARD grids,
   where each <a> holds an <img> plus a caption. The card case is the default here.
   `min(Xrem, 100%)` in every track: a fixed minimum wider than the viewport is what made
   the grid overflow by 75px at 390px. Grid items also need `min-inline-size: 0` or the
   image's min-content contribution blows the track out again. */
.language_list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-3);
  grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
}
.language_list > * { min-inline-size: 0; }
/* The _Nx modifiers keep their legacy meaning as a density hint - a smaller track minimum
   for a denser grid - rather than a hard column count, so none of them can force a track
   wider than the viewport. At the 62rem content band these land on 4 / 5 / 5 / 6 columns.
   `_5x` survives only in the recovered corpus (a2b's DE home 10-language grid, which
   P6-09 replaces with the generated `_6x` one), and `_6x` is that generated 42-language
   grid - which had no rule of its own at all, since check_style_coverage.py reads the
   recovered corpus and cannot see a class the generator invents. */
.language_list_3x { grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); }
.language_list_4x { grid-template-columns: repeat(auto-fit, minmax(min(11rem, 100%), 1fr)); }
.language_list_5x { grid-template-columns: repeat(auto-fit, minmax(min(11rem, 100%), 1fr)); }
.language_list_6x { grid-template-columns: repeat(auto-fit, minmax(min(9rem, 100%), 1fr)); }
.language_list_item_single {
  /* Measured: this class only ever lands on an `<a>` wrapping one lesson image (40
     occurrences, 31 files - a2b's hub "1. Learn the <Language> Alphabet" block), never
     on a `.language_list`. Its only rule was a `grid-template-columns` on an element
     with no grid, so the 520px image rendered raw with the following inline-flex
     `<p class="button">` pill riding up over its bottom-right corner. It is the same
     card as the grid below it, one item wide. */
  display: block;
  inline-size: fit-content;
  /* Sized to a grid track rather than to the source image: it sits one section above the
     `.language_list` grid on every hub, and at 22rem it read as a different component
     twice the size of the cards below it. */
  max-inline-size: min(13rem, 100%);
  padding: var(--space-3);
  background: var(--surface);
  border: 1px solid transparent;
  border-radius: var(--radius-lg);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.language_list_item_single:hover { border-color: var(--brand); box-shadow: var(--shadow-sm); }
.language_list_item_single img { inline-size: 100%; block-size: auto; border-radius: var(--radius); }
.language_list a {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-2);
  min-block-size: 44px;
  /* The grid stretches the <li>, but the card is the <a> inside it, so a row of cards
     whose captions run to different line counts ended on four different bottom edges. */
  block-size: 100%;
  min-inline-size: 0;
  text-decoration: none;
  color: var(--text-nav);
  /* The label sits under a centred image, so it centres too - left-aligned under a centred
     flag it read as belonging to the card next to it. */
  text-align: center;
  /* P8 review: the cards had no surface, border, padding or hover at all - an image and a
     22px heading floating on white, which is what made the grid read as unstyled rather
     than as a set of things you can click. */
  padding: var(--space-3);
  background: var(--surface);
  /* Transparent, not absent: the hover state colours this border, and a card that only
     grows one on hover shifts its own contents by a pixel. */
  border: 1px solid transparent;
  border-radius: var(--radius-lg);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.language_list a:hover { border-color: var(--brand); box-shadow: var(--shadow-sm); }
/* The curated sets are NOT one size (image-manifest.json: lesson images are 520x385,
   350x350, 512x512, 311x230, 720x400 and 500x500; flags are 256x256). One box plus
   `contain` keeps the cards on a shared baseline without cropping a flag; the card's own
   border and padding frame it, so the image needs no second plate of its own (the flag
   art already carries a white rounded square and a drop shadow of its own). */
.language_list img {
  inline-size: 100%;
  block-size: auto;
  aspect-ratio: 4 / 3;
  object-fit: contain;
  border-radius: var(--radius);
}
.language_list h3, .language_list .captionText { margin-block: 0; }
/* A card label is not a section heading: at the h3 scale (22px) it outweighed the page's
   own headings and made the grid the loudest block on the page. */
.language_list :where(h3, .captionText) {
  font-size: 1rem;
  font-weight: 600;
  line-height: 1.3;
  color: var(--text-nav);
}
/* P8-20 review: a card caption is one word and some of them are long compounds - the
   German "Kambodschanisch" ran 19px past its own 171px card at 390px and pushed the whole
   page sideways. Only the DE corpus has names this long, which is why an EN-only sweep
   never saw it. */
.language_list :where(h3, .captionText) { overflow-wrap: break-word; }

#toc_container, .toc_list {
  background: var(--surface-alt);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-3) var(--space-4);
  margin-block: var(--space-4);
}
/* A contents card sized to its own longest entry read as a fifth right edge on pages that
   already had two; it now shares the text column and lays its entries out in columns, so
   nine short links no longer stretch across the full width as an empty panel. */
@media (min-width: 40em) {
  .toc_list { columns: 2; column-gap: var(--space-5); }
  #toc_container .toc_list { columns: auto; }
}
.toc_list li { break-inside: avoid; }
/* A contents list is navigation, not a bulleted paragraph: the markers plus a permanent
   underline on every entry made it the busiest block above the fold on mpp's long pages. */
.toc_list, .toc_list ul, .toc_list ol { list-style: none; }
.toc_list li { margin-block-end: var(--space-1); }
.toc_list a { color: var(--brand-ink); text-decoration: none; }
.toc_list a:hover { text-decoration: underline; }
/* P8-09: the placeholder rule_toc() fills is a bare <span data-toc> - an inline element
   wrapping the block-level <ul> it now contains (see .toc_list above, which the <ul>
   itself carries). Without this, the span stays an anonymous inline box and the
   background/padding on the child <ul> alone reads as an odd double-indent. */
.toc { display: block; }
.toc_title { font-weight: 700; margin-block-end: var(--space-2); }
.toc_list ol, .toc_list ul { margin: 0; padding-inline-start: var(--space-4); }

.infoboxAside { background: var(--info-tint); padding: var(--space-3); border-radius: var(--radius); }
.infoboxHorizontal { background: var(--info-tint); padding: var(--space-2) var(--space-3); border-radius: var(--radius); display: flex; gap: var(--space-2); align-items: center; }

/* ---------- 13. Site search (P3-17) ---------- */

.search-results { position: absolute; inset-inline: 0; background: var(--surface); box-shadow: var(--shadow-md); z-index: var(--z-overlay); max-block-size: 70vh; overflow-y: auto; }
.search-results[hidden] { display: none; }
.search-results__group h3 { padding: var(--space-2) var(--space-3); margin: 0; background: var(--surface-alt); }
.search-results__item a { display: block; padding: var(--space-2) var(--space-3); text-decoration: none; color: var(--text-nav); min-block-size: 44px; }
.search-results__item a:hover, .search-results__item a:focus-visible { background: var(--surface-alt); }
.search-nojs-note { font-size: 0.8125rem; color: var(--text); }

/* ---------- 14. 404/410 (P3-19) ---------- */

.error-page { text-align: center; padding-block: var(--space-5); }
.error-page__code { font-size: clamp(3rem, 2rem + 4vw, 5rem); color: var(--brand); margin: 0; }

/* ---------- 15. Print (P3-08) - replaces all PDF generation ---------- */

@media print {
  .site-header, .site-footer, .breadcrumbs, .pager, .site-search, .lang-switcher,
  .search-results, .skip-link, .print-button, .download-row, [data-audio], .trainer__controls {
    display: none !important;
  }
  main { max-inline-size: none; }
  a[href]::after { content: ""; }
  table { break-inside: avoid; }
  body { color: #000; background: #fff; }
  /* P8-08: a sticky header re-anchored to each printed sheet's own scroll position would
     otherwise repeat mid-page or clip against the page box - flatten it back to plain,
     unbroken table flow, same as every other screen-only affordance in this block. */
  .rwd-table thead th, .mediaTable thead th, .responsiveTable thead th { position: static; }
  /* P8-20 review: the mobile card collapse in section 8 keys off viewport width, and a
     printed page box is narrower than its 48em breakpoint at any non-zero printer margin
     (measured: fires at 718px, i.e. A4 with Chrome's default margins; only a zero-margin
     794px box escaped it). The cheat sheets were printing as one-column cards with the
     header row display:none. Undo the collapse for print specifically. */
  .rwd-table:not(.no-label) { display: table; }
  .rwd-table:not(.no-label) thead { display: table-header-group; }
  .rwd-table:not(.no-label) tbody { display: table-row-group; }
  .rwd-table:not(.no-label) tr { display: table-row; margin-block-end: 0; border: 0; }
  .rwd-table:not(.no-label) td { display: table-cell; }
  .rwd-table:not(.no-label) td[data-th]::before { content: none; }
  /* Same reason: a scroll box cannot be scrolled on paper, so it would clip. */
  .mediaTable, .responsiveTable { display: table; overflow-x: visible; }
}

/* P8-07: layout/sizing only - fill/border/text colour now come from the
   button--secondary modifier class alongside it (section 6), same reasoning as
   .trainer__controls button above. */
.print-button {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-block-size: 44px;
  padding-inline: var(--space-3);
  cursor: pointer;
}
