/* ui.css — Chassis v2 tokens + components.
 * Spec: library/specs/cartridge-component-grammar-engspec.md §4 (tokens), §5 (components),
 *       §6 (composition), §7 (behaviours).  Served at /chassis/ui.css.
 *
 * No build step (§1.4).  This file is the file that is served.  A window authored as
 * miniapp.html and served from a four-week-stale copy at index.html shipped a correct
 * feature that did nothing; the copy is the defect, not the staleness.
 *
 * This is a REWRITE of v1's ui.css, not a port.  v1 was extracted from one built app
 * (the Beneficent website window) whose grammar was a sticky save bar plus inline
 * per-item controls.  This grammar is full-width action rows and an in-flow action list.
 * ui/TOKENS.md states token by token what survived, what did not, and why.
 *
 * ---------------------------------------------------------------------------------
 * THEME RESOLUTION — the highest-value thing in this file.  Read before "simplifying".
 *
 * Several Telegram clients report WHITE for secondary_bg_color.  A window that paints
 * its ground or its cards from --tg-theme-secondary-bg-color gets white cards on a
 * white page and hint-grey text on white: unusable, and it presents as a rendering
 * failure rather than a palette bug, so it is diagnosed slowly.  MEASURED.
 *
 * Therefore: the palette is LITERAL, and scheme selection is keyed off tg.colorScheme
 * written to a root attribute by ui.js:
 *
 *     try { document.documentElement.dataset.scheme = tg.colorScheme || 'light'; } catch (e) {}
 *
 * NOT off prefers-color-scheme.  The OS media query is not the client's scheme;
 * Telegram's in-app theme is independent of it.  The media block at the bottom is
 * scoped :root:not([data-scheme]) so it can only ever fire OUTSIDE Telegram.
 *
 * Exactly three --tg-theme-* reads are permitted, each with a literal fallback:
 *   --tg-theme-button-color, --tg-theme-button-text-color  (§4.3 rule 3 — reported
 *     correctly, and the one place a client's accent legitimately belongs)
 *   --tg-theme-text-color   (grandfathered §4.3 rule 3: a wrong INK colour is visible
 *     in review; a wrong GROUND is the one that hides itself)
 * secondary_bg_color is named and forbidden.  load-check.js asserts all of this.
 * ------------------------------------------------------------------------------- */

:root {
  color-scheme: light dark;

  /* The font-family token exists so the family slot of every `font:` shorthand holds
   * a real family.  `font: 600 13px/1 inherit` is INVALID CSS — `inherit` is a
   * CSS-wide keyword and cannot occupy the family slot — so the parser drops the
   * ENTIRE declaration, silently, with no console error and nothing struck through in
   * the authored source.  Fourteen rules died at once that way; the window did not
   * break, it looked slightly wrong everywhere, which is why it survived review.
   * ALWAYS: font: 600 13px/1 var(--ui).   NEVER: ... /1 inherit. */
  --ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", Roboto, system-ui, sans-serif;

  /* Colour — light.  Bare :root, because no data-scheme attribute means light. */
  --app-bg:  #eef1f4;                                /* ground; neutral, NOT the      */
  --surface: #ffffff;                                /* retired Beneficent cream      */
  --line:    rgba(0, 0, 0, .12);
  --hint:    #6f7680;
  --text:    var(--tg-theme-text-color, #111);
  --blue:    var(--tg-theme-button-color, #2ea6ff);
  --on-blue: var(--tg-theme-button-text-color, #fff);
  --danger:  #c53030;
  --shadow-raise: 0 1px 2px rgba(0, 0, 0, .06);      /* the ONLY elevation            */
  --ring-focus:   rgba(128, 128, 128, .35);          /* all that survives --ring-neutral */
  --sel-line:     rgba(46, 166, 255, .55);           /* picker selection border       */
  --bubble-me:    rgba(46, 166, 255, .16);           /* operator bubble ground        */
  --safe-b: env(safe-area-inset-bottom);

  /* Type — six sizes, §4.4.  --fs-body is 16px because iOS Safari zooms the viewport
   * on focus of any field below 16px and the window cannot zoom back out. */
  --fs-caption: 12px;
  --fs-meta:    13px;
  --fs-control: 14px;
  --fs-ui:      15px;
  --fs-body:    16px;
  --fs-title:   clamp(15px, 4.4vw, 19px);

  --lh-flat: 1; --lh-tight: 1.2; --lh-snug: 1.3; --lh-body: 1.45;
  --fw-medium: 600; --fw-bold: 700;   /* two weights.  There is no third. */

  /* Spacing — the 4px grid.  Load-bearing, not advisory: ActionRow and ListEntry need
   * fixed geometry to be ONE component rather than a family of near-misses.  §4.5:
   * the grid does not bend. */
  --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px;

  /* Radius */
  --r-sm: 8px; --r-control: 10px; --r-card: 14px; --r-bubble: 15px;
  --r-pill: 99px;    /* optional — no required component uses it (retired chip)      */
  --r-round: 50%;    /* optional — spinner only                                      */

  /* Geometry.  NOT lifted from the wireframes: those are 300x490 print frames, so a
   * `.arow{padding:9px 2px}` with a 22px icon column is a scaled drawing.  These are
   * derived from the roles plus two hard platform constraints. */
  --tap-min:   44px;   /* minimum tap target; a constraint the figures cannot express */
  --row-min-h: 48px;   /* >= --tap-min with room for label + one description line     */
  --icon-col:  24px;   /* one value so rows on different screens align                */
  --chev-col:  16px;
  --tab-underline: 2px;

  /* Published at runtime by ui.js from visualViewport (§7.1).  Resting values are the
   * correct no-keyboard values, so anything reading them works before the first event.
   * NOTE: --bar-h is deliberately absent.  See TOKENS.md. */
  --vv-h: 100vh;
  --kbd-inset: 0px;
}

:root[data-scheme=dark] {
  --app-bg:  #17212b;                   /* Telegram's own dark ground   */
  --surface: #212d3b;
  --line:    rgba(255, 255, 255, .14);
  --hint:    #8b98a5;
  --text:    var(--tg-theme-text-color, #f1f1f1);
  --shadow-raise: 0 1px 2px rgba(0, 0, 0, .35);
  --bubble-me:    rgba(46, 166, 255, .22);
}

/* Outside Telegram — a browser, the diagnostic page, the test harness — nothing sets
 * data-scheme, so honour the OS.  The :not() guarantees this can never override an
 * explicit tg.colorScheme; inside Telegram the attribute is always present and this
 * whole block is inert. */
@media (prefers-color-scheme: dark) {
  :root:not([data-scheme]) {
    --app-bg:  #17212b;
    --surface: #212d3b;
    --line:    rgba(255, 255, 255, .14);
    --hint:    #8b98a5;
    --text:    var(--tg-theme-text-color, #f1f1f1);
    --shadow-raise: 0 1px 2px rgba(0, 0, 0, .35);
    --bubble-me:    rgba(46, 166, 255, .22);
  }
}

* { box-sizing: border-box }

body {
  margin: 0;
  font: var(--fs-body)/var(--lh-body) var(--ui);
  background: var(--app-bg);
  color: var(--text);
  -webkit-font-smoothing: antialiased;
  /* THE HORIZONTAL AXIS IS OURS; THE VERTICAL ONE IS THE BROWSER'S.
   *
   * MEASURED, 2026-09-19: with the default `touch-action: auto` a horizontal drag was
   * claimed by the compositor as a pan almost immediately — Chromium delivered
   * pointerdown and then POINTERCANCEL, with no pointermove and no pointerup, so
   * ui.js's swipe recogniser never saw a gesture at all and swipe-to-go-back could not
   * fire on any screen.  `pan-y` hands vertical panning to the browser, which is what
   * scrolls a screen and what pages the calendar, and keeps horizontal movement as
   * ordinary pointer events.
   *
   * `pinch-zoom` is kept explicitly: `pan-y` alone also takes away the operator's
   * ability to zoom a screen, and a photograph of a syllabus is exactly the thing he
   * will want to zoom. */
  touch-action: pan-y pinch-zoom;
}
/* AND ON EVERY SCROLL CONTAINER IN THE GRAMMAR, because touch-action is NOT inherited
 * across one.  MEASURED: with the declaration on <body> alone the recogniser still saw
 * pointerdown -> pointercancel on every screen.  `.screen` carries `overflow-x: hidden`,
 * which makes it a scroll container, and the effective touch-action walk stops there —
 * so the element under the thumb resolved to `auto` and the compositor claimed the
 * gesture.  Listing the three containers by name rather than reaching for `*`: a
 * universal rule would also take horizontal panning away from a text field, and §5.3
 * forbids horizontal scrolling in the grammar, not inside a control. */
.screen, .weekpager, [role=tabpanel] { touch-action: pan-y pinch-zoom }

/* --- 5.1 TitleBar -----------------------------------------------------------------
 * The cartridge names itself here because the Telegram app bar cannot be renamed —
 * there is no setTitle.  Telegram's header supplies the close and the menu, so no
 * close and no overflow live here, and an AUTHORED <button> inside .titlebar is still
 * a build failure that the load check fails on.
 *
 * The rule held through 2026-09-19, when back appeared to be missing on every screen
 * and a chevron was injected here to fix it.  The mechanism was fine; ui.js was pinned
 * in the device's cache.  The line came straight back out. */
.titlebar {
  background: var(--surface);
  padding: var(--sp-3) var(--sp-4);
  font: var(--fw-bold) var(--fs-title)/var(--lh-tight) var(--ui);
  border-bottom: 1px solid var(--line);
}
/* --- THE DRAWN BACK CONTROL — ui.js `drawBack`, DESKTOP CLIENTS ONLY --------------
 * MEASURED, 2026-09-19, off the operator's two devices: Telegram's BackButton WORKS on
 * his phone and DOES NOT APPEAR on Telegram Desktop.  His client reports `tdesktop v9.6
 * back:on`, so the SDK accepted show() and tdesktop simply never renders it.  ui.js
 * `needsDrawnBack()` decides; ios and android get nothing drawn at all, which is the
 * operator's own ruling — "space is the most precious commodity".
 *
 * FLOATING, because there is no longer a titlebar to put it in: the header came out of
 * all 18 School screens on the same instruction.  The earlier titlebar chevron of
 * 2026-09-19 is where the button reset and the 44px target below come from — what was
 * wrong with that control was that it applied EVERYWHERE and cost a line at the top of a
 * phone, not how it was built.
 *
 * IT COSTS ZERO VERTICAL PIXELS, and that is the point of D-17.  There WAS a rule here —
 * `body[data-drawnback] .screen { padding-top: calc(var(--sp-4) + var(--tap-min)) }` —
 * which reserved a 44px band above the first tile so the control could never land on it.
 * Measured through the shell on tdesktop at 390x780: inside the cartridge frame it moved
 * the first tile from 16px to 60px down, and the operator read that band as page content:
 * "course screen back is in sub heading not in header".  In the SHELL the same rule did
 * nothing at all, because `#screen { padding: var(--sp-4) }` in shell.html outranks it on
 * specificity — so one rule produced a 0px cost in one document and a 44px cost in the
 * other, which is why one control looked like two different things.
 *
 * It is gone.  The control is drawn ONCE, by the outermost document (ui.js
 * `ownsTheExit`), floating over the top-left corner of the frame it is the chrome for,
 * and it displaces nothing.  Overlapping 44px of the corner is the price and it is the
 * one the operator has already been looking at on every tabs screen without complaint;
 * a whole line of his screen is the price he refused, twice, in writing. */
.backctl {
  /* Button reset first: without it the platform paints its own face and its own font. */
  appearance: none; -webkit-appearance: none;
  border: 1px solid var(--line); padding: 0;
  background: var(--surface); color: var(--blue);
  box-shadow: var(--shadow-raise);
  border-radius: var(--r-round);
  /* NOT `font: inherit` — `inherit` cannot occupy the family slot of the shorthand, so
     the parser drops the whole declaration and the platform's button face returns. */
  font: var(--fw-medium) var(--fs-title)/var(--lh-flat) var(--ui);
  /* The glyph is small; the TARGET is not.  44px is the difference between a control the
     operator hits and one they hit on the second try. */
  min-width: var(--tap-min); min-height: var(--tap-min);
  display: flex; align-items: center; justify-content: center;
  position: fixed; z-index: 3;
  /* THE WINDOW'S OWN CORNER, AND 0 IS A MEASUREMENT RATHER THAN A TASTE.
   *
   * It sat at (8,8) until 2026-09-20 and the operator's verdict was "desktop has back in
   * a subheading on all screens" — a third report of the same sentence, against a control
   * that by then cost the page nothing.  MEASURED through a client that frames the whole
   * Mini App, 390x780, tdesktop, absolute viewport boxes, AND on android beside it:
   *
   *   shell #screen        0,0 390x699        the frame gutter is `padding: var(--sp-4)`
   *   cartridge iframe    16,16 358x660
   *   .tabs   (depth 2)   16,16 358x45        the cartridge's OWN rail — on the phone too
   *   .screen (depth 3)   16,16               first tile at 32,32; its <h2> text at 44,44
   *   .backctl             8,8  44x44
   *
   * Every one of those boxes is IDENTICAL on android, where nothing is drawn.  So the
   * band above the page is not the control's price — at depth 2 it is the tab rail and at
   * depth 3 it is 16px of frame gutter plus `.screen`'s own 16px, both paid on the phone.
   * What was wrong was the control's own position: at (8,8) it floated ALONE in the
   * gutter, touching nothing, and still ran to (52,52) — over the first glyph of
   * "Molecular Biology" at (44,44).  A lone button in a strip of ground, clipping the
   * first line, is read as a row, which is the sentence he keeps writing.
   *
   * At 0,0 the 44px box ends exactly at 44,44: the LARGEST tap-minimum target that
   * reaches the page's first surface — it lands on the tile's own corner, 12px in — and
   * still clears the first line of text by construction.  Moving it one pixel down or
   * right puts it back on the text; making it smaller breaks --tap-min.  Held down by
   * "it floats ON the page and clear of the page's first line of text" in
   * test/browser/back-control.spec.js, watched failing against the (8,8) rule first.
   *
   * The insets are `env()` only, so a notched phone still pushes it clear — no --sp-2 to
   * re-open the gap on a device that has no inset to give. */
  top: env(safe-area-inset-top);
  left: env(safe-area-inset-left);
  cursor: pointer;
}
.backctl:active { opacity: .5 }
/* NO `body[data-drawnback] .screen` PADDING RULE, AND THERE MUST NOT BE ONE AGAIN.  See
   the block above: reserving room for this control is the D-17 defect, not the fix for
   it.  The attribute is still set on <body> by whichever document drew, as a diagnostic
   — it is a hook for "who drew this", never for layout.
   AND THE CLAIM THAT USED TO STAND HERE — "a tab rail is depth 1 only and this control
   is depth >= 2 only, so the two can never be on one screen" — IS MEASURABLY FALSE, and
   was false the day it was written.  The two depths are counted in DIFFERENT documents:
   School's tabs screen is the cartridge's own depth 1 and the SHELL's depth 2, so the
   shell draws the control directly over the rail.  That collision is fixed below. */

/* NOTHING ELSE LIVES IN THE TITLEBAR.  A `.backctl` chevron was injected here on
 * 2026-09-19 and removed the same day: the mechanism was never broken, ui.js was
 * cache-pinned on the operator's device, and he rejected the drawn control on sight —
 * "there's no way we want to give up a whole line at the top of our UI to a header and
 * a back — we want the native back, the one we've always used".  BackButton is the only
 * back control; an in-page equivalent buys nothing and creates a divergence to keep. */

/* --- 5.2 TabBar — depth 1 only, 2-4 tabs, label only, no badge or count ----------
 * STICKY, and that is load-bearing rather than decorative.  The calendar scrolls the
 * DOCUMENT to reach the current week on open, so a static tab bar scrolls off the top
 * the moment School opens — and the week tiles then sit exactly where the tabs were and
 * swallow the taps aimed at them.  Measured by the browser harness 2026-09-19: clicking
 * "Courses" timed out with `<div class="tile"> … intercepts pointer events`.  The tab
 * bar is how you leave the calendar; it cannot be a thing you have to scroll back up to
 * find. */
.tabs { display: flex; gap: var(--sp-4); padding: 0 var(--sp-4);
        background: var(--surface); border-bottom: 1px solid var(--line);
        position: sticky; top: 0; z-index: 2; }
/* THE RAIL STEPS SIDEWAYS FOR THE DRAWN BACK CONTROL, AND ONLY SIDEWAYS.
 *
 * MEASURED 2026-09-20, 390x780, tdesktop, through a client that frames the whole Mini
 * App, absolute viewport boxes:
 *
 *   .backctl                       0,0   44x44    drawn by the SHELL, in the window's corner
 *   #tab_calendar  BEFORE          32,16 54x44    overlap x=32..44 — 12px of the tab
 *   #tab_calendar  AFTER           68,16 54x44    clear by 24px
 *
 * The screenshot of the BEFORE row reads "(alendar Courses": the control clipped the "C".
 * The worse half is invisible in a screenshot — those 12px of the tab took no tap at all,
 * because the control is in the outer document and sits on top of the frame.  A tab that
 * renders and does not answer a tap is worse than a missing one; it looks available.
 *
 * WHY THE OTHER TWO FIXES ARE NOT THE FIX.  Moving the control is out: 0,0 is measured
 * (see the block above `.backctl`) and every other position lands on the page's first
 * line of text.  Padding the rail DOWN is out and is out permanently — "space is the most
 * precious commodity", "there's no way we want to give up a whole line at the top of our
 * UI".  He has refused vertical cost three times in writing and this control has already
 * been rebuilt twice for ignoring it.  A horizontal inset costs the page NO height at
 * all; what it spends is 52px of a 358px rail that holds two short labels.
 *
 * `--tap-min + --sp-2` = 44 + 8: the control's own width, plus a gap wide enough that the
 * two are read as separate things rather than as a control with a label.  Written in the
 * tokens so it stays true if the tap minimum ever moves — a hard 52px would silently stop
 * clearing the control the day --tap-min changed.
 *
 * AND IT FIRES ONLY WHERE A CONTROL IS ACTUALLY DRAWN.  `data-drawnback` says "a drawn
 * back control is in this document's top-left corner", published across the frame
 * boundary on the one bridge (ui.js `chassis:drawnback`, posted by shell.html).  It is NOT
 * `@media (hover: hover)` and must not become it: `needsDrawnBack()` already answers "does
 * this client need a drawn control", and a second answer in CSS is how one control came to
 * look like two different things on two screens.  On a phone nothing is drawn, no message
 * says otherwise, and the rail is byte-for-byte what it always was.
 *
 * Held down by "at depth 2 it clears the tab rail, and the whole Calendar tab still takes
 * a tap" in test/browser/back-control.spec.js — which hit-tests three points across the
 * tab from the shell document rather than trusting the rectangles, and which also asserts
 * the rail's y and height are unchanged from android, so the vertical answer cannot come
 * back as a fix for something else. */
body[data-drawnback] .tabs { padding-left: calc(var(--tap-min) + var(--sp-2)) }
/* --- THE WEEK PAGER — one week is one screen, and a swipe moves exactly one ---------
 *
 * Operator, 2026-09-19: "on the calendar on a phone i want the week tile to be a screen
 * so swipe up goes back one week and swipe down goes ahead a week. not a fluid scroll
 * through weeks."
 *
 * NATIVE CSS SCROLL-SNAP, not a hand-written touch recogniser.  Snapping keeps momentum,
 * inertia, the keyboard, and the accessibility tree for free; a bespoke gesture
 * recogniser owning a whole screen's vertical axis on a phone is where this goes wrong,
 * and it would have to be re-tuned per device for ever.
 *
 * `scroll-snap-stop: always` is the half of it that makes this PAGING rather than
 * snapping: without it one hard flick carries past several weeks and lands on whichever
 * one the momentum ran out over, which is the fluid scroll he asked us to stop doing.
 *
 * The scroller is the WEEK LIST, not the document, so the tab bar above it and the
 * action list below it stay on screen and stay tappable.  Its height is published as a
 * pixel value by school.html's sizer — not 100vh, which ignores both of those and ignores
 * the keyboard inset as well. */
.weekpager {
  overflow-y: auto; overflow-x: hidden;
  /* The scroll ends here.  Without this, paging past the last mounted week hands the
     gesture to the document — and on a mobile Telegram client the document's overscroll
     is what minimises the whole Mini App. */
  overscroll-behavior-y: contain;
  -webkit-overflow-scrolling: touch;
  scroll-snap-type: y mandatory;
  display: flex; flex-direction: column;
  /* Registered through ui.registerSizer, which REFUSES an element whose CSS min-height
     is 0 (§7.2 part 2): the pager has to be usable even if the sizer never runs. */
  min-height: 240px;
  height: var(--pager-h, 60vh);
}

/* ⚠ THE DIRECTION OF TRAVEL.  See SWIPE_UP_GOES_BACK in school.html — this class is
   what that constant switches on, and the comment there is the one to read.  In short:
   the operator asked for swipe-up to go BACK a week, which inverts the usual reading of
   a list whose past is above; `column-reverse` is the one mechanism that delivers it
   while leaving the DOM order alone, so past stays FIRST in the document and every
   piece of mount/prefetch/observer arithmetic keeps reading the way it reads today. */
.weekpager.invert { flex-direction: column-reverse }

[data-week] {
  /* ONE WEEK, ONE SCREEN.  min-height rather than height: a week dense enough to be
     taller than the window is still readable by scrolling through it, and mandatory
     snapping relaxes for a snap area larger than the scrollport.  A fixed height would
     clip a busy week, which is the week that matters most. */
  min-height: var(--pager-h, 60vh);
  flex: 0 0 auto;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  /* The pager's top IS the top of the scrollport — it starts below the tab bar — so
     there is nothing to clear here any more.  The old value was the tab bar's height,
     for the days when the DOCUMENT scrolled to reach a week; left at 0 explicitly rather
     than deleted, because a non-zero value here silently offsets every snap position. */
  scroll-margin-top: 0;
  /* OPT OUT OF THE BROWSER'S SCROLL ANCHORING.  Scroll snap re-snaps to the week that
     was snapped when content is inserted or resized, and that is the one mechanism that
     behaves the same in WebKit (which implements no scroll anchoring at all) and in
     Chrome (which does).  With anchoring ALSO running, the two corrected the same
     insertion twice and one wheel gesture at the top of the list threw the page 1510px —
     measured.  One mechanism, everywhere. */
  overflow-anchor: none;
}
.tab {
  background: none; border: 0;
  border-bottom: var(--tab-underline) solid transparent;   /* transparent, not absent:
        selection must not move layout */
  color: var(--hint); padding: var(--sp-3) 0; min-height: var(--tap-min);
  font: var(--fw-medium) var(--fs-control)/var(--lh-flat) var(--ui);
}
.tab[aria-selected=true] { color: var(--text); border-bottom-color: var(--text); }
.tab[disabled] { opacity: .5 }

/* --- 5.3 Screen — the scroll container for one depth.  Content, then at most one
 * ActionList, last.  Horizontal scrolling is forbidden. */
.screen { padding: var(--sp-4); overflow-x: hidden; }

/* --- 5.4 SectionHeading — text only.  A trailing "+" here IS a per-section button
 * row wearing a different hat, and it is what makes one-action-list untrue. */
.shead { font: var(--fw-medium) var(--fs-caption)/var(--lh-snug) var(--ui);
         color: var(--hint); text-transform: uppercase; letter-spacing: .06em;
         margin: var(--sp-4) 0 var(--sp-2); }

/* --- The ground.  THIS LIVES IN ui.css, NOT IN THE SHELL, because every document links
 * ui.css and the operator's instruction was "should be on all pages" — a rule in the
 * shell paints the launcher and nothing else, since a cartridge screen is its own
 * document inside an iframe and an iframe is opaque.
 *
 * A fixed pseudo-element rather than `background-attachment: fixed`, which mobile
 * webviews handle badly.  LIGHT SCHEME ONLY: the artwork is beige and reads as a stain
 * under a dark palette, so dark stays flat on its own token. */
body::before {
  content: ""; position: fixed; inset: 0; z-index: -1;
  background: var(--app-bg) url("/chassis/ui/2n-background.png") center/cover no-repeat;
}
:root[data-scheme="dark"] body::before { background: var(--app-bg); }

/* --- 5.5 Tile — a COLLECTION as one floating object.  Content, never an action host. */
.tile { background: var(--surface); border-radius: var(--r-card);
        box-shadow: var(--shadow-raise); padding: var(--sp-3);
        margin-bottom: var(--sp-2); }
.tile > h2, .tile > h3 { margin: 0 0 var(--sp-2);
        font: var(--fw-bold) var(--fs-ui)/var(--lh-tight) var(--ui); }
.tile .meta { font: var(--fs-meta)/var(--lh-snug) var(--ui); color: var(--hint); }

/* A TAPPABLE tile.  The spec calls a Tile "content, never an action host", and for a
 * tile that GROUPS rows that is right — the rows are the actions.  But wireframe 2 shows
 * each course as one floating tile the learner taps, and the launcher shows each app the
 * same way.  A collection whose members are each one object has nothing inside to tap.
 *
 * So this is a declared variant rather than a local override, and it carries the button
 * reset the bare `.tile` does not need: background, border, text-align and width all have
 * UA defaults on <button> that make an unreset button look like a button and not like a
 * tile.  That is the same gap `.iline` hit — noted for the library owner, not papered over
 * screen by screen. */
.tile.tappable { display: block; width: 100%; text-align: left; border: 0;
                 color: inherit; cursor: pointer;
                 font: var(--fs-ui)/var(--lh-snug) var(--ui);
                 min-height: var(--row-min-h); }
.tile.tappable:active { box-shadow: var(--shadow-press, var(--shadow-raise));
                        transform: translateY(1px); }
.tile[aria-disabled="true"] { opacity: .55; cursor: default; }
.tile[aria-disabled="true"]:active { transform: none; }

/* --- 5.6 ListEntry — one item in a listing.  A card in a list.
 * Elevation owns box-shadow; SELECTION owns the check glyph and border-color.  That
 * resolves the v1 conflict where a state ring and elevation were both box-shadow and
 * so could not compose. */
.lentry { display: grid; grid-template-columns: var(--icon-col) 1fr auto;
          gap: var(--sp-3); align-items: center; width: 100%; text-align: left;
          min-height: var(--row-min-h); padding: var(--sp-2) var(--sp-3);
          margin-bottom: var(--sp-2); background: var(--surface); color: inherit;
          border: 1px solid var(--line); border-radius: var(--r-card);
          font: var(--fs-ui)/var(--lh-snug) var(--ui); }
.lentry strong { display: block; font-weight: var(--fw-medium); }
.lentry small  { display: block; font: var(--fs-meta)/var(--lh-snug) var(--ui);
                 color: var(--hint); }
.lentry[aria-checked=true] { border-color: var(--sel-line); }
.lentry[disabled], .lentry[aria-disabled=true] { opacity: .5 }

/* --- 5.6a ListEntry, TWO-TARGET variant — `.lentry.mrow`.  Same card, same geometry,
 * same tokens; no new colours and no new sizes.  It exists because one row can mean two
 * things: the operator's ruling on an artifact list is "if i click that button i want to
 * edit the thing if i click the filename i want to see the thing."
 *
 * The row itself stops being the control.  `.lentry` is authored as a <button> and a
 * <button> may not contain a <button> — nested interactive content is invalid HTML and
 * browsers and screen readers genuinely disagree about what a click inside one means.
 * So `.lentry.mrow` is a non-interactive <div> holding two sibling native buttons, both
 * keyboard-activatable, both in the tab order, no tabindex anywhere.
 *
 * Both targets restate .lentry's own button reset, because that reset applies to the ROW
 * and the row is no longer the button.  The font is RESTATED rather than `font: inherit`:
 * a <button> does not inherit the page font (the UA sets its own), and `inherit` in the
 * family slot of the shorthand is invalid CSS that makes a parser drop the whole
 * declaration silently — load-check.js fails the build over exactly that, having been
 * beaten by it once for fourteen rules at a time. */
.mrow-edit, .mrow-open {
  -webkit-appearance: none; appearance: none;
  background: none; border: 0; padding: 0; color: inherit;
  font: var(--fs-ui)/var(--lh-snug) var(--ui);
  text-align: left; cursor: pointer;
}

/* THE 44px TARGET IN A 24px COLUMN.  A 44px control placed naively in the icon cell
 * widens the column by 20px and makes every row on the screen taller.  Instead the
 * button is 44x44 with NEGATIVE margins that shrink its MARGIN box back to the exact
 * size the cell already was.  Layout is computed from the margin box; hit-testing is
 * done against the border box.  The grid is untouched, the row is the height it has
 * always been, and the thumb gets the full 44px.  The overflow goes into space that is
 * already empty: 10px left into the card's padding, 10px right into the column gap.
 *
 * The two calc()s ARE that arithmetic, written in tokens rather than pixels so a change
 * to --tap-min, --icon-col, --row-min-h or --sp-2 keeps the geometry true instead of
 * quietly breaking it: (24 - 44)/2 = -10px each side, (48 - 16 - 2 - 44)/2 = -7px top
 * and bottom.
 *
 * THE `- 2px` IS THE ROW'S BORDER AND IT WAS MEASURED, NOT REASONED.  `* { box-sizing:
 * border-box }`, so .lentry's min-height of --row-min-h 48px is the BORDER box: the
 * content box a child is laid out in is 48 less 2x--sp-2 of padding less the 1px border
 * top and bottom = 30px, not 32px.  Written as 32, the icon's margin box was 2px taller
 * than the row's minimum and every row grew from 48px to 50px — which the "does not make
 * the row any taller" test caught on the first run, and which no amount of looking at it
 * would have.
 *
 * AND THE TWO TARGETS DO NOT OVERLAP, with 2px to spare.  From the card's left edge the
 * edit button spans 2px → 46px; the label button starts at 12 + 24 + 12 = 48px.  A tap
 * at the inner edge of the icon cannot reach view, and a tap at the first character of
 * the filename cannot reach edit.  test/browser/material-row.spec.js asserts that
 * against real bounding boxes, because arithmetic like this survives a token change and
 * stops being true without anything looking different. */
.mrow-edit {
  display: flex; align-items: center; justify-content: center;
  width: var(--tap-min); height: var(--tap-min);
  margin-left:  calc((var(--icon-col) - var(--tap-min)) / 2);
  margin-right: calc((var(--icon-col) - var(--tap-min)) / 2);
  margin-top:    calc((var(--row-min-h) - var(--sp-2) * 2 - 2px - var(--tap-min)) / 2);
  margin-bottom: calc((var(--row-min-h) - var(--sp-2) * 2 - 2px - var(--tap-min)) / 2);
  border-radius: var(--r-sm);
}
/* The one visible difference from a plain icon: pressing it lights up, so the 44px the
 * finger actually has is discoverable rather than a secret.  --ring-focus is the only
 * neutral wash token in the file and it is defined in both schemes. */
.mrow-edit:active { background: var(--ring-focus); }

/* The large target.  95% of taps are for viewing — "if i click it 95% of the time its
 * because i want to look at it. rarely want to edit it" — so the label gets the area:
 * the whole 1fr column, full row height.  min-width:0 is what stops a long filename from
 * blowing the grid column out past the card instead of ellipsing inside it. */
.mrow-open {
  display: flex; flex-direction: column; justify-content: center;
  align-self: stretch; min-width: 0;
}
.mrow-open strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* --- 5.7 ItemLine — a line INSIDE a tile.  No border, no radius, no ground.
 * The distinction from ListEntry is load-bearing and is the one place the wireframes
 * are easy to misread. */
.iline { display: grid; grid-template-columns: var(--icon-col) 1fr auto;
         gap: var(--sp-3); align-items: center; min-height: var(--row-min-h);
         font: var(--fs-ui)/var(--lh-snug) var(--ui); }
.iline b { font-weight: var(--fw-medium) }
.iline .meta { font: var(--fs-meta)/var(--lh-snug) var(--ui); color: var(--hint) }

/* --- 5.8 ActionRow — THE dominant primitive.  Anything that navigates to a
 * destination or invokes an intention.  Not a button: no fill, no border box, no
 * centring.  The chevron is what distinguishes a row from a label; without it the
 * row stops reading as a subordinate destination. */
.arow { display: grid; grid-template-columns: var(--icon-col) 1fr var(--chev-col);
        gap: var(--sp-3); align-items: center; width: 100%; text-align: left;
        min-height: var(--row-min-h); padding: var(--sp-2) var(--sp-1);
        background: none; border: 0; border-bottom: 1px solid var(--line);
        color: var(--text); font: var(--fs-ui)/var(--lh-snug) var(--ui); }
.arow:last-child { border-bottom: 0 }
.arow strong { display: block; font-weight: var(--fw-medium) }
/* ONE line of description.  Two lines makes the list a wall and makes the "reachable
 * with one committed swipe" claim worse than it already is (§0.2a). */
.arow small { display: block; font: var(--fs-meta)/var(--lh-snug) var(--ui);
              color: var(--hint); white-space: nowrap; overflow: hidden;
              text-overflow: ellipsis; }
.arow-ico  { font-size: var(--fs-ui); text-align: center }
.arow-chev { font-size: var(--fs-ui); color: var(--hint); text-align: right }
.arow[disabled], .arow[aria-disabled=true] { opacity: .5 }
.arow[aria-busy=true] { opacity: .7 }         /* in flight; re-entry is refused in ui.js */
/* A destructive action is a ROW IN THE SAME LIST, never a separate confirmation
 * screen.  The description must state the consequence BEFORE the tap — what survives
 * and what does not — and load-check.js fails a .danger row with no description. */
.arow.danger strong, .arow.danger .arow-ico { color: var(--danger) }

/* --- THE ARMED DANGER ROW.  `data-armed="true"`, set by the screen on the row itself.
 *
 * MEASURED, 2026-09-19, and it is the worst kind of defect: the operator believed he had
 * destroyed a course and everything attached to it.  "whoah super bad i just deleted the
 * course and everything associated with it."  Nothing was deleted — archive is archive —
 * but the sentence that matters is the next one: "i tapped it again because i didn't
 * think it worked."  The confirmation step gave him nothing he could see, so he tapped
 * straight through it.  A confirm that is not obviously ARMED is not a confirm; it is a
 * double-tap trap, and the second tap is the one that does the thing.
 *
 * His own design: "when you archive the button changes to something RED or warning-ish
 * with text on top that changes to explain the effect ... click it and the colour
 * reverses and says 'confirm archive + this removes the course from calendar it wont be
 * deleted' basically use the double click behaviour to our benefit on .danger items."
 *
 * INVERTED, not tinted.  Resting is --danger ink on the surface; armed is the reverse, a
 * filled --danger ground.  A tint reads as a hover state at arm's length on a moving
 * thumb, which is the distance this has to be legible from.
 *
 * THE LAYOUT DOES NOT MOVE — not one pixel.  Colour and background only: no border
 * WIDTH change, no padding, no font-weight.  A row that changes size under a thumb
 * already travelling toward it is how the second tap lands somewhere else, which is the
 * defect this rule exists to fix rather than a new one to introduce.
 *
 * The ink is a LITERAL #fff rather than a token, and that is deliberate: --surface and
 * --line are redefined under :root[data-scheme=dark] and --danger is NOT, so the armed
 * ground is the same #c53030 in both schemes and white is the contrast answer in both.
 * A token that follows the scheme would change the ink out from under a ground that
 * does not.  The icon and the chevron are named because they carry their own colours
 * (--danger and --hint) and would otherwise sit on the filled ground unreadable. */
/* A RECOVERABLE ACTION AND AN UNRECOVERABLE ONE ARE NOT NEIGHBOURS.
 *
 * MEASURED, 2026-09-20: "Archive course" and "Delete course" sat flush against each
 * other in one action list, gap ZERO, both `.danger`, both with a chevron. The operator
 * aimed a confirming tap at Archive and came away believing he had deleted a course and
 * all seven of its documents. He had not — but one row low is "Nothing survives", and
 * nothing on the screen was keeping his thumb away from it.
 *
 * A full tap target of clear space between them, so a mis-aimed tap lands on nothing at
 * all rather than on the permanent one. It applies only where two danger rows are
 * ADJACENT, which is the only place the accident is available. */
.actions .arow.danger + .arow.danger { margin-top: var(--tap-min); }

.arow.danger[data-armed="true"] {
  background: var(--danger);
  color: #fff;
  border-bottom-color: var(--danger);
}
.arow.danger[data-armed="true"] strong,
.arow.danger[data-armed="true"] small,
.arow.danger[data-armed="true"] .arow-ico,
.arow.danger[data-armed="true"] .arow-chev { color: #fff }

/* --- 5.9 ActionList — the single place a screen's actions live.  Last element of the
 * screen.  NOT sticky: pin it and it is a bar, and the whole cost/benefit of this
 * grammar changes (that bar is exactly what made the v1 tokens non-portable). */
.actions { border-top: 1px solid var(--line); margin-top: var(--sp-4);
           padding-bottom: var(--safe-b); }

/* --- 5.10 Button — EXACTLY ONE style, and the whole component is marked UNPROVEN in
 * the spec: `.primary` is defined in the wireframe stylesheet and used in ZERO of the
 * 21 figures.  It is built here minimally and deliberately nothing is designed around
 * it.  It is hidden on a clean form (the resting state) and offered by form state.
 * A cartridge that is unsure implements save-on-blur with the unsaved guard instead. */
.primary { display: block; width: 100%; min-height: var(--tap-min);
           border: 0; border-radius: var(--r-control);
           background: var(--blue); color: var(--on-blue);
           font: var(--fw-medium) var(--fs-ui)/var(--lh-flat) var(--ui);
           margin: var(--sp-4) 0; }
.primary[hidden] { display: none }
.primary[disabled] { opacity: .5 }

/* --- 5.11 Field — capturing one value, AS CONTENT.  Fields sit in the content region
 * above the action list, exactly like a tile.  16px is not a taste: below it, iOS
 * Safari zooms the viewport on focus and the window cannot zoom back out.  There is
 * deliberately no size variant. */
.field { display: block; margin-bottom: var(--sp-3) }
.field > span { display: block; font: var(--fs-meta)/var(--lh-snug) var(--ui);
                color: var(--hint); margin-bottom: var(--sp-1); }
.field input, .field textarea, .field select {
  display: block; width: 100%; min-height: var(--tap-min); padding: var(--sp-2);
  background: var(--surface); color: var(--text);
  border: 1px solid var(--line); border-radius: var(--r-control);
  font: var(--fs-body)/var(--lh-body) var(--ui);
}
.field textarea.big { min-height: calc(3 * var(--fs-body) * var(--lh-body) + 2 * var(--sp-2));
                      resize: vertical; }
.field[data-dirty=true] input, .field[data-dirty=true] textarea { border-color: var(--blue) }
.field[data-invalid=true] input, .field[data-invalid=true] textarea { border-color: var(--danger) }

/* --- 5.12 Picker — the scope line is part of the component, not a nicety: the screen
 * must SAY that deselecting does not remove anything from the collection, and the
 * confirm handler in ui.js is constructed with no delete verb in reach. */
.picker .scope { font: var(--fs-meta)/var(--lh-snug) var(--ui); color: var(--hint);
                 margin-bottom: var(--sp-3); }
.lentry .check { font-size: var(--fs-ui); text-align: center }

/* --- 5.13 EmptyState — followed, NEVER replaced, by the screen's normal action list.
 * This is what makes empty states cheap: only the content region changes. */
.empty { text-align: center; padding: var(--sp-5) var(--sp-4) }
.empty h2 { margin: 0 0 var(--sp-2); font: var(--fw-bold) var(--fs-ui)/var(--lh-tight) var(--ui) }
.empty p  { margin: 0; font: var(--fs-meta)/var(--lh-body) var(--ui); color: var(--hint) }

/* --- 5.14 InlineNotice — a recoverable condition reported IN PLACE.  Severity by
 * colour only.  No fill, no border, no radius, no icon badge: the filled amber warning
 * box was measured and replaced by one muted line of text.  It never becomes a screen
 * and it never claims a success that did not land. */
.notice { font: var(--fs-meta)/var(--lh-body) var(--ui); color: var(--hint);
          margin: var(--sp-2) 0; }
.notice.error { color: var(--danger) }

/* --- 5.15 ContextCard — rendering only.  It contains NO control: no button, no
 * inline keyboard, no chips.  The options are named in prose and answered by typing.
 * That is the whole point: the card opens a conversation, it does not present a menu. */
.ctxcard { background: var(--surface); border: 1px solid var(--line);
           border-radius: var(--r-card); padding: var(--sp-3); }
.ctxcard h2 { margin: 0; font: var(--fw-bold) var(--fs-ui)/var(--lh-tight) var(--ui) }
.ctxcard .stamp { font: var(--fs-caption)/var(--lh-snug) var(--ui); color: var(--hint);
                  margin: var(--sp-1) 0 var(--sp-2); }
.ctxcard p { margin: 0 0 var(--sp-2); font: var(--fs-meta)/var(--lh-body) var(--ui) }
.ctxcard .lead { font-weight: var(--fw-medium) }

/* --- 5.16 Transcript — a returned, bounded transcript as a READ-ONLY record.
 * There is no composer and there is nothing to wire one to: Telegram is the one
 * conversation.  Assistant left, operator right, timestamp at the lower right. */
.transcript { background: var(--app-bg); padding: var(--sp-3) }
.bubble { max-width: 88%; margin-bottom: var(--sp-2); padding: var(--sp-2) var(--sp-3);
          border-radius: var(--r-bubble); background: var(--surface);
          border: 1px solid var(--line);
          font: var(--fs-meta)/var(--lh-body) var(--ui); }
.bubble.me { margin-left: auto; background: var(--bubble-me); border-color: transparent }
.bubble .stamp { display: block; text-align: right;
                 font: var(--fs-caption)/var(--lh-snug) var(--ui); color: var(--hint) }

/* --- 5.17 Spinner — in-place progress inside a row or a button already tapped.
 * Never a full-screen overlay: a loading screen shows its NAME and a skeleton. */
.spinner { display: inline-block; width: 14px; height: 14px; border-radius: var(--r-round);
           border: 2px solid var(--ring-focus); border-top-color: var(--text);
           animation: ui-spin .8s linear infinite; }
@keyframes ui-spin { to { transform: rotate(360deg) } }
.skeleton { background: var(--ring-focus); border-radius: var(--r-sm); min-height: var(--sp-4) }

/* --- 5.18 TypeTag — words, in the trailing slot of a ListEntry.  Icons support words
 * and never replace them; the leading glyph is decoration and carries aria-hidden. */
.tag { display: inline-block; padding: var(--sp-1) var(--sp-2); border-radius: var(--r-sm);
       background: var(--ring-focus); color: var(--hint);
       font: var(--fs-caption)/var(--lh-flat) var(--ui); }

/* --- 7.5 Focus.  Not optional: this window runs in a webview on hardware with
 * external keyboards. */
:focus-visible { outline: 2px solid var(--ring-focus); outline-offset: 1px }

/* --- 7.1 The ONE keyboard-avoidance pin in the library.  Nothing else in any chassis
 * or cartridge stylesheet may be fixed or sticky (§6.9), and load-check.js enforces
 * that by selector.  --kbd-inset is published from visualViewport by ui.js; a cartridge
 * that genuinely needs a pinned footer publishes its OWN height variable in its OWN
 * document by this same technique.  The technique survived v1; the --bar-h token did not. */
.ui-kbd-pin { position: fixed; left: 0; right: 0;
              bottom: calc(var(--kbd-inset) + var(--safe-b)); }

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