/* ===========================================================================
 * app-surface.css — responsive shell: phone → iPad → iPad landscape → desktop
 * ===========================================================================
 * Loaded by BOTH the dev shell and the production build (it is a real file, so
 * there is no dev/prod copy to drift). Everything here keys off the
 * `data-surface` attribute that surface.js stamps on <html> — never off a raw
 * media query — so the dev device-frame preview and a real iPad resolve the
 * same rules from the same measurement.
 *
 *   phone    < 700   the shell as it always was: full-bleed, two-tier topbar
 *   tablet   700+    same chrome, wider column, roomier gutters. EVERY iPad in
 *                    portrait lands here, the 13" (1024) included — see the
 *                    VB-08 note in surface.js for why the boundary is 1100.
 *   desktop  1100+   the topbar is REPLACED by a persistent left sidebar
 *
 * The layout box the app actually lives in is:
 *   .shell            scroll container (flex row at desktop)
 *     .sidenav        desktop only — sticky nav rail
 *     .shell-body     topbar + main.stage
 * ========================================================================= */

:root {
  /* LIGHT-PAPER ONLY (CLAUDE.md: "cream backdrop everywhere, never dark").
   * Declared so the UA knows it: under an OS dark preference an undeclared
   * `color-scheme: normal` leaves scrollbars, form-control internals and
   * autofill grounds painted dark against cream. This is the whole of the
   * dark-mode story and is not a dark theme. */
  color-scheme: light;

  --content-max: 560px;
  --content-wide: 560px;
  --gutter: 16px;
  --sidenav-w: 248px;
  /* THE READABLE MEASURE for one question's content — stem, choices and the
   * explanation all cap here at tablet/desktop so a question draws in ONE
   * column instead of three (study P2.8). 622px is what 62ch of the UI face
   * resolved to, i.e. the width the stem already had. */
  --content-measure: 622px;

  /* MOTION TOKENS. The app is a quiet-paper surface: motion is a rare accent,
   * never ambience. Two durations and one curve is the whole vocabulary —
   * `quick` for a state that has already happened (a mark landing), `settle`
   * for a value growing into place (a progress width). --ease-settle is a
   * decelerating curve: it leaves fast and arrives slowly, so the eye reads
   * the END state rather than the travel. Spend these only where a value
   * genuinely CHANGED under the student; never on hover, entry or layout.
   * Under prefers-reduced-motion both durations collapse to 0ms, which turns
   * every transition using them into an instant swap with no code branch. */
  --ease-settle: cubic-bezier(0.22, 0.61, 0.36, 1);
  --dur-quick: 120ms;
  --dur-settle: 220ms;
}

@media (prefers-reduced-motion: reduce) {
  :root { --dur-quick: 0ms; --dur-settle: 0ms; }
}
html[data-surface="tablet"] { --content-max: 680px; --content-wide: 820px; --gutter: 24px; }
html[data-surface="desktop"] { --content-max: 760px; --content-wide: 1120px; --gutter: 32px; }

/* A STANDALONE product's rail is not the prep app's. It carries no test group
 * and no Study group — a wordmark, Reading comfort, Settings, and the
 * Today/Deck block when it has something to say — so it can hand width back to
 * the content plane, which on the math tutor is the tightest column in the app
 * (the stepped problem splits what is left into a stem ledger and a step pane).
 * Measured at 1180x820 with the worst-case stats block forced on, in both
 * standalone products: the wordmark is the widest thing in the rail and holds
 * its current TWO lines down to 224px ("ISEE/SSAT Vocabulary Builder" breaks to
 * three below that); the 3-figure Deck row stops fitting under 220px; no
 * .sidenav-item wraps down to 200px. 232px keeps every one of those with a
 * cushion. The prep app is untouched at 248 — its rail also lists the tests and
 * the Study destinations.
 */
html.standalone-app[data-surface="desktop"] { --sidenav-w: 232px; }

/* ---------- Shell skeleton ------------------------------------------------
 * `.shell-body` exists so the sidebar can be a sibling of the topbar+main
 * stack rather than a peer of the topbar. It is layout-neutral below desktop.
 */
/* THE VIEWPORT FILL, and the only box that carries it. The column below is a
 * flex one so `main.stage` can simply GROW to the viewport instead of being
 * told a pixel height: the old floor was a literal `calc(874px - 68px)` on
 * main.stage, .subject-root and .settings-page (the dev iPhone frame), which
 * on any shorter phone was scrollable dead space under every screen. It has to
 * be `.shell-body` and not main.stage: main.stage carries the Aa control's
 * `zoom`, so no viewport unit inside it is honest (see the NO VIEWPORT UNITS
 * note below). How tall the viewport IS comes from the two shells — `100%` of
 * the device frame in dev (index.html), `100dvh` in production (build.js
 * shellCSS) — and everything from here down is shared. */
.shell-body { display: flex; flex-direction: column; min-width: 0; }
main.stage { flex: 1 0 auto; }
/* viewport-fit=cover extends the layout viewport under the sensor housing, so
   in landscape the leading edge of the readable column sits beneath the notch
   (47pt on a notched iPhone against a 16px gutter). Inset the whole content
   stack once, here: the topbar, main.stage and every sticky bar inside it
   inherit it, and the cream fill still paints edge to edge (padding is inside
   the background box). Browsers with no insets resolve these to 0. */
.shell-body {
  box-sizing: border-box;
  padding-left: env(safe-area-inset-left, 0px);
  padding-right: env(safe-area-inset-right, 0px);
}

html[data-surface="desktop"] .shell {
  display: flex;
  align-items: flex-start;
}
html[data-surface="desktop"] .shell-body {
  flex: 1;
  min-width: 0;
  /* The sidebar carries the app's identity now, so the content plane gets the
     cream page tone edge-to-edge. */
  background: var(--bg);
  min-height: calc(100dvh - env(safe-area-inset-top, 0px));
}
/* Row 1/2 of the topbar ARE the sidebar's content at desktop — showing both
   would be two navigations for the same destinations. */
html[data-surface="desktop"] header.topbar { display: none !important; }

/* ---------- Sidebar (desktop only) --------------------------------------- */
.sidenav { display: none; }
html[data-surface="desktop"] .sidenav {
  display: flex;
  flex-direction: column;
  flex: 0 0 var(--sidenav-w);
  width: var(--sidenav-w);
  box-sizing: border-box;
  align-self: flex-start;
  position: sticky;
  /* The rail pins against the PAGE scroller: `.shell` in dev, the window in the
     production/native bundle (see index.html / build.js). At rest it sits below
     `.shell`'s `padding-top: env(safe-area-inset-top)`; once the page scrolls,
     sticky pins it at `top`, and `top: 0` is the window edge — UNDER the status
     bar, behind the fixed `body::before` strip, so the wordmark slid beneath the
     clock on iPad (2026-09-12, native en-vocab). Pin at the inset instead, and
     take the same inset off the height so the pinned rail still ends at the
     viewport bottom (a 100dvh rail pinned below the inset would overhang, and
     the containing block would shove it back up at the end of the page).
     Browsers resolve env() to 0, so this is the old rule everywhere else. */
  top: env(safe-area-inset-top, 0px);
  height: calc(100dvh - env(safe-area-inset-top, 0px));
  /* When the shell is given a definite height — the dev device frame, and any
     packaged native shell that insets the web view — 100dvh is the WINDOW, not
     the box we actually live in, and the pinned footer (Settings) falls off the
     bottom. In production .shell is auto-height, where this resolves to none. */
  max-height: 100%;
  overflow-y: auto;
  /* VBI-42 — the rail was ALREADY its own scroller (it scrolls programmatically:
     scrollTop held, scrollHeight 1165 vs client 768 at ipad-ls), but on a
     packaged iPad the WebView handed the touch to the page scroller instead and
     the rows below the fold — four word-part drills in the Vocabulary Builder —
     were unreachable by gesture. Claim the vertical gesture for this scrollport
     (`touch-action: pan-y`), stop the fling from chaining out to the page once
     the rail hits its end (`overscroll-behavior: contain`), and keep the legacy
     momentum hint for older WebKit. Horizontal is deliberately NOT claimed: the
     rail sits on the left screen edge, where the native shell's edge-pan
     back gesture starts. */
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  touch-action: pan-y;
  /* Vertical padding lives on the two PINNED ends (.sidenav-brand /
     .sidenav-foot below): `position: sticky` pins to this scrollport's
     PADDING box, so a padding-top here would be a gap the middle section
     scrolls through, above the pinned wordmark. */
  padding: 0 12px;
  background: var(--bg-deep);
  border-right: 1px solid var(--line);
  gap: 2px;
}
/* Hidden while the first-run scrim owns the screen. */
html.fresh-user .sidenav { display: none !important; }

/* SP-14 — THE RAIL'S GROUND RUNS TO THE TOP OF THE WINDOW.
   index.html's `body::before` paints a fixed strip over `env(safe-area-inset-top)`
   so content passes under a legible status bar — but it paints the PAGE ground
   (`--bg`) across the FULL width. On a native iPad in portrait that left a
   32pt band of page cream above the rail (sampled: #F4F0E3 to y=31.5pt, then
   the rail's #ECE5D0), so the rail read as a panel starting below a stripe
   rather than as the edge of the window. Repaint the rail's own `--bg-deep`
   over the rail's width, in front of that strip, and carry its hairline up with
   it. `env()` resolves to 0 in browsers, so this strip has no height there and
   the web is unchanged; landscape moves the inset to the leading edge, where it
   is likewise 0 tall. Not painted while the rail is hidden (first-run scrim).
   Keep the z-index one above `body::before`'s 52 and below the overlays. */
html[data-surface="desktop"] body::after {
  content: '';
  position: fixed;
  top: 0; left: 0;
  width: var(--sidenav-w);
  height: env(safe-area-inset-top, 0px);
  box-sizing: border-box;
  background: var(--bg-deep);
  border-right: 1px solid var(--line);
  z-index: 53;
  pointer-events: none;
}
html.fresh-user body::after { display: none; }

/* VL-01 + VL-06 — the rail's two fixed ends stay put while the middle scrolls.
   Once a student has answered anything the rail grows TODAY/DECK blocks and runs
   past a landscape viewport (measured 1169 in 1024 at 1366×1024): `Aa · Reading
   comfort` and `Settings` left the screen with no other door, and reaching them
   by scrolling took the wordmark and the first destinations with them. The rail
   was always its own scroller (`overflow-y: auto` above) — what was missing is
   that the brand and the footer are CHROME, not list. Pinning both is scroll-
   model agnostic (dev `.shell` scroll vs the production document scroll: neither
   is the rail's scrollport) and independent of the safe-area top inset, which
   sits on the rail box itself. */
html[data-surface="desktop"] .sidenav-brand {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--bg-deep);
  padding-top: 20px;
}
/* Says "there is more under here" without a hard rule across the rail. */
html[data-surface="desktop"] .sidenav-brand::after {
  content: '';
  position: absolute;
  left: 0; right: 0; top: 100%;
  height: 10px;
  background: linear-gradient(var(--bg-deep), rgba(0, 0, 0, 0));
  pointer-events: none;
}
html[data-surface="desktop"] .sidenav-foot {
  position: sticky;
  bottom: 0;
  z-index: 2;
  /* Short rail (no stats yet, and the standalone products render no
     .sidenav-spacer): the footer still belongs at the bottom. */
  margin-top: auto;
  background: var(--bg-deep);
  padding-bottom: calc(14px + env(safe-area-inset-bottom));
}
html[data-surface="desktop"] .sidenav-foot::before {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 100%;
  height: 10px;
  background: linear-gradient(rgba(0, 0, 0, 0), var(--bg-deep));
  pointer-events: none;
}

.sidenav-brand {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 4px 10px 16px;
}
.sidenav-brand-name {
  font-family: var(--font-head);
  font-weight: 600;
  font-size: 19px;
  letter-spacing: -0.015em;
  color: var(--ink);
  line-height: 1.15;
}
.sidenav-brand-sub {
  font-size: 11px;
  color: var(--ink-soft);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  font-weight: 600;
}

.sidenav-group {
  display: flex;
  flex-direction: column;
  /* 6, not 2 (VBI-45). The rows are 38pt and carry a 44pt ::after hit box (see
     "Minimum touch boxes" below); at a 2px gap that box reached 3pt into its
     neighbour's, so two adjacent rows contested 4pt of each other's target and
     the effective pitch was 40. A 6px gap makes the PITCH exactly 44 — every
     row gets the whole floor, nothing overlaps, and the rows keep their own
     visual size and weight. The cost is 4pt of rail height per row, which is
     why this lands with the rail's scroll fix (VBI-42). */
  gap: 6px;
  margin-bottom: 12px;
}
.sidenav-group[hidden] { display: none; }
/* --ink-faint is a hairline tone for dividers, not text: at 10px uppercase on
   the rail it measured under 3:1. --ink-soft is the readable quiet ink (4.7:1). */
.sidenav-label {
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  padding: 6px 10px 4px;
}

/* Nav items follow the app's surface language: a selected row is the RAISED,
   tappable card tone against the deeper rail; the rest are quiet text rows. */
.sidenav-item {
  appearance: none;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 10px;
  cursor: pointer;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  color: var(--ink-soft);
  text-align: left;
  padding: 9px 10px;
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  box-sizing: border-box;
  line-height: 1.25;
  transition: background var(--dur-quick) ease, color var(--dur-quick) ease;
}
.sidenav-item:hover:not(:disabled) { background: rgba(42, 38, 32, 0.05); color: var(--ink); }
.sidenav-item[aria-selected="true"] {
  background: var(--card);
  border-color: var(--line);
  color: var(--ink);
  box-shadow: 0 1px 2px rgba(42, 38, 32, 0.05);
}
.sidenav-item:disabled { opacity: 0.45; cursor: default; }
.sidenav-item-sub {
  display: block;
  font-size: 11px;
  font-weight: 500;
  color: var(--ink-soft);   /* same reason as .sidenav-label */
  margin-top: 2px;
}
.sidenav-item-text { min-width: 0; }
.sidenav-spacer { flex: 1 1 auto; min-height: 12px; }
.sidenav-foot {
  display: flex;
  flex-direction: column;
  gap: 6px;   /* same 44pt pitch as .sidenav-group (VBI-45) */
  border-top: 1px solid var(--line);
  padding-top: 8px;
}
.sidenav-item svg { flex: 0 0 auto; opacity: 0.75; }
.sidenav-streak {
  font-size: 11px;
  color: var(--ink-soft);
  padding: 4px 10px 6px;
  font-weight: 600;
}
.sidenav-streak[hidden] { display: none; }

/* Today block — the rail's one piece of state (questions answered today + the
   day streak, app-wide). Read-only by construction, so it wears the inset
   treatment: darker fill, radius 4, no border, no shadow, nothing to tap.
   Desktop-only for free — the whole rail is. Hidden at 0/0 by the renderer:
   a rail of zeros is worse than cream. */
.sidenav-stats {
  display: flex;
  flex-direction: column;
  gap: 7px;
  margin: 2px 2px 12px;
  padding: 10px 11px 11px;
  background: var(--bg-inset);
  border-radius: var(--radius-inset, 4px);
}
.sidenav-stats[hidden] { display: none; }
.sidenav-stats-label {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
}
.sidenav-stats-row { display: flex; gap: 16px; }
.sidenav-stat { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.sidenav-stat-n {
  font-family: var(--font-head);
  font-size: 20px;
  font-weight: 700;
  line-height: 1.1;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.sidenav-stat-l {
  font-size: 12.5px;
  font-weight: 500;
  color: var(--ink-soft);
  line-height: 1.2;
}

/* ---------- Main stage ---------------------------------------------------
 * NO VIEWPORT UNITS INSIDE main.stage. The stage carries the text-size
 * control's `zoom` (default textScale 1.0 on a phone, 1.1 on tablet/desktop —
 * window.__a11yDefaultScale, index.html), and a `dvh` resolves in layout px
 * and is THEN scaled — so `100dvh` here painted 1.1× the window and left a
 * phantom scrollbar of exactly 0.10×viewport. Desktop was fixed first; the
 * tablet copy carried the leak on (with an unaudited `-96px` of topbar math),
 * and phone had the same bug in pixels — `calc(874px - 68px)`, the dev device
 * frame. All three are gone: main.stage is a flex ITEM of `.shell-body` now
 * (above) and grows to whatever the viewport actually is, which is a number
 * neither the zoom nor a guess at the topbar can get wrong.
 */
html[data-surface="desktop"] main.stage {
  padding-top: 22px;
}

/* ---------- Mode screens --------------------------------------------------
 * `.mode-screen` is the shared wrapper for every full-screen sub-surface
 * (skill drills, past papers, the Editing Lab, the vocab modes). One rule
 * centres all of them in the readable column instead of letting a phone
 * layout stretch to 1400px. A screen that genuinely wants the whole plane —
 * the two-pane runner, a year test — adds `.mode-screen--wide`.
 */
html[data-surface="tablet"] .mode-screen,
html[data-surface="desktop"] .mode-screen {
  max-width: var(--content-max);
  margin: 0 auto;
  width: 100%;
  box-sizing: border-box;
}
html[data-surface="tablet"] .mode-screen--wide,
html[data-surface="desktop"] .mode-screen--wide { max-width: var(--content-wide); }

/* ---------- Mode header --------------------------------------------------
 * PIN OR SCROLL — THE ONE RULE (SI-31, and the same call the Vocab study
 * deferred as VI-20; decided once, here, for both).
 *
 *   RUNNER AND DRILL PAGES PIN THEIR MODE HEADER.
 *   LONG READ-ONLY PAGES SCROLL IT.
 *
 * A drill page is any surface where the student is ANSWERING — the practice
 * runner, the Editing Lab stepper, the vocab drills. There the header is not
 * decoration: it carries the only exit, the round name and the position, and
 * a long reveal scrolls all three away exactly when they become useful.
 * Pinning costs a strip of the fold and buys back the controls.
 *
 * A read-only page is one the student is READING or BROWSING — Settings, the
 * progress report, the Lab's rule list, the topic browser, a plan, a summary.
 * There the header is chrome over content nobody is answering, and pinning it
 * only spends the fold. It scrolls; getting back out is one flick.
 *
 * MECHANISM: `sticky` on window.PracticeHeader / ModeHeader — it wraps the
 * bar, the progress track and the eyebrow in `.mode-headwrap--sticky` so the
 * three pin as ONE block (the wrapper paints, and lives in shared-chrome.css).
 * Every header also stamps `data-header="pinned|scroll"`, so which side of the
 * rule a screen chose can be read straight off the DOM. Nothing styles off the
 * attribute — do not start; the wrapper class is the pin. A screen whose own
 * ancestor pins the header (the practice runner's `.runner-rail`) passes
 * `sticky="outer"`: no wrapper, still reported as pinned.
 *
 * The shared back/progress/eyebrow header (window.ModeHeader) is a full-bleed
 * strip. Once the content below it is a centred column, a Back button pinned
 * to the far left of a 1400px window reads as belonging to something else —
 * so the header takes the same column and the same gutter as its content.
 * `.is-wide` (the split runner) opts into the wider budget.
 */
html[data-surface="tablet"] .mode-header,
html[data-surface="tablet"] .mode-header-eyebrow,
html[data-surface="tablet"] .progress-bar-track,
html[data-surface="desktop"] .mode-header,
html[data-surface="desktop"] .mode-header-eyebrow,
html[data-surface="desktop"] .progress-bar-track {
  max-width: var(--content-max);
  margin-left: auto;
  margin-right: auto;
  width: 100%;
  box-sizing: border-box;
  padding-left: var(--gutter);
  padding-right: var(--gutter);
}
/* Tablet too (VB-02): `.mode-screen--wide` widens the body at BOTH surfaces, so
   a header that only follows at desktop still orphans itself on an 11" iPad. */
html[data-surface="tablet"] .is-wide .mode-header,
html[data-surface="tablet"] .is-wide .mode-header-eyebrow,
html[data-surface="tablet"] .is-wide .progress-bar-track,
html[data-surface="desktop"] .is-wide .mode-header,
html[data-surface="desktop"] .is-wide .mode-header-eyebrow,
html[data-surface="desktop"] .is-wide .progress-bar-track { max-width: var(--content-wide); }

/* The in-bar eyebrow variant (shared-chrome.css .mode-header-eyebrow--inline)
 * ellipsises, which silently ate the topic name once the question header had a
 * second occupant. Nothing below it depends on the bar being exactly one line
 * tall, so let a long activity name wrap instead of vanishing. `html` prefix
 * only to out-specify shared-chrome.css, which loads after this file. */
html .mode-header-eyebrow--inline {
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
  line-height: 1.25;
}

/* ---------- Settings ------------------------------------------------------
 * A settings form at 1200px wide is unreadable. Centre it in the content
 * column like every other surface, and let the body use the two-column
 * budget so long option lists don't become a mile of scrolling.
 */
html[data-surface="tablet"] .settings-page,
html[data-surface="desktop"] .settings-page {
  max-width: var(--content-wide);
  margin: 0 auto;
  padding: 28px var(--gutter) 64px;
  width: 100%;
  box-sizing: border-box;
}
/* Same zoom leak as main.stage — the settings page lives inside the stage, so
   its 100dvh painted 1.1× the window. Nothing is lost: the page's only reason
   to be tall was the cream fill, and `.shell-body` already carries that
   edge-to-edge at desktop in the same var(--bg). */
html[data-surface="desktop"] .settings-page { min-height: 0; }
html[data-surface="tablet"] .settings-body,
html[data-surface="desktop"] .settings-body { max-width: var(--content-max); margin: 0 auto; }
/* VB-04 — the title, the back link and the Account/About tabs are the HEAD of
   those cards, so they take the same measure. `.settings-page` keeps the wider
   budget (it is the page ground); only this header block was left outside the
   centring above, which put a 115px step between a heading and the thing it
   heads at 1366×1024. margin-left/right rather than the shorthand: both blocks
   carry their own bottom margin. */
html[data-surface="tablet"] .settings-header,
html[data-surface="desktop"] .settings-header,
html[data-surface="tablet"] .settings-tabs-wrap,
html[data-surface="desktop"] .settings-tabs-wrap {
  max-width: var(--content-max);
  margin-left: auto;
  margin-right: auto;
  width: 100%;
  box-sizing: border-box;
}
html[data-surface="desktop"] .settings-title { font-size: 34px; }

/* Settings data rows stack on a phone (C19): label + sub-hint on one side and
 * a button on the other left ~110px for the text, so "Fabrika ayarları…" and
 * its hint both wrapped to three lines. Below tablet the action goes full
 * width under the text. `width: 100%` rather than `align-items: stretch`
 * because two of the rows carry an inline align-items that would win. */
html[data-surface="phone"] .data-row {
  flex-direction: column;
  align-items: stretch;
  gap: 12px;
}
html[data-surface="phone"] .data-btn-group { width: 100%; }
html[data-surface="phone"] .data-btn-group > button { flex: 1; }

/* ---------- Onboarding ----------------------------------------------------
 * The first-run flow is a modal column and stays one; it just gets air.
 *
 * 680/94% rather than 620/86% (2026-08-28 welcome-flow audit): on a 1180×820
 * iPad in landscape the old box left the pitch list scrolling with the product's
 * argument below the fold, on the device with the most room to show it. Now
 * three full pitch cards paint before any scroll.
 *
 * min-height guard: without it this rule (0,2,1) also beat index.html's
 * `@media (max-height: 500px)` full-bleed treatment (0,1,0), so a short, wide
 * window got a 620px letterbox instead of the intended edge-to-edge sheet.
 */
/* TABLET TOO (study LGS-18 / ISEE-19). An iPad held upright is a `tablet`
 * surface, and it was getting the phone's 560 sheet: a narrow column floating
 * in the middle of an 834pt screen, with the blurred app behind it peeking out
 * on both sides and reading as a rendering artefact rather than as depth. The
 * same 680 column the desktop rule already gives, at the surface that has the
 * room for it. Height is capped, never set — the sheet hugs its content (see
 * updateOnboardScrollCue). */
@media (min-height: 501px) {
  html[data-surface="desktop"] .onboard-shell,
  html[data-surface="tablet"] .onboard-shell {
    max-width: 680px;
    max-height: 94%;
  }
}

/* Hero tagline measure. 340px is right in the phone column; inside the 680px
 * desktop sheet it read as a narrow ragged block hanging under a full-width
 * wordmark. 480 keeps it a comfortable measure and lets the TR taglines (which
 * run longer than the EN one) settle in fewer, evener lines. Phone unchanged.
 * .onboard-legal keeps a FOOTNOTE measure of its own — see the SB-04 block below. */
html[data-surface="desktop"] .onboard-hero-tag,
html[data-surface="tablet"] .onboard-hero-tag { max-width: 480px; }

/* Step 2 is a left-aligned column — title, sub and the test rows all start at
 * the same edge — but the sync block's three text lines were centred (a phone
 * treatment, where the column IS the page). At desktop that put three ragged
 * centred lines under a left-aligned stack. Left-align them to the same edge.
 * Scoped to step 2 so the sign-in wall (#signin-gate-privacy, its own centred
 * modal) is untouched; the provider buttons keep their centred column. */
html[data-surface="desktop"] .onboard-step[data-step="setup"] .onboard-sync-label,
html[data-surface="desktop"] .onboard-step[data-step="setup"] .onboard-sync-note,
html[data-surface="desktop"] .onboard-step[data-step="setup"] .onboard-privacy {
  text-align: left;
  margin-left: 0;
  margin-right: auto;
}

/* SB-04 — ONE CARD, ONE LEFT EDGE, ONE MEASURE.
 * Measured at 1024x1366: the .onboard-step card is 628 wide and inside it sat a
 * full-width eyebrow (628), a 340px lede wrapping at ~46 characters with 288px
 * of the card unused beside it, a 254px sign-in button CENTRED, and a
 * full-width CTA. Four alignments and two measures in one card. 340px is a
 * PHONE measure (index.html .onboard-section-sub / .onboard-legal) that never
 * got a surface-aware token.
 *
 * Two rules, both scoped to the surfaces where the card is actually wide:
 *   1. open the prose to the readable measure (--content-measure, the same
 *      token the question column uses), capped at the card so nothing changes
 *      when the card is narrower than the measure;
 *   2. start the provider stack at the same left edge as the eyebrow and the
 *      CTA. Its buttons keep their fixed 280px width — they are controls, not
 *      prose — they just stop being centred against left-aligned siblings.
 * The sign-in GATE (#signin-gate) is a narrow centred dialog and is deliberately
 * untouched: there the centred stack IS the layout. */
html[data-surface="tablet"] .onboard-section-sub,
html[data-surface="desktop"] .onboard-section-sub {
  max-width: min(100%, var(--content-measure));
}
/* The trademark line keeps a FOOTNOTE measure — the rule above it used to say
 * "deliberately 340" and the intent stands; 340 is simply a phone number inside
 * a 589px card, which is what made the paragraph App Review reads wrap at ~46
 * characters. 480 is the hero tagline's measure, already proven on this card. */
html[data-surface="tablet"] .onboard-legal,
html[data-surface="desktop"] .onboard-legal { max-width: min(100%, 480px); }
html[data-surface="tablet"] .onboard-hero-tag { max-width: min(100%, 480px); }
html[data-surface="tablet"] .onboard-step .auth-stack,
html[data-surface="desktop"] .onboard-step .auth-stack { align-items: flex-start; }
html[data-surface="tablet"] .onboard-step .auth-stack > .onboard-social-btn,
html[data-surface="desktop"] .onboard-step .auth-stack > .onboard-social-btn { margin: 0; }
html[data-surface="tablet"] .onboard-step .auth-stack .gsi-overlay-host,
html[data-surface="desktop"] .onboard-step .auth-stack .gsi-overlay-host { justify-content: flex-start; }
/* Tablet gets step 2's left-aligned sync block too — same defect, same card,
 * the desktop rule above simply never covered the 820x1180 iPad. */
html[data-surface="tablet"] .onboard-step[data-step="setup"] .onboard-sync-label,
html[data-surface="tablet"] .onboard-step[data-step="setup"] .onboard-sync-note,
html[data-surface="tablet"] .onboard-step[data-step="setup"] .onboard-privacy {
  text-align: left;
  margin-left: 0;
  margin-right: auto;
}

/* SB-15 (layout half) — Settings → Account had the same two left edges: the
 * provider stack centred its 280px buttons inside a 799px section whose every
 * other row starts at the section's left edge. (The a11y half — one accessible
 * name for the Google control — is auth-sync.js's aria-hidden on the drawn
 * button; nothing to do here.) */
html[data-surface="tablet"] #settings-body .auth-stack,
html[data-surface="desktop"] #settings-body .auth-stack { align-items: flex-start; }
html[data-surface="tablet"] #settings-body .auth-stack > .onboard-social-btn,
html[data-surface="desktop"] #settings-body .auth-stack > .onboard-social-btn { margin: 0; }
html[data-surface="tablet"] #settings-body .auth-stack .gsi-overlay-host,
html[data-surface="desktop"] #settings-body .auth-stack .gsi-overlay-host { justify-content: flex-start; }
/* The stack's error line (SI-05) follows the edge the stack now starts at. */
html[data-surface="tablet"] .auth-stack-error,
html[data-surface="desktop"] .auth-stack-error { text-align: left; }
html[data-surface="tablet"] .signin-gate .auth-stack-error,
html[data-surface="desktop"] .signin-gate .auth-stack-error { text-align: center; }

/* ---------- Minimum touch boxes -------------------------------------------
 * Compact controls keep their small VISUAL size but grow an invisible hit
 * area to the 44px minimum (consultant-3 P0: the back affordance measured
 * 65×20 with zero padding — the primary exit on every sub-screen). The
 * ::after overlay hit-tests to its owner without shifting layout. Applied
 * here to the app-wide back look and the topbar icon button; the choice
 * eliminators get the same treatment in their component sheets.
 */
.home-link-btn, .settings-back, .icon-btn, .btn-text, .tap-xl { position: relative; }
/* Settings' Reset / Factory reset / Edit measured 40pt tall — the rows whose
   mis-tap costs the most (usability C27). The pill keeps its size; the box
   grows vertically only, so two side-by-side pills never overlap.
   CAVEAT (study L1-28): side-by-side is the only safe arrangement. In a
   WRAPPING row the growth is vertical into the next line, and where two
   overlays overlap the later one wins — the report-a-problem reason chips
   (29px tall, 7px apart) measured 35, not 44. A chip row that wants the floor
   has to pay for it in its own box: ≥15px of vertical gap, or a taller chip. */
.btn-text::after {
  content: ''; position: absolute;
  left: 0; right: 0; top: 50%; transform: translateY(-50%);
  height: 100%; min-height: 44px;
}
/* -13 horizontally, not -10: the back affordance drops its LABEL on a narrow
 * phone while a deneme clock is up (runner Header), and a bare chevron with -10
 * measures 38 wide — under the floor on the only way out of a live
 * 100-question sitting (study P1.10). P1.10 wrote -12 against a 20px glyph;
 * the shell draws it at 18 (shared-app-shell.jsx), so -12 measured 42. -13 is
 * what 18 needs, and it is what the rail already used.
 * VERTICALLY it is stated as a RESULT, not as an inset (2026-09-19, package G's
 * handoff): -12 on a 19px-tall label box lands at 43, and the study measured
 * 42. An inset is a bet that the control's own height never changes; the
 * .btn-text recipe below-the-floor-proofs it instead — `height: 100%` plus a
 * 44px floor, centred on whatever the button actually measures. */
.home-link-btn::after, .settings-back::after {
  content: ''; position: absolute; left: -13px; right: -13px;
  top: 50%; transform: translateY(-50%); height: 100%; min-height: 44px;
}
/* Centred, never smaller than 44pt: a bare -4px inset only reaches 44 while
   the circle is exactly 36 (it is 28 in the shell's own base rule). */
.icon-btn::after {
  content: ''; position: absolute;
  left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 100%; height: 100%; min-width: 44px; min-height: 44px;
}
/* The test-switcher chips are 29pt tall in a 38pt strip. Vertical growth only:
   the pills sit 2px apart horizontally, and the row above them holds the icon
   buttons at the other end of the bar, so nothing collides (D6). */
.test-tab { position: relative; }
.test-tab::after {
  content: ''; position: absolute;
  left: 0; right: 0; top: 50%; transform: translateY(-50%);
  height: 100%; min-height: 44px;
}
/* The rail's rows are 207×38, and at every iPad size the rail is a primary
   TOUCH target, not the mouse chrome it was built as (VB-11). Vertical growth
   only, same shape as .test-tab above. The gap between rows is 6px rather than
   2 (.sidenav-group / .sidenav-foot, VBI-45), so 44 on a 38 row lands exactly
   inside the 44pt pitch: no two expanders overlap and no row is contested. */
.sidenav-item { position: relative; }
.sidenav-item::after {
  content: ''; position: absolute;
  left: 0; right: 0; top: 50%; transform: translateY(-50%);
  height: 100%; min-height: 44px;
}
/* The deck's A-Z jump strip: 26 letters at a 35pt horizontal pitch, boxes 33
   wide (VB-11). The glyph column keeps its size; the box grows sideways to the
   44pt floor. The strip is inline-styled in subjects/vocab/deck.jsx, which is
   why the expander lives here (that file owns no stylesheet, and
   subjects/vocab/styles.css is another lane's). Expanders overlap by ~9px at
   the pitch — in the overlap the later letter wins, so every glyph keeps a
   full-pitch box and the gaps stop being dead. */
.deck-az-letter { position: relative; }
.deck-az-letter::after {
  content: ''; position: absolute;
  top: 0; bottom: 0; left: 50%; transform: translateX(-50%);
  width: 100%; min-width: 44px;
}
/* Utility for inline-styled compact pills (Flag, Scratchpad, İşaretle). */
.tap-xl::after { content: ''; position: absolute; left: -8px; right: -8px; top: -9px; bottom: -9px; }
/* GUARANTEED 44 (study P1.10). `tap-xl` adds a fixed margin, which is enough
 * for a pill and not enough for a 20×20 glyph or a 28px map chip. This one is
 * sized rather than inset: at least 44 in both axes, centred on the control, so
 * whatever the visual measures the hit box clears the floor. Neighbours must be
 * ≥44 apart on a grid that uses it (the exam map's gap is 16 for that reason)
 * or the boxes overlap. */
.tap-44 { position: relative; }
.tap-44::after {
  content: ''; position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  width: 100%; height: 100%; min-width: 44px; min-height: 44px;
}

/* The controls a two-test market added or made load-bearing, all measured
 * under 44pt in the 2026-09 YKS study (P1.8). Every one keeps its drawn size —
 * the topbar is already 59px tall and the chips are a wrapping row — and grows
 * an outboard box only.
 *   .test-tab           56×31 → 64×44   the TYT↔AYT switcher, tapped daily,
 *                                       18px from the top edge. The two pills
 *                                       sit 2px apart, so the boxes cannot
 *                                       overlap enough to steal a tap.
 *   a.brand-name        32×26 → 40×46   the same control on a one-test market,
 *                                       where the wordmark IS the home link.
 *   .focus-chip         72×33 → 80×44   the alan chips (onboarding + Settings).
 *   .onboard-privacy a 110×16 → 122×44  the legal link App Review follows.
 * The two pill bands state the RESULT (a fixed 44px band, `.tap-44`'s shape)
 * rather than an inset: `inset` resolves against the PADDING box, so -7px on a
 * .test-tab's 26.8px-tall padding box measured 40.8, not 44.
 */
.test-tab, a.brand-name, .focus-chip, .onboard-privacy a { position: relative; }
.test-tab::after { content: ''; position: absolute; left: -4px; right: -4px;
  top: 50%; height: 44px; transform: translateY(-50%); }
a.brand-name::after { content: ''; position: absolute; inset: -10px -4px; }
.focus-chip::after { content: ''; position: absolute; left: -4px; right: -4px;
  top: 50%; height: 44px; transform: translateY(-50%); }
.onboard-privacy a { display: inline-block; }
.onboard-privacy a::after { content: ''; position: absolute; left: -6px; right: -6px; top: -14px; bottom: -14px; }

/* ---------- Topic-browser ders strip ---------------------------------------
 * window.GroupTabs lays its pills out as a WRAPPING row, which is right for
 * four groups and wrong for ten: TYT's ten ders names wrapped to seven rows of
 * sticky chrome above the list they exist to reach. One scrolling row instead,
 * with a soft edge so a clipped pill reads as "more this way" (the same
 * affordance the attached tab strip uses). The inline flex-wrap GroupTabs sets
 * is why these need !important.
 */
.topics-strip .group-tabs {
  flex-wrap: nowrap !important;
  overflow-x: auto; overflow-y: hidden;
  scrollbar-width: none; -ms-overflow-style: none;
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
}
.topics-strip .group-tabs::-webkit-scrollbar { display: none; }
.topics-strip .group-tabs > button { flex: none; white-space: nowrap; }

/* ---------- Settings rows on a phone --------------------------------------
 * .sb-row is a two-column flex row (label + control). The telemetry row's
 * 60-word privacy disclosure then renders as a 164px newspaper column, ten
 * lines at ~20 characters (E10). Below 480 the row wraps and the hint runs
 * full width UNDER the label and its control, which is where a paragraph of
 * explanation belongs.
 */
@media (max-width: 479px) {
  /* `display: contents` promotes the label block's children to flex items of
     the row itself, which is the only way a nested hint can claim the row's
     full width without restructuring the markup every settings surface emits.
     Scoped to the Settings page — the Aa popover stacks these rows already. */
  .settings-page .sb-row { flex-wrap: wrap; }
  .settings-page .sb-row > div:first-child { display: contents; }
  .settings-page .sb-row > div:first-child > .sb-row-label { flex: 1 1 auto; min-width: 0; }
  .settings-page .sb-row > div:first-child > .sb-hint { flex-basis: 100%; order: 3; margin-top: 8px; }
  .settings-page .sb-row > .sb-row-opts { order: 2; }
}

/* Topbar wordmark + test tabs (measured 50×26) and the onboarding Back (62×34)
   — study P1.14. Vertical only for the tabs: they sit shoulder to shoulder, so
   a horizontal overlay would swallow the neighbour's taps. The strip clips
   overflow-y, so this only reaches 44 because .topbar-row1 is 44 tall. */
.test-tabs .brand-name, .test-tab, .onboard-topbar-back { position: relative; }
.test-tabs .brand-name::after, .test-tab::after {
  content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px;
}
.onboard-topbar-back::after {
  content: ''; position: absolute; left: -6px; right: -6px; top: -5px; bottom: -5px;
}
/* Reading-comfort sheet options (measured 78x42 and 120x42, no hit box at all)
   — study P1.14. Vertical only: the three pills of a segmented control sit
   shoulder to shoulder, so a horizontal overlay would eat the neighbour's
   taps. -3px each side takes 42 to 48; the pill's own size is unchanged. */
.a11y-pop .sb-opt { position: relative; }
.a11y-pop .sb-opt::after {
  content: ''; position: absolute; left: 0; right: 0; top: -3px; bottom: -3px;
}
/* …and the same pills everywhere ELSE in Settings (study L1-03: telemetry
   Off/On measured 43×42 and 41×42 with no box at all). ±3 on every side, which
   is the exact ceiling: `.sb-row-opts` is a wrapping flex row with a 6px gap on
   BOTH axes, so two neighbours' overlays ABUT on the shared line and neither
   loses reach — anything past 3 would overlap, and where two overlays overlap
   the later one wins and the earlier control ends up with LESS than it started
   with. ±2 was tried first and left the narrowest pill ("On", a 40.5px box) at
   43.0px measured, a pixel under the floor. The a11y sheet keeps its own rule
   above (vertical only, shoulder-to-shoulder pills with no gap to spend). */
.sb-opt { position: relative; }
.sb-opt::after {
  content: ''; position: absolute; left: -3px; right: -3px; top: -3px; bottom: -3px;
}
/* The site footer's crawlable link rows — five exam links and About/Privacy,
   19–21px tall (study L1-07). REAL padding, not an ::after overlay: the rows
   WRAP, and two wrapped lines 2px apart cannot both carry a 44px overlay
   without one eating the other. Vertical only, because the `·` separator is
   drawn from the FOLLOWING anchor and sits inside its box — the anchors
   already abut and there is no horizontal room to take. `:not([hidden])` so
   the per-host filtering (the edge and setupSiteFooter hide the links this
   domain does not carry) still wins from the shell's own sheet. Touch
   surfaces only; a desktop footer stays compact. */
html[data-surface="phone"] .site-footer-row a:not([hidden]),
html[data-surface="phone"] .site-footer-meta a:not([hidden]),
html[data-surface="tablet"] .site-footer-row a:not([hidden]),
html[data-surface="tablet"] .site-footer-meta a:not([hidden]) {
  display: inline-block;
  padding: 13px 0;   /* 19px line box + 26 = 45 */
}

/* ---------- Pointer affordances ------------------------------------------
 * A desktop user hovers before they click. The phone build has almost no hover
 * states because nothing hovers on a touchscreen; add the minimum so the app
 * doesn't feel inert under a cursor. Scoped to real pointers so a Magic
 * Keyboard iPad gets it and a finger doesn't.
 */
@media (hover: hover) and (pointer: fine) {
  html[data-surface="desktop"] button:not(:disabled),
  html[data-surface="tablet"] button:not(:disabled) { transition: filter var(--dur-quick) ease, transform var(--dur-quick) ease; }
  /* Perceptible, theme-aware: dims on paper, brightens under the lamp
     (stamped by activateTheme; the old 0.985 read as no feedback at all). */
  html[data-surface="desktop"] button:not(:disabled):hover { filter: var(--hover-filter, brightness(0.96)); }
}

/* ---------- Wide-surface scrollbars --------------------------------------
 * The rail and the content plane scroll independently at desktop; keep the
 * bars quiet so the app doesn't grow two loud gutters.
 */
html[data-surface="desktop"] .sidenav::-webkit-scrollbar { width: 8px; }
html[data-surface="desktop"] .sidenav::-webkit-scrollbar-thumb {
  background: var(--line-strong);
  border-radius: 4px;
}
html[data-surface="desktop"] .sidenav::-webkit-scrollbar-track { background: transparent; }

/* ---------- Figure stimulus ----------------------------------------------
 * A figure never grows past its own display width (FigureCard caps it), so on
 * a wide column the inset panel around it was a full-width band with a small
 * drawing marooned in the middle. Hug the figure instead and centre the panel;
 * `--fig-hug` is the figure's width + its gutters, published by FigureCard and
 * absent for the escape-hatch kinds, which keep the full column.
 */
html[data-surface="tablet"] .figure-card,
html[data-surface="desktop"] .figure-card {
  max-width: var(--fig-hug, none);
  margin-inline: auto;
}

/* ---------- Prose measure -------------------------------------------------
 * 14–16px text set across the whole 680–760px column runs 85–100 characters
 * per line, past the ~90 ceiling — and the explanation is the text a student
 * reads most carefully. Cap the TEXT, never its panel: the why panel and the
 * item keep the full column, the prose just wraps sooner, still left-aligned.
 *
 * `ch` is the width of "0" — ~1.2x the average character in Lexend/Iowan — so
 * 62ch measures ~75 chars/line in English. Measured, not assumed: 68ch lands
 * at 83 and misses the window; Turkish runs ~10 above English at the same
 * physical width, its letters being narrower. Same 62ch that .mc-why /
 * .mc-why-tldr already carry in mc-item.css.
 *
 * Deliberately uncapped: choice rows (a wrapped choice is harder to scan than
 * a long one), figures, the passage pane (its own designed measure), the
 * bordered stimulus blocks .mc-quote / .mc-boxed (capping would move the
 * border, and the quote holds hard-broken verse), and .mc-insight (its top
 * rule must span the panel). The testlet stems .pi-stem / .pei-stem need no
 * entry of their own: their text blocks ARE .mc-stem (McItem's block renderer)
 * and are capped by the rule below like any other stem.
 */
html[data-surface="tablet"] .mc-stem,
html[data-surface="desktop"] .mc-stem,
html[data-surface="tablet"] .pi-why-body,
html[data-surface="desktop"] .pi-why-body,
html[data-surface="tablet"] .pei-why-body,
html[data-surface="desktop"] .pei-why-body {
  max-width: var(--content-measure);
}
/* ONE COLUMN FOR ONE QUESTION (study P2.8). At 1280 the same question drew in
 * three different widths — stem 622 (62ch of UI type), choice rows 766 (the
 * whole content column), why block 569 (62ch of SERIF type) — so the answers
 * were wider than the question they belong to and a one-word option sat in a
 * 766px row. `ch` cannot be the token: it resolves against each element's own
 * font, which is what produced 622 and 569 from the same declaration. The
 * token is px, and px inside `main.stage` is already scaled by the reading-size
 * zoom, so it tracks the text size like everything else.
 *
 * Choice rows were deliberately uncapped before ("a wrapped choice is harder to
 * scan than a long one"); that reasoning holds INSIDE the measure and stops
 * being about scanning past it — nothing is wrapped by matching the stem. */
/* `.mcq-choices` is the SHARED answer row the four item renderers now all emit
   alongside their own class (package B, S-04) — without it named here the cap
   reached an mc single and skipped an analogy, a reading question and an SHSAT
   Editing B testlet, which is the same measure bug seen three more times. */
html[data-surface="tablet"] .mc-choices,
html[data-surface="desktop"] .mc-choices,
html[data-surface="tablet"] .mcq-choices,
html[data-surface="desktop"] .mcq-choices,
html[data-surface="tablet"] .mc-why-block,
html[data-surface="desktop"] .mc-why-block {
  max-width: var(--content-measure);
}
/* The year-test paper sets its own booklet measure (below) and the two-column
   figure/short-answer grids need the room they were given. */
html[data-surface="tablet"] .anchor-paper .mc-choices,
html[data-surface="desktop"] .anchor-paper .mc-choices,
html[data-surface="tablet"] .anchor-paper .mcq-choices,
html[data-surface="desktop"] .anchor-paper .mcq-choices,
html[data-surface="tablet"] .mc-choices.has-figs,
html[data-surface="desktop"] .mc-choices.has-figs,
html[data-surface="tablet"] .mcq-choices.has-figs,
html[data-surface="desktop"] .mcq-choices.has-figs { max-width: none; }
/* The year-test paper sets its own booklet type and has not had its desktop
   pass; leave its stem at the paper's width. */
html[data-surface="tablet"] .anchor-paper .mc-stem,
html[data-surface="desktop"] .anchor-paper .mc-stem { max-width: none; }

/* ---------- Attached group tabs -------------------------------------------
 * The strip is one horizontally scrolling row; the edge fades are the "more
 * this way" cue, so the bar itself is noise on top of a 40px-tall strip.
 */
.group-tabs--attached .gt-scroll { scrollbar-width: none; }
.group-tabs--attached .gt-scroll::-webkit-scrollbar { display: none; }

/* ---------- Short viewports — a phone in landscape ------------------------
 * 390pt of height under 68pt of two-tier chrome pushed the home's single
 * action below the fold (usability D12). The SURFACE deliberately stays
 * `tablet` — the landscape runner is better in two columns, all five choices
 * visible — so only the vertical budget shrinks here. Nothing is hidden: the
 * test switcher (tier 2) is the sole way to change tests without a rail.
 */
html[data-short="1"] header.topbar { padding-top: 4px; padding-bottom: 4px; }
html[data-short="1"] .topbar-row1 { height: 24px; padding-top: 0; padding-bottom: 0; }
html[data-short="1"] .topbar-row2 { height: 30px; }
html[data-short="1"] main.stage { padding-top: 4px; }

/* ---------- Focus ring — shell-wide ---------------------------------------
 * The item renderers ship a designed ring (components/items/focus-rings.css)
 * and .test-tab has its own; every other keyboard-reachable control fell back
 * to the UA's 1px near-black outline at zero offset, which is invisible on an
 * accent- or ink-filled button (usability D14). :where() keeps specificity at
 * 0, so any element's own ring still wins.
 */
:where(button, a[href], summary, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
  outline: var(--item-focus-width, 2px) solid var(--item-focus-color, var(--accent));
  outline-offset: var(--item-focus-offset, 2px);
}

/* ---------- Touch behaviour (iOS) ----------------------------------------
 * Three declarations separate "a web page in a wrapper" from an app
 * (usability D19): the system tap flash ignores the 14-20px radii the surface
 * language rests on; a double-tap on a choice card is otherwise zoom-eligible
 * (`manipulation` still allows pinch-zoom, which is the point of D2); and the
 * shell's rubber-band otherwise chains into the web view's own bounce.
 */
:root { -webkit-tap-highlight-color: transparent; }
:where(button, a, [role="button"], summary, label) { touch-action: manipulation; }
.shell { overscroll-behavior-y: contain; }

/* ---------- B-49 — a sticky bar paints the notch band above itself --------
 * A bar pinned at `top: env(safe-area-inset-top)` leaves a strip between the
 * top of the scrollport and the top of the bar. `body::before` (index.html /
 * build.js shellCSS) is supposed to own that strip, and on the practice runner
 * it measurably does not: scrolling content came THROUGH it, so the runner
 * already paints its own band (SI-12, runner.jsx). This is that fix as a
 * shared class, so the next sticky bar does not rediscover it — the sealed
 * paper's meta/clock row had the same half-cut line of passage text sitting
 * beside the status-bar clock.
 *
 * `bottom: 100%` of the sticky element is always the sliver immediately above
 * it, so the band travels with the bar. Zero-height in a browser (env() is 0),
 * so it costs nothing off-device. The ground defaults to the page's, and a bar
 * on a different ground sets `--safe-band-bg`.
 */
.safe-band-top { position: relative; }
.safe-band-top::before {
  content: '';
  position: absolute; left: 0; right: 0; bottom: 100%;
  height: env(safe-area-inset-top, 0px);
  background: var(--safe-band-bg, var(--bg));
  pointer-events: none;
}

/* ---------- Bottom tab bar (phone + tablet) --------------------------------
 * An iOS-style tab bar for the home screens. LAYOUT ONLY: the cards it pages
 * between are unchanged, they just sit on one tab instead of one long scroll.
 *
 * STICKY, NOT FIXED (see window.BottomTabs, shared-app-shell.jsx): the page
 * scroller is `.shell` in dev and the document in the production bundle, and
 * dev renders inside an iPhone frame — a fixed bar would span the browser
 * window. As the last child of the screen root, `bottom: 0` pins it against
 * whichever ancestor scrolls and keeps it inside the shell column.
 *
 * The host root carries `.has-bottom-tabs`: a flex column so the bar can take
 * `margin-top: auto` and sit at the foot of a short page rather than floating
 * under two cards. The height that makes that work is MEASURED and applied
 * inline (window.useBottomTabsHost, shared-app-shell.jsx) — no viewport unit
 * can be used inside main.stage's zoom, see the "NO VIEWPORT UNITS" note.
 */
.has-bottom-tabs {
  display: flex;
  flex-direction: column;
}
/* WHERE THE SITE FOOTER SITS WHILE THE BAR IS UP. #site-footer is normally a
 * sibling of the subject roots, further down main.stage — i.e. OUTSIDE the
 * sticky bar's containing block, which is all it takes to drag the bar up off
 * the bottom of the screen at the end of every home scroll. The shell moves
 * the node in here instead (index.html placeSiteFooter), directly above the
 * bar, where it is the home's own last block and the bar is once again the
 * last thing on the page. The footer already centres itself in the readable
 * column and pads itself to the gutter; inside the host it is already in that
 * column, so it hands both back. */
/* …AND NOTHING MAY SIT BELOW THE BAR, an ancestor's bottom padding included.
 * A sticky element is only ever offset UP toward its stick edge — it never
 * travels past its own flow position — so every px of padding under the host
 * parks the bar exactly that far short of the screen edge at the end of the
 * scroll (the vocab app's `.app-main` pays 60, and it measured as a 60px gap
 * under the bar). Both hosts already zero their OWN bottom pad inline when
 * tabbed; this is the wrapper they sit in. The bar carries the home-indicator
 * inset itself, so there is nothing left for a parent to add. The leading
 * `html` is there to win: the wrappers this has to beat are single-class
 * rules in a stylesheet that loads after this one (subjects/vocab/styles.css
 * `:where(.vocab-app) .app-main`), and an element-qualified selector settles
 * it without naming any one app. */
html :has(> .has-bottom-tabs) { padding-bottom: 0; }

.site-footer-slot:empty { display: none; }
.site-footer-slot > .site-footer {
  max-width: none;
  margin: 40px 0 16px;
  padding-left: 0;
  padding-right: 0;
}
.bottom-tabs {
  margin-top: auto;
  position: sticky;
  bottom: 0;
  z-index: 6;
  display: flex;
  align-items: stretch;
  background: var(--card);
  border-top: 1px solid var(--line);
  /* The bar's own bottom inset — a home-indicator phone keeps the labels off
     the gesture bar. */
  padding-bottom: env(safe-area-inset-bottom, 0px);
  /* It is pinned inside a padded column; the negative margins let the hairline
     and the fill run edge to edge like a real tab bar. The host sets
     --btabs-bleed to its OWN horizontal padding (the practice home's gutter is
     surface-driven, the vocab home's is a flat 20), so the bleed can never
     out- or under-run the column it sits in. */
  margin-left: calc(-1 * var(--btabs-bleed, var(--gutter, 20px)));
  margin-right: calc(-1 * var(--btabs-bleed, var(--gutter, 20px)));
}
.bottom-tab {
  flex: 1 1 0;
  min-width: 0;
  height: 56px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  padding: 0 2px;
  border: none;
  background: none;
  color: var(--ink-soft);
  font-family: var(--font-ui, inherit);
  cursor: pointer;
}
.bottom-tab.is-active { color: var(--accent); }
.bottom-tab-icon { position: relative; display: block; line-height: 0; }
.bottom-tab-label {
  font-size: 11px;
  font-weight: 600;
  line-height: 1.1;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A count, not a dot: the review queue's size is the reason to tap. */
.bottom-tab-badge {
  position: absolute;
  top: -5px; left: 13px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  box-sizing: border-box;
  border-radius: 999px;
  background: var(--accent);
  color: var(--card);
  font-size: 10px;
  font-weight: 700;
  line-height: 16px;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
/* ---------- Top placement (tablet) -----------------------------------------
 * ONE NAVIGATION MODEL ACROSS ROTATION (owner, 2026-09-23). An iPad held
 * upright is the tablet surface; turned sideways it is desktop and the rail
 * carries the same tabs. Upright, the strip moves to the TOP of the column
 * (the iPadOS 18 idiom) instead of a phone-style bar inset to a 680 column.
 * It is the host's FIRST child, pinned at the top inset like the pinned mode
 * header (shared-chrome.css .mode-headwrap--sticky), and the host applies
 * none of the foot machinery above (no .has-bottom-tabs, no footer slot).
 * Pills on the page ground: inactive ink-soft, active on accent-soft with the
 * tinted ink (never `accent` on `accent-soft` — CLAUDE.md accent-tile note).
 * No transition: a tab swap is a page swap, not a value growing. */
.bottom-tabs[data-placement="top"] {
  margin-top: 0;
  margin-bottom: 16px;
  position: sticky;
  top: env(safe-area-inset-top, 0px);
  bottom: auto;
  z-index: 6;
  height: 48px;
  box-sizing: border-box;
  align-items: center;
  justify-content: center;
  gap: 4px;
  padding: 0 8px;
  background: var(--bg);
  border-top: none;
  border-bottom: 1px solid var(--line);
}
.bottom-tabs[data-placement="top"] .bottom-tab {
  flex: 0 0 auto;
  height: auto;
  flex-direction: row;
  gap: 6px;
  padding: 8px 14px;
  border-radius: 999px;
}
.bottom-tabs[data-placement="top"] .bottom-tab.is-active {
  background: var(--accent-soft);
  color: var(--ink-on-tint);
}
.bottom-tabs[data-placement="top"] .bottom-tab-label {
  font-size: 13px;
  line-height: 1.2;
}
/* Desktop: the rail IS the strip (index.html renderSidenav, the unlabelled
   tab group), so the host renders its tabbed page with no strip of its own. */
html[data-surface="desktop"] .bottom-tabs { display: none; }
