/* The learner's own layout.
 *
 * READS tokens.css AND NEVER A RAMP. Every colour below is a semantic token:
 * --bg, --bg-sunken, --card, --fg, --fg-2, --muted, --hair, --seal, --shadow.
 * That indirection is the whole reason the ground toggle (step 8 of
 * docs/student-web-build.md) is a token swap rather than a rewrite of this
 * file, and it is why nothing here mentions --paper-* or --ink-*.
 *
 * assertCssWellFormed() runs over this file in tools/build-web.mjs. It exists
 * because a comment closed one character early once, which left the rest of its
 * prose in the file as raw CSS and silently dropped every rule until the parser
 * found something it recognised again. Nothing errored, and the page rendered
 * wrong in a way that looks like a spacing bug.
 *
 * ── THE ONE LAYOUT DECISION IN THIS FILE ───────────────────────────────────
 * The four tabs are a LEFT SIDEBAR on a wide screen and a BOTTOM BAR on a
 * phone, because that is what they already are in the app: Route.swift:16
 * names four tabs and the iOS shell puts them under the thumb. A sidebar
 * squeezed onto a 390px screen is a sidebar nobody can reach one-handed, and a
 * learner arriving from the app should not have to relearn where the tabs went.
 * The breakpoint is 860px, below which the grid becomes one column.
 */

/* ── THE APP'S OWN SCALE ───────────────────────────────────────────────────
 *
 * tokens.css carries the SITE's type ramp — `--fs-display` at 104px, `--fs-h1`
 * at 60 — and it is a marketing document's ramp: fluid, enormous at the top,
 * built so a landing page can shout. This host is an app, and an app's ramp is
 * short, fixed and quiet. Using the site's meant every screen here was tuned by
 * writing a literal `font-size` next to it, and there were sixty of them: nine
 * different sizes between 12 and 15px, four separate "kicker" classes, and no
 * way to answer "how big is a label" except by reading the rule you were
 * looking at.
 *
 * These are the app's, from `Theme.swift`, and they are FIXED rather than
 * clamped. A 30px display heading that grows to 104px on a desktop is a
 * marketing page; a learner's Today screen is the same screen on both.
 *
 * ── THREE SURFACES, AND THE THIRD ONE IS NEW ──────────────────────────────
 * page (`--bg`) → card (`--card`) → raised (`--raised`). The app has had all
 * three since Theme.swift:18; this host had two and faked the third with a
 * hairline, which is why a selected nav row and an unselected one looked the
 * same at a glance. `--raised` is the chip, the segment, the ring's track and
 * the lit nav row.
 *
 * ── AND EVERY DURATION IS NAMED ───────────────────────────────────────────
 * Because the block at the very bottom of this file turns all of them off
 * under `prefers-reduced-motion`, and a duration written inline is one that
 * block cannot find.
 */
:root {
  /* Type. Display is the screen title, over is the all-caps strap. */
  --t-display: 30px;
  --t-h1: 26px;
  --t-h2: 19px;
  --t-h3: 15px;
  --t-body: 14.5px;
  --t-label: 12.5px;
  --t-cap: 11px;
  --t-over: 10.5px;

  /* Hanzi, which is its own ramp: a character is a picture at the top of this
     scale and a glyph in a chip at the bottom, and neither is "text at a size". */
  --hz-hero: 96px;
  --hz-card: 64px;
  --hz-row: 28px;
  --hz-cell: 22px;
  --hz-glyph: 15px;

  /* Space. Seven steps, and nothing between them: the sixty literals this
     replaced included 13px, 14px and 15px paddings on adjacent elements. */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px;
  --s5: 24px; --s6: 32px; --s7: 48px;

  /* ── CORNERS: FOUR, NOT NINE ────────────────────────────────────────────
     This file had radii of 9, 10, 11, 12, 13, 14 and 16px, mostly on things of
     the same kind. Nobody can see the difference between 11 and 12 and everyone
     can see that nothing lines up. `Theme.swift:103 cardRadius` is 16; a PANEL
     is larger than that here because a panel on a laptop is much bigger than a
     card on a phone, and radius has to grow with the box or a large box reads
     as a rectangle with the corners filed off. */
  --r-panel: 20px;
  --r-card: 16px;
  --r-chip: 10px;
  --r-pill: 99px;

  /* Motion. `--lift` is the one shadow a pressable card gets; every animation
     in this file uses one of these three durations and this one ease. */
  --ease: cubic-bezier(.2, .7, .3, 1);
  --fast: 120ms;
  --base: 220ms;
  --slow: 380ms;
  --lift: 0 1px 2px rgba(0, 0, 0, .16), 0 10px 26px -18px rgba(0, 0, 0, .5);

  /* ── A SURFACE IS A FILL, NOT AN OUTLINE ────────────────────────────────
     Forty-five elements in this file were `background: var(--card); border: 1px
     solid var(--hair)`, and the result reads as a form: a page made of outlined
     boxes is a page of INPUTS, whatever is inside them. Every dark interface
     that reads as expensive does the opposite — Cosmos, Brick, Halide, Opal all
     separate a panel from its page by FILL and by space, and none of them draws
     a line around one.

     `--edge` is the top inner highlight, and it is the whole trick: one
     hairline of light along the upper edge, at four per cent, reads as a
     physical surface catching the light. Without it a filled panel on a dark
     ground is a flat patch; with it, it is an object. `--sink` is the drop
     underneath — wide, deep, and almost black, so it darkens rather than greys.

     Both are declared here and swapped with the ground at the end of the file:
     on paper the light comes from the same place, so the highlight is white and
     the drop is warm rather than black. `--hair` survives, but only for
     DIVIDERS INSIDE a panel. Nothing draws a border around one any more. */
  /* Declared as PAPER, because :root is paper — the ground swap at the end of
     this file replaces both with the ink pair. On paper a white highlight on a
     near-white card is invisible and the work is done by the drop, which is
     warm rather than black so it reads as shadow on paper rather than as
     grease. */
  --edge: inset 0 1px 0 rgba(255, 255, 255, .9);
  --sink: 0 1px 2px rgba(26, 24, 19, .05), 0 18px 40px -26px rgba(26, 24, 19, .26);
  --panel: var(--edge), var(--sink);
}

*, *::before, *::after { box-sizing: border-box; }

/* ── A BUTTON HAS NO BORDER UNLESS IT ASKS FOR ONE ────────────────────────
   Removing `border: 1px solid var(--hair)` from a `<button>` does not remove
   its border: the user agent's own `border: 2px outset buttonborder` comes back
   underneath. So the surfaces pass silently handed the tone buttons and the
   deck options a BROWSER default outline — greyer, thicker and uglier than the
   one it was replacing — and the two rules that then said `border-color: green`
   for a right answer suddenly had a border to colour.

   One line, at the top, and every rule that genuinely wants an edge says so:
   `.r-grade` and the text fields. It is the kind of thing that only shows up
   in a screenshot of a screen you have to be mid-drill to see. */
button { border: 0; }
/* A field's hint in the page's quiet ink, not the browser's #757575 grey,
   which is 3.8:1 on paper's sunken field and 3.8 on ink's (#2144). */
input::placeholder, textarea::placeholder { color: var(--muted); opacity: 1; }

/* ── NO PINCH ON A CARD, AND NO FOCUS ZOOM ON A FIELD (#1943) ──────────────
 * "No zoom in should be possible, static screen." Two separate causes, and
 * only one of them is a gesture:
 *
 *   1. Double-tap zoom. `touch-action: manipulation` removes the 300ms wait
 *      and the zoom with it, which also makes every button feel immediate.
 *      A round is `pan-y` — scrolling the card yes, pinching it no.
 *   2. iOS zooms the whole page when a field under 16px takes focus. The
 *      email field was 14.5px, so tapping it zoomed the sign-in card and left
 *      it zoomed. `max(16px, …)` is the fix and it applies only where the
 *      pointer is coarse, so a desktop keeps the smaller field.
 *
 * NOT `user-scalable=no`. iOS has ignored it since iOS 10, and WCAG 1.4.4 asks
 * for 200% zoom to be available — a learner who needs to magnify the Library
 * still can. What is refused is zoom nobody asked for.
 */
html { touch-action: manipulation; -webkit-text-size-adjust: 100%; }
body.in-session { overscroll-behavior-y: none; }
.screen.review, .screen.result { touch-action: pan-y; }
/* Grading, tone and option rows are tapped fast and repeatedly, and a fast
   second tap on text selects the word instead of pressing the button. */
.grades, .tones, .opts, .limits, .seg-rows { -webkit-user-select: none; user-select: none; }

@media (pointer: coarse) {
  input, select, textarea { font-size: max(16px, var(--t-body)); }
}

/* ── `hidden` HAS TO WIN, AND BY DEFAULT IT DOES NOT ────────────────────────
   The attribute gets `display: none` from the browser's own stylesheet, and any
   author rule that sets `display` beats it. `.boot` and `.signin` are both
   `display: grid`, so `screen('signin')` set `#boot.hidden = true` and the boot
   screen stayed exactly where it was, a full viewport tall, with the sign-in
   card pushed below the fold. The page looked like it had hung on the loading
   mark while the DOM said it had not.

   platform.css:53 carries the same line for the same reason. It is the one
   `!important` either stylesheet has, and it earns it: `hidden` is a statement
   about whether an element exists to the reader, which no layout rule should be
   able to overrule. */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--bg-sunken);
  color: var(--fg);
  font-family: 'Inter', system-ui, -apple-system, sans-serif;
  font-size: var(--fs-small);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

/* 'Noto Serif SC' after Fraunces rather than instead of it: Fraunces carries no
   CJK glyphs, so a Chinese heading falls straight past it, and what it fell to
   was whatever the machine happened to have. Same stack as the other two hosts,
   for the same reason. */
h1, h2, h3 {
  font-family: 'Fraunces', 'Noto Serif SC', Georgia, serif;
  font-weight: 600;
  margin: 0;
}

a { color: inherit; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* The mark. Identical geometry to the platform's, deliberately: a learner who
   has seen the teaching platform or the landing page should not cross a visible
   seam between hosts. */
.chop {
  width: 28px; height: 28px; border-radius: 8px; background: var(--seal);
  color: #F4EEE0; display: grid; place-items: center; flex: 0 0 auto;
  font-family: 'Noto Serif SC', serif; font-size: 9px; line-height: 1.02; font-weight: 600;
}

/* ── The first frame ──────────────────────────────────────────────────────
   A held breath and nothing else. No picture behind it: a full landscape under
   a loading screen promises a page that has already arrived. What turns is the
   mark. */
/* ── BLACK, AND ONLY THE SEAL ─────────────────────────────────────────────
   *"wenn der gesamte Screen läd dann will ich das man nur das Logo drehen sieht
   und nix im Hintergrund … Hintergrund einfach nur schwarz."*

   It was `--bg-sunken` from the body, which is cream on paper and near-black on
   ink — so half the learners got a pale page with a small red square turning on
   it, which reads as a page that has already arrived and is missing its
   content. Black reads as a shutter: nothing has happened yet, and nothing is
   pretending to have happened.

   `#0B0A08` and not `#000`. Pure black against the seal's own red is a harder
   edge than anything else in this product has, and the drawing's ink is
   `#1C1E18` — a black with a little warmth in it belongs to the same palette
   as everything it is about to reveal.

   IT COVERS THE VIEWPORT ITSELF rather than relying on the body: the court is
   `position: fixed` and mounted at sign-in, and on a reload with a live session
   there is a frame where both could be on screen. `position: fixed` with a
   z-index above the room means the shutter is a shutter. */
.boot {
  position: fixed; inset: 0; z-index: 90;
  display: grid; place-items: center;
  background: #0B0A08;
}

/* ── THE SHUTTER FADES; IT DOES NOT CUT ───────────────────────────────────
   *"Wenn wirklich alles geladen ist, dann soll die Seite einmal flüssig
   erscheinen."* The app fades up while this fades out, both over the same
   420ms, so the page arrives OUT of the black it was loading on rather than
   replacing it. `pointer-events: none` the moment it starts leaving, or a
   press that lands during the fade hits a sheet of nothing.

   learn.js hides it with `screen()` when the fade is done — on a timer and not
   on `transitionend`, because that event does not fire on a hidden tab and a
   black sheet left over the app for ever is the worst available outcome. */
.boot.is-out { opacity: 0; pointer-events: none; transition: opacity 420ms var(--ease); }

/* ── NOT ONE THING ANIMATES ON THE FRAME A SCREEN FIRST APPEARS ───────────
   When `display: none` comes off a subtree, every element in it computes its
   styles for the first time — and every transition on those elements then has a
   from-value that differs from its to-value, because the from-value is whatever
   the initial computed style happened to be. The court's 4s filter crossfades
   from `none`, the labels' 900ms scale runs from 1 to 1/1.06, the parchment's
   620ms roll re-settles. Each is correct alone; together they are the screen
   that spazzed for four seconds after the load.

   One frame of this kills all of them, and one frame is enough: by the next,
   every element has a settled computed style and there is nothing stale left to
   animate away from. `!important` because it has to beat every transition in
   the file without knowing any of their names, which is the one job this
   selector has. */
html.no-anim *,
html.no-anim *::before,
html.no-anim *::after {
  transition: none !important;
  animation-duration: 0s !important;
  animation-delay: 0s !important;
}

/* ── THE FADE IS GATED ON `booting`, AND FAIL-VISIBLE IS THE POINT ────────
   The first version was `.app, .signin { opacity: 0 }` with the fade adding
   `.is-in`. That is correct for the boot and catastrophic everywhere else: any
   path that does not reach `curtainUp` shows a blank page. It took one
   screenshot to prove it — the workbench, which has no boot sequence at all,
   came back as an empty cream rectangle.

   A shell must fail VISIBLE. So the transparency is a state the document
   declares while it is loading — `<html class="booting">`, in the markup, so it
   is true from the first byte — and `curtainUp` takes it off. Anything that
   never runs the boot, including a tool and including a JavaScript error after
   the stylesheet has landed, gets a page rather than nothing. */
html.booting .app,
html.booting .signin { opacity: 0; }
.app.is-in, .signin.is-in { opacity: 1; transition: opacity 420ms var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .app.is-in, .signin.is-in, .boot.is-out { transition: none; }
}
.boot .chop {
  width: 52px; height: 52px; border-radius: 13px; font-size: 16px;
  animation: boot-breathe 2.4s ease-in-out infinite, boot-turn 5.6s linear infinite;
}
@keyframes boot-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes boot-turn { from { rotate: 0deg; } to { rotate: 360deg; } }
@media (prefers-reduced-motion: reduce) { .boot .chop { animation: none; } }

/* ── THE SAME SEAL, WAITING INSIDE A SCREEN ──────────────────────────────
   The word "Loading" in grey was the one piece of this host that looked like an
   unstyled web page, and it sat in the middle of a landscape. The first frame
   has turned this mark since the shell was written; the rest of the app was
   still saying the word.

   `is-turning` is one turn and not a loop, because it is also what a PRESS on
   the wordmark runs — one class, two uses, and a spinner that happens to be a
   single revolution repeated is indistinguishable from a spinner. */
.waiting { display: grid; place-items: center; padding: 18vh 0; margin: 0; }
.waiting .chop { width: 44px; height: 44px; border-radius: 12px; font-size: 14px; }
.chop.is-turning { animation: boot-turn 900ms cubic-bezier(.4, 0, .3, 1); }
.waiting .chop.is-turning { animation: boot-turn 1.5s linear infinite; }
/* The seal is inside a link, and a link with a turning child still has to look
   like a link. `will-change` keeps the rotation on its own layer so the text
   beside it is not re-rasterised for eleven frames. */
.top-brand .chop { will-change: rotate; }
@media (prefers-reduced-motion: reduce) {
  .chop.is-turning, .waiting .chop.is-turning { animation: none; }
}

/* ── Signed out ───────────────────────────────────────────────────────────── */
.signin { min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
.signin-card {
  width: 100%; max-width: 420px;
  background: var(--card);
  border-radius: var(--r-panel);
  padding: clamp(26px, 5vw, 38px);
  box-shadow: var(--panel);
}
.signin-card .kicker {
  margin: 14px 0 0; font-size: 11.5px; font-weight: 500;
  letter-spacing: .13em; text-transform: uppercase; color: var(--muted);
}
/* No `html[lang^="zh"]` rule here, though the other two hosts carry one: this
   app ships German and English only (LANGS in learn.js), so a rule for a
   language nobody can choose is a rule that never runs. */
.signin-card h1 { margin-top: var(--s2); font-size: 30px; letter-spacing: -.012em; line-height: 1.16; }
.signin-card .lead { margin: var(--s3) 0 0; color: var(--fg-2); line-height: 1.6; }

/* ── THE CARD FLOATS ON THE LANDING PAGE'S PICTURE (#2180) ─────────────────
   The Teaching Platform's sign-in has done this since 2026-09-05 and
   platform.css carries the long argument; the three rules that matter are
   the same here. FIXED, so a card taller than the window scrolls over a
   picture that stays where it was measured. INERT, `pointer-events: none`,
   because a full-bleed layer that can take a click is how the platform lost
   its range picker. And the CARD above it, `position: relative` with a
   z-index, since a positioned layer otherwise paints over an unpositioned
   one.

   The drawing is ink on cream with its colours in the SVG, so the ink
   ground is the court's night filter (invert and turn the hue back, never a
   dim: dimming puts black mountains on black) under a veil of the page's own
   ground, strong enough that the card reads first. On paper it is the drawing
   as drawn, veiled a little for the same reason. */
.signin { position: relative; }
.signin-art { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.signin-art .art { position: absolute; inset: 0; width: 100%; height: 100%; }
.signin-art::after {
  content: ''; position: absolute; inset: 0;
  background: color-mix(in srgb, var(--bg) 18%, transparent);
}
:root[data-theme="dark"] .signin-art .art {
  filter: invert(1) hue-rotate(180deg) brightness(1.04) contrast(.9) saturate(.72);
}
:root[data-theme="dark"] .signin-art::after { background: color-mix(in srgb, var(--bg) 42%, transparent); }
.signin-card { position: relative; z-index: 1; }

/* ── NO ✎ ON THE DOOR (#2175) ─────────────────────────────────────────────
   The owner: *"hier auf der login / registrier seite … bitte den feedback
   button raus."* A sibling rule rather than a line in learn.js: `#signin` and
   `.fb-open` are both children of <body>, `#signin` first, and `hidden` is the
   one fact every path to and from the card already sets (`screen`,
   `curtainUp`). The fresh-account question and the new-password form are on
   the same card, so they lose it too. */
#signin:not([hidden]) ~ .fb-open { display: none; }

/* ── The app ──────────────────────────────────────────────────────────────── */
/* ══ THE SHELL: ONE BAR, AND IT USED TO BE A SIDEBAR ═══════════════════════
   The owner, 2026-09-09: *"Ich will keine Navbar mehr an der Seite haben. Alles
   wird in diesem Screen gesteuert."*

   232px down the left held six destinations, and the front door holds them now
   — standing in the drawing, at six places in the landscape. Keeping both would
   have put the same six links twice on one screen, and the list would have won:
   a list is easier to read than a painting is to look at, which is precisely
   the outcome the courtyard exists to avoid.

   What survives is not navigation. Three things the marks cannot carry — the
   way home from a screen that is not the front door, the streak, and the
   account — and all three are true wherever you are.
   ═══════════════════════════════════════════════════════════════════════════ */
.app { display: flex; flex-direction: column; min-height: 100dvh; }

.top {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; gap: var(--s5);
  padding: 10px clamp(14px, 2.4vw, 28px);
  padding-top: calc(10px + env(safe-area-inset-top));
  background: color-mix(in srgb, var(--card) 88%, transparent);
  backdrop-filter: blur(14px) saturate(1.06);
  border-bottom: 1px solid var(--hair);
}
.top-brand { display: flex; align-items: center; gap: 10px; text-decoration: none; }
.top-brand .brand { font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-size: 16px; }
/* The account goes right and everything else stays left, which is the one
   arrangement that survives the strip being hidden: `margin-left: auto` on the
   account rather than `space-between` on the bar, or an account with no streak
   beside it would slide into the middle. */
.top-acct { margin-left: auto; display: flex; align-items: center; gap: var(--s3); }

/* ── THE BAR FLOATS, ON EVERY SCREEN, BECAUSE THE ROOM IS ON EVERY SCREEN ──
   A 52px strip of paper across the top of a landscape is a landscape with a lid
   on it. The bar keeps its three controls and loses everything it is made of —
   no paper, no rule, no blur — and each control keeps a small paper of its own
   instead, which is the same language the panel below it is in.

   ONLY WHERE THE ROOM IS FULLSCREEN, which is why this is inside a media query.
   Under 900px there is no room: the drawing is a band, the screens are ordinary
   pages under it, and a bar taken out of the flow there lands on the greeting —
   which is exactly what it did. */
@media (min-width: 744px) {
  .top {
    background: none;
    border-bottom: 0;
    backdrop-filter: none;
    /* Out of the flow, or it would push the room 52px down the window. */
    position: absolute; left: 0; right: 0;
    padding-top: calc(16px + env(safe-area-inset-top));
  }
  /* Each control keeps a small paper of its own, because the bar itself has
     none. THE STRIP IS IN THIS LIST and was not: it is the only one of the
     three that is plain text, so it was the only one with the landscape showing
     straight through it — a bird crossing the top of the picture went through
     "1,240 XP this week" and the line became unreadable for as long as it took
     to pass. */
  .top-brand,
  .acct {
    padding: 5px 12px 5px 6px;
    border-radius: var(--r-pill);
    background: color-mix(in srgb, var(--card) 78%, transparent);
    backdrop-filter: blur(10px);
    box-shadow: var(--sink);
  }
  /* Two of the four go on the map, where the parchment carries the streak three
     inches below in bigger type, and Sign out floating loose on a painting is a
     destructive control somebody hits by accident. Both are on Profile, which
     the avatar opens. Off the map the strip comes back: there is a panel to
     measure it against and no parchment to duplicate. */

  /* ── THE MAP DOES NOT SCROLL ────────────────────────────────────────────
     *"man soll nicht scrollen können."* Everything that was below the fold is
     on the screen it belongs to — the deck rows are Training's and the heatmap
     is Progress's — so there is nothing under the picture to reach, and a
     picture that fills the window and then moves when you touch a trackpad is a
     picture that never felt like a place.

     ONLY THE MAP. Every other screen is a panel with its own scroll, because
     the Library is ten thousand rows and a panel that cannot scroll is a
     Library you can see the first screenful of.

     `overflow: hidden` on the ELEMENTS and not on `<body>` alone: iOS Safari
     ignores it on body while a scroll is in flight, and the html element is the
     one that actually owns the viewport's scroller. */
  /* The parchment hangs from the very top of the window, so the map's own
     gutter has to go — the padding that every other screen wants is the thing
     that would leave a strip of sky above a hanging scroll. */
  /* ══ ON THE MAP, THE SHELL IS TRANSPARENT TO THE POINTER ════════════════
     *"Bei Library und bei League sind die Buttons noch ein bisschen verbuggt.
     Die Symbole, die kann man nicht anklicken."* Both, and it is the same fault
     as the dragon's last time — fixed one layer too shallow.

     THE ROOM IS AT z-index 0 AND THE SHELL IS AT 1, so anything in the shell
     with a box over a mark eats the press, whether or not it has a background.
     Last time `#view` was made transparent, which was the layer the parchment
     is in. But `.main` CONTAINS `#view` and is full-width and as tall as the
     parchment, and `.top` is full-width and 58px — so between them they cover
     a band across the entire top of the window. The League's dragon flies at y
     32% and the Library's pagoda stands at 37%: both were inside that band, and
     the four marks lower down were not, which is exactly the report.

     Naming layers one at a time is how this bug comes back a third time. So the
     whole shell goes transparent on this route and the CONTROLS take their own
     clicks back — the parchment, and the three things in the bar. Anything new
     that wants a press on the map has to say so, which is the right default for
     a screen whose entire background is interactive.

     `pointer-events: none` inherits, so every one of those `auto` lines is
     load-bearing rather than defensive. */
  body.in-court .top,
  body.in-court .main,
  body.in-court #view { pointer-events: none; }
  body.in-court #view { padding: 0 clamp(16px, 2.2vw, 32px); max-width: none; }
  body.in-court .scroll,
  body.in-court .top-brand,
  body.in-court .acct,
  body.in-court .acct { pointer-events: auto; }

  body.in-court,
  html:has(body.in-court) {
    overflow: hidden;
    overscroll-behavior: none;
    height: 100%;
  }
  /* ── AND NO SCREEN BOUNCES OFF THE ROOM (#2338, #2339) ─────────────────
     *"unten Riesen schwarzer Balken"* and *"auch hier ist beim Runterscrollen
     ein schwarzer Balken"*, on the map and on a lesson. A trackpad's scroll
     past the end of a page that has nothing more to show rubber-bands the
     WHOLE page, the fixed room included, and what shows in the gap is the
     root's own ground: `--bg-sunken`, near black after dark. Every screen at
     a desk is a sheet with its own scroller or the map, so the window itself
     has nowhere to go and nothing may bounce it. */
  html { overscroll-behavior: none; }
}

/* The streak strip's rules were here. It is gone from the bar — the bar may not
   change between screens, and this was one of two things being hidden on the
   map. See the note in learn.js for where the number went and what that cost. */

/* ── THE SHELL SITS OVER THE ROOM, AND `.top` IS NOT IN THIS RULE ─────────
   The picture is at z-index 0 and the screen has to be above it, which is what
   this line is for.

   IT USED TO NAME `.top` AS WELL, AND THAT WAS A REAL BUG — the one behind
   *"fange die Pergamentrolle weiter oben an."* The bar is `position: absolute`
   on the map so the drawing starts at the top of the window rather than 64px
   down it, and that override lives in a `@media (min-width: 744px)` block
   ABOVE this line. A media query adds no specificity, so at (0,1,0) each this
   rule simply came later and won: the bar was `relative`, in flow, and every
   screen on this host began 64px lower than it was written to. On the map that
   was a strip of sky above a hanging scroll; on a panel it was 64px on top of
   the 58px margin already there.

   `.top` needs nothing here. Its own rule already carries `z-index: 40`, which
   is over the room by a wide margin, and its position is decided by width — and
   both of those are things this line was quietly resetting. */
.main { position: relative; z-index: 1; }

.main { min-width: 0; display: flex; flex-direction: column; align-items: center; }
/* Wider gutters than the site's `--pad`, and a wider column. An app screen
   read at arm's length wants more margin than a marketing page read leaning
   in — and the gutter is most of what separates "spacious" from "empty".

   CENTRED NOW, which it could not be before: with a 232px sidebar on the left,
   a centred column would have been off-centre in the window, so it was pinned
   to the start and the space piled up on the right. With the bar across the
   top there is nothing to pin it to and `auto` is simply correct — which is
   also what makes `margin-left: calc(50% - 50vw)` work on the court below. */
#view { padding: clamp(20px, 3vw, 56px) clamp(20px, 3.4vw, 64px) 96px;
        max-width: 1120px; width: 100%; margin-inline: auto; }

/* ══ THE PANEL: A SCREEN, ON PAPER, IN THE ROOM ════════════════════════════
   *"Have this as an environment for each subpage, still having all the
   important functions but always inside this beautiful space."*

   `#view` is one element doing two jobs and neither needs a wrapper: on the map
   it is the parchment's slot and has no appearance of its own, and everywhere
   else it is a sheet of paper laid on the landscape.

   ── WHY IT SCROLLS AND THE ROOM DOES NOT ─────────────────────────────────
   The Library is ten thousand rows. A panel that cannot scroll is a Library you
   can see the first screenful of, and a panel that scrolls the PAGE takes the
   picture with it. So the sheet has its own scroller, sized to the window less
   the bar and its margins, and the room behind it never moves — which is also
   what makes the landscape read as a room rather than as a very tall
   background.

   ── TRANSLUCENT, AND MEASURED RATHER THAN GUESSED ────────────────────────
   88% of the card colour over a 14px blur. Below about 84 the ranges start
   showing through paragraphs and body text stops being body text; above about
   92 it is a white box and there was no point putting it on a landscape. The
   blur is what makes it legible at either end: without it the mountains behind
   read as stains rather than as distance. */
/* ── 744: WHERE THE DESK BEGINS ──────────────────────────────────────────
   This pair of breakpoints (744 up, 743 down) is the line between the desk
   layout — the sheet, the five rooms, the door — and the plain column that
   the phone gate now sends to the App Store instead.

   It was 901 until 2026-09-23, and the owner's screenshot of an iPad-width
   window is why it moved: *"auf dem iPad, wenn es sich im Browser öffnet,
   sollte es genau so sein … nur in Fullscreen, sonst die Sperre."* An iPad
   mini in portrait is 744 across, so 901 meant the one device the gate lets
   through was shown the fallback column rather than the room. 744 is that
   iPad and every iPad above it; Split View (320, 375, 507, 694) falls under
   the gate, which is the "nur in Fullscreen" half of the sentence. */
@media (min-width: 744px) {
  body:not(.in-court):not(.in-intro) #view {
    margin-top: calc(58px + env(safe-area-inset-top));
    margin-bottom: clamp(16px, 2.4vw, 34px);
    max-height: calc(100dvh - 58px - env(safe-area-inset-top) - clamp(32px, 4.8vw, 68px));
    overflow-y: auto;
    /* The sheet keeps its own architecture inside it. Each place hangs
       something off the edge — the reading room's walls, the Progress room's
       lamp — sized for a wide desk, and at 1024 the lamp put four pixels of
       horizontal scrollbar on the screen. Asked for as `clip`; a browser with
       `auto` on the other axis uses `hidden`, which is the same thing here —
       the sideways drag is gone either way and the vertical scroll stays. */
    overflow-x: clip;
    overscroll-behavior: contain;
    /* Narrower than the 1120 column every screen used to have. The point of
       the room is that you can see it, and a sheet that reaches both gutters is
       a sheet with no room around it. */
    /* ── AND A HAND'S WIDTH OF ROOM AROUND IT, AT EVERY WINDOW ───────────
       *"Das Design klebt ja direkt an den Rändern"* (owner, 2026-09-23, with a
       window about 900 across). The sheet was capped at a WIDTH and nothing
       else, so on any window narrower than that cap it ran edge to edge and
       the landscape — the whole argument for a sheet — was only visible on a
       wide desk. The gutter is part of the cap now, so the paper floats at
       1440 and at 900 alike. `--gutter` so the rooms can measure against the
       same number the sheet leaves them. */
    --gutter: clamp(16px, 4.5vw, 56px);
    max-width: min(980px, calc(100% - 2 * var(--gutter)));
    padding: clamp(24px, 2.6vw, 44px) clamp(24px, 3vw, 52px) clamp(28px, 3vw, 52px);
    border-radius: var(--r-panel);
    background: color-mix(in srgb, var(--card) 88%, transparent);
    backdrop-filter: blur(14px) saturate(1.06);
    box-shadow: var(--panel);
  }
  /* A round is the one screen somebody is answering something on, and a
     landscape showing through a card they are grading is the definition of in
     the way — the same argument fly.js makes about where the dragon may cross.
     So the paper goes nearly solid for the duration. */
  /* ── AND NEARLY SOLID PAPER NEEDS NO BLUR UNDER IT (#2431) ──────────────
     *"Irgendwie läuft die Performance noch nicht so rund, leicht ruckelig"*,
     on the finish of a round. The sheet kept the frosted glass of the 88%
     sheets, `blur(14px) saturate(1.06)`, under paper that lets 3% through.
     A backdrop blur is recomputed by the GPU on every frame anything on the
     screen moves (a bird, the bobbing dragon, a scroll), over the whole
     sheet, and at 97% it is the most expensive thing on the screen that
     nobody can see: with and without it, the finish, a round and the
     practice decks differ by at most 2 of 255 in any channel, at noon and at
     night. Measured in Chrome at 1920x1080 with the GPU on, the finish
     screen's GPU time went from about 58 to 13 ms a second standing still,
     and from 209 to 94 while the landscape flies back to the camp; the
     render passes a frame from 4.5 to 2.5. Every place that turns a
     sheet to 97% says `none` beside it; a sheet that lets the landscape
     through keeps its glass. */
  body.in-session #view { background: color-mix(in srgb, var(--card) 97%, transparent); backdrop-filter: none; }
  /* ── AT DUSK THE INK SHEET LIES ON A LIGHT HOUR (#2144, #2150) ───────────
     Since #2101 dusk is ink, and dusk is the one ink hour whose landscape is
     still light: 12% of a pale orange mist through the sheet lifted it from
     #1F1D17 to #2E2B24, and every quiet line on it — `--muted`, the tone
     colours — fell to 4.1:1. Night's picture is inverted and dark, so it does
     not need this. Five more points of card at dusk and nothing else.
     (1,2,2): over the sheet's own rule, under every place that paints its
     room (1,3,1), and never on a round, which is nearly solid already. */
  html[data-theme="dark"] body[data-phase="dusk"]:where(:not(.in-court):not(.in-intro):not(.in-session)) #view {
    background: color-mix(in srgb, var(--card) 95%, transparent);
  }
}

/* The phone loses the address, not the account. 30ch of email is most of a
   375px bar, and the avatar beside it is the same link. */
@media (max-width: 860px) {
  .top { gap: var(--s3); padding-inline: 14px; }
  .acct-who { display: none; }
}

/* ── What a failed read says ───────────────────────────────────────────────
   Fixed rather than in the flow, because the thing that failed is usually the
   thing that would have given this element somewhere to sit. */
.toast {
  position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
  margin: 0; max-width: min(560px, calc(100vw - 32px));
  background: var(--card); color: var(--fg);
   border-radius: var(--r-card);
  padding: 11px 15px;
  z-index: 60;
  box-shadow: var(--panel);
}
@media (max-width: 860px) {
  .toast { bottom: calc(82px + env(safe-area-inset-bottom)); }
}

/* ── 庆 "SENT. +100 XP", CELEBRATED (#2240) ──────────────────────────────────
   The toast in gold and the seal's warmth, springing up from its place with a
   party popper that wiggles twice and the XP as the gold pill the review's
   flights are drawn in, counting up (learn.js `partyToast`, reward.js burst).
   `scale` and `rotate` rather than `transform`, which already centres it. */
.toast.party {
  display: flex; align-items: center; gap: 10px; font-weight: 500;
  padding: 10px 14px 10px 12px; border-radius: var(--r-pill);
  background: linear-gradient(100deg, color-mix(in srgb, var(--t2) 24%, var(--card)),
              color-mix(in srgb, var(--seal) 16%, var(--card)));
  box-shadow: var(--panel), inset 0 0 0 1.5px color-mix(in srgb, var(--t2) 55%, transparent);
  animation: party-in .55s cubic-bezier(.3, 1.5, .5, 1) both;
}
.party-pop { font-size: 22px; line-height: 1; display: inline-block; animation: party-wiggle .9s ease-in-out .15s 2; }
.party-xp {
  padding: 3px 10px; border-radius: var(--r-pill); font-weight: 700; font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--growth-4) var(--jade-keep), var(--fg));
  background: color-mix(in srgb, var(--growth-4) 16%, var(--card));
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--growth-4) 60%, transparent);
}
@keyframes party-in { from { opacity: 0; scale: .5; translate: 0 18px; } }
@keyframes party-wiggle {
  0%, 100% { rotate: 0deg; }
  25% { rotate: -16deg; scale: 1.15; }
  60% { rotate: 12deg; }
}
/* 欢迎 "You're part of Gushi now" (#2344, learn.js `welcomeParty`): a box over
   the page after a purchase lands, under the confetti it throws. */
.welcome-veil {
  position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px;
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  animation: welcome-fade .25s ease-out both;
}
.welcome-card {
  display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;
  width: min(360px, 100%); padding: 28px 24px 24px; border-radius: var(--r-card);
  background: var(--card); color: var(--fg); box-shadow: var(--panel);
  animation: party-in .55s cubic-bezier(.3, 1.5, .5, 1) both;
}
.welcome-card h2 { margin: 0; font-family: 'Fraunces', Georgia, serif; font-size: 22px; }
.welcome-pop { font-size: 52px; line-height: 1; display: inline-block; animation: party-wiggle .9s ease-in-out .2s 2; }
@keyframes welcome-fade { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  .welcome-veil, .welcome-card, .welcome-pop { animation: none; }
}

/* The bits reward.js throws, from a message sent and a place climbed. */
.confetti {
  position: fixed; left: 0; top: 0; z-index: 96; pointer-events: none;
  width: 7px; height: 11px; margin: -5px 0 0 -3px; border-radius: 2px;
}
.confetti.round { width: 8px; height: 8px; border-radius: 50%; }
@media (prefers-reduced-motion: reduce) {
  .toast.party, .party-pop { animation: none; }
}

/* ══ 色 A TINT THAT CARRIES TEXT MIXES INTO PAPER, NOT INTO THE LANDSCAPE ══
   Feedback #2089, #2090, #2092: *"the colors in dark mode often dont work
   properly to portray the visuals and text clearly"*, *"in the bright layout in
   the morning there is too little contrast"*, and a photograph of four grade
   buttons in pale pastel with white labels on them.

   The cause is one pattern, used in three dozen places: `color-mix(… 16%,
   transparent)`. That is not a pale colour, it is a SIXTEEN PER CENT colour —
   whatever is behind shows through. Everywhere else on the web that ground is a
   card; here it is a sheet of paper over a landscape that changes with the hour
   and a theme that changes with the reader. A chip drawn that way is a
   different colour at dawn than at midnight, and its text colour — which does
   NOT change with the hour — is only legible against one of them.

   So every tint that carries TEXT now mixes into `--card` (or `--raised` where
   that is the control's own ground): the same hue, the same weight, but an
   opaque surface whose contrast with `--fg` is the theme's business and
   nothing else's. Hover washes, veils and the sheets themselves keep their
   transparency — they carry no text, and the translucency is the point. */

/* ── Controls ─────────────────────────────────────────────────────────────
   The same shapes the teaching platform uses, because they came from the same
   palette and a learner crossing between the two hosts should not meet a
   different button. What is NOT copied is anything the dashboard's own screens
   need: this host has a form and a sign-out and nothing else yet. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  padding: 9px 16px; border-radius: var(--r-chip); border: 0;
  background: var(--raised); color: var(--fg); font: inherit; font-size: var(--fs-fine);
  font-weight: 500; cursor: pointer; text-decoration: none; white-space: nowrap;
  transition: background var(--fast) var(--ease), filter var(--fast) var(--ease);
}
/* A FILL AND NOT AN OUTLINE, which is the whole of the change in this pass: a
   row of outlined buttons is a form, and a row of filled ones is a set of
   things to press. `color-mix` towards the text colour rather than a second
   token, so hover is one step brighter on either ground with nothing to keep
   in step. */
.btn:hover { background: color-mix(in srgb, var(--fg) 12%, var(--raised)); }
.btn:disabled { opacity: .5; cursor: default; }
.btn:disabled:hover { background: var(--raised); }
.btn-primary { background: var(--seal); color: var(--seal-on); }
.btn-primary:hover { background: var(--seal-press); }
/* The one shape that keeps an outline, because it is not a surface: a capsule
   whose job is to look like a link that can be pressed. */
.btn-lg { padding: 13px 22px; font-size: var(--fs-small); border-radius: var(--r-chip); }

/* ── The rest of the family ───────────────────────────────────────────────
   `GushiButton` is a capsule, and the three variants below are the three
   sentences this host actually needs: a button that is not the point
   (`.btn-ghost`), a button that destroys something (`.btn-danger`), and a
   round one for an icon (`.ibtn`).

   `.btn-danger` is SEAL TEXT on the card rather than a red fill. Red fill is
   the primary button's, once per screen, and a sign-out that shouts louder
   than "Review your cards" has the priorities of this app backwards. */
.btn-ghost { background: none; color: var(--fg-2); }
.btn-ghost:hover { background: var(--raised); color: var(--fg); }
/* The one button that keeps an edge, and it declares it — a destructive action
   should not look like a surface you can rest on. A ring rather than a border,
   so it does not change the button's size when it lights up. */
.btn-danger { color: var(--seal-text); background: none; box-shadow: inset 0 0 0 1px var(--hair); }
.btn-danger:hover { background: none; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 45%, transparent); }

.ibtn {
  display: inline-grid; place-items: center;
  width: 32px; height: 32px; padding: 0;
  border: 0; border-radius: var(--r-pill);
  background: none; color: var(--fg-2); cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.ibtn:hover { background: var(--raised); color: var(--fg); }
.ibtn svg { width: 17px; height: 17px; }

/* ── ONE FOCUS RING, AND IT IS THE SEAL ───────────────────────────────────
   The browser's default ring is blue, which is the one colour this palette
   does not own — docs/brand.md tracks five tones and a seal red, and a system
   blue on top of them reads as an unstyled page. `:focus-visible` and not
   `:focus`, so a mouse press never draws it and a Tab always does.

   A ring rather than a border change: a border that appears on focus moves
   the layout by a pixel, and a row of buttons that shifts as you tab through
   it is the kind of thing nobody reports and everybody notices. */
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--seal);
  outline-offset: 2px;
  border-radius: var(--r-chip);
}

/* ── FORTY-FOUR PIXELS UNDER A THUMB ──────────────────────────────────────
   Apple's own minimum, and the phone is where this host is used one-handed.
   It is a MIN-HEIGHT rather than a padding change so nothing re-flows: a
   button that was 34px tall grows into its own row instead of pushing the
   layout around. */
@media (max-width: 860px) {
  .btn, .ibtn, .opt, .seg button, [data-setup], [data-review] { min-height: 44px; }
  .ibtn { width: 44px; }
}

label { display: block; font-size: var(--fs-fine); font-weight: 500; margin-bottom: 5px; color: var(--fg-2); }
/* ── THE ONE THING THAT KEEPS A BORDER ────────────────────────────────────
   Everything else on this host lost its outline in favour of a fill, and a
   field must not: an outline is how a text input says it can be typed into,
   and a filled rectangle with no edge is a label. It is a QUIET edge —
   inset, on the sunken surface — rather than the hairline box everything else
   used to wear. */
input, textarea {
  width: 100%; padding: 12px 14px; border-radius: var(--r-chip);
  border: 1px solid var(--hair); background: var(--bg-sunken); color: var(--fg);
  font: inherit; font-size: var(--fs-small);
  transition: border-color var(--fast) var(--ease);
}
input:focus, textarea:focus { border-color: color-mix(in srgb, var(--fg) 30%, transparent); }
.code-input { font-family: 'SF Mono', Menlo, Consolas, monospace; letter-spacing: 8px; text-align: center; font-size: 21px; }

.lead { color: var(--fg-2); }
.fine { color: var(--muted); font-size: var(--fs-fine); }
.msg { font-size: var(--fs-fine); color: var(--seal-text); }
/* A refusal rather than a hint. `signin.unknown` means "this page cannot reach
   that account", which is a wall and not a typo, and it should not look like
   the ordinary "try again" grey. */
.msg-stop { font-weight: 500; }

.signin form { margin-top: 18px; }
.signin form > * + * { margin-top: 13px; }
/* ── THE THREE WAYS ON, UNDER THE BUTTON ──────────────────────────────────
   Buttons and not links, because none of them navigates: two switch the form
   and one sends mail. `.linkish` is what makes a button that does not look like
   one — the alternative was three <a href="#"> that go nowhere, which is a
   keyboard user's worst kind of control and a screen reader's outright lie. */
.signin-alt {
  display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4);
  margin: var(--s3) 0 0;
}
.linkish {
  padding: 0; border: 0; background: none; cursor: pointer;
  font: inherit; color: var(--fg-2); text-decoration: underline;
  text-underline-offset: 3px; text-decoration-thickness: 1px;
  transition: color var(--fast) var(--ease);
}
.linkish:hover { color: var(--fg); }
#pw-hint { margin: 6px 0 0; }

/* ── THE EYE BESIDE EVERY PASSWORD (#2446) ────────────────────────────────
   Inside the field's right edge, as the phone draws it: open while the dots
   show, struck through while the letters do. The glyph is a mask so it takes
   the button's ink, muted until pointed at. Edge draws an eye of its own in a
   password field; that one goes, or the field has two. */
.pw-field { position: relative; }
.pw-field input { padding-right: 44px; }
.pw-field input::-ms-reveal { display: none; }
.pw-eye {
  position: absolute; right: 4px; top: 50%; translate: 0 -50%;
  display: grid; place-items: center; width: 36px; height: 36px; padding: 0;
  border: 0; border-radius: var(--r-chip); background: none; color: var(--muted); cursor: pointer;
}
.pw-eye:hover { color: var(--fg); }
.pw-eye::before {
  content: ''; width: 18px; height: 18px; background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E") center / contain no-repeat;
}
.pw-eye[aria-pressed="true"]::before {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.7 5.1A10.4 10.4 0 0 1 12 5c6.4 0 10 7 10 7a17.8 17.8 0 0 1-2.7 3.7'/%3E%3Cpath d='M6.6 6.6C3.9 8.4 2 12 2 12s3.6 7 10 7a10.2 10.2 0 0 0 5.4-1.6'/%3E%3Cpath d='M9.9 9.9a3 3 0 0 0 4.2 4.2'/%3E%3Cpath d='M3 3l18 18'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.7 5.1A10.4 10.4 0 0 1 12 5c6.4 0 10 7 10 7a17.8 17.8 0 0 1-2.7 3.7'/%3E%3Cpath d='M6.6 6.6C3.9 8.4 2 12 2 12s3.6 7 10 7a10.2 10.2 0 0 0 5.4-1.6'/%3E%3Cpath d='M9.9 9.9a3 3 0 0 0 4.2 4.2'/%3E%3Cpath d='M3 3l18 18'/%3E%3C/svg%3E");
}

.signin-note { margin: 20px 0 0; padding-top: 14px; border-top: 1px solid var(--hair); }

/* ── The provider buttons ─────────────────────────────────────────────────
   Empty until learn.js hears back from /auth/v1/settings, and `:empty` is what
   keeps the gap from appearing before there is anything to put in it — a card
   that reserves space for a button that may never come reads as a slow page.

   The mark sits in the button rather than beside the label because Apple's
   guidelines ask for the logo and the words as one target, and because a mark
   outside the button is a second thing to align at every width. */
#providers:not(:empty) { margin-top: 18px; }
#providers > * + * { margin-top: 10px; }

.provider {
  display: flex; align-items: center; justify-content: center; gap: 9px;
  font-weight: 600;
}
.provider-mark { display: flex; align-items: center; line-height: 0; }
/* Apple's mark is monochrome and takes the button's ink; Google's is the
   four-colour G and must not, which is why only one of them is filled by
   `currentColor` up in the markup. */
.provider[data-provider="apple"] .provider-mark { margin-top: -2px; }

/* A rule with the word in it, rather than a bare line: "or" is the whole point
   of the divider, and a line alone leaves a reader deciding whether the form
   below is an alternative or a second step. */
.or {
  display: flex; align-items: center; gap: 12px;
  margin: 16px 0 0; color: var(--muted); font-size: var(--t-label);
}
.or::before, .or::after {
  content: ''; flex: 1 1 0; height: 1px; background: var(--hair);
}
#code-sent { margin: 0; }
.signin-get { margin: 10px 0 0; }
.signin-get a { color: var(--seal-text); text-decoration: none; font-weight: 500; }
.signin-get a:hover { text-decoration: underline; }

/* ── The app, on Profile ──────────────────────────────────────────────────
   A section rather than a banner, and one button rather than two store
   badges: Android is not shipping, and an official badge for a store that
   would 404 is worse than a sentence saying when. */
.theapp { margin-top: var(--s6); padding-top: var(--s5); border-top: 1px solid var(--hair); }
.theapp .fine { margin: var(--s1) 0 0; }
.theapp-row { display: flex; align-items: center; gap: var(--s4); flex-wrap: wrap; margin-top: var(--s3); }
.theapp-mark { display: flex; align-items: center; line-height: 0; margin-top: -2px; }
.theapp-soon { color: var(--muted); font-size: var(--fs-fine); font-style: italic; }
.theapp a { color: var(--seal-text); text-decoration: none; }
.theapp .btn { color: var(--fg); }
.theapp a:hover { text-decoration: underline; }

/* ── The intro ────────────────────────────────────────────────────────────
   The one screen with no nav and nowhere else to be, so it is centred in the
   window rather than laid into the column: a page with one question on it
   should not be a page with one question at the top of it.

   The options are big targets with a label under them — four of each, at the
   size of a thing somebody presses once and never again. */
/* No chrome at all. When this was a sidebar the argument was that every
   destination in it would draw an empty deck, and offering them is inviting
   somebody to leave a question the product cannot work without. The bar that
   replaced it carries no destinations — but it does carry Sign out and a link
   home, and home for an unplaced account is this screen, so the argument is
   unchanged and so is the rule. `learn.js` puts the class on for this route
   only. */
body.in-intro .top { display: none; }
body.in-intro .main { padding-bottom: 0; }
body.in-intro #view { padding: 0; max-width: none; }

.intro {
  min-height: 100dvh; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: var(--s6);
  padding: var(--s6) var(--s5);
}
.intro-step {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--s3);
  width: 100%; max-width: 560px;
}
.intro-step .chop { width: 40px; height: 40px; border-radius: 11px; font-size: 12px; }
.intro-step h1 {
  font-size: clamp(28px, 3vw, 38px); letter-spacing: -.015em; line-height: 1.14;
  margin-top: var(--s2);
}
.intro-step .lead { color: var(--fg-2); line-height: 1.6; margin: 0; max-width: 52ch; }
.intro-step .fine { margin: 0; max-width: 52ch; line-height: 1.6; }
.intro-step .btn { align-self: flex-start; margin-top: var(--s3); }

.intro-opts {
  display: grid; gap: var(--s2); width: 100%;
  grid-template-columns: repeat(auto-fit, minmax(124px, 1fr));
  margin: var(--s3) 0 var(--s2);
}
.intro-opt {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  padding: var(--s4); border-radius: var(--r-card); cursor: pointer;
  background: var(--raised); color: var(--fg); font: inherit; text-align: left;
  transition: background var(--fast) var(--ease), box-shadow var(--fast) var(--ease);
}
.intro-opt:hover { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
.intro-opt.on { box-shadow: inset 0 0 0 1.5px var(--seal); background: var(--seal-soft); }
.intro-opt-n { font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-weight: 600; }
.intro-opt-k { font-size: var(--t-label); color: var(--muted); line-height: 1.35; }
.intro-opt.on .intro-opt-k { color: var(--fg-2); }

/* Three dots, which say how long this is rather than how far along you are —
   the difference matters on a screen somebody is deciding whether to finish. */
/* ── ON THE SAME AXIS AS THE WORDS ────────────────────────────────────────
   The rail is a flex child of a centred column, so it centred itself in the
   VIEWPORT while the step's text sat left-aligned inside its 560px column. On a
   phone the two are the same place and it looked fine; on a laptop the dots
   floated in the middle of the screen with the sentence they belong to four
   hundred pixels to their left, which reads as two unrelated things.

   Same width, same edge, one axis. */
.intro-rail {
  display: flex; gap: 7px;
  width: 100%; max-width: 560px;
}
.intro-rail span {
  width: 6px; height: 6px; border-radius: 50%; background: var(--raised);
  transition: background var(--base) var(--ease), width var(--base) var(--ease);
}
.intro-rail span.on { width: 20px; border-radius: 3px; background: var(--seal); }

/* ── A screen ─────────────────────────────────────────────────────────────
   ── THE TITLE IS THE ONLY LOUD THING, AND IT WAS NOT LOUD ───────────────
   27px for a screen title, six pixels above the body text, with a grey line
   under it and the first block twenty pixels below: every screen opened at the
   same volume as its own contents, so nothing on it had a beginning. The apps
   that read as expensive all do the same thing here — Tolan, Vocabulary, Places
   — a large SET serif, a short rule, and a lot of nothing before the content
   starts. It costs one heading and about forty pixels of air.

   The rule is the seal, six per cent of the width and two pixels of it. It is
   the smallest possible use of the one voltage colour this palette has
   (docs/brand.md: twice per page at most), and it does the job a border around
   everything was failing to do — it says where the page begins.

   `letter-spacing: -.015em` because Fraunces at 36px sets loose by default, and
   a display size that has not been tightened is the single clearest tell of an
   interface nobody drew. */
/* ── A COLUMN HAS A WIDTH, AND IT IS NOT THE WINDOW'S ─────────────────────
   A screen made of sentences and short rows was running the full 1000px of a
   laptop, so a two-word entry sat in a box nine hundred pixels wide with
   nothing in the other eight hundred. Nothing about that is calm: the eye has
   to travel a line that carries no information.

   `--column` is the reading width for the screens that are DOCUMENTS — a
   character, a passage, a grammar point, the profile. The screens that are
   grids or boards keep the full width, because a grid uses it. */
.screen { display: flex; flex-direction: column; gap: var(--s6); }
/* `.run` joins them: the streak is a document about one number, not a grid.
   At the full 1000px the calendar's cells sat 140px apart and a run bar swept
   seven hundred pixels of nothing, which is the opposite of the shape it is
   there to make. */
.screen.char, .screen.reading, .screen.result, .screen.run { max-width: 760px; }
.screen-head { margin-bottom: var(--s1); }
.screen-head h1 {
  font-size: clamp(28px, 2.4vw, 36px); letter-spacing: -.015em; line-height: 1.12;
}
.screen-head h1::after {
  content: ''; display: block; width: 34px; height: 2px; border-radius: 2px;
  margin-top: var(--s4); background: var(--seal);
}
.screen-head .fine { margin: var(--s4) 0 0; max-width: 62ch; line-height: 1.6; }

/* ── The band at the top of Today ─────────────────────────────────────────
   The landing page's ink-wash scene, third host. scene.css owns the drawing —
   how it crops, how the birds beat — and everything here is about the window
   it is seen through, which is this app's business and not the picture's.

   ── IT IS INERT, AND THAT IS A RULE WITH A PRICE ATTACHED ───────────────
   `pointer-events: none`. On 2026-09-05 the same drawing went behind the
   Teaching Platform's dashboard as a positioned, aria-hidden band with no such
   rule, and it painted over the page and swallowed every click in its 400px:
   the range picker did nothing and the invite button did nothing, reported as
   two bugs and caused by one transparent rectangle. Nothing could see it —
   invisible in a screenshot by definition, aria-hidden to every accessibility
   check. assertDecorationInert() in build-web.mjs exists for it.

   ── THE CROP IS ON THE TWO PEOPLE ──────────────────────────────────────
   The platform's band stops ABOVE them on purpose. This one is the opposite
   ask, so the geometry is arranged around them: the figures sit at about 70%
   of the drawing's own height and x is centred, so the <svg> is pinned by its
   own 70% line to 84% of the band's height. `translate` resolves its
   percentages against the element's own box, which is what makes that one
   line rather than a calc against a width nobody knows yet.

   `min-width` on the art rather than `width: 100%`: a 1600-unit stage rendered
   into a 300px sidebar-less phone column is a picture of nothing, so it stays
   at least 760px wide and is cropped by the band instead. */
.today-head { position: relative; isolation: isolate; }
.today-art {
  position: absolute; inset: 0; z-index: -1; pointer-events: none;
  overflow: hidden; border-radius: var(--r-panel);
  background: var(--bg-sunken);
  box-shadow: var(--panel);
}
/* The partial is the bare <svg class="art"> — the .scene / .frame pair that
   sizes it on the landing page belongs to that page, not to the drawing, so
   this host does its own positioning and never loads those two rules. */
.today-art .art {
  position: absolute; left: 50%; top: 84%;
  width: max(100%, 760px); height: auto;
  translate: -50% -70%;
  opacity: .95;
}
/* The words are the reason this screen exists and the picture is not, so the
   ground under them is darkened towards the left where they sit. A scrim and
   not a flat opacity on the art: the ridge and the two figures on the right
   keep their contrast, and the greeting keeps its. */
.today-art::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(100deg,
    var(--bg) 0%,
    color-mix(in srgb, var(--bg) 76%, transparent) 26%,
    color-mix(in srgb, var(--bg) 14%, transparent) 48%,
    transparent 66%);
}
/* The band's height is the words' height plus room for the drawing to be a
   landscape rather than a strip. 150px is where the ridge, the mist and both
   figures are all in frame at the crop above; below about 130 the mountains
   are cropped to a line and it stops reading as a picture. */
.today-said {
  position: relative; padding: 26px 22px 30px; max-width: 30ch;
  min-height: 150px; box-sizing: border-box;
  display: flex; flex-direction: column; justify-content: center;
}
@media (prefers-reduced-motion: no-preference) {
  /* It fades in rather than appearing: this screen is rendered after a read,
     so the band would otherwise snap into place a beat after the text. */
  .today-art { animation: today-art-in .5s ease-out both; }
}
@keyframes today-art-in { from { opacity: 0; } to { opacity: 1; } }
@media (max-width: 560px) {
  /* A 30ch column and a landscape do not fit SIDE BY SIDE, so they stop being
     side by side: the words take the top of the band and the drawing the
     bottom, and the band grows to hold both rather than the two being layered
     on top of each other. The scrim turns with them. */
  .today-said {
    padding: 20px 16px; max-width: none;
    min-height: 208px; justify-content: flex-start;
  }
  .today-art .art { top: 95%; }
  /* ── AND IT DIMS EVENLY RATHER THAN FADING ──────────────────────────────
     The gradient stopped at 38% and the lead line sits at about 45, on the
     brightest part of the mountain — "417 cards are ready for you" was
     near-white on near-white on every phone. Nobody would have reported that;
     a person reading their own phone concludes the app is badly made.

     A gradient needs somewhere for the words to be that the picture is not,
     and at 390px there is nowhere: the text is the full width. So the whole
     band dims by one amount and the words sit on it at full strength, which is
     what every app that puts a headline over an image does. The drawing is
     still legible under it; it is scenery, and scenery survives being
     scenery. */
  .today-art::after { background: color-mix(in srgb, var(--bg) 64%, transparent); }
  .today-said .fine { color: var(--fg-2); }
}

/* `.tiles` and `.tile` were here and are gone. Progress and Profile drew their
   figures as cards, which is the recipe that means "you can press this" on
   every other screen in the app; views.js:41 carries the argument. Both screens
   use the `.stats` figure line now, defined with the rest of Today's furniture
   further down this file.

   One fact is a date rather than a count, so it is a line and not a figure: a
   long date in a figure column sets the width of every column beside it. It
   loses the card for the same reason the tiles did — a start date is not a
   control — and keeps a hairline above it so it still reads as its own row. */
.row-fact {
  display: flex; align-items: baseline; justify-content: space-between; gap: 16px;
  margin: 0; padding: 12px 0 0;
  border-top: 1px solid var(--hair);
}
.row-fact span { color: var(--muted); font-size: var(--fs-fine); }

.note { margin: 0; max-width: 62ch; }
/* ── AN EMPTY STATE IS ONE PARAGRAPH AND ONE BUTTON ───────────────────────
   `.screen` sets 32px between blocks, which is right for a page of panels and
   wrong for four short sentences: they came out scattered down a third of the
   window, each one alone, which makes an ordinary state read as an error page.
   Tighter, narrower, and the one thing to do is the primary button — this is
   the screen whose entire instruction is "open the app once", and until #1948
   there was no way to act on it from here at all. */
.empty { max-width: 52ch; gap: var(--s4); }
.empty h1 { font-size: 30px; }
.empty p { margin: 0; line-height: 1.6; }
.empty .btn { align-self: flex-start; margin-top: var(--s2); }
.empty .note { margin-top: var(--s3); }

.side-you {
  margin: 0; padding-top: 11px; border-top: 1px solid var(--hair);
  color: var(--muted); font-size: var(--fs-fine);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ── Sections inside a screen ─────────────────────────────────────────────── */
/* ── A SECTION LABEL IS AN OVERLINE, NOT A HEADING ────────────────────────
   These were 15px semibold serif — a second heading, one step under the screen
   title, on a screen that has four of them. Four competing headings is a page
   with no hierarchy at all.

   Small caps, tracked, muted, above the block rather than attached to it. It is
   what Halide, Opal and Places all use for exactly this, and it is the one
   ornament in this file: it reads as considered precisely because it is quiet.
   The serif is kept for the things that ARE headings — the screen title, and a
   number somebody is proud of. */
.sub {
  font-family: 'Inter', system-ui, sans-serif;
  font-size: var(--t-over); font-weight: 600; margin: 0 0 var(--s3);
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
}
.panel { display: flex; flex-direction: column; gap: 10px; }
/* A crumb is a way BACK, so it is shaped like the thing it goes back to: an
   arrow and a word, at label size, with room under it. It was a bare grey word
   floating at the top of a screen with no title. */
.crumb { margin: 0 0 var(--s2); font-size: var(--t-label); font-weight: 500; }
.crumb a {
  color: var(--muted); text-decoration: none;
  display: inline-flex; align-items: center; gap: 6px;
}
.crumb a::before { content: '←'; font-size: 13px; }
.crumb a:hover { color: var(--fg); }

/* ── Pinyin, in the tone's own colour ─────────────────────────────────────
   The five are frozen across the app, the site and the story art. A learner
   who has used Gushi reads the colour before the diacritic, which is the whole
   point of fixing them; see docs/brand.md. */
.py { font-weight: 500; }
.t1 { color: var(--t1-ink); } .t2 { color: var(--t2-ink); } .t3 { color: var(--t3-ink); }
.t4 { color: var(--t4-ink); } .t5 { color: var(--t5-ink); }

/* ── 调 THE HUE IS FROZEN; HOW MUCH OF IT A LETTER CARRIES IS NOT (#2144) ────
   *"oft sind irgendwie die buttons oder texte echt überhaupt nicht passend
   zum lesen … bitte checke die gesamte Webapp darauf."* Measured, not by eye
   (tools/learn-contrast.mjs): the five tones and the three quiet accents are
   fill colours, and as TYPE on paper they are 2.2:1 (tone 2) to 3.7:1 (tone
   1) — every pinyin line on the day's ground, which since #2101 is every
   pinyin line before dusk. On ink they pass, until a tinted tile or the dusk
   glass lightens what is under them.

   So a letter in a tone colour is that colour carried toward the page's own
   ink, the rule `.acc-tone*` already made for the camp's chips (#2089): the
   hue stays, the lightness follows the ground. `--tN-keep` is how much of
   the tone survives, the least mixing that clears 4.5:1 on that ground's
   card, page and raised steps; the fill colours themselves are untouched,
   and so is every dot, bar and stroke drawn in them.

   Resolved on every element, not once on :root: the Library and Progress
   are rooms that re-declare `--fg`, and a mix resolved at the root would
   carry paper ink into a timber room. So `--jade-ink` and the rest are
   declared on `*`, and a rule that sets TYPE in an accent reads the `-ink`
   name; a rule that paints a fill reads the colour itself. */
:root {
  --t1-keep: 76%; --t2-keep: 54%; --t3-keep: 63%; --t4-keep: 72%; --t5-keep: 74%;
  --jade-keep: 63%; --amber-keep: 54%; --violet-keep: 71%;
}
:root[data-theme="dark"] {
  --t1-keep: 80%; --t2-keep: 100%; --t3-keep: 100%; --t4-keep: 88%; --t5-keep: 86%;
  --jade-keep: 100%; --amber-keep: 100%; --violet-keep: 89%;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --t1-keep: 80%; --t2-keep: 100%; --t3-keep: 100%; --t4-keep: 88%; --t5-keep: 86%;
    --jade-keep: 100%; --amber-keep: 100%; --violet-keep: 89%;
  }
}
/* A phase tile (the Library's cells, a level's sets) is a 22% wash of its
   own hue, which is a mid tone on either ground: the letters on it need
   more of the page's ink than the plain card does. */
:is(.cell, .wcell, .set-a):is(.ph-ready, .ph-learning, .ph-learned) {
  --t1-keep: 72%; --t2-keep: 52%; --t3-keep: 60%; --t4-keep: 69%; --t5-keep: 70%;
  --jade-keep: 60%; --amber-keep: 52%; --violet-keep: 67%;
}
:root[data-theme="dark"] :is(.cell, .wcell, .set-a):is(.ph-ready, .ph-learning, .ph-learned) {
  --t1-keep: 46%; --t2-keep: 87%; --t3-keep: 62%; --t4-keep: 52%; --t5-keep: 52%;
  --jade-keep: 62%; --amber-keep: 87%; --violet-keep: 53%;
}
/* A glyph drawn LARGE needs 3:1, not 4.5 (WCAG's large text), so it keeps
   more of its hue: the review card's character, the pinyin over a character
   page, the tone buttons, the course's tone marks. On ink every tone clears
   3:1 as it is. */
:is(.r-hanzi, .char-py, .ref-h, .tone-n, .bs-mark, .bs-tonew) {
  --t1-keep: 100%; --t2-keep: 73%; --t3-keep: 85%; --t4-keep: 97%; --t5-keep: 100%;
  --jade-keep: 85%; --amber-keep: 73%; --violet-keep: 96%;
}
:root[data-theme="dark"] :is(.r-hanzi, .char-py, .ref-h, .tone-n, .bs-mark, .bs-tonew) {
  --t1-keep: 100%; --t2-keep: 100%; --t3-keep: 100%; --t4-keep: 100%; --t5-keep: 100%;
  --jade-keep: 100%; --amber-keep: 100%; --violet-keep: 100%;
}
/* The `*-ink` colours, resolved on the element that reads them. */
* {
  --t1-ink: color-mix(in srgb, var(--t1) var(--t1-keep), var(--fg));
  --t2-ink: color-mix(in srgb, var(--t2) var(--t2-keep), var(--fg));
  --t3-ink: color-mix(in srgb, var(--t3) var(--t3-keep), var(--fg));
  --t4-ink: color-mix(in srgb, var(--t4) var(--t4-keep), var(--fg));
  --t5-ink: color-mix(in srgb, var(--t5) var(--t5-keep), var(--fg));
  --jade-ink: color-mix(in srgb, var(--jade) var(--jade-keep), var(--fg));
  --amber-ink: color-mix(in srgb, var(--amber) var(--amber-keep), var(--fg));
  --violet-ink: color-mix(in srgb, var(--violet) var(--violet-keep), var(--fg));
}

/* ── Play ─────────────────────────────────────────────────────────────────
   Small, quiet, and never the loudest thing in a row: it is next to the thing
   it plays, so it does not have to announce itself. */
.play {
   background: var(--raised); color: var(--fg-2);
  width: 22px; height: 22px; border-radius: var(--r-card); padding: 0;
  font-size: 11px; line-height: 1; cursor: pointer; vertical-align: middle;
}
.play:hover { background: var(--seal-soft); color: var(--seal-text); }

/* ── Today ────────────────────────────────────────────────────────────────── */
.lanes { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(124px, 1fr)); }
.lane {
  display: flex; flex-direction: column; gap: 2px;
  padding: 12px 14px; border-radius: 12px;
  background: var(--seal-soft); border: 1px solid color-mix(in srgb, var(--seal) 22%, transparent);
}
.lane-n { font-family: 'Fraunces', Georgia, serif; font-size: 22px; font-weight: 600; color: var(--seal-text); }
.lane-k { font-size: var(--fs-fine); color: var(--fg-2); }

/* ── The primary block on Today ───────────────────────────────────────────
   ONE thing, and it is the scheduler's answer. Today was four deck cards of
   equal weight, which made choosing a deck the first decision of the day for
   somebody who had not asked to make one. This is the door; the four decks are
   a screen behind it.

   It is a card because it is pressable, which is the rule the rest of this
   file now keeps. The glyphs are the material itself — three characters
   actually coming back — and they are the reason this reads as a Chinese app
   rather than as a dashboard with Chinese content in it. */
.primary {
  display: flex; align-items: center; gap: var(--s5);
  padding: var(--s5); border-radius: var(--r-panel); color: var(--fg);
  background: var(--card);
  box-shadow: var(--panel);
}
.primary-lede { display: flex; flex-direction: column; gap: var(--s1); min-width: 0; }
.primary-lede .btn { align-self: flex-start; margin-top: var(--s3); }
.primary-k {
  font-size: var(--t-over); font-weight: 600; letter-spacing: .12em;
  text-transform: uppercase; color: var(--seal-text);
}
.primary-n { font-family: 'Fraunces', Georgia, serif; font-size: var(--t-display); font-weight: 600; line-height: 1.12; }
.primary-m { color: var(--fg-2); font-size: var(--fs-fine); }

/* ── The ring ─────────────────────────────────────────────────────────────
   `GoalRing` in the app. The track is the third surface, which is the whole
   reason a ring reads as a ring on ink: a hairline circle at twelve per cent
   opacity is a smudge, and a filled arc needs something to be full OF.

   Rotated so zero is at the top and the fill runs clockwise — the direction
   every progress ring in every app runs, and the direction nobody notices
   until it is the other way. */
.primary-ring { position: relative; flex: 0 0 auto; width: 104px; height: 104px; }
.ring { width: 104px; height: 104px; transform: rotate(-90deg); display: block; }
.ring circle { fill: none; stroke-width: 9; }
.ring-track { stroke: var(--raised); }
.ring-fill {
  stroke: var(--seal); stroke-linecap: round;
  transition: stroke-dashoffset var(--slow) var(--ease);
}
/* Jade when the queue is clear, which is the app's own reward for it: the
   thing stops being red. */
.ring-done .ring-fill { stroke: var(--jade); }
.ring-n {
  position: absolute; inset: 0; display: grid; place-items: center;
  font-family: 'Fraunces', Georgia, serif; font-size: 27px; font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.primary-clear .ring-n { color: var(--jade-ink); font-size: 30px; }

/* ── The day in one line ──────────────────────────────────────────────────
   Three numbers in a sentence. `<b>` is the count already behind you, which is
   the only one of the three that is an achievement rather than a fact. */
.dayline { margin: 0 0 var(--s4); color: var(--fg-2); font-size: var(--t-body); }
.dayline b { color: var(--fg); font-weight: 600; }

/* ── The deck rows ────────────────────────────────────────────────────────
   `DeckRow` in the app, and feedback #1946 is about this shape being missing:
   a glyph tile in the deck's own weight, the name, what is waiting, a chevron.
   Four rows in ONE card separated by hairlines rather than four cards — four
   bordered boxes stacked is a list pretending each item is a decision. */
.drows-box {
  border-radius: var(--r-panel); overflow: hidden;
  background: var(--card);
  box-shadow: var(--panel);
}
.drow {
  display: flex; align-items: center; gap: var(--s4);
  padding: var(--s4) var(--s5); text-decoration: none; color: var(--fg);
  transition: background var(--fast) var(--ease);
}
.drow + .drow { box-shadow: inset 0 1px 0 var(--hair); }
.drow:hover { background: color-mix(in srgb, var(--fg) 5%, transparent); }
.drow-g {
  flex: 0 0 auto; width: 40px; height: 40px; display: grid; place-items: center;
  border-radius: var(--r-chip); background: var(--raised);
  font-family: 'Noto Serif SC', serif; font-size: 19px; color: var(--fg-2);
}
.drow-t { flex: 1 1 auto; min-width: 0; font-size: var(--t-body); font-weight: 500; }
.drow-r { display: flex; align-items: center; gap: var(--s2); flex: 0 0 auto; }
.drow-none { color: var(--muted); font-size: var(--fs-fine); }
.drow-c { color: var(--muted); font-size: 19px; line-height: 1; flex: 0 0 auto; }
.drow-quiet .drow-g, .drow-quiet .drow-t { color: var(--muted); }

/* Filled means "waiting for you"; outlined means "and these went wrong". The
   second is the one worth doing first and it must not shout louder than the
   first, so it is an outline in the same red rather than a second fill. */
.pill {
  font-size: var(--t-label); font-weight: 500; line-height: 1;
  padding: 5px 9px; border-radius: var(--r-pill);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.pill-due { background: var(--seal-soft); color: var(--seal-text); }
/* New is JADE, not red. Red on this host means "owed" — a due count, an Again
   button, the seal — and a character you have never seen is not a debt. It is
   the one thing on Today that is purely an offer. */
.pill-new {
  background: color-mix(in srgb, var(--jade) 20%, transparent);
  color: color-mix(in srgb, var(--jade) 63%, var(--fg));
}
/* `.pill-bad` was the got-wrong outline and came off the overviews with it.
   The recipe stays because the review setup screen still marks a deck that has
   some, and a second definition of "an outlined pill in the accent" is a second
   thing to keep in step. */
.pill-bad {
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 40%, transparent);
  color: var(--seal-text);
}

/* ── Half a year, glanced at ──────────────────────────────────────────────
   The same 26-week grid Progress draws, with a quarter of the furniture: no
   month labels, no weekday column, nothing to press. Those belong on the
   screen somebody came to read; here it is the shape of a habit.

   `grid-auto-flow: column` with seven rows is what makes a flat list of days
   fall into weeks — the same column-per-week arrangement, without having to
   hand the markup a second time. */
.mini-cells {
  display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 1fr);
  gap: 3px; overflow: hidden;
}
.mini .sub { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s3); }
.mini-more { color: var(--seal-text); text-decoration: none; font-size: var(--t-label); font-weight: 500; }
.mini-more:hover { text-decoration: underline; }

/* ── Two columns, once there is room for two ──────────────────────────────
   Below 1100 this is one column and the aside falls under the decks, which is
   the phone order and the right one: what to DO, then how it has been going.
   The record of the day is the reason somebody opens this at night, and the
   thing to do is the reason they open it at nine in the morning. */
.today-grid { display: grid; gap: var(--s5); align-items: start; }
.today-col { display: flex; flex-direction: column; gap: var(--s5); min-width: 0; }
@media (min-width: 1100px) {
  .today-grid { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); }
}
@media (max-width: 560px) {
  /* The ring and the words stop fitting side by side well before the card
     does: at 380px the button wraps under a 104px ring and leaves a hole. */
  .primary { flex-direction: column; align-items: flex-start; gap: var(--s4); }
}
/* The glyphs sit between the words and the button and are the first thing to
   go when the row wraps: they are the most beautiful thing here and the least
   load-bearing. */
.primary-glyphs {
  font-family: 'Noto Serif SC', serif; font-size: 28px; color: var(--fg-2);
  letter-spacing: 6px; white-space: nowrap; margin-left: auto;
}
.primary-clear { border-style: dashed; box-shadow: none; }

/* ── A row of options ─────────────────────────────────────────────────────
   Used by Today for the three other ways to study and by Review for the four
   decks. One shape for both because they are the same act: choosing between
   named things that carry a number.

   `button` and `a` both, so the same class works for a link that goes
   somewhere and a control that selects something. Everything below is written
   against the class and not the element. */
.opts { display: flex; flex-direction: column; gap: 6px; }
.opt {
  display: flex; align-items: center; gap: 14px; justify-content: space-between;
  width: 100%; text-align: left;
  padding: 12px 15px; border-radius: var(--r-card); text-decoration: none;
  background: var(--raised);  color: var(--fg);
  font: inherit; font-size: var(--fs-small); cursor: pointer;
  transition: border-color .12s ease-out;
}
.opt:hover { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
.opt:disabled { cursor: default; opacity: .5; }
.opt:disabled:hover { background: var(--raised); }
.opt-l { display: flex; align-items: center; gap: 11px; min-width: 0; }
.opt-g {
  font-family: 'Noto Serif SC', serif; font-size: 16px; color: var(--muted);
  width: 19px; text-align: center; flex: none;
}
.opt-r { color: var(--muted); font-size: var(--fs-fine); flex: none; font-variant-numeric: tabular-nums; }
.opt.on { box-shadow: inset 0 0 0 1.5px var(--seal); background: var(--seal-soft); }
.opt.on .opt-r { color: var(--seal-text); font-weight: 500; }
.opt.on .opt-g { color: var(--seal-text); }
/* The dot is the only part of a chosen row that is filled, so the row reads as
   selected without the seal having to fill the whole card. */
.tick {
  width: 15px; height: 15px; border-radius: 99px; flex: none;
  border: 1.5px solid var(--hair);
}
.opt.on .tick { border-color: var(--seal); background: var(--seal); box-shadow: inset 0 0 0 3px var(--card); }

/* ── How many ─────────────────────────────────────────────────────────────
   Pills rather than a number field: the answer is one of four, and a field
   asks somebody to invent a number the queue may not even hold.

   ── IT WAS CALLED `.seg`, AND SO WAS SOMETHING ELSE ────────────────────
   Training's segment blocks twenty lines below claim the same class name with
   `flex-direction: column`, and being second they won it — so this row of
   pills rendered as a COLUMN of full-width pills on every screen that showed
   it. Nothing errored and nothing in the markup was wrong; two rules two
   hundred lines apart simply meant different things by one word. It is the
   clearest argument in this file for the scale at the top of it: a name that
   describes a shape (`.limits`) cannot be claimed twice by accident, and a
   name that abbreviates a word can. */
.limits { display: flex; gap: var(--s2); flex-wrap: wrap; }
.limits button {
  font: inherit; font-size: var(--fs-fine); font-weight: 500; cursor: pointer;
  padding: 9px 18px; border-radius: var(--r-pill);
  background: var(--raised); border: 0; color: var(--fg-2);
  transition: color var(--fast) var(--ease), background var(--fast) var(--ease);
}
.limits button:hover { color: var(--fg); }
.limits button[aria-pressed="true"] { background: var(--fg); color: var(--bg); }

.setup-go { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
.setup-go .fine { color: var(--muted); margin: 0; }
/* The other two kinds of round, as links rather than as a third control: they
   are exits from this screen, not more of its form. */
.setup-else { display: flex; gap: 18px; flex-wrap: wrap; }
.setup-else a { color: var(--seal-text); text-decoration: none; font-size: var(--fs-fine); }
.setup-else a:hover { text-decoration: underline; }

/* ── Training ─────────────────────────────────────────────────────────────
   Five segments and a heading each. The glyph in the heading is the app's own
   word for the segment — 读 听 法 写 说 — which is the one place on this host
   where a section is named in Chinese as well as in the reader's language, and
   it earns it: these five are the syllabus's own divisions rather than ours. */
/* ── SEVEN SEGMENTS, AND THEIR HEADINGS ARE OVERLINES ─────────────────────
   Seven serif subheadings down one screen is seven things claiming to be the
   title. They are labels for the group underneath, so they are set like labels:
   small caps, tracked, muted — the same overline `.sub` uses, with the Chinese
   character kept at reading size beside them, because that character IS the
   name and shrinking it to 10px would make it a smudge. */
.seg { display: flex; flex-direction: column; gap: var(--s3); }
.seg-h {
  display: flex; align-items: center; gap: var(--s2);
  font-family: 'Inter', system-ui, sans-serif;
  font-size: var(--t-over); font-weight: 600; margin: 0;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
}
.seg-g {
  font-family: 'Noto Serif SC', serif; font-size: 14px; color: var(--fg-2);
  letter-spacing: 0; text-transform: none;
}
.seg-rows { display: flex; flex-direction: column; gap: 5px; }
.seg-row {
  display: flex; align-items: center; gap: 14px; justify-content: space-between;
  padding: 11px 15px; border-radius: var(--r-card); text-decoration: none;
  background: var(--raised);  color: var(--fg);
  font-size: var(--fs-small);
}
a.seg-row:hover { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
/* Not built yet, and it keeps its place in the list rather than being hidden:
   the row is there to say the segment exists. Dashed rather than faded to 40%,
   because a row somebody cannot read is not telling them anything. */
.seg-soon {
  border-style: dashed; background: transparent; color: var(--muted);
}
.seg-soon .opt-g { color: var(--hair); }
.seg-soon .opt-r { font-style: italic; }

.profile-email { margin: 2px 0 0; }

/* ── The board rows ───────────────────────────────────────────────────────
   Feedback #1946: the list of ways to practise read as a settings screen, and
   it did because every row was a label and a grey number. `DeckRow` in the app
   is a glyph TILE, a name, a line saying what the deck is, and what is waiting.
   The line under the name is what turns a list of links into a list of things a
   learner can tell apart at a glance.

   The tile carries the deck's accent, which is where the palette earns its
   keep: eight decks of the same weight in a column is a wall, and eight tinted
   tiles is a page somebody scans. The tint is 14% of the colour behind the
   glyph at full strength, so it reads as a family rather than as eight
   different products. */
/* ── ONE PANEL PER SEGMENT, NOT ONE TILE PER ROW ──────────────────────────
   Eighteen separate filled tiles down a page is eighteen objects, and a page of
   eighteen objects is busy however soft each one is. Grouped, they are seven
   objects — which is what the seven segments ARE — and the rows inside are
   separated by a hairline and by nothing else. That is Opal's settings list,
   Brick's mode list, and every grouped table since iOS 7; it is quieter than
   tiles and it also says the right thing, because the rows in a segment belong
   together.

   The hairline survives here for exactly this: a divider INSIDE a surface,
   which is the one job it kept when the borders around surfaces went. */
.brows {
  display: flex; flex-direction: column;
  background: var(--card); border-radius: var(--r-panel); overflow: hidden;
  box-shadow: var(--panel);
}
.brow {
  display: flex; align-items: center; gap: var(--s4);
  padding: var(--s4) var(--s5);
  text-decoration: none; color: var(--fg);
  transition: background var(--fast) var(--ease);
}
.brow + .brow { box-shadow: inset 0 1px 0 var(--hair); }
a.brow:hover { background: color-mix(in srgb, var(--fg) 5%, transparent); }
.brow-g {
  flex: 0 0 auto; width: 40px; height: 40px; display: grid; place-items: center;
  border-radius: var(--r-chip);
  font-family: 'Noto Serif SC', serif; font-size: 19px;
}
.brow-mid { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1 1 auto; }
.brow-t {
  display: flex; align-items: baseline; gap: var(--s2);
  font-size: var(--t-body); font-weight: 500;
}
/* The level a deck serves, as a chip rather than a sentence. It is on the four
   rows that HAVE a level and on none of the others, which is the app's rule:
   the tone drill is not at HSK 3, it is at every level at once. */
.brow-tag {
  font-size: var(--t-over); font-weight: 600; letter-spacing: .04em;
  padding: 2px 6px; border-radius: var(--r-pill);
  background: var(--raised); color: var(--muted);
}
.brow-s {
  font-size: var(--t-label); color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.brow-r { display: flex; align-items: center; gap: var(--s2); flex: 0 0 auto; }
.brow-note { color: var(--muted); font-size: var(--fs-fine); }
/* Not built yet, and the row says WHEN. Dashed rather than faded: a faded row
   reads as disabled — something you are not allowed — and this is something
   that is not here yet, which is a different sentence. */
.brow-off .brow-t { color: var(--muted); }
.brow-off .brow-g { opacity: .55; }
/* ── A STATE, NOT AN APOLOGY ──────────────────────────────────────────────
   Italic grey text repeated down eight rows reads as eight small regrets. It
   is a STATE — this deck lives somewhere else — so it is shaped like every
   other state on this host: a quiet chip on the third surface, at label size,
   upright. */
.brow-soon {
  font-size: var(--t-over); font-weight: 600; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted);
  background: var(--raised); border-radius: var(--r-pill);
  padding: 5px 11px; white-space: nowrap;
}

/* The eight accents, off the palette rather than out of eight new hues.
   `DeckAccent` in DeckBoard.swift names exactly these. */
/* ── A TONE COLOUR IS AN IDENTITY; ITS INK IS NOT ────────────────────────
   The five tone colours are the brand's and do not move (docs/brand.md). On a
   pale chip they were also the TEXT, at about 2.5:1 — *"the colors … dont work
   properly to portray the visuals and text clearly"* (#2089, in the camp,
   where every deck wears one). So the chip keeps the hue and the glyph on it
   is that hue carried toward the page's own ink: same colour to the eye, a
   readable one to read. `--fg` is dark on paper and light on ink, so one
   declaration serves both. */
.acc-tone1 { background: color-mix(in srgb, var(--t1) 18%, var(--card)); color: color-mix(in srgb, var(--t1) 68%, var(--fg)); }
.acc-tone2 { background: color-mix(in srgb, var(--t2) 18%, var(--card)); color: color-mix(in srgb, var(--t2) 68%, var(--fg)); }
.acc-tone3 { background: color-mix(in srgb, var(--t3) 18%, var(--card)); color: color-mix(in srgb, var(--t3) 68%, var(--fg)); }
.acc-tone4 { background: color-mix(in srgb, var(--t4) 18%, var(--card)); color: color-mix(in srgb, var(--t4) 68%, var(--fg)); }
.acc-jade  { background: color-mix(in srgb, var(--jade) 18%, var(--card)); color: color-mix(in srgb, var(--jade) 68%, var(--fg)); }
.acc-seal  { background: var(--seal-soft); color: var(--seal-text); }
.acc-block { background: var(--raised); color: var(--fg-2); }
.acc-sound { background: color-mix(in srgb, var(--t5) 18%, var(--card)); color: color-mix(in srgb, var(--t5) 68%, var(--fg)); }

@media (max-width: 560px) {
  /* The subtitle is the first thing to go: at 360px the name and the due pill
     are the row, and a truncated sentence under them is noise. */
  .brow-s { display: none; }
  .brow { padding: var(--s3); gap: var(--s2); }
}

/* ══ 营 THE TRAINING CAMP ══════════════════════════════════════════════════
   *"wie so wenn man seitlich von oben auf so ein Camp schaut … mehr in diesen
   Berg rein und das an den Berghang hinten."*

   views.js has the plan, the seven structures and the argument. This is what
   holds them on the hillside, and it is the court's own mechanism one scale
   smaller: a stage with a fixed aspect, buildings placed at a point in the
   drawing's coordinates, and ONE unit of that drawing expressed in pixels so
   every size on the screen is a number of units rather than a number of px.

   The court's note has the reason and it is the whole reason this is not a
   flex row of cards: a building that is the same number of pixels whatever the
   window does is a building on the glass. Here the camp grows with its sheet,
   so it is a place that got nearer rather than a picture that got bigger.

   `container-type: size` needs a definite height, which `aspect-ratio` over a
   definite width gives it. 1200x640 is nearly 2:1 — much wider than the
   court's 16:10, because a camp spreads sideways and a taller box would have
   been sky. It was 5:3 until the levels were re-cut into three rows of three,
   two and one; that arrangement is squatter and the box came with it. */
/* ── 牌楼 THE GATE, WHICH IS ALSO THE TITLE ────────────────────────────────
   *"ich meine mehr so ein coolen Bogen oben drüber als Titel der Seite wo dann
   Trainingscamp draufsteht, gerne auch schön breit."*

   One wide span across the top of the sheet with the camp's name on a board at
   its crown. The arch is a drawing and the board is HTML, because the title has
   to be real text in three languages and an SVG `text` element is neither
   translatable nor selectable.

   IT REPLACED THE LINTEL RATHER THAN JOINING IT. A lintel is a piece of
   architecture the shell hangs over whatever screen is open, knowing nothing
   about it; this carries the page's own words and has to be laid out with them,
   so `courtAt` gives Training no lintel and the screen draws this instead. */
/* ── ONE WIDTH FOR THE GATE AND THE CAMP, AND THE HEIGHT SETS IT ────────
   *"mache niemals scrollbare Sachen auf der Webapp … alles immer auf einen
   Screen."*

   A camp is a PLACE, and half a place with the other half below the fold is
   not one. So the gate and the camp share one width, and that width is
   whichever is smaller: the sheet, or the width at which BOTH still fit in the
   height left over after the top bar, the sheet's padding and its margins. The
   arch is 1000x180 and the stage 1200x640, and the camp comes up under the
   arch by five per cent of the width — so together they are 0.663 of their
   width in height, and 1.50 is the other side of that sum. The 162px is
   everything around them: the top bar, the sheet's padding and its margins.

   BOTH NUMBERS ARE MEASURED AND NOT REASONED. The first version of this said
   210 and 1.37, which was fifty-three pixels short and put a scrollbar on the
   one screen that had just promised not to have one. The arithmetic below the
   camp — a panel's padding, a heading's margin, the rounding on an
   `aspect-ratio` — does not add up the way a person adds it up, so the way to
   set these is to print `scrollHeight` against `clientHeight` at four window
   heights and read them off.

   On a tall window the sheet wins and none of this happens. On a short one the
   whole place gets smaller — gate, buildings, signs and chips together,
   because every one of them is sized in the drawing's own units.

   This is the one screen on the host that can keep the rule. A Library of ten
   thousand rows cannot shrink to fit and never could; the rule lands where it
   can be kept. */
.camp-screen { --camp-w: min(100%, calc((100dvh - 162px) * 1.50)); }
.camp-gate {
  position: relative;
  width: var(--camp-w);
  margin: 0 auto;
  aspect-ratio: 1000 / 180;
  /* Page furniture, not landscape: it takes the THEME's ink rather than the
     drawing's literal, because the board hanging on it is paper and has to
     stay paper when the page goes dark. The rest of the camp is the other way
     round and the two notes are deliberately next to each other. */
  color: color-mix(in srgb, var(--fg) 82%, transparent);
}
.camp-gate-art { position: absolute; inset: 0; }
.camp-gate-art svg { width: 100%; height: 100%; display: block; }
/* 匾 The board, hanging under the middle roof on the great beam, which is
   where a pailou carries its name (#2189). It covers the beam on purpose —
   that is how a name board is mounted — and it is opaque, because a sign you
   can see the sky through is not a sign. 24.5% is the foot of the middle
   roof's bracket band (y 44 of 180 in the drawing), measured and not guessed;
   the lead and the ladder's line move down with it, below the two hanging
   posts' buds. */
.camp-board {
  position: absolute;
  left: 50%; top: 24.5%;
  translate: -50% 0;
  padding: var(--s3) var(--s6);
  border-radius: var(--r-card);
  background: var(--card);
  box-shadow: var(--panel), 0 0 0 1px color-mix(in srgb, var(--fg) 8%, transparent);
}
.camp-board h1 { margin: 0; white-space: nowrap; }
/* No seal-red rule under it. `h1::after` is the mark that says "this is the
   top of a screen", and the arch is already saying that at forty times the
   size. */
.camp-board h1::after { display: none; }
/* The lead, hanging in the opening under the board. Centred, quiet, and on no
   paper of its own — there is an arch over it, which is ground enough. */
/* ── THE LEAD GETS PAPER, BECAUSE IT WAS UNREADABLE ─────────────────────
   The owner sent a crop of this line with the text SELECTED, which is the only
   way he could make it legible enough to point at: *"DAS KANN man gar nicht
   lesen."*

   It hung in the arch's opening at `--fg-2` over whatever the landscape
   happened to be doing behind it — a wash, at low contrast, in a soft colour.
   Two attempts at a darker grey had already been made and both were the same
   mistake: the problem is not the ink, it is that there is nothing under it.

   Everything else on this screen solved that the same way and this now joins
   them — the structure names, the class's header, the chips' labels are all
   type on a small pill of the card colour over a blur. It is four millimetres
   of paper and it is the difference between a sentence and a rumour. */
.camp-lead,
.camp-next {
  position: absolute;
  left: 50%;
  translate: -50% 0;
  margin: 0;
  max-width: 62ch;
  text-align: center;
  white-space: nowrap;
  /* *"Der Satz ist ein bisschen hässlich umrahmt"* (#2069). The padding was
     in camp units — three of them, three pixels — around type that is NOT
     in camp units, so the paper hugged the letters like a highlighter
     stroke. It is measured off the type now, with a hairline edge and a
     soft drop the way the pills on the plaza have one. */
  padding: .5em 1.4em;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--card) 90%, transparent);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg) 7%, transparent),
              0 8px 18px -12px rgba(26, 24, 19, .35);
  color: var(--fg-2);
  line-height: 1.3;
}
.camp-lead { top: 61%; }
/* The ladder's warning, under the lead and in the same opening. See views.js
   for why it is in here rather than under the camp: a line that appears for
   some accounts and not others may not be allowed to change the height the
   camp was sized against. */
.camp-next { top: 79%; color: var(--seal-text); font-weight: 600; }

.camp {
  position: relative;
  /* The gate's width, because they are one object — see `.camp-screen`. */
  width: var(--camp-w);
  /* ── AND IT COMES UP UNDER THE ARCH ────────────────────────────────────
     Five per cent of the width, negative. The gate's opening is empty paper
     below the crown and the stage's own top band is empty sky above the tower,
     so the two were stacking one nothing on top of another — sixty pixels of
     height on a window that had none to spare. Overlapped, the arch frames the
     camp instead of sitting above it, which is what the photograph it came
     from actually looks like. */
  margin: calc(var(--camp-w) * -0.05) auto 0;
  aspect-ratio: 1200 / 640;
  /* ── AND IT CLIPS, WHICH THE WALKERS MADE NECESSARY ────────────────────
     `.camp-paths` is a box 1200 by 640 CSS PIXELS that is then scaled down by
     `--cu` — that is what lets a road's path data be written in the drawing's
     own units. A transform changes what is painted and not what is laid out,
     so the layer contributes its full unscaled height to the scrollable
     overflow of the camp, and the camp handed it to the sheet: 765px of
     content in 483px of panel, which is a scrollbar on the one screen that
     promised not to have one.

     `clip` rather than `hidden`: it creates no scroll container, so there is
     nothing for a stray wheel event to move by a pixel. Everything meant to be
     seen is inside the stage by construction — learn-units checks the seven
     structures and their signs against its bounds — and the chips' labels open
     UPWARD into the camp's own sky. */
  overflow: clip;
  container-type: size;
  --cu: calc(100cqw / 1200);
  /* ── THE SAME RATIO AS A NUMBER, AND IT IS NOT A DUPLICATE ─────────────
     `--cu` is a LENGTH: one unit of the drawing, in pixels, which is what
     every size in the camp multiplies. `transform: scale()` wants a NUMBER,
     and `scale(0.76px)` is invalid — so the declaration was dropped in
     silence and the walkers' layer was never scaled at all. It stayed 1200 by
     640 real pixels inside a camp 917 wide, which is why figures turned up
     standing in the dark off every road and one of them at the very bottom of
     the sheet: they were walking the right roads in the wrong world.

     Dividing two lengths gives a number, which is what this is. */
  --cscale: calc(100cqw / 1200px);
  /* The drawing's own ink, not the interface's — the same literal and the same
     argument as `.mark-art`: `--fg` follows the THEME and this follows the
     PICTURE, and two kinds of ink in one painting is what that mistake looked
     like on the map. `--art-filter` is inherited from the court, so the camp
     takes the hour along with the hillside it stands on.

     70 is the GROUND's share and each structure sets its own — see `ink` in
     views.js, which is the camp's three bands of distance. */
  color: color-mix(in srgb, #1C1E18 70%, transparent);
  filter: var(--art-filter, none);
  transition: filter 4s linear;
}

/* The three terraces. Stretched with `preserveAspectRatio: none` because they
   are ellipses in the camp's own coordinates and they are MEANT to be the
   shape the stage is, not circles that have been squashed — which is the trap
   the League's podium fell into four times before it became one soft band. */
.camp-ground { position: absolute; inset: 0; pointer-events: none; color: #1C1E18; }
.camp-ground svg { width: 100%; height: 100%; display: block; }

/* Two things standing in the camp that are not doors to anything: the statue,
   and a pair watching from the plaza. A place where every object is a control
   is not a place, it is a toolbar with a landscape behind it.

   Positioned from `CAMP_PROPS` rather than from here, and anchored by the FOOT
   like everything else that stands on something — *"Personen dürfen nur auf
   den drei Ebenen sein oder auf den Wegen"*, and a coordinate written into a
   stylesheet is a coordinate no test can hold to that. views.js has the table
   and learn-units has the check. */
.camp-prop {
  position: absolute;
  left: var(--x); top: var(--y);
  transform: translate(-50%, -100%);
  pointer-events: none;
  color: var(--camp-fig);
}
.camp-prop svg { width: calc(var(--w, 46) * var(--cu)); height: auto; display: block; }

/* ── 灯 THE LANTERNS, AND THE LIGHT IN THE BUILDINGS ─────────────────────
   *"in the night add some nice warm light in the buildings as well as some
   laterns which are turned on."*

   Anchored by the foot like everything else that stands on something, and
   drawn at every hour: by day a stone post with a hole in it, which is what a
   lantern looks like at noon. */
.camp-lantern {
  position: absolute;
  left: var(--x); top: var(--y);
  transform: translate(-50%, -100%);
  pointer-events: none;
  color: var(--camp-fig);
}
.camp-lantern svg { width: calc(var(--w, 24) * var(--cu)); height: auto; display: block; }

/* ── 行 THE LEARNERS, AND THEY STAY ON THE ROADS ─────────────────────────
   *"füge vielleicht noch einen zweiten Pfad hinzu und dann dürfen die nur auf
   den Wegen entlang laufen."*

   `offset-path` is the whole of it: the figure is put ON a road and
   `offset-distance` moves it along, so it cannot leave one. views.js hands the
   same path string to the ground and to this, so a road that moves in the
   drawing takes its walkers with it.

   ── THE LAYER IS 1200 BY 640 AND SCALED, WHICH IS THE TRICK ──────────────
   `offset-path` takes CSS pixels and the camp is measured in the drawing's own
   units. So the walkers live in a box that is literally the stage's size, and
   the box is scaled by `--cu`. Inside it a road's own path data works
   unchanged and a figure is 26px because here 26px IS 26 units. The
   alternative was rewriting both roads for every window width, in JavaScript,
   on every render.

   `offset-rotate: 0deg` because a person does not lean into a bend, and
   `offset-anchor` at the foot because that is where everything in this camp is
   anchored. */
.camp-paths {
  position: absolute; left: 0; top: 0;
  width: 1200px; height: 640px;
  transform: scale(var(--cscale));
  transform-origin: 0 0;
  pointer-events: none;
}
/* ── AND SINCE #2328 THE ROAD IS WALKED IN ADVANCE ──────────────────────
   `offset-path` put the figure on the road and `offset-distance` moved it,
   and no browser runs either off the main thread, so every frame of every
   walker was a restyle and a repaint of the blurred roads under it. views.js
   (`CAMP_TRIP_CSS`) now samples each trip along the same road, at every per
   cent of its cycle, into a keyframe list of `transform`s, which the
   compositor runs alone. The figure is still put on the road and cannot
   leave it: every point it passes through was read off that road's curve. */
.camp-walk {
  position: absolute; left: 0; top: 0;
  width: 26px;
  opacity: 0;
  color: var(--camp-fig);
  animation: var(--walk, none) var(--wd, 40s) linear infinite;
  animation-delay: var(--wdelay, 0s);
}
.camp-walk svg {
  width: 100%; height: auto; display: block;
  animation: camp-bob .62s ease-in-out infinite;
}
/* Facing the way they are going. The figure is drawn walking to the right, so
   a trip that runs backwards down its road gets the DRAWING mirrored — and
   only the drawing. Flipping the element would flip the coordinate system
   `offset-path` is measured in, which sends it off the side of the camp. */
.camp-walk-back svg { scale: -1 1; }
/* The gait. Six tenths of a second a step, which at this size is somebody in a
   hurry — the owner's word was *eilen*. */
@keyframes camp-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-2.5px); }
}
/* Somebody who has asked for less motion gets a camp with nobody crossing it.
   Not a frozen walker: one stopped halfway along a road is a person standing
   in a strange place, which is a worse answer than an empty yard. */
@media (prefers-reduced-motion: reduce) {
  .camp-paths { display: none; }
}

/* ── AND AT NIGHT THE CAMP IS LIT ────────────────────────────────────────
   `--camp-fire` fills the lanterns' fire boxes and `--court-hole` is the paper
   showing through every doorway and window in the camp. By day the first is
   transparent and the second is the drawing's own paper; after nine at night
   both go warm, and three lanterns and seven lit doorways is the difference
   between a camp at night and a camp with the lights off.

   `body[data-phase]` and not `.court[data-phase]`: a custom property is only
   visible to its element's descendants, and the camp is the court's NEPHEW.
   learn.js's `setPhase` has the whole of that.

   The glow is a drop-shadow on the fire box rather than a blurred div behind
   it, so it moves with the drawing at every size and costs no element. */
/* ── AND THE MASSES GO DARKER, NOT PALER ────────────────────────────────
   The map's buildings INVERT at night along with the ranges, because at that
   distance they are marks in a wash and the wash goes pale. The camp is not at
   that distance: you are standing in it, and a building you are standing next
   to after dark is a dark shape with a lit window in it. So the ink goes up by
   fourteen points instead of flipping — enough that a roofline still reads
   against a night hillside, and the lit doorways stay the brightest thing on
   the screen, which is the whole of what the owner asked for. */
body[data-phase="night"] .camp-art {
  color: color-mix(in srgb, var(--camp-night-tint)
                   calc((var(--ink, 70) + var(--camp-night-lift)) * 1%), transparent);
}
/* ── AND SO DOES THE GROUND, AND EVERYBODY STANDING ON IT ───────────────
   *"wenn es dunkel ist sehe ich den Weg nicht mehr und die Leute laufen."*

   The terraces, the two roads, the walkers, the lanterns' stone and the two
   props are all drawn in the drawing's ink — dark on a light hillside, which
   is right until the hillside is not light. On the ink ground after dark they
   were dark on near-black: the owner's screenshot has two roads in it and
   neither of them can be seen.

   Same flip as the buildings, and it reaches the same token. On paper
   `--camp-night-tint` is still the ink and nothing changes; on ink it is the
   drawing's paper, so the roads come up as moonlight and the people walking on
   them come up with them. */
body[data-phase="night"] .camp-ground { color: var(--camp-night-tint); }
body[data-phase="night"] {
  --camp-fig: color-mix(in srgb, var(--camp-night-tint) 74%, transparent);
}
body[data-phase="night"] .camp-art svg,
body[data-phase="night"] .camp-lantern svg { --court-hole: #F2C27A; }
body[data-phase="night"] .camp-lantern { --camp-fire: #F7CE86; }
body[data-phase="night"] .camp-fire {
  filter: drop-shadow(0 0 calc(7 * var(--cu)) rgba(247, 206, 134, .85))
          drop-shadow(0 0 calc(18 * var(--cu)) rgba(247, 206, 134, .45));
}
/* The buildings get the same warmth at the door, at a quarter of the spread —
   a lantern is a light and a doorway is a room with one in it. */
body[data-phase="night"] .camp-art svg > g:last-of-type {
  filter: drop-shadow(0 0 calc(5 * var(--cu)) rgba(242, 194, 122, .6));
}
body[data-phase="dusk"] .camp-art svg { --court-hole: #F6E2C0; }
body[data-phase="dusk"] .camp-lantern { --camp-fire: #F3CE95; }
/* ── THE PAPER LANTERNS (#2189) ─────────────────────────────────────────
   They are red paper now rather than a stone fire box, so their colour is
   `--camp-lamp`: the seal's red by day, and lit from inside after dark —
   warmer and lighter, with the fire box's old glow (`.camp-fire` above). */
.camp-lantern { --camp-lamp: #C8452E; }
body[data-phase="dusk"] .camp-lantern { --camp-lamp: #E0673F; }
body[data-phase="night"] .camp-lantern { --camp-lamp: #F59A5E; }

/* ── A STRUCTURE: ANCHORED BY ITS FOOT, NAMED UNDER IT ────────────────────
   `translate(-50%, -100%)` puts the bottom edge of the drawing on `--y`, which
   is the same anchor `.mark` uses and for the same reason: a `y` read off a
   plan is a place on the ground, and centring the whole stack on it would put
   the building half a building above where it stands.

   NOT A LINK ANY MORE, which is the second pass in one declaration:
   *"ich will nicht mehr diese Türen haben."* It is a `div`, it takes no
   pointer, and the only things on this screen that go anywhere are the chips. */
.camp-b {
  position: absolute;
  left: var(--x); top: var(--y);
  transform: translate(-50%, -100%);
  display: block;
}
.camp-art {
  display: block; position: relative;
  /* Distance, as paleness. `--ink` is a NUMBER rather than a percentage
     because `color-mix` wants a percentage and a custom property holding `52%`
     cannot be multiplied — so the plan carries 52 and this is the one place
     that knows it means per cent. */
  color: color-mix(in srgb, #1C1E18 calc(var(--ink, 70) * 1%), transparent);
}
.camp-art svg {
  --court-hole: #F4F1E9;
  width: calc(var(--w, 110) * var(--cu)); height: auto; display: block;
}
/* Contact, which is what decides whether a thing is IN a picture or ON it.
   Same soft ellipse as the map's buildings, inside the art so the hour reaches
   it. */
.camp-art::before {
  content: '';
  position: absolute; left: 50%; bottom: 1%;
  width: 92%; height: 13%;
  translate: -50% 0;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(28, 30, 24, .3), rgba(28, 30, 24, 0) 76%);
}

/* Everything below the ground line, out of the structure's flow so the anchor
   above stays the drawing's foot. `max-content`, or an absolutely positioned
   child resolves its width against the building and a four-chip row folds into
   a column — which is exactly how the League's faces once ran down a cliff. */
/* ── EVERYTHING UNDER A STRUCTURE IS IN THE DRAWING'S UNITS ──────────────
   *"alles immer auf einen Screen"*, and this is the half of that rule nobody
   sees coming. The camp shrinks on a short window — see `.camp` — and for one
   version the drawing shrank while the signs did not: 28px chips under
   buildings half their old size, so the bottom row's roofs came up through the
   middle row's labels. A layout that is correct at one height and wrong at
   another is not a layout.

   So the sign is expressed in the SAME units as the plan: 42 for a chip, 9
   between them, 16 for the label's type, and 19 of gap under the name — which
   is where the two counts hang, above their chip and clear of the label. learn-units checks the seven
   structures against exactly those numbers, which is only meaningful because
   nothing here is a pixel. A small window gets a small camp, whole. */
.camp-below {
  position: absolute; top: 100%; left: 50%;
  translate: -50% calc(10 * var(--cu));
  display: grid; justify-items: center; gap: calc(19 * var(--cu));
  width: max-content;
}
/* The name of the place, and only the name. It used to carry the segment's
   glyph as well; the chips under it are all glyphs now, and a glyph over a row
   of glyphs is the same character twice. A sheet behind it because the
   alternative is text on a hillside — and `--art-filter` must NOT reach here,
   which is why the pill is outside `.camp-art`: at night the drawing inverts
   and a label that inverted with it would be unreadable paper on paper. */
.camp-tag {
  padding: calc(3 * var(--cu)) calc(13 * var(--cu));
  border-radius: var(--r-pill);
  white-space: nowrap;
  font-size: calc(16 * var(--cu)); font-weight: 600;
  letter-spacing: .06em; text-transform: uppercase;
  color: var(--fg-2);
  background: color-mix(in srgb, var(--card) 86%, transparent);
  backdrop-filter: blur(8px);
}

/* ── THE DECKS, ON THE BUILDING THAT HOUSES THEM ──────────────────────────
   *"zeige immer an den Häusern direkt mit Symbolen die unterschiedlichen
   Decks, sodass man da direkt draufklickt, alles minimalistisch."*

   A chip is the deck's own glyph on its own accent — `DeckAccent` in
   DeckBoard.swift names exactly these eight and `.acc-*` already maps them, so
   the camp and the app agree about what colour reading is without a second
   table. Eighteen chips of one colour would be a rash; eighteen in eight
   accents is a camp somebody can scan.

   28px and not smaller. That is the floor for a pointer target that is also a
   glyph somebody has to recognise, and it is what fixes the camp's scale: the
   buildings were sized around the chips rather than the other way round. */
:root {
  /* ── THE CAMP AT NIGHT, WHICH DEPENDS ON THE GROUND UNDER IT ───────────
     On paper the page stays light after dark and a building you are standing
     next to is a DARK shape with a lit window in it, so the ink goes up by
     fourteen points. On ink the page is already near-black and the same
     fourteen points made the camp disappear into it — the owner's screenshot
     at ten at night is seven lit doorways and almost no buildings.

     So the tint itself flips: on ink the masses are drawn in the drawing's
     PAPER at a little over half, which is moonlight on a roof. Depth survives
     either way, because the per-structure `--ink` still scales it and the far
     things still come out fainter. */
  --camp-night-tint: #1C1E18;
  --camp-night-lift: 14;
  /* Everything in the camp that is a person or a lamp-post rather than a
     building: the statue, the pair, the six who walk, the three lanterns. One
     token so the night can repaint all of them at once. */
  --camp-fig: color-mix(in srgb, #1C1E18 72%, transparent);
  --dchip-face: color-mix(in srgb, currentColor 20%, var(--card));
  --dchip-face-on: color-mix(in srgb, currentColor 32%, var(--card));
  --dchip-ring: 0 0 0 1px color-mix(in srgb, currentColor 34%, transparent),
                0 calc(2 * var(--cu)) calc(5 * var(--cu)) rgba(26, 24, 19, .18);
  --dchip-ring-on: 0 0 0 calc(2 * var(--cu)) color-mix(in srgb, currentColor 62%, transparent),
                   0 calc(4 * var(--cu)) calc(10 * var(--cu)) rgba(26, 24, 19, .24);
  --dchip-glow: none;
  --dchip-glow-on: none;
}

.camp-decks { display: flex; gap: calc(9 * var(--cu)); }
.dchip {
  position: relative;
  flex: 0 0 auto;
  width: calc(42 * var(--cu)); height: calc(42 * var(--cu));
  text-decoration: none;
  transition: transform var(--fast) var(--ease);
}
/* ── A CHIP HAS TO LOOK LIKE A THING YOU PRESS ────────────────────────────
   *"mache die Button sichtbarer zum Klicken für die einzelnen Decks."*

   `.acc-*` paints eighteen per cent of the deck's accent over TRANSPARENT,
   which is right in a list on paper and nearly invisible here: on ink the page
   is dark, eighteen per cent of a colour over it is the page, and what was
   left was a coloured glyph floating on a hillside. The owner's screenshot is
   of exactly that.

   Three changes and they are one idea — give the glyph a tile with an edge:

     THE ACCENT IS RE-MIXED OVER `--card` instead of over nothing, so the tile
     is a surface in both themes rather than a tint that vanishes in one.
     `currentColor` here IS the accent, because `.acc-*` sets `color` too.

     A RING IN THE ACCENT, at a third. That is the line that says "edge", and
     it is the deck's own colour rather than a grey so eighteen buttons still
     read as a family.

     AND A SHADOW UNDER IT. Contact is what the eye uses to decide whether a
     thing is ON a surface or IN it — the same argument `.mark-art::before`
     makes about buildings, one scale down.

   42 units rather than 38, because a target somebody has to hit with a glyph
   in it wants the extra four. */
/* ── ON PAPER A TILE, ON INK A GLOWING CHARACTER ─────────────────────────
   *"im dunklen Modus sollen die chinesisch Charakter einfach leuchten aber man
   braucht nicht so einen hässlichen Hintergrund pro Deck wie gerade."*

   Both grounds want the chip to read as a thing you press and they want
   opposite means. On paper a coloured tile with an edge is the button: the
   page is bright, so a shape is what separates the glyph from the hillside. On
   ink the page is already dark, a twenty-per-cent tile behind a character is a
   grey box with a character in it, and eighteen of them in a row is what the
   owner called ugly — correctly. What a dark ground offers instead is LIGHT:
   the accent at full strength with a halo of itself, which is the only thing
   on the screen that glows and therefore unmistakably the thing to press.

   Six tokens rather than two blocks of component rules, because this file's
   own note says a ground is a token swap and nothing in it may redefine a
   component. `currentColor` inside them is the DECK's accent — a custom
   property is substituted where it is used, not where it is written, so these
   resolve against the chip and not against the root. */
.dchip-g {
  display: grid; place-items: center;
  width: 100%; height: 100%;
  border-radius: var(--r-chip);
  font-family: 'Noto Serif SC', serif; font-size: calc(23 * var(--cu)); line-height: 1;
  background-color: var(--dchip-face);
  box-shadow: var(--dchip-ring);
  filter: var(--dchip-glow);
  transition: box-shadow var(--fast) var(--ease), background-color var(--fast) var(--ease),
              filter var(--fast) var(--ease);
}
.dchip:hover, .dchip:focus-visible { transform: translateY(-2px); }
.dchip:hover .dchip-g, .dchip:focus-visible .dchip-g {
  background-color: var(--dchip-face-on);
  box-shadow: var(--dchip-ring-on);
  filter: var(--dchip-glow-on);
}
/* A deck that is not on this host yet. None are, today — all eighteen are
   built — but `where: null` is still a state board.js can express, and a chip
   that is not a link must not look like one. */
.dchip-off { opacity: .5; }

/* What is waiting, on the face of the chip. Only four of the eighteen decks
   have a queue at all, so at most four of these are ever on screen — which is
   what keeps *"was heute gemacht werden muss"* answerable at a glance instead
   of turning the camp into a report. */
/* ── TWO NUMBERS, AND BOTH LIGHTER THAN THE ONE THEY REPLACE ─────────────
   *"mache das due ein bisschen leichter und teile es auf new und review
   auf."* It was a single solid seal-red pill with white type on it, carrying
   new and due added together — the loudest thing on a quiet hillside, saying
   the least useful of the three numbers it could have said.

   Reviews go top RIGHT in the seal's soft tint and what is new goes top LEFT
   in plain raised grey. Which is which is a product statement rather than a
   palette one: a review is something you OWE, which is what the seal means
   everywhere else here, and a new card is something WAITING, which owes the
   eye nothing.

   The ring is the page's own ground, not a colour: it is what lifts a small
   number off the tile under it without adding a third hue to a chip that
   already has one. */
/* ── BOTH ON TOP, IN A ROW, AND NO RING ─────────────────────────────────
   *"delete the black big border and put new and review on top."*

   THE BORDER WAS A RING IN `--bg`, put there to lift a small number off the
   tile under it. On paper it is a hairline of cream and invisible; on ink
   `--bg` is near-black, so each badge came with two and a half units of solid
   black around it — the owner's crop is of exactly that. What it was solving
   is solved better by not overlapping the tile at all.

   AND THEY WERE DIAGONAL, one at each of two opposite corners, because "347"
   is a thirty-unit pill on a forty-two-unit chip and the two met in the middle
   when both sat at the top. In a ROW above the tile they cannot meet: the row
   lays them out side by side and grows outward from the chip's centre, so the
   only thing either can run into is its own neighbour's row — and at these
   sizes that needs a four-digit count on two adjacent decks. */
.dchip-counts {
  position: absolute;
  bottom: calc(100% + 2 * var(--cu));
  left: 50%;
  translate: -50% 0;
  display: flex; gap: calc(3 * var(--cu));
  white-space: nowrap;
}
.dchip-new,
.dchip-rev {
  min-width: calc(16 * var(--cu)); height: calc(16 * var(--cu));
  padding: 0 calc(5 * var(--cu));
  display: grid; place-items: center;
  border-radius: var(--r-pill);
  font-family: 'Inter', system-ui, -apple-system, sans-serif;
  font-size: calc(11 * var(--cu)); font-weight: 600;
  line-height: 1;
}
/* ── THE TWO QUIET ACCENTS NEED A LOUDER VOICE HERE ──────────────────────
   `acc-block` is "a quiet chip on the third surface" and `acc-sound` is the
   fifth tone — both written for a ROW, where a tinted tile carries them and
   the type only has to sit on it. With the tile gone on ink there is nothing
   left but `--fg-2` on a dim hillside, and 考 and 读 went from quiet to
   absent. The camp's own chips take a step up; the rows everywhere else are
   untouched. */
.dchip-g.acc-block { color: var(--fg); }

/* The review count on a pill of card, not of `--seal-soft`: that is 12% seal
   over nothing, and at dusk nothing is a pale hillside — "347" measured
   1.5:1 (#2144). Tinted card is the same pink pill, opaque. */
.dchip-rev { color: var(--seal-text); background: color-mix(in srgb, var(--seal) 14%, var(--card)); }
.dchip-new { color: var(--fg-2); background: var(--raised); }

/* ── AND THE WORDS, ONE GESTURE AWAY ──────────────────────────────────────
   The map's rule is *"always show directly the name, not only the character"*,
   and this is the one place on this host that does not — because the ask is
   minimalism and because eighteen permanent captions on a hillside is the
   table of contents this screen just stopped being.

   So the label is in the DOM, always, and shows on hover and on FOCUS: the
   name, the line saying what the deck is, the level it serves and what is
   waiting. A keyboard gets it, a screen reader reads it, and nothing a learner
   used to be told has stopped being true — it is one gesture away rather than
   gone.

   Above the chip rather than below it. The chips sit at the bottom of a
   structure and three of the seven structures are on the front terrace, where
   below is the edge of the sheet. */
/* ── QUIETER, AND OFF TO ONE SIDE ─────────────────────────────────────────
   *"mache das das Fenster schön dezenter … leicht transparent und nach links
   oben oder rechts oben neben Gebäude oder Artefakt geöffnet wird."*

   Two changes and they are the same change. It was an opaque card centred over
   the chip, which put it squarely on top of the building it belongs to — so
   the one thing it was explaining disappeared behind it. Now it opens from a
   CORNER: up and outward, away from the middle of the camp, so the structure
   stays in view beside its own label.

   And it is glass rather than paper. 84% of the card colour over a blur, which
   is the bargain the training panes and the class's pills already make on this
   host: enough ground under the type to read it, enough transparency that the
   hillside is still there behind it. */
.dchip-b {
  position: absolute;
  bottom: calc(100% + 24 * var(--cu)); left: 0;
  width: max-content; max-width: 218px;
  display: grid; gap: 1px;
  padding: var(--s2) var(--s3);
  border-radius: var(--r-chip);
  background: color-mix(in srgb, var(--card) 72%, transparent);
  backdrop-filter: blur(14px) saturate(1.06);
  box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent);
  text-align: left;
  /* ── OPACITY AND NOT `visibility: hidden` ───────────────────────────────
     This is the chip's accessible NAME. The glyph is `aria-hidden` — it is a
     drawing — so if the label were hidden from the accessibility tree the
     whole camp would be eighteen unlabelled links. `visibility: hidden` does
     exactly that and `opacity: 0` does not, which is the entire reason for
     the pair of declarations below rather than the obvious one. */
  opacity: 0;
  transition: opacity var(--fast) var(--ease);
  pointer-events: none;
  z-index: 3;
}
.dchip:hover .dchip-b,
.dchip:focus-visible .dchip-b { opacity: 1; }
/* The structures are siblings and the later ones paint over the earlier ones,
   so a label opening on Reading would go behind the tower. One rule, and it
   has to be on the STRUCTURE because that is the positioned ancestor. */
.camp-b:hover, .camp-b:focus-within { z-index: 4; }
/* Which corner it hangs from, and the threshold is 800 rather than the middle
   of the stage. Only the two structures at the far right open leftward; the
   other five open to the right, because every one of them has open ground on
   that side and the two that do not would otherwise throw their label across
   a neighbour. A camp three structures wide cannot promise a label that
   overlaps nothing — what it can promise is that the thing you are pointing at
   stays visible, which is what the ask was about. */
.camp-b[data-open="left"] .dchip-b { left: auto; right: 0; }
.dchip-t {
  display: flex; align-items: baseline; gap: var(--s2);
  font-family: 'Inter', system-ui, -apple-system, sans-serif; font-size: var(--t-label); font-weight: 600;
  color: var(--fg);
}
.dchip-tag {
  font-size: var(--t-over); font-weight: 600; letter-spacing: .04em;
  padding: 1px 5px; border-radius: var(--r-pill);
  background: var(--raised); color: var(--muted);
}
/* It WRAPS, and the label is narrow on purpose. At 218px a line like
   "trace one and it joins the writing rotation" does not fit, and `nowrap`
   sent it out through the right-hand edge — a label that is quiet because you
   cannot read the end of it is not quiet, it is broken. The phone puts the
   nowrap back, because there it is a row and gets an ellipsis instead. */
.dchip-s, .dchip-note {
  font-family: 'Inter', system-ui, -apple-system, sans-serif; font-size: var(--fs-fine); font-weight: 400;
  color: var(--muted); line-height: 1.35;
}
.dchip-note { color: var(--seal-text); font-weight: 600; }

@media (prefers-reduced-motion: reduce) {
  .dchip:hover, .dchip:focus-visible { transform: none; }
}

/* ── ON A PHONE THE CAMP IS A LIST, AND THAT IS THE COURT'S OWN ANSWER ────
   The map does the same thing at the same width and its note has the reason:
   the buildings stand in a landscape on a desktop and must not have paper
   behind them there, but here there is no landscape to belong to — at 390px
   the stage would be 234px tall with seven structures and eighteen chips
   fighting over it. The drawing goes rather than shrinks. A 20px tower is not
   a tower.

   AND THE LABEL COMES OUT OF HIDING. There is no hover on a phone, so the
   thing that was one gesture away has to be on the page: the same markup lays
   out as a row of eighteen decks grouped under their seven places, with the
   name, the line under it and what is waiting — which is the board this screen
   had before the camp, arrived at from the other direction. */
@media (max-width: 743px) {
  .camp {
    aspect-ratio: auto;
    container-type: normal;
    display: grid; gap: var(--s5);
    filter: none;
    color: inherit;
  }
  /* ── AND THE ARCH GOES WITH THE DRAWING ────────────────────────────────
     At 390px the gate is eighty pixels tall and the name board is wider than
     the span it hangs on — a festival arch needs the width a festival has. So
     the phone gets what it had before there was one: a heading, its seal-red
     rule and a line under it. The words are identical; only the architecture
     around them is gone. */
  .camp-gate { aspect-ratio: auto; width: auto; margin-bottom: var(--s5); }
  .camp-gate-art { display: none; }
  .camp-board {
    position: static; translate: none;
    padding: 0; background: none; box-shadow: none;
  }
  .camp-board h1 { white-space: normal; }
  .camp-board h1::after { display: block; }
  .camp-lead,
  .camp-next {
    position: static; translate: none;
    margin-top: var(--s4);
    text-align: left; white-space: normal;
    padding: 0; background: none; backdrop-filter: none;
  }
  .camp-ground, .camp-prop,
  .camp-lantern, .camp-paths { display: none; }
  .camp-b {
    position: relative;
    left: auto; top: auto;
    transform: none;
  }
  .camp-art { display: none; }
  .camp-below {
    position: static;
    translate: none;
    justify-items: stretch;
    gap: var(--s3);
    width: auto;
  }
  .camp-tag {
    justify-self: start;
    padding: 0; background: none; backdrop-filter: none;
    color: var(--muted);
  }
  /* The chips become rows: the glyph keeps its tile and the label that used to
     hover lies down beside it. */
  .camp-decks {
    flex-direction: column; gap: 0;
    background: var(--card); border-radius: var(--r-panel); overflow: hidden;
    box-shadow: var(--panel);
  }
  /* And on a phone the units go away again, because there is no drawing left
     to be in scale with — `--cu` is meaningless once `container-type` is off. */
  .camp-decks { gap: 0; }
  .dchip {
    width: auto; height: auto;
    display: flex; align-items: center; gap: var(--s4);
    padding: var(--s4);
  }
  .dchip + .dchip { box-shadow: inset 0 1px 0 var(--hair); }
  .dchip:hover, .dchip:focus-visible { transform: none; }
  .dchip:hover .dchip-g, .dchip:focus-visible .dchip-g { box-shadow: none; }
  .camp .dchip-lit { display: none; }
  .dchip-g {
    flex: 0 0 auto; width: 38px; height: 38px;
    font-size: 19px;
    box-shadow: none;
  }
  .camp-tag { padding: 0; font-size: var(--t-body); }
  .camp-below { translate: none; gap: var(--s3); }
  /* The badges go: the label under the name already says what is waiting, in
     words, and the same number twice in one row is a row that does not trust
     itself. */
  .dchip-counts { display: none; }
  .dchip-b {
    position: static;
    opacity: 1;
    width: auto; max-width: none;
    padding: 0; background: none; box-shadow: none; backdrop-filter: none;
    flex: 1 1 auto; min-width: 0;
  }
  .dchip-s { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}

/* ══ 卷 THE READING SHELF IS A RACK OF SCROLLS ════════════════════════════
   docs/learn-places.md, the third of five. The note above keeps every word of
   its argument — a shelf of texts is a LIST and not a grid of cards, because
   ninety-eight near-identical tiles truncate the one thing that tells them
   apart. What changes is what a row is a picture of.

   A text in this world is a scroll, and a scroll in a rack lies down with its
   rod showing. So each row keeps its title, its level, its word count and its
   question count, and grows a rod: a dark round bar down its left edge,
   overhanging top and bottom, which is the whole silhouette of a rolled scroll
   seen from the side. The paper beside it takes the laid texture the
   parchment on Today already uses.

   The panel around them goes. Eight scrolls in one box is a box; eight scrolls
   with air between them is a rack. */
.shelf {
  background: none; box-shadow: none; overflow: visible;
  gap: var(--s2);
  /* A scroll is a scroll and not a ribbon. The sheet on this screen is 1240
     wide because it shares Training's, and a row that long stops reading as an
     object you could pick up. */
  max-width: 780px;
}
/* The hairline between rows was the divider INSIDE a panel, and there is no
   panel any more. Every scroll gets the same shadow — writing `none` here
   instead would win on specificity and leave two of every three lying flat. */
.shelf li + li a { box-shadow: var(--panel); }
.shelf a {
  position: relative;
  margin-left: 7px;
  padding-left: var(--s5);
  border-radius: 3px var(--r-card) var(--r-card) 3px;
  background:
    repeating-linear-gradient(to bottom,
      rgba(120, 104, 78, .03) 0 1px, transparent 1px 3px),
    var(--card);
  box-shadow: var(--panel);
}
/* 轴 The rod. It overhangs by three at each end, which is what a rod does and
   is the only reason this reads as a scroll rather than as a row with a stripe
   on it. The inset shadow on its right is the paper curling onto it. */
.shelf a::before {
  content: '';
  position: absolute; left: -7px; top: -3px; bottom: -3px;
  width: 13px;
  border-radius: 7px;
  background: color-mix(in srgb, var(--fg) 52%, transparent);
  box-shadow: inset -2px 0 3px -2px rgba(0, 0, 0, .45);
}

/* ══ 园 PROGRESS IS A GARDEN SEEN THROUGH THE GATE ════════════════════════
   The arch is `LINTEL.stats`, the moon gate the map draws this place with,
   reused at the width of the block it stands over. views.js has the argument.

   It is the ARCH and not a frame: the drawing is a band of wall with a wide
   shallow curve taken out of its underside, seen from INSIDE, which is where
   you are once you have come through one. */
.gate-arch {
  display: block; height: 22px;
  margin-bottom: calc(-1 * var(--s1));
  color: color-mix(in srgb, var(--fg) 40%, transparent);
}
.gate-arch svg { width: 100%; height: 100%; display: block; }
/* 石 And the weeks are stones in the path rather than cells in a grid. Same
   twenty-six columns, same seven rows, same five tones — a stone is a rounded
   square with the light on its top edge, which is the one change that turns a
   chart into ground somebody walked on. */
.heat-cell {
  border-radius: 34%;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .28);
}

/* ══ 案 THE PROFILE IS A DESK ═════════════════════════════════════════════
   The only screen with no building on the map, because it is not somewhere you
   go — it is your own desk. docs/learn-places.md says so and also says this is
   the lowest priority of the five, because Profile is the one screen where
   being a plain form is not a failure. So it gets the smallest treatment that
   is still a design: the head is a desk surface, and what is on it is paper.

   No wood grain and no leather. The desk is the page's own third surface with
   an inset edge, which is what a surface you put things ON looks like in this
   palette — and the sheet on it is the card colour, which is what paper is
   everywhere else here. */
.profile-head {
  padding: var(--s5);
  border-radius: var(--r-panel);
  background: var(--raised);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 7%, transparent);
}
/* ── ONE SURFACE, NOT A CARD ON A DESK ON A SHEET ──────────────────────────
   *"Sieht irgendwie wieder nicht so clean aus"* (#2077). The name sat on a
   white card on a beige desk on the paper sheet — three boxes deep for five
   lines — with a Save button hanging beside the field whether or not
   anything had changed. The desk stays and the card on it goes; Save shows
   while the name is being edited, and Enter does the same. */
.profile-head > div {
  flex: 1 1 auto;
  display: flex; flex-direction: column; gap: 4px;
}
.profile-head .fine, .profile-head .pf-chips { margin: 0; }
.profile-head .pf-chips { margin: 4px 0; }
.profile-head .name-form { flex-wrap: nowrap; }
.profile-head .name-form input { flex: 0 1 12ch; }
.name-form button { flex: none; opacity: 0; transition: opacity var(--fast) var(--ease); }
.name-form:focus-within button, .name-form button:focus-visible { opacity: 1; }

/* ── The tracing canvas ───────────────────────────────────────────────────
   A square, because the glyph's coordinate system is one and because keeping it
   square is what lets a pointer be converted with a single scale instead of an
   inverted matrix. `touch-action: none` is what stops a phone treating a stroke
   as a scroll — without it the first downward stroke scrolls the page and never
   reaches the canvas at all. */
.tr-canvas {
  width: min(100%, 360px); aspect-ratio: 1; display: block;
  touch-action: none; cursor: crosshair; border-radius: 14px;
}
.tr-ground { fill: var(--bg-sunken); }
/* The copybook guides: a centre cross and the diagonals, at the weight of a
   fold in paper rather than of a line somebody is meant to read. */
.tr-grid { stroke: var(--hair); stroke-width: 2; fill: none; }
.tr-done { fill: var(--fg); }
/* The stroke being asked for, at the weight of a shadow. Strokes after it are
   not drawn at all: a character with every stroke on screen has already
   answered the question of what comes next. */
.tr-now { fill: color-mix(in srgb, var(--fg) 18%, transparent); }
.tr-dot { fill: var(--seal); opacity: .85; }
.tr-ink {
  fill: none; stroke: var(--seal); stroke-width: 34;
  stroke-linecap: round; stroke-linejoin: round;
}
.tr-head { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.tr-hanzi { font-family: 'Noto Serif SC', serif; font-size: 30px; line-height: 1.1; }
/* The stroke order before the first trace (feedback #1888): the same diagram
   the character page plays, at the canvas's own size, so the shape the learner
   watched is the shape they are then asked to trace. */
.tr-preview { width: min(100%, 360px); aspect-ratio: 1; display: grid; place-items: center;
  background: var(--bg-sunken); border-radius: 14px; }
.tr-preview .strokes { width: 86%; height: 86%; }
/* ── THE BOX BESIDE THE CHARACTER, AND NO SCROLL (#2457) ──────────────────
   Paul: *"wieso sollte man scrollen hier bei der aufgabe? total nervig bitte
   akurater anordnen sodass man hier ohne scrollen entspannt seine striche
   machen kann."* Stacked, the card was the character, its reading, the box
   and the buttons one under the other, and the box ran off a laptop's
   window. On a desk the box takes the left of the card at a size the
   window's HEIGHT allows, and the character, the verdict and the buttons
   stand to its right. A phone keeps the one column. */
@media (min-width: 820px) {
  .tr-screen .drill-card {
    max-width: 900px;
    display: grid; grid-template-columns: auto minmax(220px, 1fr);
    grid-template-areas: "canvas head" "canvas after";
    align-items: center; justify-items: center;
    column-gap: var(--s6); row-gap: var(--s4);
    padding: clamp(18px, 2.4vw, 30px);
  }
  .tr-screen .tr-head { grid-area: head; align-self: end; }
  .tr-screen .drill-after { grid-area: after; align-self: start; text-align: center; }
  .tr-screen :is(.tr-canvas, .tr-preview) {
    grid-area: canvas;
    /* The sheet's top, the head and the card's padding come to about 350px
       of a laptop's window; the box takes what is left, and no more than it
       needs on a tall screen. */
    width: clamp(240px, calc(100dvh - 350px), 480px);
  }
  .tr-screen .screen-head .fine { margin-top: var(--s2); max-width: none; }
}

/* The scope, as a quiet select beside the tally — the app's LevelScope menu
   in the top corner. */
.tr-scope select {
  font: inherit; font-size: var(--fs-fine); color: var(--fg-2);
  background: var(--raised); border: 1px solid var(--hair); border-radius: 99px;
  padding: 4px 10px;
}

/* ── The pinyin table ─────────────────────────────────────────────────────
   A syllable and its five tones, with the gaps left standing. The five slots
   are a fixed row rather than a flow, so the fourth tone is under the fourth
   tone all the way down the column and a missing one reads as a hole rather
   than as a shorter row. */
.py-filter { display: flex; flex-wrap: wrap; gap: 5px; }
.chip {
  padding: 5px 11px; border-radius: var(--r-card); text-decoration: none;
  background: var(--raised);  color: var(--fg-2);
  font-size: var(--fs-fine); font-weight: 500;
}
.chip:hover { color: var(--fg); }
.chip.on { background: var(--seal); border-color: var(--seal); color: var(--seal-on); }

.py-group { display: flex; flex-direction: column; gap: 6px; }
.py-rows { display: flex; flex-direction: column; gap: 4px; }
.py-row {
  display: grid; grid-template-columns: 84px 1fr; gap: 14px; align-items: center;
  padding: 6px 12px; border-radius: 10px;
}
.py-row:hover { background: var(--bg-sunken); }
.py-s { font-weight: 500; font-variant-numeric: tabular-nums; }
.py-slots { display: grid; grid-template-columns: repeat(5, 58px); gap: 6px; }
.py-slot {
  display: flex; align-items: baseline; justify-content: center; gap: 5px;
  padding: 6px 4px; border-radius: var(--r-chip); cursor: pointer; font: inherit;
  background: var(--raised);
}
.py-slot:hover { border-color: currentColor; }
.py-h { font-family: 'Noto Serif SC', serif; font-size: 17px; color: var(--fg); }
.py-t { font-size: 12px; letter-spacing: 0.01em; }
.py-slot.t1 .py-t { color: var(--t1-ink); }
.py-slot.t2 .py-t { color: var(--t2-ink); }
.py-slot.t3 .py-t { color: var(--t3-ink); }
.py-slot.t4 .py-t { color: var(--t4-ink); }
.py-slot.t5 .py-t { color: var(--t5-ink); }
/* An empty slot is drawn, not skipped. A tone missing from a syllable may be
   one Mandarin does not use or one no character in this deck carries, and the
   table cannot tell those apart — so it shows the shape of the gap rather than
   closing it. */
.py-none { border-style: dashed; opacity: .45; cursor: default; }

/* ── The two ways into the Library that are not the grid ──────────────────
   A collection you keep and a table you look things up in. They were stacked,
   which put four rows of furniture — two headings and two full-width banners,
   about 190px on a laptop — between the level tabs and the first character, to
   offer two links. Side by side it is one row, and both headings survive
   because they name genuinely different kinds of thing (views.js has the
   argument). Below 640px a card with a right-aligned note is too narrow for a
   half, so they stack again. */
.lib-more { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px; align-items: start; }
.lib-more-col { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
@media (max-width: 640px) { .lib-more { grid-template-columns: 1fr; } }
.list-id { font-family: 'Noto Serif SC', serif; font-size: 17px; }
/* A row in My list that has nowhere to go. Still a row, because the item is in
   the list and the list should say so. */
.opt-flat { cursor: default; background: transparent; }
.opt-flat:hover { border-color: var(--hair); }

@media (max-width: 620px) {
  /* The five slots do not fit beside a syllable on a phone, so the syllable
     takes its own line above them. */
  .py-row { grid-template-columns: 1fr; gap: 5px; }
  .py-slots { grid-template-columns: repeat(5, 1fr); }
}

/* ── The handwriting box ──────────────────────────────────────────────────
   The same square as the tracer, with the guide taken away: tracing teaches the
   shape and writing asks whether you remember it. Ink only, until the reveal
   puts the real character under it to compare against. */
.wb { width: min(100%, 300px); aspect-ratio: 1; display: block;
      touch-action: none; cursor: crosshair; border-radius: 14px; margin: 4px auto 0; }
.wb-ink { fill: none; stroke: var(--fg); stroke-width: 30;
          stroke-linecap: round; stroke-linejoin: round; }
/* The answer, under the ink rather than beside it, so the comparison is a
   look and not a look-and-then-look-again. */
.wb-truth { fill: color-mix(in srgb, var(--seal) 42%, transparent); }
/* The order, played on the answer (#2460): each stroke of the character comes
   in on its turn, a half second apart, and the learner's ink steps back while
   it does so the order is what is read. `--n` is the stroke count, set on the
   box, so the ink comes back when the last stroke is down. */
.wb.wb-play .wb-truth {
  animation: wb-stroke .42s ease-out calc(var(--i) * .5s) both;
}
.wb.wb-play .wb-ink {
  animation: wb-ink-back calc(var(--n, 1) * .5s + .5s) linear both;
}
@keyframes wb-stroke {
  from { opacity: 0; fill: var(--seal); }
  to { opacity: 1; }
}
@keyframes wb-ink-back {
  0%, 92% { opacity: .18; }
  100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .wb.wb-play .wb-truth, .wb.wb-play .wb-ink { animation: none; }
}

/* A stroke a Hint showed (#2459): the trace screen's shadow weight. */
.wb-hint { fill: color-mix(in srgb, var(--fg) 18%, transparent); }
/* THE START DOTS ARE GONE (2026-09-08, asked for). There was a dot on the first
   point of every stroke while a card was under MATURE_DAYS. Eleven dots on 得
   are the stroke count, the stroke order and roughly the layout — most of the
   answer to "write it from memory", handed over before the question is asked.
   Tracing is the screen that scaffolds; this one asks. See writeBox in views.js.
*/
.wb-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
              justify-content: center; margin-top: 10px; }
.wb-actions .fine { margin: 0; color: var(--muted); }
/* The face and the box are each one block, so a phone stacks them as before. */
.r-write .r-face { display: contents; }
.r-write .wb-side { width: 100%; }

/* ── THE BOX BESIDE THE ANSWER, AND NO SCROLL (#2458) ─────────────────────
   Paul: *"hier auch wieder bei der Lösung zu scrollen ist blöd."* On a desk
   the box takes the left of the card at a size the window's HEIGHT allows,
   and the question or the answer stands to its right, so after Show the ink
   over the character, the answer and the four grades are on one screen. The
   same move #2457 made for tracing. A phone keeps the one column. */
@media (min-width: 820px) {
  .r-card.r-write {
    --wb: clamp(190px, calc(100dvh - 440px), 300px);
    display: grid; grid-template-columns: auto minmax(0, 1fr);
    align-items: center; column-gap: var(--s4);
    min-height: 0; padding: 18px;
  }
  .r-write .r-face {
    display: flex; flex-direction: column; align-items: center; gap: 6px;
    grid-column: 2; grid-row: 1; min-width: 0;
  }
  .r-write .wb-side { grid-column: 1; grid-row: 1; width: auto; }
  /* The sheet's top, the round's bar, the card's padding, the grades and the
     gaps between come to about 440px of a laptop's window; the box takes what
     is left, and no more than 300px on a tall screen. */
  .r-write .wb { width: var(--wb); margin: 0; }
  .r-write .wb-actions { margin-top: 6px; gap: 4px 8px; max-width: var(--wb); margin-inline: auto; }
  /* The face is the taller column, so it is set a step tighter than a
     reading card's: the character is under the ink in the box already, so
     its glyph here can be smaller, and the parts sit on one line. */
  .r-write .r-hanzi { font-size: 44px; line-height: 1.05; }
  .r-write .rpairs { row-gap: 6px; }
  .r-write .r-cast { gap: 6px; margin-top: 2px; }
  .r-write .r-cast li { padding: 5px 9px; gap: 6px; }
  .r-write .r-cast .cast-f { font-size: 19px; }
}

/* ── Where you stand, on Today ────────────────────────────────────────────
   Three facts, each a door to the screen that explains it. Cards, because they
   are pressable — which is the rule the rest of this file keeps, and the reason
   the figures under them are not cards. */
.facts { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.fact {
  display: flex; flex-direction: column; gap: 2px;
  padding: 14px 16px; border-radius: var(--r-card); text-decoration: none; color: var(--fg);
  background: var(--raised);
  transition: border-color .12s ease-out;
}
.fact:hover { border-color: color-mix(in srgb, var(--fg) 26%, transparent); }
.fact-n { font-family: 'Fraunces', Georgia, serif; font-size: 24px; font-weight: 600;
          line-height: 1.1; font-variant-numeric: tabular-nums; }
.fact-k { color: var(--muted); font-size: var(--fs-fine); }

/* ── The ✎ button and its dialog ──────────────────────────────────────────
   Fixed to the corner and present on every screen including sign-in, because a
   learner who cannot get in is exactly the one with something to say.

   Bottom RIGHT, not left: the bottom left of this app is the account row on a
   laptop, and under 860px the whole bottom edge is the tab bar — so the button
   lifts clear of it at that width rather than sitting on top of a tab. */
/* ── AND IT HAS TO BE FINDABLE, WHICH IT WAS NOT ─────────────────────────
   Reported as "the feedback button is not showing". It was showing: a 40px
   circle of `--card` inside a `--hair` border, which on ink is #1F1D17 on
   #1B1A14 — four points of red apart, the same near-invisible pair that made
   isBlank() throw away working screenshots in feedback.js. In the corner of a
   full-bleed character grid there was nothing to see.

   So it says what it is. The glyph keeps its circle and a label unfurls beside
   it on a pointer, which costs nothing at rest and makes the thing legible the
   moment somebody goes near it; and the resting state gets the sunken ground
   and a real border instead of two colours that differ by nothing. Widened on
   focus too, because a keyboard reaches this before a mouse does. */
.fb-open {
  position: fixed; right: 18px; bottom: 18px; z-index: 60;
  display: flex; align-items: center; justify-content: center;
  min-width: 40px; height: 40px; padding: 0; border-radius: 99px; cursor: pointer;
  background: var(--bg-sunken);
  border: 1px solid color-mix(in srgb, var(--fg) 30%, transparent);
  color: var(--fg-2); font: inherit; font-size: 16px; line-height: 1;
  box-shadow: var(--shadow);
  transition: padding .16s ease-out, color .12s ease-out, border-color .12s ease-out;
}
/* The word is already in the markup as `aria-label`, so it is drawn from the
   attribute rather than written a second time: one string, and the one that
   applyCopy() already translates along with everything else carrying a key. */
.fb-open::after {
  content: attr(aria-label);
  max-width: 0; overflow: hidden; white-space: nowrap;
  font-size: var(--fs-fine);
  transition: max-width .16s ease-out;
}
.fb-open:hover, .fb-open:focus-visible {
  color: var(--seal-text); border-color: var(--seal); padding: 0 14px 0 12px;
}
.fb-open:hover::after, .fb-open:focus-visible::after { max-width: 12rem; margin-left: 8px; }
@media (max-width: 860px) {
  .fb-open { bottom: calc(74px + env(safe-area-inset-bottom)); }
}
/* A pointer is what unfurls the label and a touch screen has none — a hover
   state on a phone is whatever was tapped last. So on those it is simply
   there. */
@media (hover: none) {
  .fb-open { padding: 0 14px 0 12px; }
  .fb-open::after { max-width: 12rem; margin-left: 8px; }
}

.fb {
  width: min(92vw, 460px); padding: 0;
  border-radius: var(--r-panel); background: var(--raised); color: var(--fg);
}
.fb::backdrop { background: rgba(0, 0, 0, .5); }
/* A closed dialog is never drawn, whatever class it shares with something
   else: the browser's own `dialog:not([open]) { display: none }` loses to any
   author rule that sets `display`, which is how #ask came to show (above,
   "The bubbles"). */
dialog:not([open]) { display: none; }
.fb-form { display: flex; flex-direction: column; gap: 12px; padding: 22px; }
.fb-form h2 { font-size: 19px; }
.fb-form .fine { margin: 0; color: var(--muted); }
.fb-label { font-size: var(--fs-fine); color: var(--fg-2); }
.fb textarea {
  width: 100%; resize: vertical; font: inherit; font-size: var(--fs-small);
  padding: 10px 12px; border-radius: 10px;
  background: var(--bg-sunken); border: 1px solid var(--hair); color: var(--fg);
}
.fb textarea:focus-visible { outline: 2px solid var(--seal); outline-offset: -1px; }
/* The thumbnail, so somebody can see what is about to be attached before it
   goes. The app shows one for the same reason. */
/* #2056: the picture is a canvas you draw on, so it is shown WHOLE rather
   than cropped to its top — a mark on the part the crop hid would be a mark
   nobody can see before sending. Tall pictures are capped by height and keep
   their shape. */
.fb-mark { display: flex; flex-direction: column; gap: 8px; }
.fb-mark[hidden] { display: none; }
.fb-thumb {
  display: block; align-self: center;
  max-width: 100%; max-height: 44vh; width: auto; height: auto;
  border-radius: var(--r-card);
  box-shadow: 0 0 0 1px var(--hair);
  cursor: crosshair; touch-action: none;
}
.fb-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.fb-tools .fine { margin-left: 4px; }
.fb-tool {
  padding: 5px 12px; border-radius: var(--r-pill); border: 0; cursor: pointer;
  font: inherit; font-size: var(--fs-fine); font-weight: 600;
  color: var(--fg-2); background: var(--raised);
}
.fb-tool.on { color: #fff; background: var(--seal); }
.fb-tool[data-tool="marker"].on { color: #2A2310; background: #F2C94C; }
.fb-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 2px; }
.fb-thumb { cursor: zoom-in; }
.fb-grow, .fb-done { margin-left: auto; }
/* #2379: the picture as large as the window, with only its tools. The same
   canvas, so the strokes drawn here are the ones sent. `#fb` and not `.fb`:
   the question dialog shares the class. */
#fb.big {
  width: 100vw; height: 100vh; height: 100dvh;
  max-width: 100vw; max-height: 100vh; max-height: 100dvh;
  margin: 0; border-radius: 0; background: var(--bg-sunken, #111);
}
#fb.big .fb-form { height: 100%; padding: 16px; }
#fb.big .fb-form > :not(.fb-mark) { display: none; }
#fb.big .fb-mark { flex: 1; min-height: 0; justify-content: center; }
#fb.big .fb-thumb {
  max-height: calc(100vh - 96px); max-height: calc(100dvh - 96px);
  cursor: crosshair;
}
#fb.big .fb-tools { justify-content: center; }

/* ── A face ───────────────────────────────────────────────────────────────
   One size, three places: the league board, the Profile heading and the
   sidebar foot. `object-fit: cover` because the phone writes a 128px square
   and nothing guarantees the next version will.

   The fallback is a letter on the sunken ground rather than a silhouette: a
   board of forty identical grey heads is worse at telling people apart than a
   board of letters, which is the entire job of a picture on a leaderboard. */
.av {
  width: 26px; height: 26px; border-radius: var(--r-pill); flex: 0 0 auto;
  object-fit: cover; display: block; background: var(--raised);
}
/* `--raised` and not `--bg-sunken`: sunken is two per cent off the page on ink,
   so a learner with no picture had an invisible avatar — a letter floating
   beside their name with nothing under it. The initial needs a disc to sit on
   or it is not standing in for a photograph. */
.av-none {
  display: grid; place-items: center;
  font-family: 'Fraunces', Georgia, serif; font-size: 12px; font-weight: 600;
  color: var(--fg-2);
}
/* ── THE PROFILE OPENS ON A PERSON ────────────────────────────────────────
   A 46px disc beside a 36px name is a favicon beside a headline. This is the
   one screen that is ABOUT somebody, so the picture is the size of a portrait
   and the name sits beside it — which also gives the screen rule somewhere to
   be that is not under a 26px avatar. */
.profile-head { display: flex; align-items: center; gap: var(--s5); }
.profile-head .av { width: 84px; height: 84px; }
.profile-head .av-none { font-size: 32px; }
.profile-head h1::after { display: none; }
.profile-email { margin-top: var(--s2) !important; }
.board .av { width: 24px; height: 24px; }

/* ── The drills ───────────────────────────────────────────────────────────
   Tone and sentence listening. One card, centred, because a drill is a single
   question and everything else on the screen is a distraction from it — which
   is the one place this app centres anything besides the end of a round.

   Not a `.deck` and not an `.opt`: those are things you choose between, and
   this is the thing you are doing. */
.drill-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; flex-wrap: wrap; }
.drill-tally { display: flex; align-items: center; gap: 14px; }
.drill-tally span { color: var(--muted); font-size: var(--fs-fine); font-variant-numeric: tabular-nums; }
/* A pill here rather than the review header's quiet second line, which is
   `ToneDrillView`'s own choice: this screen has no progress rail to hang a line
   under, and the score beside it is already a span of the same weight. Tone 2's
   amber, at the same low opacities the app uses, so it reads as a tally and not
   as something to press. */
.drill-tally .drill-xp {
  color: var(--t2-ink); font-weight: 600;
  padding: 3px 10px; border-radius: 99px;
  background: color-mix(in srgb, var(--t2) 12%, var(--card));
  border: 1px solid color-mix(in srgb, var(--t2) 35%, transparent);
}

/* ── ONE QUESTION, AND A CARD THE SIZE OF ONE QUESTION ────────────────────
   The drill is a syllable, five buttons and a verdict, and it was drawing them
   across a thousand pixels with the content pooled in the middle — a card whose
   width has nothing to do with what is in it. Capped at the review card's
   width, because it is the same act: one thing asked, one answer given. */
.drill-card {
  background: var(--card); border-radius: var(--r-panel);
  padding: clamp(24px, 3vw, 38px);
  display: flex; flex-direction: column; gap: var(--s5); align-items: center;
  box-shadow: var(--panel);
  max-width: 620px; width: 100%; margin: 0 auto;
}
.drill-ask { display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; }
.drill-hanzi { font-family: 'Noto Serif SC', serif; font-size: clamp(56px, 9vw, 88px); line-height: 1; }
.drill-line { font-family: 'Noto Serif SC', serif; font-size: clamp(24px, 3.4vw, 34px); line-height: 1.45; }
/* The question mark and the dots are placeholders for something deliberately
   withheld, so they are drawn as an absence rather than as content. */
.drill-hidden { color: var(--hair); }
.drill-mean { color: var(--muted); font-size: var(--fs-fine); }
.play-lg { width: 42px; height: 42px; font-size: 17px; margin-top: 6px; }

/* ── The five tones ───────────────────────────────────────────────────────
   Each button wears its own tone colour, which is the one place in this app
   where the tone scale is the SUBJECT rather than a property of some text. The
   number is the answer and the word under it is what the number means, because
   "3" is only obvious to somebody who already has this. */
.tone-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.tone-btn {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  min-width: 84px; padding: 12px 14px; border-radius: var(--r-card); cursor: pointer;
  background: var(--raised);  font: inherit;
  transition: border-color .12s ease-out, background .12s ease-out;
}
.tone-btn:hover:not(:disabled) { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
.tone-btn .tone-n { font-family: 'Fraunces', Georgia, serif; font-size: 24px; font-weight: 600; line-height: 1; }
.tone-btn .tone-k { font-size: 11.5px; color: var(--muted); }
.tone-btn:disabled { cursor: default; }
/* The five colours are the tone scale itself — brand.md's, and the same hexes
   the pinyin above uses. */
.tone-btn.t1 .tone-n { color: var(--t1-ink); }
.tone-btn.t2 .tone-n { color: var(--t2-ink); }
.tone-btn.t3 .tone-n { color: var(--t3-ink); }
.tone-btn.t4 .tone-n { color: var(--t4-ink); }
.tone-btn.t5 .tone-n { color: var(--t5-ink); }

/* ── Right, wrong, and the ones that were neither ─────────────────────────
   Green marks the answer and red marks what was chosen, and BOTH are shown on
   a wrong answer: being told only that you were wrong teaches nothing, and the
   app makes the same point by playing the tone you picked. The untouched
   options dim rather than vanish, so the row does not reflow under the cursor
   that has just left it. */
/* A RING rather than a border, for the same reason the focus ring is one: a
   border that appears on answering moves the row by a pixel, and a row of five
   that shifts as you press one is the kind of thing nobody reports and
   everybody feels. */
.tone-btn.right, .opt-answer.right {
  box-shadow: inset 0 0 0 1.5px var(--growth-4);
  background: color-mix(in srgb, var(--growth-4) 15%, var(--card));
}
.tone-btn.wrong, .opt-answer.wrong {
  box-shadow: inset 0 0 0 1.5px var(--seal);
  background: var(--seal-soft);
}
.tone-btn.dim, .opt-answer.dim { opacity: .4; }
.opt-answer { justify-content: flex-start; }
.opt-answer:disabled { opacity: 1; }
.opt-answer.dim:disabled { opacity: .4; }

.drill .opts { width: 100%; max-width: 520px; }
.drill-after { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.drill-after p { margin: 0; font-size: var(--fs-small); }
.drill-yes { color: color-mix(in srgb, var(--growth-4) var(--jade-keep), var(--fg)); }
.drill-no { color: var(--seal-text); }

/* ── Hear the difference (feedback #529) ─────────────────────────────────
   The answer and the tone you picked, on the same syllable, side by side and
   each in its own tone's colour, so the two sounds are two things to press
   rather than a sentence to read. */
.hd { display: flex; flex-direction: column; align-items: center; gap: 8px;
  padding-top: 12px; border-top: 1px solid var(--hair); width: 100%; }
.drill-after .hd-k { text-transform: uppercase; letter-spacing: .1em; font-size: 11px;
  font-weight: 600; color: var(--muted); }
.hd-row { display: flex; gap: 22px; align-items: flex-start; justify-content: center; }
.hd-cell {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  background: none; border: 0; cursor: pointer; padding: 6px 10px; border-radius: 12px;
  color: var(--tc, var(--fg));
}
.hd-cell:hover { background: var(--raised); }
.hd-cell.t1 { --tc: var(--t1-ink); } .hd-cell.t2 { --tc: var(--t2-ink); } .hd-cell.t3 { --tc: var(--t3-ink); }
.hd-cell.t4 { --tc: var(--t4-ink); } .hd-cell.t5 { --tc: var(--t5-ink); }
.hd-hanzi { font-family: 'Noto Serif SC', serif; font-size: 30px; line-height: 1.1; }
.hd-py { font-size: 13px; font-weight: 500; }
.hd-cap { font-size: 11px; color: var(--muted); }
.hd-none { max-width: 18ch; text-align: center; }
/* The round's per-tone figures carry their tone's colour on the number. */
.tone-figs .fig.t1 .fig-n { color: var(--t1-ink); } .tone-figs .fig.t2 .fig-n { color: var(--t2-ink); }
.tone-figs .fig.t3 .fig-n { color: var(--t3-ink); } .tone-figs .fig.t4 .fig-n { color: var(--t4-ink); }
.tone-figs .fig.t5 .fig-n { color: var(--t5-ink); }

/* ── The account row in the sidebar foot ──────────────────────────────────
   What four elements became. It is a link to Profile because that is where the
   things it used to hold now live. */
.acct {
  display: flex; align-items: center; gap: 9px; width: 100%;
  padding: 7px 8px; border-radius: 10px; text-decoration: none;
  background: var(--bg-sunken); color: var(--fg-2);
  /* This opaque ground is what the floating bar's pill ends up with too: it
     comes later than the bar's tint (`.top-brand, .acct` at 78%), so it wins,
     and the 10px glass that rule gives the pill blurred a backdrop nobody can
     see, on every screen, on every frame (#2431; with and without it the pill
     differs by 1 of 255). If the pill is ever given the bar's tint back, its
     blur comes back with it. */
  backdrop-filter: none;
}
.acct:hover { color: var(--fg); }
.acct-av, .acct .av { width: 24px; height: 24px; border-radius: 99px; font-size: 11px; }
.acct-av img { width: 100%; height: 100%; }
.acct-who {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: var(--fs-fine);
}
/* Sign out stops being a button that competes with the nav above it. It is
   still a button, because it does something rather than going somewhere. */
.btn-quiet {
  background: transparent; border-color: transparent; color: var(--muted);
  font-size: var(--fs-fine); padding: 6px 8px; justify-content: flex-start;
}
.btn-quiet:hover { color: var(--fg); background: var(--bg-sunken); }

/* ── The headline, which is the streak ────────────────────────────────────
   Every learning app that keeps people opens this screen on one number, and it
   is always the streak. It goes where the eye lands and the seven dots beside
   it say what has been done about it this week.

   `pg-` and not `hero-`: the mascot at the end of a round already owns `.hero`
   further down this file, and two things called hero is how a stylesheet
   starts lying. */
.pg-hero {
  display: flex; align-items: center; justify-content: space-between;
  gap: 20px; flex-wrap: wrap;
  padding: 18px clamp(16px, 2.4vw, 24px); border-radius: var(--r-panel);
  background: var(--card); 
  box-shadow: var(--panel);
}
.pg-main { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.pg-n {
  font-family: 'Fraunces', Georgia, serif; font-size: clamp(38px, 6vw, 52px);
  font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums;
  color: var(--seal-text);
}
.pg-k { font-size: var(--fs-small); color: var(--fg-2); }
.pg-best { font-size: var(--fs-fine); color: var(--muted); }
.pg-week { display: flex; gap: 8px; }
.pg-d { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.pg-dl { font-size: 11px; color: var(--muted); }
/* A ring for a day not yet studied, filled for one that was, and dimmed for a
   day that has not happened — three states, because "not yet" and "missed" are
   different things and a learner on Tuesday should not see Friday as a
   failure. */
.pg-dot {
  width: 15px; height: 15px; border-radius: 99px;
  border: 1.5px solid var(--hair); background: transparent;
}
.pg-d.on .pg-dot { background: var(--growth-4); border-color: var(--growth-4); }
.pg-d.on .pg-dl { color: var(--fg-2); }
.pg-d.ahead .pg-dot { opacity: .45; }
/* The dot says "not yet", and only the dot: the whole day at .45 put its
   label at 2:1 on the board, a weekday nobody could read (#2144). */

/* ── The heatmap ──────────────────────────────────────────────────────────
   Twenty-six columns of seven days. Columns are weeks and rows are weekdays,
   so a horizontal run is a habit and a horizontal gap is a fortnight away —
   a shape a row of bars cannot show at all.

   The grid sizes itself: `repeat(var(--cols), 1fr)` with square cells means it
   fits any width without a scrollbar, which is the whole reason the plot it
   replaced needed one and never used it properly. `heat-scroll` is there for
   the genuinely narrow case and has a floor on the cell size so the grid never
   degrades into unreadable dust. */
.heat-block { display: flex; flex-direction: column; gap: 10px; }
.heat-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 14px; flex-wrap: wrap;
}
.heat-head .sub { margin: 0; }
.heat-legend { display: flex; align-items: center; gap: 6px; }
.heat-legend .fine { margin: 0; color: var(--muted); }
.heat-scroll { overflow-x: auto; padding-bottom: 4px; }
.heat { display: flex; flex-direction: column; gap: 5px; min-width: 300px; }
.heat-months {
  display: grid; grid-template-columns: repeat(var(--cols), 1fr);
  margin-left: 26px; font-size: 11px; color: var(--muted);
}
/* Each label starts over its own month's first column and is allowed to run
   past it — a month name is wider than a week. `nowrap` so it never wraps into
   the row of cells below. */
.heat-m { white-space: nowrap; }
.heat-rows { display: flex; gap: 5px; }
.heat-days {
  display: grid; grid-template-rows: repeat(7, 1fr); gap: 3px;
  width: 21px; font-size: 11px; color: var(--muted);
}
.heat-day { display: flex; align-items: center; line-height: 1; }
/* ── A QUIET DAY IS QUIET ──────────────────────────────────────────────────
   An empty day wore a nine-per-cent outline, so a half-year of a real learner's
   history was two hundred visible EMPTY boxes with the work scattered among
   them — the grid read as the subject and the study read as an interruption of
   it. The outline is gone and the empty fill does the work: present, because an
   empty day is still a day, and barely.

   Rounder and further apart, which is the other half. Three pixels of gap on a
   nine-pixel square is a mesh; four on a slightly softer square is a set of
   marks. */
.heat-cells {
  flex: 1 1 auto; display: grid; gap: 4px;
  grid-template-rows: repeat(7, 1fr);
  grid-auto-flow: column; grid-auto-columns: 1fr;
}
.heat-cell {
  aspect-ratio: 1; min-width: 9px; border-radius: 4px; padding: 0;
  border: 0; background: var(--growth-0);
  font: inherit; cursor: pointer;
}
.heat-legend .heat-cell { width: 11px; cursor: default; }
/* The same four-step ramp the bars used, and for the reason tokens.css gives:
   seal is one voltage colour, spent twice a page at most, and a screen that
   spends it a hundred and eighty times is one where the accent stops meaning
   anything. The ramp carries the quantity; seal is saved for `you are here`. */
/* ── A DAY WITH NOTHING IS STILL A DAY ────────────────────────────────
   `--growth-0` is the sunken ground on both grounds, which is right for one
   empty cell among busy ones and wrong for fifteen in a row: a fortnight away
   drew as a HOLE in the grid rather than as a fortnight away, and the layout
   read as broken. The hairline is what keeps the lattice visible through a
   gap, which is the whole thing a heatmap is for. */
.h0 { background: color-mix(in srgb, var(--fg) 6%, transparent); }
.h1 { background: var(--growth-1); }
.h2 { background: var(--growth-2); }
.h3 { background: var(--growth-3); }
.h4 { background: var(--growth-4); }
.heat-gap { visibility: hidden; }
button.heat-cell:hover { box-shadow: inset 0 0 0 1.5px var(--fg-2); }
button.heat-cell:focus-visible { outline: 2px solid var(--seal); outline-offset: 1px; }
/* The pressed day, marked rather than recoloured: the colour is the count and
   must keep meaning that. */
button.heat-cell.on { border-color: var(--seal); }
.heat-read {
  margin: 0; min-height: 1.4em; font-size: var(--fs-small); color: var(--fg-2);
}

/* ── WHAT USED TO BE HERE ─────────────────────────────────────────────────
   The forecast chart, the two rhythms, the deck split, the freshness meter
   and the three fact cards. All five came off Progress on 2026-09-21 — see
   views.js, which has the reason for each — and the rules are deleted rather
   than left behind. A selector with nothing to match is a selector the next
   person has to prove is dead before they can move anything near it.

   `.d-char` and its three siblings went with them: the deck ramp was drawn
   nowhere else on this host. */

/* ── The tone ear ─────────────────────────────────────────────────────────
   Five rails, each in its own tone colour, because this is the second place in
   the app where the tone scale is the SUBJECT rather than a property of some
   text — the drill's five buttons are the first, and they use the same hexes.

   A tone nobody has been asked draws no fill and says so in words. Nought per
   cent is a claim about your ear; "not asked yet" is the truth. */
.ear { display: flex; flex-direction: column; gap: 7px; }
.ear-row {
  display: grid; grid-template-columns: 22px 1fr 44px auto; gap: 12px; align-items: center;
}
.ear-t {
  font-family: 'Fraunces', Georgia, serif; font-size: 19px; font-weight: 600;
  line-height: 1; text-align: center;
}
.ear-rail { height: 8px; border-radius: 4px; background: var(--bg-sunken); overflow: hidden; }
.ear-fill { display: block; height: 100%; border-radius: 4px; transition: width .3s ease-out; }
.t1bg { background: var(--t1); } .t2bg { background: var(--t2); } .t3bg { background: var(--t3); }
.t4bg { background: var(--t4); } .t5bg { background: var(--t5); }
.ear-n { font-variant-numeric: tabular-nums; font-size: var(--fs-small); text-align: right; }
.ear-a { color: var(--muted); white-space: nowrap; }
/* The reset, at the right under the five rails where the phone has it
   (#2138): quiet, in the muted ink, because it is the one control on this
   wall that throws something away. */
.ear-foot { display: flex; justify-content: flex-end; margin-top: var(--s2); }
.ear-reset {
  padding: 4px 12px; border-radius: var(--r-pill);
  border: 1px solid color-mix(in srgb, var(--fg) 20%, transparent);
  background: none; color: var(--fg-2); font: inherit; font-size: var(--fs-fine); font-weight: 600;
  cursor: pointer;
}
.ear-reset:hover { color: var(--seal-text, var(--seal)); border-color: currentColor; }
.ear-reset:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; }
@media (max-width: 560px) {
  /* The "n asked" column is the first thing to go: the percentage beside the
     rail is the fact, and the sample size is the footnote. */
  .ear-row { grid-template-columns: 20px 1fr 44px; }
  .ear-a { display: none; }
}

/* ── 生长 THE GROWTH PICTURE ───────────────────────────────────────────────
   Two curves, two counts, two plaques and a tree behind all of them. The app
   opens Stats on these four numbers and this host drew none of them.

   The two panes share a grid so the counts line up whatever the numbers are,
   and each curve is its own SVG scaled to its own range — characters and words
   are an order of magnitude apart and a shared axis would draw one of them as
   a flat line along the bottom.

   `preserveAspectRatio: none` on the curve is deliberate: the chart is read
   for its SHAPE over time, not for a slope in degrees, and a line that keeps
   its aspect ratio in a box this wide has to letterbox itself. */
.gw-block { position: relative; display: flex; flex-direction: column; gap: var(--s4); }
.gw-art {
  position: absolute; inset: 0; z-index: 0;
  display: flex; align-items: flex-end; justify-content: center;
  pointer-events: none; overflow: hidden;
}
.gw-art svg { width: min(100%, 420px); height: 100%; color: var(--fg); }
/* The glyph over the tree, at painting size. It is a WATERMARK and not a
   label: everything else in this frame is a figure, and the one thing that
   makes a frame a picture is something in it that is not. */
.gw-seal {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  font-family: 'Noto Serif SC', serif;
  font-size: clamp(90px, 16vw, 190px); line-height: 1;
  color: var(--fg); opacity: .09;
}
.gw-tree { opacity: .17; }
/* Each cloud at a low alpha and overlapping its neighbours, so where two of
   them meet is darker — which is how a dabbed canopy gets its depth, and the
   only reason six ellipses do not read as six ellipses. */
.gw-leaves { fill: currentColor; opacity: .055; }
.gw-ground { fill: currentColor; opacity: .1; }
/* Everything but the drawing sits over it. `:not` and not a rule before it:
   this selector is two classes and the one on `.gw-art` is one, so a plain
   `.gw-block > *` wins wherever it is written and quietly puts the tree back
   into the flow — which is what it did, and the picture grew by 300 empty
   pixels on every width. */
.gw-block > *:not(.gw-art) { position: relative; z-index: 1; }
.gw-panes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s4); }
.gw-pane {
  display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto auto;
  align-items: baseline; column-gap: var(--s3);
}
.gw-glyph {
  grid-row: 1 / 3;
  font-family: 'Noto Serif SC', serif; font-size: 34px; line-height: 1;
  color: var(--muted);
}
.gw-n {
  font-family: 'Fraunces', Georgia, serif; font-size: 32px; font-weight: 600;
  line-height: 1.05; font-variant-numeric: tabular-nums;
}
.gw-k { grid-column: 2; font-size: var(--fs-fine); color: var(--muted); }
/* ── 线 THE TEACHING PLATFORM'S CHART (#2134) ──────────────────────────────
   `growth()` in apps/teacher: a three-unit line, a wash of its own colour
   under it, a hairline at the middle date and the dates inside the picture.
   The box scales UNIFORMLY now (width 100%, height from the viewBox), so the
   line keeps its weight and the dates their shape at every width, which is
   what the teacher's rebuild of its daily charts was about. */
.gw-plot { grid-column: 1 / -1; position: relative; display: block;
           width: 100%; margin-top: var(--s3); }
.gw-curve { display: block; width: 100%; height: auto; overflow: visible; }
.gw-line { fill: none; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.gw-rule { stroke: var(--hair); stroke-width: 1.5; }
.gw-tick { font-family: 'Inter', system-ui, sans-serif; font-size: 15px; fill: var(--muted); }
/* ── 点 EVERY READING IS STILL PRESSABLE, AND NOTHING IS DRAWN ON IT ────────
   *"mag gerade nicht die punkte oben drauf"*: the dots are gone. What stays
   is the reading, because a line that refuses to say its numbers is the
   complaint every chart eventually gets. Each day is an invisible band the
   full height of the plot; the one under the pointer, or pressed, shows a
   hairline at its day and the date and figure over it, and that is all. */
.gw-hit {
  position: absolute; top: 0;
  padding: 0; border: 0; background: none; cursor: pointer;
}
.gw-hit::before {
  content: ''; position: absolute; top: 0; bottom: 0; left: var(--x, 50%);
  width: 1.5px; translate: -50% 0;
  background: color-mix(in srgb, var(--fg) 30%, transparent);
  opacity: 0; transition: opacity .12s ease-out;
}
.gw-hit:hover::before, .gw-hit:focus-visible::before, .gw-hit.on::before { opacity: 1; }
.gw-hit:focus-visible { outline: none; }
/* The hover label. A native title attribute waits a second and a half and
   then draws in the operating system's font, on a wall made of wood. */
.gw-tip {
  position: absolute; bottom: 100%; left: var(--x, 50%);
  translate: -50% -4px;
  padding: 4px 8px; border-radius: 4px;
  font-size: var(--fs-fine); line-height: 1.2; white-space: nowrap;
  color: #F3EFE4; background: rgba(38, 30, 20, .92);
  pointer-events: none;
  z-index: 3;
  /* NOT `opacity: 0`, which leaves the label in the layout: a label per day
     on a chart 300px wide reaches a long way past both edges, and at iPad
     width that was a page you could drag sideways. */
  display: none;
}
/* The first and last few would hang off the frame when centred, so they
   lean inward instead. */
.gw-hit:first-of-type .gw-tip { translate: -8px -4px; }
.gw-hit:last-of-type .gw-tip { translate: calc(-100% + 8px) -4px; }
.gw-hit:hover .gw-tip, .gw-hit:focus-visible .gw-tip, .gw-hit.on .gw-tip { display: block; }
.gw-plot:hover .gw-hit.on:not(:hover) .gw-tip,
.gw-plot:hover .gw-hit.on:not(:hover)::before { display: none; }
/* The window the drawers are counted over, small, on the cabinet's own top
   rail — the one caption on this wall that belongs to a piece of furniture
   rather than to a picture. */
.drawers-k { display: none; }
.gw-thin { grid-column: 1 / -1; margin: var(--s3) 0 0; color: var(--muted); }
/* 牌 The two plaques. A POSITION rather than an amount, which is why they are
   one line each and not a chart. */
.gw-plaques { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s4); }
.gw-plaque {
  display: grid; grid-template-columns: auto 1fr; align-items: center;
  column-gap: var(--s3); row-gap: 0;
  padding: var(--s3) var(--s4);
  border-radius: 3px;
  background: var(--raised);
}
.gw-p-g {
  grid-row: 1 / 3;
  font-family: 'Noto Serif SC', serif; font-size: 24px; line-height: 1; color: var(--seal-text, var(--seal));
}
.gw-p-n {
  font-family: 'Fraunces', Georgia, serif; font-size: 22px; font-weight: 600;
  line-height: 1.1; font-variant-numeric: tabular-nums;
}
.gw-p-k { grid-column: 2; margin: 0; color: var(--muted); }

/* ── 身份 IDENTITY ────────────────────────────────────────────────────────
   Facts true of this learner and of nobody else who studied the same amount.
   A row is a glyph and two lines, and it is a LINK whenever there is a
   character behind it: the obvious next thing to do with "this is the one you
   keep forgetting" is to go and look at it. */
.id-block { display: flex; flex-direction: column; gap: var(--s3); }
.id { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.id li + li { border-top: 1px solid var(--hair); }
.id-row {
  display: grid; grid-template-columns: 38px 1fr; align-items: center; gap: var(--s4);
  padding: var(--s3) 0;
  color: inherit; text-decoration: none;
}
a.id-row:hover .id-h { text-decoration: underline; text-underline-offset: 3px; }
.id-g {
  display: grid; place-items: center;
  width: 38px; height: 38px; border-radius: 8px;
  font-family: 'Noto Serif SC', serif; font-size: 21px; line-height: 1;
  background: var(--raised);
}
.id-t { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.id-h { font-size: var(--fs-small); font-weight: 500; }
.id-d { margin: 0; color: var(--muted); }

/* ── 积压 THE BACKLOG ─────────────────────────────────────────────────────
   Five bands, and older is WARMER — a temperature rather than five
   categories, so the row that should worry somebody is the one that looks
   hot. The first band is what is due today and not late yet, which is why it
   is the only grey one. */
.bl-block { display: flex; flex-direction: column; gap: var(--s3); }
.bl { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.bl li { display: grid; grid-template-columns: 34px 1fr 48px; gap: var(--s4); align-items: center; }
.bl-d { font-size: var(--fs-fine); color: var(--fg-2); font-variant-numeric: tabular-nums; }
.bl-rail { height: 8px; border-radius: 4px; background: var(--bg-sunken); overflow: hidden; }
.bl-fill { display: block; height: 100%; border-radius: 4px; min-width: 2px; }
.bl-fill.b0 { background: var(--muted); }
.bl-fill.b1 { background: var(--t4); }
.bl-fill.b2 { background: var(--t2); }
.bl-fill.b3 { background: var(--seal); }
.bl-fill.b4 { background: color-mix(in srgb, var(--seal) 78%, #000); }
.bl-n { font-size: var(--fs-small); text-align: right; font-variant-numeric: tabular-nums; }

/* ── 声年 THE TONE YEAR ───────────────────────────────────────────────────
   The five standing figures are lifetime counts, which is the one thing they
   cannot show: an ear that has improved for months is still dragging its first
   bad week around in them. The curves are the same five tones as they have
   MOVED, from the daily snapshots — and they are drawn with no axis at all,
   because five accuracies between sixty and a hundred per cent is a shape
   question and gridlines would be five-sixths of the ink. */
.tn-block { display: flex; flex-direction: column; gap: var(--s3); }
/* ── 预 眼 忆 周 THE PHONE'S OTHER PROGRESS CARDS ────────────────────────────
   Forecast, tones on characters, memory, and the calendar week and month —
   each the same flat block the tone year is, so the room's picture frames
   (below, `body.at-stats`) take them without a rule of their own. */
.fc-block, .tc-block, .mem-block, .sp-block { display: flex; flex-direction: column; gap: var(--s3); }
.st-meter { display: block; height: 7px; border-radius: 4px; background: var(--bg-sunken); overflow: hidden; }
.st-meter i { display: block; height: 100%; border-radius: 4px; background: var(--seal); }
.st-meter.jade i { background: var(--jade); }
.fc { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.fc-row { display: grid; grid-template-columns: 4.5em minmax(0, 1fr) 10em; align-items: center; gap: 10px; }
.fc-row .st-meter { height: 6px; }
.fc-say { display: flex; flex-direction: column; align-items: flex-end; font-size: var(--fs-fine); font-variant-numeric: tabular-nums; }
.fc-say small { color: var(--muted); }
.fc-row.done .fc-say { color: var(--jade-ink); }
/* #2491: the finished levels, one checked line with no bar under it. */
.fc-row.fc-fin { grid-template-columns: minmax(0, 1fr) auto; }
.fc-row.fc-fin b { color: var(--jade-ink); }
.fc-more summary { cursor: pointer; font-size: var(--fs-fine); font-weight: 600; color: var(--seal-text, var(--seal)); }
.fc-more[open] summary { margin-bottom: 10px; }
.sp-figs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; text-align: center; }
.sp-figs div { display: flex; flex-direction: column-reverse; gap: 2px; }
.sp-figs dd { margin: 0; font-family: 'Fraunces', Georgia, serif; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sp-figs dt { font-size: 11px; color: var(--fg-2); }
.sp-squares { display: flex; flex-wrap: wrap; gap: 4px; }
.sp-squares .heat-cell { width: 16px; height: 16px; aspect-ratio: auto; }
.sp-squares.month .heat-cell { width: 12px; height: 12px; }
.tn-worst { margin: 0; }
/* Seals open a sheet; the prestige metals sit under the four families. */
.wall .seal[role="button"] { cursor: pointer; }
.wall .seal[role="button"]:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; }
.seal-big { display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; }
.seal-big .seal-stone { width: 96px; height: 96px; font-size: 48px; display: grid; place-items: center; }
.seal-big .seal-meter { width: 70%; }
.metals { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.metal { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; }
.metal-stone {
  display: grid; place-items: center; width: 72px; height: 72px; border-radius: 18px;
  font-size: 32px; color: var(--muted); background: var(--bg-sunken); box-shadow: inset 0 0 0 1.5px var(--hair);
}
.metal.won .metal-stone { color: #231F19; rotate: -4deg; }
.metal.silver.won .metal-stone { background: linear-gradient(135deg, #DADDE3, #C8CCD4, #8E939D); box-shadow: inset 0 0 0 2.5px #8E939D; }
.metal.gold.won .metal-stone { background: linear-gradient(135deg, #EFD58E, #E5C15C, #A8842B); box-shadow: inset 0 0 0 2.5px #A8842B; }
.metal.jade.won .metal-stone { background: linear-gradient(135deg, #A6DABD, #7FC8A0, #3E7D5C); box-shadow: inset 0 0 0 2.5px #3E7D5C; }
.metal .seal-meter { width: 70px; }
.metal-won { color: var(--jade-ink); }
/* Profile: the two chips, the snapshot, the ladder. */
.pf-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }
.pf-chip { padding: 3px 10px; border-radius: var(--r-pill); font-size: var(--fs-fine); font-weight: 600; }
.pf-chip.lvl { color: var(--t2-ink); background: color-mix(in srgb, var(--t2) 14%, var(--card)); }
.pf-chip.rank { color: var(--seal-text, var(--seal)); background: color-mix(in srgb, var(--seal) 12%, var(--card)); }
.pf-snap { display: flex; flex-direction: column; gap: 16px; }
.pf-powers { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.pf-powers div { display: flex; flex-direction: column; gap: 4px; }
.pf-powers b { font-family: 'Fraunces', Georgia, serif; font-size: 26px; font-variant-numeric: tabular-nums; }
.st-meter.t4 i { background: var(--t4); }
/* The three counts and the three figures under them are ONE kind of thing
   (#2077): they had three typographies between them — centred and light
   here, left and bold below. Both rows are now the same three columns, the
   figure bold over its label. */
.pf-counts { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; box-shadow: inset 0 1px 0 var(--hair); padding-top: 14px; }
.pf-counts div { display: flex; flex-direction: column; gap: 1px; }
.pf-counts dd { margin: 0; font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-weight: 600; line-height: 1.1; font-variant-numeric: tabular-nums; }
.pf-counts dt { font-size: var(--fs-fine); color: var(--muted); }
.profile .stats-grid { grid-template-columns: repeat(3, 1fr); }
.pf-ladder { display: flex; flex-direction: column; gap: 6px; }
/* About: a finished First Chinese lesson and a reached instalment carry a tick. */
.bs-g.done { color: var(--jade-ink); }
.ab-tick { margin-left: auto; color: var(--jade-ink); font-weight: 700; }
.ab-inst.reached summary::after { margin-left: 10px; }
.ab-hub { display: block; margin-top: var(--s3); color: inherit; text-decoration: none; }
.ab-hub b { color: var(--seal-text, var(--seal)); }
.pf-rung summary { display: flex; align-items: baseline; gap: 10px; cursor: pointer; padding: 6px 0; }
.pf-rung.done summary b { color: var(--jade-ink); }
.pf-skills { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.pf-skills li { display: grid; grid-template-columns: 8em minmax(0, 1fr) 6em; align-items: center; gap: 8px; font-size: var(--fs-small); }
.pf-skills small { color: var(--muted); margin-left: 4px; }
.pf-skills b { text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; }
button.id-row { width: 100%; border: 0; background: none; font: inherit; color: inherit; text-align: left; cursor: pointer; padding: var(--s3) 0; }
button.id-row:hover .id-h { text-decoration: underline; text-underline-offset: 3px; }
/* 享 Share, a round button at the end of Progress's title row (#2133): it was
   a chip chalked onto the streak board, and the board is a picture of one
   number. It takes the wall's own ink and hairline, so it reads on the day
   plaster and on the lamplit one without a rule of its own for either. */
body.at-stats .screen-head {
  display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s4);
}
.pg-share {
  flex: 0 0 auto; display: grid; place-items: center;
  width: 40px; height: 40px; padding: 0; margin-top: 2px;
  border: 1px solid color-mix(in srgb, var(--fg) 22%, transparent); border-radius: 50%;
  background: color-mix(in srgb, var(--fg) 5%, transparent); color: var(--fg-2); cursor: pointer;
}
.pg-share svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6;
  stroke-linecap: round; stroke-linejoin: round; }
.pg-share:hover { color: var(--fg); border-color: color-mix(in srgb, var(--fg) 45%, transparent); }
.pg-share:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; }
.pg-share:disabled { opacity: .5; cursor: progress; }
/* The notes that went behind a dot on Progress (#2137) open from inside a
   section title, which is small capitals, and over the wall, whose card is
   half transparent. The panel is prose on a sheet of paper, lamplit after
   dusk like the pictures. */
body.at-stats .sub .info-p { text-transform: none; letter-spacing: normal; font-weight: 400; }
body.at-stats .info-p { background: #F4EEE0; color: #2C2720; --fg-2: #4E463A; }
body:is([data-phase="dusk"], [data-phase="night"]).at-stats .info-p {
  background: #2F2922; color: #EDE5D3; --fg-2: #CFC4AE;
}
/* The two charts that live in sheets: skills over time, and the hours. */
.sk-chart { display: block; width: 100%; height: 180px; background: var(--bg-sunken); border-radius: var(--r-chip); }
.sk-line { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; }
.sk-line.t1 { stroke: var(--t1); } .sk-line.t2 { stroke: var(--t2); } .sk-line.t4 { stroke: var(--t4); }
.sk-line.jade { stroke: var(--jade); } .sk-line.seal { stroke: var(--seal); }
.sk-axis { display: flex; justify-content: space-between; margin: 0; }
.sk-legend { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px 14px; font-size: var(--fs-fine); }
.sk-legend li { display: flex; align-items: center; gap: 6px; }
.sk-legend b { margin-left: auto; font-variant-numeric: tabular-nums; }
.sk-legend i { width: 12px; height: 3px; border-radius: 2px; background: currentColor; }
.sk-legend i.t1 { background: var(--t1); } .sk-legend i.t2 { background: var(--t2); } .sk-legend i.t4 { background: var(--t4); }
.sk-legend i.jade { background: var(--jade); } .sk-legend i.seal { background: var(--seal); }
.hr-bars { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); align-items: end; gap: 2px; height: 96px; }
.hr-bar { display: block; border-radius: 2px; background: var(--jade); }
.hr-bar.none { background: var(--bg-sunken); }
.hr-parts { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.hr-parts li { display: grid; grid-template-columns: 8em 3.5em minmax(0, 1fr) 3em; align-items: center; gap: 8px; font-size: var(--fs-small); }
.hr-parts small { color: var(--muted); font-variant-numeric: tabular-nums; }
.hr-parts b { text-align: right; font-variant-numeric: tabular-nums; }
.tn-year { width: 100%; height: 86px; margin-bottom: var(--s3); }
.tn-line { fill: none; stroke-width: 1.6; vector-effect: non-scaling-stroke;
           stroke-linecap: round; stroke-linejoin: round; opacity: .85; }
.t1st { stroke: var(--t1); } .t2st { stroke: var(--t2); } .t3st { stroke: var(--t3); }
.t4st { stroke: var(--t4); } .t5st { stroke: var(--t5); }

/* ── 盆栽 THE BONSAI ──────────────────────────────────────────────────────
   It stands on the cabinet, and only on the wall — see the room block, where
   it is lifted onto the lid. On a narrow screen there is no cabinet to stand
   on and no room to stand in, so it is not drawn at all rather than floating
   beside a list of figures. */
/* ── EVERY ROOM'S ORNAMENTS BELONG TO ITS ROOM ─────────────────────────
   The seal, the lantern and the bonsai all belong to the wall, and the wall
   only exists above 901px — below it Progress is a column of panels on paper,
   which is the right screen for a phone. They are hidden here rather than
   left to inherit nothing: an unsized SVG in a flow layout is not invisible,
   it is FULL WIDTH, and the lantern drew itself eight hundred pixels tall
   across the top of the narrow screen for exactly that reason. */
.bonsai, .room-lamp, .board-seal { display: none; }
/* And the reading room's four, for the same reason: below 901px the Library is
   a column of cells on paper, which is the right screen for a phone — and an
   unsized SVG in a flow layout is not invisible, it is full width. */
/* The reading room's, likewise — and its rules above are scoped to
   `body.at-library` as well, so a renderer that emits the room on a screen
   behind another tab draws nothing rather than putting a bookcase through
   somebody's passage list. */
.lib-wall, .lib-ivy, .lib-lantern, .room-floor { display: none; }
/* ── 榜 THE LEAGUE, ON A PHONE ─────────────────────────────────────────────
   The same podium and list as the desktop room below, laid out flat: a head,
   three faces, and a list that scrolls, as every screen on a phone does. The
   room adds the sky, the paper card and the columns. */
/* One column since the emblem dragon went (#2082) — the League is drawn
   inside the map, and the map already has a dragon standing at this place. */
.lg-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; align-items: center; }
.lg-title h1 { margin: 0; }
.lg-title .fine { margin: 4px 0 0; }
.lg-left { font-weight: 600; color: var(--seal-text, var(--seal)); }
.lg-mine { grid-column: 1 / -1; display: flex; gap: 8px; margin: 0; }
.lg-mine > div { flex: 1; padding: 8px 12px; border-radius: var(--r-card); background: var(--raised); }
.lg-mine dt { font-size: 11px; color: var(--muted); }
.lg-mine dd { margin: 2px 0 0; font-family: 'Fraunces', Georgia, serif; font-size: 20px; font-weight: 600; }
.lg-podium {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; gap: 10px;
}
.pd { display: flex; flex-direction: column; align-items: center; gap: 3px; text-align: center; min-width: 0; }
.pd-face { position: relative; }
.pd .av { width: 52px; height: 52px; }
.pd1 .av { width: 66px; height: 66px; }
.pd-medal {
  position: absolute; left: 50%; bottom: -8px; translate: -50% 0;
  display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%;
  font-size: 11px; font-weight: 700; color: #1C1E18; background: #E0B94A;
}
.pd2 .pd-medal { background: #AEB4BF; }
.pd3 .pd-medal { background: #C47A3E; }
.pd-name { margin-top: 8px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pd-xp { font-family: 'Fraunces', Georgia, serif; font-weight: 600; }
.pd-xp small, .lg-xp small { font-size: .62em; color: var(--muted); }
/* Today's share under the week, as on every row of the list (#2241). */
.pd-today { font-size: 11px; font-weight: 600; color: var(--jade-ink); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* The streak, smaller than the week and quieter than today (#2432). */
.pd-streak { font-size: 11px; color: var(--fg-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.pd-block { display: none; }
.lg-card { padding: 4px 14px; border-radius: var(--r-panel); background: var(--card); box-shadow: var(--panel); }
.lg-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.lg-row {
  --av: 32px;
  display: grid; align-items: center;
  grid-template-columns: 2.6ch var(--av) minmax(0, 1fr) auto;
  gap: 12px; min-height: 52px;
  box-shadow: inset 0 -1px 0 var(--hair);
}
.lg-row:last-child { box-shadow: none; }
.lg-row .av { width: var(--av); height: var(--av); }
.lg-row .av-none { font-size: calc(var(--av) * .44); }
.lg-rank { font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
.lg-who { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
.lg-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lg-lvl { font-size: .76em; color: var(--muted); white-space: nowrap; }
.lg-xp { font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.lg-row.me { background: color-mix(in srgb, var(--seal) 12%, var(--card)); border-radius: 8px; }
.lg-row.me .lg-name { font-weight: 700; }
/* #2523: a friend or a classmate on the global board, lightly: the raised
   tone, neither green (online) nor seal (you). */
.lg-row.lg-known { background: var(--raised, color-mix(in srgb, var(--fg) 6%, var(--card))); border-radius: 8px; }
.pd.lg-known .lg-name, .pd.lg-known b { text-decoration: underline 2px color-mix(in srgb, var(--fg) 25%, transparent); text-underline-offset: 3px; }
.lg-online-who { font-weight: 600; color: var(--jade-ink); }
/* COLD IS THE FACE, NOT THE WORDS. The whole row at .6 put a quiet player's
   rank and XP at 2.3:1 (#2144): a board is read for exactly those numbers.
   The face goes pale and the name goes quiet, and every figure stays legible. */
.lg-row.cold .av, .pd.cold .pd-face { opacity: .5; }
.lg-row.cold .lg-name, .pd.cold .pd-name { color: var(--muted); }
/* A face nobody has uploaded: an initial on a warm tint rather than the host's
   ink disc, which on the paper card was a row of black holes. */
.lg-row .av-none, .pd .av-none {
  background: #E6DECB; color: #5B4E38; text-shadow: none;
  font-family: 'Fraunces', Georgia, serif; font-weight: 600;
}
.pd .av-none { font-size: 26px; }
.pd1 .av-none { font-size: 32px; }
/* ── 窗 SHEETS ─────────────────────────────────────────────────────────────
   One dialog shape for every sheet the phone opens — a player, the ranks, a
   seal, a chart's explanation. The same raised ground and radius as the ✎
   dialog, a head with the title and a close, and a body that scrolls inside
   the dialog rather than past it. */
.sheet {
  width: min(94vw, 460px); max-height: min(86vh, 760px); padding: 0; border: 0;
  border-radius: var(--r-panel); background: var(--raised); color: var(--fg);
  box-shadow: 0 30px 80px -30px rgba(0, 0, 0, .6);
}
.sheet-wide { width: min(94vw, 640px); }
.sheet::backdrop { background: rgba(0, 0, 0, .5); }
/* ── A SHEET IS PAPER IN ANY ROOM ─────────────────────────────────────────
   Each place re-declares the tokens on #view — the Progress wall's `--raised`
   is a ten-percent wash so its cards read as plaster — and a sheet opened
   from inside #view inherited them: the twenty-four hours behind the golden
   hour opened as a tinted pane with the wall's own cards showing through the
   bars (#2075). The document's values are taken once, on :root, where a
   custom property resolves, and every sheet puts them back. */
:root {
  --sheet-raised: var(--raised); --sheet-fg: var(--fg); --sheet-fg-2: var(--fg-2);
  --sheet-muted: var(--muted); --sheet-hair: var(--hair); --sheet-sunken: var(--bg-sunken);
  --sheet-card: var(--card);
}
.sheet {
  --raised: var(--sheet-raised); --fg: var(--sheet-fg); --fg-2: var(--sheet-fg-2);
  --muted: var(--sheet-muted); --hair: var(--sheet-hair); --bg-sunken: var(--sheet-sunken);
  --card: var(--sheet-card);
}
.sheet-in { display: flex; flex-direction: column; gap: 12px; padding: 20px 22px 24px; }
.sheet-head { display: flex; align-items: center; gap: 12px; }
.sheet-head h2 { margin: 0; flex: 1; font-size: 19px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.sheet-x {
  width: 32px; height: 32px; border-radius: 50%; border: 0; cursor: pointer;
  font: inherit; font-size: 20px; line-height: 1; color: var(--fg-2); background: var(--bg-sunken);
}
.sheet-x:hover { color: var(--fg); }
.sheet .fine { margin: 0; color: var(--muted); }
.sheet h3.sub { margin: 8px 0 0; }

/* ── 阶 THE TIER, UNDER THE LEAGUE'S NAME ──────────────────────────────────
   A button, because it opens the ranks and prices. The bar fills from the
   current rung's floor — see league.js/rankProgress. */
.lg-tier {
  display: grid; gap: 2px; justify-items: start; margin-top: 8px; padding: 8px 12px;
  border: 0; border-radius: var(--r-card); cursor: pointer; text-align: left;
  font: inherit; color: inherit; background: color-mix(in srgb, var(--raised) 80%, transparent);
}
.lg-tier b { font-family: 'Fraunces', Georgia, serif; font-size: 16px; }
.lg-tier .fine { margin: 0; }
.lg-tier-more { font-size: var(--fs-fine); font-weight: 600; color: var(--seal-text, var(--seal)); }
.lg-meter { display: block; width: 160px; max-width: 100%; height: 4px; border-radius: 2px; background: var(--bg-sunken); overflow: hidden; }
.lg-meter i { display: block; height: 100%; background: var(--seal); border-radius: 2px; }
.lg-tabs { display: flex; gap: 6px; }
.lg-group { margin: 0; color: var(--muted); }
.lg-score { display: flex; flex-direction: column; align-items: flex-end; line-height: 1.15; }
.lg-today { font-size: 11px; font-weight: 600; color: var(--jade-ink); font-variant-numeric: tabular-nums; }
.lg-row[role="button"], .pd[role="button"] { cursor: pointer; }
/* A row that opens a card has to look like it does (#2063): the phone's rows
   are buttons with a press state, and a desk's version of that is hover. */
.lg-row[role="button"] { transition: background-color .15s; }
.lg-row[role="button"]:hover { background-color: color-mix(in srgb, var(--fg) 6%, transparent); }
.pd[role="button"] .pd-face { transition: transform .15s; }
.pd[role="button"]:hover .pd-face { transform: translateY(-2px); }
.lg-row[role="button"]:focus-visible, .pd[role="button"]:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; }
.lg-pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.lg-pager .chip:disabled { opacity: .4; cursor: default; }

/* The player card: a face, where they stand, and seven figures in two rows. */
.pc { display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; }
.pc-face .av { width: 96px; height: 96px; font-size: 40px; }
.pc-rank { margin: 0; color: var(--seal-text, var(--seal)); font-size: var(--fs-fine); }
.pc-figs { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; width: 100%; margin: 0; gap: 6px; }
.pc-figs div { display: flex; flex-direction: column-reverse; gap: 2px; }
.pc-figs dd { margin: 0; font-family: 'Fraunces', Georgia, serif; font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; }
.pc-figs dt { font-size: 11px; color: var(--muted); }
.pc-gap { margin: 4px 0 0; font-weight: 600; color: var(--seal-text, var(--seal)); }
.pc-gap.ahead { color: var(--jade-ink); }

/* The ranks and the price list: a name on the left, a number on the right. */
.rk { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.rk li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 6px 0; box-shadow: inset 0 -1px 0 var(--hair); }
.rk li:last-child { box-shadow: none; }
.rk li.on span { color: var(--seal-text, var(--seal)); font-weight: 600; }
.rk li span small { display: block; font-size: 11px; color: var(--muted); }
.rk b { font-variant-numeric: tabular-nums; white-space: nowrap; }
.rk b small { font-size: .7em; color: var(--muted); }

/* 龙 Your dragon (#2291): the head at the top right of the League, and the
   sheet it opens, the drawing over one row per thing it wears. The tabs and
   the head share the head's right column, so the grid above keeps its two. */
.lg-head-r { display: flex; align-items: center; gap: 10px; justify-self: end; }
.lg-dragon-open {
  display: inline-grid; place-items: center; width: 38px; height: 38px; padding: 0;
  border-radius: 10px; background: var(--card); border: 1px solid var(--hair);
  cursor: pointer; transition: transform .15s;
}
.lg-dragon-open:hover { transform: translateY(-1px); }
.lg-dragon-open:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; }
.lg-dragon-open img { display: block; width: 26px; height: 26px; object-fit: contain; }
.dg-art { display: flex; justify-content: center; }
.dg-art .dragon { width: min(300px, 100%); height: auto; }
.dg-legend { list-style: none; margin: 0; padding: 0; }
.dg-legend li { display: flex; align-items: center; gap: 12px; padding: 10px 0; box-shadow: inset 0 -1px 0 var(--hair); }
.dg-legend li:last-child { box-shadow: none; }
.dg-sw { display: inline-grid; place-items: center; width: 22px; }
.dg-l { flex: 1; }
.dg-legend b { font-variant-numeric: tabular-nums; font-size: 16px; }
.dg-body { display: block; width: 14px; height: 14px; border-radius: 50%; }
.dg-scale { display: block; width: 11px; height: 11px; border: 1px solid; transform: scale(.8, 1.15) rotate(45deg); }

/* 周 The week that ended: what the dragon says, the XP, and the podium. */
.wk .sheet-in { align-items: center; text-align: center; }
.wk-say { margin: 4px 0 0; font-family: 'Fraunces', Georgia, serif; font-size: 20px; }
.wk-xp { margin: 0; font-family: 'Fraunces', Georgia, serif; font-size: 32px; font-weight: 600; color: var(--seal-text, var(--seal)); }
.wk-rows { list-style: none; margin: 6px 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: 6px; }
.wk-row {
  display: grid; grid-template-columns: 2ch 30px minmax(0, 1fr) auto; align-items: center; gap: 10px;
  padding: 8px 12px; border-radius: var(--r-chip); background: var(--card, var(--bg-sunken)); text-align: left;
}
.wk-row .av { width: 30px; height: 30px; font-size: 13px; }
.wk-row.me { background: color-mix(in srgb, var(--seal) 14%, var(--raised)); }
.wk-row.m1 .wk-n { color: #D4AF37; }
.wk-row.m2 .wk-n { color: #9AA0AA; }
.wk-row.m3 .wk-n { color: #CD7F32; }
.wk-n { font-weight: 700; text-align: right; }
.wk-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wk-gap { color: var(--muted); }
/* ── 案 THE CONTROL RAIL ───────────────────────────────────────────────────
   Which shelf on the left, which phase on the right, and the tally at the end
   of the phases — it is the result of that filter and of nothing else on the
   row. They were three rows down the page with the grid they are about a
   hundred and eighty pixels below them. */
/* ── 找 THE SEARCH FIELD ───────────────────────────────────────────────────
   A glyph, a field, and nothing else. It sits at the top of the room because
   it is the one control here that ignores every other control: the level, the
   segment and the phase all narrow what you are looking at, and this cuts
   across all of them.

   The native search affordances are turned off — Safari draws its own round
   field and its own clear button, and both are the operating system's design
   sitting in the middle of a library. */
.lib-find {
  display: flex; align-items: center; gap: var(--s3);
  padding: 10px var(--s4); border-radius: var(--r-chip);
  background: var(--raised);
  box-shadow: inset 0 0 0 1px var(--hair);
}
.lib-find-g { font-family: 'Noto Serif SC', serif; font-size: 16px; color: var(--muted); }
.lib-find input {
  flex: 1 1 auto; min-width: 0;
  border: 0; background: none; color: var(--fg);
  font: inherit; font-size: var(--fs-small);
  -webkit-appearance: none; appearance: none;
}
.lib-find input::-webkit-search-decoration,
.lib-find input::-webkit-search-cancel-button { -webkit-appearance: none; }
.lib-find input::placeholder { color: var(--muted); }
.lib-find input:focus { outline: none; }
.lib-find:focus-within { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 26%, transparent); }
.lib-hits { display: flex; flex-direction: column; gap: var(--s4); }
.lib-hits .sub { margin: 0; }
.lib-hits .sub .fine { margin-left: var(--s3); }
.lib-browse { display: flex; flex-direction: column; gap: var(--s6); }

.lib-rail { display: flex; flex-wrap: wrap; gap: var(--s4) var(--s5); align-items: center; }
.lib-rail .lsegs, .lib-rail .lchips { margin: 0; }
.lib-rail .lchips { flex: 1 1 auto; }
.lcount { margin: 0; white-space: nowrap; }

/* ── The Library ──────────────────────────────────────────────────────────── */
/* ── NINE LEVELS IN ONE ROW, WHICH MEANS SCROLLING ────────────────────────
   Wrapped, nine level chips are two rows on a phone — and they sit under two
   rows of segment chips and above two rows of stage chips, so the Library
   opened on five rows of controls and no characters. Five rows of filters
   before any content is a filing cabinet.

   One row that scrolls sideways is what every app does with a long chip rail,
   and it is honest here because the chips are ORDERED: 1 to 9, so a reader
   knows what is off the right-hand edge without seeing it. Snapped, so a flick
   lands on a chip rather than between two, and the scrollbar is hidden because
   a 3px bar under a row of pills is the only thing on the screen that looks
   like a scrollbar. */
.levels {
  display: flex; gap: var(--s2); overflow-x: auto;
  scroll-snap-type: x proximity; scrollbar-width: none;
  padding-bottom: 2px;
}
.levels::-webkit-scrollbar { display: none; }
.level {
  display: flex; align-items: baseline; gap: 6px; flex: 0 0 auto;
  scroll-snap-align: start;
  padding: 8px 14px; border-radius: var(--r-chip); text-decoration: none;
  background: var(--raised); color: var(--fg-2);
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.level b { font-weight: 600; color: var(--fg); }
.level span { font-size: var(--fs-fine); color: var(--muted); }
.level:hover { border-color: color-mix(in srgb, var(--fg) 26%, transparent); }
.level.on { background: var(--seal); border-color: var(--seal); }
.level.on b, .level.on span { color: var(--seal-on); }

/* ══ 书架 THE LIBRARY'S RACK ═══════════════════════════════════════════════
   The camp's lesson on the second-biggest screen here: the controls should BE
   the drawing. The pagoda on the map has storeys, the Library has levels, and
   a storey is a shelf — so the nine tabs are one rack with nine compartments
   under the pagoda's own eave. views.js has the argument and
   docs/learn-places.md has the system this is the first of.

   Only the Library. `.levels` is Grammar's row as well and Grammar has between
   two and seven of them depending on what the syllabus teaches where, so a
   frame built for nine would be a frame with holes in it. */
.rack { display: grid; }
.rack-eave {
  display: block; height: 17px;
  color: color-mix(in srgb, var(--fg) 58%, transparent);
}
.rack-eave svg { width: 100%; height: 100%; display: block; }
.levels-rack {
  gap: 0;
  overflow: visible;
  padding: 4px;
  border-radius: 0 0 5px 5px;
  background: color-mix(in srgb, var(--fg) 8%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 10%, transparent);
}
/* Nine equal compartments. `flex: 1 1 0` and not `auto`: a rack whose boxes
   are the width of their contents is a rack that shifts every time a level's
   character count gains a digit. */
.levels-rack .level {
  flex: 1 1 0;
  justify-content: center;
  border-radius: 3px;
  background: var(--card);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent);
}
.levels-rack .level + .level { margin-left: 3px; }
/* The open compartment, and the specificity is load-bearing: `.level.on` is
   (0,2,0) and so is `.levels-rack .level`, so the plain compartment's ground
   was winning on source order and the level you were looking at came out
   white on white. */
.levels-rack .level.on {
  background: var(--seal);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 60%, transparent);
}
/* ── AND THE ONE THAT IS OPEN IS LIT ──────────────────────────────────────
   At night the camp's doorways go warm and its lanterns come on; this is the
   same hour reaching the same idea one screen over. The compartment you are
   looking into is the only lit thing on the shelf, which is what a lamp held
   up to a rack actually does. */
body[data-phase="night"] .levels-rack .level.on {
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--seal) 50%, transparent),
              0 0 14px color-mix(in srgb, var(--seal) 45%, transparent);
}
/* On a phone nine compartments are thirty-eight pixels each and the counts
   stop fitting, so the rack comes apart into the row of chips it was — the
   same degrade the camp makes at the same width. */
@media (max-width: 743px) {
  .rack-eave { display: none; }
  .levels-rack {
    gap: var(--s2); padding: 0; background: none; box-shadow: none;
    overflow-x: auto;
  }
  .levels-rack .level {
    flex: 0 0 auto; border-radius: var(--r-chip);
    background: var(--raised); box-shadow: none;
  }
  .levels-rack .level + .level { margin-left: 0; }
  .levels-rack .level.on { background: var(--seal); }
}

/* ── 小 SMALLER, BECAUSE A LEVEL IS THREE HUNDRED OF THEM ─────────────────
   *"make the character cards smaller."* At 112px a level of 284 characters is
   a wall of tiles a page and a half long, and the cell was 13px of padding
   round a 30px glyph — a poster for one character, printed 284 times.

   92px fits two more per row at every width this grid is drawn at, and nothing
   in the cell got harder to read: the glyph is still the largest thing in it
   by a factor of two and a half. The phone's tile is about 84pt wide, so this
   is the app's size rather than a new one. */
.grid { display: grid; gap: 6px; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); }
/* ── WHAT THE STATES OF A CELL MEAN ───────────────────────────────────────
   Nothing        not in the deck at all.
   .seeded        came with a placement and has never been answered. FAINT, and
                  deliberately not a shade of known: a seed is carried, not
                  learned, and colouring it as partial knowledge would be the
                  app claiming somebody knows something they have never been
                  asked.
   .r1 .r2        answered, and currently under the 80 per cent bar.
   .r3 .r4        over it, right now. Not a stored flag: it is a function of
                  the clock, so this square can change colour overnight.

   ── WHY FOUR STEPS AND NOT TWO ──────────────────────────────────────────
   It was `.met` and `.known`, two states cut at 0.8. That threshold has not
   moved and must not: `HAS_IT` in views.js is the same 0.8 the app and the
   Teaching Platform call knowing something, and the count in the lead line
   still comes from it. What changed is only what the square SHOWS. The
   underlying number is a retrievability between 0 and 1 — one quantity on one
   axis — and docs/brand.md keeps a sequential ramp for exactly that. Drawing
   it as a coin flip threw away the difference between a character that is
   about to go and one that is solid, which is the difference a learner
   scanning a level is actually looking for.

   The 0.8 line stays legible because it is where the ramp crosses from the two
   dim steps to the two bright ones. It is a boundary in the ramp, not a
   boundary the ramp replaced.

   ── AND WHY THESE READ --growth-* AND NOT --t3 ──────────────────────────
   They resolve to the same green, and docs/brand.md says that collision is
   accepted on purpose: tone colour is ink on a syllable, growth is a fill
   behind a square, and these squares are the second thing. But the token a
   rule reads is a statement about what it means. Reading --t3 here said the
   Library was showing a TONE, on a screen that also shows real tones in the
   pinyin under every glyph, and the next person to restyle the tone scale
   would have repainted the mastery grid without knowing it. */
.cell {
  display: flex; flex-direction: column; align-items: center; gap: 1px;
  padding: 9px 6px; border-radius: var(--r-card); text-decoration: none;
  background: var(--raised); color: var(--fg);
  transition: background var(--fast) var(--ease);
}
.cell:hover { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
.cell-h { font-family: 'Noto Serif SC', serif; font-size: 25px; line-height: 1.12; }
.cell .py { font-size: 11px; }
/* A polyphone's "+1" beside its taught pinyin (#2425): the tile's quietest
   type, raised a little, so it reads as a footnote to the reading. */
.cell-py { display: inline-flex; align-items: baseline; gap: 2px; }
.cell-more { font-size: 11px; font-weight: 600; color: var(--muted); position: relative; top: -3px; }
/* On a coloured tile it takes the meaning's ink, which is what keeps that
   line at AA on the four phase fills. */
.cell:is(.ph-ready, .ph-learning, .ph-learned) .cell-more { color: var(--fg-2); }
:root[data-theme="dark"] .cell:is(.ph-ready, .ph-learning, .ph-learned) .cell-more {
  color: color-mix(in srgb, var(--fg-2) 50%, var(--fg));
}
.cell-m {
  font-size: 10px; color: var(--muted); text-align: center;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.cell.seeded { opacity: .55; }

/* ── WHY THE RAMP IS A BAR AND NOT THE FILL ───────────────────────────────
   Four tints behind the glyph was the obvious way to draw this and it does not
   survive either ground. The fill has to stay weak because a 30px hanzi sits on
   it and the hanzi is the thing being read — and at the strength that keeps
   --fg legible, the four paper steps (#C9D9C2 -> #5FA86A) are barely tellable
   apart across a grid. Mixing harder fixes paper and breaks ink: --growth-4 at
   70 per cent behind --ink-fg measures about 2.6:1, so the price of a visible
   ramp was an unreadable character.

   So the fill answers the ONE question that has to survive at a glance — known
   or not, the 0.8 line, the same two states this grid always had — and the ramp
   moves to a 3px bar along the bottom edge, where it is at full strength with
   nothing on top of it. Four levels are legible because a solid colour with no
   text over it can be. `inset` rather than a child element: the cell keeps its
   own box, the grid keeps its row heights, and nothing new has to be escaped.

   The bar is also where the eye already is. It sits directly under the meaning
   line, so scanning a level reads glyph, pinyin, meaning, then how well — which
   is the order somebody asks those questions in. */
/* ── AND THE OUTLINE IS GONE WITH EVERY OTHER OUTLINE ────────────────────
   The two bright steps also carried a green BORDER, so a level of known
   characters was a grid of two hundred and forty outlined boxes in two greens —
   which is the pattern this whole pass is about, at its worst density. The
   fill and the bar say the same two things without it: the fill answers known
   or not, the bar answers how well. */
/* ── AND THE BAR IS THE CARD'S OWN COLOUR ─────────────────────────────────
   *"make the yellow cards just yellow."* The ramp was four steps of GREEN,
   which is right on a green card and is a green line under a gold one — two
   hues on one tile, and the tile is 92px wide. A learning card with a jade
   edge reads as half-learned, which is a claim the bar is not making.

   So the strength is the ramp and the HUE is the phase: `--ph` is set by the
   phase class below, and the four steps are four strengths of it. One colour
   per card, and the bar still answers the question it was drawn for — a
   learned character can be dim, and that is the most useful cell on the
   screen. */
.cell { --ph: var(--muted); }
.cell.r1 { box-shadow: inset 0 -3px 0 color-mix(in srgb, var(--ph) 28%, transparent); }
.cell.r2 { box-shadow: inset 0 -3px 0 color-mix(in srgb, var(--ph) 48%, transparent); }
.cell.r3 { box-shadow: inset 0 -3px 0 color-mix(in srgb, var(--ph) 72%, transparent); }
.cell.r4 { box-shadow: inset 0 -3px 0 var(--ph); }

/* ── The segments and the stages (#1947) ──────────────────────────────────
   Two rows of controls that do different things and must not look alike: the
   segment picks WHAT you are browsing and the stage filters it. So a segment
   is a tab with a glyph and a total, and a stage is a pill with a coloured dot.

   The dot colours are the app's `StageDot.colour` and they are the four this
   product already owns: gold `--t2` for what is next, blue `--t4` for what is
   in progress, jade for what is held, and muted for what has not been offered.
   Four states, no new hues. */
/* The segments scroll for the same reason, on the same rail. Four of them fit
   on a laptop and do not on a 390px screen once "in the works" is on one. */
.lsegs {
  display: flex; gap: var(--s2); overflow-x: auto;
  scroll-snap-type: x proximity; scrollbar-width: none;
  margin-bottom: var(--s4); padding-bottom: 2px;
}
.lsegs::-webkit-scrollbar { display: none; }
.lseg { flex: 0 0 auto; scroll-snap-align: start; }
.lseg {
  display: inline-flex; align-items: center; gap: var(--s2);
  padding: 9px 15px; border-radius: var(--r-chip);
  background: var(--raised); border: 0; color: var(--fg-2);
  font: inherit; font-size: var(--fs-fine); font-weight: 500; cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.lseg:hover:not(.lseg-off) { color: var(--fg); }
.lseg.on { background: var(--fg); color: var(--bg); }
.lseg.on .lseg-n { color: var(--bg); opacity: .65; }
.lseg.on .lseg-g { color: var(--bg); }
.lseg-g { font-family: 'Noto Serif SC', serif; font-size: 15px; }
.lseg-n { color: var(--muted); font-variant-numeric: tabular-nums; }
/* Not built yet, and it says so instead of being absent — a segment nobody can
   see is a segment a learner concludes was cut. Dashed, like the Training rows,
   and for the same reason: this is "not here yet", not "not allowed". */
.lseg-off { background: none; box-shadow: inset 0 0 0 1px var(--hair); cursor: default; color: var(--muted); }
.lseg-off .lseg-n { font-style: italic; }

/* Five stage chips, on the same rail as the segments and the levels: the
   Library's three rows of controls are three ROWS now rather than five, which
   is the difference between opening on filters and opening on characters. */
.lchips {
  display: flex; gap: var(--s2); overflow-x: auto;
  scroll-snap-type: x proximity; scrollbar-width: none;
  margin: var(--s4) 0 var(--s2); padding-bottom: 2px;
}
.lchips::-webkit-scrollbar { display: none; }
.lchip { flex: 0 0 auto; scroll-snap-align: start; }
.lchip {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 7px 14px; border-radius: var(--r-pill);
  background: var(--raised); border: 0; color: var(--fg-2);
  font: inherit; font-size: var(--t-label); font-weight: 500; cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.lchip:hover:not(:disabled) { color: var(--fg); background: color-mix(in srgb, var(--fg) 12%, var(--raised)); }
.lchip.on { background: var(--fg); color: var(--bg); }
.lchip.on .lchip-n { color: var(--bg); opacity: .7; }
/* A stage at zero is DISABLED rather than hidden: a learner needs to see that
   "learned" exists and stands at nothing, which is a different fact from the
   filter not being offered at all. */
.lchip:disabled { opacity: .45; cursor: default; }
.lchip-n { color: var(--muted); font-variant-numeric: tabular-nums; }

/* ── THE DOTS WERE THE WRONG WAY ROUND ───────────────────────────────────
   `StageDot.colour(for:)` is tone 4 for ready and tone 2 for learning — blue
   then gold — and the note two screens up in this file says so. These two were
   swapped, which nobody could see while the only place the phase appeared was
   a dot; the moment the card took the colour, the chip over the grid and the
   cards under it disagreed about the same four words. */
.dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
.d-ready { background: var(--t4); }
.d-learning { background: var(--t2); }
.d-learned { background: var(--jade); }
.d-none { background: var(--muted); opacity: .45; }

/* THE DOT IS GONE FROM THE CHARACTER CELL. *"no dots on them, just card
   background."* The card's own colour is the phase now, and a dot repeating
   what the card already says is the second mark feedback #1953 asked to be rid
   of. The word and grammar ROWS keep theirs: a row has no card to colour. */
.cell { position: relative; }

/* ── 词 A WORD IS A WIDE CARD ──────────────────────────────────────────────
   *"make the word cards prettier, easier to see."* They were rows: a dot, the
   word, its reading and its gloss on one line, on no ground at all — so five
   hundred of them were five hundred lines of text with a coloured pip at the
   left margin, and the pip was the only thing carrying the phase.

   Not a 92px tile, which is what the note here used to argue and it was right:
   four characters plus a reading plus a gloss is three lines of ellipsis at
   that width. A WIDE card instead, two or three to a row, with the word and
   its reading on the first line and the gloss under it — so the card is a
   card, the phase is the card's colour the way it is on a character, and the
   dot goes.

   Grammar points stay rows. A point is a sentence-length title and nothing
   else; there is no second line to give a card a shape. */
.wgrid { display: grid; gap: 6px; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); }
.ggrid { display: flex; flex-direction: column; gap: 2px; }
.wcell {
  --ph: var(--muted);
  display: flex; flex-direction: column; gap: 2px;
  padding: 10px 13px; border-radius: var(--r-card);
  text-decoration: none; color: var(--fg);
  background: var(--raised);
  transition: background var(--fast) var(--ease);
}
.wcell-top { display: flex; align-items: baseline; gap: var(--s3); min-width: 0; }
.wcell.ph-ready { --ph: var(--t4); }
.wcell.ph-learning { --ph: var(--t2); }
.wcell.ph-learned { --ph: var(--growth-4); }
.wcell.ph-ready, .wcell.ph-learning, .wcell.ph-learned {
  background: color-mix(in srgb, var(--ph) 22%, var(--card));
  outline: 1px solid color-mix(in srgb, var(--ph) 55%, transparent);
  outline-offset: -1px;
}
.wcell:hover { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
.wcell.ph-ready:hover, .wcell.ph-learning:hover, .wcell.ph-learned:hover {
  background: color-mix(in srgb, var(--ph) 32%, var(--card));
}
.gcell {
  display: flex; align-items: baseline; gap: var(--s3);
  padding: 10px 13px; border-radius: var(--r-chip);
  text-decoration: none; color: var(--fg);
  transition: background var(--fast) var(--ease);
}
.gcell:hover { background: var(--raised); }
.gcell .dot { align-self: center; }
.wcell-h { font-family: 'Noto Serif SC', serif; font-size: 21px; line-height: 1.15; flex: none; }
.wcell-p { font-size: var(--fs-small); flex: 0 1 auto; min-width: 0; }
/* 自行车 on one line (#2195, #2196): three characters a size down, four two
   sizes, and the reading may wrap only at the <wbr> between two syllables. */
.wcell-h[data-n="3"] { font-size: 18px; }
.wcell-h[data-n="4"] { font-size: 16px; }
.wcell .cell-m, .gcell-t {
  color: var(--fg-2); font-size: var(--fs-fine); min-width: 0; text-align: left;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ── WHICH PHASE, ON THE WHOLE CARD ───────────────────────────────────────
   Feedback #1953, "make always the border or card in the colour and not just
   the point", and the phase half of #1947. The app's four states in the app's
   own colours — `LibraryView.colour(for:)` is tone 4 for ready, tone 2 for
   learning, affirm green for learned — so the two products read the same.

   BORDER at full strength, fill at 10 per cent. The note above this applies
   unchanged: a 30px hanzi sits on the fill and is the thing being read, so the
   fill cannot carry the colour and the border can. `ph-none` is deliberately
   nothing: three hundred tiles all wearing a colour is a grid with no signal
   in it, and "not started" is the default state of most of the level.

   These come AFTER the ramp rules on purpose — same specificity, later wins,
   so the phase owns the border and the ramp keeps the bar it was given. */
/* ── THE RING IS AN `outline` AND NOT A `box-shadow` ──────────────────
   The ramp's four steps are already an inset box-shadow — the 3px bar along
   the bottom edge — and these rules come later with the same specificity, so
   a second box-shadow here does not add a ring, it REPLACES the bar. An
   outline drawn inward is the same picture and cannot collide. */
/* ── ONE HUE PER CARD ─────────────────────────────────────────────────────
   The fill, the ring and the ramp bar all read `--ph`, so a card is its phase
   and nothing else: blue is ready, gold is learning, jade is learned, and the
   card that has not been offered yet keeps the plain raised ground.

   THE RING IS AN `outline` AND NOT A `box-shadow`. The ramp's four steps are
   an inset box-shadow — the 3px bar along the bottom edge — and these rules
   come later with the same specificity, so a second box-shadow here would not
   add a ring, it would replace the bar. An outline drawn inward is the same
   picture and cannot collide. */
.cell.ph-ready { --ph: var(--t4); }
.cell.ph-learning { --ph: var(--t2); }
.cell.ph-learned { --ph: var(--growth-4); }
.cell.ph-ready, .cell.ph-learning, .cell.ph-learned {
  background: color-mix(in srgb, var(--ph) 22%, var(--card));
  outline: 1px solid color-mix(in srgb, var(--ph) 55%, transparent);
  outline-offset: -1px;
}
/* The quiet lines on a phase tile — the meaning, the date a mark is due —
   were `--muted` on the 22% wash: 4.4:1 on paper and 3.1 on ink (#2144).
   One step up on paper, and on ink half-way to the glyph's own colour,
   because the ink wash is the lighter of the two. */
:is(.cell, .wcell, .set-a):is(.ph-ready, .ph-learning, .ph-learned) :is(.cell-m, .mk-d, .fine) {
  color: var(--fg-2);
}
:root[data-theme="dark"] :is(.cell, .wcell, .set-a):is(.ph-ready, .ph-learning, .ph-learned) :is(.cell-m, .mk-d, .fine) {
  color: color-mix(in srgb, var(--fg-2) 50%, var(--fg));
}

/* ── One character ────────────────────────────────────────────────────────── */
/* ── THE CHARACTER IS THE PAGE ────────────────────────────────────────────
   This screen had a 168px tile beside a 44px repeat of the same character and
   four grey lines — the glyph and its own echo, competing, with the actual
   subject at a quarter of the size a museum label would give it. It is a
   DETAIL page for one character: that character should be the largest thing a
   reader has seen on this host, and everything else should be a caption to it.

   So the tile grows to 224 and the duplicate `h1` goes to the pinyin's size and
   steps aside — it is there for a screen reader and for search, not to be read
   twice. The facts under it become one line of separated clauses rather than
   four stacked grey sentences, which is what a caption is.

   No box around the text column. The tile is the object; text beside an object
   does not need a second object around it. */
.char-head {
  display: flex; gap: var(--s6); align-items: center; flex-wrap: wrap;
  padding: var(--s3) 0 var(--s4);
}
.glyph {
  width: clamp(168px, 18vw, 224px); aspect-ratio: 1; flex: 0 0 auto;
  display: grid; place-items: center;
  background: var(--card); border-radius: var(--r-panel);
  box-shadow: var(--panel);
  position: relative; overflow: hidden;
}
/* The same ink-wash bloom the review card carries, for the same reason: it is
   what makes a large empty surface read as paper rather than as a hole. */
.glyph::before {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(58% 52% at 50% 42%,
    color-mix(in srgb, var(--seal) 9%, transparent), transparent 70%);
}
.glyph-plain { font-family: 'Noto Serif SC', serif; font-size: var(--hz-hero); position: relative; }
.char-id { display: flex; flex-direction: column; gap: var(--s1); min-width: 0; }
.char-id h1 {
  font-family: 'Noto Serif SC', serif; font-size: 24px; line-height: 1.1;
  color: var(--fg-2); font-weight: 500;
}
.char-py { margin: 0; font-size: 26px; }
.char-m {
  margin: var(--s2) 0 0; color: var(--fg); max-width: 46ch;
  font-family: 'Fraunces', Georgia, serif; font-size: 21px; line-height: 1.35;
}
/* The three facts as one line, separated by a thin rule rather than by
   returns: they are the same KIND of fact and stacking them made each look
   like a paragraph. */
.char-id .fine { margin: var(--s3) 0 0; }
.char-id .fine + .fine {
  margin-top: var(--s1);
}

/* ── The star ─────────────────────────────────────────────────────────────
   Gold when it is on, because gold on this palette is `--t2` and already means
   "next" — it is the Library's own colour for a row that is ready. Muted when
   it is off, so an unstarred character does not carry a bright empty shape
   asking to be pressed.

   It is a big target for a small mark: the outline is 22px and the button is
   36, because it sits beside a 96px glyph and next to a link on the starred
   list, and a thumb should not be able to open the character by aiming at it. */
.star {
  display: inline-grid; place-items: center;
  width: 36px; height: 36px; padding: 0; margin-left: var(--s2);
  border-radius: var(--r-pill); background: none; cursor: pointer;
  color: var(--muted); font-size: 20px; line-height: 1;
  vertical-align: middle;
  transition: color var(--fast) var(--ease), background var(--fast) var(--ease);
}
.star:hover { background: var(--raised); color: var(--fg-2); }
.star.on { color: var(--t2-ink); }
.star.on:hover { color: var(--t2-ink); }
.opt-r .star { margin-left: var(--s3); }

/* ── The strokes write themselves ─────────────────────────────────────────
   Each path takes its turn, in writing order, staggered off its own index.
   THE PATHS ARE FILLED OUTLINES AND NOT CENTRE LINES, so the usual
   stroke-dasharray trace does not apply here: a dash pattern on an outline
   runs around its rim instead of along its spine. Revealing them in order
   reads as writing because the order IS the writing order.

   `animation-fill-mode: backwards` is what keeps a stroke invisible during its
   own delay; without it every stroke is drawn at once and then re-fades. */
.strokes { width: 148px; height: 148px; }
.strokes path {
  fill: var(--fg);
  opacity: 0;
  animation: stroke-in .34s ease-out calc(var(--i) * .16s) both;
}
@keyframes stroke-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .strokes path { animation: none; opacity: 1; }
}

/* The cast: the components the mnemonic is built from. */
.cast { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.cast li {
  display: flex; align-items: center; gap: 9px;
  padding: 8px 13px; border-radius: var(--r-chip);
  background: var(--raised);
}
.cast-f { font-family: 'Noto Serif SC', serif; font-size: 24px; }
.cast-none { color: var(--muted); }
/* A block with no codepoint, drawn from its own strokes, and the two-glyph
   fallback for a description sequence nobody has picked strokes for —
   `formFace` in views.js, `GlyphBody` in the app (#2057, #2227, #2229). Sized
   in em so every tile that sets a font size sizes these with it. */
.fshape { display: inline-block; width: 1em; height: 1em; vertical-align: -0.12em; fill: currentColor; }
.fids { display: inline-flex; align-items: center; font-style: normal; font-size: .6em; line-height: 1; gap: .04em; }
.fids-v { flex-direction: column; gap: 0; }
.fids i { font-style: normal; }
.cast-m { color: var(--fg-2); font-size: var(--fs-fine); }

/* ── The building blocks of a word, which OPEN ────────────────────────────
   A character card's cast is a component and a meaning and goes nowhere: 彳 is
   not a character anybody has a card for. A word's blocks are its characters
   and every one of them has a page, which is the half feedback #1775 was
   about — *"The building blocks to click on are missing in the word decks"*.
   So these are links wearing the same chip. */
.sec-blocks li { padding: 0; }
.sec-blocks a {
  display: flex; align-items: center; gap: 9px;
  padding: 8px 13px; border-radius: 11px; text-decoration: none; color: inherit;
}
.sec-blocks li:hover { border-color: color-mix(in srgb, var(--fg) 26%, transparent); }
.sec-blocks .cast-m { display: flex; align-items: baseline; gap: 6px; }

/* The story: the meaning scene, then the pronunciation line in italic.
   Washes rather than highlighter — the register is literary, and a neon marker
   would make it read as a worksheet.

   FOUR categories, and they are the app's, not this host's invention:
   `Highlight` in GushiUI/Theme.swift lays down the same four at the same 18%.
   This host used to render every bold run identically, which meant a learner
   who moved between the phone and the browser saw the same story teach four
   things in one colour and four things in four. `.cue` stays for a WORD's
   mnemonic, which is a plain string with `**bold**` in it and has no
   categories to lose. */
.story {
  display: flex; flex-direction: column; gap: 10px;
  padding: 15px 17px; border-radius: var(--r-card);
  background: var(--raised);
  max-width: 68ch;
}
.story p { margin: 0; }
.story .pron { font-style: italic; color: var(--fg-2); }

.hl-meaning, .hl-block, .hl-sound, .cue {
  font-weight: 600; border-radius: 3px; padding: 0 3px;
}
.hl-meaning, .cue { background: color-mix(in srgb, var(--amber) 18%, transparent); }
.hl-block { background: color-mix(in srgb, var(--violet) 18%, transparent); }
.hl-sound { background: color-mix(in srgb, var(--rose) 18%, transparent); }
/* On the warm black 18% disappears (#2397: "Die Markierungsfarben in der
   Story kommen nicht so gut bei dem Dark Mode"). The dark side lays the same
   hues down at 36%, `Highlight.darkWashOpacity` in Theme.swift; paper keeps
   18% and every base hex stays. */
:root[data-theme="dark"] :is(.hl-meaning, .cue) { background: color-mix(in srgb, var(--amber) 36%, transparent); }
:root[data-theme="dark"] .hl-block { background: color-mix(in srgb, var(--violet) 36%, transparent); }
:root[data-theme="dark"] .hl-sound { background: color-mix(in srgb, var(--rose) 36%, transparent); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) :is(.hl-meaning, .cue) { background: color-mix(in srgb, var(--amber) 36%, transparent); }
  :root:not([data-theme="light"]) .hl-block { background: color-mix(in srgb, var(--violet) 36%, transparent); }
  :root:not([data-theme="light"]) .hl-sound { background: color-mix(in srgb, var(--rose) 36%, transparent); }
}

/* The archetype is the exception and stays ink: it IS the character's tone,
   which is the whole of what it is saying. Same rule as the app's. */
.hl-arch { font-weight: 600; }
.hl-arch.t1 { color: var(--t1-ink); } .hl-arch.t2 { color: var(--t2-ink); }
.hl-arch.t3 { color: var(--t3-ink); } .hl-arch.t4 { color: var(--t4-ink); }
.hl-arch.t5 { color: var(--t5-ink); }

/* The words a character appears in are a LIST, and a list of two is not two
   objects. A hairline between them and nothing else — the same grouped-table
   shape the deck rows use, at the smaller scale a sub-list wants. */
.words { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.words > li {
  padding: var(--s4) 0;
}
.words > li + li { box-shadow: inset 0 1px 0 var(--hair); }
.w-top { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; }
.w-h { font-family: 'Noto Serif SC', serif; font-size: 20px; }
.w-m { color: var(--fg-2); font-size: var(--fs-fine); }
.ex { list-style: none; margin: 9px 0 0; padding: 9px 0 0; border-top: 1px solid var(--hair); display: flex; flex-direction: column; gap: 8px; }
.ex li { display: flex; flex-direction: column; gap: 1px; }
.ex-h { font-family: 'Noto Serif SC', serif; }
.ex-p { color: var(--fg-2); font-size: var(--fs-fine); }
.ex-e { color: var(--muted); font-size: var(--fs-fine); }

/* ── The league ───────────────────────────────────────────────────────────── */
.board { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.board li {
  /* Five columns since the pictures landed: rank, face, handle, level, XP. The
     face is `auto` rather than a fixed track so the fallback letter and the
     image occupy the same width without either being padded to a guess.
     Centred rather than baseline-aligned, because an image has no baseline and
     a 24px square hung off one sits high. */
  display: grid; grid-template-columns: 34px auto 1fr auto minmax(48px, 84px) auto;
  gap: 12px; align-items: center;
  padding: 10px 14px; border-radius: var(--r-card);
  background: var(--raised);
}
.board .rank { color: var(--muted); font-size: var(--fs-fine); }
.board .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The reader's own line, marked rather than moved: a leaderboard that lifts you
   out of position has stopped being a leaderboard. */
.board li.me { border-color: var(--seal); background: var(--seal-soft); }

/* ── The gap, which a column of numbers does not show ─────────────────────
   Second place four points behind and second place four hundred behind read
   identically as figures. Scaled to the leader, so the top row is always full
   and every other row is literally how far there is to go. Hidden on a phone,
   where the row has no width to give it. */
.lg-bar { display: block; height: 5px; border-radius: 3px; background: var(--bg-sunken); overflow: hidden; }
.lg-bar span { display: block; height: 100%; background: var(--growth-3); border-radius: 3px; }
.board li.me .lg-bar span { background: var(--seal); }
/* The three that are standing on something. Gold, silver and bronze are the
   dragon's own four hexes minus the red — Dragon.swift is where they are
   settled, and brand.md is why they are not invented twice. */
.board li.podium .rank { font-weight: 600; color: var(--fg); }
.board li.p1 .rank { color: #D4AF37; }
.board li.p2 .rank { color: #B9BEC7; }
.board li.p3 .rank { color: #CD7F32; }
.board li.p1 .lg-bar span { background: #D4AF37; }
.board li.p2 .lg-bar span { background: #B9BEC7; }
.board li.p3 .lg-bar span { background: #CD7F32; }
@media (max-width: 700px) {
  .board li { grid-template-columns: 30px auto 1fr auto auto; }
  .lg-bar { display: none; }
}

/* ── 龙, and the three numbers it is made of ──────────────────────────────
   The drawing beside the standing rather than under it: the app puts the
   dragon at the foot of a phone-width scroll because that is the only room it
   has, and a laptop has a column to spare. Under 760px it goes back to being
   stacked, which is the shape it was designed in.

   `pointer-events: none` on the drawing. It is aria-hidden decoration sitting
   in a flex row, and a decorative layer that can take a click is the bug that
   cost the Teaching Platform its range picker on 2026-09-05. */
.lg-top {
  display: grid; grid-template-columns: minmax(0, 230px) 1fr;
  gap: clamp(14px, 3vw, 30px); align-items: center;
  padding: clamp(14px, 2vw, 22px);
  background: var(--card); border-radius: var(--r-panel);
  box-shadow: var(--panel);
}
.lg-dragon { pointer-events: none; }
.lg-dragon .dragon { display: block; width: 100%; height: auto; }
.lg-standing { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.lg-standing .fine { margin: 0; color: var(--muted); }
.lg-place { display: flex; align-items: baseline; gap: 10px; margin: 0; }
.lg-place-n {
  font-family: 'Fraunces', Georgia, serif; font-size: 44px; font-weight: 600;
  line-height: 1; font-variant-numeric: tabular-nums;
}
.lg-place-k { color: var(--muted); font-size: var(--fs-fine); }
/* A medal tints the number the same way it tints the dragon, so the two are
   obviously one fact rather than two. */
.lg-place.m1 .lg-place-n { color: #D4AF37; }
.lg-place.m2 .lg-place-n { color: #B9BEC7; }
.lg-place.m3 .lg-place-n { color: #CD7F32; }
.lg-figs { display: flex; gap: 26px; flex-wrap: wrap; }
@media (max-width: 760px) {
  .lg-top { grid-template-columns: 1fr; justify-items: center; text-align: center; }
  .lg-dragon { max-width: 210px; }
  .lg-standing { align-items: center; }
}

/* ═══ THE OTHER GROUND ══════════════════════════════════════════════════════
 *
 * Both ramps are already declared in tokens.css: --paper-* at 34-41 and
 * --ink-* at 24-31, with WCAG AA on the ink steps asserted by
 * PaletteTests.swift. What follows redefines only the SEMANTIC tokens, which
 * is why no component rule anywhere above needs to know this block exists.
 *
 * tokens.css is shared with the marketing site and the teaching platform, and
 * neither wants this. That is why it lives here rather than there: the site is
 * a light document on purpose (site.css:44), and this app is the one surface
 * where ink is the design being worked on.
 *
 * THREE STATES, TWO BLOCKS, and the order matters:
 *
 *   1. system dark  the media query, guarded so an explicit "light" beats it
 *   2. explicit     [data-theme="dark"], which must also win over a system
 *                   preference for light, so it is written separately rather
 *                   than folded into the query above
 *
 * There is no third block for "light": paper is what :root already says, so
 * choosing it means removing the attribute and the media guard letting go.
 * theme.js is the other half.
 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: var(--ink);
    --bg-sunken: var(--ink-sunken);
    --card: var(--ink-card);
    --fg: var(--ink-fg);
    --fg-2: var(--ink-fg-2);
    --muted: color-mix(in srgb, var(--ink-muted) 92%, var(--ink-fg));
    --hair: var(--ink-hair);
    --raised: var(--ink-raised);
    /* Ink is the design; these are the values the block at the top of the file
       describes. `--shadow` stays `none` — it is the SITE's shadow and nothing
       on this host reads it any more. */
    --edge: inset 0 1px 0 rgba(255, 255, 255, .045);
    --sink: 0 1px 2px rgba(0, 0, 0, .28), 0 22px 48px -28px rgba(0, 0, 0, .75);
    --seal-text: var(--ink-seal-text);
    --shadow: none;
    --growth-0: var(--growth-ink-0);
    --growth-1: var(--growth-ink-1);
    --growth-2: var(--growth-ink-2);
    --growth-3: var(--growth-ink-3);
    --growth-4: var(--growth-ink-4);
    /* The camp's deck chips: no tile, and the character glows. See `.dchip-g`. */
    --camp-night-tint: #F4F1E9;
    --camp-night-lift: -22;
    --camp-night-tint: #F4F1E9;
  --camp-night-lift: -22;
  --dchip-face: transparent;
    --dchip-face-on: color-mix(in srgb, currentColor 14%, transparent);
    --dchip-ring: none;
    --dchip-ring-on: none;
    --dchip-glow: drop-shadow(0 0 calc(4 * var(--cu)) currentColor)
                  drop-shadow(0 0 calc(12 * var(--cu)) color-mix(in srgb, currentColor 55%, transparent));
    --dchip-glow-on: drop-shadow(0 0 calc(5 * var(--cu)) currentColor)
                     drop-shadow(0 0 calc(18 * var(--cu)) currentColor);
  }
}
:root[data-theme="dark"] {
  --bg: var(--ink);
  --bg-sunken: var(--ink-sunken);
  --card: var(--ink-card);
  --fg: var(--ink-fg);
  --fg-2: var(--ink-fg-2);
  /* 8% of the way to `--ink-fg`. The palette's #948B7A is 5:1 on the page and
     4.4 on `--raised`, the chip and the tile it most often sits on (#2144);
     the hex itself is the brand's and stays (PaletteTests.swift). */
  --muted: color-mix(in srgb, var(--ink-muted) 92%, var(--ink-fg));
  --hair: var(--ink-hair);
  --raised: var(--ink-raised);
  /* Ink is the design; these are the values the block at the top of the file
     describes. `--shadow` stays `none` — it is the SITE's shadow and nothing
     on this host reads it any more. */
  --edge: inset 0 1px 0 rgba(255, 255, 255, .045);
  --sink: 0 1px 2px rgba(0, 0, 0, .28), 0 22px 48px -28px rgba(0, 0, 0, .75);
  --seal-text: var(--ink-seal-text);
  --shadow: none;
  --growth-0: var(--growth-ink-0);
  --growth-1: var(--growth-ink-1);
  --growth-2: var(--growth-ink-2);
  --growth-3: var(--growth-ink-3);
  --growth-4: var(--growth-ink-4);
  --dchip-face: transparent;
  --dchip-face-on: color-mix(in srgb, currentColor 14%, transparent);
  --dchip-ring: none;
  --dchip-ring-on: none;
  --dchip-glow: brightness(1.15)
                drop-shadow(0 0 calc(3 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(10 * var(--cu)) color-mix(in srgb, currentColor 75%, transparent))
                drop-shadow(0 0 calc(22 * var(--cu)) color-mix(in srgb, currentColor 40%, transparent));
  --dchip-glow-mid: brightness(1.3)
                drop-shadow(0 0 calc(4 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(14 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(28 * var(--cu)) color-mix(in srgb, currentColor 55%, transparent));
  --dchip-glow-on: brightness(1.45)
                drop-shadow(0 0 calc(5 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(18 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(34 * var(--cu)) currentColor);
}

/* ── 亮 THE DECK CHARACTERS SHINE AFTER DARK, WHATEVER THE THEME ───────────
   *"Can you make the characters more shiny, glowing, so it is more clear to
   hover over them?"* and again: *"make the characters more shiny so it is
   easier to recognise they are for clicking"* (#2054, #2062).

   Two faults. The glow was a two-layer halo on the DARK THEME only — but the
   camp goes dark at night on the light theme too (`data-phase`), and there
   the chips kept their daytime paper tiles, which on a night hillside are
   dim grey boxes. So the glow now follows the HOUR as well as the theme.

   And a still glow reads as colour, not as something to press. Every chip
   BREATHES now — the halo swells and settles over four seconds, each chip a
   little out of step with its neighbours so the camp shimmers rather than
   blinks — and swells all the way on hover. The one thing on the screen that
   moves like light is the thing to touch. Off under reduced motion. */
body:is([data-phase="dusk"], [data-phase="night"]) .camp {
  --dchip-face: transparent;
  --dchip-face-on: color-mix(in srgb, currentColor 14%, transparent);
  --dchip-ring: none;
  --dchip-ring-on: none;
  --dchip-glow: brightness(1.15)
                drop-shadow(0 0 calc(3 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(10 * var(--cu)) color-mix(in srgb, currentColor 75%, transparent))
                drop-shadow(0 0 calc(22 * var(--cu)) color-mix(in srgb, currentColor 40%, transparent));
  --dchip-glow-mid: brightness(1.3)
                drop-shadow(0 0 calc(4 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(14 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(28 * var(--cu)) color-mix(in srgb, currentColor 55%, transparent));
  --dchip-glow-on: brightness(1.45)
                drop-shadow(0 0 calc(5 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(18 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(34 * var(--cu)) currentColor);
}
/* ── THE BREATH IS A SECOND COPY FADING, NOT A FILTER CHANGING (#2328) ─────
   It was `filter` animated between two lists of drop-shadows. A drop-shadow
   moves pixels, and Chrome will not run an animation of a filter that moves
   pixels on the compositor, so each of the camp's chips was a blur rebuilt on
   the main thread sixty times a second: measured in headless Chrome, the
   chips alone were a quarter of the main thread with the camp standing still.

   Now the chip keeps its resting glow and a copy of it, glyph and tile, lies
   on top with the mid glow already applied, and what animates is that copy's
   OPACITY, which the compositor runs by itself. Half way through the breath
   the copy is fully there, which is the old keyframe at 50%; at the ends it
   is gone, which is the old 0% and 100%.

   ── AND THE COPY IS NOT INSIDE THE THING IT COPIES (#2389) ─────────────
   It was `.dchip-g::after`, a child of the glyph, and the glyph carries a
   `filter`. A filter on the ancestor of a layer the compositor is animating
   cannot be painted once and kept: it has to be applied again on every
   frame, over every chip, and the copy's own drop-shadows with it. Measured
   in Chrome with the GPU on, the camp standing still at night: about 1,150
   render passes and 130 dropped frames a second, the walkers stuttering and
   the glow flickering, which is what Jonas and Paul both reported. Now the
   copy is `.dchip-lit`, a SIBLING laid over the glyph: the element that
   fades has no filter, its filter is on the child inside it and is painted
   into its layer once, and the resting glyph under it is painted once with
   the camp. What the eye gets is the same two glows. */
.dchip-lit { display: none; }
.camp .dchip-lit {
  display: block;
  position: absolute; inset: 0;
  opacity: 0;
  pointer-events: none;
}
.camp .dchip-lit-g {
  display: grid; place-items: center;
  width: 100%; height: 100%;
  border-radius: var(--r-chip);
  font-family: 'Noto Serif SC', serif; font-size: calc(23 * var(--cu)); line-height: 1;
  background-color: var(--dchip-face);
  box-shadow: var(--dchip-ring);
  filter: var(--dchip-glow-mid);
}
.camp .dchip-lit-g.acc-block { color: var(--fg); }
:root[data-theme="dark"] .camp .dchip-lit,
body:is([data-phase="dusk"], [data-phase="night"]) .camp .dchip-lit {
  animation: dchip-breathe 4.2s ease-in-out infinite;
}
.camp .dchip:nth-child(2) .dchip-lit { animation-delay: -1.4s; }
.camp .dchip:nth-child(3) .dchip-lit { animation-delay: -2.8s; }
.camp .dchip:nth-child(4) .dchip-lit { animation-delay: -0.7s; }
.camp .dchip:nth-child(5) .dchip-lit { animation-delay: -2.1s; }
.camp .dchip:hover .dchip-lit, .camp .dchip:focus-visible .dchip-lit { animation: none; }
@keyframes dchip-breathe {
  0%, 100% { opacity: 0; }
  50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .camp .dchip-lit { animation: none !important; }
}

/* ── 亮 AND BY DAY TOO, A LITTLE ──────────────────────────────────────────
   *"lass gerne die charakter auch tagsüber ein bisschen glowen sodass man die
   mal mehr sieht"* (#2123).

   After dark the camp is dark and a character lit in its own colour is the
   brightest thing on it. By day it is not, and on ink it is worse than not:
   the hillside is a pale grey mist, and a pale accent haloed in itself on
   pale grey is a pale smudge — the owner's screenshot has 题 and 耳 all but
   gone. So by day on ink the character sits in a small pool of the drawing's
   own ink first and glows from inside it, which is how a lit sign reads at
   noon; on paper the tile keeps its edge and gains a soft halo of its accent.
   Both breathe, a little, like the night's. */
body:is([data-phase="dawn"], [data-phase="morning"], [data-phase="day"]) .camp {
  --dchip-glow: drop-shadow(0 0 calc(5 * var(--cu)) color-mix(in srgb, currentColor 42%, transparent));
  --dchip-glow-mid: drop-shadow(0 0 calc(8 * var(--cu)) color-mix(in srgb, currentColor 50%, transparent));
  --dchip-glow-on: drop-shadow(0 0 calc(10 * var(--cu)) color-mix(in srgb, currentColor 58%, transparent));
}
:root[data-theme="dark"] body:is([data-phase="dawn"], [data-phase="morning"], [data-phase="day"]) .camp {
  --dchip-glow: brightness(1.3)
                drop-shadow(0 0 calc(.8 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(2 * var(--cu)) rgba(20, 21, 17, .8))
                drop-shadow(0 0 calc(7 * var(--cu)) rgba(20, 21, 17, .45))
                drop-shadow(0 0 calc(12 * var(--cu)) color-mix(in srgb, currentColor 65%, transparent));
  --dchip-glow-mid: brightness(1.4)
                drop-shadow(0 0 calc(1 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(2 * var(--cu)) rgba(20, 21, 17, .8))
                drop-shadow(0 0 calc(7 * var(--cu)) rgba(20, 21, 17, .45))
                drop-shadow(0 0 calc(17 * var(--cu)) color-mix(in srgb, currentColor 85%, transparent));
  --dchip-glow-on: brightness(1.5)
                drop-shadow(0 0 calc(1 * var(--cu)) currentColor)
                drop-shadow(0 0 calc(2 * var(--cu)) rgba(20, 21, 17, .85))
                drop-shadow(0 0 calc(8 * var(--cu)) rgba(20, 21, 17, .5))
                drop-shadow(0 0 calc(20 * var(--cu)) currentColor);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) body:is([data-phase="dawn"], [data-phase="morning"], [data-phase="day"]) .camp {
    --dchip-glow: brightness(1.3)
                  drop-shadow(0 0 calc(.8 * var(--cu)) currentColor)
                  drop-shadow(0 0 calc(2 * var(--cu)) rgba(20, 21, 17, .8))
                  drop-shadow(0 0 calc(7 * var(--cu)) rgba(20, 21, 17, .45))
                  drop-shadow(0 0 calc(12 * var(--cu)) color-mix(in srgb, currentColor 65%, transparent));
    --dchip-glow-mid: brightness(1.4)
                  drop-shadow(0 0 calc(1 * var(--cu)) currentColor)
                  drop-shadow(0 0 calc(2 * var(--cu)) rgba(20, 21, 17, .8))
                  drop-shadow(0 0 calc(7 * var(--cu)) rgba(20, 21, 17, .45))
                  drop-shadow(0 0 calc(17 * var(--cu)) color-mix(in srgb, currentColor 85%, transparent));
    --dchip-glow-on: brightness(1.5)
                  drop-shadow(0 0 calc(1 * var(--cu)) currentColor)
                  drop-shadow(0 0 calc(2 * var(--cu)) rgba(20, 21, 17, .85))
                  drop-shadow(0 0 calc(8 * var(--cu)) rgba(20, 21, 17, .5))
                  drop-shadow(0 0 calc(20 * var(--cu)) currentColor);
  }
}
body:is([data-phase="dawn"], [data-phase="morning"], [data-phase="day"]) .camp .dchip-lit {
  animation: dchip-breathe 4.2s ease-in-out infinite;
}

/* ── A number with a button either side ───────────────────────────────────
   Twenty options is too many for the switch row beside it, and a text field
   invites a number nobody wants to type. The value is set in the serif at
   figure size, because it is the thing being changed and the buttons are not. */
.stepper {
  display: inline-flex; align-items: center; gap: var(--s2);
  background: var(--raised); border-radius: var(--r-pill); padding: 3px;
}
.stepper .ibtn { width: 34px; height: 34px; font-size: 17px; line-height: 1; }
.stepper .ibtn:disabled { opacity: .35; cursor: default; }
.stepper .ibtn:disabled:hover { background: none; }
.step-n {
  min-width: 3.2ch; text-align: center;
  font-family: 'Fraunces', Georgia, serif; font-size: 18px; font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* ── The two controls, wherever they are ──────────────────────────────────
   Language and ground, one above the other, because they are the same kind of
   thing: properties of the person at the keyboard rather than of the page.

   `.side-set` is gone with the sidebar copy of them. They were pinned in the
   foot at all widths above 860px — six buttons and two headings, permanently
   on screen, for two settings somebody sets about once — while the phone
   already showed the same block on Profile because it has no foot to pin them
   in. The phone was right, so Profile is now the one home at every width and
   the foot is an account row that leads there. */
.switch { display: flex; gap: 2px; padding: 2px; border-radius: 10px; background: var(--bg-sunken); }
.switch button {
  flex: 1 1 0; border: 0; background: transparent; color: var(--muted);
  font: inherit; font-size: var(--fs-fine); font-weight: 500;
  padding: 6px 4px; border-radius: 8px; cursor: pointer;
}
.switch button:hover { color: var(--fg); }
.switch button[aria-pressed="true"] { background: var(--card); color: var(--fg); box-shadow: var(--shadow); }

/* ── The settings block on Profile ────────────────────────────────────────
   No media query on it any more: this is where the controls live, at every
   width. The width cap is so two switches do not stretch across a 1080px
   column, which is the one thing that made them look like a form. */
/* ── 块 EVERY SECTION IS A CARD ──────────────────────────────────────────
   *"Gebe den Statistiken Karten, sodass man das einfacher visuell abgrenzen
   kann"* and *"mache diese Abschnitte immer besser als Boxen oder so, dass man
   die Abgrenzung besser sieht"* (#2097, #2099). A hairline between sections is
   the lightest possible divider, and on a Profile nine groups long it
   disappears: the whole screen read as one column of type. Each section is a
   card now — the same paper, edge and shadow this host uses everywhere else
   for "an object you can deal with" — and the hairline goes with it, because
   two dividers is one too many.

   Three selectors, one look: `.screen-set-wrap` is every section the markup
   wraps, `.pf-snap` is the reading/listening snapshot and `.pf-figures` the
   row of counts under it — those two predate the wrapper and are left where
   they are rather than re-nested. */
.screen-set-wrap,
.profile .pf-snap,
.profile .pf-figures {
  border-top: 0; padding: var(--s5);
  border-radius: var(--r-panel);
  background: var(--card);
  box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 7%, transparent);
}
/* Only the wrapper needs a layout of its own — the other two already have one
   (a grid of counts, a column of meters) and keep it. */
.screen-set-wrap { display: flex; flex-direction: column; gap: 12px; }
.screen-set-wrap > .sub { margin: 0; }
/* Cards need air between them where a hairline needed none. */
.profile { gap: var(--s4); }
.screen-set { display: flex; flex-direction: column; gap: 10px; max-width: 320px; }
/* The one control here that changes what other SCREENS show gets a line saying
   so. Language and ground are self-evident from the words on the buttons;
   "Stories: Off" is not, and the sentence it carries is also where the fact
   that this one follows the account rather than the browser is stated. */
.set-note { margin: -2px 0 0; color: var(--muted); }

/* ── The links out (feedback #1948) ───────────────────────────────────────
   A row rather than a button: these leave the app, and a button that leaves
   looks like a button that does something here. The arrow is the only thing
   marking them as external, which is the same mark the iOS Profile uses for
   the same three destinations, so the two products point at each other the
   same way. */
.set-link {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  background: var(--card); border: 1px solid var(--hair); border-radius: 12px;
  padding: 11px 15px; text-decoration: none; color: inherit;
}
.set-link:hover { border-color: var(--muted); }
.set-link span:first-child { display: flex; flex-direction: column; gap: 2px; }
.set-link small { color: var(--muted); font-size: var(--fs-fine); }
.set-go { color: var(--seal-text); font-size: 15px; }

/* ── 列 ONE ROW PER THING, LABEL LEFT, CONTROL RIGHT (#2109) ──────────────
   *"übersichtlicher und schärfer … sieht alles so hingeklatscht aus."* The
   settings were a 320px column of label-over-control stacks, the links three
   boxed cards at a third of the width, the logins a list stopping at 480px:
   four different right edges in one sheet. The settings pages on Mobbin that
   read as sharp (Aboard, Delphi, Neon) all do one thing, and this is it:
   rows the width of the card, the name on the left, the control on the right
   edge, a hairline between, and every control the same height (36px). */
.profile .screen-set { max-width: none; gap: 0; }
.profile #screen-set > div,
.profile #screen-set > .set-group,
.profile .screen-set > .set-link,
.profile .cls-row,
.profile .login-row {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4);
  min-height: 52px; padding: 8px 0; margin: 0;
  border: 0; border-top: 1px solid var(--hair); border-radius: 0; background: none;
}
.profile .screen-set > :first-child,
.profile #screen-set > :first-child,
.profile .login-list > :first-child { border-top: 0; }
.profile #screen-set label,
.profile #screen-set .set-label {
  display: inline-flex; align-items: center; margin: 0;
  font-size: var(--fs-small); font-weight: 500; color: var(--fg);
}
.profile #screen-set .switch { flex: none; min-width: 188px; padding: 3px; }
.profile #screen-set .switch button { flex: 1 1 auto; min-height: 30px; padding: 5px 12px; }
.profile #screen-set .stepper { flex: none; min-width: 188px; justify-content: space-between; }
.profile #screen-set .stepper .ibtn { width: 30px; height: 30px; }
/* The password is the one row whose control is a small form: its label sits
   level with the first field, and the fields stack at the right edge. */
.profile #screen-set > .pw-form { align-items: flex-start; padding-top: 12px; }
.profile #screen-set .pw-form .set-label { padding-top: 9px; }
.pw-fields { display: flex; flex-direction: column; gap: 8px; width: min(300px, 100%); }
.pw-fields input { margin: 0; }
.pw-fields .btn { align-self: flex-end; }
.pw-fields .set-note { margin: 0; }
/* Forgotten, under Change (#2368): the quiet link, right-aligned with the
   button it is the alternative to. */
.pw-fields .pw-forgot { align-self: flex-end; font-size: var(--t-label); }
/* The links, the classes and the logins: the same rows, without their boxes. */
.profile .screen-set > .set-link { padding: 10px 0; }
.profile .screen-set > .set-link:hover b { color: var(--seal-text); }
.profile .cls-list { max-width: none; }
.profile .cls-open { padding: 0; border: 0; background: none; }
.profile .login-list { max-width: none; gap: 0; }
.profile .login-row { min-height: 44px; }
.profile .set-foot { margin: 0; }
.profile .pf-level .lv-pick { margin-top: -4px; }
.acct-row { display: flex; align-items: center; justify-content: space-between; gap: var(--s4); flex-wrap: wrap; }
.acct-who { display: grid; gap: 4px; min-width: 0; }
.acct-who p { margin: 0; }
.profile .acct-danger { margin: 0; padding-top: 12px; border-top: 1px solid var(--hair); }

/* ── Grammar ──────────────────────────────────────────────────────────────── */
/* The second half of a section label is a NAME — a Chinese category, a count —
   and it must not inherit the overline's small caps: 语气助词 uppercased is
   still 语气助词, and an English name uppercased twice in one heading reads as
   the heading having been printed twice, which is exactly how it looked. */
.sub .fine {
  font-weight: 400; margin-left: 7px;
  text-transform: none; letter-spacing: 0; font-size: var(--t-label);
}
/* ══ 碑林 A FOREST OF STELES ═══════════════════════════════════════════════
   The camp draws grammar as a STELE, and it is the one building in it that is
   not a building — a stone with the rules cut into it, because grammar is a
   reference rather than a room. docs/learn-places.md has the system this is
   the second of.

   ── THE REFERENCE IS A REAL PLACE AND IT IS EXACTLY THIS ─────────────────
   The Forest of Stone Steles in Xi'an is four thousand tablets standing in
   rows so dense the place is named after a forest, and the Kaicheng Stone
   Classics in its first hall are literally the rules of the written language
   carved onto stones you walk between. Four hundred and twenty-eight points,
   grouped by the syllabus's own categories, is the same object.

   ── WHAT CHANGED AND WHAT DID NOT ────────────────────────────────────────
   A column of rows became a row of STONES: narrow, upright, a rounded head, a
   plinth under it, the pattern carved into the face. Every point still carries
   its name, its pattern, its English and its examples — the markup is the same
   `details` it was, and opening one still opens exactly what it opened.

   What a stone does that a row cannot is be SEEN FROM THE DOOR: sixty points
   in a category are sixty rows you scroll past, and sixty stones are a hall
   you can take in at a glance and walk into at the one you want. */
.points {
  display: flex; flex-wrap: wrap;
  gap: var(--s4) var(--s3);
  padding-bottom: var(--s2);
}
.point {
  flex: 0 1 172px;
  position: relative;
  /* The text begins BELOW the head, which is what the head is for: a stele's
     face starts where the round stops. A tall enough stone also stops a
     two-word point and a nine-word one reading as two different objects. */
  min-height: 136px;
  padding: calc(var(--s6) + var(--s1)) var(--s3) var(--s5);
  display: flex; flex-direction: column; justify-content: flex-start;
  /* The head is a half-round and the foot is square, which is what tells a
     stele from a headstone at a glance: the radius is half the width, so the
     top is a true semicircle however the column is sized. */
  border-radius: 86px 86px 5px 5px;
  background: var(--raised);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 7%, transparent),
              0 2px 5px -3px rgba(26, 24, 19, .3);
  transition: flex-basis var(--base) var(--ease), border-radius var(--base) var(--ease);
}
/* 座 The plinth. Wider than the stone and under it, which is the whole of what
   makes a slab read as standing rather than lying. */
.point::after {
  content: '';
  position: absolute; left: 50%; bottom: calc(-1 * var(--s2));
  width: 112%; height: var(--s2);
  translate: -50% 0;
  border-radius: 2px;
  background: color-mix(in srgb, var(--fg) 13%, transparent);
}
.point summary {
  display: flex; flex-direction: column; align-items: center; gap: var(--s2);
  text-align: center;
  cursor: pointer; list-style: none;
}
.point summary::-webkit-details-marker { display: none; }
.p-name { font-weight: 500; }
/* 刻 Carved rather than printed: the pattern is cut INTO the face, so it is an
   inset shadow on the stone's own colour rather than a chip laid on it. */
.p-pat {
  font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: var(--fs-fine);
  color: var(--fg-2);
  background: color-mix(in srgb, var(--fg) 6%, transparent);
  box-shadow: inset 0 1px 2px color-mix(in srgb, var(--fg) 12%, transparent);
  padding: 2px 8px; border-radius: 5px;
}
/* ── AND THE ONE YOU ASKED ABOUT STEPS FORWARD ───────────────────────────
   An open stone takes the width of the hall and squares its head, because what
   is on it now is prose and prose in a column 172px wide is a poem. It is the
   same element and the same markup; only the stone's shape answers. */
.point[open] {
  flex: 1 1 100%;
  min-height: 0;
  border-radius: 12px;
  padding: var(--s4) var(--s5) var(--s5);
  background: var(--card);
}
.point[open] summary { flex-direction: row; align-items: baseline; gap: var(--s3);
                       text-align: left; flex-wrap: wrap; }
.point[open]::after { width: 46%; }
.p-en { margin: 10px 0 0; color: var(--fg-2); max-width: 66ch; }
.point .ex { margin-top: 10px; }

/* A hall of stones on a phone is a column of them: at 390px two stones are
   168px each and their names break to three lines. The stele goes and the row
   comes back, which is what it was. */
@media (max-width: 560px) {
  .points { flex-direction: column; gap: 5px; }
  .point, .point[open] {
    flex: 1 1 auto; min-height: 0;
    border-radius: var(--r-card); padding: 11px 15px;
  }
  .point::after { display: none; }
  .point summary { flex-direction: row; align-items: baseline; gap: 11px;
                   text-align: left; flex-wrap: wrap; }
}

/* ── Reading ──────────────────────────────────────────────────────────────── */
/* ── A SHELF OF TEXTS IS A LIST, NOT A GRID OF CARDS ──────────────────────
   Ninety-eight passages in a four-across grid of 224px cards is a wall of
   near-identical tiles whose only distinguishing feature — the title — is the
   one thing a 224px card truncates. They are things to READ, and a list of
   things to read is a table of contents: title, then what it costs you, on one
   line, in one panel, with a hairline between.

   Set in the serif at reading size, because a title is prose and the rest of
   this screen is prose. */
.shelf {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column;
  background: var(--card); border-radius: var(--r-panel); overflow: hidden;
  box-shadow: var(--panel);
}
.shelf li + li a { box-shadow: inset 0 1px 0 var(--hair); }
.shelf a {
  display: flex; align-items: baseline; justify-content: space-between; gap: var(--s4);
  padding: var(--s4) var(--s5); text-decoration: none;
  color: var(--fg); flex-wrap: wrap;
  transition: background var(--fast) var(--ease);
}
.shelf a:hover { background: color-mix(in srgb, var(--fg) 5%, transparent); }
.sh-t {
  font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-size: 18px;
  min-width: 0;
}
.shelf .fine { flex: 0 0 auto; }

/* THE TEXT ITSELF, and it is set to be read rather than to be a data table.
   Serif, large, generous leading, and a measure that stops well short of the
   window: a paragraph of Chinese at 15px across 1400px is a paragraph nobody
   finishes. */
.passage {
  font-family: 'Noto Serif SC', serif; font-size: 24px; line-height: 1.3;
  max-width: 34em;
  padding: var(--s6) var(--s6); border-radius: var(--r-panel);
  background: var(--card); box-shadow: var(--panel);
  display: flex; flex-wrap: wrap; align-items: flex-start; row-gap: 6px;
}
/* Every word is a button, and none of them looks like one until it is pressed.
   A paragraph of outlined buttons is not a paragraph.

   ── THREE RAILS, ALWAYS RESERVED ─────────────────────────────────────────
   Pinyin above, the word, its gloss below — PassageReaderView's layout. Both
   rails keep their height whether shown or not (visibility, not display), so
   revealing a word does not reflow the paragraph under the reader's eye. The
   gloss is cut to about the word's own width (#2011/#2024): a long gloss no
   longer shoves the word onto a line of its own, and the panel under the text
   has the whole of it. */
.tok, .tok-p {
  font: inherit; color: inherit; background: none; border: 0;
  padding: 0 2px; border-radius: 4px;
  display: inline-flex; flex-direction: column; align-items: center;
}
.tok { cursor: pointer; }
.tok-p { padding: 0; }
.t-py, .t-g {
  font-family: 'Inter', system-ui, sans-serif; font-size: 11px; line-height: 16px;
  height: 16px; visibility: hidden; white-space: nowrap;
}
.t-py { color: var(--fg-2); }
.t-g {
  color: var(--muted); max-width: calc(100% + 14px); min-width: 100%;
  overflow: hidden; text-overflow: ellipsis; text-align: center;
}
.t-h { line-height: 1.35; }
.passage.show-py .t-py, .passage.reveal-all .t-py, .tok.open .t-py,
.passage.reveal-all .t-g, .tok.open .t-g { visibility: visible; }
/* The speaker at the END of each sentence, where the eye already stops. */
.s-say {
  align-self: center; margin: 0 4px; padding: 0 5px; min-height: 0;
  font-size: 11px; line-height: 20px; border-radius: 99px; cursor: pointer;
  color: var(--fg-2); background: none; border: 0; opacity: .85;
}
.s-say:hover, .s-say.on { opacity: 1; background: var(--raised); }
.tok:hover { background: var(--bg-sunken); }
.tok.on { background: color-mix(in srgb, var(--amber) 34%, var(--card)); }
/* New at this level, underlined rather than coloured — and the underline is
   SEAL, not a tone.

   It was `--t4` at 55%, which is tone four's blue: in a paragraph where the
   tone colours mean tones, an unlearned word was marked in the hue that means
   "falling tone". That is precisely the second meaning for a hue this comment
   used to warn against while spending one. Seal is the product's one accent and
   means nothing else, and it is what the app uses — `passage.tapWord` says so
   out loud: *"A red underline is a word you haven't learned."* It is on the
   CHARACTERS only, so the gloss rail below does not wear it. And since
   2026-09-22 it is true: the mark is the learner's unknown words, not the
   author's "above this level" tag it used to follow. */
.tok.new .t-h { box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--seal) 62%, transparent); }

.gloss {
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  padding: 12px 16px; border-radius: 12px;
  background: var(--card); border: 1px solid var(--seal);
  position: sticky; bottom: 12px;
}
.gloss b { font-family: 'Noto Serif SC', serif; font-size: 21px; }
.g-m { color: var(--fg-2); }
.g-new {
  font-size: var(--fs-fine); color: var(--seal-text);
  background: var(--seal-soft); padding: 2px 8px; border-radius: 20px;
}

/* ── THE TRANSLATION AND THE QUESTIONS ────────────────────────────────────
   Two `<details>` that drew as near-invisible grey bars with the browser's own
   4px triangle. On a screen whose entire job is reading, the translation is the
   second most important control on it, and it did not look pressable.

   A card with a real disclosure mark, and a hit area that is the whole row. The
   marker is drawn rather than borrowed: `list-style` on a summary is the one
   place Safari and Chrome still disagree about size and position. */
.reading details.panel {
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: 0;
}
.reading details.panel summary {
  cursor: pointer; list-style: none;
  display: flex; align-items: center; gap: var(--s3);
  padding: 14px var(--s5);
  transition: color var(--fast) var(--ease);
}
.reading details.panel summary::-webkit-details-marker { display: none; }
.reading details.panel summary::after {
  content: ''; margin-left: auto; width: 8px; height: 8px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform var(--base) var(--ease);
}
.reading details[open].panel summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.reading details.panel summary:hover { color: var(--fg); }
.reading details.panel > :not(summary) { padding: 0 var(--s5) var(--s5); }
.reading details.panel p { margin: 0; max-width: 66ch; color: var(--fg-2); }
.quiz { margin: 12px 0 0; padding-left: 20px; display: flex; flex-direction: column; gap: 12px; }
.quiz .q { margin: 0 0 5px; font-family: 'Noto Serif SC', serif; color: var(--fg); }
.quiz ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 7px; flex-wrap: wrap; }
/* ── AN OPTION IS A THING YOU PRESS ────────────────────────────────────────
   These were `<li>`s with the right one tinted — the answer sat on the page
   from the moment the panel opened, which is not a comprehension question, it
   is a comprehension statement. They are buttons now, and the reveal is what
   pressing one buys.

   The tints are the drill's, not new ones: growth for right, seal for the one
   that was picked and was not. A disabled option keeps full contrast — the
   browser's default fade would grey out the answer somebody just earned. */
.quiz .opt {
  font: inherit; padding: 7px 14px; border-radius: var(--r-chip);
  font-size: var(--fs-fine); color: var(--fg-2);
  background: var(--raised); cursor: pointer;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.quiz .opt:hover:enabled { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); color: var(--fg); }
.quiz .opt:disabled { cursor: default; opacity: 1; }
.quiz .opt.right {
  color: var(--jade-ink); background: color-mix(in srgb, var(--jade) 14%, var(--raised));
  font-weight: 600;
}
.quiz .opt.wrong {
  color: var(--seal-text); background: color-mix(in srgb, var(--seal) 12%, var(--raised));
  text-decoration: line-through;
}

/* The end of a passage. The button is the last thing on the screen because
   finishing is the last thing somebody does — and it is centred rather than
   full-bleed: a bar across the bottom of a reader reads as a paywall. */
.passage-end {
  display: flex; flex-direction: column; align-items: center;
  gap: var(--s3); margin-top: var(--s5);
}
/* One chip, not a sentence saying the same thing twice. The first version read
   "Read Marked as read. This counts towards today." — a label and its own
   explanation side by side, which is how a mark turns into noise. */
.read-mark {
  margin: 0; display: inline-flex; align-items: center; gap: 7px;
  font-size: var(--fs-fine); color: var(--fg-2);
  padding: 6px 14px 6px 11px; border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--jade) 14%, var(--card));
}
.read-mark b { color: var(--jade-ink); font-weight: 600; }

@media (max-width: 860px) {
  .passage { font-size: 21px; padding: 16px 17px; }
}

/* ── Reviewing ────────────────────────────────────────────────────────────
   The one screen somebody looks at for twenty minutes, so it is the one that
   gets out of the way. No chrome the card does not need, the card centred, and
   the four buttons where the thumb already is on a phone. */
.review { gap: var(--s4); max-width: 620px; margin: 0 auto; width: 100%; }

/* ── TWO COLUMNS, ONCE THE CARD HAS SOMEBODY BESIDE IT ────────────────────
   Above 1280 the round has a laptop's width and used six hundred pixels of it,
   with the reference strip — the cast, the story, the words, the strokes —
   below the fold under four grade buttons. Those five sections are the reason
   a card is worth turning over, and they were the part nobody scrolled to.

   The card and the grades stay one column and keep their width: a review card
   that grows with the window is a character that changes size between screens.
   The strip takes the room that was empty, and sticks, so it does not scroll
   away from the card it belongs to. Below 1280 it goes back under, which is
   the phone order and the order it was designed in. */
@media (min-width: 1280px) {
  .review {
    max-width: 1120px;
    display: grid; grid-template-columns: 620px minmax(0, 1fr);
    /* The slack goes into a LAST row nobody stands in (#2078). With `auto
       auto 1fr`, a long strip — the Similar tab's list of look-alikes — gave
       its extra height to the rows of column one, and the line saying which
       keys do what dropped to the foot of the page, far under the grades it
       explains. Column one now packs at the top whatever the strip does. */
    grid-template-rows: auto repeat(6, auto) 1fr;
    column-gap: var(--s6); align-items: start;
  }
  /* EVERYTHING IS COLUMN ONE unless it is the strip. The first version named
     only the two it wanted moved, so the reveal button — which exists on the
     front of a card and not the back — fell into the next free cell, which was
     the empty right-hand column. A grid that places some of its children and
     lets the rest fall where they may is a grid that changes layout when a
     screen changes state. */
  .review > * { grid-column: 1; }
  .review > .r-top { grid-column: 1 / -1; }
  .review > .ref {
    grid-column: 2; grid-row: 2 / -1;
    position: sticky; top: var(--s5);
  }
}
.r-top { display: flex; align-items: center; gap: 14px; }
.r-top .fine { margin: 0; white-space: nowrap; }
/* ── The counter column ───────────────────────────────────────────────────
   Two lines: how far through, and what the round has paid. `SessionScaffold`
   stacks them the same way and for the same reason — the row already carries a
   rail and two buttons, and this column has the height going spare.

   `tabular-nums` on both, so neither shifts sideways as the digits climb: a
   number that jitters while you are watching it is a number you stop watching.
   The XP is `--growth-4`, the affirm green the drill's own "right" line uses,
   and NOT seal: seal is the one voltage colour and tokens.css caps it at twice
   a page. This screen has already spent it on the progress rail. */
.r-count { display: flex; flex-direction: column; gap: 1px; }
.r-count .fine { font-variant-numeric: tabular-nums; }
.r-xp {
  margin: 0; white-space: nowrap; font-size: 11px; font-weight: 600;
  color: color-mix(in srgb, var(--growth-4) var(--jade-keep), var(--fg)); font-variant-numeric: tabular-nums;
}
/* ── THE PAYOUT, IN FLIGHT AND ARRIVING (#2051, #2164) ─────────────────────
   reward.js flies the pill from the grade button to the counter above and
   swells the counter as it lands: `XPFlight` in SessionScaffold.swift, the
   grade buttons' own tinted surface and rim so it reads as having come out of
   one. Fixed and click-through, so the next card's buttons are live under it.
   The swell is a class and a keyframe, which is what lets the reduced-motion
   block further down stop it; the flight itself is not started at all there. */
.xp-fly {
  position: fixed; left: 0; top: 0; z-index: 95; pointer-events: none;
  padding: 7px 13px; border-radius: var(--r-pill); white-space: nowrap;
  font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--growth-4) var(--jade-keep), var(--fg));
  background: color-mix(in srgb, var(--growth-4) 16%, var(--card));
  border: 1.5px solid color-mix(in srgb, var(--growth-4) 60%, transparent);
  box-shadow: 0 4px 14px color-mix(in srgb, var(--growth-4) 28%, transparent);
  will-change: transform, opacity;
}
.r-xp.xp-bump { transform-origin: right center; animation: xp-bump var(--slow) var(--ease); }
@keyframes xp-bump {
  0% { transform: scale(1); }
  35% { transform: scale(1.5); }
  100% { transform: scale(1); }
}
.r-top-actions { display: flex; gap: 6px; margin-left: auto; }
/* ── HOME, AND THE KEYS BEHIND A DOT ──────────────────────────────────────
   The house is a plain outline in the bar's own weight, a button's size so it
   reads as something to press (#2131). The keys' dot only where a pointer
   hovers, which is as close as CSS gets to "there is a keyboard" (#2126). */
.r-home {
  display: inline-grid; place-items: center; flex: none;
  width: 34px; height: 34px; border-radius: var(--r-chip);
  color: var(--fg-2); background: var(--raised); text-decoration: none;
}
.r-home:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 12%, var(--raised)); }
.r-home svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6;
              stroke-linecap: round; stroke-linejoin: round; }
.r-keys { display: none; }
@media (hover: hover) and (pointer: fine) {
  .r-keys { display: inline-flex; align-items: center; }
  .r-keys .info-b { margin: 0; width: 30px; height: 30px; justify-content: center; color: var(--fg-2); }
  .r-keys .info-b svg { width: 18px; height: 18px; }
}
.r-bar { flex: 1 1 auto; height: 4px; border-radius: 2px; background: var(--hair); overflow: hidden; }
.r-bar span { display: block; height: 100%; background: var(--seal); transition: width .2s ease-out; }

.r-card {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  min-height: 244px; justify-content: center; text-align: center;
  padding: 26px 22px; border-radius: var(--r-panel);
  background: var(--card);
  box-shadow: var(--panel);
}

/* ── THE BLOOM BEHIND THE GLYPH ───────────────────────────────────────────
   A very soft radial warmth behind the character, which is the seal ink
   spreading on paper — `docs/brand.md`'s "paper grain + ink wash: a faint
   ink-wash bloom behind the hero character - warmth, not flat plastic". At
   four per cent it is not a colour, it is the difference between a card and a
   rectangle, and it is the one thing a screenshot of this screen was missing
   next to the app's.

   On the ::before so the character keeps the stacking context, and
   `pointer-events: none` because the whole front face is a flip target. */
.r-card::before {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(60% 52% at 50% 40%,
    color-mix(in srgb, var(--seal) 8%, transparent), transparent 70%);
}
/* ── The photo on the back of a card ──────────────────────────────────────
   Pinned to the corner and tilted, which is the app's own treatment and its own
   argument: a full-width band was three separate reports (#1803, #1808, #1811)
   and the side-by-side that replaced it pushed the glyph off centre on exactly
   the cards that had a photo. The glyph holds the middle of the card on every
   card; this leans into the corner like something stuck there.

   `position: absolute` inside the card's own box, so it takes no part in the
   column layout and nothing below it moves when the image decodes. The card is
   `position: relative` for this and clips nothing else.

   Height fixed, width auto: the frame follows the IMAGE. A fixed box around a
   fitted photo draws its border out in the letterbox and reads as broken —
   which is the comment `facePicture` carries in the app, arrived at the same
   way. */
.r-card { position: relative; }
.r-pic {
  position: absolute; top: 14px; right: 14px;
  height: 92px; width: auto; max-width: 40%;
  object-fit: contain;
  border-radius: var(--r-card);
  background: var(--raised);
  rotate: 7deg;
  box-shadow: var(--shadow);
}
@media (max-width: 520px) {
  /* On a phone the card is barely wider than the glyph, so a 92px sticker in
     the corner is sitting on it. Smaller, and tucked further out. */
  .r-pic { height: 62px; top: 10px; right: 10px; }
}
@media (prefers-reduced-motion: reduce) { .r-pic { rotate: none; } }

.r-ask { margin: 0; color: var(--muted); font-size: var(--fs-fine); }
.r-hanzi { margin: 0; font-family: 'Noto Serif SC', serif; font-size: 68px; line-height: 1.1; }
.r-py { margin: 0; font-size: 21px; }
.r-meaning { margin: 0; color: var(--fg-2); max-width: 36ch; }
.r-cast { justify-content: center; margin-top: 4px; }
.r-ex { border: 0; padding: 0; margin-top: 4px; text-align: center; }

/* The listening prompt is a single large target, because the whole question is
   "press this and tell me what you heard". */
.r-audio {
  width: 84px; height: 84px; border-radius: 50%;
  border: 1px solid var(--seal); background: var(--seal-soft); color: var(--seal-text);
  font-size: 28px; cursor: pointer;
}
.r-audio:hover { background: var(--seal); color: var(--seal-on); }

.r-reveal { width: 100%; }

/* ── THE FOUR BUTTONS ARE TINTED, AND THIS FILE ARGUED THE OPPOSITE ───────
   What was here said: *"Not red-to-green, and that is deliberate. This palette
   has exactly one colour that means stop and four that mean TONES, and a red
   Again beside a green Easy would put a fifth and sixth meaning on hues a
   learner is being trained to read as first and third tone."*

   That argument is real and the APP had it first, at more length —
   `ReviewView.swift:1155`: *"These were four tone-coloured buttons, and that
   was the palette's worst moment … on fsrs.png the syllable zhī is tone-1 red,
   the story word giant is tone-1 red, and the Again button is red, three
   different meanings in one colour a thumb's width apart."*

   And then the owner resolved it, on 2026-08-29, revising #1761: *"like in
   Anki"* — the BOX carries the colour, not the word. That is the version the
   app ships (`gradeTint`, 22% fill and 55% border), and it survives the
   objection because a tinted SURFACE is not a tinted glyph: the tone scale
   lives on text, and no learner reads the fill behind an English word as a
   tone. What the web had was this file arguing a position the app it mirrors
   had already moved past.

   NOTHING IS PRE-SELECTED, which is the part of the honesty rule that stands:
   *"a self-graded review only works if the grade is YOURS. An app that
   pre-picks Good is asking a leading question, and the honest answers are the
   ones the scheduler needs most."* Four tinted boxes, neutral labels, real
   intervals, and no suggestion. */
.r-grades { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s2); }
.r-grade {
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  min-height: 56px; padding: 10px 6px; border-radius: 13px; cursor: pointer;
  background: color-mix(in srgb, var(--g) 22%, var(--card));
  border: 1.5px solid color-mix(in srgb, var(--g) 55%, transparent);
  color: var(--fg); font: inherit;
  transition: background var(--fast) var(--ease);
}
.r-grade:hover { background: color-mix(in srgb, var(--g) 34%, var(--card)); }
/* Anki's own scale, and `gradeTint`'s: red, amber, green, blue. */
.r-grade.g1 { --g: var(--seal); }
.r-grade.g2 { --g: var(--t2); }
.r-grade.g3 { --g: var(--jade); }
.r-grade.g4 { --g: var(--t4); }
.g-l { font-weight: 600; font-size: var(--t-body); }
.g-i { font-size: var(--t-cap); color: color-mix(in srgb, var(--fg-2) 60%, var(--fg)); font-variant-numeric: tabular-nums; }
/* The tracing screen suggests a grade from how the strokes went, which is the
   one place a suggestion is honest: it is grading the DRAWING, which it
   watched, rather than a memory only the learner can see. */
.r-grade.on { outline: 2px solid color-mix(in srgb, var(--g) 80%, transparent); outline-offset: 2px; }

/* ── THE KEY, ON A KEYBOARD ONLY ──────────────────────────────────────────
   1–4 grade and Space reveals, and the standing sentence under the card said
   so on every screen including the ones with no keyboard. The hint belongs on
   the control it describes, and only where it is true: a pointer that can
   hover is as close as CSS gets to asking "is there a keyboard". */
.r-grade kbd { display: none; }
@media (min-width: 861px) and (hover: hover) {
  .r-grade kbd {
    display: block; font: inherit; font-size: var(--t-over);
    color: var(--muted); opacity: 0; transition: opacity var(--fast) var(--ease);
  }
  .r-grades:hover .r-grade kbd { opacity: 1; }
}

@media (max-width: 860px) {
  .r-hanzi { font-size: 58px; }
  .r-card { min-height: 200px; }
  .r-grade { padding: 14px 4px; }
}

/* ── The decks on Today ───────────────────────────────────────────────────
   One card per round, each its own way in. They are not summed and they are
   not rows in a list: a deck is a thing you start, so it is shaped like a
   button and its number is the biggest thing on it. */
.decks { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); }
/* THE ONE THING TO DO ON THIS PAGE, and it should look like it. A press
   target, raised off the ground, with the number carrying the weight. */
.deck {
  position: relative;
  display: flex; flex-direction: column; gap: 2px;
  padding: 18px 19px 22px; border-radius: var(--r-card); text-decoration: none;
  background: var(--raised);  color: var(--fg);
  box-shadow: var(--shadow);
  transition: border-color .12s ease-out, transform .12s ease-out;
}
.deck:hover { border-color: var(--seal); transform: translateY(-1px); }
.deck-n { font-family: 'Fraunces', Georgia, serif; font-size: 34px; font-weight: 600; line-height: 1.05; }
.deck-k { color: var(--fg-2); font-weight: 500; }
/* A chevron, not four red links. The card IS the target, and this palette has
   one colour that means something urgent (docs/brand.md) — spending it four
   times across one row is how it stops meaning anything. */
.deck-go { position: absolute; right: 15px; bottom: 14px; color: var(--muted); display: flex; }
.deck-go svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; }
.deck:hover .deck-go { color: var(--seal-text); }

/* ── The end of a round ───────────────────────────────────────────────────
   Centred, and the only screen in this app that is. Everything else is a
   document with a left edge; this one is a moment. */
.result { align-items: center; text-align: center; max-width: 520px; margin: 0 auto; width: 100%; gap: 18px; }
.result h1 { font-size: 30px; }
.result .lead { color: var(--fg-2); max-width: 40ch; }
.result-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
/* "More new cards" arrives after the screen does (learn.js `fillMoreNew`). An
   empty slot must not hold a gap open, so it is no box at all and its button
   is a flex item of the row like the others. */
.result-more { display: contents; }

/* The seal, for the round that had nothing in it. Scarcity is what gives the
   seal its weight (docs/brand.md), so it marks the quiet outcome and the
   dragon takes the finish. */
.glyph-seal {
  display: grid; place-items: center;
  width: 76px; height: 76px; border-radius: 18px;
  background: var(--seal); color: #F4EEE0;
  font-family: 'Noto Serif SC', serif; font-size: 38px;
}

/* ── Him ──────────────────────────────────────────────────────────────────
   Two frames stacked. The base never fades and the blink is switched hard on
   top of it, which is exactly what GushiFace.swift does and for the reason
   recorded there: crossfading two stacked images dips the composite below full
   opacity mid-fade, and the background came through the head for a frame. */
/* The DEAD `.hero` rule this replaces was the right one all along — the markup
   simply never used it. `.mascot` is the name the markup uses now, and the two
   frames stack inside it because `GushiFace.swift` switches the blink HARD
   rather than crossfading: two stacked images at half opacity composite to
   0.75, so a crossfade lets the background through the head for a frame and
   reads as flicker. */
.mascot {
  position: relative; width: clamp(112px, 16vw, 148px); aspect-ratio: 1;
  flex: 0 0 auto; margin: 0 auto;
}
.hero-face { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.hero-blink { opacity: 0; animation: blink 5.2s steps(1, end) infinite; }
/* Two closes in the cycle, the second a beat after the first, because a single
   evenly spaced blink reads as a metronome rather than as a face. */
@keyframes blink {
  0%, 92% { opacity: 0; }
  93%, 95% { opacity: 1; }
  96%, 97% { opacity: 0; }
  98%, 99% { opacity: 1; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .hero-blink { animation: none; } }

/* The four numbers. Four is the budget and SessionScaffold.swift enforces it
   rather than documenting it, because the screen it replaced grew to ten by
   nobody ever saying no. */
.figures { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; width: 100%; }
.fig {
  display: flex; flex-direction: column; gap: 2px;
  padding: 13px 8px; border-radius: var(--r-card);
  background: var(--raised);
}
.fig-n { font-family: 'Fraunces', Georgia, serif; font-size: 24px; font-weight: 600; line-height: 1.05; }
.fig-k { color: var(--muted); font-size: 11px; }

@media (max-width: 560px) {
  .figures { grid-template-columns: repeat(2, 1fr); }
}

/* ── THE ROUND, ADDED TO THE WEEK (#2164) ──────────────────────────────────
   Under the four figures and not a fifth: the four are the round, this is the
   round landing in the week, and — only when it moved — in the league.
   `WeekTally.swift`. Label and number on one line each; the week counts up
   (reward.js), the old place is struck through and the new one springs in
   when `.risen` is set. */
.week-tally {
  width: 100%; display: flex; flex-direction: column; gap: var(--s2);
  padding: var(--s3) var(--s4); border-radius: var(--r-card);
  background: var(--raised);
}
.wt-line { display: flex; align-items: baseline; gap: var(--s3); }
.wt-k { color: var(--fg-2); font-size: 13px; }
.wt-n, .wt-place { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; }
.wt-n { font-size: 15px; }
.wt-league {
  padding-top: var(--s2); border-top: 1px solid var(--hair);
  animation: wt-in var(--base) var(--ease);
}
@keyframes wt-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.wt-place { display: inline-flex; align-items: baseline; gap: 6px; }
.wt-from { font-size: 15px; transition: color var(--base) var(--ease), font-size var(--base) var(--ease); }
.wt-arrow, .wt-to {
  color: color-mix(in srgb, var(--growth-4) var(--jade-keep), var(--fg));
  opacity: 0; transform: translateY(10px) scale(.4);
  transition: opacity var(--base) var(--ease), transform var(--slow) cubic-bezier(.34, 1.56, .64, 1);
}
.wt-to { font-size: 17px; font-weight: 700; }
.wt-league.risen .wt-from { color: var(--muted); font-size: 13px; text-decoration: line-through; }
.wt-league.risen .wt-arrow, .wt-league.risen .wt-to { opacity: 1; transform: none; }

/* ── THE FINISH, WITH ENERGY (#2244) ───────────────────────────────────────
   *"der Screen braucht mehr Energie … Stichwort ist Motivation!"* Gushi in a
   warm ring beside the headline (the height he gave up is the league
   slice's), the four figures in four colours popping in one after another,
   the week's card warmed with gold, and the slice below. Every move rests in
   its final state and stops under reduced motion. */
.result.cheer { max-width: 560px; gap: 14px; }
.result.cheer .result-actions { align-items: center; }
.result-hero { display: flex; align-items: center; justify-content: center; gap: var(--s4); }
.result-ring {
  display: grid; place-items: center; flex: none; width: 104px; aspect-ratio: 1; border-radius: 50%;
  background: radial-gradient(closest-side, color-mix(in srgb, var(--t2) 34%, transparent),
              color-mix(in srgb, var(--seal) 12%, transparent) 72%, transparent);
}
.result-ring .mascot { width: 86px; }
.result.cheer h1 { font-size: clamp(28px, 3vw, 36px); text-align: left; }
.fig.k-cards { --fc: var(--t4); --fi: var(--t4-ink); }
.fig.k-kept { --fc: var(--t3); --fi: var(--t3-ink); }
.fig.k-again { --fc: var(--t1); --fi: var(--t1-ink); }
.fig.k-xp { --fc: var(--t2); --fi: var(--t2-ink); }
.result.cheer .fig {
  background: color-mix(in srgb, var(--fc, var(--fg)) 14%, var(--card));
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--fc, var(--fg)) 42%, transparent);
  animation: fig-pop .5s cubic-bezier(.3, 1.45, .5, 1) backwards;
  animation-delay: calc(120ms + var(--i, 0) * 90ms);
}
.result.cheer .fig-n { color: var(--fi, var(--fg)); font-variant-numeric: tabular-nums; }
@keyframes fig-pop { from { opacity: 0; transform: translateY(8px) scale(.7); } }
.result.cheer .week-tally {
  background: linear-gradient(115deg, color-mix(in srgb, var(--t2) 13%, var(--raised)), var(--raised) 65%);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--t2) 30%, transparent);
}

/* The slice (#2244): four rows at most, the reader's lit in the seal's wash
   and lifted over the others while it climbs. `--shift` is how many rows a
   row starts away from its place (the reader: the rows passed; each of those:
   one up), and `.moved` sends every row home at once. */
.wt-slice {
  list-style: none; margin: 2px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px;
  --row: 34px;
}
.wt-row {
  position: relative; display: grid; grid-template-columns: 2.4ch 24px minmax(0, 1fr) auto;
  align-items: center; gap: 10px; height: var(--row); padding: 0 10px; border-radius: 10px;
  background: var(--card); text-align: left; font-size: 13px;
  transform: translateY(calc(var(--shift, 0) * (var(--row) + 4px)));
  transition: transform .75s cubic-bezier(.3, 1.2, .45, 1);
}
.wt-row .av { width: 24px; height: 24px; font-size: 11px; }
.wt-rank { font-weight: 700; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.wt-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-xp { font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.wt-row.me {
  z-index: 1; font-weight: 600;
  background: color-mix(in srgb, var(--seal) 16%, var(--card));
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--seal) 55%, transparent);
}
.wt-row.me .wt-rank { color: var(--seal-text); }
.wt-slice.moved .wt-row { transform: none; }
.wt-slice.moved .wt-row.me {
  animation: climb-glow 1.2s ease-out .55s 1 backwards;
}
@keyframes climb-glow {
  0% { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--seal) 55%, transparent), 0 0 0 0 color-mix(in srgb, var(--t2) 60%, transparent); }
  40% { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--seal) 55%, transparent), 0 0 0 8px color-mix(in srgb, var(--t2) 0%, transparent); }
}
.wt-slice.moved .wt-row.passed .wt-rank { color: var(--muted); }

/* Whom the round passed (#2488): one line, last in the card or under the
   farewell's two numbers, rising in once with a small spring. `.in` is the
   end state, which a redraw and reduced motion draw at once. */
.wt-news {
  margin: 0; padding-top: var(--s2); border-top: 1px solid var(--hair);
  font-size: 14px; font-weight: 600; text-align: center;
  animation: wt-news-in .55s cubic-bezier(.3, 1.45, .5, 1) backwards;
}
.wt-news.in { animation: none; }
.wt-up { color: color-mix(in srgb, var(--growth-4) var(--jade-keep), var(--fg)); font-weight: 700; }
.farewell-news:empty { display: none; }
.farewell-news .wt-news { border-top: 0; padding-top: 0; }
@keyframes wt-news-in { from { opacity: 0; transform: translateY(8px) scale(.9); } }
@media (prefers-reduced-motion: reduce) {
  .result.cheer .fig, .wt-slice.moved .wt-row.me { animation: none; }
  .wt-row { transition: none; }
  .wt-news { animation: wt-news-fade .2s ease-out backwards; }
}
@keyframes wt-news-fade { from { opacity: 0; } }

/* ── The reference layer under a graded card ──────────────────────────────
   Under the buttons, not on the card. The card is the question and the answer;
   this is what somebody reaches for once they have both, and putting it on the
   card would make the card a page.

   The strip never changes shape between two cards of a kind, which is feedback
   #1728: chips that come and go move the tab out from under a thumb between
   one card and the next. An empty section draws its own sentence instead. */
.ref { border-top: 1px solid var(--hair); padding-top: 12px; }
.ref-strip { display: flex; gap: 4px; flex-wrap: wrap; }
.ref-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 6px 11px; border-radius: 9px; cursor: pointer;
  border: 1px solid transparent; background: transparent; color: var(--muted);
  font: inherit; font-size: var(--fs-fine); font-weight: 500;
}
.ref-chip:hover { color: var(--fg); }
.ref-chip.on { background: var(--card); border-color: var(--hair); color: var(--fg); }
/* ── READABLE ON EVERY GROUND ─────────────────────────────────────────────
   #2125: *"die rechte seite sieht man viel der schrift schwer … sichtbar!"*
   The tabs you are not on were `--muted`, a hint of a word, and the one you
   are on was `--card` on a sheet that is itself `--card`, told apart by a
   hairline. On the round they are `--fg-2`, and the open one is filled. */
.review .ref-chip { color: var(--fg-2); }
.review .ref-chip:hover { color: var(--fg); background: var(--raised); }
.review .ref-chip.on { background: var(--raised); border-color: var(--hair); color: var(--fg); font-weight: 600; }
.review .sec-head { color: var(--fg-2); }
/* The section's own glyph, which is the app's: 用 故 词 写 句. It carries the
   meaning for anyone who has used the app, and the word beside it carries it
   for everyone else. */
.ref-g { font-family: 'Noto Serif SC', serif; font-size: 14px; }

.ref-body { padding: 12px 2px 0; text-align: left; min-height: 68px; }
.sec-empty { margin: 0; }
/* ── THE BACK OF A CARD, AS THE PHONE SETS IT ─────────────────────────────
   A small head over each block, usage as BULLETS with the claim in weight, a
   "Built from" row of parts over the story, and three rows then a fold. */
.sec-head {
  margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted);
}
.sec-usage { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-width: 64ch; }
.sec-usage li { position: relative; padding-left: 16px; color: var(--fg); line-height: 1.5; }
.sec-usage li::before { content: '\2022'; position: absolute; left: 2px; color: var(--muted); }
.un-claim { font-weight: 650; }
.un-tail { color: var(--fg-2); }
.sec-parts { list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.sec-parts a, .sec-parts span:not(.sp-f):not(.sp-m) {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 6px 12px 6px 8px; border-radius: var(--r-chip);
  background: var(--raised); color: var(--fg); text-decoration: none;
}
.sec-parts a:hover { background: color-mix(in srgb, var(--fg) 12%, var(--raised)); }
.sp-f { font-family: 'Noto Serif SC', serif; font-size: 20px; line-height: 1; }
.sp-m { font-size: var(--fs-small); color: var(--fg-2); }
.sec-usage li.sec-fold, .sec-words li.sec-fold { display: block; padding: 0; }
.sec-usage li.sec-fold::before { content: none; }
.sec-more > ul { margin-top: 8px; }
.sec-more > summary {
  list-style: none; cursor: pointer; margin-top: 2px;
  font-size: var(--fs-fine); font-weight: 600; color: var(--seal-text, var(--seal));
}
.sec-more > summary::-webkit-details-marker { display: none; }
.sec-more[open] > summary { display: none; }
.ref-body .story { border: 0; padding: 0; background: none; }
.ref-body .strokes { width: 132px; height: 132px; }

.sec-words { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
.sec-words li { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.sec-ex { border: 0; padding: 0; margin: 0; }

/* The answer face is centred; its reference layer is not. A paragraph of usage
   set centred is a paragraph nobody finishes. */
.review .ref { width: 100%; }

/* ── THE ROUND IS ON PAPER AGAIN ──────────────────────────────────────────
   `body.in-session #view` makes the sheet nearly solid for a round, for the
   reason written beside it: a landscape through a card being graded is in the
   way. The camp's "the page itself is gone" rule is on `at-training` and so on
   every route whose tab is Training, the round included, and it out-ranks the
   session's (1,3,1 to 1,1,1). So the round stood bare on the hillside, and in
   the ink theme at a light hour its pale type lay on pale mist (#2125). This
   puts the sheet back for the round alone; the camp and its drills are
   untouched. */
@media (min-width: 744px) {
  body.at-training.in-session:not(.in-court):not(.in-intro) #view {
    background: color-mix(in srgb, var(--card) 97%, transparent);
    /* No glass under 97% paper (#2431): `body.in-session #view` has why. */
    backdrop-filter: none;
    box-shadow: var(--panel);
  }
}

/* ── The day's figures, which are read and not pressed ────────────────────
   They were five bordered cards, identical to the deck cards above them, so
   the page had two rows of equal weight and the eye had nowhere to land. A
   figure is not a control: no border, no fill, a hairline between them and
   nothing else. Same numbers, a tenth of the furniture. */
.today-stats { display: flex; flex-direction: column; gap: 10px; }
.stats {
  display: flex; flex-wrap: wrap;
  border-top: 1px solid var(--hair);
  padding-top: 13px;
}
.stat {
  display: flex; flex-direction: column; gap: 1px;
  padding-right: 26px; margin-right: 26px;
  border-right: 1px solid var(--hair);
}
.stat:last-child { border-right: 0; margin-right: 0; padding-right: 0; }

/* ── Six figures instead of five ──────────────────────────────────────────
   Progress and Profile carry six, and one of them is "Gelernte Zeichen" next
   to "Karten insgesamt". The row wraps well above 860px, and the phone block
   at the end of this file explains what a wrapped hairline row looks like: the
   divider lands at the end of a line and points at nothing, because
   `:last-child` knows about the last of six and not the last of a row.

   So those two screens take the grid form at every width. Today keeps the
   line — five short labels fit, and the hairlines are what make it read as one
   sentence of figures rather than a table. */
.stats-grid {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
  gap: 16px 12px;
}
.stats-grid .stat { padding-right: 0; margin-right: 0; border-right: 0; }
.stat-n { font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-weight: 600; line-height: 1.1; }
.stat-k { color: var(--muted); font-size: var(--fs-fine); }

/* ── The info dot, and the panel behind it ────────────────────────────────
   The same control the teaching platform uses, added there because the owner
   wanted less standing text on a page. Two grey paragraphs that were always on
   Today are behind this now: read once, not on every visit. */
.info-b {
  display: inline-flex; align-items: center; vertical-align: middle;
  margin-left: 6px; padding: 0; border: 0; background: none;
  color: var(--muted); cursor: pointer;
}
.info-b svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; }
.info-b svg circle[r=".2"] { fill: currentColor; stroke-width: 1.9; }
.info-b:hover { color: var(--fg); }
.info-p {
  max-width: 44ch; padding: 18px 20px;
   border-radius: var(--r-panel);
  background: var(--card); color: var(--fg);
  box-shadow: 0 12px 40px -18px rgba(0, 0, 0, .45);
  box-shadow: var(--panel);
}
.info-p::backdrop { background: rgba(26, 24, 19, .22); }
.info-p h3 { font-family: 'Fraunces', Georgia, serif; font-size: 16px; font-weight: 600; margin: 0 0 9px; }
.info-p p { margin: 0 0 10px; font-size: 13.5px; line-height: 1.6; color: var(--fg-2); }
.info-p p:last-child { margin-bottom: 0; }
/* A browser without `popover` never opens the panel, which loses an
   explanation rather than a screen. Hiding it is better than having it sit
   open at the end of the document. */
@supports not selector(:popover-open) { .info-p { display: none; } }
/* #2392: the deck's shortcuts, one row each, the key on the left as a key
   and what it does on the right. The key column is as wide as its widest key,
   so every action starts on the same line. */
.kbd-list {
  display: grid; grid-template-columns: max-content 1fr;
  column-gap: 16px; row-gap: 8px; align-items: center;
}
.kbd-row { display: contents; }
.kbd-keys { display: inline-flex; align-items: center; gap: 4px; justify-self: start; color: var(--muted); font-size: 12px; }
.kbd-what { font-size: 13.5px; line-height: 1.45; color: var(--fg-2); }
.kbd-line { grid-column: 1 / -1; margin: 0 !important; }
.kbd-p kbd {
  display: inline-block; min-width: 24px; padding: 3px 7px; text-align: center;
  font: 600 12px/1.2 -apple-system, 'Segoe UI', system-ui, sans-serif; color: var(--fg);
  background: var(--raised); border-radius: 6px;
  box-shadow: inset 0 0 0 1px var(--hair), 0 1px 0 var(--hair);
}

@media (max-width: 860px) {
  /* ── THE DIVIDERS CANNOT SURVIVE A WRAP ────────────────────────────────
     Five figures do not fit across 375px, so the row wraps — and a right-hand
     hairline then lands at the END of a line, pointing at nothing. `:last-child`
     only knows about the last of five, not the last of a row, and CSS has no
     way to ask "are you last on your line".

     So the phone drops the dividers and uses a grid instead. Same figures, same
     order, and the alignment does the separating that the hairlines did. */
  .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px 10px; }
  .stat { padding-right: 0; margin-right: 0; border-right: 0; }

  /* Four full-width decks is a screen and a half of scrolling before the day's
     figures come into view. Two columns puts all four above the fold. */
  .decks { grid-template-columns: 1fr 1fr; }
  .deck { padding: 15px 15px 20px; }
  .deck-n { font-size: 28px; }
}

/* ── 龙 IN FLIGHT (#1949) ──────────────────────────────────────────────────
   The layer is FIXED and covers the viewport, which is exactly the shape that
   cost the Teaching Platform its range picker and its invite button on
   2026-09-05 — so `pointer-events: none` is not a nicety here, and
   `assertDecorationInert` in tools/build-web.mjs is the check that says so.

   The dragon itself hovers on its own axis while the layer translates it
   across, so the two motions compose without either having to know about the
   other: `transform` on `.fly-d` is the journey, `translateY` on the sketch is
   the breath. */
#fly {
  position: fixed; inset: 0; z-index: 30;
  pointer-events: none; overflow: hidden;
}
.fly-d {
  position: absolute; top: 0; left: 0;
  width: 96px; opacity: .55;
  will-change: transform;
}
/* Facing the way it is going. Mirroring the whole group rather than the sketch
   keeps the flame on the mouth end at both headings. */
.fly-back { transform-origin: 50% 50%; }
.fly-back .fly-inner { scale: -1 1; }
.fly-inner { position: relative; }
.fly-d .dragon { display: block; width: 100%; height: auto; animation: fly-hover 1.7s ease-in-out infinite; }
@keyframes fly-hover { 0%, 100% { translate: 0 -6px; } 50% { translate: 0 6px; } }

/* The flame, at the mouth. The offsets are `DragonFlame`'s own — 0.86 of the
   width across and 0.11 of the height down — and it grows from its left edge,
   which is what makes it read as coming OUT rather than appearing. */
.fly-flame {
  position: absolute; left: 86%; top: 11%;
  width: 42px; height: 30px; color: var(--seal);
  opacity: 0; transform-origin: left center; scale: .5 1;
  transition: opacity .24s ease-in, scale .24s ease-in;
}
.fly-flame.on {
  opacity: 1; scale: 1.2 1;
  transition: opacity .15s ease-out, scale .15s ease-out;
}

/* ── EVERY ANIMATION IN THIS FILE, AND THE ONE SWITCH THAT TURNS THEM OFF ──
 *
 * `prefers-reduced-motion` was honoured in exactly one place — the boot mark —
 * and every animation added after that was added without it. A reader who has
 * asked their OS for less motion is usually asking because motion makes them
 * ill, and honouring the request on one loading screen and nowhere else is the
 * same as not honouring it.
 *
 * So the switch is here, at the END of the file, listing every animated thing
 * by name. The list is the point: a new animation that is not on it keeps
 * running, and the only way to notice is to read this block — which is why the
 * scale at the top of the file names its three durations, and why nothing in
 * this file should write a duration inline again.
 *
 * `!important` twice, because these rules have to beat whatever specificity
 * the component rule used, and there is no other way to say "stop" globally.
 * The transitions become instantaneous rather than absent: a state change that
 * still HAPPENS is a state change a reader can follow.
 */
@media (prefers-reduced-motion: reduce) {
  /* The boot mark's breath and turn — the one that was already handled. */
  .boot .chop { animation: none; }
  /* And the dragon does not fly at all, which fly.js also refuses to start.
     Belt and braces on purpose: this rule holds even if somebody later renders
     the layer from somewhere else. */
  #fly { display: none; }

  /* Everything else that moves: the strokes writing themselves on a character
     page, the heatmap's fade-in, the XP pop at the end of a round, the drill's
     right/wrong flash, the dragon's scales, and every hover transition on a
     button, chip, nav row and card. */
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ── THE TROPHY WALL ──────────────────────────────────────────────────────
   A seal is a carved stone. An earned one is PRESSED — cinnabar on paper, the
   glyph reversed out of a filled square, which is what a real 印章 leaves. An
   unearned one is the stone itself: the same glyph, cut into a low surface,
   present and legible and plainly not yet pressed.

   That is why neither state is grey-on-grey and neither is hidden. The wall
   this replaces on the phone was seventeen identical squares, and the reference
   wall that does the same thing (a grid of black tiles with question marks) is
   the least useful screen in the whole set: a locked seal saying "312 of 500"
   is a reason to keep going, a locked seal saying nothing is furniture. */
.wall-group { margin-top: var(--s6); }
.wall-group .sub { margin: 0 0 var(--s4); }

.wall {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: var(--s3);
  grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
}
.seal {
  display: flex; flex-direction: column; align-items: flex-start; gap: 5px;
  padding: var(--s4); border-radius: var(--r-panel);
  background: var(--card); box-shadow: var(--panel);
}
/* The stone. Square with a hair of a radius, because a seal is cut square —
   a pill would be a badge, and a badge is something given to you. */
.seal-stone {
  font-family: 'Noto Serif SC', serif; font-size: 26px; line-height: 1;
  width: 46px; height: 46px; border-radius: 7px;
  display: grid; place-items: center;
  background: var(--bg-sunken); color: var(--muted);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, .12);
  transition: background var(--base) var(--ease), color var(--base) var(--ease);
}
.seal.won .seal-stone {
  background: var(--seal); color: var(--seal-on);
  box-shadow: 0 1px 2px rgba(0, 0, 0, .18);
}
.seal-t { font-size: var(--fs-small); color: var(--fg); font-weight: 600; margin-top: 3px; }
.seal-need, .seal-at, .seal-on { font-size: var(--fs-fine); color: var(--fg-2); }
.seal.won .seal-on { color: var(--jade-ink); }
/* The meter, and it is deliberately thin: it is a hint at distance, not a
   second progress bar competing with the ring on Today. */
.seal-meter {
  width: 100%; height: 3px; border-radius: 99px;
  background: var(--bg-sunken); overflow: hidden; margin-top: 3px;
}
.seal-meter i { display: block; height: 100%; background: var(--muted); border-radius: 99px; }

@media (max-width: 860px) {
  .wall { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); }
}

/* A second door out of a screen head — the wall from Progress. Right-aligned on
   a wide ground so it sits beside the title rather than under the subtitle,
   where it would read as a third line of explanation. */
.head-more { margin: var(--s3) 0 0; display: flex; gap: var(--s2); flex-wrap: wrap; }
.head-more a {
  display: inline-flex; align-items: baseline; gap: 8px;
  padding: 7px 14px; border-radius: var(--r-pill);
  background: var(--raised); color: var(--fg);
  font-size: var(--fs-small); text-decoration: none;
  transition: background var(--fast) var(--ease);
}
.head-more a:hover { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }

/* ── THE STREAK ───────────────────────────────────────────────────────────
   `.panel` on this host is layout and not a surface, so these three carry
   their own — the same `--card` + `--panel` pair every other grouped block
   uses, spelled once here rather than three times below. */
.run-hero, .run-cal, .run-miles-panel {
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s5);
}

.run-hero { align-items: center; gap: var(--s2); text-align: center; }
/* The stone, at the size the number deserves beside it. Unlit it is uncut —
   the same sentence the trophy wall makes, and the reason there is no emoji
   anywhere on this host. */
.run-mark {
  font-family: 'Noto Serif SC', serif; font-size: 34px; line-height: 1;
  width: 62px; height: 62px; border-radius: 10px;
  display: grid; place-items: center;
  background: var(--bg-sunken); color: var(--muted);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, .12);
}
.run-mark.lit {
  background: var(--seal); color: var(--seal-on);
  box-shadow: 0 1px 2px rgba(0, 0, 0, .18);
}
.run-n {
  font-family: 'Fraunces', Georgia, serif; font-weight: 600;
  font-size: clamp(52px, 7vw, 64px); line-height: 1; margin-top: var(--s2);
  font-variant-numeric: tabular-nums;
}
.run-figures {
  display: flex; width: 100%; margin-top: var(--s4);
  border-top: 1px solid var(--hair); padding-top: var(--s4);
}
.run-figures > div { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.run-figures b {
  font-family: 'Fraunces', Georgia, serif; font-size: 22px; font-weight: 500;
  font-variant-numeric: tabular-nums;
}

/* ── THE CALENDAR THAT DRAWS A RUN AS A RUN ───────────────────────────────
   No column gap, so consecutive days TOUCH — that is the whole mechanism. A
   cell rounds only the ends it is not joined at, which turns a stretch into one
   capsule sweeping through the week. With a gap it would be seven squares and
   the screen would be back to saying nothing about consecutiveness. */
.rc, .rc-wd { display: grid; grid-template-columns: repeat(7, 1fr); }
.rc-wd {
  gap: 0; margin-top: var(--s3);
  font-size: var(--fs-fine); color: var(--muted); text-align: center;
}
.rc-wd i { font-style: normal; padding-bottom: 6px; }
/* #2468: the month between two arrows. A spent arrow fades rather than goes,
   so the name does not jump sideways at either end. */
.rc-nav { display: inline-flex; align-items: center; gap: 2px; }
.rc-step {
  width: 28px; height: 28px; border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 18px; line-height: 1; color: var(--fg);
}
.rc-step:disabled { opacity: .3; cursor: default; }
.rc { row-gap: 5px; }
.rc-pad { height: 34px; }
.rc-d {
  height: 34px; display: grid; place-items: center; font-style: normal;
  font-size: var(--fs-fine); color: var(--fg-2);
  font-variant-numeric: tabular-nums;
}
.rc-d b { font-weight: 500; }
.rc-on {
  background: color-mix(in srgb, var(--seal) 20%, var(--card));
  color: var(--seal-text); border-radius: var(--r-pill);
}
.rc-on.rc-l { border-top-left-radius: 0; border-bottom-left-radius: 0; }
.rc-on.rc-r { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.rc-today b {
  font-weight: 700; text-decoration: underline; text-underline-offset: 3px;
}

.run-miles {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s3);
}
.run-miles li { display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
.run-miles b { font-size: var(--fs-fine); color: var(--muted); font-variant-numeric: tabular-nums; }
.run-miles .won b { color: var(--fg); }
.run-miles .won .seal-stone { background: var(--seal); color: var(--seal-on); box-shadow: 0 1px 2px rgba(0, 0, 0, .18); }

@media (max-width: 560px) {
  .run-miles { grid-template-columns: repeat(2, 1fr); }
}

/* A label and its trailing note on one line — "Activity … September 2026". It
   read as two stacked labels without this, which made the month look like a
   second heading rather than like the scope of the one above it. */
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s3); }
.panel-head .sub { margin: 0; }

/* ── THE FOUR SKILL BARS ──────────────────────────────────────────────────
   Two fills in ONE track. The lighter is what has been started and the darker
   what has settled, and the darker sits on top rather than beside — feedback
   #109 wants the bar to move the moment a word enters the rotation, and #431
   wants the screen to say where somebody actually stands. Two separate bars
   would make a reader compare them; one track makes the relationship the
   picture. */
.sk-block { display: flex; flex-direction: column; gap: var(--s3); }
.sk { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s3); }
.sk li { display: grid; grid-template-columns: 92px 1fr auto; align-items: center; gap: var(--s4); }
.sk-k { font-size: var(--fs-small); color: var(--fg-2); }
.sk-track {
  position: relative; height: 8px; border-radius: 99px;
  background: var(--bg-sunken); overflow: hidden;
}
.sk-track i { position: absolute; inset: 0 auto 0 0; border-radius: 99px; display: block; }
.sk-started { background: color-mix(in srgb, var(--jade) 34%, transparent);
              width: calc(var(--started, 0) * 1%); }
.sk-learned { background: var(--jade); width: calc(var(--learned, 0) * 1%); }
/* The share written inside a jar exists only where the bar is a jar (the
   Progress wall); a bar eight pixels tall has no inside to write in. */
.sk-pc { display: none; }
.sk-n {
  font-size: var(--fs-fine); color: var(--muted); font-variant-numeric: tabular-nums;
  min-width: 8ch; text-align: right;
}

@media (max-width: 560px) {
  .sk li { grid-template-columns: 72px 1fr auto; gap: var(--s3); }
  .sk-n { min-width: 0; }
}

/* ── THE EXAM GYMS ────────────────────────────────────────────────────────
   Fourteen kinds of question through one layout. The card is the SUBJECT — a
   picture, a line, an exchange — and the options are underneath it, always in
   the same place, because a learner working through eight questions should be
   moving their eyes and not re-learning the screen. */
.screen.gym { max-width: 620px; margin: 0 auto; width: 100%; gap: var(--s4); }

.gy-head {
  display: flex; align-items: baseline; justify-content: space-between;
  font-size: var(--fs-fine); color: var(--muted);
}
.gy-part { text-transform: uppercase; letter-spacing: .1em; font-weight: 600; }
.gy-count { font-variant-numeric: tabular-nums; }
.gy-rail { height: 3px; border-radius: 99px; background: var(--bg-sunken); overflow: hidden; }
.gy-rail i { display: block; height: 100%; background: var(--seal); transition: width var(--base) var(--ease); }

/* The instruction, at label size: it is the same eight sentences all round and
   a learner stops reading it by question three. Loud once would be shouting. */
.gy-ask { margin: var(--s2) 0 0; font-size: var(--fs-small); color: var(--fg-2); }

.gy-card {
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s5);
  display: flex; flex-direction: column; align-items: center; gap: var(--s3);
  min-height: 96px; justify-content: center;
}
.gy-play { align-self: center; }
.gy-pic { font-size: 64px; line-height: 1.1; }
.gy-line {
  margin: 0; font-family: 'Noto Serif SC', serif; font-size: 26px;
  line-height: 1.7; text-align: center;
}
/* The 拼 aid, over the line of Chinese it reads (#2527): on the prompt, a
   reply's question, an exchange's turns, the answers and the chips alike,
   in the sans face and the muted ink so the characters stay the line. */
.py-over {
  display: block; font-family: 'Inter', system-ui, -apple-system, sans-serif;
  font-size: var(--fs-fine); font-weight: 400; line-height: 1.35; color: var(--muted);
  letter-spacing: .02em;
}
.gy-line .py-over, .gy-q .py-over { margin-bottom: 2px; }
.ex-otext { display: inline-block; vertical-align: middle; }
.gy-chip .py-over { text-align: center; font-size: 11px; }
.gy-dlg { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.gy-dlg p {
  margin: 0; font-family: 'Noto Serif SC', serif; font-size: 19px; line-height: 1.6;
  padding: 8px 14px; border-radius: var(--r-chip); background: var(--raised);
}
/* The question is not a turn in the dialogue, so it does not wear a turn's
   bubble — it sits under them, marked. */
.gy-q {
  margin: var(--s2) 0 0; font-family: 'Noto Serif SC', serif; font-size: 20px;
  color: var(--fg); text-align: center;
}

.gy-opts { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
/* ── ITS OWN CLASS, NOT `.opt` ────────────────────────────────────────────
   `.opt` on this host is the settings row: `display: flex` and `width: 100%`,
   which turned the chips of an order task into four full-width stacked bars —
   a sentence to arrange, drawn as a list. Third time a class on this host has
   been quietly taken (`.seg`, `.pg-hero`), so these are named for where they
   live and nothing else uses them. */
.gy-o {
  width: 100%; text-align: left; font: inherit; font-size: var(--fs-small);
  padding: 13px 16px; border-radius: var(--r-card);
  background: var(--raised); color: var(--fg); cursor: pointer;
  transition: background var(--fast) var(--ease);
}
.gy-o:hover:enabled { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
.gy-o:disabled { cursor: default; opacity: 1; }
/* An emoji option is a picture and is drawn at picture size — three of them set
   in body type is a row of dots. */
.gy-o-big { font-size: 40px; text-align: center; line-height: 1.3; }
.gy-o.right {
  background: color-mix(in srgb, var(--jade) 16%, var(--raised)); color: var(--fg);
  box-shadow: inset 0 0 0 1.5px var(--jade);
}
.gy-o.wrong {
  background: color-mix(in srgb, var(--seal) 12%, var(--raised)); color: var(--seal-text);
}

/* 连词成句: the slot shows the sentence being built, the chips are the words. */
/* The slot is a PLACE, and on the ink ground `--bg-sunken` is within a shade of
   the page — so the sentence being built floated in nothing and there was no
   target to aim at. A dashed inset ring says "this is where it goes" on both
   grounds without spending a fill. */
.gy-slot {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  min-height: 62px; padding: 12px 14px; border-radius: var(--r-card);
  background: var(--bg-sunken);
  box-shadow: inset 0 0 0 1.5px var(--hair);
  font-family: 'Noto Serif SC', serif; font-size: 22px;
}
.gy-slot .gy-hint { font-family: inherit; font-size: var(--fs-fine); color: var(--muted); }
.gy-chips { display: flex; flex-wrap: wrap; gap: var(--s2); }
.gy-chip {
  font-family: 'Noto Serif SC', serif; font-size: 20px; padding: 9px 15px;
  border-radius: var(--r-chip); background: var(--raised); color: var(--fg);
  cursor: pointer; font-weight: inherit;
  transition: background var(--fast) var(--ease);
}
.gy-chip:hover:enabled { background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
.gy-chip:disabled { opacity: .35; cursor: default; }
.gy-acts { display: flex; gap: var(--s2); }

.gy-verdict {
  display: flex; flex-direction: column; gap: 3px;
  padding: 13px 16px; border-radius: var(--r-card);
}
.gy-verdict.good { background: color-mix(in srgb, var(--jade) 13%, var(--card)); }
.gy-verdict.good b { color: var(--jade-ink); }
.gy-verdict.bad { background: color-mix(in srgb, var(--seal) 11%, var(--card)); }
.gy-verdict.bad b { color: var(--seal-text); }

.gy-next { align-self: stretch; }

/* ── THE ROW OF CONTROLS UNDER A DRILL'S HEAD ─────────────────────────────
   The level, the 拼 switch, the round's XP and the way out — the phone keeps
   these in its bar's trailing slot; on a desk they sit in one quiet line
   under the counter, where the eye does not have to hunt for them. */
.gy-tools { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
.gy-lvl { display: inline-flex; align-items: center; gap: 6px; }
.gy-sel {
  font: inherit; font-size: var(--fs-fine); font-weight: 600; color: var(--fg);
  background: var(--raised); border: 1px solid var(--hair); border-radius: 99px;
  padding: 4px 10px; cursor: pointer;
}
.gy-py-t {
  width: 30px; height: 30px; border-radius: 50%; flex: none; cursor: pointer;
  font-family: 'Noto Serif SC', serif; font-size: 14px;
  background: var(--raised); color: var(--muted); border: 1px solid var(--hair);
}
.gy-py-t.on { background: color-mix(in srgb, var(--seal) 16%, var(--raised)); color: var(--fg); border-color: var(--seal); }
.gy-xp {
  font-size: var(--fs-fine); font-weight: 600; font-variant-numeric: tabular-nums;
  padding: 3px 10px; border-radius: 99px;
  background: color-mix(in srgb, var(--amber) 13%, var(--card));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--amber) 35%, transparent);
}
.gy-end { margin-left: auto; }
.gy-figs { justify-content: center; }
/* A lifted paper task is long: the passage reads left-aligned at a measure. */
.gy-upper { align-items: stretch; }
.gy-upper .gy-line { font-size: 19px; text-align: left; }
.gy-score { align-items: center; text-align: center; padding: var(--s6) var(--s5); gap: var(--s2); }
.gy-score b {
  font-family: 'Fraunces', Georgia, serif; font-size: 56px; font-weight: 600;
  line-height: 1; font-variant-numeric: tabular-nums;
}

/* The sentence decks share the gym's card and rail — same shape, same place on
   the screen, because they are the same act. What differs is that nothing here
   is graded, so the row under the card is flip and move rather than four
   verdicts. */
.pr-card { gap: var(--s2); }
/* Play and play slower, side by side on a listening card (#2426). */
.pr-plays { display: inline-flex; gap: var(--s2); align-self: center; }
.pr-py { margin: 0; font-size: var(--fs-small); color: var(--fg-2); letter-spacing: .02em; }
.pr-en { margin: 0; font-size: var(--fs-small); color: var(--muted); text-align: center; max-width: 40ch; }
/* The veil: the characters are the ANSWER on a listening card, so they are not
   merely hidden, they are visibly withheld. 〓 rather than blank space, or the
   card reads as broken. */
.pr-veil {
  margin: 0; font-size: 34px; letter-spacing: .12em; color: var(--muted); opacity: .45;
}
.pr-acts { justify-content: space-between; }

/* ── THE PLAYGROUND SHELF ─────────────────────────────────────────────────
   Three tracks, each a group of topics. The row carries the two facts that
   decide whether a topic is worth opening — what it is about, and how much is
   in it — because a shelf of names alone is a shelf nobody browses twice. */
.pl-track { display: flex; flex-direction: column; gap: var(--s3); }
.pl-track .sub { margin: 0; }
.pl-zh { font-family: 'Noto Serif SC', serif; color: var(--muted); margin-left: 6px; letter-spacing: 0; }
.pl-topics {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: var(--s2); grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
}
/* A topic is a card whose head OPENS it, the way the phone's row does: the
   blurb, the words and the start button live under the head, not behind a
   second page. The card is the li; the head is a button, so a star or a word
   link inside the panel is not also a press on the row. */
.pl-topic {
  display: flex; flex-direction: column; gap: var(--s3);
  padding: var(--s4); border-radius: var(--r-panel);
  background: var(--card); box-shadow: var(--panel); color: var(--fg);
  align-self: start;
}
.pl-topic.open { box-shadow: var(--panel), inset 0 0 0 1px color-mix(in srgb, var(--seal) 45%, transparent); }
.pl-head {
  display: flex; align-items: center; gap: var(--s4); width: 100%;
  font: inherit; color: inherit; text-align: left; background: none; padding: 0; cursor: pointer;
}
.pl-chev { flex: none; color: var(--muted); font-size: 16px; }
.pl-prog { display: flex; flex-direction: column; gap: 4px; }
.pl-prog .fine { color: var(--muted); }
.pl-bar { height: 6px; border-radius: 99px; background: var(--raised); overflow: hidden; }
.pl-bar i { display: block; height: 100%; background: var(--jade); }
.pl-detail { display: flex; flex-direction: column; gap: var(--s2); align-items: flex-start; }
.pl-detail > .fine { margin: 0; }
.pl-h {
  margin: var(--s2) 0 0; font-size: var(--t-cap); font-weight: 600;
  letter-spacing: .08em; text-transform: uppercase; color: var(--muted);
}
.pl-words { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.pl-words li {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 4px 3px 9px; border-radius: 9px; background: var(--raised);
}
.pl-words a { display: inline-flex; align-items: baseline; gap: 4px; color: var(--fg); text-decoration: none; }
.pl-wh { font-family: 'Noto Serif SC', serif; font-size: 15px; }
.pl-wl { font-size: var(--t-cap); color: var(--muted); font-variant-numeric: tabular-nums; }
.pl-words .star { padding: 0 3px; }
.pl-stretch { margin: 0; color: var(--amber-ink); }
.pl-note { margin-top: var(--s4); }

/* The drill's own pieces. */
.pl-level { margin: 0; display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3); flex-wrap: wrap; }
.pl-level .gy-ask { margin: 0; }
.pl-up { color: var(--amber-ink); }
.pl-ch { color: inherit; text-decoration: none; }
.pl-ch:hover { text-decoration: underline; text-underline-offset: 6px; text-decoration-thickness: 1px; }
.pl-ruby { line-height: 2.1; }
.pl-ruby rt { font-family: system-ui, sans-serif; font-size: 11px; color: var(--muted); letter-spacing: 0; }
/* Two columns, not a wrapping row: four chunks of different widths wrap to
   three and a lonely one, and the odd chip out reads as the odd ANSWER out —
   the shape cue the distractor rule works to remove. */
.pl-opts { display: grid; grid-template-columns: 1fr 1fr; }
.pl-opts .gy-o {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  text-align: center; text-decoration: none; height: 100%;
}
.pl-opts .gy-o b { font-family: 'Noto Serif SC', serif; font-size: 20px; font-weight: 500; }
.pl-og { font-size: var(--fs-fine); color: var(--fg-2); }
.pl-opts .gy-o .fine { color: var(--muted); }
.pl-placed { display: flex; flex-direction: column; gap: var(--s2); }
.pl-placed > .fine { margin: 0; }
.pl-placed-h { display: flex; align-items: center; gap: var(--s3); }
.pl-chunk { font-family: 'Noto Serif SC', serif; font-size: 24px; font-weight: 500; }
.pl-open { margin-left: auto; font-size: var(--fs-small); }
.pl-gloss { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; }
.pl-gloss li { display: flex; flex-direction: column; align-items: center; max-width: 88px; }
.pl-gloss b { font-family: 'Noto Serif SC', serif; font-size: 16px; font-weight: 500; }
.pl-gloss span { font-size: 11px; color: var(--fg-2); text-align: center; line-height: 1.25; }
.pl-gloss li.on b { color: var(--amber-ink); }

/* ── 量 THE REASON, UNDER THE WORD (#2153) ─────────────────────────────────
   One sentence from measure-notes.json: what the measure word means and so
   what it counts. Body size and the main ink, because it is the thing the
   answer was for, not a footnote to it. */
.pl-why { margin: 0; font-size: var(--fs-small); line-height: 1.5; color: var(--fg); }

/* ── THE QUESTION AND WHAT IT TAUGHT, SIDE BY SIDE (#2151) ─────────────────
   *"keine lust zu scrollen, der screen ist so breit, wäre doch cool wenn die
   infos die da unten sind auf der rechten seite angezeigt werden."* The two
   halves are one column on a narrow window, as they always were. On a desk
   the answered drill widens to the sheet and the verdict, the reason and the
   word stand to the right of the question, so nothing needs a scroll. */
.pl-stage, .pl-main, .pl-side { display: flex; flex-direction: column; gap: var(--s4); }
@media (min-width: 960px) {
  /* The head and the tools keep one width before and after the answer, so
     only the question moves: centred while it is asked, then to the left of
     what it taught. */
  .screen.gym.play-drill { max-width: 1060px; }
  .play-drill .pl-stage {
    display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
    align-items: start; gap: var(--s5);
  }
  .play-drill:not(.is-open) .pl-main { grid-column: 1 / -1; justify-self: center; width: min(100%, 620px); }
}

/* ── 牌 AND THEY ARE READ ON PAPER, AT EVERY HOUR (#2150) ───────────────────
   *"mag den kontrast zu dieser tageszeit nicht, ist mir irgendwie zu wenig."*
   `at-training` takes the sheet away for the CAMP, whose buildings are the
   page, and it is on for every route under the camp's tab, so the decks were
   drawn straight onto the hillside too: labels in `--muted` on a sunlit
   slope, and on ink the same labels on a grey mountain, nearly gone. These
   decks get their paper back, nearly solid for the same reason a review
   round's is: somebody is answering on it. The pills that stood in for a
   page are folded away where the page is back. */
@media (min-width: 744px) {
  body.at-training:not(.in-court):not(.in-intro) #view:has(> .play-drill, > .pl-result, > .pl-shelf, > .pr-deck) {
    background: color-mix(in srgb, var(--card) 97%, transparent);
    /* No glass under 97% paper (#2431): `body.in-session #view` has why. */
    backdrop-filter: none;
    box-shadow: var(--panel);
  }
  body.at-training #view:has(> .play-drill, > .pl-result, > .pl-shelf, > .pr-deck) :is(.screen-head, .screen > .note) {
    background: none; box-shadow: none; backdrop-filter: none;
    padding: 0; max-width: none;
  }
}
/* And on the paper, the quiet type one step darker and the answer tiles an
   object rather than a tint: `--raised` on a near-white card is a chip
   nobody can see (tokens.css says so of itself). The tiles take their ground
   and edge from the ink colour, so they hold on paper and on ink alike. */
:is(.play-drill, .pr-deck) .gy-head,
.play-drill :is(.pl-level, .gy-tools) .fine,
.pr-deck .pr-en { color: var(--fg-2); }
.pl-opts .gy-o {
  background: color-mix(in srgb, var(--fg) 6%, var(--card));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 16%, transparent);
}
.pl-opts .gy-o:hover:enabled { background: color-mix(in srgb, var(--fg) 11%, var(--card)); }
.pl-opts .gy-o .fine { color: var(--fg-2); }
.pl-opts .gy-o.right {
  background: color-mix(in srgb, var(--jade) 16%, var(--card));
  box-shadow: inset 0 0 0 1.5px var(--jade);
}
.pl-opts .gy-o.wrong {
  background: color-mix(in srgb, var(--seal) 12%, var(--card));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 45%, transparent);
}

/* ── 字 THE WORD UNDER THE POINTER (#2148) ─────────────────────────────────
   views.js `pointLine`. A word is a link that looks like the line until the
   pointer finds it; the card above it is the text colour on the page colour
   turned round, which is readable on paper and on ink at every hour. */
.hz-w {
  position: relative; color: inherit; text-decoration: none;
  border-radius: 5px; padding: 0 1px;
  transition: background var(--fast) var(--ease);
}
.hz-w:hover, .hz-w:focus-visible {
  background: color-mix(in srgb, var(--seal) 14%, transparent);
  outline: none;
}
.hz-pop {
  display: none; position: absolute; z-index: 20;
  bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
  flex-direction: column; align-items: center; gap: 1px;
  width: max-content; max-width: 240px; padding: 7px 11px 8px;
  border-radius: 10px; background: var(--fg); color: var(--bg);
  box-shadow: 0 8px 22px -10px rgba(0, 0, 0, .45);
  font-family: system-ui, -apple-system, sans-serif; font-size: 13px; line-height: 1.35;
  letter-spacing: 0; text-align: center; white-space: normal; pointer-events: none;
}
.hz-w:hover .hz-pop, .hz-w:focus-visible .hz-pop { display: flex; }
.hz-pop b { font-family: 'Noto Serif SC', serif; font-size: 20px; font-weight: 500; }
.hz-py { font-weight: 600; }
.hz-gl { opacity: .85; }
/* The pinyin under a line nobody has flipped yet, and the button that asks
   for it (#2149). */
.pr-hint { align-self: center; }
.pr-hint.on { box-shadow: inset 0 0 0 1.5px var(--seal); }
.pl-glyph {
  font-family: 'Noto Serif SC', serif; font-size: 22px;
  width: 42px; height: 42px; flex: none; border-radius: 9px;
  display: grid; place-items: center;
  background: var(--raised); color: var(--fg-2);
}
.pl-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pl-body b { font-size: var(--fs-small); }
.pl-body .fine { color: var(--fg-2); }
.pl-n { flex: none; color: var(--muted); font-variant-numeric: tabular-nums; }

@media (max-width: 560px) {
  .pl-topics { grid-template-columns: 1fr; }
}

/* ── THE EXAM HALL ────────────────────────────────────────────────────────
   The shelf, then the paper. The paper reuses the gym's card and options —
   deliberately, because they are the same act — and adds the two things a
   sitting has that practice does not: a clock, and a spent option in a banked
   part. */
.ex-level { display: flex; flex-direction: column; gap: var(--s3); }
.ex-level .sub { margin: 0; }
.ex-papers { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.ex-papers a {
  display: flex; align-items: center; gap: var(--s4); justify-content: space-between;
  padding: var(--s4) var(--s5); border-radius: var(--r-panel); text-decoration: none;
  background: var(--card); box-shadow: var(--panel); color: var(--fg);
}
.ex-papers a:hover { background: color-mix(in srgb, var(--fg) 4%, var(--card)); }
.ex-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.ex-body b { font-size: var(--fs-small); }
.ex-best {
  flex: none; font-size: var(--fs-fine); color: var(--muted);
  padding: 5px 12px; border-radius: var(--r-pill); background: var(--raised);
}
.ex-best.won { color: var(--jade-ink); background: color-mix(in srgb, var(--jade) 14%, var(--card)); }
.ex-disclaimer { max-width: 66ch; }

.ex-blueprint { background: var(--card); box-shadow: var(--panel); border-radius: var(--r-panel); padding: var(--s5); }
.ex-sections { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s3); }
.ex-sections li { display: flex; align-items: baseline; gap: var(--s3); flex-wrap: wrap; }
.ex-zh { font-family: 'Noto Serif SC', serif; color: var(--muted); }
.ex-sections .fine { margin-left: auto; color: var(--muted); }

/* The clock. Tabular figures so it does not jitter, and it turns seal in the
   last minute — that is the one moment the number is the most important thing
   on the screen. */
.ex-clock {
  font-variant-numeric: tabular-nums; font-weight: 600;
  font-size: var(--fs-small); color: var(--fg-2);
}
.ex-clock.low { color: var(--seal-text); }

.ex-passage {
  font-family: 'Noto Serif SC', serif; font-size: 18px; line-height: 1.95;
  text-align: left; width: 100%; max-width: 44em;
}
.ex-letter {
  display: inline-grid; place-items: center; width: 22px; height: 22px;
  border-radius: 6px; margin-right: 10px; flex: none;
  background: var(--bg-sunken); color: var(--muted);
  font-size: var(--fs-fine); font-weight: 600;
}
.gy-o.on { box-shadow: inset 0 0 0 1.5px var(--seal); background: var(--seal-soft); }
.gy-o.on .ex-letter { background: var(--seal); color: var(--seal-on); }
.gy-o:disabled .ex-letter { opacity: .4; }
.gy-o:disabled { opacity: .45; }

.ex-write { display: flex; flex-direction: column; gap: var(--s2); }
.ex-write textarea {
  font-family: 'Noto Serif SC', serif; font-size: 19px; line-height: 1.8;
  padding: 13px 15px; border-radius: var(--r-card); resize: vertical;
  background: var(--card); color: var(--fg); border: 1px solid var(--hair);
}
/* The rubric, as the answer sheet prints it: four labels that light rather than
   a mark out of four. It is checking while you type, which is the point. */
.ex-rubric { list-style: none; margin: 0; padding: 0; display: flex; gap: var(--s2); flex-wrap: wrap; }
.ex-rubric li {
  font-family: 'Noto Serif SC', serif; font-size: var(--fs-fine);
  padding: 5px 12px; border-radius: var(--r-pill);
  background: var(--raised); color: var(--muted);
}
.ex-rubric li.ok { background: color-mix(in srgb, var(--jade) 15%, var(--card)); color: var(--jade-ink); }

.ex-acts { justify-content: space-between; }
.ex-verdict { align-items: center; text-align: center; padding: var(--s5); gap: var(--s2); border-radius: var(--r-panel); }
.ex-verdict.good { background: color-mix(in srgb, var(--jade) 13%, var(--card)); }
.ex-verdict.good b { color: var(--jade-ink); font-size: 26px; }
.ex-verdict.bad { background: var(--raised); }
.ex-verdict.bad b { font-size: 26px; }
.ex-marks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.ex-marks li {
  display: flex; justify-content: space-between; align-items: baseline;
  padding: 12px 16px; border-radius: var(--r-card); background: var(--raised);
}
.ex-mark { font-variant-numeric: tabular-nums; font-weight: 600; }

/* ── THE HALL'S LEVEL ROW, AND WHAT IS IN A PAPER ────────────────────────
   Six chips, the ones above the learner's level locked and faded — `levelChip`
   in the app. The paper's outline folds shut: the summary line is what most
   people want, and at HSK 5-6 the part list is long. */
.ex-chips { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
.ex-chip {
  border: 0; cursor: pointer; font: inherit; font-size: var(--fs-small);
  padding: 9px 4px; font-family: 'Noto Serif SC', serif;
}
.ex-chip:disabled { opacity: .45; cursor: not-allowed; }
.ex-lock { font-size: 11px; margin-right: 2px; }
details.ex-blueprint > summary {
  cursor: pointer; display: flex; flex-wrap: wrap; gap: 4px var(--s3); align-items: baseline;
}
details.ex-blueprint > summary .fine { color: var(--muted); }
details.ex-blueprint[open] > summary { margin-bottom: var(--s4); }
details.ex-blueprint > summary::-webkit-details-marker { display: none; }
details.ex-blueprint > summary::after {
  content: '\25BE'; margin-left: auto; color: var(--muted); transition: transform var(--base) var(--ease);
}
details.ex-blueprint[open] > summary::after { transform: rotate(180deg); }
.ex-sections li { flex-direction: column; align-items: flex-start; gap: 2px; }
.ex-sec-name { display: flex; gap: var(--s2); align-items: baseline; flex-wrap: wrap; }
.ex-sec-name .fine { color: var(--muted); }
.ex-part { font-size: var(--fs-fine); color: var(--muted); font-family: 'Noto Serif SC', serif; }

/* The beat before a section: its name large, in the paper's own characters. */
.ex-sec-intro { text-align: center; display: flex; flex-direction: column; gap: 4px; padding: var(--s4) 0; }
.ex-sec-intro h2 { margin: 0; }
.ex-sec-zh { margin: 0; font-family: 'Noto Serif SC', serif; font-size: 46px; line-height: 1.1; color: var(--seal-text); }

/* The run's header tools: 拼, the clock, and ✕. */
.ex-tools { display: inline-flex; align-items: center; gap: var(--s3); }
.ex-close, .ex-py {
  border: 0; cursor: pointer; font: inherit; line-height: 1;
  width: 28px; height: 28px; border-radius: 50%; display: inline-grid; place-items: center;
  background: var(--raised); color: var(--fg-2);
}
.ex-close:hover { color: var(--fg); }
.ex-py { font-family: 'Noto Serif SC', serif; font-size: 13px; color: var(--muted); }
.ex-py.on { background: var(--seal-soft); color: var(--fg); box-shadow: inset 0 0 0 1px var(--seal); }

/* The kind as a TAG, not a whisper (feedback #1828): the chip is what tells
   somebody mid-section that the rules just changed. */
.ex-kind { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
.ex-tag {
  font-family: 'Noto Serif SC', serif; font-size: var(--fs-fine);
  padding: 4px 11px; border-radius: var(--r-pill);
  color: var(--seal-text); background: var(--seal-soft);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 50%, transparent);
}
.ex-kind .fine { color: var(--fg-2); }
.ex-hear { margin: 0; color: var(--muted); font-size: var(--fs-small); }
.ex-who { color: var(--muted); }
.ex-ptitle { margin: 0 0 var(--s2); font-weight: 600; }
.ex-count { margin: 0; color: var(--muted); font-variant-numeric: tabular-nums; }
.ex-count.ok { color: var(--jade-ink); }
.ex-pics { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }
.ex-pics .gy-o { font-size: 34px; justify-content: center; }

/* The report: the verdict as the certificate prints it, a bar per section,
   and the answer sheet. */
.ex-result-head { align-items: center; text-align: center; }
.ex-seal { margin: 0; font-family: 'Noto Serif SC', serif; font-size: 44px; line-height: 1.1; color: var(--seal-text); }
.ex-seal.good { color: var(--jade-ink); }
.ex-gain { color: var(--jade-ink); }
/* A result screen centres its children at their own width; the section bars
   and the answer sheet need the column's, or a bar has no room to be a bar. */
.result > .ex-sections-block, .result > .ex-answers { width: 100%; text-align: left; }
.ex-marks li { gap: var(--s3); }
.ex-marks li > span:first-child { flex: none; min-width: 9em; }
.ex-bar { flex: 1; height: 6px; border-radius: 99px; background: var(--bg-sunken); overflow: hidden; }
.ex-bar i { display: block; height: 100%; background: var(--seal); }
.ex-bar.good i { background: var(--jade); }
.ex-answers { display: flex; flex-direction: column; gap: var(--s2); }
.ex-sheet { list-style: none; margin: 0; padding: 0; counter-reset: q; }
.ex-sheet li {
  display: flex; gap: var(--s3); align-items: flex-start;
  padding: 10px 0; border-bottom: 1px solid var(--hair); counter-increment: q;
}
.ex-sheet li::before {
  content: counter(q); flex: none; width: 2em; text-align: right;
  color: var(--muted); font-size: var(--fs-fine); font-variant-numeric: tabular-nums; padding-top: 2px;
}
.ex-tick { flex: none; font-weight: 700; width: 1.2em; order: 2; margin-left: auto; }
.ex-sheet li.ok .ex-tick { color: var(--jade-ink); }
.ex-sheet li.miss .ex-tick { color: var(--seal-text); }
.ex-row { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ex-brief { font-family: 'Noto Serif SC', serif; }
.ex-sheet li.ok .ex-yours { color: var(--jade-ink); }
.ex-sheet li.miss .ex-yours { color: var(--seal-text); }

/* HSKK's clock, its picture gloss, and the line a mock take gets instead of a
   verdict. */
.hk-gloss { font-family: inherit; font-size: var(--fs-small); color: var(--muted); }
.hk-kept { text-align: center; color: var(--muted); margin: 0; }
.hk-glyph { margin: 0; font-family: 'Noto Serif SC', serif; font-size: 44px; color: var(--seal-text); }

/* ── SHADOWING ────────────────────────────────────────────────────────────
   Four steps as a rail. The glyph carries the meaning and the English under it
   is the caption — 听 慢 说 评 is a rhythm somebody learns in one round. */
.sh-steps {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s2);
  counter-reset: step;
}
.sh-steps li {
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  padding: 10px 6px; border-radius: var(--r-chip);
  background: var(--raised); color: var(--muted);
  transition: background var(--base) var(--ease), color var(--base) var(--ease);
}
.sh-steps li b { font-family: 'Noto Serif SC', serif; font-size: 20px; font-weight: 500; }
.sh-steps li .fine { font-size: var(--t-cap); }
.sh-steps li.was { color: var(--fg-2); }
.sh-steps li.on { background: var(--seal); color: var(--seal-on); }
.sh-steps li.on .fine { color: var(--seal-on); }

.sh-card { min-height: 128px; }
.sh-line { display: flex; flex-wrap: wrap; justify-content: center; }
.sh-c { font-weight: inherit; }
/* Three states, and only two of them are coloured: a hit is simply the text.
   Marking what went RIGHT in green would leave a line somebody read perfectly
   looking like a diagnostic report. */
.sh-hit { color: var(--fg); }
.sh-tone { color: var(--amber-ink); }
.sh-miss { color: var(--seal-text); text-decoration: underline wavy; text-underline-offset: 6px; }

.sh-turn {
  margin: 0; text-align: center; font-size: var(--fs-small); color: var(--fg-2);
}
.sh-turn.live { color: var(--seal-text); font-weight: 600; }

.sh-score {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: var(--s4); border-radius: var(--r-panel);
}
.sh-score.good { background: color-mix(in srgb, var(--jade) 13%, var(--card)); }
.sh-score.good b { color: var(--jade-ink); }
.sh-score.bad { background: var(--raised); }
.sh-score b {
  font-family: 'Fraunces', Georgia, serif; font-size: 40px; font-weight: 600;
  line-height: 1; font-variant-numeric: tabular-nums;
}
.sh-words { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.sh-words i {
  font-style: normal; font-family: 'Noto Serif SC', serif; font-size: var(--fs-small);
  padding: 3px 9px; border-radius: var(--r-chip); background: var(--card);
}
.sh-err { text-align: center; }
/* What the recogniser is hearing, while it hears it — feedback on its own:
   you can hear yourself being misheard. */
.sh-live {
  margin: 0; min-height: 1.6em; text-align: center;
  font-family: 'Noto Serif SC', serif; font-size: 20px; color: var(--fg-2);
}
.sh-lead { text-align: center; }
.sh-verdict { font-size: var(--fs-small); color: var(--fg-2); }
.sh-sum { text-align: center; max-width: 46ch; }
.sh-acts { justify-content: space-between; }
/* Your take and the line again, one row of chips under the score (#2514). */
.sh-compare { display: flex; flex-wrap: wrap; gap: var(--s2); justify-content: center; }

/* HSKK's two extra blocks: the verdict's sentence, which is longer than a
   score and wants a measure, and the model answer, which is behind a control
   because a sample on screen while somebody composes is a sample being read. */
.hk-detail { max-width: 46ch; text-align: center; }
.hk-model {
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s5);
  align-items: center; text-align: center; gap: var(--s2);
}

/* ── THE WORD PAGE ────────────────────────────────────────────────────────
   The character page's document shape over a word: the subject large, the
   picture beside it on a wide ground and under it on a phone. */
.wd-hero {
  display: flex; align-items: flex-start; gap: var(--s5);
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s5);
}
.wd-lede { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; flex: 1; }
.wd-h {
  font-family: 'Noto Serif SC', serif; font-size: clamp(40px, 6vw, 56px);
  line-height: 1.1; margin: 0;
}
.wd-py { margin: 0; font-size: 20px; }
.wd-m { margin: 0; font-size: var(--fs-small); color: var(--fg-2); max-width: 44ch; }
.wd-acts { display: flex; align-items: center; gap: var(--s2); margin-top: var(--s2); }
.wd-pic {
  width: 168px; height: 168px; flex: none; object-fit: cover;
  border-radius: var(--r-card); background: var(--raised);
}

.wd-block { background: var(--card); box-shadow: var(--panel); border-radius: var(--r-panel); padding: var(--s5); }
.wd-block .sub { margin: 0 0 var(--s3); }
.wd-block p { margin: 0; color: var(--fg-2); max-width: 62ch; line-height: 1.65; }

.wd-chars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.wd-chars a {
  display: flex; align-items: center; gap: var(--s4);
  padding: 10px 14px; border-radius: var(--r-card); text-decoration: none;
  background: var(--raised); color: var(--fg);
}
.wd-chars a:hover { background: color-mix(in srgb, var(--fg) 8%, var(--raised)); }
.wd-c { font-family: 'Noto Serif SC', serif; font-size: 30px; line-height: 1; flex: none; }
.wd-cb { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

.wd-usage { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: var(--s2); }
.wd-usage li { color: var(--fg-2); line-height: 1.65; max-width: 62ch; }

.wd-ex { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s4); }
.wd-ex-h {
  font-family: 'Noto Serif SC', serif; font-size: 21px; line-height: 1.6;
  margin: 0; color: var(--fg);
}
.wd-ex-p { margin: 2px 0 0; font-size: var(--fs-fine); color: var(--muted); }

/* The word in a character page's list is a link now; it must not look like a
   different kind of thing from the rest of the row. */
a.w-h { text-decoration: none; color: inherit; }
a.w-h:hover { text-decoration: underline; text-underline-offset: 4px; }

@media (max-width: 560px) {
  .wd-hero { flex-direction: column; }
  .wd-pic { width: 100%; height: 200px; }
}

/* ── HOW GUSHI WORKS ──────────────────────────────────────────────────────
   Sections rather than one scroll. Every block is prose, so every block gets a
   measure — a paragraph running the full column is a paragraph nobody
   finishes. */
.ab-sec { display: flex; flex-direction: column; gap: var(--s3); }
.ab-sec .sub { margin: 0; }
.ab-block {
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s5); gap: var(--s3);
}
.ab-block p { margin: 0; color: var(--fg-2); line-height: 1.7; max-width: 62ch; white-space: pre-line; }
.ab-block b { font-size: var(--fs-small); }
.ab-tour { margin: 0; }

.ab-path { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.ab-path li {
  background: var(--card); box-shadow: var(--panel); border-radius: var(--r-panel);
  padding: var(--s4) var(--s5); display: flex; flex-direction: column; gap: 4px;
}
.ab-step {
  font-size: var(--t-over); letter-spacing: .12em; font-weight: 600; color: var(--muted);
}
.ab-path b { font-size: var(--fs-small); }
.ab-path p { margin: 0; color: var(--fg-2); line-height: 1.65; max-width: 62ch; }

/* Collapsed by default: six open blocks would make this the longest thing on a
   long page, and the six titles are a usable table of contents. */
.ab-method { display: flex; flex-direction: column; gap: var(--s2); }
.ab-inst { padding: 0; }
.ab-inst summary {
  display: flex; align-items: center; gap: var(--s3);
  padding: 13px var(--s5); cursor: pointer; list-style: none;
}
.ab-inst summary::-webkit-details-marker { display: none; }
.ab-inst summary::after {
  content: ''; margin-left: auto; width: 8px; height: 8px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform var(--base) var(--ease);
}
.ab-inst[open] summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.ab-glyph {
  font-family: 'Noto Serif SC', serif; font-size: 19px;
  width: 34px; height: 34px; flex: none; border-radius: 8px;
  display: grid; place-items: center; background: var(--raised); color: var(--fg-2);
}
.ab-inst > p { padding: 0 var(--s5) var(--s4); }
.ab-take { color: var(--fg) !important; font-weight: 500; }

.ab-swatches { display: flex; gap: var(--s5); flex-wrap: wrap; margin: 0 0 var(--s2) !important; }
.ab-swatches span { display: inline-flex; align-items: center; gap: 8px; font-size: var(--fs-fine); }

/* The five tones in their own colours. The point is that they are seen, not
   read — a paragraph about a colour is a paragraph nobody checks. */
.ab-tones { display: flex; gap: var(--s3); flex-wrap: wrap; margin: 0 0 var(--s2) !important; }
.ab-tones span {
  font-size: var(--fs-fine); font-weight: 600;
  padding: 5px 12px; border-radius: var(--r-pill); background: var(--raised);
}
.ab-exam { display: flex; flex-direction: column; gap: var(--s2); }

/* The third intro question is four rows, not four tiles: each carries a
   sentence rather than a number, and a sentence in a 124px column is four
   words a line. */
.intro-tracks { grid-template-columns: 1fr; }
.intro-tracks .intro-opt { flex-direction: row; align-items: flex-start; gap: var(--s4); }
.intro-opt-g {
  font-family: 'Noto Serif SC', serif; font-size: 20px;
  width: 38px; height: 38px; flex: none; border-radius: 9px;
  display: grid; place-items: center; background: var(--card); color: var(--fg-2);
}
.intro-tracks .intro-opt.on .intro-opt-g { background: var(--seal); color: var(--seal-on); }
/* The two texts stack rather than sitting side by side: on a phone the row is
   about 250px of text and two columns made both of them wrap — "Pass an HSK /
   exam" beside "For a job, a visa, / or a university place" is four ragged
   lines where two would do. */
.intro-tracks .intro-opt-b { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.intro-tracks .intro-opt-n { font-size: var(--fs-small); font-weight: 600; font-family: inherit; }
.intro-tracks .intro-opt-k { line-height: 1.45; }

/* ── FIRST CHINESE ────────────────────────────────────────────────────────
   The shelf, and then one screen at a time. Every illustration is drawn — the
   story pictures all paste a cast figure in as the tone marker and that family
   is being retired, so a drawn line is the only honest way to show a pitch. */
.today-course {
  display: flex; align-items: center; gap: var(--s4); text-decoration: none;
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s4) var(--s5); color: var(--fg);
}
.today-course:hover { background: color-mix(in srgb, var(--fg) 4%, var(--card)); }
.tc-g {
  font-family: 'Noto Serif SC', serif; font-size: 24px; flex: none;
  width: 46px; height: 46px; border-radius: 10px; display: grid; place-items: center;
  background: var(--seal); color: var(--seal-on);
}
.tc-b { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.tc-b b { font-size: var(--fs-small); }

.bs-shelf { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.bs-shelf a {
  display: flex; align-items: center; gap: var(--s4); text-decoration: none;
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s4) var(--s5); color: var(--fg);
}
.bs-shelf a:hover { background: color-mix(in srgb, var(--fg) 4%, var(--card)); }
.bs-g {
  font-family: 'Noto Serif SC', serif; font-size: 22px; flex: none;
  width: 42px; height: 42px; border-radius: 9px; display: grid; place-items: center;
  background: var(--raised); color: var(--fg-2);
}
.bs-b { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.bs-b b { font-size: var(--fs-small); }
.bs-min { flex: none; color: var(--muted); }

/* ── FIRST CHINESE, THE SHELF: ONE SCREEN, FOUR COLOURS (#2239) ────────────
   *"hier kann man wieder scrollen … bitte nicht! … gib ihm mehr Farbe und
   Energie."* The four lessons wear the four tone colours, which the first of
   them teaches, as a stripe, a tile and a minutes chip; a done lesson fills
   its tile and its step on the strip under the title. Two by two on a desk,
   so the shelf is two rows tall rather than four and the page ends above the
   fold of a 720px laptop. The one red is the button into the next lesson.
   Filled tiles carry the podium medals' dark ink (#1C1E18), not white: white
   on the amber is 2:1. */
.bs-home { gap: var(--s5); }
.bs-home .c1 { --c: var(--t1); --ci: var(--t1-ink); }
.bs-home .c2 { --c: var(--t2); --ci: var(--t2-ink); }
.bs-home .c3 { --c: var(--t3); --ci: var(--t3-ink); }
.bs-home .c4 { --c: var(--t4); --ci: var(--t4-ink); }
.bsh-head { display: flex; align-items: center; gap: var(--s5); }
.bsh-face {
  flex: none; display: grid; place-items: center; width: 108px; aspect-ratio: 1; border-radius: 50%;
  background: radial-gradient(closest-side, color-mix(in srgb, var(--seal) 24%, transparent),
              color-mix(in srgb, var(--seal) 7%, transparent) 70%, transparent);
}
.bsh-b { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
.bsh-head.screen-head { margin: 0; }
.bsh-b .fine { margin: 0; max-width: 64ch; }
.bsh-head h1::after { margin-top: var(--s2); }
.bs-steps { list-style: none; display: flex; gap: 6px; margin: 4px 0 0; padding: 0; }
.bs-steps li {
  width: 36px; height: 6px; border-radius: 3px;
  background: color-mix(in srgb, var(--c) 24%, transparent);
}
.bs-steps li.done { background: var(--c); }
.bs-home .bs-shelf { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s3); }
@media (min-width: 900px) {
  .bs-home .bs-shelf { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.bs-home .bs-shelf li { animation: bs-in .42s cubic-bezier(.3, 1.3, .5, 1) backwards; animation-delay: calc(80ms + var(--i, 0) * 70ms); }
@keyframes bs-in { from { opacity: 0; transform: translateY(10px) scale(.98); } }
.bs-home .bs-shelf a {
  position: relative; height: 100%; box-sizing: border-box; overflow: hidden;
  padding: var(--s4) var(--s4) var(--s4) var(--s5);
  background: linear-gradient(120deg, color-mix(in srgb, var(--c) 13%, var(--card)), var(--card) 60%);
  transition: transform var(--fast) var(--ease), background var(--fast) var(--ease);
}
.bs-home .bs-shelf a::before {
  content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--c);
}
.bs-home .bs-shelf a:hover {
  transform: translateY(-2px);
  background: linear-gradient(120deg, color-mix(in srgb, var(--c) 20%, var(--card)), var(--card) 70%);
}
.bs-home .bs-g {
  width: 50px; height: 50px; border-radius: 13px; font-size: 26px;
  background: color-mix(in srgb, var(--c) 20%, var(--card)); color: var(--ci);
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--c) 50%, transparent);
}
.bs-home li.done .bs-g { background: var(--c); color: #1C1E18; box-shadow: none; }
.bs-home li.next a { box-shadow: var(--panel), inset 0 0 0 1.5px color-mix(in srgb, var(--c) 70%, transparent); }
.bs-home .bs-min {
  align-self: flex-start; padding: 2px 9px; border-radius: var(--r-pill); font-weight: 600;
  background: color-mix(in srgb, var(--c) 15%, transparent); color: var(--ci);
}
.bs-home li.done .bs-min { background: var(--c); color: #1C1E18; }
.bsh-acts { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s3); }
@media (prefers-reduced-motion: reduce) {
  .bs-home .bs-shelf li { animation: none; }
  .bs-home .bs-shelf a:hover { transform: none; }
}

/* One screen, centred in what it has. A lesson screen is one idea and it should
   sit in the middle of the space rather than hugging the top of it. */
.screen.basics-lesson { max-width: 620px; margin: 0 auto; width: 100%; gap: var(--s4); }
.bs-screen {
  display: flex; flex-direction: column; align-items: center; gap: var(--s4);
  text-align: center; min-height: 340px; justify-content: center;
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s6) var(--s5);
}
.bs-screen h1 { font-size: clamp(22px, 3vw, 28px); line-height: 1.2; margin: 0; }
.bs-body { margin: 0; color: var(--fg-2); line-height: 1.7; max-width: 44ch; white-space: pre-line; }
.bs-hero { margin: 0; font-family: 'Noto Serif SC', serif; font-size: 56px; line-height: 1; }

.bs-contour { display: block; }
.bs-tone { display: flex; flex-direction: column; align-items: center; gap: var(--s2); }
.bs-tone-sm { gap: 4px; }
.bs-hanzi {
  margin: 0; font-family: 'Noto Serif SC', serif; font-size: 64px; line-height: 1;
  cursor: pointer;
}
.bs-tone-sm .bs-hanzi { font-size: 44px; }
.bs-py { margin: 0; font-size: 22px; font-weight: 600; }
.bs-hook {
  margin: 0; font-size: var(--fs-small); color: var(--fg);
  padding: 10px 16px; border-radius: var(--r-card);
  background: var(--raised); max-width: 40ch;
}
.bs-swatches, .bs-marks { display: flex; gap: var(--s4); flex-wrap: wrap; justify-content: center; }
.bs-marks span { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.bs-marks b { font-size: 30px; line-height: 1; }

.bs-emoji { margin: 0; font-size: 62px; line-height: 1; }
.bs-letters { font-family: 'Fraunces', Georgia, serif; font-size: 46px !important; }
.bs-not { margin: 0; font-size: var(--fs-small); color: var(--muted); }

.bs-build { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; justify-content: center; }
.bs-part { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.bs-part b { font-family: 'Noto Serif SC', serif; font-size: 44px; line-height: 1; font-weight: 400; }
.bs-whole b { font-size: 56px; }
.bs-plus { font-size: 22px; color: var(--muted); }

.bs-anatomy { display: flex; align-items: center; gap: var(--s5); flex-wrap: wrap; justify-content: center; }
.bs-card {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: var(--s5) var(--s6); border-radius: var(--r-panel);
  background: var(--bg-sunken); box-shadow: inset 0 0 0 1px var(--hair);
}
.bs-card-h { font-family: 'Noto Serif SC', serif; font-size: 52px; line-height: 1; }
.bs-card-p { font-family: 'Noto Serif SC', serif; font-size: 17px; color: var(--fg-2); }
.bs-anatomy-l { margin: 0; padding-left: 20px; text-align: left; display: flex; flex-direction: column; gap: 7px; }
.bs-anatomy-l li { color: var(--fg-2); line-height: 1.6; max-width: 34ch; font-size: var(--fs-fine); }

.bs-q { max-width: 30ch; }
.bs-say { align-self: center; }
.bs-opts { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); width: 100%; max-width: 30ch; }
.bs-opts .gy-o { text-align: center; }
.bs-opts-tone { display: grid; grid-template-columns: repeat(2, 1fr); }
.bs-opts-tone .gy-o { display: grid; place-items: center; padding: var(--s3); }
.bs-verdict { margin: 0; font-size: var(--fs-small); font-weight: 600; }
.bs-verdict.good { color: var(--jade-ink); }
.bs-verdict.bad { color: var(--seal-text); }
.bs-acts { justify-content: space-between; }

/* The per-deck limits, behind a disclosure on the setup screen: they are a
   thing somebody changes once and then forgets, and two steppers open by
   default would be two controls between choosing a deck and starting. */
.setup-limits {
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: 0;
}
.setup-limits summary {
  cursor: pointer; list-style: none; padding: 13px var(--s5);
  display: flex; align-items: center;
}
.setup-limits summary::-webkit-details-marker { display: none; }
.setup-limits summary::after {
  content: ''; margin-left: auto; width: 8px; height: 8px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform var(--base) var(--ease);
}
.setup-limits[open] summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.sl-body { padding: 0 var(--s5) var(--s5); display: flex; flex-direction: column; gap: var(--s2); }
.sl-row { display: flex; align-items: center; justify-content: space-between; gap: var(--s4); }
.sl-k { font-size: var(--fs-small); }
.sl-body .fine { margin: 0 0 var(--s3); color: var(--fg-2); max-width: 52ch; }
.sl-body .btn-danger { align-self: flex-start; }

/* The usage log: a table, and it looks like one. Tabular figures, because four
   columns of counts that jitter as they change are four columns nobody scans. */
.us-table { width: 100%; border-collapse: collapse; font-size: var(--fs-fine); }
.us-table th {
  text-align: right; font-weight: 600; color: var(--muted);
  font-size: var(--t-over); letter-spacing: .1em; text-transform: uppercase;
  padding-bottom: 8px;
}
.us-table th:first-child, .us-table td:first-child { text-align: left; }
.us-table td {
  text-align: right; padding: 7px 0; color: var(--fg-2);
  font-variant-numeric: tabular-nums;
  border-top: 1px solid var(--hair);
}
.us-events { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.us-events li {
  display: flex; justify-content: space-between; align-items: baseline;
  font-size: var(--fs-fine); color: var(--fg-2);
}
.us-events b { font-variant-numeric: tabular-nums; color: var(--fg); }

/* ── THE LESSONS ──────────────────────────────────────────────────────────
   A row per lesson, with the Library's own three states — the same colours
   mean the same thing here as on a character tile, so this screen is read with
   knowledge somebody already has. */
.ls-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.ls-row {
  display: flex; align-items: center; gap: var(--s4); text-decoration: none;
  background: var(--card); box-shadow: var(--panel);
  border-radius: var(--r-panel); padding: var(--s4) var(--s5); color: var(--fg);
}
.ls-row:hover { background: color-mix(in srgb, var(--fg) 4%, var(--card)); }
.ls-n {
  font-family: 'Fraunces', Georgia, serif; font-size: 20px; font-weight: 600;
  width: 38px; height: 38px; flex: none; border-radius: 9px;
  display: grid; place-items: center; background: var(--raised); color: var(--fg-2);
  font-variant-numeric: tabular-nums;
}
.ls-learning .ls-n { background: color-mix(in srgb, var(--amber) 18%, var(--raised)); color: var(--amber-ink); }
.ls-learned .ls-n { background: var(--jade); color: var(--ink); }
.ls-b { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.ls-b b { font-size: var(--fs-small); }
/* A meter, not a percentage: what this column is asked is "how much is left",
   which a bar answers at a glance and a number does not. */
.ls-meter {
  width: 74px; height: 4px; flex: none; border-radius: 99px;
  background: var(--bg-sunken); overflow: hidden;
}
.ls-meter i { display: block; height: 100%; background: var(--jade); border-radius: 99px; }
.ls-go { display: flex; flex-direction: column; align-items: flex-start; gap: var(--s2); }
.ls-go .fine { margin: 0; color: var(--fg-2); max-width: 52ch; }
.ls-grammar { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s3); }
.ls-grammar li { display: flex; flex-direction: column; gap: 2px; }
.ls-grammar b { font-family: 'Noto Serif SC', serif; font-size: var(--fs-small); }

/* One list of similar words, with the reason at the end of each row. Two
   visually identical sections distinguished only by a heading is what this
   replaces — the difference is a property of the ROW, so that is where it is. */
.wd-similar { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.wd-similar a {
  display: flex; align-items: center; gap: var(--s4); text-decoration: none;
  padding: 9px 14px; border-radius: var(--r-card);
  background: var(--raised); color: var(--fg);
}
.wd-similar a:hover { background: color-mix(in srgb, var(--fg) 8%, var(--raised)); }
.wd-s-h { font-family: 'Noto Serif SC', serif; font-size: 22px; flex: none; }
.wd-s-b { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.wd-s-why {
  flex: none; color: var(--muted); white-space: nowrap;
  padding: 3px 9px; border-radius: var(--r-pill); background: var(--card);
}

/* ══ 院 THE COURTYARD ═══════════════════════════════════════════════════════
   The front door. court.js carries the argument for why this host has one and
   the app does not; this is how it is put together.

   ── THE PROBLEM THIS LAYOUT SOLVES, WHICH IS NOT OBVIOUS ─────────────────
   Six marks have to land on six specific ridges in a drawing, at every window
   size, for ever. The drawing is an SVG with `preserveAspectRatio="xMidYMid
   slice"` — it crops to fill, exactly like `object-fit: cover` — so a mark
   positioned as a per cent of the COURT would drift off its ridge the moment
   the court stopped being 16:10, and it would drift by a different amount at
   every width. That is the kind of bug that looks like "the pin is a bit off"
   for months.

   So there is a `.court-stage` between them: a box that is ALWAYS exactly
   1600:1000, sized to cover the court and centred in it, and both the drawing
   and the marks live inside it. Inside that box `slice` and `meet` are the same
   thing, no crop happens, and `left: 65.375%` is the same point in the picture
   that x 1046 is in the SVG. The cover arithmetic is the `max()` pair on width
   and height — 1600/1000 is 1.6, so covering needs `max(100cqw, 160cqh)` — and
   `container-type: size` on the court is what makes `cq` units mean the court.

   ── WHAT ZOOMS AND WHAT DOES NOT ──────────────────────────────────────────
   The STAGE scales, so the marks travel with their ridges — a mark that stayed
   put while the mountain moved under it would be the whole illusion gone. Each
   mark then counter-scales itself by `1/--zoom` about its own centre, so its
   position moves and its type does not: text rendered at 1.06 is soft text, and
   soft text is the tell that separates "the landscape leaned" from "somebody
   zoomed the page".

   The parchment is outside the stage entirely and never moves. That is the
   point of a camera: the world shifts, the notes in your hand do not.
   ═══════════════════════════════════════════════════════════════════════════ */
.court {
  /* ── A ROOM, AND NOT A BLOCK ON A PAGE ──────────────────────────────────
     It was `margin-left: calc(50% - 50vw)` inside the content column — a
     full-bleed element that happened to fill the window. It cannot be that any
     more: it has to survive every navigation, so it lives beside `#view`
     rather than inside it, and the honest way to express "the room this app
     happens in" is `position: fixed`.

     Fixed also solves what the full-bleed trick only approximated. There is no
     column to escape, no gutter arithmetic, and no dependence on `#view` being
     centred — the room is the viewport, at every width, for ever.

     100dvh AND NOT 100vh. On iOS Safari `vh` is the height with the address bar
     hidden, so a room sized in `vh` is taller than the window at rest and the
     bottom of the drawing sits under the browser chrome. `dvh` is the box that
     is actually visible. */
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  container-type: size;
  background: var(--bg-sunken);

  /* Where the picture leans, set by learn.js from the route's own place. The
     defaults are the centre, so a room nobody has navigated in is a room that
     has not moved. */
  --ox: 50%;
  --oy: 50%;
  /* NOT 1. The drawing is 1600x1000 with a lot of deliberate air in it — the
     sky above the ranges and the empty paper below the ledge — which is right
     for a picture you look at and too much margin for a picture you live in. A
     base of 1.06 crops three per cent off every edge and lands the ranges, the
     ledge and the two people in the frame together. */
  --zoom: 1.06;
  /* The hour's light, set by the phase rules below. `none` and not `unset`: an
     undefined custom property drops the whole declaration, and a dropped
     `filter` is a different bug from an empty one. */
  --art-filter: none;
  --sky-wash: transparent;
  --art-dim: 0;
  /* ── ONE UNIT OF THE DRAWING, IN PIXELS ─────────────────────────────────
     The stage is cover-fitted at 1600x1000, so its width on screen is
     `max(100cqw, 160cqh)` — and a single unit of the drawing's own coordinate
     space is that over 1600. Every building's size is then a number of UNITS
     rather than a number of pixels, which is what makes it a fixed size in the
     picture and grow with it on a wider screen.

     Before this each was 74 CSS px: the same at every distance, and relatively
     smaller the bigger the window — the two things that make a thing read as
     pasted on rather than as part of what it stands in. */
  --u: calc(max(100cqw, 160cqh) / 1600);
}

/* ── THE ROOM WITH A SCREEN OPEN IN IT ────────────────────────────────────
   *"Have this as an environment for each subpage, still having all the
   important functions but always inside this beautiful space."*

   Two things change and neither is the picture. The marks go, because they are
   the map's own navigation and at 2.6 they are enormous and behind the paper
   anyway — the way back to all six is the seal, top left. And the drawing takes
   a little more of the page's ground over it, because a screen is a thing
   somebody is reading and a landscape at full strength under a paragraph is a
   landscape competing with it.

   The panel does the rest. It is opaque enough to read on and translucent
   enough that the ranges show through at its edges, which is the whole
   sensation the owner asked for. */
/* ── THE BUILDING YOU CAME TO STAYS STANDING ──────────────────────────────
   *"when we click on training [there should be] a training house … which you
   can also see slightly in the background. Out of this you can then click on
   those different options to do the training."*

   The other five go; the one you pressed does not. And it is where you would
   expect it to be without any arithmetic, because `transform-origin` at the
   place means the place is the FIXED POINT of the zoom — it does not travel to
   the centre, it stays exactly where it was on screen while the rest of the
   landscape grows away from it. So the pavilion is still out on the left where
   the pavilion was, half beside the sheet and half showing through it, at
   double the size.

   Quiet, and not pressable. It is not a link any more: you are already there,
   and the options are on the paper in front of it. */
.court.is-open .mark { opacity: 0; pointer-events: none; }
.court.is-open .mark.is-here { opacity: .5; }
/* ── AND ITS LABEL AND ITS FACES GO ─────────────────────────────────────
   The tag names the place you are already in. The faces are the map's way of
   saying who is around it — and on the two places that HAVE faces, the screen
   you just opened is the place those faces live: every one of them is a seat
   with a green ring in the schoolyard, or a row on the board in the sky.
   Drawing them a second time at the building's foot, half behind the panel and
   at a size nothing else on the screen shares, is the same information twice
   with the worse copy on top. On the map they are the whole point; here they
   are four initials lying on the ground. */
.court.is-open .mark.is-here .mark-tag,
.court.is-open .mark.is-here .around { opacity: 0; }
.court.is-open .court-veil::after { opacity: calc(var(--art-dim) + .12); }
.mark, .mark-tag { transition: opacity var(--slow) var(--ease); }

/* ── AND THE GRASS GOES, BECAUSE IT STOPS BEING GRASS ────────────────────
   Feedback #2032, a learner on the Class screen: *"what are those 4 lines on
   the hill, they are laying over the class visual."*

   Eight blades of grass on the ledge either side of the two people, each a
   14-unit stroke at 26% ink — texture, at the size the drawing was drawn at.
   The Class opens at `zoom: 4`, the deepest of the five, and aimed along the
   bank at x 1240, which is exactly where four of the eight stand. At four
   times the size they are 40px crescents floating on empty paper, one of them
   straight across the schoolhouse's railing, and nothing on the screen
   explains what they are. They read as marks because at that size they ARE
   marks: a blade of grass is only grass next to something it is grass beside.

   THE SCENE IS NOT EDITED, and that is the point of doing it here. The same
   drawing is the landing page's and the Teaching Platform's sign-in, and at
   1.06 the grass is right on all three. What is wrong is the magnification,
   so what changes is what the magnification does.

   ON `is-open` AND NOT ON THE FIVE PLACE CLASSES, which would be the exact
   trigger and a worse rule: five names to keep in step with `PLACES`, for a
   difference nobody can see. `is-open` is every screen that stands over the
   map — the five zoomed ones and the three that leave it at rest — and on
   those three the grass is behind a 980px sheet in the middle of the window.
   It fades on the transition the marks already use, so the whole background
   settles as one thing rather than in two moves.

   The mist bands, the water strokes and the ridge line are NOT in here. They
   are wide runs that keep reading as landscape at any size; these are the one
   detail drawn small enough that growing it changes what it is. */
.court-art .reeds { transition: opacity var(--slow) var(--ease); }
.court.is-open .court-art .reeds { opacity: 0; }

/* ── THE DOOR, AND IT IS A DOOR NOW ──────────────────────────────────────
   *"make that bigger like an actual door."* It was a 20px gate on a pill —
   which is a button with an icon on it, and this screen has spent a week
   getting away from buttons with icons on them. The rest of the map is drawn
   at the size of a building; the one control that is supposed to be an
   ARCHITECTURAL feature was the smallest thing on it.

   So: a real gate, 46px of it, standing on the ground in the bottom-left
   corner with the words beside it rather than inside a capsule. No pill — the
   paper is a soft rounded sheet behind the pair, which is how the bar's own
   controls read, and the gate itself is ink on it like every building.

   Absent on the map, because a door out of the room you are standing in is a
   door to nowhere; absent under 900px, where there is no room to be out of and
   the bar at the top is the ordinary way back. */
/* ── AND NOW IT IS ONLY THE DOOR ────────────────────────────────────────
   *"Hinaus auf die Karte ist auch zu fett der Button … mache doch einfach
   eine Tür im Gushi-Orange … mehr nicht"* (#2064). The paper behind it and
   the words beside it went: the gate stands on its own in the seal's colour,
   which is the colour of every other thing on this host that you press. The
   words stay for a screen reader and as the tooltip. */
/* ── ONE BUTTON, THE SAME ON EVERY SCREEN ───────────────────────────────
   *"hier unten links sieht der home button jetzt so ganz scheisse aus"*
   (#2142). The bare gate needed help wherever the ground behind it was busy,
   and it got it one place at a time: a dark box with a lit top edge in the
   Library (#2083), nothing anywhere else. So it looked like a different
   control in every room, and in the Library like a hole cut in the shelves.

   Now it has one small paper of its own, round, in the material the bar's
   own controls are made of (`.top-brand`, `.acct`): the same tint, the same
   blur, the same sunk shadow. The gate stays the Gushi orange on it. That
   reads on paper, on ink, over a painted sky and over a wall of books, with
   no per-room rule, because the paper is what it stands on everywhere. */
.door {
  position: fixed; z-index: 2;
  left: clamp(16px, 2.2vw, 32px); bottom: clamp(14px, 1.8vw, 24px);
  display: none; align-items: center; justify-content: center;
  width: 56px; height: 56px; padding: 0 0 2px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--card) 78%, transparent);
  backdrop-filter: blur(10px);
  /* The hairline is what keeps its edge on ink over dark timber, where the
     tint alone is dark on dark. */
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 16%, transparent),
              var(--sink), 0 4px 14px -6px rgba(20, 14, 8, .45);
  color: var(--seal); text-decoration: none;
  transition: translate var(--base) var(--ease), color var(--fast) var(--ease);
}
.door-art { display: block; width: 36px; height: 36px; color: inherit; }
.door-art svg { width: 100%; height: 100%; display: block; }
.door-t {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
/* It lifts on hover rather than changing shape: a door you are about to walk
   through should feel like it is opening, and two pixels of rise is the
   smallest thing that reads as that. */
.door:hover { translate: 0 -2px; color: color-mix(in srgb, var(--seal) 82%, #000); }
.door:focus-visible { outline: 2px solid var(--seal); outline-offset: 3px; }
/* The Library's dark box for #2083 ("ein bisschen eng an der Bibliothek")
   is gone: the round paper above is that distance now, on every screen. */
/* ── THE GATE NEEDS A GUTTER TO STAND IN ─────────────────────────────────
   Not 744, which is where the desk begins: the gate stands in the margin
   BESIDE the sheet, and at 950 that margin is 43px against a 46px gate — it
   stood on the paper's bottom corner. 1120 is the sheet at its widest (980)
   plus room for the gate and its air on both sides. Under that, the way home
   is the seal in the bar, which is the same link. */
@media (min-width: 1120px) {
  body:not(.in-court):not(.in-intro) .door { display: inline-flex; }
}

.court.is-leaning { --zoom: 1.14; }

/* The stage: always 16:10, always covering, always centred. Nothing else in
   this file may position against the court directly. */
.court-art,
.marks {
  position: absolute;
  left: 50%;
  top: 50%;
  width: max(100cqw, 160cqh);
  height: max(62.5cqw, 100cqh);
  translate: -50% -50%;
}

.court-art,
.marks {
  transform: scale(var(--zoom));
  transform-origin: var(--ox) var(--oy);
  transition: transform 900ms var(--ease);
}

/* ── 平板 AN IPAD IS NOT 16:10 (#2526) ───────────────────────────────────
   *"I feel on the iPad it is not 100% centred."* Cover is right for a
   laptop and wrong for the glass an iPad has. The six marks span the
   drawing from x 150 (Training's label, the League's dais) to x 1360
   (Library's), and the cover box crops whatever is wider than the window:
   at 4:3 that was about 130 units a side before the resting zoom took
   three per cent more, so Training's label and the dais ran off the left
   edge while the Library stood clear on the right. Lopsided, and on a
   portrait iPad worse than lopsided: League, Training Camp and the Library
   were off the glass altogether, with no other way to reach two of them.

   NARROWER THAN 16:10, the resting zoom is 1 (the three per cent it crops
   is air on a laptop and Training's label on an iPad; a place's own zoom is
   set inline by learn.js and is untouched), and the stage covers only as
   long as it still shows 1400 of the 1600 units, x 100 to 1500, which is
   every place with room round its label. Where covering would crop more
   (4:3 and every portrait window) it fits those 1400 units to the width
   instead, centred on the same axis as before, so the two figures and the
   parchment stay on one line. What is left above and below is the
   drawing's own sky and paper carried on, in its own light (`--art-filter`
   is the hour's, as on the drawing), so it reads as more sky and more
   ground rather than as a picture with a border. `--u` follows the stage,
   so every building keeps its size in the picture. */
@media (max-aspect-ratio: 8 / 5) {
  .court {
    --zoom: 1;
    --stage-w: min(160cqh, 100cqw * 8 / 7);
    --u: calc(var(--stage-w) / 1600);
  }
  .court-art,
  .marks { width: var(--stage-w); height: calc(var(--stage-w) * .625); }
  .court-art::before,
  .court-art::after {
    content: '';
    position: absolute; left: 0; right: 0;
    height: 100cqh;
    filter: var(--art-filter);
    /* The drawing's own four seconds (below), or the two turn at different
       speeds and the seam shows for the length of a phase change. */
    transition: filter 4s linear;
    pointer-events: none;
    z-index: 1;
  }
  /* The top of the drawing is the wash at full strength: #2C6B71 at .92
     over the paper. Over the drawing's edge by two pixels, because the
     drawing's own first row is antialiased (paper and wash each half a
     pixel) and drew a pale hairline where the two met. */
  .court-art::before { bottom: calc(100% - 2px); background: #3C767B; }
  .court-art::after { top: calc(100% - 2px); background: #F4F1E9; }
}
/* The hour turns over while somebody is sitting here — learn.js watches the
   clock and swaps `data-phase` — and a landscape that snaps from dusk to night
   between two blinks is a flicker. Four seconds is slower than anything else on
   this host by an order of magnitude, and deliberately: it is the only
   transition here that nobody is meant to notice happening. */
.court-art .art { transition: filter 4s linear; }
.court-veil { transition: background 4s linear; }

/* THE DRAWING IS INERT, AND THIS IS THE LINE THAT MAKES IT SO.
   assertDecorationInert() in build-web.mjs exists because a full-bleed
   decorative layer that could take a click cost the Teaching Platform its range
   picker on 2026-09-05. This layer is very much larger than that one was. */
.court-art { pointer-events: none; z-index: 0; }
.court-art .art { position: absolute; inset: 0; width: 100%; height: 100%; }

/* ══ THE HOUR IS THE BACKGROUND ════════════════════════════════════════════
   The owner: *"Der Hintergrund passt sich immer an die Zeit an, an die
   Tageszeit."* court.js's `phaseFor` splits the day into five and views.js
   prints the answer as `data-phase`; these ten declarations are what that
   means in light.

   ── WHY IT IS A FILTER AND A WASH AND NOT A SECOND DRAWING ────────────────
   The scene is one file on three hosts — the landing page, the Teaching
   Platform's sign-in and this — and five copies of 330 lines of geometry, one
   per hour of the day, is the drift the partial exists to prevent. It is also
   unnecessary: an ink wash is almost entirely LIGHT, so the whole of dawn
   against noon is a hue and a brightness. Not one coordinate moves.

   Two layers, because one cannot do it. `--art-filter` turns the ink itself —
   the ranges go warm at dusk because they are lit warm, not because something
   warm is lying on top of them. `--sky-wash` is a gradient over the top of the
   frame, which is where the colour of an hour actually lives; the ground at
   dusk is in shadow and the sky is on fire, and a filter alone tints both the
   same amount.

   ── NIGHT IS AN INVERSION, AND IT HAS TO BE ───────────────────────────────
   The drawing is ink on cream: `<rect fill="#F4F1E9">` is the first thing in
   it. Dimming it does not make night, and it is worth knowing why, because
   dimming is the obvious thing to try. The mountains are DARKER than the paper,
   so multiplying the whole thing down makes the paper a readable grey and the
   mountains black on black — the picture loses exactly the part that is the
   picture. Only an inversion turns a dark-on-light drawing into a light-on-dark
   one. `hue-rotate(180deg)` after it puts the teal back where it started, and
   what comes out is the same landscape after dark, with two lit figures on the
   ledge. */
.court[data-phase="dawn"] {
  /* ── DAWN IS THE SLOW MORNING, NOT AN ICY ONE ───────────────────────────
     It was built as the opposite of dusk — *"at dusk the ground has been warm
     all day; at dawn the sky is pink over air that is still cold"* — which is
     true of the hour and wrong about the FEELING the owner named for it: seven
     in the morning, before work, a morning that is opening up. Cold blue in the
     valley is a winter dawn, and this is not that.

     So the pink stays and the blue in the valley goes almost entirely, replaced
     by the faintest cool at the very bottom — enough that the ground reads as
     not yet lit, not so much that it reads as frost. Warmer filter than before
     and the same lift in contrast the morning gets, for the same reason: the
     ridges have to come out of the mist. */
  --art-filter: sepia(.03) saturate(1.16) brightness(1.07) contrast(1.1);
  --sky-wash:
    radial-gradient(42% 34% at 22% 4%, rgba(255, 214, 186, .58) 0%, rgba(255, 206, 178, 0) 74%),
    linear-gradient(to bottom,
      rgba(255, 158, 138, .34) 0%, rgba(255, 196, 164, .2) 18%,
      transparent 48%, rgba(126, 140, 168, .1) 100%);
}
.court[data-phase="morning"] {
  /* ── A BLOSSOMING MORNING, AND THE HAZE HAD TO LIFT ─────────────────────
     *"Für mich ist das jetzt gerade zu diesig … da muss irgendwie ein bisschen
     mehr Sonne auch in dem Bild sein … wenn jemand um 7/8 Uhr morgens lernt,
     vor der Arbeit, dass er dann so einen aufblühenden Morgen hat, oder
     vielleicht so leicht diesig, aber nicht zu viel. So ein schöner langsamer
     Morgen."*

     The sun was already here and the haze was eating it. The drawing carries
     three big cream ellipses — the weather at the top of the ranges and two
     mist bands that cut them off at the knees — and they are in the shared
     partial, on three hosts, so they cannot be turned down there. What CAN be
     turned up is contrast, and contrast is exactly what pulls a ridge back out
     of a mist without removing the mist: 1.14 lifts the ranges clear while the
     bands stay bands. That is the "leicht diesig, aber nicht zu viel".

     And more sun than before: brighter, warmer, and the gold reaches further
     across. The wash is still a CORNER and not a band — a direction is the
     whole difference between morning and midday — but it now carries a small
     hot core where the sun would be, which is what makes light look like it has
     a source rather than like a tint. */
  /* SEPIA STAYS LOW. At .13 it took the ranges to olive: sepia collapses hue,
     and a teal mountain dragged a third of the way to brown under a gold sky is
     yellow-green. A sunny morning is warm LIGHT over cool shadows, so the
     warmth belongs in the wash and the filter only lifts and sharpens. */
  --art-filter: sepia(.04) saturate(1.18) brightness(1.09) contrast(1.14);
  --sky-wash:
    radial-gradient(38% 30% at 15% 2%, rgba(255, 244, 214, .72) 0%, rgba(255, 236, 186, 0) 72%),
    radial-gradient(130% 105% at 12% -12%,
      rgba(255, 220, 148, .52) 0%, rgba(255, 232, 180, .28) 38%, transparent 74%),
    linear-gradient(to bottom, rgba(255, 246, 220, .22) 0%, transparent 48%);
}
.court[data-phase="day"] {
  /* The drawing exactly as it was drawn. Somebody chose these greens and teals
     against a reference, and the middle of the day is when they are right. */
  --art-filter: none;
  --sky-wash: transparent;
}
.court[data-phase="dusk"] {
  /* Longer and redder than dawn, and darker underneath. The hour the owner
     named twice. */
  /* Held down deliberately. At sepia .55 with saturate 1.7 the whole picture
     was one orange and the four ranges stopped being four — sepia collapses
     hue, so past about a third of it the layering that makes this a landscape
     goes with it. The colour of the hour belongs in the wash; the filter only
     has to say the light is warm. */
  --art-filter: sepia(.3) saturate(1.3) brightness(.99) contrast(1.03);
  --sky-wash: linear-gradient(to bottom,
    rgba(214, 84, 60, .4) 0%, rgba(240, 140, 80, .24) 20%,
    transparent 48%, rgba(58, 38, 60, .16) 100%);
}
.court[data-phase="night"] {
  --art-filter: invert(1) hue-rotate(180deg) brightness(1.04) contrast(.9) saturate(.72);
  /* Indigo from the top and nothing at the bottom, because the inversion has
     already made the ground dark and a second darkness there would close it. */
  --sky-wash: linear-gradient(to bottom,
    rgba(28, 34, 78, .5) 0%, rgba(34, 42, 86, .3) 34%, transparent 70%);
}

.court-art { pointer-events: none; z-index: 0; }
.court-art .art {
  position: absolute; inset: 0; width: 100%; height: 100%;
  filter: var(--art-filter);
}

/* ══ 鸟 THE SKY, A LAYER OF ITS OWN (#2328) ══════════════════════════════════
   views.js `liftBirds` has the argument: the birds were transforms on SVG
   children, which the main thread has to run, restyle and repaint on every
   frame, over tiles full of blur. Here they are HTML, and every animation
   below is a `transform` on an element of its own, which the compositor runs
   without the main thread and without repainting anything.

   Each keyframe is scene.css's, converted. The flock is the whole stage, so
   its percentages are of 1600 by 1000 units (-420px is -26.25%). A bird's box
   is its glyph's viewBox, 22 by 12 units, so the bob and the drift are
   percentages of that (.5px is 2.27%). The wing turns about the body, which
   is 50% 61.67% of that box: (0, -.6) in a viewBox that starts at (-11, -8).

   The hour: the drawing's `--art-filter` turned the birds' ink with the rest
   of it, and a filter over a layer this size would be a full-screen pass on
   every frame, which is the cost this layer exists to remove. So the ink is a
   colour per hour instead, worked out from the filter: the night's inversion
   makes #28383C the pale #C0CCCF, the dusk's sepia warms it a shade, and the
   morning filters move it by less than anybody can see. */
.court-art .sky { position: absolute; inset: 0; pointer-events: none; --sky-ink: #28383C; }
.court[data-phase="dusk"] .sky { --sky-ink: #31393A; }
.court[data-phase="night"] .sky { --sky-ink: #C0CCCF; }
.sky-flock {
  position: absolute; inset: 0;
  animation: sky-fly-r linear infinite;
}
.sky-flock--l { animation-name: sky-fly-l; }
.sky-at { position: absolute; width: 0; height: 0; }
.sky-bird {
  position: absolute;
  left: calc(-11 * var(--u)); top: calc(-8 * var(--u));
  width: calc(22 * var(--u)); height: calc(12 * var(--u));
  animation: sky-bob ease-in-out infinite;
}
.sky-bd {
  position: absolute; inset: 0;
  animation: sky-drift var(--drift, 12s) ease-in-out infinite alternate;
  animation-delay: var(--driftd, 0s);
}
.sky-wing {
  position: absolute; inset: 0; width: 100%; height: 100%;
  overflow: visible;
  transform-origin: 50% 61.667%;
  fill: none; stroke: var(--sky-ink); stroke-width: 2.1; stroke-linecap: round;
  transition: stroke 4s linear;
  animation: sky-beat-l var(--beat, 1.9s) linear infinite;
  animation-delay: var(--beatd, 0s);
}
.sky-wing--r { animation-name: sky-beat-r; }
@keyframes sky-fly-r {
  0%   { transform: translate(-26.25%, 2.4%); }
  25%  { transform: translate(11.875%, .4%); }
  50%  { transform: translate(50%, -.8%); }
  75%  { transform: translate(88.125%, -2%); }
  100% { transform: translate(126.25%, -2.4%); }
}
@keyframes sky-fly-l {
  0%   { transform: translate(126.25%, -1.8%); }
  25%  { transform: translate(88.125%, -.2%); }
  50%  { transform: translate(50%, 1%); }
  75%  { transform: translate(11.875%, 1.8%); }
  100% { transform: translate(-26.25%, 2.2%); }
}
@keyframes sky-bob {
  0%   { transform: translate(0, 0); }
  34%  { transform: translate(2.273%, -28.333%); }
  66%  { transform: translate(-1.364%, 9.167%); }
  100% { transform: translate(0, 0); }
}
@keyframes sky-drift { from { transform: translate(0, 0); } to { transform: translate(22.727%, -29.167%); } }
@keyframes sky-beat-l {
  0%   { transform: rotate(11deg);  animation-timing-function: cubic-bezier(.45, 0, .55, 1); }
  20%  { transform: rotate(-25deg); animation-timing-function: cubic-bezier(.4, 0, .6, 1); }
  50%  { transform: rotate(10deg);  animation-timing-function: cubic-bezier(.45, 0, .55, 1); }
  70%  { transform: rotate(-21deg); animation-timing-function: cubic-bezier(.4, 0, .6, 1); }
  100% { transform: rotate(11deg); }
}
@keyframes sky-beat-r {
  0%   { transform: rotate(-11deg); animation-timing-function: cubic-bezier(.45, 0, .55, 1); }
  20%  { transform: rotate(25deg);  animation-timing-function: cubic-bezier(.4, 0, .6, 1); }
  50%  { transform: rotate(-10deg); animation-timing-function: cubic-bezier(.45, 0, .55, 1); }
  70%  { transform: rotate(21deg);  animation-timing-function: cubic-bezier(.4, 0, .6, 1); }
  100% { transform: rotate(-11deg); }
}
@media (prefers-reduced-motion: reduce) {
  .sky-flock, .sky-bird, .sky-bd, .sky-wing { animation: none; }
  .sky-flock { transform: translateX(var(--rest, 0%)); }
}
/* ── AND THE HAZE HOLDS STILL ─────────────────────────────────────────────
   Two cream ellipses drift sideways, one across the ranges and one over the
   garden's lake: five per cent of their width over three and four minutes,
   which is a unit every few seconds and nothing an eye can follow. Each is
   drawn through a blur, 24 and 7 units deep, so each of those frames was the
   blur computed again over a band the width of the picture. On this host they
   stand where they are; the landing page keeps its drift. */
.court-art .haze { animation: none; }
/* ── AND THE WINGS IN THE DEFINITION STAND STILL (#2389) ──────────────────
   #2328 lifted the birds out into HTML and left one thing behind: the
   drawing still carries the `#bird` glyph in its <defs>, and scene.css still
   beats that glyph's two wing paths. Nothing on this host draws the glyph
   (the sky birds have wings of their own, `.sky-wing`), but an animated
   transform on an SVG child is main-thread work whether anybody sees it or
   not: measured in Chrome with the GPU on, the Training camp ran a style
   recalc, a layout, a pre-paint and a full layer-tree commit 26 times a
   second standing still, and the walkers and the breathing chips were
   competing with it for every frame. Jonas and Paul both saw flicker. */
.court-art .wing { animation: none; }
/* ── AND WHILE A DIALOG IS UP, THE PICTURE HOLDS STILL ────────────────────
   The second half of #2328 was the typing: *"das schreiben hier vom text ist
   auch super verzögert"*, in the feedback box, over the camp. Everything
   behind a modal dialog is behind its backdrop and nobody is watching it, so
   the birds, the walkers and the breathing chips pause where they are (not
   stop: they carry on from the same frame when it closes) and every frame
   goes to the keyboard. */
:root:has(dialog[open]) :is(.sky-flock, .sky-bird, .sky-bd, .sky-wing, .camp-walk, .camp-walk svg),
:root:has(dialog[open]) .camp .dchip-lit { animation-play-state: paused; }

/* ══ 日 THE SMALL SUN (#2167) ══════════════════════════════════════════════
   Its place is three numbers learn.js writes on the court once a minute
   (`setSun`); its colour is the hour's. Inside an <svg> a CSS pixel is one of
   the drawing's units, so the translate is in the drawing's own coordinates.
   The transitions are four seconds, like the hour's own filter: they smooth a
   phase change and a tab coming back, and nothing runs between two minutes. */
.court { --sun-disc: #E4633D; --sun-halo: #FFB98A; }
.court[data-phase="morning"] { --sun-disc: #F0AE4A; --sun-halo: #FFE0A2; }
.court[data-phase="day"] { --sun-disc: #F2BE5C; --sun-halo: #FFEDC4; }
.court[data-phase="dusk"] { --sun-disc: #D8462B; --sun-halo: #FF9868; }
.court-art .sun { opacity: var(--sun-o, 0); transition: opacity 4s linear; }
.court-art .sun-move {
  transform: translate(calc(var(--sun-x, 606) * 1px), calc(var(--sun-y, 480) * 1px));
  transition: transform 4s linear;
}
.court-art .sun-disc { fill: var(--sun-disc); transition: fill 4s linear; }
.court-art #sun-glow stop { stop-color: var(--sun-halo); transition: stop-color 4s linear; }

/* ══ 园 THE GARDEN, AND WHERE IT STEPS ASIDE (#2190) ════════════════════════
   The two places that zoom past three times (the camp and the class) would
   put a willow the size of a building behind their own ground; everywhere
   else the garden stays in the landscape behind the sheet, as the rest of the
   drawing does. It fades on the marks' own transition. */
.court-art .garden { transition: opacity var(--slow) var(--ease); }
body:is(.at-training, .at-klass) .court.is-open .court-art .garden { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .court-art .sun, .court-art .sun-move, .court-art .sun-disc, .court-art .garden { transition: none; }
}

/* THE INK THEME DARKENS THE HOUR; IT DOES NOT REPLACE IT.
   The first version inverted the drawing whenever the reader had chosen dark,
   which fought the phase rules the moment they existed — night would have been
   inverted twice and come out as noon. The hour owns the picture. What the
   theme owns is how much of it there is: a bright morning behind a dark
   parchment at full strength is a screen with two minds, so on ink the drawing
   sits
   under a scrim of the page's own ground. Nothing about which hour it is
   changes; there is simply less light in the room. */
/* ── HALF WAS TOO MUCH, AND IT SHOWED IN A MORNING ────────────────────────
   A screenshot of ink mode at nine in the morning came back grey-green and
   gloomy: the phase had laid a warm sun over the picture and this scrim took
   half of it straight back off. The theme is allowed to take the edge off a
   bright hour so a dark parchment does not float on a glare — it is not allowed
   to decide what hour it is. A quarter does the first without the second. */
:root:not([data-theme="light"]) .court { --art-dim: .26; }
/* NIGHT TAKES NOTHING, and the zero is the whole point of the line rather than
   a line doing nothing. The night phase is already an inverted picture on a
   dark sky; scrimming it further took the far ranges to black-on-black and made
   the mark discs — which are `--card`, and dark on ink — disappear into the
   ground they stand on. Night in ink and night on paper are the same picture,
   which is correct: night is not a theme, it is the hour. */
:root:not([data-theme="light"]) .court[data-phase="night"] { --art-dim: 0; }

/* A breath of paper at the very top and bottom so the drawing does not end at a
   hard line against the nav or the content below it. Not a vignette: a vignette
   darkens the corners, and this picture's corners are where the cliffs are. */
/* ── THE VEIL DOES THREE THINGS, AND THEY STACK IN THIS ORDER ─────────────
   The hour's wash at the top, the theme's scrim over everything, and a fade at
   the bottom where the picture meets the day's numbers below it.

   THERE IS NO FADE AT THE TOP any more, and there must not be one: the drawing's
   own sky wash is strongest in its top fifth and gone by two fifths, and laying
   paper over it made the whole picture read as weather rather than as a sky.
   The hour's wash goes exactly where that fade used to be, which is the point —
   the top of the frame is where the colour of an hour lives.

   AND THE BOTTOM ONE IS NEARLY GONE TOO. It was 72% of the page's ground, put
   there when half a screen of Today lived under the picture and the two had to
   meet somewhere. Nothing is under the picture any more — the map does not
   scroll — so on ink it was 72% of near-black laid across the foot of a bright
   morning, which reads as a grey wall the landscape stops at. 30% over a
   shorter run is enough to keep the very bottom edge from being a ruled line,
   which is all it was ever needed for. */
.court-veil {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background:
    var(--sky-wash),
    linear-gradient(to top,
      color-mix(in srgb, var(--bg) 30%, transparent) 0%, transparent 9%);
}
/* The theme's scrim, its own layer so it sits over the hour's wash rather than
   under it. `background-color` on the veil would have gone beneath the
   gradients and tinted nothing. */
.court-veil::after {
  content: ''; position: absolute; inset: 0;
  background: var(--bg);
  opacity: var(--art-dim);
}

.marks { z-index: 2; pointer-events: none; }

/* ── A MARK: A BUILDING IN THE PICTURE, AND A NAME ON PAPER ───────────────
   views.js has the argument for the drawings; this is what holds them in the
   landscape. No disc, no blur, no shadow — they are ink on the same paper the
   mountains are on, and the moment one of them gets a background it is a
   control somebody stuck onto a painting.

   ── THE BUILDING SCALES WITH THE WORLD AND THE NAME DOES NOT ─────────────
   That split is the whole rule, and the first version had it backwards: the
   MARK counter-scaled by `1 / --zoom`, so a pavilion was the same number of
   pixels whether the picture was at 1.06 or at 1.9. A building that does not
   grow when you walk toward it is not in the place, it is on the glass. Now the
   art rides the transform like the ridge it stands on, and only the label
   counter-scales — because a label is paper, and paper stays where the reader's
   eyes are.

   `translate(-50%, -50%)` centres the mark on its point and nothing scales it,
   so the point is fixed and the drawing grows about it. */
/* ── ANCHORED BY THE FOOT, NOT BY THE MIDDLE ─────────────────────────────
   `--y` is the GROUND now — a value read off the ridge curve in the partial,
   which court.js records for every place. `translate(-50%, -100%)` puts the
   bottom edge of the drawing on it.

   It was `-50%, -50%`, which centred the whole mark — art plus label plus the
   row of faces — on the coordinate. So a `y` taken from a ridge put the middle
   of a stack of three things on the rock and the building itself forty pixels
   above it, which is most of why they floated. The label and the faces are in
   `.mark-below` now, out of the flow, so the mark's height is the drawing's
   height and nothing else. */
.mark {
  position: absolute;
  left: var(--x); top: var(--y);
  transform: translate(-50%, -100%);
  transition: opacity 420ms var(--ease);
  pointer-events: auto;

  display: block;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  color: var(--fg);
  font: inherit;
  -webkit-tap-highlight-color: transparent;
}

/* Everything below the ground line: the label, and the faces when there are
   any. Out of the mark's flow so the anchor above stays the drawing's foot. */
.mark-below {
  position: absolute;
  top: 100%; left: 50%;
  translate: -50% 6px;
  display: grid; justify-items: center; gap: 5px;
  /* ── AND IT MUST NOT INHERIT THE BUILDING'S WIDTH ───────────────────────
     The mark is `display: block` now and its width is the drawing's — 64 units
     for the house on the left cliff, which is about 58px. An absolutely
     positioned child resolves its own auto width against that, so the row of
     faces under the League folded into a column two wide and six tall, running
     down the cliff toward Training. `max-content` is the fix and it is the
     whole fix: the label and the faces get the width they need and the
     building keeps the width it is. */
  width: max-content;
}

.mark-art {
  display: block;
  /* ── THE DRAWING'S INK, AS A LITERAL, AND NOT THE INTERFACE'S ───────────
     It was `var(--fg)` at 88%, which sounds more correct than a hex and is the
     bug: `--fg` follows the THEME and the drawing's ink follows the PICTURE. In
     ink mode at nine in the morning the landscape is not inverted — only night
     inverts — so the two figures on the ledge sat there in near-black while
     five buildings around them were cream. Two kinds of ink in one painting.

     `#1C1E18` is the value the partial gives the teacher, the student and the
     book. Written out because that is what it is: not a token, not a theme
     colour, but the colour of the ink in this particular drawing. It goes dark
     or pale with the picture through `--art-filter` below, which is exactly how
     the figures do it. 88% of it, so the centre of the composition still
     leads.

     AND A DROP OF THE MOUNTAINS' TEAL IN IT (#2230): the garden's pavilion
     and bridge are the figures' ink let down with the ranges' teal, and five
     buildings in pure black beside them read as a second, harder painting.
     The same mix here, so a roof on the right cliff and a roof on the lake are
     one ink. */
  color: color-mix(in srgb, #1C1E18 76%, #3E646C);

  /* ── THE SAME LIGHT AS THE LANDSCAPE, AND THIS IS WHAT WAS MISSING ───────
     *"nicht einfach nur so ein paar Striche drüber haben, sondern es so wirklich
     fest auch so einen Bestandteil von dem Bild sein."*

     Making them masses instead of outlines was half of integration. This is the
     other half, and it is the half a screenshot found: the picture goes warm at
     dusk, cool at dawn, pale at night — and the buildings stayed the same flat
     ink through all five, because they are in a sibling layer that no filter
     touched. A pavilion lit differently from the mountain it stands on is a
     pavilion pasted onto the mountain, however solid it is.

     `--art-filter` is the hour, set by the phase rules above and already
     applied to the drawing itself. One declaration puts the buildings under it.

     IT ALSO REPLACES THE MANUAL NIGHT FLIP. Night's filter inverts, so the ink
     goes pale and the holes punched through a mass go dark, both for free and
     both exactly as the ranges and the two figures do — which is what the flip
     was hand-simulating and getting only approximately right. */
  filter: var(--art-filter);
  transition: color var(--base) var(--ease), filter 4s linear;
}
/* `--court-hole` is the paper showing through a mass — a doorway, a window
   band. Same argument as the ink above: it is the DRAWING's paper and not a
   token, and `#F4F1E9` is the first rect in scene.html. It needs no theme and
   no phase branch, because `--art-filter` inverts it at night along with
   everything else in the picture. */
/* ── A LITTLE TOWN, NOT FIVE MONUMENTS ────────────────────────────────────
   *"Das soll wie so ein kleines Städtchen sein, so ein Training, ein kleines
   Häuschen … sodass das so ein bisschen süß ist."*

   100px was monument scale. The two figures on the ledge are about 45px on
   screen, so a building at 100 is twice a person's height and reads as a temple
   somebody would make a pilgrimage to. 74 is about one and a half — a house
   with a person's height of roof over it, which is what a little town is made
   of. It also stops them competing with the two people at the centre, who are
   still the composition's subject. */
/* THE SIZE IS IN THE DRAWING'S UNITS. `--w` comes off the place, `--u` is one
   unit in pixels, and `height: auto` lets the viewBox keep the proportion — so
   the buildings differ by distance and all of them grow with the picture. */
.mark-art svg {
  --court-hole: #F4F1E9;
  width: calc(var(--w, 74) * var(--u)); height: auto; display: block;
}
/* ── THE FOOT GOES INTO THE GROUND (#2230) ────────────────────────────────
   *"es soll aussehen wie ein Bild."* Every building ended in a plinth with a
   hard bottom edge, the one straight horizontal line on a hillside, and a
   straight edge is where a cut-out shows. The last eighth of each drawing
   fades now, as the ranges fade at their foot, so the terrace is standing IN
   the ground rather than on the glass. Not the dragon: he is in the sky and
   his clouds are his own. */
.mark:not(.mark-league) .mark-art svg {
  -webkit-mask-image: linear-gradient(to top, transparent 0, #000 13%);
  mask-image: linear-gradient(to top, transparent 0, #000 13%);
}

/* ── AND THEY STAND ON SOMETHING ──────────────────────────────────────────
   *"nicht einfach nur so darüber liegend, wie es gerade ist."* The last thing
   keeping them on the glass was that nothing was under them. A mass with no
   shadow is a sticker however well it is lit, because contact is what the eye
   uses to decide whether a thing is IN a picture or ON it — and one soft
   ellipse under a base is the whole of contact.

   Inside `.mark-art`, so the hour's filter reaches it: at dusk the shadow goes
   warm with the ground it falls on, and at night it inverts along with
   everything else, which is what a shadow on a pale night ground should do.

   Not the dragon. He is flying, and a shadow under something in the air is the
   one shadow that gives the game away. */
.mark-art { position: relative; }
.mark-art::before {
  content: '';
  position: absolute;
  left: 50%; bottom: 3%;
  width: 82%; height: 13%;
  translate: -50% 0;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(28, 30, 24, .3), rgba(28, 30, 24, 0) 78%);
  pointer-events: none;
}
/* ── THE FLYING ONE KEEPS ITS MIDDLE, AND HAS NO SHADOW ─────────────────
   A dragon in the air has no foot on any contour, so anchoring him by one would
   be the single mistake that undoes every other anchor on the screen. And no
   contact shadow, for the same reason: a shadow under something in the air is
   the one shadow that gives the game away. */
.mark-flying { transform: translate(-50%, -50%); }
.mark-league .mark-art::before { display: none; }
/* He is drawn in a 124x62 box because a dragon is twice as long as he is tall,
   and a square would have cost half his length. At 100px wide that makes him
   50px tall — visibly slighter than five buildings that are 100 both ways — so
   he gets the extra width back. */

/* ── THE NIGHT FLIP IS GONE; THE FILTER DOES IT ───────────────────────────
   Three declarations used to turn the buildings pale at night and re-colour the
   holes punched through them, by hand, to imitate what the inversion was doing
   to everything else in the picture. `filter: var(--art-filter)` on `.mark-art`
   does it properly and for every phase, not just the one somebody remembered.

   WHAT IT COST was the lantern's lit window, and then the lantern went too.
   Both are worth recording. Every hole punched through a building is a doorway
   with nothing behind it and should go dark at night, except a fire box — which
   at midnight is the one thing on this landscape that would actually be lit. A
   glow on the BUTTON kept it, because the filter does not reach there.

   Today's mark was then removed entirely, so `.mark-today` matches nothing and
   the glow went with it. If the day ever gets a landmark back, this rule and
   the drawing in views.js come back together. */
/* THERE IS NO `.mark-art-dragon` ANY MORE. It gave the League's mark 132px and
   no paper, because the mark WAS dragon.js's dragon and he is drawn from a
   340-unit box — his strokes shrink with him, and under about 100px the body
   lines fall below a pixel. The signpost is drawn in this file's own idiom now
   and is the same size as the other five, which is the point of it. */

/* ── THE NAME, ALWAYS ─────────────────────────────────────────────────────
   *"Always show directly the name, not only the character."* It used to arrive
   on hover, on the argument that six permanent labels are six captions and a
   caption under a building is a museum. That was wrong about what these are: a
   museum labels things you are looking at, and these are places you are going
   to — a signpost with its name hidden until you touch it is not a signpost.

   A faint sheet behind it, because the alternative is text on a mountain. It is
   the only paper left on this screen and it is four millimetres of it.

   The counter-scale is here and nowhere else. Text rendered at 1.9 is soft
   text, and soft text on a landscape reads as part of the landscape. */
.mark-tag {
  display: flex; align-items: center; gap: 5px;
  padding: 3px 10px;
  border-radius: var(--r-pill);
  white-space: nowrap;
  /* ── READABLE, WHICH 62% WAS NOT ────────────────────────────────────────
     *"Der Name soll immer direkt gezeigt werden."* It was being shown — a crop
     of it came back with "League" as a grey ghost on a sheet you could not see.
     Always-visible and barely-visible are the same thing to somebody reading
     the screen. 88% and the text at full weight: a label on a landscape has to
     win against a mountain, and the mountain is not going to move. */
  /* ── AND A HAIRLINE, BECAUSE 88% CAN BE INVISIBLE ───────────────────────
     A sheet at 88% of the card colour reads beautifully over a mountain and
     disappears entirely over pale sky, which is most of this drawing at nine in
     the morning — Training, Progress and Class all lost their sheets while
     Library and League kept theirs, purely because of what happened to be
     behind them. A fill can match its ground; an EDGE cannot. One ring at eight
     per cent of the ink is what makes the label a label on any part of the
     picture, and it is cheaper than pushing the fill up until it wins
     everywhere, which would put five opaque chips back on a painting. */
  background: color-mix(in srgb, var(--card) 88%, transparent);
  box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 9%, transparent);
  backdrop-filter: blur(8px);
  scale: calc(1 / var(--zoom));
  transition: background var(--base) var(--ease), opacity var(--slow) var(--ease),
              scale 900ms var(--ease);
}
.mark-g {
  font-family: var(--serif-hz); font-size: 14px;
  color: var(--seal);
  transition: color var(--base) var(--ease);
}
.mark-n {
  font-size: var(--t-cap); font-weight: 500; letter-spacing: .01em;
  color: var(--fg);
  transition: color var(--base) var(--ease);
}

.mark:hover .mark-art,
.mark:focus-visible .mark-art { color: var(--fg); }
.mark:hover .mark-g,
.mark:focus-visible .mark-g { color: var(--seal); }
.mark:hover .mark-n,
.mark:focus-visible .mark-n { color: var(--fg); }
.mark:hover .mark-tag,
.mark:focus-visible .mark-tag { background: var(--card); }

/* ══ WHO IS AROUND ═════════════════════════════════════════════════════════
   *"where the user can see other users profiles if they are right now online …
   like they are popping up."*

   A row of faces under the League's label and under the Class's, and a stone
   dais under the League's only — the League is a competition and a class is a
   room, which is a difference worth drawing rather than flattening.

   THE ROW COUNTER-SCALES, like the label and unlike the buildings. A face is a
   photograph of a person and photographs do not belong to the landscape's
   perspective; a 128px avatar rendered at 1.9 is a blurry 128px avatar. Same
   split as `.mark-tag`, same reason. */
.around {
  position: relative;
  display: grid;
  justify-items: center;
  margin-top: 5px;
  scale: calc(1 / var(--zoom));
  transition: scale 900ms var(--ease);
}
/* ── THE DAIS, AND ONLY THE LEAGUE HAS ONE ───────────────────────────────
   Two stone steps under the row, as wide as the row is — which is why they are
   drawn here and not as an SVG in views.js. The row is as wide as however many
   faces arrived, and a fixed-width drawing either has the faces spilling off
   both ends (which is what the first attempt did) or has to stretch, and a
   stone step drawn at a different scale from the buildings beside it is not a
   stone step.

   THE CLASS HAS NONE, and that difference is the point. The League is a
   competition, so its people stand on something; a class is a room, so its
   people stand in the schoolyard the drawing already gave them. Flattening the
   two would have been less code and less true.

   They take the hour's light like everything else in the picture. */
/* ── A MIST BAND, WHICH IS WHAT THIS DRAWING CALLS A CLOUD ────────────────
   views.js has the four wrong answers that came before it. This is the right
   one: the partial draws its own clouds as single soft ellipses — the weather
   at the tops of the ranges and the two bands that cut them off at the knees —
   and so does this, in ink rather than cream because it carries faces.

   One element. A single ellipse has no seams to show, which is the entire
   reason it works where four overlapping lobes did not.

   The blur is 3px and not more: past about four it stops being a bank of cloud
   and becomes a shadow under the row, which is a different object and reads as
   one. `--art-filter` first in the chain so the hour reaches it before the
   softening — a cloud lit differently from the sky it hangs in is a cloud from
   another painting. */
/* ── AND THEN A BRUSH STROKE (#2230) ──────────────────────────────────────
   *"… wie auch die einzelnen Symbole … noch edler."* The mist band above was
   the right shape and the wrong material: a grey lozenge blurred at 3px reads
   as a shadow floating in the sky, and beside a garden whose one hard line is
   a brush stroke along a shore it was the only thing on the map with no edge
   at all. So the faces stand on a single stroke of the same ink: pressed down
   on the left, dragged to a fine lifting tail on the right, the paper showing
   through where the brush ran dry. A horizontal run survives being stretched
   to the row's width, which is the one thing a cloud with lobes could not do,
   so it is a mask over the ink and not a drawing of fixed width. The ink is
   the buildings' own and the hour's filter still reaches it. */
.mark-league .around { padding-bottom: 13px; }
.around-cloud {
  position: absolute;
  left: 50%; bottom: 1px; translate: -50% 0;
  width: calc(100% + 34px); height: 12px;
  background: color-mix(in srgb, #1C1E18 78%, #3E646C);
  filter: var(--art-filter);
  opacity: .78;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M5 11.4C4.6 8 7.8 6.2 13 6.4C40 7.4 92 8.4 140 9C166 9.3 185 8.8 198 7.2C188 10.8 166 12.6 140 13C92 13.6 42 14.6 15 15C8.4 15.1 5.3 13.8 5 11.4Z'/%3E%3Cpath d='M120 14.6C146 14.4 170 13.4 186 11.6C172 15 148 16.4 120 16.2Z' opacity='.55'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M5 11.4C4.6 8 7.8 6.2 13 6.4C40 7.4 92 8.4 140 9C166 9.3 185 8.8 198 7.2C188 10.8 166 12.6 140 13C92 13.6 42 14.6 15 15C8.4 15.1 5.3 13.8 5 11.4Z'/%3E%3Cpath d='M120 14.6C146 14.4 170 13.4 186 11.6C172 15 148 16.4 120 16.2Z' opacity='.55'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
  pointer-events: none;
  z-index: -1;
}

.around-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 3px; }

/* ── NO ROW, NO CLOUD ────────────────────────────────────────────────────
   The slot is in the markup from the first frame and empty until the read
   lands, and the cloud is drawn on the slot — so an empty row drew a cloud with
   nobody on it, both before the faces arrived and for good if nobody was
   around. An empty stage under a dragon is a strange enough object to be worth
   one selector.

   `:has` and not a class from learn.js: the emptiness is a fact about the DOM
   and the DOM is where it should be read. Nothing here needs JavaScript to
   notice that a container has no children. */
.around:has(.around-row:empty) { display: none; }

/* ── POPPING UP ───────────────────────────────────────────────────────────
   Each face arrives on its own beat — `--i` is its index and the delay is
   `--i * 70ms`, which is fast enough that six of them read as one gesture and
   slow enough that you see them land one after another rather than all at once.

   `backwards` on the fill, or every face is at full size for the frame before
   its delay elapses and the whole row flashes in before it animates. */
.face {
  width: 26px; height: 26px;
  border-radius: 50%;
  overflow: hidden;
  display: grid; place-items: center;
  background: var(--raised);
  box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--card) 92%, transparent), var(--sink);
  font-size: 11px; font-weight: 600; color: var(--fg-2);
  animation: face-pop 420ms var(--ease) backwards;
  animation-delay: calc(var(--i, 0) * 70ms);
}
.face img { width: 100%; height: 100%; object-fit: cover; display: block; }
.face-more { background: var(--card); color: var(--fg-2); font-size: 11px; }
@keyframes face-pop {
  from { opacity: 0; scale: .4; }
  to   { opacity: 1; scale: 1; }
}
@media (prefers-reduced-motion: reduce) { .face { animation: none; } }

/* The name over a face, on hover and on focus (#2111). `overflow: hidden` on
   the face itself clips the picture to a circle, so the label cannot live
   inside it — it is a sibling layer positioned against the row, which is why
   `.face` keeps `position: relative` and the label is absolutely placed
   against it with `overflow: visible` of its own. */
.face { overflow: visible; }
.face > img, .face > .face-i { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; display: block; }
/* ── UNDER THE FACE, AND ON THE FACE (#2186) ─────────────────────────────
   *"wenn ich über den aktiven User hovere wird der Name so verbuggt nur
   angezeigt."* Two faults, one picture. The note above promised
   `position: relative` on `.face` and no rule ever set it, so the label was
   placed against `.around` instead: centred on the whole ROW whichever face
   was hovered. And it opened UPWARD, straight onto the place's own name tag
   six pixels above the row, so two translucent pills lay on each other and
   the blur behind the top one smeared the words of the one under it.

   So the face is the anchor now and the label opens DOWNWARD, over the dais
   or the yard, where nothing else carries words. It is opaque: a name that
   lets the landscape through is a name that reads differently at every
   hour. */
.face { position: relative; }
.face:hover, .face:focus-visible { z-index: 4; }
.face-name {
  position: absolute; left: 50%; top: calc(100% + 6px);
  translate: -50% 0;
  padding: 3px 9px; border-radius: var(--r-pill);
  background: var(--card);
  box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 10%, transparent),
              0 6px 14px -8px rgba(26, 24, 19, .4);
  color: var(--fg); font-size: var(--fs-fine); font-weight: 600; line-height: 1.3;
  white-space: nowrap;
  opacity: 0; pointer-events: none; transition: opacity var(--fast) var(--ease);
  z-index: 3;
}
.face:hover .face-name, .face:focus-visible .face-name { opacity: 1; }

/* ══ 报 THE CAPSULE BESIDE THE DRAGON AND THE SCHOOLHOUSE (#2184, #2243) ════
   views.js (`newsCard`) has the argument and learn.js (`courtNews`) the
   timing. What this owns is the object. It was a frosted notification card,
   322px of paper with a header line, a medal and two sentences; #2243 asked
   for it simpler and more beautiful, and it is one line now: a capsule in the
   page's own ink with its paper for letters (the speech bubbles' inversion, so
   it is dark on the day's paper and light on the night's ink), holding the
   seal, the place, the move, a face and the XP. Nothing on it is a sentence.

   It slides in from the place it belongs to, a little overshoot and settle,
   and lifts away when it goes. Without motion it only fades. It lives in the
   court, in screen space, so it does not grow when the picture does. */
.nc {
  position: absolute; z-index: 5;
  max-width: calc(100vw - 32px);
  display: inline-flex; align-items: center; gap: 10px;
  padding: 6px 16px 6px 6px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--fg) 90%, transparent);
  -webkit-backdrop-filter: blur(18px) saturate(1.4); backdrop-filter: blur(18px) saturate(1.4);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--bg) 14%, transparent) inset,
              0 18px 36px -18px rgba(20, 14, 8, .6), 0 2px 6px rgba(20, 14, 8, .12);
  color: var(--bg); text-decoration: none; white-space: nowrap;
  opacity: 0; translate: -14px 0; scale: .92; transform-origin: left center;
  transition: opacity 360ms var(--ease),
              translate 560ms cubic-bezier(.2, 1.25, .3, 1),
              scale 560ms cubic-bezier(.2, 1.25, .3, 1);
}
.nc.is-in { opacity: 1; translate: 0 0; scale: 1; }
.nc.is-out { opacity: 0; translate: 0 -10px; scale: .98; transition-duration: 380ms; }
.nc:hover { background: var(--fg); }
.nc:focus-visible { outline: 2px solid var(--seal); outline-offset: 3px; }
/* The seal: a disc of the red with the place's own character, the same seal
   the map's label carries, which is what says WHICH board moved. */
.nc-app {
  flex: none; width: 36px; height: 36px; border-radius: 50%;
  display: grid; place-items: center;
  background: radial-gradient(circle at 34% 28%, #DE6A48, var(--seal) 58%, #A93822);
  color: var(--seal-on); font-family: var(--serif-hz); font-size: 19px; line-height: 1;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22);
}
/* The place, as the ordinal the League itself uses ("3rd", "3.", "3e"). */
.nc-place {
  font-family: var(--serif); font-size: 22px; font-weight: 700; line-height: 1;
  letter-spacing: -.01em; font-variant-numeric: tabular-nums;
}
/* Up is green and down is red, the two colours this host already means those
   things by, as a small solid chip so the direction is read before the number. */
.nc-move {
  padding: 2px 7px; border-radius: 99px;
  font-size: 12px; font-weight: 700; line-height: 1.3; font-variant-numeric: tabular-nums;
  color: #fff; background: var(--growth-4);
}
.nc-move.down { background: var(--seal); }
/* Whoever moved past, or was moved past: a face and a first name, no ring
   (#2245), a hairline of the capsule's own letters between it and the move. */
.nc-who {
  display: inline-flex; align-items: center; gap: 6px; min-width: 0;
  padding-left: 10px;
  box-shadow: inset 1px 0 0 color-mix(in srgb, var(--bg) 22%, transparent);
  font-size: 13px; font-weight: 600;
}
.nc-who .av { width: 24px; height: 24px; font-size: 11px; }
.nc-name { max-width: 9em; overflow: hidden; text-overflow: ellipsis; }
.nc-who.by .nc-name { color: color-mix(in srgb, #F2A184 60%, var(--bg)); }
/* What was earned, in green when it is new; the week's total, quieter, on a
   first look of the week when nothing has been earned since. */
.nc-xp {
  margin-left: 2px;
  font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--bg) 78%, transparent);
}
.nc-xp.gained { color: color-mix(in srgb, var(--growth-3) 88%, var(--bg)); }
.nc-xp small { margin-left: 3px; font-size: 11px; font-weight: 600; letter-spacing: .04em; opacity: .75; }
@media (prefers-reduced-motion: reduce) {
  .nc, .nc.is-out { translate: 0 0; scale: 1; transition: opacity 300ms linear; }
}

.mark:focus-visible { outline: none; }
.mark:focus-visible .mark-art {
  outline: 2px solid var(--seal); outline-offset: 6px; border-radius: var(--r-card);
}

/* ── THE FLIGHT ───────────────────────────────────────────────────────────
   *"und wo man dann draufklickt und rein [zoomt] in das Bild."* Pressing a
   place drives the picture into it and then the hash changes, so arriving at
   the Library is arriving at the pagoda.

   620ms, and learn.js holds the same number — it navigates on a TIMER rather
   than on `transitionend`, because that event does not fire when the property
   never changes, when the element is removed, or when the tab is hidden, and
   any one of those would strand somebody in a zoomed landscape. learn-units
   asserts the two numbers match.

   The paper goes first and faster. A parchment still hanging at the top while
   the world rushes past behind it reads as two screens rather than one
   movement, so it and the marks are gone by the time the zoom is halfway. */
/* NO `--zoom` HERE. It used to say 6, which made every press a lunge to six
   times and a snap back to the 1.9 the room settles at — and only sometimes,
   because an inline custom property from a previous navigation beat this class
   rule. learn.js sets the destination inline; this carries the timing and the
   fading of the paper and nothing else. */
.court.is-flying .court-art,
.court.is-flying .marks {
  transition: transform 620ms cubic-bezier(.55, 0, .85, .3);
}
.court.is-flying .mark,
.court.is-flying .scroll { opacity: 0; pointer-events: none; }
.court.is-flying .scroll { transition: opacity 260ms var(--ease); }

/* ── THE PARCHMENT ─────────────────────────────────────────────────────────
   A hanging scroll, and the details that make it one rather than a card with a
   texture: the rod is at the BOTTOM, where a hanging scroll is weighted, and
   the paper collapses UPWARD into the nav rather than sliding away.

   `grid-template-rows: 1fr → 0fr` is the roll. It is the one way to animate a
   collapse to the content's own height without measuring it in JavaScript, and
   the rod is the second row so it travels with the paper's edge instead of
   staying put while the paper leaves from under it. */
.scroll {
  /* It sits inside `#view` now rather than inside the court, because the court
     outlives every screen and the day does not. `#view` is already centred and
     already at the top of the window on this route, so the scroll only has to
     be as wide as it wants to be. */
  width: min(880px, 100%);
  margin-inline: auto;
  display: grid;
  grid-template-rows: 1fr auto;
  transition: grid-template-rows 620ms var(--ease);
}
.scroll.is-rolled { grid-template-rows: 0fr auto; }
.scroll-body { cursor: pointer; }

/* ── IT HAS TO LOOK LIKE PAPER WINDING, NOT LIKE A DIV COLLAPSING ─────────
   *"The animation should also be some kind of paper rolling up and down."*

   A height going to zero is a shutter. Three things together make it a scroll,
   and none of them is a keyframe:

   · THE SHEET TRAVELS UP AT HALF THE RATE the box shrinks. That difference is
     the whole illusion — paper being wound onto a rod moves less than the edge
     of it does, and an interior that moves in lockstep with its own clip reads
     as a mask instead of as material. It is the same trick a parallax is,
     applied to one object.

   · THE ROD FATTENS, because there is now paper on it. Six pixels to eleven,
     over the same 620ms. This is the detail that makes it read as winding
     rather than as sliding away, and it costs one declaration.

   · A SHADOW GATHERS AT THE BOTTOM EDGE, which is where the curl would be. It
     is absent when the scroll is open — an open scroll hangs flat — and
     strongest at the moment it is smallest.

   `overflow: hidden` on the body is what clips the travelling sheet, and it was
   already there for the collapse. */
.scroll-in { transition: transform 620ms var(--ease); }
.scroll.is-rolled .scroll-in { transform: translateY(-46%); }

.scroll-body { position: relative; }
.scroll-body::after {
  content: ''; position: absolute; inset: auto 0 0 0; height: 30px;
  pointer-events: none;
  background: linear-gradient(to top, rgba(26, 24, 19, .16), transparent);
  opacity: 0; transition: opacity 620ms var(--ease);
}
.scroll.is-rolled .scroll-body::after { opacity: 1; }

.rod-bar { transition: height 620ms var(--ease); }
.scroll.is-rolled .rod-bar { height: 11px; }

.scroll-body {
  overflow: hidden;
  border-radius: 0 0 var(--r-panel) var(--r-panel);
  /* ── PAPER, AND THINNER PAPER THAN BEFORE ───────────────────────────────
     *"can you make this a bit transparent und larger … maybe even in a little
     paperish style."*

     TRANSPARENCY. It was 95% of the card colour, then 72%, now 58. On a cream
     page 95 was a cream rectangle that stopped the mountains a hand's width
     from the top of the window; 58 is a sheet you can read the ridge through.
     The blur is what buys that: at 26px the ranges behind the greeting are
     DISTANCE rather than smudges on the words, and without it 58% would be
     unreadable long before it was pretty.

     PAPER. Three layers, all of them cheap and none of them an image file. A
     repeating 3px line at just over one per cent, which is the laid pattern of
     宣纸 and is the layer that does most of the work — below about 2px it moirés
     against a retina screen and above about 4 it reads as ruled paper. A second
     repeating line at 90 degrees and half the strength, so the grain is a WEAVE
     rather than a set of stripes. And a wide radial lift from the top left, for
     the unevenness a real sheet has: paper is never one flat tone, and one flat
     tone is most of what makes a rectangle read as a panel.

     A texture image would look better and cost a request and a decode on the
     first screen of the session, which is the one place this host has promised
     not to spend anything. */
  background:
    repeating-linear-gradient(to bottom,
      rgba(120, 104, 78, .032) 0 1px, transparent 1px 3px),
    repeating-linear-gradient(to right,
      rgba(120, 104, 78, .016) 0 1px, transparent 1px 4px),
    radial-gradient(120% 100% at 12% -10%,
      rgba(255, 252, 244, .1) 0%, transparent 62%),
    color-mix(in srgb, var(--card) 58%, transparent);
  backdrop-filter: blur(26px) saturate(1.12);
  box-shadow: var(--edge), 0 1px 2px rgba(26, 24, 19, .04), 0 26px 54px -36px rgba(26, 24, 19, .32);
}
.scroll-in {
  padding: var(--s5) var(--s6) var(--s5);
  display: grid;
  gap: var(--s4);
}
.scroll-said h1 { font-size: var(--t-h2); letter-spacing: -.015em; margin: 0; }
.scroll-said .fine { margin: 4px 0 0; }

/* TWO COLUMNS: who you are on the left, what to do on the right. The parchment
   has to stay under about 190px or it covers the two people it hangs over, and
   three stacked blocks could not. */
.scroll-in {
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--s6);
}
.scroll-do {
  display: grid; justify-items: start; gap: 4px;
  text-align: left;
}
.scroll-n {
  font-family: var(--serif); font-size: var(--t-h2);
  letter-spacing: -.015em; line-height: 1.05;
}
.scroll-do .btn { margin-top: 6px; }

/* ── THE STREAK, WITH FIRE ON IT ──────────────────────────────────────────
   It was one of three grey numbers in a row of links, set in the same 13px as
   "HSK 3" — and it is the one number on this screen that decides whether
   somebody comes back tonight. A pill, a warm ground, and a drawn flame.

   The flame is drawn and not an emoji: an emoji is somebody else's typeface and
   renders as a different object on every machine, which on a screen this
   careful about ink is the one thing that would look pasted on.

   THE FOUR FIGURES THAT USED TO BE HERE ARE GONE — reviews, correct, new
   characters, minutes, across the foot of the scroll. They are a report on a
   morning that has not happened yet, so on the screen somebody opens first they
   were four zeroes under a greeting. All four are on Progress, which is where
   somebody goes to read about themselves rather than to start. */
.flame {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 11px 3px 8px;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--seal) 12%, var(--card));
  color: var(--fg);
}
.flame-i { display: block; width: 15px; height: 15px; color: var(--seal); }
.flame-i svg { width: 100%; height: 100%; display: block; }
.flame b { font-size: var(--t-body); }
.flame:hover { background: color-mix(in srgb, var(--seal) 20%, var(--card)); }

/* The offer of the beginner course, for the few people it is drawn for. It sits
   under the day rather than over it: somebody who has never met a character has
   no day yet, so the row above it is four zeroes and this is the only line on
   the parchment with anything to do. */
.scroll-course {
  grid-column: 1 / -1;
  display: flex; align-items: center; gap: var(--s4);
  padding: var(--s3) var(--s4);
  border-radius: var(--r-card);
  background: var(--raised);
  text-decoration: none; color: inherit;
}
/* The seal tile carries its glyph in `--seal-on`: `--seal` here was red on its
   own red, the empty square in #2338's screenshot. */
.scroll-course .tc-g { font-family: var(--serif-hz); font-size: 26px; color: var(--seal-on); }
.scroll-course .tc-b { display: grid; gap: 2px; }

/* The three numbers, as one line of text rather than three cards. They are
   facts about the reader and not choices, and a card gives a fact the weight of
   a decision — which on a strip this short is most of the strip. */
.scroll-nums {
  display: flex; flex-wrap: wrap; gap: var(--s4);
  margin: var(--s3) 0 0;
  font-size: var(--t-cap); color: var(--fg-2);
}
.scroll-nums a { color: inherit; text-decoration: none; }
.scroll-nums a:hover { color: var(--fg); }
.scroll-nums b { font-size: var(--t-body); color: var(--fg); font-weight: 600; }

/* The rod. A button, because it does something; drawn as a weighted bar with a
   grip, because that is what you would take hold of. */
.scroll-rod {
  position: relative;
  justify-self: center;
  display: grid;
  justify-items: center;
  gap: 3px;
  /* Proportional to the sheet, which is 880 now and was 720. At a fixed 280 the
     rod looked like a rod for a smaller scroll — a dowel is as long as the paper
     it holds, give or take the knobs. */
  width: min(380px, 46%);
  padding: 0 0 10px;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
/* A rod, not an underline. The first version was a plain bar and read as a
   progress meter hanging under a card — which on a screen whose whole job is to
   not look like a dashboard is the worst available misreading. Two knobs at the
   ends are what the eye needs to see a dowel instead of a rule. */
.rod-bar {
  position: relative;
  width: 100%; height: 6px;
  border-radius: var(--r-pill);
  background: var(--seal);
  box-shadow: 0 6px 16px -8px rgba(0, 0, 0, .55);
  opacity: .92;
}
.rod-bar::before,
.rod-bar::after {
  content: ''; position: absolute; top: 50%;
  width: 10px; height: 10px; border-radius: 50%;
  translate: 0 -50%;
  background: var(--seal);
  box-shadow: var(--edge);
}
.rod-bar::before { left: -5px; }
.rod-bar::after { right: -5px; }
.rod-grip {
  width: 34px; height: 4px;
  border-radius: var(--r-pill);
  background: var(--fg);
  opacity: .22;
  transition: opacity var(--base) var(--ease);
}
.scroll-rod:hover .rod-grip,
.scroll-rod:focus-visible .rod-grip { opacity: .45; }
.scroll-rod:focus-visible { outline: 2px solid var(--seal); outline-offset: 4px; border-radius: var(--r-pill); }

/* A first-timer is offered the beginner course below the picture, and the
   parchment at full height pushes it past the fold on a short window. Half a
   step tighter, only for them. */
.scroll.scroll-tight .scroll-in { padding-block: var(--s4); gap: var(--s3); }

/* ── THE PHONE, AND IT IS A DIFFERENT SCREEN ───────────────────────────────
   Not a smaller one. At 390px the drawing is 244px tall at its own aspect, the
   six marks would sit on top of one another — and none of that is a layout problem to solve, because the owner's
   brief says what this screen is FOR: *"morgens oder abends, wenn man in der
   Ruhe am Computer sitzt"*. A phone is not that, and this host's phone case is
   somebody checking in.

   So the phone gets the picture as a band — the same crop Today has had all
   along — the parchment sitting under it as an ordinary block, and the six
   places as a plain grid of the same marks. Everything is reachable and nothing
   pretends to be an immersive landscape on a screen that cannot hold one. */
@media (max-width: 743px) {
  .court {
    height: auto;
    min-height: 0;
    container-type: normal;
    display: grid;
    gap: var(--s4);
    background: none;
    margin-bottom: var(--s5);
  }

  .court-band { display: block; }

  .court-art {
    position: relative;
    left: auto; top: auto; translate: none;
    width: 100%; height: 190px;
    transform: none;
    border-radius: var(--r-panel);
    overflow: hidden;
    box-shadow: var(--panel);
    background: var(--bg-sunken);
  }
  .court-veil { display: none; }

  .marks {
    position: relative;
    left: auto; top: auto; translate: none;
    width: 100%; height: auto;
    transform: none;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
    gap: var(--s3);
  }
  /* ── ON A PHONE THEY ARE A LIST, AND A LIST IS ALLOWED CARDS ────────────
     The buildings stand in the landscape on a desktop and must not have paper
     behind them there — a disc under a drawing is an admission it does not
     belong in the picture. Here there is no picture to belong to: the drawing
     is a band above, and these six are a menu under it. A card is the honest
     shape for a menu row, and the name is always shown, because a menu with
     hidden labels is a puzzle. */
  .mark {
    position: relative;
    left: auto; top: auto;
    transform: none;
    padding: var(--s4) var(--s2);
    border-radius: var(--r-card);
    background: var(--card);
    box-shadow: var(--panel);
    gap: var(--s2);
  }
  .mark-art { background: none; }
  .mark-art svg { width: 58px; height: 58px; }
  /* No room to counter-scale against: the phone's court does not transform, so
     the label would come out at 1/1.06 for nothing. */
  .mark-tag { padding: 0; background: none; backdrop-filter: none; scale: 1; }

  .scroll {
    position: relative;
    left: auto; translate: none;
    width: 100%;
    order: -1;
  }
  .scroll-body { border-radius: var(--r-panel); backdrop-filter: none; background: var(--card); }
  .scroll-in { padding: var(--s5) var(--s4); }
  .scroll-in { grid-template-columns: 1fr; gap: var(--s4); }
  .scroll-do, .scroll-do:has(.camp-plan) { justify-items: stretch; }
}

/* Somebody who has asked for less motion gets the whole screen and none of the
   travel: the marks still light and the parchment still rolls, and pressing a
   place still goes there — it simply arrives rather than moves. The lean and
   the flight are the two things here that are purely pleasure, so they are the
   two that go. learn.js checks the same query and navigates at once. */
@media (prefers-reduced-motion: reduce) {
  .court.is-leaning { --zoom: 1; }
  .court-art,
  .marks,
  .mark,
  .scroll { transition: none; }
}

/* ══ EACH PLACE ITS OWN ROOM ════════════════════════════════════════════════
   *"versuche die einzelnen Screens individueller zu machen, sodass sie sich
   auch nach dem Reinzoomen in die Landschaft ergänzen … dabei sollen aber keine
   Funktionen und Informationen verloren gehen."*

   The room already moved to the right spot and the sheet already floated in it,
   and it was not enough: all five panels were the same panel, so walking into
   the pagoda and walking into the schoolyard produced one object with different
   words on it. The zoom said "somewhere" and the paper said "anywhere".

   ── THREE DIFFERENCES, AND NOT ONE OF THEM IS A WORD ─────────────────────
   Every screen's CONTENT is untouched — that is the ask's own condition and the
   only way to be certain of it is to change nothing inside the renderers. So
   what varies is the room: the piece of building over the sheet's head, the
   paper the sheet is made of, and how wide it is.

   THE WIDTH IS NOT DECORATION. It is the strongest of the three, because it is
   the one a reader feels without looking: the Library is ten thousand rows and
   wants the full column, the League is a ranking and wants a narrow one so the
   eye runs DOWN it, and a class of thirty people is a short list that looks
   abandoned at 980px.
   ═══════════════════════════════════════════════════════════════════════════ */
@media (min-width: 744px) {
  /* ── THE LINTEL ─────────────────────────────────────────────────────────
     Sits on the sheet's top edge and overlaps it by a hair, so it reads as
     resting ON the paper rather than floating above it. Behind the sheet in z,
     which is what puts the paper's own top edge in front of the eave — the way
     a sheet pinned under a beam actually looks.

     Lit by the hour like everything else in the picture. It is architecture,
     and architecture in this drawing takes the same light as the mountain. */
  /* ── FULLY ABOVE THE SHEET, AND NARROWER THAN IT ────────────────────────
     The first version overlapped the paper by 14px so the eave would read as
     resting ON it. Two things went wrong and both are about corners: the sheet
     has a 20px radius and the lintel does not, so the lintel's square bottom
     corners poked out either side as dark triangles — and everything tucked
     under the paper was simply invisible, which left about a sixth of the
     drawing doing all the work.

     So it stands clear, 60px narrower than the sheet and 6px above it. An eave
     over a doorway rather than a beam through a wall, which is the same idea
     and has no corner to argue with. */
  .lintel {
    position: relative;
    z-index: 0;
    width: min(calc(var(--sheet, 980px) - 60px), calc(100% - 60px));
    margin: calc(58px + env(safe-area-inset-top)) auto 6px;
    height: 26px;
    color: color-mix(in srgb, #1C1E18 74%, transparent);
    filter: var(--art-filter);
    pointer-events: none;
  }
  .lintel svg { width: 100%; height: 100%; display: block; }
  /* ── THE SKY'S IS SOFTER THAN THE ARCHITECTURE'S ────────────────────────
     Four of these are buildings and read correctly as dark, hard runs of ink.
     The fifth is weather. At the architecture's weight it came out as a black
     slab hanging over the ranking — cloud does not have an edge, and a cloud
     drawn with one is a rock. Half the ink and a whisper of blur is the
     difference, and it is the same pair of moves the League's mist band under
     the faces already makes for the same reason. */
  body.at-league .lintel {
    color: color-mix(in srgb, #1C1E18 34%, transparent);
    filter: var(--art-filter) blur(4px);
    height: 34px;
    /* And no bottom edge. The other four are architecture and a building is
       entitled to end at a line; a cloud is not, and at 42% with a flat foot it
       read as a grey slab hanging over the ranking. The mask takes the lower
       third away so it dissolves toward the paper instead of stopping on it. */
    mask-image: linear-gradient(to bottom, #000 0, #000 58%, transparent 100%);
  }

  /* ── THE RANKING LETS THE SKY THROUGH ───────────────────────────────────
     *"ein Beispiel zur Liga, das man dann im Himmel reingezoomt ist und dann
     dort wie so eine Rangliste sieht."*

     The sheet is the most transparent of the five and the rows on it were
     solid — so the board blocked exactly the thing the sheet was letting
     through, and the transparency bought nothing where the eye actually was.
     A hairline and a wash instead of a fill: the sky runs behind every row, and
     the rank, the face, the level, the gap bar and the XP are all still there
     and all still in the same places. Nothing is removed; the rows stop being
     opaque.

     The reader's own row keeps a fill, because the one thing this screen has to
     answer at a glance is "where am I" — and that is the row that may not
     dissolve into the weather. */
  body.at-league .board li {
    background: color-mix(in srgb, var(--raised) 34%, transparent);
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 7%, transparent);
  }
  body.at-league .board li.me {
    background: color-mix(in srgb, var(--raised) 92%, transparent);
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 34%, transparent);
  }

  /* The sheet reads `--sheet` now instead of a literal, so one line per place
     below sets its width. The margin-top moves to the lintel when there is one,
     or the two would stack their own clearances. */
  body:not(.in-court):not(.in-intro) #view {
    max-width: min(var(--sheet, 980px), calc(100% - 2 * var(--gutter)));
  }
  body:not(.in-court):not(.in-intro):has(#lintel:not([hidden])) #view { margin-top: 0; }
  #view { position: relative; z-index: 1; }

  /* ══ 练 THE TRAINING CAMP ═════════════════════════════════════════════
     *"wenn man dann reinzoomt soll das ganz anders werden … wie ein Camp mit
     Gebäuden, Plätzen etc. wo man unterschiedliche Sachen üben kann."*

     views.js has the plan and the argument; the camp's own rules are up beside
     the board rows, because a camp is content and these are the room around
     it. What is left here is what the ROOM does differently at this place, and
     since the camp arrived there are three things rather than one.

     ── WHAT WAS HERE: SEVEN PANES OF GLASS ────────────────────────────────
     Worth keeping, because the pane survived and its reasoning is why. The
     brief before this one was *"einen Schüler der dann all die möglichen Decks
     zum Scrollen hat"*, with a picture of somebody sitting beside a sheet of
     glass — so the page went the way the class's panel went, and the seven
     SEGMENTS became seven labelled panes with the landscape running between
     them. The camp replaced the seven; the pane is still exactly right for the
     one area a learner has walked into, and `.seg` below is unchanged.

     ── 84%, AND THE CLASS IS WHY ────────────────────────────────────────
     The schoolyard's clearing had to go from 74% to 92% before names stopped
     dissolving into a mountain, and these carry a 15px title, a 12px line under
     it and a pill. 84 of the card colour over a 16px blur is the same bargain
     one step lighter: enough paper under the type to read it, enough glass that
     the ranges show through. */
  /* 1120, THE WIDEST OF THE FIVE, BECAUSE THE HEIGHT ALREADY HAS A BRAKE. Seven structures around an
     empty floor need the room, so this started at 1080 — and then the lintel
     stopped being a band over the sheet and became the page's own title — a
     festival arch the width of the paper.

     Guessing a width that fits every window is what the first three attempts
     at this number were doing, and `--camp-w` does it properly: the gate and
     the camp shrink together when the window is short. So the sheet stops
     being the brake and goes back to being what it is everywhere else — how
     wide this place is ALLOWED to be on a screen with room. Seven structures
     around a floor want the room, so it is the widest of the five.

     ALL FIVE WIDTHS ARE DIFFERENT ON PURPOSE — learn-units asserts it —
     because the width is the difference a reader feels without looking, and
     two places sharing one is two places that have stopped being told apart. */
  body.at-training { --sheet: 1240px; }
  /* ── AND IT GIVES THE PLACE ITS MARGINS BACK ───────────────────────────
     Every other screen on this host is a document, and a document wants a
     hand's width of paper round it: 38px of padding at this width, and a
     `max-height` that reserves 68px under the sheet against a margin that only
     uses 34.

     A camp is not a document. Every one of those pixels is height the drawing
     cannot have, and the drawing is the whole screen — so the padding comes in
     to 16, the reserve is set to what the margin actually takes, and the fifty
     pixels that buys go into `--camp-w`, where they come back as a bigger camp
     rather than as white space nobody looks at. */
  /* THE SCOPE IS THE CAMP AND NOT THE PLACE. `at-training` is on for every
     route whose tab is Training — the rounds, the drills, the passages, the
     grammar shelf — and every one of those is a document that may want a
     gutter and a scroller. `:has(.camp-screen)` is the camp itself. */
  body.at-training:not(.in-court):not(.in-intro) #view:has(.camp-screen) {
    padding: var(--s2);
    margin-top: calc(44px + env(safe-area-inset-top));
    margin-bottom: var(--s4);
    max-height: calc(100dvh - 44px - env(safe-area-inset-top) - var(--s4));
    /* ── AND IT CANNOT SCROLL, WHICH IS THE POINT OF ALL OF THE ABOVE ────
       *"mache niemals scrollbare Sachen auf der Webapp."* `--camp-w` is sized
       so the content fits at every window height this was measured at; this is
       the line that makes that a guarantee rather than an intention. */
    overflow: hidden;
  }
  /* ── AND NEITHER DOES THE PAGE UNDER IT ────────────────────────────────
     The panel fitting is not the whole of it: the DOCUMENT was scrolling too,
     by a couple of dozen pixels vertically and — on the owner's own window —
     horizontally as well, which is the grey pill in the screenshot that
     started this. The map already solves exactly this and the reasoning is
     three hundred lines up: `overflow: hidden` on the ELEMENTS and not on
     `body` alone, because iOS Safari ignores it on body while a scroll is in
     flight and the html element owns the viewport's scroller. */
  body:has(.camp-screen),
  html:has(.camp-screen) {
    overflow: hidden;
    overscroll-behavior: none;
    height: 100%;
  }

  /* ── AND THE BUILDING YOU PRESSED IS NOT DRAWN TWICE ────────────────────
     Every other place keeps its mark standing while its sheet is open — the
     court's own rule, *"the building you came to stays standing"*. The Class
     brought it forward to .74 while its class gathered beside it; since the
     class went outdoors for good under a pavilion of its own (2026-10-06) it
     inverts the rule too, for the camp's reason below.

     Training is where that rule first inverted, and the camp is why.
     The mark on the map IS the camp: a hall, two huts and a plaza, at 86 units
     on a cliff. Walking in draws the same camp at fifteen times the size with
     its seven buildings named. Leaving the small one on the hillside behind it
     is the same object twice with the worse copy underneath — which is the
     argument the Class already makes about drawing its classmates' faces both
     in the yard and at the building's foot. */
  body.at-training .court.is-open .mark.is-here { opacity: 0; }
  /* The page itself is gone. Repeating both `:not`s because the panel's own
     rule is (1,2,1) — see the note on the schoolyard. */
  /* ── ON THE CAMP, AND ONLY THERE (#2144) ─────────────────────────────
     This rule said `#view` for the whole TAB, and the note above it says why
     that was wrong: every screen under Training stood bare on the hillside.
     Three times somebody fixed one screen of it — the round (#2125), the
     decks (#2150) — and a measured pass found the rest: at dusk the ink type
     lay on a mist that is still pale (1.1 to 2:1 on Review, Grammar,
     Reading, the gym, the exam hall), and on a 1024 window the grammar
     shelf's labels lay on the teal hills at noon. Scoped to the camp, the
     others keep the sheet every other place has. */
  body.at-training:not(.in-court):not(.in-intro) #view:has(.camp-screen) {
    background: none;
    backdrop-filter: none;
    box-shadow: none;
  }
  /* One pane per segment: the heading belongs INSIDE it, or a label with no
     ground under it is a line of small caps lying on a hillside. */
  body.at-training .seg {
    gap: var(--s4);
    padding: var(--s5);
    border-radius: var(--r-panel);
    background: color-mix(in srgb, var(--card) 84%, transparent);
    box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 7%, transparent),
                0 30px 60px -44px rgba(26, 24, 19, .5);
    backdrop-filter: blur(16px) saturate(1.06);
  }
  /* The rows stop being a panel of their own — they are on the pane now, and a
     card inside a card is the stack of boxes this screen used to be. The
     hairlines between them stay: they are what makes eighteen rows read as
     seven groups. */
  body.at-training .brows {
    background: none;
    box-shadow: none;
    border-radius: 0;
    overflow: visible;
  }
  body.at-training .brow { padding-inline: 0; }
  body.at-training .brow:first-child { padding-top: 0; }
  body.at-training .brow:last-child { padding-bottom: 0; }
  /* The label sits on its own pane, so it can carry the weight it was too weak
     to carry on a page: `--muted` on glass over a mountain is a rumour. */
  body.at-training .seg-h { color: var(--fg-2); }
  /* And the words that are not decks — the title, what opens next, how many of
     the app's ways are here — get the pill the schoolyard's header uses, for
     the same reason: there is no page under them any more. */
  body.at-training .screen-head,
  body.at-training .screen > .note {
    align-self: start;
    max-width: max-content;
    padding: var(--s4) var(--s5);
    border-radius: var(--r-panel);
    background: color-mix(in srgb, var(--card) 86%, transparent);
    box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 8%, transparent);
    backdrop-filter: blur(10px);
  }
  body.at-training .screen > .note { padding: var(--s3) var(--s5); }

  /* ── 库 THE PAGODA ──────────────────────────────────────────────────────
     The full column, because this is the one screen that is a GRID — ten
     thousand characters, and every 40px taken off the width is a row lost to
     wrapping. Paper with shelf rules in it: a faint horizontal every 34px,
     which is the pitch of the library cells, so the grid appears to be sitting
     on shelves rather than floating on a sheet. */
  /* ══ 书房 THE LIBRARY IS A READING ROOM ═══════════════════════════════
     *"bitte überarbeite die Library und mache sie wesentlich ästhetischer …
     finde das so schön, auch mit den Pflanzen … so die Bücher links und rechts
     als Rahmen oder so … sei kreativ und mache es aber übersichtlich."*

     Two photographs: a timber library under a pitched roof with ivy spilling
     off the shelves and a stack of books on a desk in the foreground; and a
     Japanese reading room whose walls are books from floor to ceiling, lit by
     paper lanterns hanging between them.

     What was here was the right idea at a tenth of the strength — faint
     horizontal rules every 34px, so the grid appeared to sit on shelves. On
     ink that is a dark rectangle with three rows of pills on it, which is what
     the owner was looking at.

     ── THE SAME ARCHITECTURE AS PROGRESS, WITH A DIFFERENT WALL ─────────
     Progress is a room with lattice screens; this is a room with BOOKCASES.
     Everything else is shared on purpose — a heavy timber frame, a boarded
     floor, gutters the sheet sits between, and objects that keep their own
     colour on paper and on ink, because walking into a room does not repaint
     it. Two rooms in one building.

     The shelves themselves are elements and not a gradient: views.js has the
     argument, and it is that no two spines on a real shelf are the same
     width. */
  body.at-library { --sheet: 1300px; }
  /* 1300 IS THE ROOM'S WIDTH, NOT THE TAB'S. The reading room needs it — two
     walls of shelving take 328px before a character is drawn — and the four
     screens behind the tab do not: a character sheet caps its column at 760px,
     so on a 1300px page it is a narrow document pinned to the left of half a
     metre of empty paper. They keep the width the whole tab used to have. */
  body.at-library:not(.in-court):not(.in-intro) #view:not(:has(.lib-wall)) {
    max-width: min(1020px, 100%);
  }
  /* ── AND THE ROOM IS ONLY WHERE THE SHELVING IS ────────────────────
     `at-library` is the whole TAB — the pinyin table, a level's lessons, a
     single character, the starred list. Only the index draws the two walls,
     and a timber room with cream type and no bookcases in it is not a room,
     it is a screen somebody turned the lights off on. `:has` asks the exact
     question rather than inventing a second body class for it. */
  body.at-library:not(.in-court):not(.in-intro) #view:has(.lib-wall) {
    /* A FLEX COLUMN, which is what lets the floor find the bottom. See
       `.room-floor` below — the sheet scrolls, and an absolutely positioned
       floor in a scrolling sheet lands at the bottom of the FIRST screenful
       and then scrolls away up the page with everything else. */
    display: flex; flex-direction: column;
    /* The room's own light, warm and from above — a library is lit by lamps
       and not by a window, which is the one thing that makes it different from
       the room next door. */
    --fg: #EFE7D5;
    --fg-2: #C8BCA4;
    /* #93866F measured 3.4:1 on the level chips and 3.0 under the search
       placeholder (#2144); this is the least lift that clears 4.5 on both. */
    --muted: #B1A691;
    --hair: rgba(240, 232, 214, .13);
    --card: rgba(58, 45, 34, .5);
    --raised: rgba(240, 228, 202, .07);
    --bg-sunken: rgba(20, 15, 10, .4);
    --seal-text: #E0A886;
    --edge: none;
    /* ── AND `--bg` WITH THEM, WHICH IS THE ON-STATE ────────────────────
       The selected segment and the selected phase are `background: var(--fg);
       color: var(--bg)`, and this room has just made `--fg` cream. Without
       swapping `--bg` as well, that is cream type on the page's own light
       background — the same trap the Library rack sprang once already, and the
       reason every surface in the room next door has to say `color` out loud.
       Here the room's own timber IS the background. */
    --bg: #2A2019;
    color: var(--fg);
    /* And the floor is left clear at the bottom: 84px of boards with a row of
       links standing on them reads as furniture that has slid off the rug. */
    /* The gutters a wall stands in, the clearance the floor reaches back
       through, and how tall its boards are. `.room-floor` and every ornament
       below read these, so the room has one set of measurements rather than
       five copies of the same number.

       ── AND THE GUTTER IS WIDER THAN THE WALL ─────────────────────────
       *"Mache links und rechts noch ein kleines Padding zu dieser Library …
       sieht sonst zu squeezed aus."* It was 188, which is exactly the 24px
       inset plus the 164px wall — so the page began on the last shelf board
       and a card's rounded corner sat against a row of spines. 224 leaves
       36px of wall between the shelving and the first thing on the page,
       which is the gap a room has between its furniture and its walls. */
    --room-x: 224px;
    --room-b: 116px;
    --room-floor-h: 84px;
    padding-left: var(--room-x);
    padding-right: var(--room-x);
    padding-bottom: var(--room-b);
    background:
      radial-gradient(90% 60% at 50% 0%, rgba(250, 206, 136, .16), transparent 70%),
      radial-gradient(120% 90% at 50% 46%, transparent 40%, rgba(14, 9, 5, .5) 100%),
      repeating-linear-gradient(to bottom,
        transparent 0 33px, rgba(240, 228, 202, .035) 33px 34px),
      linear-gradient(168deg, #3A2D22 0%, #2A2019 100%);
    box-shadow:
      inset 0 0 0 1px rgba(240, 228, 202, .07),
      0 0 0 16px #241B14,
      0 0 0 21px #3B2E23,
      0 24px 60px -24px rgba(0, 0, 0, .7);
    /* The tones as type in lamplight: see "the hue is frozen" above. */
    --t1-keep: 66%; --t2-keep: 100%; --t3-keep: 93%; --t4-keep: 77%; --t5-keep: 76%;
    --jade-keep: 93%; --amber-keep: 67%; --violet-keep: 79%;
  }
  /* ── 读 AND A TILE IS READ IN THE ROOM'S OWN LIGHT (#2144) ─────────────
     A phase tile is a 22% wash over `--card`, and in here `--card` is half
     transparent, so on the timber the wash landed as a mid brown or a moss
     green: the meaning line and the pinyin on it measured 2.1 to 2.5:1. The
     wash goes over the room's darkest timber instead — the colour its frame
     is drawn in — so the tile is still gold, blue or green, and the letters
     on it read. */
  body.at-library #view:has(.lib-wall) :is(.cell, .wcell):is(.ph-ready, .ph-learning, .ph-learned) {
    background: color-mix(in srgb, var(--ph) 22%, #241B14);
    --t1-keep: 57%; --t2-keep: 100%; --t3-keep: 76%; --t4-keep: 63%; --t5-keep: 63%;
    --jade-keep: 76%; --amber-keep: 100%; --violet-keep: 65%;
  }
  body.at-library #view:has(.lib-wall) :is(.cell, .wcell):is(.ph-ready, .ph-learning, .ph-learned):hover {
    background: color-mix(in srgb, var(--ph) 32%, #241B14);
  }
  /* ── 地板 THE BOARDED FLOOR, IN BOTH ROOMS ─────────────────────────
     IN THE FLOW rather than pinned to the bottom of the sheet. It was
     `#view::before` at `bottom: 0` in both rooms, and a sheet that scrolls
     puts that at the bottom of the first SCREENFUL — so a long search result
     drew a band of floorboards straight across the middle of the grid, and
     Progress drew one across the middle of its wall.

     `order` and not markup order: the room is one call at the top of a render,
     and a floor that has to be emitted separately at the bottom is a floor
     somebody forgets on the next screen. `margin-top: auto` with the screen
     stretched to the sheet puts it under the content when the content is long
     and at the foot of the room when it is short, which is what a floor does.

     The two rooms differ only in how far they have to reach out to the gutters
     and how tall the boards are, so those are properties the room sets and
     this rule reads. */
  body.at-library .screen { flex: 1 1 auto; }
  body.at-library .room-floor, body.at-stats .room-floor {
    display: block;
    order: 9; margin-top: auto;
    margin-inline: calc(-1 * var(--room-x));
    margin-bottom: calc(-1 * var(--room-b));
    height: var(--room-floor-h);
    background:
      linear-gradient(to bottom, rgba(22, 15, 9, .5) 0 5px, transparent 5px),
      repeating-linear-gradient(to right,
        rgba(22, 15, 9, .34) 0 1px, transparent 1px 68px),
      linear-gradient(to bottom, #6B5238 0%, #4C3A27 100%);
    z-index: 0;
  }
  /* Progress hangs its objects on a GRID, so its floor takes a whole row at
     the end of it rather than being the last item of a column. */
  body.at-stats .room-floor { grid-column: 1 / -1; }
  /* ── THE ROOM IS MEASURED FROM THE SCREEN, AND THE SCREEN IS THE PAGE ──
     The walls were absolute against `#view`, and `#view` is a SCROLLING
     sheet: an absolutely positioned child of one covers the first screenful
     and then rides up the page with the content, so a level of three hundred
     characters had a bookcase for its first window and a dark column under it
     — *"add the books until the bottom"*.

     `.screen` is the full height of the content, so the walls go against that
     instead. Which means `.screen` may not be narrowed: a character page caps
     its column at 760px, and a bookcase measured from that lands in the middle
     of the room. So the CAP moves to the column's contents, and the room keeps
     the sheet. */
  body.at-library .screen { position: relative; }
  body.at-library .screen > *:not(.lib-wall):not(.lib-ivy):not(.lib-lantern):not(.room-floor) {
    position: relative; z-index: 1;
  }
  body.at-library .screen.char { max-width: none; }
  body.at-library .screen.char
    > *:not(.lib-wall):not(.lib-ivy):not(.lib-lantern):not(.room-floor) {
    width: min(100%, 760px);
    margin-inline: auto;
  }

  /* ── 书架 THE TWO WALLS ─────────────────────────────────────────────
     Floor to ceiling at every height the room turns out to be: the books are
     one drawing per wall (`--books`, from `bookcase()` in views.js) repeated
     down it, so a level of three hundred characters and the whole of "All"
     are both full shelves to the floor (#2143).

     The carcase is dark timber with a board under every shelf and the usual
     falloff into the corners; the books stand on the boards. The inner edge
     catches the room's light, the outer one does not — which is what puts the
     lamp in the middle of the room rather than everywhere. */
  body.at-library .lib-wall {
    position: absolute; top: 0; bottom: 0; z-index: 0;
    width: 164px;
    display: block;
    overflow: hidden;
    background:
      var(--books, none) 0 0 / 100% auto repeat-y,
      /* Under the books, the carcase's own boards at the same pitch: what
         shows for the moment before the drawing has decoded. */
      repeating-linear-gradient(to bottom,
        transparent 0 90px, #4A392A 90px 96px),
      linear-gradient(to bottom, #2E241B 0%, #241B14 100%);
    box-shadow: inset 0 0 0 7px #3B2E23, inset 0 0 0 8px rgba(0, 0, 0, .34);
  }
  body.at-library .lib-wall-l { left: calc(-1 * var(--room-x) + 24px); }
  body.at-library .lib-wall-r { right: calc(-1 * var(--room-x) + 24px); }
  /* Six spines. Cloth, board and leather in the colours a wall of old books
     actually is — and no hue that is not already in the palette, which is
     docs/brand.md's standing rule: these are the tones, the seal and the
     growth ramp, darkened to the light in here.

     The books themselves take these from `SHELF_INK` in views.js now, since
     a drawing in a data URL cannot read a stylesheet. The six rules stay
     because they are not scoped to the room and Progress's backlog bars
     (`.bl-fill.b0` to `.b4`) have been drawn in them on the desk; taking
     them away is a change to Progress, not to the Library. */
  .b0 { background-color: #6E3226; }
  .b1 { background-color: #7C6330; }
  .b2 { background-color: #34503C; }
  .b3 { background-color: #2F4257; }
  .b4 { background-color: #C0B193; }
  .b5 { background-color: #4A3428; }

  /* ── 藤 THE IVY ─────────────────────────────────────────────────────
     Hanging off the top of each wall and over the books, which is exactly
     where it is in the photograph. One drawing, flipped for the right-hand
     side: a vine has no reading order. */
  body.at-library .lib-ivy {
    display: block;
    position: absolute; top: -6px; z-index: 2;
    width: 62px;
    color: #4E6B44;
    pointer-events: none;
    filter: drop-shadow(0 4px 6px rgba(12, 8, 4, .55));
  }
  body.at-library .lib-ivy svg { width: 100%; height: auto; display: block; }
  .ivy-leaf { opacity: .95; }
  body.at-library .lib-ivy-l { left: calc(-1 * var(--room-x) + 40px); }
  /* The right-hand vine hangs at the OUTER edge of its wall and the lantern
     over the inner one, so the two are beside each other rather than in each
     other — a corner with two things falling out of it is a corner nobody can
     read. */
  body.at-library .lib-ivy-r { right: calc(-1 * var(--room-x) + 32px); scale: -1 1; }

  /* ── 灯笼 THE LANTERN ───────────────────────────────────────────────
     Hung high on the right, between the shelving and the sheet: the red
     lantern with gold caps and a tassel (#2145), the Chinese one rather than
     the Japanese paper balloon it replaced. Its drawing is in views.js.

     The glow is on the body alone — a filter over the whole drawing makes
     the caps and the tassel glow with it. */
  body.at-library .lib-lantern {
    display: block;
    position: absolute; top: 18px; z-index: 3;
    right: calc(-1 * var(--room-x) + 120px);
    width: 76px;
    color: #241B14;
    pointer-events: none;
  }
  /* ── AND THE GLOW HAS TO BE LET OUT OF THE BOX ────────────────────
     A CSS `filter` on a child of an SVG is clipped to that SVG's viewport, so
     a 74px-wide drawing with a 74px halo drew the halo as a RECTANGLE — a
     lantern in a lightbox, which is the one thing a paper lantern is not. */
  body.at-library .lib-lantern svg { width: 100%; height: auto; display: block; overflow: visible; }
  /* Red silk lit from inside, so the halo is warm red-orange rather than the
     cream a paper lamp throws. The gold is the tone-2 amber, darkened to the
     light in here like the spines; the tassel is the seal's red. */
  .lib-lamp-body {
    filter: drop-shadow(0 0 9px rgba(244, 118, 66, .85))
            drop-shadow(0 0 28px rgba(232, 96, 52, .55))
            drop-shadow(0 0 70px rgba(240, 146, 78, .36));
  }
  .lib-lamp-rib { stroke: rgba(98, 22, 12, .5); }
  .lib-lamp-gold { fill: #D19A38; }
  .lib-lamp-band { stroke: #8A5F1C; }
  .lib-lamp-tassel { fill: #B8392A; }
  .lib-lamp-fringe { fill: none; stroke: rgba(70, 14, 8, .45); stroke-width: 1; }

  /* ── 架 AND THE LEVEL RACK IS TIMBER NOW, NOT METAL ─────────────────
     The eave over the nine compartments takes `currentColor` at 58%, which on
     this room's cream `--fg` came out a pale grey bar — an aluminium extrusion
     over a shelf of books. It is the same crown the map's mark is drawn with
     and it wants to be the same wood as everything else in here.

     The compartments go with it: a carcase with nine boxes routed into it, the
     open one lit by the lantern above rather than filled with the seal. A red
     block on a wall of books is the loudest thing in the room and it is a tab. */
  body.at-library .rack-eave { color: #6B5238; }
  body.at-library .levels-rack {
    background: linear-gradient(to bottom, #3A2D22 0%, #2E241B 100%);
    box-shadow: inset 0 0 0 1px rgba(255, 248, 232, .09),
                0 3px 8px -4px rgba(0, 0, 0, .6);
  }
  body.at-library .levels-rack .level {
    background: rgba(18, 13, 8, .45);
    box-shadow: inset 0 0 0 1px rgba(255, 248, 232, .05);
  }
  body.at-library .levels-rack .level.on {
    background: linear-gradient(to bottom, #8C4A2E 0%, #77391F 100%);
    box-shadow: inset 0 0 0 1px rgba(255, 220, 170, .3),
                0 0 16px rgba(240, 176, 88, .35);
  }

  /* ══ 室 PROGRESS IS A ROOM YOU WALKED INTO ═══════════════════════════
     *"vielleicht sucht man wirklich rein in diesen Progress-Bogen oder in
     dieses Tor und ist dann in so einem Raum und hat dann so einen Schrank, wo
     du Informationen drauf hast. Dann hast du mehrere Bilder, dann hast du
     noch eine Tafel etc. Und das sind dann mehr oder weniger all die Daten die
     wir auch in der App haben."*

     Three photographs: a console under one big framed picture on a raw
     plaster wall; a blackboard covered in chalked characters; and a scholar's
     corner — a framed scroll with a seal, a black lacquer cabinet, a chair.

     ── THE PREVIOUS PASS WAS RIGHT ABOUT THE THING AND WRONG ABOUT THE SCALE
     It made the SHEET into a board, which fixed the ink-mode problem — a panel
     that follows the theme goes black, and a black rounded rectangle is the
     one thing an old board is not. But one board carrying six kinds of number
     is a poster, and *"eine dunkle Tafel viel cooler"* is the note that says
     so: a room has several objects and each of them holds one thing.

     So this is a WALL, and four things hang on it or stand against it:

       黑板 THE BOARD, near-black with a wooden frame, carrying the streak in
            chalk. It is the darkest thing in the room, which is right for the
            number that changes every day.
       画  TWO FRAMED PICTURES, cream grounds in mitred dark frames, one for
            the four skill bars and one for the twenty-six weeks.
       柜  THE CABINET, black lacquer, one drawer per figure with a brass pull
            — six numbers that do not change often, in the thing a room keeps
            things it does not need every day in.

     ── AND THE ROOM KEEPS ITS OWN LIGHT ─────────────────────────────────
     The whole point, and the same rule the map's drawing follows: the wall,
     the frames and the lacquer are OBJECTS. They hold their colour whether the
     page is on paper or on ink, because walking into a room does not repaint
     it. Every one of them swaps the tokens its contents read rather than
     styling its contents — `--fg`, `--card`, `--hair`, the growth ramp — so
     nothing inside needed a rule written for it.

     TWO TRAPS, both found by screenshot. Redefining `--fg` does nothing to
     inherited text, because `body { color: var(--fg) }` already resolved at
     the body — every surface here has to say `color` out loud. And on ink the
     growth ramp is mapped to its dark variants, which are right on a black
     page and are four shades of bottle green on cream. */
  body.at-stats { --sheet: 1260px; }
  /* ── 墙面 AND IT IS HUNG LIKE A WALL, NOT STACKED LIKE A PAGE ──────────
     *"ich will aber wie eine Wand sehen … mit den Features, Bildern, Schränken
     etc. und da die Inhalte drin."*

     The first version of this room had the right objects in the wrong
     arrangement: four full-width rectangles down a column, which is a page
     with textures on it. The reference photographs are all compositions — one
     big framed picture with a console under it, a cabinet to the side, things
     at different sizes hung at different heights.

     So the screen is a grid and the objects have places on it.

     ── AND THE ORDER IS THE OWNER'S, NOT THE ARRANGEMENT'S ──────────────
     *"der Progress-Seite ganz oben soll das Week-Streak sein … dann sollten
     wir diesen Character-Timeline-Graph und den Word-Timeline-Graph haben,
     dann Can Read und Current Level … dann die Skills … die Identity … der
     Backlog … der Forecast … Tone Year … ganz zum Schluss die Heatmap."*

     That is a SEQUENCE, and the first version of this wall had the objects in
     a composition that fought it: the twenty-six weeks hung in the middle
     because they were the biggest picture, with the streak tucked beside them.
     The owner's order is the app's order with two moves — the week to the very
     top, the heatmap to the very bottom — and both moves are right. The week
     is what somebody is protecting; the heatmap is the only thing here about
     the past rather than about where they stand.

     So the wall is TWO COLUMNS and the objects fill it in reading order, in
     pairs:

       row 1   the title, full width
       row 2   黑板 the streak board  │  画 the growth picture with its tree
       row 3   架 the shelf of jars    │  身份 identity
       row 4   积压 the backlog        │  声 the tone year
       row 5   柜 the cabinet, full width, with the bonsai standing on it
       …       anything added, full width, in the rows below
       last    画 the twenty-six weeks, full width, along the bottom

     Four objects came off this wall on 2026-09-21 — the fortnight forecast,
     the two rhythms, the deck split and the freshness meter — so the tone year
     moved up beside the backlog and the wall lost two rows.

     Pairing halves the height of the wall without reordering a single thing —
     a two-column grid is read left to right and then down, which is the same
     sequence the owner wrote out. Anything added to this screen lands in the
     full-width rows before the heatmap rather than in a hole somebody has to
     notice. */
  body.at-stats .screen {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
    gap: var(--s5);
    align-items: stretch;
  }
  body.at-stats .screen > * { grid-column: 1 / -1; }
  body.at-stats .screen-head { grid-row: 1; }
  /* ── THE PICTURE IS A PAINTING WITH FIGURES BESIDE IT ─────────────────
     The tree and its glyph take the right-hand third of the frame and run its
     full height; the two counts, their curves and the two plaques stack down
     the left. Laid over each other they were a wash behind text — which is a
     texture, not a picture — and the tree had to be turned down so far to keep
     the numbers legible that it stopped being a drawing at all.

     Side by side, both can be themselves: the drawing at full strength, the
     figures on clean ground. */
  body.at-stats .gw-block { min-height: 322px; }
  /* The drawing keeps the top right of the frame and stops above the plaques,
     which run the full width along the bottom the way a title block does. */
  body.at-stats .gw-art {
    left: auto; right: 0; width: 36%; bottom: 96px;
    align-items: center;
  }
  body.at-stats .gw-panes { grid-template-columns: 1fr; width: 62%; gap: var(--s5); }
  body.at-stats .gw-plaques { margin-top: auto; }
  body.at-stats .gw-plot { margin-top: var(--s2); }
  /* Small enough to sit IN the crown rather than across it — a seal on a
     painting is a mark in a corner of it, not a second painting. */
  body.at-stats .gw-seal {
    place-items: center;
    font-size: 76px;
    padding-top: 42px;
    opacity: .12;
  }
  body.at-stats .pg-hero { grid-column: 1; grid-row: 2; }
  body.at-stats .gw-block { grid-column: 2; grid-row: 2; }
  body.at-stats .sk-block { grid-column: 1; grid-row: 3; }
  body.at-stats .id-block { grid-column: 2; grid-row: 3; }
  body.at-stats .bl-block { grid-column: 1; grid-row: 4; }
  body.at-stats .tn-block { grid-column: 2; grid-row: 4; }
  /* And the sideboard along the full width, which is where the photograph's
     is. It is the last thing before the loose figures, so the bonsai on its
     lid has a lid to stand on. */
  /* The gap over the cabinet is the bonsai's headroom, not a margin choice:
     the drawing hangs off its top edge and anything less puts a tree through
     the bottom of the frame above. */
  body.at-stats .stats-grid { grid-row: 5; margin-top: 82px; }
  /* The twenty-six weeks, along the bottom of the wall. `order` rather than a
     row number: everything between the cabinet and it places itself, and a
     fixed row would put the heatmap ON one of them the day a section is
     added. */
  body.at-stats .heat-block { order: 2; }
  /* The board is small and the number on it is the subject, so it is centred
     on its own surface rather than sitting in a line the way it did when it
     was a full-width strip. */
  /* ── AND THE NUMBER IS CHALKED IN THE MIDDLE OF IT ───────────────────
     The board is a square of slate on a wall now, not a strip across a page,
     and a figure sitting in its bottom left corner is a figure that has been
     pushed there. Centred, stacked, and the number twice the size it is
     anywhere else on this host — a streak is the one thing on this screen
     somebody checks from across the room. */
  body.at-stats .pg-hero {
    flex-direction: column; align-items: center; justify-content: center;
    gap: var(--s5);
    text-align: center;
    min-height: 210px;
  }
  body.at-stats .pg-main {
    flex-direction: column; align-items: center; gap: 2px;
  }
  body.at-stats .pg-n { font-size: clamp(56px, 6.4vw, 88px); }
  body.at-stats .pg-k { font-size: var(--fs-body); letter-spacing: .02em; }
  /* ── 七 AND THE SEVEN ARE CHALKED, NOT PRINTED ───────────────────────
     *"ganz oben soll das Week-Streak sein, mit sieben Punkten, die ausgefüllt
     sind von dieser Woche oder eben nicht."*

     They were fifteen pixels wide on a strip and are on a slate the size of a
     real one now, so they are drawn at the size somebody reads from a chair:
     a chalk RING for a day not yet studied, a filled one for a day that was,
     and a dimmed one for a day that has not happened. Three states, because
     "not yet" and "missed" are different things and nobody on a Tuesday should
     see Friday as a failure.

     The fill is chalk rather than the growth ramp's green: everything else on
     this board is written in chalk, and one green dot in a chalk drawing is
     the only thing on it that came from a stylesheet. */
  body.at-stats .pg-week { gap: 14px; }
  body.at-stats .pg-d { gap: 9px; }
  body.at-stats .pg-dl { font-size: var(--fs-fine); letter-spacing: .04em; }
  body.at-stats .pg-dot {
    width: 26px; height: 26px;
    border-width: 2px; border-color: rgba(240, 236, 224, .32);
  }
  body.at-stats .pg-d.on .pg-dot {
    background: #EFEBDF; border-color: #EFEBDF;
    box-shadow: 0 0 9px rgba(240, 236, 224, .45);
  }
  body.at-stats:not(.in-court):not(.in-intro) #view {
    /* 墙 The wall. Lime plaster over brick: a warm grey, mottled twice, a
       little darker toward the corners the way a wall lit from one window is,
       and a floor shadow along the bottom so the room has a depth to stand
       things in. */
    --fg: #2C2720;
    --fg-2: #4E463A;
    /* #7C7462 read 2.2 to 3.3:1 on the plaster, which darkens toward its
       corners (#2144); the least darkening that clears 4.5 on the frames.
       The plaster and the frames are darker than paper, so the tones as type
       carry more of the ink here than on a page. */
    --muted: #564F42;
    --t1-keep: 55%; --t2-keep: 37%; --t3-keep: 44%; --t4-keep: 51%; --t5-keep: 52%;
    --jade-keep: 44%; --amber-keep: 37%; --violet-keep: 50%;
    --hair: rgba(58, 48, 34, .15);
    --card: rgba(255, 252, 243, .5);
    --raised: rgba(92, 78, 54, .1);
    --bg-sunken: rgba(92, 78, 54, .14);
    --seal-text: #9E3B24;
    --sink: 0 1px 2px rgba(48, 40, 28, .12);
    --panel: 0 1px 3px rgba(48, 40, 28, .12);
    --edge: none;
    color: var(--fg);

    border-radius: 2px;
    /* 墙 The wall, and the light on it comes from the window at the left: a
       wide soft wash from that edge, a little darker toward the far corner,
       and the lime's own mottle under both. The floor shadow at the bottom is
       where the wall meets the boards. */
    background:
      linear-gradient(to top, rgba(48, 40, 28, .22) 0, transparent 120px),
      radial-gradient(70% 90% at -6% 34%, rgba(255, 246, 226, .7), transparent 66%),
      radial-gradient(56% 46% at 92% 78%, rgba(74, 60, 40, .16), transparent 70%),
      radial-gradient(120% 100% at 42% 44%, transparent 46%, rgba(60, 48, 30, .19) 100%),
      repeating-linear-gradient(126deg,
        rgba(92, 78, 54, .022) 0 2px, transparent 2px 5px),
      linear-gradient(172deg, #DED6C6 0%, #D5CCB8 62%, #CAC0A9 100%);
    /* 框 The room's own timber, which is the frame you are looking through.
       Two tones and a hairline, heavier at the foot the way a door frame sits
       on its sill. */
    box-shadow:
      inset 0 0 0 1px rgba(255, 250, 238, .16),
      0 0 0 16px #2D2721,
      0 0 0 21px #443A2D,
      0 0 0 22px rgba(0, 0, 0, .35),
      0 30px 60px -28px rgba(0, 0, 0, .55);
    /* A lattice screen stands in each of these bands, and the floor reaches
       back out through them — see `.room-floor`, which reads all three. */
    --room-x: 148px;
    --room-b: clamp(28px, 3vw, 52px);
    --room-floor-h: 92px;
    padding-left: var(--room-x);
    padding-right: var(--room-x);
    backdrop-filter: none;
  }
  /* ── 窗棂 THE LATTICE SCREEN ──────────────────────────────────────────
     The one thing every photograph in the reference board has: a wall of
     wooden lattice with daylight behind it. It is what says INTERIOR in this
     architecture — more than a floor, more than a beam — because a lattice is
     only ever a wall between a room and somewhere else.

     Drawn in gradients and not an image: two repeating runs of dark wood
     crossing at 38 units make the grid, and a warm pale wash behind them is
     the day outside, from the top of the wall to the floor.

     Its light is on the WALL as well — the wash in the background above comes
     from this edge — so the two agree about where the sun is. */
  body.at-stats .screen { position: relative; z-index: 1; }
  /* Everything on the wall sits over the lattice and the floor — except the
     seal, which is the one child of this screen that is not a grid item and
     places itself. `:not` rather than a second rule after it, because a rule
     that has to come later to win is a rule the next edit moves. */
  body.at-stats .screen > *:not(.board-seal):not(.room-lamp) { position: relative; z-index: 1; }
  body.at-stats .screen::before,
  body.at-stats .screen::after {
    content: '';
    position: absolute; top: 0; bottom: 0;
    width: 128px;
    z-index: 0;
    /* The glass runs to the floor (#2140, *"lass das glas ganz runter
       gehen"*). The bottom third was a solid panel of wood, the way a real
       screen turns into furniture; on a wall this tall it read as the window
       stopping halfway and a dark column taking over. */
    /* ── 冰裂 CRACKED ICE, NOT A SQUARE GRID (#2189) ────────────────────
       *"bitte checke dass keines der neuen Design-Elemente japanisch ist."*
       Dark wood crossing at right angles over paper, in a room drawn from a
       photograph of a tea room, is a shoji, and it was one. The Suzhou
       window is the lattice the owner means: 冰裂纹, the cracked-ice pattern,
       straight bars meeting at every angle but a right one. One 96px tile,
       drawn to meet itself at all four edges, so the panel reads as one
       window running to the floor and not as a stamp repeated. It is an
       inline drawing rather than a file: nothing is fetched. */
    /* ── 方 AND THEN THE SQUARE GLASS, AT EVERY HOUR (#2342) ───────────────
       The cracked ice above stood by day and the square panes from dusk, so
       two people looking at Progress at the same moment saw two different
       windows: the hour is each browser's own (court.js `phaseOn`, off the
       clock and the time zone), and Paul's was already dusk. The owner, of
       the dusk window: *"kannst du bitte immer dieses quadratische Glas
       übernehmen bei jeder Tageszeit … das Licht, was durchfällt, ist soooo
       schön"*, and Paul, of the same room: *"so ist der Look super, bitte
       beibehalten"* (#2331, #2332). So the square panes and the evening
       light through them are the window at every hour now, with the warm
       light they throw into the room; the night keeps its stars below. This
       reverses #2189's cracked ice on the owner's word, and the drawing of
       it is in the history if it is ever wanted back. */
    background:
      repeating-linear-gradient(to right, #2A221A 0 6px, transparent 6px 38px),
      repeating-linear-gradient(to bottom, #2A221A 0 6px, transparent 6px 38px),
      linear-gradient(to bottom, #5B5A86 0%, #9E7390 38%, #E0A07C 72%, #F2C48E 100%);
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4);
  }
  /* ── AND THERE ARE TWO OF THEM, WHICH IS THE WHOLE OF "ÜBERSICHTLICHER" ─
     *"irgendwie muss das noch schöner und übersichtlicher wirken … alles muss
     toll in so eine Wand integriert werden"*, with a photograph of a tea room:
     one big panel in the middle, a slatted light screen on EACH side of it, a
     low sideboard running the full width underneath, and everything set into
     the wall rather than hung on it.

     One screen on the left was a room with a window. Two, one at each hand,
     is a WALL — the composition closes, the middle becomes the middle, and
     the objects in it stop looking scattered because there is something for
     them to be centred between. */
  body.at-stats .screen::before {
    left: calc(-1 * var(--s5) - 148px);
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4),
                9px 0 30px -10px rgba(240, 170, 110, .3);
  }
  body.at-stats .screen::after {
    right: calc(-1 * var(--s5) - 148px);
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4),
                -9px 0 30px -10px rgba(240, 170, 110, .3);
  }

  /* ── 灯 THE LANTERN ──────────────────────────────────────────────────
     Hung on its cord in front of the right-hand screen, which is where a
     lantern goes in every one of the reference photographs: over the edge of
     the light, not in the middle of the room. views.js has the drawing and
     the argument for why this room needs one thing that is not a number. */
  .room-lamp {
    display: block;
    position: absolute; top: -14px; z-index: 2;
    /* Centred on the part of the screen the frame leaves visible: the round
       body is wider than the old cage and was touching the timber. */
    right: calc(-1 * var(--s5) - 112px);
    width: 84px;
    color: #2F281F;
    pointer-events: none;
  }
  .room-lamp svg { width: 100%; height: auto; display: block; }
  /* ── 红灯笼 A CHINESE LANTERN (#2146) ─────────────────────────────────
     *"bitte verwende hier eher eine typisch chinesische Lampe anstelle einer
     japanischen"*. The glazed cage of dark wood was an andon; this is the
     red lantern of a Chinese street: a round body gathered on its ribs, gold
     caps top and bottom, a gold tassel hanging from a knot.

     The light is in the paper: the body is filled with a gradient that is
     brightest at its heart, and the halo sits behind the whole drawing, so
     the gold stays metal rather than shining like paper. Every rule is under
     `.room-lamp`, because the Library hangs a lantern of its own and neither
     room may paint the other's. */
  .room-lamp .lamp-hang { fill: #2F281F; }
  .room-lamp .lamp-body { fill: url(#pg-lamp-lit); }
  /* The halo is a round wash BEHIND the drawing and not a drop-shadow on it:
     a filter on an SVG shape is clipped to a box a tenth larger than the
     shape, which drew the glow as a lit square round a round lantern. Centred
     on the body (68 of 176 units down, at 84px wide). */
  .room-lamp::before {
    content: ""; position: absolute; z-index: -1;
    left: 50%; top: 79px; width: 210px; height: 210px;
    translate: -50% -50%; border-radius: 50%;
    background: radial-gradient(circle, rgba(246, 128, 78, .5) 0%,
      rgba(246, 150, 92, .22) 34%, rgba(250, 190, 120, .08) 56%, transparent 70%);
  }
  .room-lamp .lamp-rib { fill: none; stroke: #8E2216; stroke-width: 1.3; opacity: .55; }
  .room-lamp .lamp-gold { fill: #D6A03C; }
  .room-lamp .lamp-band { fill: #9A6A1E; }

  /* ── 黑板 THE BOARD ──────────────────────────────────────────────────
     *"ich finde auch so eine dunkle Tafel viel cooler"*, with a photograph of
     a blackboard covered in chalked characters. The streak is the one number
     on this screen that is different tomorrow, so it is the one that is
     chalked rather than painted or printed.

     The chalk is what takes the work: a flat pale fill reads as white type on
     black, which is a terminal. Chalk is uneven — so the text is slightly warm
     rather than white, and it has the faintest glow of dust around it. */
  body.at-stats .pg-hero {
    --fg: #EFEBDF;
    --fg-2: #C8C2B0;
    /* #918A78 was 3.7:1 where the board is lightest (#2144). */
    --muted: #A19A8A;
    --seal: #E9B3A2;
    --hair: rgba(240, 236, 224, .16);
    color: var(--fg);
    border-radius: 3px;
    background:
      radial-gradient(70% 60% at 22% 26%, rgba(240, 236, 224, .05), transparent 72%),
      radial-gradient(60% 70% at 84% 78%, rgba(240, 236, 224, .04), transparent 70%),
      linear-gradient(168deg, #24241F 0%, #1D1D19 100%);
    box-shadow:
      0 0 0 7px #4A3F30,
      0 0 0 8px rgba(0, 0, 0, .4),
      0 14px 28px -14px rgba(0, 0, 0, .55);
    text-shadow: 0 0 6px rgba(240, 236, 224, .18);
  }
  body.at-stats .pg-n { color: #F3EFE4; }

  /* ── 画 THE TWO PICTURES ─────────────────────────────────────────────
     A cream ground gone ochre at the edges in a mitred dark frame, which is
     the reference exactly. The skills hang in one and the twenty-six weeks in
     the other, because they are the two things on this screen that are a
     PICTURE of something rather than a figure. */
  body.at-stats .sk-block,
  body.at-stats .fc-block,
  body.at-stats .tc-block,
  body.at-stats .mem-block,
  body.at-stats .sp-block,
  body.at-stats .gw-block,
  body.at-stats .id-block,
  body.at-stats .bl-block,
  body.at-stats .tn-block,
  body.at-stats .heat-block {
    --fg: #2C2720;
    --fg-2: #4E463A;
    --muted: #544D40;
    --hair: rgba(58, 48, 34, .16);
    --growth-0: rgba(58, 48, 34, .08);
    --growth-1: #C9D9C2;
    --growth-2: #A6C8A4;
    --growth-3: #82B887;
    --growth-4: #5FA86A;
    color: var(--fg);
    padding: var(--s5);
    border-radius: 2px;
    background:
      radial-gradient(56% 46% at 14% 14%, rgba(150, 120, 70, .15), transparent 70%),
      radial-gradient(60% 50% at 88% 84%, rgba(126, 100, 58, .16), transparent 72%),
      radial-gradient(120% 100% at 50% 50%, transparent 54%, rgba(74, 58, 32, .15) 100%),
      repeating-linear-gradient(to bottom,
        rgba(104, 86, 56, .03) 0 1px, transparent 1px 3px),
      linear-gradient(168deg, #EDE5D2 0%, #E3D9C2 100%);
    /* ── LIT FROM BEHIND, WHICH IS WHAT SETS A PANEL INTO A WALL ───────
       The reference photograph has a warm line all round its centre panel —
       concealed light in the reveal behind it. That is the difference between
       a picture HUNG on a wall and one set INTO it, and it costs one more
       shadow: a wide warm spread outside the frame, under the drop shadow
       that was already there. */
    box-shadow:
      0 0 0 3px #46392C,
      0 0 0 11px #2B241C,
      0 0 0 12px rgba(0, 0, 0, .3),
      0 0 34px 8px rgba(250, 226, 178, .38),
      0 16px 30px -16px rgba(0, 0, 0, .45);
  }

  /* ── 架 THE SKILLS ARE JARS ON A SHELF ────────────────────────────────
     *"oder so was?"*, with a photograph of a tea shop's shelf wall: a grid of
     wooden compartments, a glass jar standing in each, every one filled to a
     different height, and a small paper tag under it.

     It is the best picture of a RATIO anybody has sent for this screen, and
     the skills are the only four ratios on it — 186 of 299, 94 of 299, 22 of
     48, 63 of 300. A bar says those across; a jar says them UP, and four jars
     filled to four heights are readable from the other side of the room in a
     way four bars are not.

     Two fills, because a skill has two numbers: what you have STARTED is the
     pale leaf and what has SETTLED is the dark, and the dark sits inside the
     pale exactly as it sat inside the bar. Nothing was added and nothing was
     dropped — the same two shares, stood on end.

     THE JAR IS DRAWN AND NOT AN IMAGE. A body with a lid pressed into its top
     edge, a highlight down the left where the glass turns, and the leaf
     filling from the bottom. The compartment around it is the shelf: an
     upright between each pair and a board under them all. */
  /* ── 两层 TWO SHELVES OF TWO, AND THE JARS ARE BIGGER ────────────────
     *"kannst du die Gläser irgendwie schöner so übereinander anordnen, so
     vier wie in einem Regal, wie in dem ursprünglichen Bild? Gerne auch
     größer."*

     The reference is a tea shop's shelf WALL, which is a grid and never a
     rail: four compartments in a single row is a spice rack, and it wasted
     the whole bottom half of a square frame while keeping the jars small
     enough that two of them looked empty. Two rows of two fills the frame,
     and every jar is half again as tall — which is the part that matters,
     because a jar is read by how far up the leaf comes.

     The shelf is built out of the compartments and not around them: an
     upright on the right of the left-hand one, a board under each row. The
     last cell of a row has no upright and the bottom row's board is the
     carcase's own edge. */
  body.at-stats .sk {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0;
    margin-top: var(--s4);
    border-radius: 2px;
    background: linear-gradient(168deg, #6B573C 0%, #57462F 100%);
    box-shadow: inset 0 0 0 1px rgba(255, 248, 232, .1),
                0 2px 6px -3px rgba(38, 30, 20, .5);
  }
  body.at-stats .sk li {
    display: flex; flex-direction: column; align-items: center;
    gap: var(--s3);
    padding: var(--s4) var(--s3) var(--s4);
    box-shadow: inset -1px 0 0 rgba(38, 30, 20, .4),
                inset 0 -4px 0 rgba(38, 30, 20, .28);
  }
  body.at-stats .sk li:nth-child(2n) { box-shadow: inset 0 -4px 0 rgba(38, 30, 20, .28); }
  body.at-stats .sk-track {
    position: relative;
    width: 62px; height: 96px;
    border-radius: 6px 6px 11px 11px;
    overflow: hidden;
    background: linear-gradient(to bottom, rgba(255, 252, 244, .5), rgba(240, 232, 214, .3));
    box-shadow: inset 0 0 0 1px rgba(255, 252, 244, .5),
                inset 0 8px 0 rgba(58, 48, 34, .22),
                0 2px 4px -2px rgba(38, 30, 20, .6);
  }
  body.at-stats .sk-track::after {
    content: '';
    position: absolute; left: 8px; top: 16px; bottom: 8px; width: 6px;
    border-radius: 3px;
    background: linear-gradient(to bottom, rgba(255, 253, 248, .62), transparent);
    z-index: 2;
  }
  body.at-stats .sk-track i { inset: auto 0 0 0; width: auto; border-radius: 0; }
  /* The leaf is OPAQUE in the jar, which the bar's fills were not. A
     thirty-four per cent jade over pale glass is pale glass, and the first
     screenshot had two jars that looked empty and two that looked half full
     when all four have a started share. Mixed toward the board's own cream
     rather than toward transparency: it is leaf in a jar, and leaf is solid. */
  /* 0.86 of a per cent, because the jar is 96 tall and the lid presses 8 of
     it — a full jar fills to the underside of the lid and not through it. */
  body.at-stats .sk-started {
    height: calc(var(--started, 0) * 0.86px);
    background: color-mix(in srgb, var(--jade) 40%, #E7DFC8);
  }
  body.at-stats .sk-learned { height: calc(var(--learned, 0) * 0.86px); }
  /* 百分 The share, written in the glass (#2135). Over the fill and under
     the highlight's stripe, in the ink of the paper tags, with a halo of the
     glass's own cream so it reads on empty glass, on the pale leaf and on
     the dark one alike. */
  body.at-stats .sk-pc {
    display: block;
    position: absolute; left: 0; right: 0; top: 50%; z-index: 3;
    translate: 0 -30%;
    text-align: center;
    font-family: 'Fraunces', Georgia, serif; font-size: 17px; font-weight: 600;
    font-variant-numeric: lining-nums tabular-nums; line-height: 1;
    color: #2C2720;
    text-shadow: 0 0 3px rgba(255, 252, 244, .95), 0 0 7px rgba(255, 252, 244, .8);
  }
  /* *"Diese Gläser bei Characters reading ist nicht zentriert"* (#2072): on a
     narrower desk the longest name wraps, the label stayed ragged-left and
     its jar stood a line lower than its neighbour's. The name centres, and
     the jar sits on the shelf board — the bottom of the cell — so a row of
     jars stands level whatever the names above them do. */
  body.at-stats .sk-k { color: #F0E9D8; font-weight: 500; font-size: var(--fs-body);
    text-align: center; line-height: 1.25; }
  body.at-stats .sk-track { margin-top: auto; }
  body.at-stats .sk-n { min-width: 0; text-align: center; color: #D2C9B5; }

  /* ── 柜 THE CABINET ──────────────────────────────────────────────────
     Black lacquer, and every figure is a drawer. Six numbers that do not
     change from one day to the next, in the thing a room keeps the things it
     does not need every day in.

     The pull is a single brass dot and not a handle: at this size a drawn
     handle is a smudge, and one warm point of metal per drawer is what the eye
     reads as "this opens". */
  body.at-stats .stats-grid {
    --fg: #EDE7D8;
    --muted: #A2998A;
    position: relative;
    gap: 2px;
    padding: 6px;
    border-radius: 3px;
    background: linear-gradient(168deg, #2C251D 0%, #221C16 100%);
    box-shadow:
      0 0 0 5px #4A3F30,
      0 0 0 6px rgba(0, 0, 0, .4),
      0 16px 30px -16px rgba(0, 0, 0, .5);
  }
  body.at-stats .stats-grid > *:not(.bonsai):not(.drawers-k) {
    position: relative;
    padding: var(--s4) var(--s4) var(--s4) var(--s6);
    border-radius: 2px;
    color: var(--fg);
    background: linear-gradient(168deg, #3A3027 0%, #2E2720 100%);
    box-shadow: inset 0 1px 0 rgba(240, 236, 224, .07),
                inset 0 -1px 0 rgba(0, 0, 0, .3);
  }
  body.at-stats .stats-grid > *:not(.bonsai):not(.drawers-k)::before {
    content: '';
    position: absolute; left: var(--s4); top: 50%;
    width: 9px; height: 9px;
    translate: 0 -50%;
    border-radius: 50%;
    background: radial-gradient(circle at 34% 30%, #D9B87C, #8A6B38);
    box-shadow: 0 1px 1px rgba(0, 0, 0, .5);
  }

  /* ── 盆栽 AND ONE LIVING THING ON TOP OF IT ──────────────────────────
     *"du kannst gerne eine Bonsai dahin stellen."*

     On the cabinet's right-hand end, sitting on the lid rather than beside it:
     `bottom: 100%` puts its pot exactly on the top edge, and the pot's own
     shadow is what lands it there. It is the second object on this wall that
     is only itself — the lantern is the first — and between them they are what
     keeps the room from being a dashboard with a floor.

     views.js has the drawing. It is a child of the cabinet because absolute
     positioning needs something to be absolute against, and it is excluded
     from the drawer rules two blocks up by name rather than by order. */
  body.at-stats .bonsai {
    display: block;
    position: absolute; right: 44px; bottom: calc(100% - 5px);
    width: 112px;
    color: #2A2119;
    pointer-events: none;
    filter: drop-shadow(0 3px 5px rgba(28, 22, 14, .3));
  }
  body.at-stats .bonsai svg { width: 100%; height: auto; display: block; }
  /* The window the six drawers are counted over, on the carcase above them.
     It is a caption on a piece of FURNITURE and not on a picture, so it is
     brass-small and sits on the wood rather than on a mount. */
  body.at-stats .drawers-k {
    display: block;
    position: absolute; left: 12px; bottom: calc(100% + 7px);
    font-size: var(--fs-fine); letter-spacing: .08em; text-transform: uppercase;
    color: #8A7B62;
  }
  /* The leaf pads are the one part of a bonsai that is not wood, and on a
     drawing this small they are the only chance to say so. */
  body.at-stats .bon-leaf { fill: #4E6B44; }
  body.at-stats .bon-pot { fill: #7C4A33; }

  /* ══ 夜 THE ROOM AFTER DARK ═══════════════════════════════════════════
     *"Das ist zu hell … gerade für abends."*

     The room was built to keep its own light whatever the hour — the wall,
     the frames and the lacquer are OBJECTS, and walking into a room does not
     repaint it. That was right about the theme and wrong about the clock: a
     room at ten at night is not lit like a room at noon. Daylight through the
     lattice on a pale lime wall is the brightest thing on the whole host, and
     it was being shown to somebody studying in the dark.

     So from dusk the room is LAMPLIT, on the same `data-phase` the map, the
     camp and the League are lit by. The lattice has night behind it instead of
     day, with a few stars; the wall is the same plaster in shadow, warmed from
     the right where the lantern hangs; the pictures are the same paper by
     lamplight — dark, with the type pale on it — and the lantern itself burns
     brighter, because it is now what lights the room. The chalkboard, the jar
     shelf and the cabinet were dark already and are untouched.

     Every override is a TOKEN or a background, as the room's own rules are:
     nothing inside a frame needed a rule of its own to follow it. */
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats:not(.in-court):not(.in-intro) #view {
    --fg: #EDE5D3;
    --fg-2: #CFC4AE;
    /* #A2977F, lifted to clear 4.5 on the lamplit frames (#2144). The tones
       as type, likewise, on its warmer, lighter surfaces. */
    --t1-keep: 75%; --t2-keep: 100%; --t3-keep: 100%; --t4-keep: 82%; --t5-keep: 81%;
    --jade-keep: 100%; --amber-keep: 100%; --violet-keep: 84%;
    --muted: #B2A891;
    --hair: rgba(240, 230, 210, .14);
    --card: rgba(30, 25, 20, .5);
    --raised: rgba(240, 228, 202, .07);
    --bg-sunken: rgba(0, 0, 0, .28);
    --seal-text: #E6A58A;
    color: var(--fg);
    background:
      linear-gradient(to top, rgba(0, 0, 0, .38) 0, transparent 120px),
      radial-gradient(48% 56% at 97% 10%, rgba(246, 190, 110, .26), transparent 72%),
      radial-gradient(40% 50% at 2% 30%, rgba(120, 136, 196, .1), transparent 70%),
      radial-gradient(120% 100% at 42% 44%, transparent 40%, rgba(0, 0, 0, .34) 100%),
      repeating-linear-gradient(126deg,
        rgba(255, 240, 210, .018) 0 2px, transparent 2px 5px),
      linear-gradient(172deg, #3A3129 0%, #2F2821 62%, #26201A 100%);
  }
  /* 窗 Night behind the lattice: the same bars over an indigo sky and a
     scatter of stars, and the light the screens throw into the room is cool
     moonlight now rather than warm day. */
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .screen::before,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .screen::after {
    background:
      repeating-linear-gradient(to right, #2A221A 0 6px, transparent 6px 38px),
      repeating-linear-gradient(to bottom, #2A221A 0 6px, transparent 6px 38px),
      radial-gradient(1.3px 1.3px at 30% 9%, #FFF6E0, transparent),
      radial-gradient(1px 1px at 72% 17%, #FFF6E0, transparent),
      radial-gradient(1.5px 1.5px at 54% 31%, #FFF6E0, transparent),
      radial-gradient(1px 1px at 18% 44%, #FFF6E0, transparent),
      radial-gradient(1.2px 1.2px at 84% 50%, #FFF6E0, transparent),
      linear-gradient(to bottom, #141A33 0%, #1D2443 58%, #262A44 100%);
  }
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .screen::before {
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4),
                9px 0 30px -10px rgba(130, 150, 210, .22);
  }
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .screen::after {
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4),
                -9px 0 30px -10px rgba(130, 150, 210, .22);
  }
  /* 暮 At dusk the sky behind the lattice is still an evening sky, not night.
     #2155: *"warum ist es hinter den gläsern schon dunkel obwohl das gesamte
     Environment noch hell ist?"* The room is lamplit from dusk on (the ground
     follows the phase, theme.js), but the scenery around the frame at dusk is
     a lit sunset, and stars behind the glass contradicted it. So the panes
     carry the same hour as the landscape: the afterglow low, violet above, no
     stars yet, and the light they throw into the room is warm. Night keeps the
     indigo and the stars above. */
  body[data-phase="dusk"].at-stats .screen::before,
  body[data-phase="dusk"].at-stats .screen::after {
    background:
      repeating-linear-gradient(to right, #2A221A 0 6px, transparent 6px 38px),
      repeating-linear-gradient(to bottom, #2A221A 0 6px, transparent 6px 38px),
      linear-gradient(to bottom, #5B5A86 0%, #9E7390 38%, #E0A07C 72%, #F2C48E 100%);
  }
  body[data-phase="dusk"].at-stats .screen::before {
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4),
                9px 0 30px -10px rgba(240, 170, 110, .3);
  }
  body[data-phase="dusk"].at-stats .screen::after {
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4),
                -9px 0 30px -10px rgba(240, 170, 110, .3);
  }
  /* 晨 THE MORNING GLASS IS YELLOW (#2467). *"In the morning it should be more
     yellowish the glass."* The evening sky above (#2342) stood behind the
     panes at every hour, so at nine in the morning the window was violet
     going orange: a sunset seen from a room the landscape outside had already
     lit with a morning sun. Only the morning changes: the same square panes
     over pale gold, light at the foot where the sun comes in, and the light
     they throw into the room a warm yellow rather than an evening orange.
     Every other hour keeps its own glass. */
  body[data-phase="morning"].at-stats .screen::before,
  body[data-phase="morning"].at-stats .screen::after {
    background:
      repeating-linear-gradient(to right, #2A221A 0 6px, transparent 6px 38px),
      repeating-linear-gradient(to bottom, #2A221A 0 6px, transparent 6px 38px),
      linear-gradient(to bottom, #D9C07A 0%, #E8D296 34%, #F2E2B4 68%, #F9EFD4 100%);
  }
  body[data-phase="morning"].at-stats .screen::before {
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4),
                9px 0 30px -10px rgba(240, 204, 110, .36);
  }
  body[data-phase="morning"].at-stats .screen::after {
    box-shadow: inset 0 0 0 9px #241D16, inset 0 0 0 10px rgba(0, 0, 0, .4),
                -9px 0 30px -10px rgba(240, 204, 110, .36);
  }
  /* 画 The pictures by lamplight: the paper goes dark and the ink goes pale,
     and the growth ramp swaps to greens that read on a dark ground — the
     cream-ground ramp is four pastels, and on this paper they were chalk. */
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .sk-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .fc-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .tc-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .mem-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .sp-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .gw-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .id-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .bl-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .tn-block,
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .heat-block {
    --fg: #EDE5D3;
    --fg-2: #CFC4AE;
    --muted: #B2A891;
    --hair: rgba(240, 230, 210, .13);
    --raised: rgba(240, 228, 202, .07);
    --bg-sunken: rgba(0, 0, 0, .3);
    --growth-0: rgba(240, 230, 210, .07);
    --growth-1: #2E4632;
    --growth-2: #3C6443;
    --growth-3: #4E8757;
    --growth-4: #63A86D;
    color: var(--fg);
    background:
      radial-gradient(56% 46% at 86% 12%, rgba(246, 196, 120, .08), transparent 70%),
      radial-gradient(120% 100% at 50% 50%, transparent 54%, rgba(0, 0, 0, .28) 100%),
      repeating-linear-gradient(to bottom,
        rgba(255, 240, 210, .02) 0 1px, transparent 1px 3px),
      linear-gradient(168deg, #3A332A 0%, #312B23 100%);
    box-shadow:
      0 0 0 3px #3A2F24,
      0 0 0 11px #1E1914,
      0 0 0 12px rgba(0, 0, 0, .4),
      0 0 30px 6px rgba(246, 190, 110, .1),
      0 16px 30px -16px rgba(0, 0, 0, .6);
  }
  /* 灯 And the lantern is the light in the room now, so it burns like it. */
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .room-lamp::before {
    width: 300px; height: 300px;
    background: radial-gradient(circle, rgba(250, 132, 80, .7) 0%,
      rgba(246, 150, 90, .3) 30%, rgba(246, 176, 100, .12) 52%, transparent 70%);
  }
  body:is([data-phase="dusk"], [data-phase="night"]).at-stats .room-floor { filter: brightness(.55); }
  /* ── 亭 THE CLASS IS OUTDOORS, AND THAT IS ALWAYS SO (owner, 2026-10-06) ─
     The class has been a gathering on a bank (2026-09-11), a classroom with
     a blackboard (2026-10-05, #2456 / #2500) and is outdoors again for good:
     *"sieht cool aus! aber hast du so ein layout auch für eine klasse im
     freien?"*, then *"Ich entscheide, dass es draußen ist und das ist immer
     so."* views.js has the whole argument. What it keeps from the room is
     the LAYOUT (a board at the front, the teachers under it, students at
     tables in rows that grow with the class and fit one screen); what it
     gives back is the landscape.

     So the sheet has no paper, as the camp has none: the map behind, zoomed
     to the schoolyard and lit by the hour, is the background, and on it
     stand a teaching pavilion in the map's own ink (mark-places.js, the
     schoolhouse's roof and lanterns), two pines at the edge, and a pale
     clearing of ground the class sits on. Words that are not on the board
     or on a name card stand on that clearing, never on a mountain.

     The sheet is WIDE (1280) because the ground is the whole screen. */
  body.at-klass { --sheet: 1280px; }
  /* ── AND THE SCHOOLHOUSE STEPS OUT OF THE YARD ──────────────────────────
     It was brought forward to .74 while the class gathered beside it. Now the
     pavilion stands in its yard at forty times its size and the board's own
     line quotes the schoolhouse beside the school's name, so the small one on
     the bank behind the pines is the camp's argument: the same place drawn
     twice, the worse copy underneath. */
  body.at-klass .court.is-open .mark.is-here { opacity: 0; }
  /* The selector carries both `:not`s for the reason the camp's does: the
     sheet's own rule is (1,2,1) and a plain `body.at-klass #view` loses.
     And `:has(.screen.klass)`, the camp's scope too: a learner in no class
     gets the JOIN form at #/class, and a form wants its paper. */
  body.at-klass:not(.in-court):not(.in-intro) #view:has(.screen.klass) {
    background: none;
    backdrop-filter: none;
    box-shadow: none;
    display: flex; flex-direction: column;
    padding: var(--s2) clamp(16px, 2.4vw, 34px) var(--s3);
  }
  body.at-klass .screen.klass {
    position: relative; flex: 1 1 auto;
    gap: var(--s3); align-items: center;
  }
  body.at-klass .screen.klass > .yard { align-self: stretch; }

  /* ── 松 THE PINES ────────────────────────────────────────────────────────
     At the two edges of the ground, in the drawing's ink thinned to the
     distance the schoolhouse's own willow stands at, so they frame the class
     rather than join it. Behind the ground and the pavilion in z. */
  body.at-klass .kp-pine {
    position: absolute; z-index: 0; top: 2%;
    /* By the window's width too: on a 1180 window a pine at the full height
       reached its lower tiers in over the end seats of a class of twenty. */
    height: min(74%, 580px, 40vw); aspect-ratio: 140 / 210;
    color: var(--kp-ink); opacity: .5;
    pointer-events: none;
  }
  body.at-klass .kp-pine svg { width: 100%; height: 100%; display: block; --court-hole: var(--kp-hole); }
  body.at-klass .kp-pine-l { left: calc(-1 * clamp(16px, 2.4vw, 34px) - 1.5vw); }
  body.at-klass .kp-pine-r { right: calc(-1 * clamp(16px, 2.4vw, 34px) - 1.5vw); }

  /* The legend is a caption on the ground, under the class, not a pill. */
  body.at-klass .screen > .note {
    position: relative; z-index: 1;
    background: none; box-shadow: none; backdrop-filter: none;
    padding: 0; margin: 0; text-align: center; color: var(--muted);
  }

  /* ══ 榜 THE LEAGUE ═════════════════════════════════════════════════════
     *"Die Wolken musst du an den Hintergrund anpassen … das Leaderboard sieht
     scheiße aus, such dir doch mal Inspo bei Mobbin."*

     ── THE SKY IS THE MAP'S OWN ─────────────────────────────────────────
     Two passes painted a sky ON the sheet — a saturated blue gradient with
     soft white clouds — in front of a map that is an ink-wash painting with a
     sky and clouds of its own, which follow the hour through five filters and
     washes. The two skies disagreed, and the painted one was right.

     So the sheet has no sky. It is a clear pane with the lightest frost, and
     the map behind it — zoomed, as every place is, to the League's mark high
     in its clouds — IS the background: its clouds are the drawing's clouds,
     its dawn is the court's dawn, and at night it is the court's inverted
     indigo without a second night being drawn on top of it.

     ── AND THE BOARD IS TWO OBJECTS ─────────────────────────────────────
     Every well-made board on Mobbin — Transit, Binance, TikTok, Duolingo,
     Uxcel — is a PODIUM for the top three and a LIST on a solid card for the
     rest. The podium stands on the sky; the list is paper, the drawing's own
     #F4F1E9 with its own ink, because a ranking is read and paper is what this
     host reads on. At night the paper inverts the way the drawing does. */
  body.at-league { --sheet: 1080px; }
  body.at-league:not(.in-court):not(.in-intro) #view {
    /* ── ONE SCROLL, FLUSH WITH THE BOTTOM OF THE WINDOW ───────────────
       *"Mach das Fenster gerne bis ganz nach unten, und beim Scrollen muss
       man ja nicht mehr oben die Tribüne sehen."* Right: the podium is the
       way IN, and somebody scrolling down is looking for themselves or for
       somebody else — at which point three large faces pinned over the list
       are only in the way. So the head, the podium and the list are one
       scroll and the podium goes up and out with the rest; the list gets the
       whole window. Every well-made board on Mobbin scrolls this way.

       And the sheet meets the bottom of the window with square corners: it
       ended 4px short with a rounded foot, which is a panel standing on the
       page rather than a list running off it. */
    height: calc(100dvh - 58px - env(safe-area-inset-top) - clamp(32px, 4.8vw, 68px) + 4px);
    margin-bottom: 0;
    border-bottom-left-radius: 0; border-bottom-right-radius: 0;
    /* Type on the pane sits on a painting, so it is dark ink with a paper halo
       by day and pale with a dark halo by night — the halo is what keeps a
       line legible across a ridge and a cloud in the same sentence. */
    --fg: #1C1E18; --fg-2: #3A3C34; --muted: #5E6056;
    --halo: rgba(244, 241, 233, .9);
    color: var(--fg);
    background: color-mix(in srgb, #F4F1E9 22%, transparent);
    backdrop-filter: blur(3px) saturate(1.05);
    box-shadow: inset 0 0 0 1px rgba(28, 30, 24, .08), 0 20px 50px -30px rgba(0, 0, 0, .4);
    /* ── AND THE LIST RUNS TO THE FOOT OF THE SHEET ───────────────────
       *"Mache das Fenster größer zum Scrollen … bis ganz nach unten."* The
       sheet faded out over its last 54px and kept 104 clear under that for
       the note and the map pill; the list stopped two finger-widths above
       the bottom with the sky showing under it. The note is in the head now,
       the pill sits outside the card's left edge at this width, and the fade
       is gone — so the card is the whole of what is left. */
    mask-image: none;
    /* No padding at the foot: a sticky row's bottom is measured from inside
       the padding, so with 32px of it the reader's pinned row floated above
       the edge with the next row peeping out underneath. The card carries the
       space instead, inside the scroll. */
    padding-bottom: 0;
  }
  body.at-league .lg-card { margin-bottom: clamp(20px, 2.4vw, 32px); }
  body[data-phase="night"].at-league:not(.in-court):not(.in-intro) #view,
  body[data-phase="dusk"].at-league:not(.in-court):not(.in-intro) #view {
    /* `--fg-2` was #D6D2C6: 4.2:1 through the halo on a dusk sky (#2144). */
    --fg: #F1EEE6; --fg-2: #E3E0D8; --muted: #B3AFA3;
    --halo: rgba(12, 14, 30, .85);
    /* Said again, because redefining --fg does nothing to type that has
       already inherited a resolved colour — the podium's names stayed ink on
       the night sky until this line was here. */
    color: var(--fg);
    background: color-mix(in srgb, #0E1024 26%, transparent);
  }
  body.at-league .screen { gap: var(--s4); }
  body.at-league .lg-head, body.at-league .lg-foot, body.at-league .lg-podium {
    text-shadow: 0 0 6px var(--halo), 0 0 2px var(--halo);
  }

  /* ── THE HEAD ───────────────────────────────────────────────────────── */
  body.at-league .lg-head {
    display: grid; grid-template-columns: minmax(0, 1fr) auto;
    align-items: center; gap: var(--s5);
  }
  body.at-league .lg-title h1 { margin: 0; }
  body.at-league .lg-title .fine { margin: 6px 0 0; color: var(--fg-2); }
  body.at-league .lg-left {
    display: inline-block; margin-left: 8px; padding: 2px 9px; border-radius: 99px;
    font-weight: 600; font-size: 11.5px; text-shadow: none;
    color: var(--seal-on); background: var(--seal);
  }
  body.at-league .lg-mine { grid-column: auto; display: flex; gap: var(--s3); margin: 0; }
  body.at-league .lg-mine > div {
    min-width: 84px; padding: 10px 14px; border-radius: var(--r-card);
    background: rgba(244, 241, 233, .64); box-shadow: 0 1px 2px rgba(28, 30, 24, .12);
    -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
    text-shadow: none; color: #1C1E18;
  }
  body.at-league .lg-mine dt { font-size: 11px; color: #5E6056; letter-spacing: .02em; }
  body.at-league .lg-mine dd {
    margin: 2px 0 0; font-family: 'Fraunces', Georgia, serif; font-size: 22px; font-weight: 600;
    line-height: 1.05; font-variant-numeric: tabular-nums;
  }

  /* ── 台 THE PODIUM ────────────────────────────────────────────────────
     Second, first, third, left to right, and first raised — the order every
     podium on the reference sheet uses, because it puts the winner in the
     middle of the eye. A face, a medal with the place on it, the name, the
     week's XP, and a block under it whose height is the place. */
  body.at-league .lg-podium {
    list-style: none; margin: 0; padding: 0;
    display: grid; grid-template-columns: repeat(3, minmax(0, 190px));
    justify-content: center; align-items: end; gap: var(--s5);
  }
  body.at-league .pd { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; }
  body.at-league .pd-face { position: relative; }
  /* ── NO RING FOR A PLACE, AND NONE FOR "ME" (#2245) ─────────────────────
     *"Randfarben sind ein bisschen verwirrend … bitte nur für den der online
     ist eine Randfarbe grün wählen, sonst aber nicht für irgendwelche
     Platzierungen."* Each face wore its medal as a 3px ring, and the
     reader's own a second ring of seal red outside that, so one podium could
     carry gold, silver, bronze, red and green edges at once and none of them
     said which was which. A face is a face now: a shadow to lift it off the
     sky and nothing round it, unless that person is online right now, which
     is the one ring left on this host (the green below, #2187). The place
     is still the medal disc at the foot of the face and the height of the
     step; "me" is still the red name. */
  body.at-league .pd .av {
    width: 54px; height: 54px; font-size: 22px;
    box-shadow: 0 6px 16px -6px rgba(0, 0, 0, .5);
  }
  body.at-league .pd1 .av { width: 68px; height: 68px; font-size: 28px; }
  body.at-league .pd1 { --medal: #E0B94A; }
  body.at-league .pd2 { --medal: #AEB4BF; }
  body.at-league .pd3 { --medal: #C47A3E; }
  body.at-league .pd-medal {
    position: absolute; left: 50%; bottom: -9px; translate: -50% 0;
    display: grid; place-items: center;
    width: 24px; height: 24px; border-radius: 50%;
    font-size: 12px; font-weight: 700; color: #1C1E18; text-shadow: none;
    background: var(--medal); box-shadow: 0 0 0 2px #F4F1E9;
  }
  /* ── AND THE NAMES STAND ON PAPER, LIKE EVERY OTHER LABEL ON THE MAP ──
     *"Highlighte die Schrift wie Name, Profilbild etc. mehr … sieht bisschen
     verblasst aus"* (#2084). A podium name sat straight on the sky with a
     halo behind it, which holds at midnight and washes out at every other
     hour. The camp's structure names, the class's header and the map's marks
     all solved this the same way — four millimetres of the card colour — and
     the podium joins them. */
  body.at-league .pd-name {
    margin-top: 8px; max-width: 100%;
    font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    padding: 2px 10px; border-radius: var(--r-pill);
    text-shadow: none;
  }
  body.at-league .pd-xp { padding: 1px 9px; border-radius: var(--r-pill); text-shadow: none; }
  /* The paper is the PLACE's and not the theme's, for the reason the friends
     panes carry at the foot of this file: at noon in dark mode a theme-coloured
     pill is a dark lozenge under dark ink. */
  body.at-league .pd-name, body.at-league .pd-xp {
    background: rgba(244, 241, 233, .84); color: #1C1E18;
    -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  }
  body[data-phase="night"].at-league .pd-name, body[data-phase="night"].at-league .pd-xp,
  body[data-phase="dusk"].at-league .pd-name, body[data-phase="dusk"].at-league .pd-xp {
    background: rgba(20, 22, 38, .82); color: #F1EEE6;
  }
  body.at-league .pd-xp small { color: inherit; }
  /* Today's share (#2241) on the same paper as the name and the week, in the
     growth ramp's two ends: the deep jade on paper, the light one on the
     night sky, where the list's --jade-ink would sit on a place it was not
     mixed for. */
  body.at-league .pd-today {
    padding: 0 8px; border-radius: var(--r-pill); line-height: 1.6;
    background: rgba(244, 241, 233, .84); color: #2F6B2E;
    -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  }
  body[data-phase="night"].at-league .pd-today,
  body[data-phase="dusk"].at-league .pd-today { background: rgba(20, 22, 38, .82); color: #8FCD72; }
  /* The streak on the same paper, in the name's ink (#2432). */
  body.at-league .pd-streak {
    padding: 0 8px; border-radius: var(--r-pill); line-height: 1.6;
    background: rgba(244, 241, 233, .84); color: #4A463C;
    -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  }
  body[data-phase="night"].at-league .pd-streak,
  body[data-phase="dusk"].at-league .pd-streak { background: rgba(20, 22, 38, .82); color: #D9D4C7; }
  /* The reader's name on the podium is the seal's red in INK, without the
     paper halo every other line here has — a halo round red type blurs it
     into a pink smudge, which is exactly what it did. */
  body.at-league .pd.me .pd-name { color: #A13A20; text-shadow: none; }
  body[data-phase="night"].at-league .pd.me .pd-name,
  body[data-phase="dusk"].at-league .pd.me .pd-name { color: #F2A184; }
  body.at-league .pd-xp { font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-size: 15px; font-variant-numeric: tabular-nums; }
  body.at-league .pd-xp small, body.at-league .lg-xp small { font-size: .62em; color: var(--muted); letter-spacing: .04em; }
  body.at-league .pd-block {
    display: block;
    width: 100%; margin-top: 6px; border-radius: 10px 10px 0 0;
    background: linear-gradient(to bottom, rgba(244, 241, 233, .82), rgba(244, 241, 233, .5));
    box-shadow: inset 0 2px 0 #F4F1E9;
    /* Low steps, because the list under them is the thing that scrolls and
       every pixel a pedestal takes is a row somebody has to scroll for. The
       difference in height is what says first, second and third — not the
       height itself. */
    height: 10px;
  }
  body.at-league .pd1 .pd-block { height: 26px; }
  body.at-league .pd2 .pd-block { height: 18px; }
  body.at-league .pd.cold .pd-face { opacity: .5; }

  /* ── 纸 THE LIST, ON PAPER ────────────────────────────────────────────
     Rank · face · name with the level under it · XP, divided by a hairline.
     The card is the flex item that grows, and its list fills column-first so
     a long league splits into columns instead of passing the fold. */
  /* ── ONE ROW EACH ───────────────────────────────────────────────────
     *"Mache doch ein User pro Zeile."* The columns kept a league of
     thirty-four on one screen by shrinking every row to eighteen pixels,
     faces overlapping. A row is 56px at every size of league, and the card is
     as long as its rows — the sheet scrolls, not the card. */
  body.at-league .lg-card {
    display: block;
    padding: 6px 18px; border-radius: var(--r-panel);
    --fg: #1C1E18; --muted: #6A6C62; --hair: rgba(28, 30, 24, .1);
    --card-bg: rgb(244, 241, 233);
    color: var(--fg); text-shadow: none;
    /* *"Ich finde das Weiß zu weiß … vielleicht leicht transparent tagsüber"*
       (#2073), and then *"immer noch sind die ganzen Accounts ein bisschen
       blass"* (#2096). Seventy per cent was too far: a list of thirty names
       over a mountain is a list nobody reads. Eighty-eight keeps the hint of
       sky — the thing #2073 asked for — and gives the names their paper back;
       the blur does the rest. */
    background: rgba(244, 241, 233, .88);
    -webkit-backdrop-filter: blur(12px) saturate(1.1);
    backdrop-filter: blur(12px) saturate(1.1);
    box-shadow: 0 1px 2px rgba(28, 30, 24, .12), 0 18px 40px -26px rgba(28, 30, 24, .5);
  }
  body[data-phase="night"].at-league .lg-card, body[data-phase="dusk"].at-league .lg-card {
    --fg: #F1EEE6; --muted: #A9A597; --hair: rgba(241, 238, 230, .1);
    --card-bg: rgb(20, 22, 38);
    background: rgba(20, 22, 38, .9);
  }
  body.at-league .lg-list { display: flex; flex-direction: column; }
  body.at-league .lg-row {
    --av: 36px;
    min-height: 56px; padding: 0 6px;
    font-size: 14.5px; gap: 14px;
    background: none; border-radius: 0;
    box-shadow: inset 0 -1px 0 var(--hair);
  }
  body.at-league .lg-row:last-child { box-shadow: none; }
  /* #2523: a friend or classmate, lightly, on the day card and the night one. */
  body.at-league .lg-row.lg-known {
    background: color-mix(in srgb, var(--fg) 7%, transparent); border-radius: 10px;
  }
  /* ── 你 AND YOUR ROW WAITS FOR YOU AT THE BOTTOM ────────────────────
     Somebody in thirtieth place opens the League on the podium, which is the
     right way in — and their own row is two screens down. So it is STICKY to
     the foot of the sheet: while its place is below the window it rides along
     the bottom edge, and when the scroll reaches it, it settles into its
     place in the list. No script, no jump, and the podium is still what the
     page opens on. Life Reset's league on Mobbin does exactly this.

     Opaque for that reason — a translucent row riding over other rows shows
     them through it — and lifted by a shadow above it, which is also what
     says it is not where it belongs yet. */
  body.at-league .lg-row.me {
    position: sticky; bottom: 0; top: 0; z-index: 2;
    border-radius: 10px;
    background: color-mix(in srgb, var(--seal) 18%, var(--card-bg));
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 45%, transparent),
                0 -8px 18px -10px rgba(0, 0, 0, .35);
  }
  /* The seal in INK on paper, and pale on the night card: `--seal-text` is
     the host's, tuned for a dark sheet, and on this paper it was a pink
     whisper. */
  body.at-league .lg-row.me .lg-name { color: #A13A20; font-weight: 700; }
  body[data-phase="night"].at-league .lg-row.me .lg-name,
  body[data-phase="dusk"].at-league .lg-row.me .lg-name { color: #F2A184; }
  body.at-league .lg-foot { margin: 2px 0 0; color: var(--fg-2); }
  /* The tier and the two tabs sit on the sky like the weeks-won plaque does,
     so they are made of the same paper: a halo'd pale line on a pale card was
     unreadable, and a transparent chip on the night sky was invisible. */
  body.at-league .lg-tabs .chip:not(.on), body.at-league .lg-pager .chip {
    --fg: #1C1E18; --muted: #5E6056;
    background: rgba(244, 241, 233, .86); color: #1C1E18; text-shadow: none;
    box-shadow: 0 1px 2px rgba(28, 30, 24, .12);
  }
  body.at-league .lg-group, body.at-league .lg-pager b { text-shadow: 0 0 6px var(--halo), 0 0 2px var(--halo); }

  /* ── 头 THE HEAD IN THREE LAYERS (#2122) ────────────────────────────────
     Name and clock on the left with the switch across from them; one band
     for where you stand; one quiet line. The band is the friends half's
     card (`.fl-head`, below), so both halves stand on the same object: paper
     by day, the night card after dark, its own ink either way. */
  body.at-league .lg-head { align-items: end; }
  body.at-league .lg-when { display: flex; align-items: center; flex-wrap: wrap; gap: 0 2px; }
  /* The two small lines on the sky get a denser halo than the title: at dusk
     the ink turns pale while the painted sky is still peach, and a 13px line
     with the title's thin halo washed out into it. */
  body.at-league .lg-when, body.at-league .lg-foot {
    font-weight: 500;
    text-shadow: 0 0 2px var(--halo), 0 0 5px var(--halo), 0 0 10px var(--halo);
  }
  /* A segmented control rather than two loose chips: one paper track, the
     chosen half in seal. */
  body.at-league .lg-seg {
    flex-wrap: wrap; justify-content: flex-end; gap: 2px; padding: 3px;
    border-radius: 18px; background: rgba(244, 241, 233, .86);
    box-shadow: 0 1px 2px rgba(28, 30, 24, .12);
  }
  body.at-league .lg-seg .chip { border-radius: var(--r-pill); padding: 6px 14px; }
  body.at-league .lg-seg .chip:not(.on) { background: transparent; box-shadow: none; }
  body.at-league .lg-status {
    display: flex; align-items: stretch;
    border-radius: var(--r-panel); overflow: hidden; text-shadow: none;
    -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
  }
  body.at-league .lg-tier {
    flex: 1; min-width: 0; margin: 0;
    display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center;
    gap: var(--s4); padding: var(--s4) var(--s5);
    border-radius: 0; background: transparent; color: var(--fg); text-shadow: none;
  }
  body.at-league .lg-tier:hover { background: color-mix(in srgb, var(--fg) 5%, transparent); }
  body.at-league .lg-tier-g {
    display: grid; place-items: center; width: 52px; height: 52px; border-radius: 14px;
    font-family: var(--serif-hz); font-size: 20px; letter-spacing: .02em;
    color: #A13A20; background: rgba(161, 58, 32, .1);
  }
  body.at-league .lg-tier-b { display: grid; gap: 6px; min-width: 0; }
  body.at-league .lg-tier-b b { font-family: 'Fraunces', Georgia, serif; font-size: 18px; line-height: 1.15; }
  body.at-league .lg-tier-place {
    font-family: 'Inter', system-ui, sans-serif; font-size: var(--fs-fine); font-weight: 600; color: var(--fg-2);
  }
  body.at-league .lg-tier .fine { color: var(--fg-2); }
  body.at-league .lg-tier .lg-meter { width: min(340px, 100%); height: 5px; background: var(--hair); }
  body.at-league .lg-tier-more { color: #A13A20; white-space: nowrap; }
  body.at-league .lg-status .lg-mine {
    flex: none; display: grid; place-content: center; text-align: center;
    padding: var(--s4) var(--s6); border-left: 1px solid var(--hair);
  }
  body.at-league .lg-status .lg-mine > div {
    display: flex; flex-direction: column-reverse; align-items: center;
    min-width: 0; padding: 0; background: none; box-shadow: none; color: inherit;
    -webkit-backdrop-filter: none; backdrop-filter: none;
  }
  body.at-league .lg-status .lg-mine dt { color: var(--fg-2); font-size: var(--fs-fine); }
  body.at-league .lg-status .lg-mine dd { margin: 0 0 2px; font-size: 26px; }
  body[data-phase="night"].at-league .lg-tier-g,
  body[data-phase="dusk"].at-league .lg-tier-g { color: #F2A184; background: rgba(242, 161, 132, .14); }
  body[data-phase="night"].at-league .lg-tier-more,
  body[data-phase="dusk"].at-league .lg-tier-more { color: #F2A184; }
}

/* ══ 班 THE CLASS IN THE OPEN: PAVILION, GROUND, TABLES (owner, 2026-10-06) ═
   views.js has the argument (`renderClass`): the class is outdoors, always.
   This is how it is drawn. Everything that is architecture is the map's own
   ink (`--kp-ink`), cut through to the paper (`--kp-hole`) as every building
   on the map is, and it follows the hour below. Everything that is WORDS is
   on paper or lacquer of its own, so it reads at every hour: the signboard,
   the name cards, the ground the figures stand on.
   ═══════════════════════════════════════════════════════════════════════════ */
/* ── THE HOUR ─────────────────────────────────────────────────────────────
   On `.screen.klass` and keyed off `body[data-phase]`, the camp's way (the
   court's own phase rules cannot reach `#view`; learn.js `setPhase` has the
   note). By day the drawing's ink on a pale clearing; at dusk the clearing
   warms and the lanterns are lit; at night the masses go pale as moonlight
   (the camp's flip), the ground goes dark and the lanterns burn. The type
   follows the ground, so it is set here and not by the theme. */
.screen.klass {
  --kp-ink: color-mix(in srgb, #1C1E18 76%, #3E646C);
  --kp-hole: #F4F1E9;
  --kp-ground: #F3EFE5;
  --kp-mat: rgba(150, 124, 80, .3);
  --kp-lamp: #C8452E;
  --kp-glow: none;
  --fg: #2C2720;
  --fg-2: #4E463A;
  --muted: #564F42;
  --hair: rgba(58, 48, 34, .15);
  --raised: rgba(92, 78, 54, .1);
  --card: rgba(255, 252, 243, .6);
  --seal-text: #9E3B24;
  color: var(--fg);
}
body[data-phase="dawn"] .screen.klass { --kp-ground: #F5EDE6; }
body[data-phase="morning"] .screen.klass { --kp-ground: #F6F0DE; }
body[data-phase="dusk"] .screen.klass {
  --kp-ink: color-mix(in srgb, #241C18 80%, #6B3A2A);
  --kp-hole: #F6E2C0;
  /* Held below the other hours' paper: the landscape behind is already
     going down, and a clearing at noon's brightness read as a spotlight. */
  --kp-ground: rgba(234, 212, 186, .9);
  --kp-lamp: #E0673F;
  --kp-glow: drop-shadow(0 0 5px rgba(243, 206, 149, .8));
}
body[data-phase="night"] .screen.klass {
  --kp-ink: color-mix(in srgb, #F4F1E9 54%, transparent);
  --kp-hole: #20242F;
  --kp-ground: rgba(25, 29, 41, .9);
  --kp-mat: rgba(214, 200, 168, .14);
  --kp-lamp: #F59A5E;
  --kp-glow: drop-shadow(0 0 6px rgba(247, 206, 134, .9)) drop-shadow(0 0 16px rgba(247, 206, 134, .45));
  --fg: #EDE5D3;
  --fg-2: #CFC4AE;
  --muted: #BDB4A0;
  --hair: rgba(240, 230, 210, .16);
  --raised: rgba(240, 228, 202, .08);
  --card: rgba(30, 25, 20, .5);
  --seal-text: #E6A58A;
}

/* ── 亭 THE PAVILION ──────────────────────────────────────────────────────
   The roof is drawn (mark-places.js `PAVILION_ROOF`) and keeps its
   proportion; the two posts, the board hanging from the beam, the teachers
   and the terrace step are HTML, so the posts stretch to whatever the board
   and the teachers need. Posts stand inside the beam's ends, the lanterns
   hang outside them. */
.kp-pav {
  position: relative; z-index: 2;
  align-self: center;
  width: min(780px, 100%);
  display: flex; flex-direction: column; align-items: center;
  padding-bottom: 22px;
  /* 台 The terrace: a pale step with one dark edge, the `ledge` every
     building on the map stands on. */
  background:
    linear-gradient(var(--kp-ink), var(--kp-ink)) 50% calc(100% - 12px) / 94% 2.5px no-repeat,
    linear-gradient(color-mix(in srgb, var(--kp-ink) 26%, transparent),
                    color-mix(in srgb, var(--kp-ink) 26%, transparent)) 50% 100% / 94% 14px no-repeat;
}
.kp-roof { position: relative; z-index: 1; display: block; width: 100%; color: var(--kp-ink); }
.kp-roof svg { display: block; width: 100%; height: auto; overflow: visible; --court-hole: var(--kp-hole); }
.kp-roof .kp-lamps { --pearl: var(--kp-lamp); filter: var(--kp-glow); }
.kp-pav::before,
.kp-pav::after {
  /* From under the roof's face (it is 0.14 of the width tall and its hips
     come down to about two thirds of that at the posts) to the terrace. */
  content: ''; position: absolute; z-index: 0; top: min(64px, 8vw); bottom: 14px;
  width: clamp(5px, 1.1%, 9px);
  background: var(--kp-ink);
}
.kp-pav::before { left: 10.5%; }
.kp-pav::after { right: 10.5%; }

/* ── 匾 THE SIGNBOARD ─────────────────────────────────────────────────────
   Black lacquer with a red rule inside its edge, the plaque every building
   on the map carries over its door at the size of a board a class reads.
   The class's name on its own line, and under it on one line the school,
   the level and the name you sit here under, and the class league's door:
   every line the board does not take is height the tables get. It hangs on
   two cords from the beam. */
body.at-klass .screen-head.kp-board {
  position: relative; z-index: 2;
  margin: 12px 0 0;
  width: min(540px, 74%);
  padding: 14px 30px 12px;
  display: flex; flex-wrap: wrap; justify-content: center; align-items: center;
  column-gap: 16px; row-gap: 5px;
  text-align: center;
  --fg: #F1E6C8;
  --fg-2: #D9CDB2;
  --muted: #CBC0A6;
  --seal: #E9B3A2;
  --hair: rgba(241, 230, 200, .18);
  --raised: rgba(241, 230, 200, .1);
  color: var(--fg);
  border-radius: 2px;
  background: linear-gradient(168deg, #2C2722 0%, #201C18 100%);
  box-shadow:
    inset 0 0 0 5px #2C2722,
    inset 0 0 0 6.5px rgba(194, 59, 37, .8),
    0 0 0 2px #14120F,
    0 12px 24px -12px rgba(0, 0, 0, .5);
}
.kp-board::before,
.kp-board::after {
  content: ''; position: absolute; bottom: 100%; height: 13px; width: 2px;
  background: var(--kp-ink);
}
.kp-board::before { left: 16%; }
.kp-board::after { right: 16%; }
.kp-board h1 { flex: 1 0 100%; margin: 0; color: #F4E7C4; }
.kp-board h1::after { margin: 7px auto 3px; opacity: .85; }
.kp-board > p { margin: 0; }
.kp-board .klass-at { justify-content: center; }
.kp-board .chip { color: var(--fg); background: var(--raised); box-shadow: 0 0 0 1px var(--hair); }

/* ── 师 THE TEACHERS, AT THE FRONT OF THE PAVILION ────────────────────────
   The same low table the students sit at, wider, with the names on a paper
   card on its front: the teacher's place faces the class's. */
.kp-lectern {
  position: relative; z-index: 2;
  margin-top: 14px;
  padding: 7px 16px 6px;
  border-radius: 2px;
  background:
    linear-gradient(to bottom, transparent 4px, color-mix(in srgb, var(--kp-hole) 45%, transparent) 4px 5px, transparent 5px),
    var(--kp-ink);
}
.kp-lectern::before,
.kp-lectern::after {
  content: ''; position: absolute; top: 100%; width: 5px; height: 8px;
  background: var(--kp-ink);
}
.kp-lectern::before { left: 8%; }
.kp-lectern::after { right: 8%; }
.kp-lectern .klass-teachers {
  --fg: #2C2720; --muted: #5A5246;
  margin: 0; padding: 3px 12px;
  color: var(--fg);
  border-radius: 2px;
  background: #F4EEE0;
  box-shadow: 0 1px 1px rgba(0, 0, 0, .25);
}
.kp-lectern .fine { color: var(--muted); }
.kp-lectern .av-none { background: #E9E1CF; color: #2C2720; }

/* ── 地 THE GROUND THE CLASS SITS ON ─────────────────────────────────────
   One pale clearing with a soft edge, the way the drawing draws a bank, and
   it HUGS THE CLASS (the lesson of 2026-09-11): it is drawn on `.yard-rows`,
   which is exactly as wide as the rows because it shrinks to them, and bled
   outward by half a table and down under the legend. Three students get a
   small clearing and twenty a wide one, with no arithmetic anywhere.
   Opaque enough to read on (#2144: at 74% the hills came through the
   names); the blur is what keeps it ground and not a panel. It follows the
   hour through `--kp-ground`. */
.yard {
  position: relative; z-index: 1;
  isolation: isolate;
  display: grid; justify-items: center; align-content: center;
  row-gap: 14px;
  padding: var(--s3) 0 var(--s3);
}
.yard-rows { position: relative; }
.yard-rows::before {
  content: '';
  position: absolute; z-index: -1;
  left: calc(-.5 * var(--seat, 112px) - 40px); right: calc(-.5 * var(--seat, 112px) - 40px);
  top: -34px; bottom: -64px;
  border-radius: 50% 50% 34% 34% / 42% 42% 26% 26%;
  background: var(--kp-ground);
  filter: blur(14px);
  pointer-events: none;
}
.yard-legend { margin: 0; text-align: center; color: var(--muted); }

/* ── ONE LENGTH, AND EVERYTHING A FRACTION OF IT ─────────────────────────
   *"ohne scrollen … bitte alles auf einen screen und auch alle schüler in
   diesen."* `--seat` is a table's width. A seat is about 0.86 of that tall
   (the head, the shoulders, the table and its legs), plus 40px that does
   not scale (the two figures under it); so the height a row may have, less
   the 40, divided by 0.86, is the widest table the height allows, and the
   width shared by the columns (each with its gap, 0.24 of a table) is the
   other bound. Clamped at 84px, where a name still reads, and at 200px,
   where three tables stop growing into furniture for giants. The desk media
   query below measures the yard itself; this one is the fallback that
   guesses the chrome. */
.yard-rows {
  list-style: none; margin: 0; padding: 0; display: grid;
  --seat: clamp(84px, min(
      calc(((100dvh - 440px) / var(--rows, 3) - 40px) / 0.86),
      calc((var(--sheet, 980px) - 360px) / (var(--cols, 4) * 1.24))),
    200px);
  row-gap: clamp(10px, calc(var(--seat) * .14), 22px);
}
.yard-row {
  list-style: none; margin: 0; padding: 0;
  display: flex; justify-content: center;
  gap: clamp(14px, calc(var(--seat) * .24), 36px);
}

/* ── A STUDENT ON A MAT, AT A LOW TABLE ──────────────────────────────────
   Head, shoulders, table, figures, top to bottom; the mat under all of it.
   The head and the shoulders are the drawing's seated figure (the pair on
   the map's ledge, the children in the schoolhouse's yard) with a face where
   the ink head was; the table is in front of them in z, which is the whole
   difference between a face on a table and a person sitting at one. */
.seat {
  position: relative; z-index: 0;
  display: grid; justify-items: center;
  width: var(--seat, 112px);
  text-align: center;
}
/* The shoulders: the seated figure's own curve, in the map's ink. */
.seat::before {
  content: ''; position: absolute; z-index: 0;
  top: calc(var(--seat, 112px) * .36); left: 50%; translate: -50% 0;
  width: calc(var(--seat, 112px) * .62); height: calc(var(--seat, 112px) * .26);
  border-radius: 50% 50% 0 0 / 100% 100% 0 0;
  background: var(--kp-ink);
}
/* 席 The mat: a pale oval of woven straw under the table. */
.seat::after {
  content: ''; position: absolute; z-index: -1;
  top: calc(var(--seat, 112px) * .6); left: 50%; translate: -50% 0;
  width: calc(var(--seat, 112px) * 1.12); height: calc(var(--seat, 112px) * .3);
  border-radius: 50%;
  background: var(--kp-mat);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--kp-ink) 16%, transparent);
}
.seat-head {
  position: relative; z-index: 1;
  display: grid; place-items: center;
  width: calc(var(--seat, 112px) * .46); height: calc(var(--seat, 112px) * .46);
  margin-bottom: calc(var(--seat, 112px) * .06);
  border-radius: 50%;
  overflow: hidden;
  background: #E9E1CF;
  box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--kp-ink) 50%, transparent), 0 2px 4px rgba(40, 30, 18, .18);
  font-family: var(--serif); font-size: calc(var(--seat, 112px) * .2); color: #2C2720;
}
.seat-head img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* 案 The low table: the map's ink, a paper cut under its top board, two
   short feet. It does not change colour with the hour beyond what the ink
   does: the same furniture at noon and at night. */
.seat-desk {
  position: relative; z-index: 2;
  display: grid; place-items: center;
  width: 100%; height: calc(var(--seat, 112px) * .26);
  border-radius: 2px;
  background:
    linear-gradient(to bottom, transparent 4px, color-mix(in srgb, var(--kp-hole) 45%, transparent) 4px 5px, transparent 5px),
    var(--kp-ink);
}
.seat-desk::before,
.seat-desk::after {
  content: ''; position: absolute; top: 100%;
  width: 5px; height: calc(var(--seat, 112px) * .06);
  background: var(--kp-ink);
}
.seat-desk::before { left: 9%; }
.seat-desk::after { right: 9%; }
/* The name card on the table's front: paper, so it reads at every hour. */
.seat-who {
  max-width: 88%;
  margin-top: 3px;
  padding: 2px 7px;
  border-radius: 2px;
  background: #F4EEE0;
  color: #2C2720;
  box-shadow: 0 1px 1px rgba(0, 0, 0, .25);
  font-size: clamp(11px, calc(var(--seat, 112px) * .105), 14px);
  font-weight: 600; line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ── THE FIGURES ARE TYPE, ON ONE LINE ───────────────────────────────────
   Characters and this week's XP, under the table, decoded once by the
   legend. `nowrap` is what the one-screen layout rests on: a seat whose
   figures can wrap has no predictable height. */
.seat-figs {
  position: relative; z-index: 1;
  display: flex; justify-content: center; gap: 6px;
  margin-top: calc(var(--seat, 112px) * .06 + 7px);
  white-space: nowrap;
  font-size: clamp(10px, calc(var(--seat, 112px) * .092), 12.5px);
  color: color-mix(in srgb, var(--fg) 80%, transparent);
}
.seat-xp::before { content: '\00B7'; margin-right: 6px; }

/* ── THE GREEN RING ───────────────────────────────────────────────────────
   *"wenn sie online sind, dann hat ihr Kreis noch eine grüne Umrandung."*
   `--growth-4`, the colour this product already uses for a thing that has
   taken root; a ring around the head and not a dot beside it. */
.seat-lit .seat-head {
  box-shadow: 0 0 0 2.5px var(--growth-4), 0 0 0 5px color-mix(in srgb, var(--growth-4) 22%, transparent);
}
/* The reader's own seat: the name card in the seal's ink. In a class of
   thirty the one question at a glance is which of them is you. */
.seat-me .seat-head { background: color-mix(in srgb, var(--seal) 18%, #E9E1CF); }
.seat-me .seat-who { background: #F6DCD2; color: #8E2E1A; }

@media (max-width: 640px) {
  /* No height to divide on a phone: the class scrolls like every screen. */
  .yard-rows { --seat: 96px; }
}
/* Below the desk layout the pines have no edge to stand at. */
@media (max-width: 999px) {
  .kp-pine { display: none; }
}

/* ── THE TABLES ARE SIZED BY WHAT IS LEFT, NOT BY A GUESS (#2420) ──────────
   On a desk the screen is given the window's height, the pavilion takes
   what it needs, and the yard takes the rest (`flex: 1 1 0`). The table is
   computed from the YARD's own size in container units: its height shared
   by the rows, its width by the columns. A class too big for the window at
   the smallest table still scrolls rather than shrinking into rumours. */
@media (min-width: 744px) {
  body.at-klass:not(.in-court):not(.in-intro) #view {
    height: calc(100dvh - 58px - env(safe-area-inset-top) - clamp(32px, 4.8vw, 68px));
  }
  body.at-klass .screen.klass { min-height: 100%; }
  body.at-klass .yard {
    flex: 1 1 0;
    container-type: size;
    min-height: calc(var(--rows, 3) * (84px * .86 + 40px) + 70px);
    /* Size containment takes the width from outside too. */
    align-self: stretch;
    align-content: center;
  }
  body.at-klass .yard-rows {
    --seat: clamp(84px, min(
        calc(((100cqh - 70px) / var(--rows, 3) - 40px) / 0.86),
        calc(100cqw / (var(--cols, 4) * 1.24))),
      200px);
  }
}

/* The school's own building beside its name — see the note in views.js for why
   it is a building and not a logo. Baseline-aligned with the line of type it
   sits in.
   ── AND IT TAKES THE CARD'S INK, NOT THE DRAWING'S ─────────────────────
   It was `#1C1E18` under `var(--art-filter)`, which is the rule for everything
   standing IN the picture and the wrong rule for this: the mark sits on the
   title card, and in ink mode the drawing's near-black on a #1F1D17 card is
   the other half of *"schwarz auf schwarz."* A quote of the drawing that has
   been brought indoors is type, and type follows the foreground. */
.klass-at { display: inline-flex; align-items: center; gap: 7px; }
.klass-at-mark {
  display: block; width: 19px; height: 19px;
  color: color-mix(in srgb, var(--fg) 76%, transparent);
}
.klass-at-mark svg { width: 100%; height: 100%; display: block; }

/* ══ 帐 THE ACCOUNT ON PROFILE (2026-09-22) ═════════════════════════════════
   Everything the phone's Profile does to an account, drawn in the vocabulary
   this sheet already has: `.screen-set-wrap` sections under a hairline, `.btn`
   for a press, `.linkish` for the quiet ones. The two that cannot be undone —
   reset and delete — are seal-coloured words rather than buttons, the phone's
   destructive rows, and each asks first (`#ask`). */
.check { display: flex; align-items: flex-start; gap: 10px; font-size: var(--fs-small); cursor: pointer; }
.check[hidden] { display: none; }
.check input { margin-top: 3px; accent-color: var(--seal); width: auto; flex: none; }
.check small { display: block; margin-top: 2px; color: var(--muted); }
.check .av { width: 34px; height: 34px; flex: none; }
/* The create form's second typing (#2176): whether it agrees, as it is typed,
   in the platform's two colours for a rule met and a rule not met. */
.pw-match { margin: 6px 0 0; }
.pw-match.is-met { color: var(--jade-ink); }
.pw-match.is-off { color: var(--seal-text); }
#fresh .check { margin: var(--s2) 0 0; }
/* Check your email (#2333): the two presses a breath apart, as on #fresh. */
#confirm .btn + .btn { margin-top: 6px; }
.signin-legal { margin: 8px 0 0; color: var(--muted); }
.signin-legal a { color: inherit; text-decoration: underline; }
.signin-get .linkish { font-weight: 500; color: var(--seal-text); }

.face-pick { position: relative; cursor: pointer; flex: none; }
.face-cam {
  position: absolute; right: -4px; bottom: -4px;
  width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center;
  background: var(--raised); box-shadow: 0 0 0 1px var(--hair); font-size: 12px; color: var(--fg-2);
}
.face-pick:hover .face-cam, .face-pick:focus-within .face-cam { color: var(--seal); }
/* The name is the heading, and it is also the field: `min-width: 0` on the
   card beside the face, or a phone's 390px pushes the field off the sheet. */
.profile-head > div { min-width: 0; }
.name-form { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
.name-form input {
  font: inherit; font-size: 26px; font-weight: 600;
  background: transparent; border: 0; border-bottom: 1px dashed var(--hair); color: var(--fg);
  padding: 2px 0; min-width: 0; width: 100%; max-width: 12ch;
}
@media (max-width: 560px) {
  .profile-head { gap: var(--s3); padding: var(--s3); }
  .profile-head .av { width: 64px; height: 64px; }
  .profile-head > div { padding: var(--s3); }
  .name-form input { font-size: 21px; }
}
.name-form input:focus-visible { outline: none; border-bottom-color: var(--seal); }
.chips { display: flex; gap: 6px; margin: 6px 0 0; }

.lv-pick > summary { list-style: none; cursor: pointer; display: inline-flex; }
.lv-pick > summary::-webkit-details-marker { display: none; }
.lv-pick[open] > summary { margin-bottom: 8px; }
.lv-list { list-style: none; margin: 8px 0; padding: 0; display: grid;
           grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 6px; max-width: 640px; }
.lv-row {
  width: 100%; display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
  font: inherit; padding: 8px 12px; border-radius: 10px; cursor: pointer;
  background: var(--card); color: var(--fg); border: 1px solid var(--hair);
}
.lv-row span { color: var(--muted); font-size: var(--fs-fine); }
.lv-row:hover:not(:disabled) { border-color: var(--seal); }
.lv-row.on { border-color: var(--seal); background: color-mix(in srgb, var(--seal) 12%, var(--card)); }
.lv-row:disabled:not(.on) { opacity: .5; cursor: default; }

.cls-list { list-style: none; padding: 0; margin: 0; max-width: 480px; }
.cls-row { display: flex; align-items: center; gap: 10px; }
.cls-open { flex: 1; display: flex; align-items: center; gap: 12px; text-decoration: none; color: inherit;
            background: var(--card); border: 1px solid var(--hair); border-radius: 12px; padding: 10px 14px; }
.cls-open:hover { border-color: var(--muted); }
.cls-open span:last-child { display: flex; flex-direction: column; gap: 2px; }
.cls-open small { color: var(--muted); font-size: var(--fs-fine); }
.cls-mark { width: 38px; height: 38px; flex: none; display: grid; place-items: center; border-radius: 10px;
            font-size: 21px; color: var(--seal); background: color-mix(in srgb, var(--seal) 12%, var(--card)); }

/* ── 印 A MARK BESIDE EACH OF GUSHI'S DOORS (#2173) ───────────────────────
   The class seal's tile, in INK rather than cinnabar: four red squares in a
   column would spend the seal four times over, and docs/brand.md keeps it for
   sparing use. The one cinnabar touch is inside each drawing (`PLATFORM_MARKS`
   in views.js). The row keeps the list's own rhythm (label, then the arrow on
   the right edge); the mark only stands in front of the label. */
.profile .screen-set > .set-link.pf-door { justify-content: flex-start; gap: 12px; }
.set-link.pf-door > .pf-mark {
  width: 38px; height: 38px; flex: none; display: grid; place-items: center; border-radius: 10px;
  color: var(--fg); background: color-mix(in srgb, var(--fg) 6%, var(--card));
  box-shadow: inset 0 0 0 1px var(--hair);
}
.set-link.pf-door > .pf-door-t { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pf-mark svg {
  width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.6;
  stroke-linecap: round; stroke-linejoin: round;
}
.pf-mark .pf-seal { fill: var(--seal); stroke: none; }
.pf-mark .pf-seal-line { stroke: var(--seal); }
/* ── THE CHOP HERE WAS BROKEN BY A RULE FOR SOMETHING ELSE (#2231) ─────────
   *"hier ist das Logo bei wie Gushi funktioniert verkackt."* `.set-link
   span:first-child` (the label column of a settings row, 0-2-1) also matched
   the chop, the first span inside the mark, and the 故事 span inside the chop:
   the chop turned into a flex column that stacked the two characters at its
   top edge with a 2px gap between them. The rows' other marks are SVGs and
   never met the rule. So the chop gets its own grid back here, four classes
   deep so it wins without an !important, and it is drawn a size up from the
   first attempt: 7.5px glyphs were a smudge on a normal-density screen. */
.set-link.pf-door .pf-mark .chop {
  display: grid; place-items: center; gap: 0;
  width: 26px; height: 26px; border-radius: 7px; font-size: 8.5px;
}
.set-link.pf-door .pf-mark .chop > span { display: block; }

.acct-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
.acct-danger { display: flex; flex-wrap: wrap; gap: 18px; margin: 4px 0 0; }
.linkish.danger { color: var(--seal-text); }
.login-list { list-style: none; padding: 0; margin: 0; max-width: 480px; display: flex; flex-direction: column; gap: 6px; }
.login-row { display: flex; justify-content: space-between; gap: 12px; font-size: var(--fs-small); }
.login-row b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.login-row b.muted { color: var(--muted); font-weight: 400; }
.email-add { display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
.email-add input, .join-form input:not([type=checkbox]) {
  font: inherit; padding: 9px 12px; border-radius: 10px;
  background: var(--bg-sunken); border: 1px solid var(--hair); color: var(--fg);
}
.set-link-inline { color: var(--seal-text); text-decoration: none; font-size: var(--fs-small); }

/* ── 售 THE PURCHASE (2026-09-26), on Profile and in the web paywall ───────
   views.js `buyFormHTML`. Two plans as two cards side by side, the chosen one
   ringed; the consent under them at reading size, because it is the one
   sentence here a person has to actually read; then the two buttons. */
.buy { display: flex; flex-direction: column; gap: 12px; max-width: 480px; }
.buy-plans { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.buy-plan { position: relative; cursor: pointer; }
.buy-plan input { position: absolute; opacity: 0; pointer-events: none; }
.buy-plan > span {
  display: flex; flex-direction: column; gap: 2px; padding: 10px 12px;
  border-radius: 12px; border: 1.5px solid var(--hair); background: var(--bg-sunken);
}
.buy-plan small { color: var(--fg-2); font-size: var(--fs-small); }
.buy-plan input:checked + span { border-color: var(--seal); }
.buy-plan input:focus-visible + span { outline: 2px solid var(--seal); outline-offset: 2px; }
.buy-consent { display: flex; gap: 10px; align-items: flex-start; font-size: var(--fs-small); color: var(--fg-2); }
/* The site-wide `input` rule is a text field's (full width, padding); a box
   that took it filled the row and pushed its sentence off the sheet. */
.buy-consent input { width: auto; padding: 0; margin-top: 3px; flex: none; }
.buy-consent span { flex: 1; min-width: 0; }
.buy-consent a { color: var(--seal-text); }
.buy-row { margin: 0; display: flex; gap: 8px; align-items: center; }
.buy-code input {
  font: inherit; padding: 8px 12px; border-radius: 10px; min-width: 0; flex: 1; max-width: 200px;
  background: var(--bg-sunken); border: 1px solid var(--hair); color: var(--fg); text-transform: uppercase;
}
.buy-msg { margin: 0; font-size: var(--fs-small); color: var(--seal-text); }
/* While a code's free access runs, the day a subscription would start, said
   once above the button at reading size: it is the one fact the choice turns
   on (2026-09-28). */
.buy-starts { margin: 0; font-size: var(--fs-small); color: var(--fg); }
/* ── 码 THE CODE, ON ITS OWN (2026-09-28): views.js `codeFormHTML`. A small
   label, the field, one button; set apart from the plans by space rather
   than a rule, because it sells nothing and should not look like a step of
   the purchase. */
.code-form { display: flex; flex-direction: column; gap: 6px; max-width: 480px; margin-top: 18px; }
.code-l { font-size: var(--fs-small); color: var(--fg-2); }
.pw .code-form { margin-top: 0; }

/* The web paywall: the phone's hero, benefits and the same form, in a sheet.
   The wall's foot carries the ways out that are not a purchase. */
.pw .sheet-in { gap: 14px; }
.pw-badge { margin: 0; font-size: var(--fs-small); color: var(--fg-2); }
.pw-hero { margin: 0; font-family: 'Fraunces', Georgia, serif; font-size: 24px; line-height: 1.2; }
.pw-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.pw-list li { display: flex; gap: 10px; align-items: baseline; font-size: var(--fs-small); }
.pw-list li span { color: var(--seal-text); font-size: 16px; }
.pw-foot { margin: 0; display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }

/* ── 退 THE SUBSCRIPTION CARD (2026-09-27) ──────────────────────────────────
   views.js `subBoxHTML`: the rows every other part of Profile uses (#2109:
   the label on the left, the value beside it, the control on the right edge,
   a hairline between, 52px tall), a status pill under the heading, and a
   foot with the one action that changes the subscription. "Cancel
   subscription" is a quiet button, as on Duolingo and Komoot: findable and
   plain, never hidden and never shouting. Reactivate is the primary. */
.sb-card { display: flex; flex-direction: column; gap: 4px; }
.sb-badge {
  display: inline-flex; align-items: center; min-height: 24px; padding: 3px 11px;
  border-radius: var(--r-pill); font-size: var(--fs-fine); font-weight: 600;
  color: var(--fg-2); background: var(--bg-sunken);
}
.sb-badge.is-active, .sb-badge.is-trial {
  color: var(--jade-ink); background: color-mix(in srgb, var(--jade) 16%, var(--card));
}
.sb-badge.is-canceled { color: var(--fg); background: color-mix(in srgb, var(--fg) 9%, var(--card)); }
.sb-badge.is-past_due { color: var(--seal-text); background: color-mix(in srgb, var(--seal) 14%, var(--card)); }
.sb-badge.is-wait { width: 72px; }
.sb-rows { display: flex; flex-direction: column; }
.sb-row {
  display: flex; align-items: center; gap: var(--s4); min-height: 52px; padding: 8px 0;
  border-top: 1px solid var(--hair); font-size: var(--fs-small);
}
.sb-row:first-child { border-top: 0; }
.sb-l { flex: 0 0 11.5rem; color: var(--fg-2); }
.sb-v { flex: 1; min-width: 0; font-weight: 500; color: var(--fg); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.sb-c { flex: none; margin-left: auto; }
/* A value not yet told: a bar where the words will be, the width of a date. */
.sb-bar, .sb-badge.is-wait {
  display: inline-block; height: 12px; width: 9rem; max-width: 100%; border-radius: 6px;
  background: linear-gradient(90deg, var(--bg-sunken) 0%, color-mix(in srgb, var(--fg) 10%, var(--bg-sunken)) 50%, var(--bg-sunken) 100%);
  background-size: 200% 100%; animation: sb-wait 1.4s ease-in-out infinite;
}
.sb-badge.is-wait { height: 24px; width: 72px; border-radius: var(--r-pill); }
@keyframes sb-wait { from { background-position: 100% 0; } to { background-position: -100% 0; } }
@media (prefers-reduced-motion: reduce) { .sb-bar, .sb-badge.is-wait { animation: none; } }
.sb-foot { margin: 6px 0 0; padding-top: 12px; border-top: 1px solid var(--hair); display: flex; }
.sb-cancel { padding-left: 0; color: var(--fg-2); }
.sb-cancel:hover { background: none; color: var(--seal-text); text-decoration: underline; text-underline-offset: 3px; }
/* Narrow: the label goes above its value, and the control stays on the
   right of the value, so a row never needs more than two lines. */
@media (max-width: 560px) {
  .sb-row { flex-wrap: wrap; gap: 2px var(--s3); }
  .sb-l { flex: 1 0 100%; font-size: var(--fs-fine); }
  /* A row with no value (Invoices) keeps its label beside its button. */
  .sb-v:empty { display: none; }
  .sb-row:has(> .sb-v:empty) > .sb-l { flex: 1 1 auto; font-size: var(--fs-small); }
}
.cx-facts dt { flex: none; }

/* ── 退 THE CANCEL DIALOG (2026-09-27) ─────────────────────────────────────
   views.js `cancelStepHTML`, in the sheet every dialog here has. `.cxs` sets
   no `display` (a class that does beats the rule hiding a closed dialog,
   "#ask" above). The two buttons of step 1 are one width and one fill: the
   way out and the way on weigh the same. */
.cxs { width: min(94vw, 480px); }
.cx-in { gap: 16px; padding: 22px 24px 24px; }
.cx-in h2 { margin: 0; font-family: 'Fraunces', Georgia, serif; font-size: 22px; line-height: 1.2; }
.cx-in h2:focus { outline: none; }
.cx-opt { font-size: var(--fs-fine); color: var(--muted); flex: none; }
.cx-facts { margin: 0; display: flex; flex-direction: column; border-radius: var(--r-card); background: var(--bg-sunken); padding: 4px 14px; }
.cx-facts > div { display: flex; justify-content: space-between; gap: var(--s4); padding: 10px 0; border-top: 1px solid var(--hair); }
.cx-facts > div:first-child { border-top: 0; }
.cx-facts dt { color: var(--fg-2); font-size: var(--fs-small); }
.cx-facts dd { margin: 0; text-align: right; font-size: var(--fs-small); font-weight: 500; overflow-wrap: anywhere; min-width: 0; }
.cx-acts { margin: 4px 0 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.cx-acts > :only-child { grid-column: 1 / -1; }
.cx-acts .btn { width: 100%; white-space: normal; text-align: center; }
/* A sheet's ground IS `--raised`, the fill `.btn` has everywhere else, so a
   plain button in here would be words with no body. */
.cx-acts .btn:not(.btn-primary) { background: color-mix(in srgb, var(--fg) 8%, var(--raised)); }
.cx-acts .btn:not(.btn-primary):hover { background: color-mix(in srgb, var(--fg) 14%, var(--raised)); }
.cx-reasons { display: flex; flex-wrap: wrap; gap: 8px; }
.cx-reason { position: relative; cursor: pointer; }
.cx-reason input { position: absolute; opacity: 0; pointer-events: none; }
.cx-reason > span {
  display: inline-flex; align-items: center; min-height: 36px; padding: 7px 14px;
  border-radius: var(--r-pill); font-size: var(--fs-small);
  background: var(--bg-sunken); color: var(--fg); box-shadow: inset 0 0 0 1.5px var(--hair);
  transition: box-shadow var(--fast) var(--ease), background var(--fast) var(--ease);
}
.cx-reason:hover > span { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--fg) 30%, transparent); }
.cx-reason input:checked + span {
  background: color-mix(in srgb, var(--seal) 12%, var(--bg-sunken)); box-shadow: inset 0 0 0 1.5px var(--seal);
}
.cx-reason input:focus-visible + span { outline: 2px solid var(--seal); outline-offset: 2px; }
.cxs textarea {
  width: 100%; resize: vertical; font: inherit; font-size: var(--fs-small); min-height: 64px;
  padding: 10px 12px; border-radius: 12px;
  background: var(--bg-sunken); border: 1px solid var(--hair); color: var(--fg);
}
.cx-msg { margin: 0; }
/* The own payment sheet (views.js `paySheetHTML`): Stripe's fields keep their
   place while they load, so the button does not jump under a finger. */
.pays .pay-element { min-height: 150px; }
.pays .pay-element[aria-busy="true"] { border-radius: 10px; background: var(--bg-sunken); }
.cx-done { text-align: center; align-items: stretch; }
.cx-done .cx-facts dt { text-align: left; }
.cx-mark {
  margin: 4px auto 0; width: 52px; height: 52px; border-radius: 50%; display: grid; place-items: center;
  font-size: 24px; font-weight: 700; color: var(--jade-ink);
  background: color-mix(in srgb, var(--jade) 18%, var(--raised));
}
.cx-line { margin: -6px 0 0; color: var(--fg-2); font-size: var(--fs-small); }
@media (max-width: 440px) {
  .cx-in { padding: 18px 16px 20px; }
  .cx-acts { grid-template-columns: 1fr; }
  .cx-acts .btn-primary { order: -1; }
}

/* ── 师 LEARNING WITH A TEACHER (#2102) ───────────────────────────────────
   `TeacherAsk` on the phone: a row with the seal on it, and behind the row a
   screen with the two rails drawn on it. This was a grey box inside the 320px
   settings column, so the first thing it needed was to stop being one — it is
   `#screen-teacher`, a section of Profile, and everything below is about
   filling that width rather than surviving it.

   The section carries `.screen-set-wrap`'s card already, so the disclosure
   itself has no paper of its own: two cards inside each other is the look the
   old one had. */
.teach { padding: 0; overflow: hidden; }
.teach-d > summary {
  display: flex; align-items: center; gap: 14px;
  padding: var(--s4) var(--s5);
  cursor: pointer; list-style: none;
}
.teach-d > summary::-webkit-details-marker { display: none; }
.teach-d > summary:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); }
/* The seal, which is what makes the row look like something rather than like a
   setting. A tile, as the phone draws the two rail tiles. */
.tch-seal {
  flex: none; display: grid; place-items: center;
  width: 42px; height: 42px; border-radius: 12px;
  background: color-mix(in srgb, var(--seal) 12%, var(--card));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 38%, transparent);
  color: var(--seal-text); font-family: var(--serif-hz); font-size: 20px; line-height: 1;
}
.tch-ask { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.tch-ask small { color: var(--muted); font-size: var(--fs-fine); }
/* The chevron turns with the disclosure, which is the one thing a summary owes
   a reader: whether the press opens or closes. */
.teach-d > summary .set-go { margin-left: auto; transition: rotate var(--fast) var(--ease); }
.teach-d[open] > summary .set-go { rotate: 90deg; }

/* ── TWO COLUMNS ON A DESK, ONE ON A PHONE ───────────────────────────────
   The phone's version is one column because a phone has one. Here the section
   is as wide as Profile, and a single 560px column inside 1080 leaves half the
   card empty — which is the "hingeklatscht" #2109 is about. So: what it SAYS
   on the left, capped at reading width, and what it IS and what to DO with it
   on the right. Below 900px they stack, in that order, which is the phone's
   order. */
.teach-body {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 320px);
  align-items: start; gap: var(--s5);
  padding: 0 var(--s5) var(--s5);
}
.teach-say, .teach-do { display: flex; flex-direction: column; gap: 14px; }
.teach-say { max-width: 60ch; }
@media (max-width: 899px) {
  .teach-body { grid-template-columns: 1fr; }
}
.teach-d h3 { font-family: var(--serif); font-size: 21px; margin: 0; }
.teach-lead { margin: 0; color: var(--fg-2); }
/* The three points, behind their glyphs, divided the way the phone's card is
   (`GushiCard` with two `Divider`s). */
.teach-points {
  margin: 0; padding: 0; list-style: none;
  border-radius: var(--r-panel);
  background: var(--bg-sunken);
  box-shadow: inset 0 0 0 1px var(--hair);
}
.teach-points li {
  display: flex; align-items: flex-start; gap: 14px;
  padding: 13px 16px; font-size: var(--fs-small);
}
.teach-points li + li { box-shadow: inset 0 1px 0 var(--hair); }
.tp-g {
  flex: none; width: 26px; text-align: center;
  font-family: var(--serif-hz); font-size: 19px; line-height: 1.3; color: var(--fg-2);
}
.tch-share { height: 50px; border-radius: var(--r-pill); font-size: var(--fs-small); font-weight: 600; }
.tch-share-i { font-size: 15px; }
.tch-share-note { text-align: center; }

/* ── 轨 The two rails ──────────────────────────────────────────────────────
   The SVG takes `currentColor` for the ties and the fade, so it is ink on
   paper and paper on ink with nothing declared twice; the two tiles are the
   only coloured things on it. `--seal-text` rather than `--seal` for the 师
   tile's glyph, because the raw seal on a dark ground is a red nobody can
   read. */
.rails { display: flex; flex-direction: column; gap: 6px; color: var(--fg); }
.rails svg { display: block; width: 100%; height: auto; }
.rail-tile rect { fill: var(--card); stroke-width: 1.2; }
.rail-tile text { font-family: var(--serif-hz); font-size: 22px; }
.rail-tile.home rect { stroke: color-mix(in srgb, var(--growth-3) 60%, transparent); }
.rail-tile.home text { fill: var(--growth-3); }
.rail-tile.teach rect { stroke: color-mix(in srgb, var(--seal) 60%, transparent); }
.rail-tile.teach text { fill: var(--seal-text); }
.rail-labels { display: flex; }
.rail-labels span {
  flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px;
  text-align: center;
}
.rail-labels b { font-size: var(--fs-fine); }
.rail-labels small { color: var(--muted); font-size: var(--fs-fine); }
/* Still the class-join screen's list of what a teacher cannot see (views.js). */
.dots { margin: 6px 0; padding-left: 18px; display: flex; flex-direction: column; gap: 5px; font-size: var(--fs-small); }
@media (prefers-reduced-motion: reduce) { .teach-d > summary .set-go { transition: none; } }

.join-form { max-width: 440px; }
.join-name { font-size: 20px; margin: 0; }
.join-sees { max-width: 560px; margin-top: 14px; }
/* A heading belongs to the list under it (#2349): it sat the panel's gap, its
   own margin and the list's margin away from its bullets, further than from
   the list before it. Now it touches its list and the air goes above it. */
.join-sees .sub { margin: 0; }
.join-sees .dots { margin: -4px 0 0; }
.join-sees .dots + .sub { margin-top: var(--s3); }
.klass-as { margin: 4px 0 0; }
.klass-teachers { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 6px 0 0; }
.klass-teachers .teacher { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-small); }
.klass-teachers .av { width: 26px; height: 26px; font-size: 13px; }
.ask p { margin: 0; color: var(--fg-2); white-space: pre-line; }

/* ══ THE STUDY LOOP, AS THE APP HAS IT ═════════════════════════════════════
   The pieces the iOS parity pass added to Today, the camp and the card. Kept
   together at the foot so one reading covers them; each says which Swift view
   it is drawn after. */

/* ── Today: the day's meter and the streak line ──────────────────────────
   `TodayView`'s Meter under the day sentence: what is behind you over what the
   day holds. Seal while there is something waiting and the affirm green once
   there is nothing, which is the app's `met ? affirm : seal`. */
.day-meter {
  display: block; height: 5px; margin: 8px 0 0; max-width: 320px;
  border-radius: 3px; background: var(--hair); overflow: hidden;
}
.day-meter i { display: block; height: 100%; background: var(--seal); border-radius: inherit; }
.day-meter.day-met i { background: var(--growth-4); }

/* ── Today: three numbers under the greeting (#2130) ─────────────────────
   The phone's tiles, as a row of figures rather than three boxes: a number
   in the display face over a small label, a hairline between them. A zero is
   muted and the flame goes grey with it, which is the phone's unlit tile; a
   figure with something in it is full ink. Ink and not the seal for the
   number itself, because the card is glass over every hour of the map and
   the seal's text tone is tuned for one ground. */
.day-stats { display: flex; flex-wrap: wrap; margin: var(--s3) 0 0; }
.day-stat {
  display: grid; gap: 3px; padding: 0 var(--s5);
  border-left: 1px solid var(--hair);
  /* Half-way from `--fg-2` to `--fg`: the card is glass, and at dusk the
     glass over a light hour put the plain `--fg-2` at 3.2:1 (#2144). */
  color: color-mix(in srgb, var(--fg-2) 50%, var(--fg)); text-decoration: none;
}
.day-stat:first-child { padding-left: 0; border-left: 0; }
.day-stat b {
  display: inline-flex; align-items: center; gap: 5px;
  font-family: var(--serif); font-size: 26px; font-weight: 600; line-height: 1;
  font-variant-numeric: tabular-nums; color: var(--muted);
}
.day-stat.lit b { color: var(--fg); }
.day-stat .flame-i { width: 18px; height: 18px; color: var(--muted); }
.day-stat.lit .flame-i { color: var(--seal); }
.day-stat > span { font-size: var(--t-cap); }
.day-stat:hover > span { color: var(--fg); }
/* The card and the stopwatch (#2165) take the flame's box and its rule: the
   seal when the figure has something in it, muted when it is a zero. A hair
   smaller, because a flame is tall and narrow and these fill their square. */
.day-stat .stat-i { width: 19px; height: 19px; }

/* ── WHAT THE CAMP HOLDS TODAY, OVER ITS DOOR (#2182) ────────────────────
   Three figures in a line above the button: to review, new, and about how
   long. Small type and the numbers in ink, the way the three figures on the
   left are numbers over words; a zero goes muted rather than away. */
/* The line is wider than the button under it, so the two are centred on each
   other: a button hanging off the left end of its own caption reads as two
   things that happen to be near each other. */
.scroll-do:has(.camp-plan) { justify-items: center; }
.camp-plan {
  display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 12px; margin: 0;
  font-size: var(--t-cap); line-height: 1.3;
  color: color-mix(in srgb, var(--fg-2) 50%, var(--fg));
}
.camp-plan .cp { white-space: nowrap; color: var(--muted); }
.camp-plan .cp.on { color: inherit; }
.camp-plan .cp b { font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
.camp-plan .cp.on b { color: var(--fg); }
.camp-plan .cp + .cp::before { content: '·'; margin-right: 12px; color: var(--muted); }

/* ── The camp: a deck's limits, beside its chip ──────────────────────────
   The slot is the chip's own size, so every width the camp's arithmetic
   assumes still holds; the ⋯ hangs off its lower right corner, small, in the
   page's own ink, and only a pointer that lands on it opens the limits. */
.dchip-slot { position: relative; flex: 0 0 auto; display: flex; }
.dchip-lim {
  position: absolute; right: calc(-6 * var(--cu)); bottom: calc(-6 * var(--cu));
  width: calc(18 * var(--cu)); height: calc(18 * var(--cu));
  display: grid; place-items: center;
  border-radius: 50%;
  font-size: calc(12 * var(--cu)); line-height: 1; text-decoration: none;
  color: var(--fg-2); background: var(--card);
  box-shadow: 0 0 0 1px var(--hair), 0 1px 3px rgba(26, 24, 19, .18);
  z-index: 2;
}
.dchip-lim:hover, .dchip-lim:focus-visible { color: var(--fg); background: var(--raised); }
/* `DeckRow`'s bar, inside the label that opens on hover. */
.dchip-bar {
  display: block; height: 4px; margin-top: 5px; border-radius: 2px;
  background: var(--hair); overflow: hidden;
}
.dchip-bar i { display: block; height: 100%; background: var(--growth-4); border-radius: inherit; }
@media (max-width: 743px) {
  .dchip-slot { display: flex; align-items: center; }
  .dchip-slot .dchip { flex: 1 1 auto; }
  .dchip-lim {
    position: static; width: 32px; height: 32px; margin-right: var(--s4);
    font-size: 16px; box-shadow: 0 0 0 1px var(--hair);
  }
  .dchip-slot + .dchip, .dchip + .dchip-slot, .dchip-slot + .dchip-slot {
    box-shadow: inset 0 1px 0 var(--hair);
  }
}

/* ── The card: NEW, the other senses, the tone chips ─────────────────────
   NEW is the writing deck's capsule (`write.new`), top left of the card so it
   never shares a line with the glyph. */
.r-new {
  position: absolute; top: 14px; left: 14px;
  padding: 2px 8px; border-radius: var(--r-pill);
  font-size: 11px; font-weight: 600; letter-spacing: .06em;
  color: var(--fg); background: color-mix(in srgb, var(--growth-4) 24%, var(--card));
}
/* #1729: the other senses trail the headline, smaller and grey. */
.r-others { display: inline; margin-left: .6em; font-size: var(--fs-fine); color: var(--muted); }

/* `ReviewView.tonePicker`: one column of five dots per syllable, the dot in its
   tone's colour. After the flip the answer is ringed in the affirm green and a
   wrong pick is struck through in seal. */
.tg { display: flex; justify-content: center; gap: 14px; flex-wrap: wrap; margin-top: 2px; }
.tg-col { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.tg-l { font-family: 'Noto Serif SC', serif; font-size: 13px; color: var(--muted); }
.tg-dots { display: flex; gap: 9px; }
.tg-dot {
  width: 20px; height: 20px; padding: 0; border-radius: 50%; cursor: pointer;
  border: 0; background: currentColor; opacity: .38;
  transition: opacity var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.tg-dot:hover:not(:disabled) { opacity: .7; }
.tg-dot.on { opacity: 1; transform: scale(1.12); }
.tg-dot:disabled { cursor: default; }
/* A dot is the tone itself, picked by its colour, so it keeps the pure fill:
   the ink-ward mix in "the hue is frozen" is for LETTERS, and a dot drawn in
   it on paper was a brown that no longer matched the phone's picker. */
.tg-dot.t1 { color: var(--t1); } .tg-dot.t2 { color: var(--t2); } .tg-dot.t3 { color: var(--t3); }
.tg-dot.t4 { color: var(--t4); } .tg-dot.t5 { color: var(--t5); }
.tg-dot.right { opacity: 1; box-shadow: 0 0 0 2px var(--card), 0 0 0 3.5px var(--growth-4); }
.tg-dot.wrong { opacity: 1; box-shadow: 0 0 0 2px var(--card), 0 0 0 3.5px var(--seal); }
.tg-verdict { margin: 0; font-size: 11px; color: var(--muted); }
.tg-verdict.ok { color: color-mix(in srgb, var(--growth-4) var(--jade-keep), var(--fg)); }

/* Strict tones, when the learner turned them on: Good and Easy wait, and a
   line says why. */
.r-grade.held, .r-grade:disabled { opacity: .45; cursor: not-allowed; }
.r-held { margin: 0; text-align: center; }
/* ── EACH GROUP UNDER ITS OWN CHARACTER (#2124) ───────────────────────────
   The row takes the glyph's font size, so one column is one em: exactly one
   character of the line above, with no gap, and the groups stand under the
   characters they ask about. A one-syllable card keeps its big dots, centred
   under the one glyph; from two syllables on the dots are sized in ems to fit
   the character, the way the phone shrinks them with the word, with a sixth
   of a character left between two groups so they read as two. The rings go
   thinner with them, or a pick beside the answer would ring into it. */
.tg.under.multi { font-size: 68px; gap: 0; flex-wrap: nowrap; }
.tg.under.multi .tg-col { width: 1em; }
.tg.under.multi .tg-dots { gap: .045em; }
.tg.under.multi .tg-dot { font-size: inherit; width: .14em; height: .14em; opacity: .5; }
.tg.under.multi .tg-dot:hover:not(:disabled) { opacity: .8; }
.tg.under.multi .tg-dot.on, .tg.under.multi .tg-dot.right, .tg.under.multi .tg-dot.wrong { opacity: 1; }
.tg.under.multi .tg-dot.right { box-shadow: 0 0 0 1px var(--card), 0 0 0 2px var(--growth-4); }
.tg.under.multi .tg-dot.wrong { box-shadow: 0 0 0 1px var(--card), 0 0 0 2px var(--seal); }
@media (max-width: 860px) { .tg.under.multi { font-size: 58px; } }

.r-grade:disabled { opacity: .45; cursor: not-allowed; }

/* The scope menu, in the card's header and on the setup screen. A pill like
   the app's, quiet at All and in seal once it narrows the round. */
.scope-menu {
  font: inherit; font-size: var(--fs-fine); font-weight: 600;
  height: 30px; padding: 0 10px; border-radius: var(--r-pill);
  color: var(--fg-2); background: var(--raised);
  border: 1px solid var(--hair); cursor: pointer; max-width: 16em;
}
.scope-menu.on { color: var(--seal-text); background: var(--seal-soft); border-color: var(--seal); }
.r-top-actions { align-items: center; }

/* The menu under the pill (#2215): the levels, the learner's own on top, each
   unfolding onto its lessons. A panel of its own rather than the browser's
   list, so it stays open while the ✎ photographs it. */
.scope-pop { position: relative; display: inline-block; }
.scope-menu { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.scope-caret { font-size: .8em; opacity: .7; }
.scope-list {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 30;
  min-width: 12em; max-height: min(70vh, 420px); overflow-y: auto;
  display: flex; flex-direction: column; gap: 1px; padding: 6px;
  background: var(--card); border: 1px solid var(--hair); border-radius: var(--r-card);
  box-shadow: var(--edge), var(--sink);
}
.scope-list[hidden], .scope-lessons[hidden] { display: none; }
/* In a round's header the pill sits at the right edge, so the panel opens
   leftwards; on the setup screen it sits under a heading and opens rightwards. */
[data-scope-menu="setup"] .scope-list { left: 0; right: auto; }
.scope-item {
  font: inherit; font-size: var(--fs-fine); text-align: left; color: var(--fg);
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 7px 10px; border: 0; border-radius: var(--r-chip); background: none; cursor: pointer;
}
.scope-item:hover, .scope-item:focus-visible { background: var(--raised); }
.scope-item.on { color: var(--seal-text); font-weight: 600; }
.scope-fold { transition: transform var(--fast) var(--ease); opacity: .6; }
.scope-level[aria-expanded="true"] .scope-fold { transform: rotate(90deg); }
.scope-lessons { display: flex; flex-direction: column; gap: 1px; padding-left: 12px;
                 margin-left: 10px; border-left: 1px solid var(--hair); }
.scope-star { margin-top: 4px; border-top: 1px solid var(--hair); border-radius: 0;
              justify-content: flex-start; gap: 8px; }

/* 词 rows are links now (#1802); the row still reads as a row. */
.w-link { display: inline-flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
          color: inherit; text-decoration: none; }
.w-link:hover .w-h { color: var(--seal-text); }
/* Each word's level, quietly (#2490): muted, small, at the row's end. The
   list is lowest level first, and this is what says so. */
.w-lv, .ref-lv { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.ref-lv { margin-left: auto; flex: none; }
/* Each reading's own labels (#2506), side by side under its meaning. */
.rp-chips { display: flex; flex-wrap: wrap; gap: 6px; }

/* ── The dragon, speaking ───────────────────────────────────────────────
   `GushiSpeech`: his head and a bubble. Used by a deck's first open and by the
   farewell for a round left early. */
.speech { display: flex; align-items: center; gap: 14px; max-width: 460px; margin: 0 auto; }
.speech .mascot { width: 84px; margin: 0; }
.speech-b {
  position: relative; margin: 0; padding: 12px 16px; text-align: left;
  border-radius: 16px; background: var(--card); box-shadow: var(--panel);
  font-size: var(--t-body); line-height: 1.45;
}
.speech-b::before {
  content: ''; position: absolute; left: -7px; top: 50%; translate: 0 -50%;
  border: 7px solid transparent; border-left: 0; border-right-color: var(--card);
}
.farewell-line { margin: 0; font-size: 22px; font-variant-numeric: tabular-nums; }

/* ── A deck's first open, and its limits ────────────────────────────────── */
.deck-intro {
  align-items: center; text-align: center; max-width: 520px; margin: 0 auto;
  width: 100%; gap: 18px; padding-top: 6vh;
}
.di-g {
  display: grid; place-items: center; width: 62px; height: 62px; border-radius: 15px;
  font-family: 'Noto Serif SC', serif; font-size: 30px; line-height: 1;
}
.deck-intro h1 { margin: 0; font-size: 24px; }
.deck-limits { max-width: 560px; gap: 16px; }
/* The sheet's one way out (#2395): a ✕ in its top-left corner, back to the
   camp. The head moves right by the ✕'s width so the two never touch. */
.deck-limits { position: relative; }
.dl-close {
  position: absolute; top: var(--s4); left: var(--s4);
  display: grid; place-items: center;
  width: 34px; height: 34px; border-radius: 50%;
  color: var(--fg-2); background: var(--raised);
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.dl-close:hover, .dl-close:focus-visible { color: var(--fg); background: color-mix(in srgb, var(--fg) 10%, var(--raised)); }
.deck-limits .dl-head { padding-left: 44px; }
.dl-head { display: flex; align-items: center; gap: 14px; }
.dl-head .di-g { width: 44px; height: 44px; font-size: 22px; border-radius: 11px; flex: 0 0 auto; }
.dl-t { display: flex; flex-direction: column; gap: 2px; }
.dl-t h1 { margin: 0; font-size: 22px; }
.dl-t .fine { margin: 0; }
.deck-limits .setup-limits .sl-body { padding-top: var(--s5); }
.deck-limits .result-actions { justify-content: flex-start; }
/* ── 字 A DECK'S SETTINGS ARE ONE CARD IN THE CAMP ─────────────────────────
   *"Das ist noch ein bisschen hässlich … außerdem kann man die Schrift unter
   Charaktere nicht lesen"* (#2068). The camp takes the sheet away, so the
   deck's name and its two small lines lay on the hillside in grey while the
   settings floated beside them as a box of their own, and the buttons under
   that. One pane now, centred in the camp, holding all three — the head, the
   limits and the way on — with the same frosted paper the camp's segments
   stand on, and the small lines in the darker ink a pane can carry. */
body.at-training .deck-limits {
  width: 100%; margin: var(--s5) auto 0;
  padding: var(--s6);
  border-radius: var(--r-panel);
  background: color-mix(in srgb, var(--card) 90%, transparent);
  -webkit-backdrop-filter: blur(16px) saturate(1.06);
  backdrop-filter: blur(16px) saturate(1.06);
  box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 7%, transparent),
              0 30px 60px -44px rgba(26, 24, 19, .5);
}
body.at-training .deck-limits .dl-head {
  padding: 0; background: none; box-shadow: none; backdrop-filter: none; max-width: none;
}
body.at-training .deck-limits .dl-t .fine { color: var(--fg-2); }
body.at-training .deck-limits .setup-limits {
  padding: var(--s5) 0 0; border-radius: 0;
  background: none; box-shadow: none; backdrop-filter: none;
  border-top: 1px solid var(--hair);
}
body.at-training .deck-limits .setup-limits .sl-body { padding: 0; }
.sl-today { margin: var(--s2) 0 0; font-size: var(--fs-fine); color: var(--fg-2); }
.sl-today-k {
  display: block; font-size: 11px; font-weight: 600; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted);
}

/* ── The rung sheet — `UnlockSheet.swift` ─────────────────────────────────
   Over the camp rather than in it: the camp's height is computed from the
   window's and nothing on that screen may scroll, so a panel in the flow would
   push the buildings off the sheet. A veil and a card, shown once per rung. */
.unlock-veil {
  position: fixed; inset: 0; z-index: 40;
  display: grid; place-items: center; padding: var(--s5);
  background: color-mix(in srgb, var(--bg) 55%, transparent);
  backdrop-filter: blur(6px);
}
.unlock {
  width: min(460px, 100%); max-height: calc(100vh - 2 * var(--s5)); overflow: auto;
  display: flex; flex-direction: column; gap: 12px; padding: 22px;
  border-radius: var(--r-panel); background: var(--card); box-shadow: var(--panel);
}
.unlock-top { display: flex; align-items: center; gap: 14px; }
.unlock-top .mascot { width: 56px; margin: 0; }
.unlock-g {
  display: grid; place-items: center; width: 62px; height: 62px; border-radius: 15px;
  font-family: 'Noto Serif SC', serif; font-size: 30px; line-height: 1;
  color: var(--seal-on); background: var(--seal); rotate: -4deg;
}
.unlock h2 { margin: 4px 0 0; font-size: 26px; }
.unlock-k { margin: 0; font-size: 11px; font-weight: 600; letter-spacing: .06em;
            text-transform: uppercase; color: var(--muted); }
.unlock-decks { margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.unlock-decks span { padding: 5px 10px; border-radius: var(--r-chip); background: var(--raised);
                     font-size: 13px; font-weight: 500; }
.unlock-body { margin: 0; color: var(--fg-2); line-height: 1.55; }
.unlock-take { margin: 0; display: flex; gap: 11px; padding: 14px; border-radius: var(--r-card);
               box-shadow: inset 0 0 0 1px var(--hair); }
.unlock-take span { font-family: 'Noto Serif SC', serif; color: var(--seal-text); }
.unlock .fine { margin: 0; text-align: center; }

/* The bubble on a card needs a ground of its own to read as a bubble. */
.result .speech-b, .deck-intro .speech-b { background: var(--raised); }
.result .speech-b::before, .deck-intro .speech-b::before { border-right-color: var(--raised); }

/* First Chinese on Today, with the app's ✕ beside it (a sibling, never inside
   the link — a control inside a control is what #basicsOffer was about). */
.scroll-course-row { display: flex; align-items: stretch; gap: 4px; }
.scroll-course-row .scroll-course { flex: 1 1 auto; }
.scroll-course-x {
  flex: 0 0 auto; width: 34px; border: 0; background: none; cursor: pointer;
  color: var(--muted); font-size: 16px; border-radius: var(--r-chip);
}
.scroll-course-x:hover { color: var(--fg); background: var(--raised); }

/* ── THE READING SHELF, MEASURED ──────────────────────────────────────────
   ReadingListView's row: the article's photo, a check and a jade frame once
   read (feedback #197), and one line of what it costs you — how much you know,
   how many words are new, where the quiz stands. */
.shelf-m a { flex-wrap: nowrap; align-items: center; justify-content: flex-start; }
.sh-pic {
  width: 56px; height: 56px; flex: none; object-fit: cover;
  border-radius: 10px; background: var(--raised);
}
.sh-b { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.sh-tick { color: var(--jade-ink); font-weight: 600; }
.shelf-m li.is-read a {
  background: color-mix(in srgb, var(--jade) 5%, transparent);
  box-shadow: inset 3px 0 0 color-mix(in srgb, var(--jade) 70%, transparent);
}
.sh-more { text-align: center; margin-top: var(--s3); }

/* ── ONE PASSAGE: THE PHOTO, THE METER, THE SWITCHES ─────────────────────── */
.rd-pic {
  width: 100%; max-width: 34em; height: 170px; object-fit: cover;
  border-radius: var(--r-panel); background: var(--raised);
}
.rd-meter { display: flex; flex-direction: column; gap: 6px; max-width: 34em; }
.rd-bar { display: block; height: 6px; border-radius: 99px; background: var(--bg-sunken); overflow: hidden; }
.rd-bar i { display: block; height: 100%; background: var(--jade); }
.rd-tools { display: flex; flex-wrap: wrap; gap: var(--s2); max-width: 34em; }
.rd-tools .btn.on { box-shadow: inset 0 0 0 1.5px var(--seal); background: var(--seal-soft); }
.rd-shadow { font-family: 'Noto Serif SC', serif; }
.rd-seal {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  max-width: 34em; padding: var(--s3) 0;
}
.rd-seal-g {
  width: 64px; height: 64px; display: grid; place-items: center;
  font-family: 'Noto Serif SC', serif; font-size: 32px; color: #fff;
  background: var(--seal); border-radius: 14px; transform: rotate(-4deg);
}
.rd-seal b { font-family: 'Fraunces', Georgia, serif; font-size: 20px; }
.rd-xp { font-weight: 600; color: var(--amber-ink); }
.rd-q { display: flex; flex-direction: column; gap: var(--s2); max-width: 34em; }
.rd-q .q { margin: 0; font-family: 'Noto Serif SC', serif; font-size: 17px; }
.quiz-o { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
.rd-v { margin: 0; }
.rd-v.good { color: var(--jade-ink); }

/* ── THE GRAMMAR REFERENCE: THE DOOR AND THE NAME'S READING ─────────────── */
.gd-door { display: flex; align-items: center; justify-content: space-between; gap: var(--s4); flex-wrap: wrap; }
.gd-door .fine { margin: 0; max-width: 52ch; }
.p-py { display: block; font-size: var(--fs-fine); color: var(--muted); font-family: 'Inter', system-ui, sans-serif; }

/* ── THE GRAMMAR DRILL ────────────────────────────────────────────────────
   The gym's card, options and chips, so a learner meets one way of answering
   on this host; what is its own is the task label, the ruby line, the rule
   and the teaching layer under a verdict. */
.gd-kind { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.gd-kind-zh {
  font-family: 'Noto Serif SC', serif; font-size: 13px; padding: 3px 9px;
  border-radius: 8px; background: color-mix(in srgb, var(--seal) 16%, var(--card));
}
/* Jade, not seal: a red pill on a question nobody has answered yet reads as a
   verdict already passed (feedback #642). */
.gd-tag {
  margin-left: auto; font-size: 11px; font-weight: 600; letter-spacing: .06em;
  padding: 2px 7px; border-radius: 8px;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--jade) 60%, transparent);
  background: color-mix(in srgb, var(--jade) 10%, var(--card));
}
.gd-line ruby { ruby-position: over; }
.gd-line rt { font-family: 'Inter', system-ui, sans-serif; font-size: 12px; color: var(--fg-2); }
.gd-line.with-py { line-height: 2.3; }
.gd-gap { display: inline-block; width: .25em; }
.gd-hint { align-self: stretch; border-top: 1px solid var(--hair); padding-top: var(--s3); }
.gd-hint p { margin: 0; }
.gd-hint-t { font-family: 'Noto Serif SC', serif; font-size: 15px; color: var(--t2, var(--fg)); margin-bottom: 4px !important; }
.gy-o kbd {
  font: inherit; font-size: 11px; color: var(--muted); margin-right: 6px;
  padding: 0 5px; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--hair);
}
.gd-cloze .gy-o { display: flex; align-items: center; gap: 8px; }
.gd-cloze .gd-o-t { font-family: 'Noto Serif SC', serif; font-size: 20px; }
.gd-o-py { margin-left: auto; font-size: var(--fs-fine); color: var(--muted); }
.gy-o.gd-dim { opacity: .5; }
.gd-placed {
  font: inherit; padding: 6px 10px; border-radius: 10px; cursor: pointer;
  background: var(--raised); color: var(--fg); border: 0;
  box-shadow: inset 0 0 0 1px var(--hair);
}
.gd-placed.right { box-shadow: inset 0 0 0 1.5px var(--jade); }
.gd-placed.wrong { box-shadow: inset 0 0 0 1.5px var(--seal); }
.gd-placed:disabled { cursor: default; }
/* #2343: a piece in the hand stays faint where it was; a seal caret says where
   it lands, before a piece or at the end of the sentence. */
.gd-placed[draggable="true"], .gy-chip[draggable="true"] { cursor: grab; }
.gd-carried { opacity: .3; }
.gd-placed.gd-drop-before { box-shadow: inset 3px 0 0 var(--seal), inset 0 0 0 1px var(--hair); }
.gd-slot.gd-drop-end::after {
  content: ''; align-self: stretch; width: 3px; min-height: 28px; border-radius: 2px; background: var(--seal);
}
.gd-reveal { display: flex; flex-direction: column; gap: 6px; }
.gd-reveal p { margin: 0; }
.gd-verdict { display: flex; align-items: baseline; gap: var(--s2); }
.gd-verdict.good b { color: var(--jade-ink); }
.gd-verdict.bad b { color: var(--seal-text); }
.gd-name { font-family: 'Noto Serif SC', serif; font-size: 18px; }
.gd-pat { font-weight: 600; color: var(--fg-2); }
.gd-sent { display: flex; align-items: center; gap: var(--s2); border-top: 1px solid var(--hair); padding-top: var(--s2); margin: 0; }
.gd-zh { font-family: 'Noto Serif SC', serif; font-size: 18px; }
.gd-py { font-size: var(--fs-fine); color: var(--fg-2); }
.gd-explain-t {
  font: inherit; font-weight: 600; font-size: var(--fs-small); color: var(--fg-2);
  background: none; border: 0; padding: var(--s2) 0; cursor: pointer;
}
.gd-explain-t::after { content: ' \25be'; color: var(--muted); }
.gd-explain-t[aria-expanded="true"]::after { content: ' \25b4'; }
.gd-explain-b { display: flex; flex-direction: column; gap: var(--s3); }
.gd-explain-b p { margin: 0; }
.gd-break { display: flex; flex-wrap: wrap; gap: 10px; }
.gd-b { display: inline-flex; flex-direction: column; align-items: center; max-width: 90px; }
.gd-b-h { font-family: 'Noto Serif SC', serif; font-size: 17px; }
.gd-b-g { font-size: 11px; color: var(--fg-2); text-align: center; }
.gd-alt { display: flex; flex-direction: column; gap: 3px; }
.gd-alt p { margin: 0; }
.gd-cap { margin: 0; font-size: 11px; font-weight: 600; letter-spacing: .1em; color: var(--muted); text-transform: uppercase; }

/* The primer sits over the drill rather than replacing it: it is a note to
   read once before the first question, and the question is right behind it. */
.gd-shape-wrap {
  position: fixed; inset: 0; z-index: 40; display: grid; place-items: center;
  background: color-mix(in srgb, var(--bg) 88%, transparent); padding: var(--s4);
}
.gd-shape {
  max-width: 460px; width: 100%; display: flex; flex-direction: column; gap: var(--s3);
  background: var(--card); box-shadow: var(--panel); border-radius: var(--r-panel); padding: var(--s6);
}
/* ── THE RULE BESIDE THE QUESTION (#2472) ─────────────────────────────────
   The playground's layout (#2151): one column on a narrow window; on a desk
   the question is centred while it is asked, then moves left of what it
   taught, so the verdict, the rule and how it works need no scroll. */
.gd-stage, .gd-main, .gd-side { display: flex; flex-direction: column; gap: var(--s4); }
@media (min-width: 960px) {
  .screen.gym.gd { max-width: 1060px; }
  .gd .gd-stage {
    display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
    align-items: start; gap: var(--s5);
  }
  .gd:not(.is-open) .gd-main { grid-column: 1 / -1; justify-self: center; width: min(100%, 620px); }
  .gd.is-open .gd-side { position: sticky; top: var(--s4); }
}

/* An answered option keeps full ink: the right one is the thing to read now. */
.gd .gy-o:disabled { color: var(--fg); }
.gd .gy-o.wrong:disabled { color: var(--seal-text); }
.gd-shape h2 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
.gd-shape p { margin: 0; }
.gd-slots { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s3); }
.gd-slots li { display: flex; align-items: flex-start; gap: 10px; }
.gd-slot-n {
  width: 26px; height: 26px; flex: none; display: grid; place-items: center;
  border-radius: 8px; background: var(--raised); font-size: 12px; font-weight: 600;
}
.gd-slot-b { flex: 1; display: flex; flex-wrap: wrap; gap: 0 6px; }
.gd-slot-note { flex-basis: 100%; }
.gd-slot-ex { font-family: 'Noto Serif SC', serif; font-size: 17px; color: var(--t2-ink, var(--fg)); }

/* ══ THE FIRST RUN ═════════════════════════════════════════════════════════
   The intro, the placement test, First Chinese and the unlock sheet, rebuilt
   against the phone on 2026-09-22 (firstrun.js says which Swift each piece
   comes from). Everything they share is here: Gushi's face, the cream speech
   bubble, the drawn tone line, and the three moves of `Liveliness.swift`.

   ── THE BUBBLE IS THE ONE LIGHT THING ON THE PAGE ────────────────────────
   `IntroView`: *"don't like the grey with the red border."* A speech bubble
   that shares the cards' surface reads as one more card; the page's own
   foreground as a fill, with the page as its ink, reads as a voice — on ink it
   is cream, on paper it is ink, and either way it is the loudest thing there.

   ── EVERY MOVE HAS AN OFF SWITCH ─────────────────────────────────────────
   `.arrive`, `.pop`, `.bob`, `.pulse`, `.greet` and the self-drawing line all
   stop under `prefers-reduced-motion`, and every one of them rests in its FINAL
   state rather than its first: an animation that is switched off must leave
   the thing on screen, not leave it invisible. */

/* The face: three heads, each with its own blink, stacked in one box. */
.gface { position: relative; display: inline-block; flex: none; aspect-ratio: 1; }
.gface-xs { width: 34px; }
.gface-sm { width: 46px; }
.gface-md { width: 72px; }
.gface-lg { width: clamp(112px, 16vw, 140px); }

/* The featured dragon in his pool of light. The ring is a gradient rather than
   a border, because a hard ring around a character reads as a badge. */
.gface-hero {
  position: relative; display: grid; place-items: center;
  width: clamp(150px, 22vw, 190px); aspect-ratio: 1; border-radius: 50%;
  background: radial-gradient(closest-side, color-mix(in srgb, var(--seal) 26%, transparent),
              color-mix(in srgb, var(--seal) 8%, transparent) 70%, transparent);
}
.gface-hero > .gface { grid-area: 1 / 1; }
.gface-hero.pulse::before {
  content: ''; position: absolute; inset: 6%; border-radius: 50%;
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--seal) 40%, transparent);
  animation: ring 2.2s ease-in-out infinite;
}
@keyframes ring {
  0%, 100% { transform: scale(.94); opacity: .9; }
  50% { transform: scale(1.04); opacity: .35; }
}
/* He greets neutral and brightens a beat later. The happy head is the SECOND
   face in the box; it fades in over the first, which is `IntroView`'s whole
   welcome performance. Its resting state is visible. */
.gface-hero.greet > .gface + .gface { animation: fade-in .3s ease-out .7s backwards; }
@keyframes fade-in { from { opacity: 0; } }

/* ── A PRESS: THE BLINK, THEN THE TURN (#2232, #2235) ──────────────────────
   learn.js adds `.is-blinking` for 140ms and turns the host on `rotate`; this
   is the blink frame switched hard on, the way the idle blink does it, on the
   face that is showing — in a hero that is the last of the two heads. The
   hand is the whole invitation: nothing else on him says he can be pressed. */
[data-gushi] { cursor: pointer; -webkit-tap-highlight-color: transparent; }
.gface-hero:has([data-gushi]) { cursor: pointer; }
:is(.gface.is-blinking, .mascot.is-blinking, .gface-hero.is-blinking > .gface:last-child) > .hero-blink {
  animation: none; opacity: 1;
}

/* The breathing bob — `MascotIdle`. Two seconds and a bit, a few pixels. */
.bob { animation: bob 2.4s ease-in-out infinite alternate; }
@keyframes bob {
  from { transform: translateY(4px) rotate(1.1deg); }
  to { transform: translateY(-4px) rotate(-1.1deg); }
}
/* Fade-and-rise into place, `--i` beats after the screen — `StagedArrival`. */
.arrive { animation: arrive .45s ease-out backwards; animation-delay: calc(.15s + var(--i, 0) * .35s); }
@keyframes arrive { from { opacity: 0; transform: translateY(12px); } }
/* A bubble pops from its tail, every time — `PopIn`. */
.pop { animation: pop .38s cubic-bezier(.3, 1.45, .5, 1) backwards; }
@keyframes pop { from { opacity: 0; transform: scale(.3); } }

@media (prefers-reduced-motion: reduce) {
  .bob, .arrive, .pop, .gface-hero.pulse::before,
  .gface-hero.greet > .gface + .gface { animation: none; }
}

/* ── The bubbles ─────────────────────────────────────────────────────────── */
/* `:not(dialog)`: the yes/no window in index.html is `<dialog class="fb ask">`,
   and a bare `.ask { display: flex }` beat the browser's own rule that hides a
   closed dialog. It sat as an empty full-width box under the sign-in card,
   its blank Yes button a red pill (2026-09-27). */
.ask:not(dialog) { display: flex; align-items: flex-end; gap: 10px; width: 100%; margin-top: var(--s3); }
.ask:not(dialog) .gface { margin-bottom: -4px; }
.ask-bubble {
  position: relative; flex: 1; margin: 0 0 34px;
  padding: 13px 16px; border-radius: 20px;
  background: var(--fg); color: var(--bg);
  font-family: var(--serif); font-size: clamp(19px, 2.2vw, 23px); line-height: 1.25;
  letter-spacing: -.01em; font-weight: 500;
  transform-origin: 0 100%;
}
.ask-bubble::after {
  content: ''; position: absolute; left: 12px; bottom: -12px;
  width: 17px; height: 13px; background: inherit;
  clip-path: polygon(100% 0, 6% 100%, 45% 0);
}
.hello-bubble {
  position: relative; margin: 14px 0 0; max-width: 34ch;
  padding: 14px 18px; border-radius: 20px;
  background: var(--fg); color: var(--bg); font-weight: 500; line-height: 1.45;
  transform-origin: 50% 0;
  /* Two lines, where the copy breaks them (#2433): "Hallo! Ich bin Gushi." and
     then "Lern mit mir Mandarin!". */
  white-space: pre-line;
}
.hello-bubble::before {
  content: ''; position: absolute; left: calc(50% - 8px); top: -12px;
  width: 17px; height: 13px; background: inherit;
  clip-path: polygon(0 100%, 94% 0, 55% 100%);
}
.says { display: flex; align-items: flex-start; gap: 10px; width: 100%; text-align: left; }
.says-bubble {
  position: relative; flex: 1; margin: 0;
  padding: 11px 14px; border-radius: 16px;
  background: var(--fg); color: var(--bg); line-height: 1.55; font-weight: 500;
  transform-origin: 0 20px;
}
.says-bubble::before {
  content: ''; position: absolute; left: -9px; top: 14px;
  width: 11px; height: 14px; background: inherit;
  clip-path: polygon(100% 0, 0 50%, 100% 100%);
}
.says-bubble b { font-weight: 700; }

/* ── The intro ───────────────────────────────────────────────────────────── */
.intro-welcome { display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--s2); width: 100%; }
.intro-welcome h1 { margin: var(--s2) 0 0; }
.intro-welcome .lead, .intro-welcome .fine { max-width: 46ch; }
.intro-rows { display: grid; gap: var(--s2); width: 100%; margin: var(--s2) 0; }
.intro-row {
  display: flex; align-items: center; gap: 13px; width: 100%;
  padding: 14px; border-radius: var(--r-card); cursor: pointer; text-align: left;
  background: var(--card); color: var(--fg); font: inherit;
  box-shadow: var(--panel);
  transition: background var(--fast) var(--ease), box-shadow var(--fast) var(--ease);
}
.intro-row:hover { background: color-mix(in srgb, var(--fg) 5%, var(--card)); }
.intro-row.on { background: var(--raised); box-shadow: var(--panel), inset 0 0 0 1.5px var(--seal); }
.intro-row-g { width: 36px; flex: none; text-align: center; font-size: 26px; line-height: 1; }
.intro-row-b { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.intro-row-b b { font-size: var(--fs-small); font-weight: 600; }
.intro-row-c {
  width: 19px; height: 19px; border-radius: 50%; flex: none;
  box-shadow: inset 0 0 0 1.5px var(--hair);
}
.intro-row.on .intro-row-c {
  background: var(--seal); box-shadow: none;
  background-image: radial-gradient(circle, var(--seal-on) 0 3px, transparent 3.5px);
}
.intro-glyph {
  display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 9px;
  font-family: var(--serif-hz); font-size: 18px; background: var(--raised); color: var(--fg-2);
}
.intro-row.on .intro-glyph { background: var(--seal); color: var(--seal-on); }
.intro-quiet {
  align-self: flex-start; background: none; border: 0; padding: 4px 0; cursor: pointer;
  font: inherit; font-size: var(--fs-small); color: var(--muted);
  text-decoration: underline; text-underline-offset: 3px;
}
.intro-quiet.on { color: var(--seal-text); font-weight: 600; }
.intro-offer {
  display: flex; flex-direction: column; gap: 6px; width: 100%;
  padding: 14px; border-radius: var(--r-card); background: var(--card); box-shadow: var(--panel);
}
.intro-offer .btn { align-self: flex-start; margin-top: 4px; }
.intro-placed { margin: 0; color: var(--jade-ink); font-weight: 600; font-size: var(--fs-small); }
.intro-acts { display: flex; flex-direction: column; align-items: flex-start; gap: var(--s2); margin-top: var(--s3); }
.intro-acts .btn { margin-top: 0; }
.intro-back {
  background: none; border: 0; padding: 6px 0; cursor: pointer;
  font: inherit; font-size: var(--fs-small); color: var(--muted);
}
.intro-back:hover { color: var(--fg); }
.intro-at-welcome .intro-acts, .intro-at-ready .intro-acts { align-items: center; align-self: center; }

/* ── A QUESTION BESIDE ITS ANSWERS, AND NOTHING BELOW THE FOLD (#2233) ────
   *"wäre cool wenn man das Intro machen könnte ohne scrollen zu müssen."*
   Measured before: the level step 1,046px tall, why 931, pace 846, against a
   laptop's 720 and a MacBook Air's 800. firstrun.js splits a question step
   into `.intro-q` (Gushi's question, its reason, the fine print) and
   `.intro-a` (the rows and the button). Narrower than a desk, both halves are
   `display: contents` and the step is its old single column. */
.intro-q, .intro-a { display: contents; }
@media (min-width: 900px) {
  .intro-step.split {
    display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.12fr);
    column-gap: var(--s6); align-items: center; max-width: 1000px;
  }
  .intro:has(> .split) .intro-rail { max-width: 1000px; }
  .intro-step.split > .intro-q, .intro-step.split > .intro-a {
    display: flex; flex-direction: column; align-items: flex-start; gap: var(--s3); min-width: 0;
  }
  .intro-step.split .intro-rows, .intro-step.split .intro-hsk,
  .intro-step.split .intro-gtable { margin: 0; }
  /* Continue and Back on one line under the rows: stacked, the pair cost
     forty pixels of the height this whole block exists to save. */
  .intro-step.split .intro-acts { flex-direction: row; align-items: center; gap: var(--s5); margin-top: var(--s2); }
}
/* A laptop's 720: the same step with less air, rather than a scroll. */
@media (min-width: 900px) and (max-height: 820px) {
  .intro { padding-block: var(--s4); gap: var(--s3); }
  .intro-step { padding-top: var(--s5); }
  .intro-step .ask { margin-top: 0; }
  .intro-row { padding-block: 10px; }
  .intro-band { padding-block: 10px; }
}

/* ── THE KEY ON THE BUTTON (#2234) ─────────────────────────────────────────
   *"dass man im Intro auch mit Shortcuts arbeiten kann … mit dem Weiter soll
   Space sein … und die Werte 1-4."* The key's name, small, on the thing it
   presses, and only where a keyboard is likely (a hovering pointer on a
   desk-wide window, the review grades' own test). */
.kh { display: none; }
@media (min-width: 861px) and (hover: hover) {
  .kh {
    display: inline-block; margin-left: 10px; padding: 0 5px; border-radius: 4px;
    font: 500 10.5px/1.6 'Inter', system-ui, sans-serif; letter-spacing: .02em;
    color: inherit; opacity: .62; box-shadow: inset 0 0 0 1px currentColor; vertical-align: 1px;
  }
  .intro-row .kh {
    margin: 0; color: var(--muted); opacity: 1; box-shadow: inset 0 0 0 1px var(--hair);
  }
  .gy-o .kh { margin: 0 8px 0 0; vertical-align: 3px; }
  /* The test's answers are tiles with the word centred: the number sits in
     the corner rather than on top of the word. */
  .pl-opts .gy-o { position: relative; }
  .pl-opts .gy-o .kh { position: absolute; left: 10px; top: 9px; margin: 0; }
  .pl-hear .kh { margin-left: 4px; }
}
.intro-who { align-self: center; margin: 0; text-align: center; overflow-wrap: anywhere; }
.intro-who .intro-quiet { display: inline; padding: 0; }

.intro-name { display: flex; align-items: center; gap: 14px; width: 100%; }
.intro-photo {
  position: relative; flex: none; width: 64px; height: 64px; cursor: pointer;
  border-radius: 14px; rotate: -4deg; background: var(--seal); color: var(--seal-on);
  display: grid; place-items: center;
}
.intro-photo img { width: 100%; height: 100%; object-fit: cover; border-radius: 14px; }
.intro-photo:focus-within { outline: 2px solid var(--seal); outline-offset: 3px; }
.intro-cam {
  position: absolute; right: -5px; bottom: -5px; width: 22px; height: 22px; border-radius: 50%;
  display: grid; place-items: center; background: var(--raised); color: var(--fg-2);
  box-shadow: inset 0 0 0 1px var(--hair);
}
.intro-name-b { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.intro-input {
  width: 100%; padding: 14px; border-radius: var(--r-chip); font: inherit; font-size: 17px;
  background: var(--card); color: var(--fg); border: 0; box-shadow: inset 0 0 0 1px var(--hair);
}
.intro-input:focus { outline: none; box-shadow: inset 0 0 0 1.5px var(--seal); }

.intro-summary {
  width: 100%; margin: var(--s3) 0 0; padding: 0; border-radius: var(--r-card);
  background: var(--card); box-shadow: var(--panel);
}
.intro-summary > div {
  display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3);
  padding: 13px 15px;
}
.intro-summary > div + div { border-top: 1px solid var(--hair); }
.intro-summary dt { color: var(--fg-2); font-size: var(--fs-small); }
.intro-summary dd { margin: 0; font-weight: 600; text-align: right; }

/* ── What HSK is (#2207) ────────────────────────────────────────────────────
   Three rows of three rungs, the band's name, and a bar that grows by how many
   characters a reader knows at the top of the band. The phone's `hskBody`. */
.intro-hsk { display: grid; gap: var(--s2); width: 100%; margin: var(--s2) 0 0; }
.intro-hsk h2 { margin: 0; font-size: var(--t-body); }
.intro-band {
  display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 6px 12px;
  padding: 14px; border-radius: var(--r-card); background: var(--card); box-shadow: var(--panel);
}
.intro-band-rungs { display: flex; gap: 4px; }
.intro-band-rungs span {
  display: grid; place-items: center; width: 24px; height: 24px; border-radius: 6px;
  font-size: var(--fs-small); font-weight: 600; font-variant-numeric: tabular-nums;
}
/* Only 1-3 wears a tile (#2384: *"Das Grün sieht ein bisschen weird aus
   hinter den Nummern 4, 5, 6, 7, 8, 9"*). The green was never meant: the rows
   were `.b1` and `.b2`, which are the library's book-spine colours further up
   this file and unscoped, so 4-6 came out amber and 7-9 green. `r0`-`r2` now,
   and 4-9 are bare numbers. */
.intro-band-rungs.r0 span { background: color-mix(in srgb, var(--seal) 14%, transparent); }

/* ── The official source, as the site stamps it (#2383) ─────────────────────
   `.stamp` on /hsk-3.0 (apps/web/pages/hsk.html): 考试 over 大纲 in an
   outlined seal — outlined, because it stands for someone else's document
   and must not read as our chop — and the document's name beside it. */
.intro-stamp {
  display: flex; gap: 14px; align-items: center; width: 100%; max-width: 340px;
  padding: 12px 16px 12px 12px; border-radius: var(--r-card);
  background: var(--card); box-shadow: var(--panel); color: var(--fg); text-decoration: none;
  transition: box-shadow var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.intro-stamp:hover { box-shadow: var(--panel), inset 0 0 0 1px var(--seal); transform: translateY(-1px); }
.intro-stamp-mark {
  flex: none; display: grid; place-content: center; gap: 1px; width: 56px; height: 56px;
  border: 2px solid var(--seal); border-radius: 8px; color: var(--seal-text);
  box-shadow: inset 0 0 0 1px var(--card), inset 0 0 0 3px var(--seal-soft);
}
.intro-stamp-mark b { display: block; font-family: var(--serif-hz); font-size: 14px; font-weight: 600; line-height: 1.1; text-align: center; }
.intro-stamp-txt { display: grid; gap: 2px; min-width: 0; }
.intro-stamp-k { font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.intro-stamp-txt > b { font-family: var(--serif-hz); font-size: 15px; font-weight: 600; }
.intro-stamp-go { font-size: 12.5px; font-weight: 600; color: var(--seal-text); }
.intro-band b { font-weight: 600; }
.intro-band .fine { margin: 0; font-variant-numeric: tabular-nums; }
.intro-band-bar {
  grid-column: 1 / -1; height: 5px; border-radius: 99px; background: var(--hair); overflow: hidden;
}
.intro-band-bar i { display: block; height: 100%; border-radius: inherit; background: var(--seal); }

/* ── What the four grades mean (#2206, #2378) ───────────────────────────────
   Each grade as the review draws its box (`.r-grade`: the tint at 22%, the
   border at 55%). The table is two things the owner asked to be told apart:
   the button and what pressing it says about you, then when the card is back
   — with a bar on a log scale, so ten minutes and fifteen days both show. */
.intro-gtable .g1, .gl-chips .g1 { --g: var(--seal); }
.intro-gtable .g2, .gl-chips .g2 { --g: var(--t2); }
.intro-gtable .g3, .gl-chips .g3 { --g: var(--jade); }
.intro-gtable .g4, .gl-chips .g4 { --g: var(--t4); }
.intro-gtable {
  width: 100%; border-collapse: separate; border-spacing: 0; overflow: hidden;
  border-radius: var(--r-card); background: var(--card); box-shadow: var(--panel);
}
.intro-gtable th {
  padding: 10px 14px 8px; text-align: left; font-size: 11px; font-weight: 600;
  letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
}
.intro-gtable th:last-child, .intro-gtable td:last-child { text-align: right; }
.intro-gtable td { padding: 10px 14px; border-top: 1px solid var(--hair); color: var(--fg-2); vertical-align: middle; }
.intro-gtable td:first-child { width: 1%; padding-right: 0; }
.intro-gtable td:last-child { white-space: nowrap; }
.intro-gtable td:last-child b { display: block; color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
.intro-gtable td:last-child i {
  display: block; width: 64px; height: 4px; margin: 6px 0 0 auto; border-radius: 99px;
  background: linear-gradient(to right, var(--g) var(--w), var(--hair) var(--w));
}
.intro-gtable .ig {
  display: grid; place-items: center; width: 88px; height: 34px; border-radius: 10px;
  font-weight: 600; font-size: var(--fs-small); color: var(--fg);
  background: color-mix(in srgb, var(--g) 22%, var(--card));
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--g) 55%, transparent);
}

/* ── The card that turns, and the buttons it is graded with (#2378) ─────────
   Five seconds, for ever: the card turns over (0.5-1.2s), the four buttons
   rise under it (1.2-1.8s), the view closes in on them (2.0-2.6s) and lights
   each in turn, and everything settles back. One clock for all of it — every
   part runs the same 5s loop, and the chips are offset by `--i` inside it —
   so nothing drifts however long the step is open. `IntroView.GradeLoop` is
   the same film on the phone. Under `prefers-reduced-motion` it is the loop's
   resting frame: the card turned, the buttons under it, nothing moving. */
/* Room above the card for the close-up: the stage grows from its foot, so
   the card rises into the space rather than out of the frame. */
.gl { width: 100%; height: 250px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 20px; overflow: hidden; perspective: 700px; }
.gl-stage {
  display: flex; flex-direction: column; align-items: center; gap: 16px;
  transform-origin: 50% 100%; animation: gl-zoom 5s ease-in-out infinite;
}
.gl-card {
  position: relative; width: 124px; height: 112px; transform-style: preserve-3d;
  animation: gl-turn 5s ease-in-out infinite;
}
.gl-face {
  position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 2px;
  border-radius: 14px; background: var(--card); box-shadow: var(--panel);
  backface-visibility: hidden; -webkit-backface-visibility: hidden;
}
.gl-front { font-family: var(--serif-hz); font-size: 40px; color: var(--fg); }
.gl-back { transform: rotateY(180deg); }
.gl-back small { font-size: 12px; color: var(--muted); }
.gl-back b { font-size: 17px; font-weight: 600; color: var(--fg); }
.gl-back span { font-family: var(--serif-hz); font-size: 18px; color: var(--fg-2); }
/* WHICH FACE SHOWS IS NOT LEFT TO backface-visibility (#2434). A browser that
   flattened the card drew its back face over the front, mirrored: the owner
   saw "hǎo" as "oǎrl". So each face is also switched on and off at the exact
   middle of each turn (17% and 92% of the loop, where the eased half-turns
   cross 90°), and a face that is edge-on or turned away is never drawn. */
.gl-front { animation: gl-front 5s step-end infinite; }
.gl-back { animation: gl-back 5s step-end infinite; }
@keyframes gl-front { 0% { opacity: 1; } 17% { opacity: 0; } 92%, 100% { opacity: 1; } }
@keyframes gl-back { 0% { opacity: 0; } 17% { opacity: 1; } 92%, 100% { opacity: 0; } }
.gl-chips { display: flex; gap: 6px; animation: gl-rise 5s ease-in-out infinite; }
.gl-chips span {
  display: grid; place-items: center; min-width: 64px; height: 30px; padding: 0 8px; border-radius: 9px;
  font-size: 12px; font-weight: 600; color: var(--fg);
  background: color-mix(in srgb, var(--g) 22%, var(--card));
  box-shadow: inset 0 0 0 1.2px color-mix(in srgb, var(--g) 60%, transparent);
  animation: gl-lit 5s linear infinite; animation-delay: calc(var(--i) * .35s);
}
@keyframes gl-turn {
  0%, 10% { transform: rotateY(0); }
  24%, 86% { transform: rotateY(180deg); }
  98%, 100% { transform: rotateY(360deg); }
}
@keyframes gl-rise {
  0%, 24% { opacity: 0; transform: translateY(10px); }
  36%, 86% { opacity: 1; transform: none; }
  98%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes gl-zoom {
  0%, 40% { transform: none; }
  52%, 84% { transform: scale(1.35); }
  96%, 100% { transform: none; }
}
/* Each chip is lit for a beat once the view has closed in (52%), one after
   the other: the delay moves each chip's window along by 0.35s. */
@keyframes gl-lit {
  0%, 52% { background: color-mix(in srgb, var(--g) 22%, var(--card)); transform: none; }
  55%, 58% { background: color-mix(in srgb, var(--g) 55%, var(--card)); transform: scale(1.08); }
  61%, 100% { background: color-mix(in srgb, var(--g) 22%, var(--card)); transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .gl-stage, .gl-card, .gl-chips, .gl-chips span, .gl-face { animation: none; }
  .gl-card { transform: rotateY(180deg); }
  .gl-front { opacity: 0; }
}

/* ── The placement test ─────────────────────────────────────────────────── */
.pl-head { display: flex; justify-content: space-between; align-items: center; width: 100%; }
.pl-glyph { margin: 0; font-family: var(--serif-hz); font-size: 46px; line-height: 1; color: var(--seal); }
.pl-loading { display: flex; align-items: center; gap: 10px; }
.pl-loading .spin {
  width: 14px; height: 14px; border-radius: 50%;
  border: 2px solid var(--hair); border-top-color: var(--fg-2);
  animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(1turn); } }
@media (prefers-reduced-motion: reduce) { .pl-loading .spin { animation: none; } }
.pl-top { display: flex; justify-content: space-between; align-items: center; width: 100%; }
.pl-skill {
  font-size: var(--t-cap); font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
  color: var(--seal-text); padding: 4px 9px; border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--seal) 13%, var(--card));
}
.placement .gy-rail { width: 100%; }
.pl-prompt { display: flex; flex-direction: column; gap: 6px; width: 100%; margin: var(--s2) 0; }
.pl-prompt .fine { margin: 0; }
.pl-hanzi { margin: 0; font-family: var(--serif-hz); font-size: 46px; line-height: 1.15; }
.pl-hanzi.long { font-size: 34px; }
.pl-note { color: var(--muted); font-style: italic; }
.pl-hear {
  display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; height: 64px;
  border: 0; border-radius: var(--r-card); cursor: pointer; font: inherit; font-weight: 600;
  background: var(--raised); color: var(--seal-text);
}
.pl-opts { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; width: 100%; }
.pl-opts .gy-o { width: 100%; text-align: left; }
.pl-opts.zh .gy-o { font-family: var(--serif-hz); font-size: 22px; }
.gy-o.dim { opacity: .55; }

/* ── First Chinese ──────────────────────────────────────────────────────── */
/* The course before the app, for a beginner: the nav comes off, like the
   intro's, because a first-timer has nothing on any other screen yet. */
body.in-course .top { display: none; }
.bs-close {
  background: none; border: 0; cursor: pointer; color: var(--muted);
  width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; flex: none;
}
.bs-close:hover { color: var(--fg); background: var(--raised); }
.basics-lesson .gy-head { align-items: center; gap: var(--s3); }
.basics-lesson .gy-head .gy-part { flex: 1; }
.bs-acts { display: flex; flex-direction: column; align-items: center; gap: var(--s2); }
.bs-acts .btn-lg { min-width: 220px; }
.bs-hero.two { font-size: 44px; }
.bs-screen .says { max-width: 44ch; }

/* ── FIRST CHINESE ON A LAPTOP, TOO (#2233) ────────────────────────────────
   The course is the rest of a beginner's first run, so the intro's rule holds
   here: nothing below the fold at 1280x720. Measured before: the card screen
   169px over, a quiz 61, the tones 44. The card and what each part of it is
   stand side by side on a desk, and a short window takes the air out of the
   screen's padding rather than making the lesson scroll. */
@media (min-width: 900px) {
  .bs-k-anatomy {
    display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: var(--s5);
    align-items: center; text-align: left;
  }
  .bs-k-anatomy > h1 { grid-column: 1 / -1; text-align: center; }
}
@media (min-width: 900px) and (max-height: 820px) {
  .screen.basics-lesson { gap: var(--s3); }
  .bs-screen { min-height: 0; gap: var(--s3); padding: var(--s5); }
  /* Next and Back side by side, as on the intro's split steps. */
  .basics-lesson .bs-acts { flex-direction: row; justify-content: center; gap: var(--s5); }
}

.bs-tonehead { margin: 0; display: flex; align-items: baseline; gap: 10px; }
.bs-tonehead [lang="zh"] { font-family: var(--serif-hz); font-size: 26px; }
.bs-tonecard {
  display: flex; flex-direction: column; align-items: center; gap: var(--s3); width: 100%; max-width: 380px;
  padding: 18px; border: 0; border-radius: var(--r-card); cursor: pointer; font: inherit;
  background: var(--raised); color: currentColor;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 50%, transparent);
}
.bs-tonerow { display: flex; align-items: center; gap: 12px; width: 100%; color: var(--fg); }
.bs-tonerow .bs-hanzi { font-size: 48px; }
.bs-tonew { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; flex: 1; }
.bs-tonew .bs-py { font-size: 24px; }
.bs-spk { color: var(--muted); display: grid; }
.bs-note { margin: 0; display: flex; gap: 11px; text-align: left; max-width: 44ch; color: var(--fg-2); line-height: 1.6; width: 100%; }
.bs-note span { font-family: var(--serif-hz); color: var(--muted); width: 22px; flex: none; font-size: 17px; }

/* The staff and the line that draws itself. `pathLength="1"` on every contour
   makes a dash of 1 the whole line, whatever its shape. */
.bs-staff { stroke: var(--hair); }
.bs-contour.draw path + path { stroke-dasharray: 1; animation: drawline .75s ease-in-out both; }
@keyframes drawline { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) { .bs-contour.draw path + path { animation: none; } }

.bs-swatches span { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.bs-swatches i { display: block; width: 100%; height: 6px; border-radius: 6px; }
.bs-marks { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; width: 100%; max-width: 440px; }
.bs-mark {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  padding: 14px 6px; border: 0; border-radius: 14px; cursor: pointer; font: inherit;
  background: var(--raised); color: var(--fg);
}
.bs-mark b { font-family: 'Fraunces', Georgia, serif; font-size: 30px; line-height: 1; font-weight: 700; }
.bs-mark:hover, .bs-chip:hover, .bs-whole:hover { filter: brightness(1.06); }

.bs-trap {
  display: flex; align-items: center; justify-content: center; gap: 14px; width: 100%; max-width: 380px;
  padding: 26px; border-radius: 22px;
  background: color-mix(in srgb, currentColor 12%, transparent);
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, currentColor 40%, transparent);
}
.bs-trap b { font-family: 'Fraunces', Georgia, serif; font-size: 64px; line-height: 1; color: var(--seal-text); }
.bs-trap > span:not(.bs-emoji) { font-family: 'Fraunces', Georgia, serif; font-size: 34px; color: var(--muted); }
.bs-trap .bs-emoji { font-size: 72px; line-height: 1; }
.bs-chip {
  display: inline-flex; align-items: center; gap: 8px; padding: 11px 18px; cursor: pointer;
  border: 0; border-radius: var(--r-pill); font: inherit; color: var(--fg-2);
  background: var(--raised); box-shadow: inset 0 0 0 1px var(--hair);
}
.bs-chip [lang="zh"] { font-family: var(--serif-hz); font-size: 26px; color: var(--fg); }
.bs-chip b { font-size: 20px; font-weight: 700; }

.bs-build { display: flex; flex-direction: column; align-items: center; gap: 18px; width: 100%; }
.bs-whole {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  background: none; border: 0; cursor: pointer; font: inherit; color: var(--fg);
}
.bs-whole b { font-family: var(--serif-hz); font-weight: 400; font-size: 76px; line-height: 1; }
.bs-parts { display: flex; align-items: center; gap: 10px; }
.bs-parts .bs-part {
  min-width: 78px; padding: 12px 8px; border-radius: 14px; background: var(--raised); gap: 5px;
}
.bs-parts .bs-part b { font-size: 36px; }
.bs-gloss { font-size: 12px; color: var(--violet-ink); }
.bs-line { margin: 0; line-height: 1.6; max-width: 40ch; }

.bs-card hr { width: 100%; border: 0; border-top: 1px solid var(--hair); margin: 6px 0; }
.bs-chips { display: flex; gap: 8px; }
.bs-gloss-chip {
  font-size: 12px; color: var(--violet-ink); padding: 5px 9px; border-radius: var(--r-chip); background: var(--raised);
}
.bs-card > .fine:last-child { max-width: 28ch; text-align: center; }

.bs-say { display: inline-flex; align-items: center; gap: 9px; }
.bs-opts-tone .gy-o { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.bs-qn { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.bs-verdict { display: flex; align-items: center; gap: 10px; font-weight: 500; }
.bs-rest { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.bs-rest .bs-body { text-align: center; }

.scroll-course-row { grid-column: 1 / -1; display: flex; align-items: stretch; gap: 4px; }
.scroll-course-row .scroll-course { flex: 1; }
.scroll-course-x {
  flex: none; width: 34px; border: 0; background: none; cursor: pointer; color: var(--muted);
  border-radius: var(--r-card);
}
.scroll-course-x:hover { color: var(--fg); background: var(--raised); }

/* ── The unlock sheet ───────────────────────────────────────────────────── */
.unlock-veil {
  position: fixed; inset: 0; z-index: 80; display: grid; place-items: center;
  padding: var(--s4); overflow-y: auto;
  background: color-mix(in srgb, #000 48%, transparent);
}
.unlock {
  width: 100%; max-width: 520px; display: flex; flex-direction: column; gap: 18px;
  padding: 22px 22px 26px; border-radius: var(--r-panel);
  background: var(--bg); color: var(--fg); box-shadow: var(--lift);
  animation: arrive .35s ease-out backwards;
}
@media (prefers-reduced-motion: reduce) { .unlock { animation: none; } }
.unlock-head { display: flex; align-items: center; gap: 14px; }
.unlock-glyph {
  width: 62px; height: 62px; border-radius: 15px; rotate: -4deg; display: grid; place-items: center;
  font-family: var(--serif-hz); font-size: 30px; background: var(--seal); color: var(--seal-on);
}
.unlock h2 { margin: 0; font-family: var(--serif); font-size: 27px; line-height: 1.2; font-weight: 500; }
.unlock-k { margin: 0 0 -10px; font-size: var(--t-cap); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.unlock-chips { margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.unlock-chips span { font-size: 13px; padding: 6px 10px; border-radius: var(--r-chip); background: var(--raised); }
.unlock-art {
  padding: 16px; border-radius: var(--r-card); background: var(--card); box-shadow: var(--panel);
  display: flex; flex-direction: column; align-items: center; gap: 9px;
}
.unlock-body { margin: 0; color: var(--fg-2); line-height: 1.65; }
.unlock-take {
  margin: 0; display: flex; gap: 11px; padding: 14px; border-radius: var(--r-card);
  background: var(--card); box-shadow: var(--panel); line-height: 1.5;
}
.unlock-take span { font-family: var(--serif-hz); color: var(--seal); font-size: 18px; width: 24px; flex: none; }
.unlock .btn { align-self: stretch; }
.unlock-again { margin: 0; text-align: center; }

/* The seven figures — `InstalmentArt`. Skills take the colours the Progress
   screen gives them; the curve takes the seal. */
.sk-listen { color: var(--t4-ink); } .sk-speak { color: var(--jade-ink); } .sk-read { color: var(--t1-ink); }
.sk-write { color: var(--t3-ink); } .sk-trans { color: var(--t2-ink); }
.ia-comp { display: flex; align-items: center; gap: 10px; }
.ia-part, .ia-whole { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.ia-part { min-width: 56px; padding: 9px 6px; border-radius: 11px; background: var(--raised); }
.ia-part b { font-family: var(--serif-hz); font-weight: 400; font-size: 28px; }
.ia-whole b { font-family: var(--serif-hz); font-weight: 400; font-size: 36px; }
.ia-part i, .ia-whole i { font-style: normal; font-size: 11px; }
.ia-part i { color: var(--violet-ink); }
.ia-whole i { color: var(--fg-2); }
.ia-sym { font-family: var(--serif); font-size: 17px; color: var(--muted); }
.ia-curve { width: 100%; height: 96px; overflow: visible; }
.ia-target { stroke: var(--hair); stroke-width: 1; stroke-dasharray: 3 3; fill: none; vector-effect: non-scaling-stroke; }
.ia-saw { stroke: var(--seal); stroke-width: 2.5; fill: none; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.ia-saw.draw { stroke-dasharray: 1; animation: drawline .9s ease-in-out both; }
@media (prefers-reduced-motion: reduce) { .ia-saw.draw { animation: none; } }
.ia-dots { fill: var(--seal); }
.ia-cap { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); align-self: flex-start; }
.ia-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--seal); display: inline-block; }
.ia-big { margin: 0; font-family: var(--serif-hz); font-size: 30px; }
.ia-pair { display: grid; gap: 8px; width: 100%; }
.ia-meter { display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 8px; }
.ia-meter b { font-family: var(--serif-hz); font-weight: 400; font-size: 17px; }
.ia-track { height: 8px; border-radius: 8px; background: var(--raised); overflow: hidden; }
.ia-track i { display: block; height: 100%; border-radius: 8px; background: currentColor; }
.ia-lbl { font-size: 11px; color: var(--fg-2); }
.ia-steps { display: flex; align-items: flex-end; }
.ia-step { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.ia-step b { font-family: var(--serif-hz); font-weight: 400; font-size: 19px; }
.ia-step i {
  display: block; width: 68px; border-radius: 6px;
  background: color-mix(in srgb, currentColor 28%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 70%, transparent);
}
.ia-step span { font-size: 11px; color: var(--fg-2); }
.ia-rule { width: 26px; height: 1px; background: var(--hair); margin-bottom: 21px; }
.ia-words { margin: 0; display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; }
.ia-words span {
  font-family: var(--serif-hz); font-size: 19px; padding: 4px 7px; border-radius: 7px;
  background: color-mix(in srgb, var(--jade) 16%, var(--card));
}
.ia-hand { display: flex; align-items: center; gap: 12px; }
.ia-glyph { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.ia-glyph b {
  display: grid; place-items: center; width: 68px; height: 68px; border-radius: 11px;
  font-family: var(--serif-hz); font-weight: 400; font-size: 36px; background: var(--raised);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 70%, transparent);
}
.ia-glyph.traced b {
  color: color-mix(in srgb, var(--t3-ink) 32%, transparent); box-shadow: none;
  outline: 1px dashed color-mix(in srgb, var(--t3-ink) 70%, transparent); outline-offset: -1px;
}
.ia-glyph span { font-size: 11px; color: var(--fg-2); }
.ia-bars { display: flex; align-items: flex-end; gap: 12px; height: 130px; }
.ia-bar { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 4px; height: 100%; width: 44px; }
.ia-bar i { display: block; width: 18px; border-radius: 5px; background: currentColor; opacity: .8; max-height: 80px; }
.ia-bar b { font-family: var(--serif-hz); font-weight: 400; font-size: 15px; }
.ia-bar span { font-size: 11px; color: var(--fg-2); text-align: center; }

/* The intro's own h1 rule is two selectors deep and would otherwise size the
   question bubble as a page title. And the step sits on a sheet of its own
   ground now: with Gushi and a bubble on it, the scenery's figures behind the
   column were walking through the sentences. */
.intro-step .ask-bubble {
  font-size: clamp(19px, 2.2vw, 23px); line-height: 1.25; margin: 0 0 34px; letter-spacing: -.01em;
}
.intro-step {
  padding: var(--s6) var(--s5) var(--s5); border-radius: var(--r-panel);
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  box-shadow: var(--lift);
}

/* ══ THE REFERENCE PAGES (parity pass, 2026-09-22) ═════════════════════════
   The character page, the word page, a building block, the parts index, the
   collections and the Library's new marks. Everything the app shows on those
   screens is here now, laid out for a desk: the sections one under the other,
   a strip at the top that jumps to each, and the schedule in the corner the
   app put it in. */
.ref-page .char-head, .ref-page .wd-hero { position: relative; align-items: flex-start; }
/* ── THE CORNER STAYS IN THE CORNER (#2421) ─────────────────────────────
   *"Ich finde es nicht sinnig hier den Platz zu lassen, vielleicht das, was
   da rechts steht, neben den Kram oben rechts?"* `.char-head` wraps, and the
   text column asked for its widest line (a 46ch meaning, a row of chips), so
   inside the Library's shelves glyph + text + corner did not fit on one line
   and the star, the share and the schedule dropped to a line of their own:
   a band of nothing between the header and the tabs. The column takes what
   is left instead and wraps its own lines, which is what the word page's
   `.wd-lede` already did, so the corner sits top right beside it. */
.ref-page .char-head .char-id { flex: 1 1 0; }
.ref-corner {
  margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; gap: var(--s3);
}
.ref-corner .wd-acts { margin: 0; }
.ref-kind { margin: 0; text-transform: uppercase; letter-spacing: .08em; font-size: 11px; }
.also { font-family: Inter, system-ui, sans-serif; font-size: var(--fs-fine); color: var(--muted); }
/* The grey senses on a line of their own, under the one bold sense
   (#2418, #2419): `faceLines` in views.js, MeaningStack in the app. */
.also-under { margin: 4px 0 0; line-height: 1.4; }
.also-under.char-m-also { max-width: 46ch; }
.also-under.wd-m-also { max-width: 44ch; }
.also-under.r-meaning-also { max-width: 36ch; margin-left: auto; margin-right: auto; }
.hz { font-family: 'Noto Serif SC', serif; }

.sched { margin: 0; display: flex; flex-direction: column; gap: 3px; align-items: flex-end; }
.sched-row { display: flex; gap: 6px; align-items: baseline; font-size: var(--fs-fine); }
.sched-row dt { font-family: 'Noto Serif SC', serif; color: var(--muted); }
.sched-row dd { margin: 0; color: var(--fg-2); font-variant-numeric: tabular-nums; }
.sched-ready dd { color: var(--t4-ink); font-weight: 500; }

.share-b {
  display: inline-grid; place-items: center; width: 36px; height: 36px; padding: 0;
  border-radius: var(--r-pill); background: none; cursor: pointer; color: var(--muted);
}
.share-b:hover { background: var(--raised); color: var(--fg-2); }
.share-b svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7;
  stroke-linecap: round; stroke-linejoin: round; }

.ref-jump { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 var(--s2); }
.ref-sec { scroll-margin-top: var(--s6); }
.ref-sec .sub { display: flex; align-items: baseline; gap: 8px; }
.ref-sec-g { font-family: 'Noto Serif SC', serif; color: var(--seal-text); font-size: 17px; }
.ref-h3 {
  margin: var(--s3) 0 0; font-size: 11px; font-weight: 600; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted);
}
/* "Study these" beside a block's family (#2209): a link, not a second heading. */
.ref-set {
  margin-left: 10px; text-transform: none; letter-spacing: 0; font-size: var(--fs-fine);
  font-weight: 600; color: var(--seal-text); text-decoration: none;
}
.ref-set:hover { text-decoration: underline; }
.ref-set-p { margin: 0 0 6px; }
.ref-set-p .ref-set { margin-left: 0; }
.un-list { margin: 0; padding-left: 1.1em; display: flex; flex-direction: column; gap: 8px; max-width: 72ch; }
.readings { margin: 0; display: flex; gap: 8px; align-items: baseline; }
.chip-taught {
  display: inline-block; padding: 1px 8px; border-radius: var(--r-pill);
  background: var(--raised); color: var(--fg-2); font-size: 11px; font-weight: 500;
}
/* What kind of character this is (#2034, #2223): label chips, no sentence —
   CharLabelRow.swift. Centred on the card, where the meaning above is. */
.clabels { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.clabels li {
  padding: 2px 9px; border-radius: var(--r-pill); background: var(--raised);
  color: var(--fg-2); font-size: 11.5px; font-weight: 500;
}
.clabels.r-labels { justify-content: center; margin-top: 2px; }
/* Each reading beside its meaning (#2353), the standing label on the taught
   one (#2327) — ReadingPairs.swift. Since #2408 a three-column table: the
   reading, its own speaker (#2400), and the meaning taking the rest of the
   width, so the meanings start on one line whatever the pinyin's width;
   centred as a block on the card. */
.rpairs { display: grid; grid-template-columns: auto auto 1fr; column-gap: 10px; row-gap: 10px; align-items: baseline; }
.rpair { display: contents; }
.rp-py { font-size: 17px; font-weight: 600; }
.rpairs .play { align-self: baseline; }
.rp-nope { display: block; width: 22px; }
.rp-m { color: var(--fg-2); font-size: 15px; display: flex; flex-direction: column; align-items: flex-start; gap: 3px; }
.rpair-main .rp-m { color: var(--fg); font-family: 'Fraunces', Georgia, serif; font-size: 21px; line-height: 1.3; }
.rpair-main .rp-m.rp-long { font-size: 18px; }
.rpair-main .rp-m.rp-longer { font-size: 16px; }
.rp-m .also { font-family: 'Inter', system-ui, -apple-system, sans-serif; font-size: 13px; line-height: 1.4; color: var(--muted); }
.rp-chip {
  display: inline-block; vertical-align: middle; margin-top: 2px; padding: 2px 9px;
  border-radius: var(--r-pill); background: var(--raised); color: var(--fg-2);
  font-family: 'Inter', system-ui, -apple-system, sans-serif; font-size: 11.5px; font-weight: 500;
}
.rpairs.r-pairs { width: fit-content; max-width: 36ch; margin: 0 auto; text-align: left; }
.rpairs.char-pairs { margin: var(--s2) 0 0; max-width: 46ch; }
/* A polyphone's usage, one reading at a time (#2205). The chips are labels
   for hidden radios; `:checked` shows the reading's panel. Six is more
   readings than any character has (啊 has five). */
.rd { display: flex; flex-direction: column; gap: 10px; }
.rd-in { position: absolute; opacity: 0; pointer-events: none; }
.rd-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.rd-chip {
  padding: 4px 12px; border-radius: var(--r-pill); cursor: pointer;
  background: var(--raised); border: 1px solid var(--hair); font-weight: 500;
}
.rd-p { display: none; flex-direction: column; gap: 8px; }
.rd-m { margin: 0; font-weight: 600; display: flex; gap: 8px; align-items: baseline; }
.rd-in:nth-of-type(1):checked ~ .rd-panels > .rd-p:nth-child(1),
.rd-in:nth-of-type(2):checked ~ .rd-panels > .rd-p:nth-child(2),
.rd-in:nth-of-type(3):checked ~ .rd-panels > .rd-p:nth-child(3),
.rd-in:nth-of-type(4):checked ~ .rd-panels > .rd-p:nth-child(4),
.rd-in:nth-of-type(5):checked ~ .rd-panels > .rd-p:nth-child(5),
.rd-in:nth-of-type(6):checked ~ .rd-panels > .rd-p:nth-child(6) { display: flex; }
.rd-in:nth-of-type(1):checked ~ .rd-chips > .rd-chip:nth-child(1),
.rd-in:nth-of-type(2):checked ~ .rd-chips > .rd-chip:nth-child(2),
.rd-in:nth-of-type(3):checked ~ .rd-chips > .rd-chip:nth-child(3),
.rd-in:nth-of-type(4):checked ~ .rd-chips > .rd-chip:nth-child(4),
.rd-in:nth-of-type(5):checked ~ .rd-chips > .rd-chip:nth-child(5),
.rd-in:nth-of-type(6):checked ~ .rd-chips > .rd-chip:nth-child(6) {
  border-color: var(--seal); background: color-mix(in srgb, var(--seal) 12%, var(--card)); font-weight: 650;
}
.rd-in:focus-visible ~ .rd-chips { outline: 2px solid var(--seal); outline-offset: 2px; border-radius: var(--r-pill); }
/* A usage note's examples, a line each (#2210): hanzi, reading, meaning. */
.un-exs { display: flex; flex-direction: column; gap: 2px; margin-top: 3px; }
.un-ex { color: var(--fg-2); }
.un-exh { font-family: 'Noto Serif SC', serif; color: var(--fg); font-size: 1.05em; }
.un-exp { color: var(--muted); font-size: .92em; }
.un-exg { color: var(--fg-2); }
.rf-facts { margin: var(--s2) 0 0; display: flex; flex-direction: column; gap: 7px; max-width: 520px; }
.rf-fact { display: flex; justify-content: space-between; gap: var(--s4); font-size: var(--fs-small); }
.rf-fact dt { color: var(--muted); }
.rf-fact dd { margin: 0; text-align: right; }
.rf-fact dd a { color: var(--seal-text); text-decoration: none; }
.measure { margin-left: 6px; color: var(--fg-2); }
.measure b { font-weight: 400; color: var(--fg); font-size: 17px; }

.story-eq { margin: 0; color: var(--fg-2); font-weight: 500; font-size: var(--fs-small); }
.origin { margin: 0; line-height: 1.55; max-width: 68ch; }

.ref-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.ref-list > li + li { box-shadow: inset 0 1px 0 var(--hair); }
.ref-row {
  display: flex; align-items: center; gap: var(--s4); padding: 9px 2px;
  text-decoration: none; color: var(--fg);
}
.ref-row:hover .ref-h { color: var(--seal-text); }
.ref-h { font-family: 'Noto Serif SC', serif; font-size: 24px; flex: none; }
.ref-h.t1 { color: var(--t1-ink); } .ref-h.t2 { color: var(--t2-ink); } .ref-h.t3 { color: var(--t3-ink); }
.ref-h.t4 { color: var(--t4-ink); }
.ref-b { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.ref-why { margin-left: auto; padding: 2px 9px; border-radius: var(--r-pill); background: var(--raised); flex: none; }
.ref-more > summary, .sec-more > summary { cursor: pointer; color: var(--seal-text); font-size: var(--fs-fine); padding: 6px 0; }

.alike { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.alike a {
  display: flex; flex-direction: column; align-items: center; min-width: 64px; gap: 2px;
  padding: 7px 10px; border-radius: var(--r-chip); background: var(--raised);
  text-decoration: none; color: var(--fg);
}
.alike .hz { font-size: 26px; }

/* The component tree — one chevron per row, a trunk down the left side of a
   branch, known parts framed in green (ComponentTree.swift). */
.tree { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.tree-sub { margin: 6px 0 0 13px; padding-left: 17px; border-left: 1px solid var(--hair); }
.tree > li { display: flex; align-items: flex-start; gap: 6px; }
.tree > li > details { flex: 1; }
.tree-node > summary { list-style: none; display: flex; align-items: center; gap: 6px; cursor: pointer; }
.tree-node > summary::-webkit-details-marker { display: none; }
.tree-chev {
  width: 24px; height: 24px; flex: none; border-radius: 50%; display: grid; place-items: center;
  background: var(--raised); color: var(--fg-2); font-size: 14px; transition: transform var(--fast) var(--ease);
}
.tree-node[open] > summary .tree-chev { transform: rotate(90deg); }
.tree-pad { width: 24px; flex: none; }
.tree-row { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--fg); min-width: 0; }
.tree-g {
  width: 38px; height: 38px; flex: none; display: grid; place-items: center;
  border-radius: var(--r-chip); background: var(--raised);
  font-family: 'Noto Serif SC', serif; font-size: 22px;
}
.tree-g.known { box-shadow: inset 0 0 0 2px var(--jade); }
.tree-b { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.tree-b b { font-size: var(--fs-small); font-weight: 600; }
.tree-py { font-size: var(--fs-fine); display: flex; gap: 8px; }
.tree-alt { color: var(--muted); }
.tree-n { margin-left: auto; }
.tree-node[open] > summary .tree-n { display: none; }
/* Which part gives the sound, which the meaning (#2208), and an opened part's
   own story, three lines at most (#2053) — ComponentTree.swift. */
.tree-role {
  display: inline-block; margin-left: 6px; padding: 0 7px; border-radius: var(--r-pill);
  background: var(--raised); color: var(--fg-2); font-size: 11px; font-weight: 500; vertical-align: 1px;
}
.tree-story {
  margin: 4px 0 2px 30px; color: var(--fg-2); font-size: var(--fs-fine); line-height: 1.5; max-width: 60ch;
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}

/* The breakdown tree's rules live in charview.css, shared with the site's
   lookup (charview.js draws both). */

/* The ancient forms, oldest first — AncientChain. */
.etym { display: flex; gap: 14px; overflow-x: auto; padding: 2px 0; }
/* At least 72px and otherwise as wide as its caption (#2422): a fixed 72
   let a long single word, "Orakelknochen" at 10.5px, run past both sides of
   the cell, and the strip's own scroller cut the first letter off. */
.etym-c { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 5px; min-width: 72px; flex: none; }
.etym-art {
  width: 64px; height: 64px; border-radius: 8px; background: var(--raised);
  display: grid; place-items: center;
}
.etym-art svg { width: 56px; height: 56px; }
.etym-art path { fill: var(--fg); }
.etym-now { font-family: 'Noto Serif SC', serif; font-size: 48px; line-height: 1; }
.etym-c figcaption { display: flex; flex-direction: column; align-items: center; font-size: 11px; color: var(--muted); text-align: center; }
.etym-zh { font-family: 'Noto Serif SC', serif; font-size: 12px; }

/* Sentences that hide their answer until tapped — ExampleRow. */
.ex-rv { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.ex-rv li { display: flex; align-items: flex-start; gap: 8px; }
.ex-rv details { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.ex-rv summary { cursor: pointer; list-style: none; font-size: 19px; }
.ex-rv summary::-webkit-details-marker { display: none; }
.ex-rv mark { background: color-mix(in srgb, var(--amber) 22%, var(--card)); color: inherit; border-radius: 3px; padding: 0 2px; }
.ex-rv .ex-p, .ex-rv .ex-e { display: block; }

/* 写: the strokes, a replay, and a square that grades nothing. */
.stk { display: flex; flex-wrap: wrap; gap: var(--s5); align-items: flex-start; }
.stk-watch { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.stk-ctl { display: flex; align-items: center; gap: 8px; }

/* ── 写 WRITING IT IN THE DIAGRAM'S PLACE (#2166) ──────────────────────────
   Nothing to write on is shown until somebody asks to write. Then ink.js puts
   the square where the diagram stood and the diagram steps aside, as the
   phone's CharacterDetailView swaps StrokeOrderDiagram for StrokeCanvas.

   `touch-action: none` is what keeps a downward stroke on a tablet from
   scrolling the page instead of reaching the square; the three `user-select`
   lines keep a mouse drag from selecting the page's text, and a long press on
   an iPad from opening the callout. The square is kept square by
   `aspect-ratio`, and ink.js maps the pointer through its box on every event
   anyway, so a CSS zoom on the place around it cannot move the ink. */
.stk-try { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
.stk-try-ctl { display: flex; align-items: center; gap: 6px; margin: 0; }
.stk:not(.is-writing) .ink-pad,
.stk:not(.is-writing) .ink-say,
.stk:not(.is-writing) .ink-clear { display: none; }
.stk.is-writing .stk-watch svg.strokes { display: none; }
.ink-pad {
  display: block; width: min(220px, 100%); height: auto; aspect-ratio: 1;
  border-radius: 14px; cursor: crosshair;
  touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
  transition: box-shadow var(--fast) var(--ease);
}
/* The border flashes, as StrokeCanvas's does: jade for a stroke taken, seal
   for one refused. */
.ink-pad.is-ok { box-shadow: 0 0 0 2px var(--jade); }
.ink-pad.is-bad { box-shadow: 0 0 0 2px var(--seal); }
/* Ahead of the pen the character is faint; the stroke asked for darkens after
   two misses; written strokes are ink, and a stroke the square had to fill in
   is the duller red. The live stroke is the phone's seal at 80 per cent. */
.ink-ghost { fill: color-mix(in srgb, var(--fg) 12%, transparent); transition: fill .18s ease-out; }
.ink-ghost.is-hint { fill: color-mix(in srgb, var(--fg) 34%, transparent); }
.ink-ghost.is-done { fill: var(--fg); }
.ink-ghost.is-failed { fill: color-mix(in srgb, var(--seal) 70%, var(--fg)); }
.ink-live {
  fill: none; stroke: var(--seal); stroke-opacity: .8; stroke-width: 32;
  stroke-linecap: round; stroke-linejoin: round; pointer-events: none;
}
.ink-say { max-width: 30ch; margin: 0; }
@media (prefers-reduced-motion: reduce) { .ink-ghost, .ink-pad { transition: none; } }

/* A building block. */
.part-head { display: flex; gap: var(--s5); align-items: center; }
.part-g {
  width: 96px; height: 96px; flex: none; display: grid; place-items: center;
  border-radius: var(--r-panel); background: var(--card); box-shadow: var(--panel);
  font-family: 'Noto Serif SC', serif; font-size: 58px;
}
.part-none { color: var(--muted); border: 2px dashed var(--hair); box-shadow: none; }
.part-t { font-family: 'Fraunces', Georgia, serif; font-size: 26px; margin: 0; }
.part-head p { margin: 4px 0 0; }
.part-full .hz { font-size: 20px; }
.part-story { display: flex; flex-direction: column; gap: 6px; text-decoration: none; color: var(--fg); }
/* One page for a block (#2493): its history and parts on the left, every
   character it is in as tiles on the right, so a desk sees it all at once. */
.part-page .part-head { margin-bottom: var(--s4); }
.part-full { margin-left: 8px; color: var(--fg-2); font-size: var(--fs-fine); }
.part-grid { display: grid; gap: var(--s5); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
.part-grid.part-one { grid-template-columns: minmax(0, 1fr); }
@media (max-width: 760px) { .part-grid { grid-template-columns: minmax(0, 1fr); } }
.part-col { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }
.part-block .sub { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.part-block .sub .ref-set { margin-left: auto; font-size: var(--fs-fine); }
.part-block .etym { flex-wrap: wrap; }
.part-tiles { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px;
  grid-template-columns: repeat(auto-fill, minmax(58px, 1fr)); }
.part-tile {
  display: flex; flex-direction: column; align-items: center; gap: 1px; padding: 6px 2px 5px;
  border-radius: var(--r-chip, 10px); background: var(--raised); text-decoration: none; color: var(--fg);
}
.part-tile:hover { background: color-mix(in srgb, var(--violet) 12%, var(--raised)); }
.part-tile .hz { font-family: 'Noto Serif SC', serif; font-size: 24px; line-height: 1.15; }
.part-tile-m { max-width: 100%; font-size: 11px; line-height: 1.2; color: var(--fg-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.parts-band { display: flex; flex-direction: column; gap: 6px; }
.parts-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.parts-b { display: flex; flex-direction: column; min-width: 0; }

/* The collections: tiles in their stage's colour, a star beside each on My list. */
.set-hero { display: flex; flex-direction: column; gap: 6px; }
.set-hero p { margin: 0; }
.set-face { font-family: 'Noto Serif SC', serif; font-size: 28px; }
.set-meter { width: min(320px, 100%); height: 5px; }
.ls-learning .ls-meter i, .ls-meter.ls-learning i { background: var(--t2); }
.set-grid { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.set-t { position: relative; display: flex; align-items: center; }
.set-a {
  --ph: var(--hair);
  display: flex; flex-direction: column; min-width: 72px; max-width: 150px; gap: 1px;
  padding: 7px 10px; border-radius: var(--r-chip); text-decoration: none; color: var(--fg);
  background: color-mix(in srgb, var(--ph) 10%, var(--raised));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ph) 55%, transparent);
}
.set-a.ph-ready { --ph: var(--t4); } .set-a.ph-learning { --ph: var(--t2); } .set-a.ph-learned { --ph: var(--jade); }
.set-a .fine { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.set-h { font-family: 'Noto Serif SC', serif; font-size: 20px; }
.set-t .star { width: 28px; height: 28px; font-size: 15px; margin-left: 2px; }
.ls-hz { font-family: 'Noto Serif SC', serif; font-size: 16px; color: var(--fg-2); }
.ls-done { color: var(--jade-ink); font-weight: 600; font-size: 11px; margin-left: 6px; }

/* The Library: the marks on a tile, the star beside it, the new toggles. */
.cellw { position: relative; display: flex; flex-direction: column; }
.cellw > .cell, .cellw > .wcell { flex: 1; }
.cellw > .star {
  position: absolute; right: 2px; bottom: 2px; width: 26px; height: 26px; font-size: 13px; margin: 0;
}
.wcellw > .star { top: 6px; bottom: auto; right: 6px; }
.mks { display: flex; gap: 5px; align-items: baseline; min-height: 13px; }
.wcell .mks { margin-left: auto; margin-right: 26px; }
.mk { display: inline-flex; gap: 2px; align-items: baseline; }
.mk-g { font-family: 'Noto Serif SC', serif; font-size: 11px; color: var(--hair); }
.mk-ready { color: var(--t4-ink); } .mk-learning { color: var(--t2-ink); } .mk-learned { color: var(--jade-ink); }
.mk-d { font-size: 8.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
.mk-lv { font-size: 11px; }
.lchips-f { flex: 0 0 auto; }
.lchip-t.on { background: var(--t2); color: var(--bg); }
.lib-halves { margin-bottom: var(--s2); }
.level-all b { font-size: 12px; }
.lib-empty { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }

.glist { display: flex; flex-direction: column; gap: 4px; }
.gband { margin: var(--s4) 0 0; color: var(--seal-text); }
.gcat { margin: var(--s3) 0 2px; font-size: 11px; font-weight: 600; letter-spacing: .07em; color: var(--muted); }
.gpoint { background: var(--raised); border-radius: var(--r-card); padding: 10px 14px; }
.gpoint[open] { background: var(--card); box-shadow: var(--panel); }
.gpoint > summary { list-style: none; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; }
.gpoint > summary::-webkit-details-marker { display: none; }
.gpoint .dot { margin-top: 7px; }
.gpoint-b { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.gpoint-n { font-size: 16px; }
.gpoint-p { color: var(--t2-ink); font-weight: 500; font-size: var(--fs-fine); }
.gpoint-e { color: var(--fg-2); font-size: var(--fs-fine); }
.gpoint-x { margin: 8px 0 0 17px; padding-top: 8px; border-top: 1px solid var(--hair); display: flex; flex-direction: column; }

.lib-sets { display: flex; flex-direction: column; gap: 8px; }
.lib-sets > .fine { margin: 0; max-width: 72ch; }
.lsh-head { display: flex; align-items: baseline; gap: 10px; margin: var(--s4) 0 0; }
.lsh-more { margin-left: auto; font-size: var(--fs-fine); color: var(--seal-text); text-decoration: none; font-weight: 500; }
.lsh { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; }
.lsh-c {
  flex: 0 0 auto; width: 170px; min-height: 112px; display: flex; flex-direction: column; gap: 5px;
  padding: 12px 14px; border-radius: var(--r-panel); background: var(--card); box-shadow: var(--panel);
  text-decoration: none; color: var(--fg);
}
.lsh-c .ls-meter { width: 100%; margin-top: auto; }
.lsh-hz { color: var(--fg-2); font-size: 15px; }
.lsh-g { font-size: 22px; }
.star-g { color: var(--t2-ink); }
.lvl-row .ls-meter { flex: 1; width: auto; margin: 0 var(--s4); }
.lvl-ahead .opt-l b { color: var(--muted); }

/* The pinyin table: a link to the character, the missing tone named, the pin. */
.py-cell { display: inline-flex; flex-direction: column; align-items: stretch; gap: 1px; }
.py-open {
  font-size: 11px; color: var(--muted); text-decoration: none; text-align: center;
  max-width: 58px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.py-open:hover { color: var(--fg); }
.py-none { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; }
.py-none .py-t { color: var(--muted); font-size: 11px; }
.py-nd { font-size: 8.5px; color: var(--muted); text-align: center; line-height: 1.1; }
.py-pin { margin-left: auto; font-size: var(--fs-fine); }
.py-pin.on { color: var(--seal-text); }
.py-compare .py-row { background: var(--raised); }
.sec-alt { margin: 0 0 8px; font-size: var(--fs-fine); color: var(--fg-2); }
.py-row { grid-template-columns: 84px 1fr auto; }
/* A character tree inside the word page list keeps the tree look, not the row card. */
.wd-chars .tree { margin: 8px 0 4px 18px; }
.wd-chars .tree a.tree-row { display: flex; padding: 0; background: none; gap: 10px; }

/* ══ 在 ONLINE RIGHT NOW (#2070) ═══════════════════════════════════════════
   presence.js toggles `is-online` on anything a render marked with
   `data-player`; a classmate's seat takes the green ring it already had, and
   since #2187 so does every face on the board: a row's, the podium's and the
   card's. Green, the colour this host already spends on "alive" (the seats,
   the heat map). It was a dot until then; the note below says why not. */
/* ── A GREEN RING, NOT A GREEN DOT (#2187) ───────────────────────────────
   *"bitte zeige User die gerade online sind mit einer grünen Umrandung an …
   kein grüner Punkt wie bei mir."* The dot sat on a face's shoulder and beside
   a row's name, and at 13px on a photograph it read as a notification badge,
   which is the one thing it is not. The class's seats already said online
   with a ring round the head (`.seat-lit`), so the League says it that way
   too: the face itself is circled in the green this host spends on "alive",
   with a hair of the card between the two so the ring reads on any picture.
   The map's faces take it as well, since every face there is somebody online.

   On the podium the ring was the medal's colour, and there it becomes green:
   the medal disc under the face still says the place, and a face ringed in
   two colours at once reads as a target. */
.pd-face, .pc-face { position: relative; }
.lg-row .av, .pd .av, .pc-face .av { transition: box-shadow var(--fast) var(--ease); }
.lg-row.is-online .av,
.pd.is-online .av,
.pc-face.is-online .av {
  box-shadow: 0 0 0 2px var(--card), 0 0 0 4.5px var(--growth-4);
}
.pc-face.is-online .av { box-shadow: 0 0 0 3px var(--card), 0 0 0 6.5px var(--growth-4); }
/* The room's podium face carries its own shadow at a higher specificity, so
   the green is restated there with it. It used to arrive through `--ring`,
   the variable the medal rings shared; there are no medal rings any more
   (#2245), so the one ring is written out. */
body.at-league .pd.is-online .av {
  box-shadow: 0 0 0 2.5px var(--card), 0 0 0 5.5px var(--growth-4), 0 6px 16px -6px rgba(0, 0, 0, .5);
}
.face:not(.face-more):not(.face-me) {
  box-shadow: 0 0 0 1.5px var(--card), 0 0 0 3px var(--growth-4), var(--sink);
}
/* The reader on the League's dais (#2242): first, a size up, and no ring,
   because the only ring on this host means somebody ELSE is online (#2245).
   A hair of the card keeps a photograph off the sky. */
.face-me {
  width: 32px; height: 32px; margin-right: 3px;
  box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--card) 92%, transparent), 0 3px 8px -3px rgba(20, 14, 8, .45);
}
.face-me .face-i { display: grid; place-items: center; font-size: 13px; }
.around-row:has(.face-me) { align-items: flex-end; }
.lg-online, .fl-head .lg-online { margin: 0; color: var(--growth-4); font-weight: 600; }
.lg-online::before { content: '● '; }

/* ══ 友 GLOBAL | FRIENDS, AND THE FRIENDS HALF (#2071) ══════════════════════ */
/* ── 龙友 THE CARVED BAND (#2168) ──────────────────────────────────────────
   views.js has the argument. One band of paper over a blur, a hairline of the
   seal round it, two segments inside; the chosen one is the seal's red with
   its glyph on a paper chop, the other is ink on paper with its glyph in a
   red chop. The two ruyi clouds are the ornament and the only one: a lacquer
   pattern or a gold edge would be a second material on a screen made of ink
   and paper. Twice the chips' size, because it is the first decision on the
   sheet and the tabs below it are the second. */
.lg-scope {
  display: flex; align-items: center; justify-content: center; gap: 6px;
  color: color-mix(in srgb, var(--seal) 72%, var(--fg));
}
.lg-scope-orn { flex: none; width: 40px; height: 22px; opacity: .85; }
.lg-scope-orn-r { transform: scaleX(-1); }
.lg-scope-band {
  display: inline-flex; gap: 4px; padding: 4px;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--card) 86%, transparent);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 34%, transparent),
              0 0 0 3px color-mix(in srgb, var(--card) 45%, transparent),
              0 12px 26px -16px rgba(26, 24, 19, .55);
}
.lg-sc {
  display: inline-flex; align-items: center; justify-content: center; gap: 9px;
  min-width: 9.2em; padding: 7px 18px 7px 8px;
  border-radius: var(--r-pill);
  font-size: 15px; font-weight: 650; letter-spacing: .01em;
  color: var(--fg); text-decoration: none;
  transition: background-color var(--fast) var(--ease), color var(--fast) var(--ease),
              box-shadow var(--fast) var(--ease);
}
.lg-sc-g {
  display: grid; place-items: center; flex: none;
  width: 28px; height: 28px; border-radius: 7px;
  font-family: var(--serif-hz); font-size: 17px; line-height: 1;
  background: var(--seal); color: var(--seal-on);
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--seal-on) 38%, transparent);
}
.lg-sc:not(.on):hover { background: color-mix(in srgb, var(--seal) 10%, transparent); }
.lg-sc.on {
  background: var(--seal); color: var(--seal-on);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .16), 0 8px 18px -10px var(--seal);
}
.lg-sc.on .lg-sc-g {
  background: var(--seal-on); color: var(--seal);
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--seal) 45%, transparent);
}
.lg-sc:focus-visible { outline: 2px solid var(--seal); outline-offset: 3px; }
@media (max-width: 560px) {
  .lg-scope-orn { display: none; }
  .lg-sc { min-width: 0; padding-right: 14px; }
}
/* The Week / Podium pair on the friends half sits on its own line under the
   league's name (#2169), where the global half has it across from the title. */
.fl-tabs { display: flex; justify-content: flex-end; margin-top: calc(-1 * var(--s2)); }
.fl { gap: var(--s4); }
.fl-title h1 { margin: 0; }
.fl-title .lg-foot { margin: 6px 0 0; }
.fl-card, body.at-league .fl-card { display: flex; flex-direction: column; gap: var(--s5); padding: var(--s5) var(--s6); }
.fl-empty h2 { margin: 0; font-size: 19px; }
.fl-empty > p { margin: 0; color: var(--fg-2); max-width: 56ch; }
.fl-forms { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--s4); }
.fl-form { display: flex; flex-direction: column; gap: 6px; margin: 0; }
.fl-form label { font-size: var(--fs-fine); font-weight: 600; color: var(--fg-2); }
.fl-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.fl-row input, .fl-link {
  flex: 1 1 10em; min-width: 0; font: inherit; padding: 9px 12px;
  border-radius: 10px; border: 1px solid var(--hair);
  background: var(--raised); color: var(--fg);
}
.fl-row input::placeholder { color: var(--muted); }
#fl-code, .fl-code { text-transform: uppercase; letter-spacing: .18em; font-variant-numeric: tabular-nums; }
/* Tokens and not literals (#2087): this half is drawn inside the League's
   place, which re-tints for the hour, and a hard-coded cream panel with hard
   -coded ink on it was two dark boxes with invisible text in dark mode —
   *"echt schlecht, man sieht gar nix in den Textboxen."* */
.fl-head {
  display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s4); flex-wrap: wrap;
  padding: var(--s4) var(--s5); border-radius: var(--r-panel);
  background: color-mix(in srgb, var(--card) 86%, transparent); color: var(--fg);
  -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
  box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 8%, transparent);
}
.fl-head h2 { margin: 0; font-size: 21px; }
.fl-head .fine { margin: 4px 0 0; color: var(--fg-2); }
/* #2487: the founder's rename, a chip that opens a field in place. */
.fl-rename { margin-top: 6px; }
.fl-rename > summary { list-style: none; cursor: pointer; display: inline-block; }
.fl-rename > summary::-webkit-details-marker { display: none; }
.fl-rename form { margin-top: 8px; }
.fl-invite { position: relative; }
.fl-invite > summary { list-style: none; cursor: pointer; }
.fl-invite > summary::-webkit-details-marker { display: none; }
.fl-invite-in {
  position: absolute; right: 0; top: calc(100% + 8px); z-index: 5; width: min(360px, 86vw);
  display: flex; flex-direction: column; gap: 10px; padding: var(--s4);
  border-radius: var(--r-card); background: var(--card); color: var(--fg);
  box-shadow: 0 18px 40px -18px rgba(0, 0, 0, .45), 0 0 0 1px color-mix(in srgb, var(--fg) 10%, transparent);
}
.fl-invite-in .fine { margin: 0; }
.fl-code { margin: 0; font-family: 'Fraunces', Georgia, serif; font-size: 30px; font-weight: 600; text-align: center; }
.fl-invite-in .fl-link { flex: none; font-size: var(--fs-fine); }
.fl-notice, .fl-error { margin: 0; padding: 8px 12px; border-radius: 10px; font-weight: 500; }
.fl-notice { background: color-mix(in srgb, var(--growth-4) 18%, var(--card)); color: var(--fg); }
.fl-error { background: color-mix(in srgb, var(--seal) 14%, var(--card)); color: var(--seal-text); }
.fl-pick { flex-wrap: wrap; }
.fl-more > summary { list-style: none; cursor: pointer; display: inline-flex; }
.fl-more > summary::-webkit-details-marker { display: none; }
.fl-more[open] .fl-forms { margin-top: var(--s3); padding: var(--s4); border-radius: var(--r-card);
  background: color-mix(in srgb, var(--card) 88%, transparent); color: var(--fg);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }
.fl-leave { margin: 0; text-align: center; }
/* ── INSIDE THE LEAGUE, THE PLACE OWNS THE PALETTE ──────────────────────
   A pane here has TWO palettes pulling at it: the reader's theme (`--card`
   from tokens.css) and the hour (`body.at-league … #view` re-declares `--fg`
   for the sky behind it). Take the card colour from one and the ink from the
   other and you get dark-on-dark at noon in dark mode, which is what #2087
   photographed. `.lg-card` settled this long ago by declaring BOTH for itself,
   by hour, and every surface in the friends half now does the same. */
body.at-league .fl-head,
body.at-league .lg-status,
body.at-league .fl-card,
body.at-league .fl-invite-in,
body.at-league .fl-more[open] .fl-forms {
  --fg: #1C1E18; --fg-2: #4E5046; --muted: #6A6C62;
  --hair: rgba(28, 30, 24, .12); --card: rgb(244, 241, 233); --raised: rgb(252, 250, 245);
  color: var(--fg);
  background: rgba(244, 241, 233, .86);
}
body[data-phase="night"].at-league .fl-head,
body[data-phase="night"].at-league .lg-status,
body[data-phase="dusk"].at-league .lg-status,
body[data-phase="night"].at-league .fl-card,
body[data-phase="night"].at-league .fl-invite-in,
body[data-phase="night"].at-league .fl-more[open] .fl-forms,
body[data-phase="dusk"].at-league .fl-head,
body[data-phase="dusk"].at-league .fl-card,
body[data-phase="dusk"].at-league .fl-invite-in,
body[data-phase="dusk"].at-league .fl-more[open] .fl-forms {
  --fg: #F1EEE6; --fg-2: #D6D2C6; --muted: #A9A597;
  --hair: rgba(241, 238, 230, .12); --card: rgb(20, 22, 38); --raised: rgb(30, 32, 50);
  background: rgba(20, 22, 38, .9);
}
/* The invite popover is not translucent: it is over the board, not the sky. */
body.at-league .fl-invite-in { background: var(--card); }
/* The title over the sheet follows the hour like the League's own head. */
body.at-league .fl-title h1, body.at-league .fl-title .fine { text-shadow: 0 0 6px var(--halo), 0 0 2px var(--halo); }
/* The band carries the League's shadow as well as the friends' ink. */
body.at-league .lg-status { box-shadow: var(--sink), 0 0 0 1px color-mix(in srgb, var(--fg) 8%, transparent); }

/* ── THE FOOT: ANOTHER LEAGUE, AND LEAVING (#2144) ─────────────────────────
   "Another league" was a bare `.chip`, which takes `--raised` from the
   THEME and `--fg-2` from the HOUR: in dark mode at noon that is a dark
   lozenge with dark ink on it, the unreadable button in the owner's shot.
   It is made of the place's paper now, like the Global | Friends chips over
   it, which reads on every hour; and leaving gets the halo every other line
   on the sky has. Both on one row, the way out on the right. */
.fl-foot { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s4); }
.fl-foot .fl-more { flex: 1; min-width: 0; }
.fl-foot .fl-leave { flex: none; }
body.at-league .fl-more > summary.chip {
  background: rgba(244, 241, 233, .86); color: #1C1E18; text-shadow: none;
  box-shadow: 0 1px 2px rgba(28, 30, 24, .12);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
body.at-league .fl-more > summary.chip:hover { background: rgba(252, 250, 245, .95); }
body.at-league .fl-leave .btn-quiet { color: var(--fg-2); text-shadow: 0 0 6px var(--halo), 0 0 2px var(--halo); }
body.at-league .fl .lg-foot { margin: 0; color: var(--fg-2); }
body.at-league .fl-title .lg-when { text-shadow: 0 0 2px var(--halo), 0 0 5px var(--halo), 0 0 10px var(--halo); }

/* ══ 班 THE CLASS HALF (#2181) ═════════════════════════════════════════════
   The friends half's objects, so the class stands on the same paper at every
   hour: the head card, the tabs, the board. Three segments in the band are
   narrower than two were, or the band outgrows a laptop's sheet. The way to
   the classroom is a plain button on the head card, its 班 in the seal's
   red: the one mark the Profile, the band and this share for a class. */
.lg-scope-band:has(> :nth-child(3)) .lg-sc { min-width: 7.4em; }
.fl-pick > a.chip { display: inline-flex; align-items: center; }
.cl-room-g { font-family: var(--serif-hz); font-size: 16px; line-height: 1; color: var(--seal); }
.klass-board { margin: 8px 0 0; }
.klass-board .chip { display: inline-flex; align-items: center; gap: 6px; }

/* ══ 手机 THE PHONE GATE ═══════════════════════════════════════════════════
   *"Ich will, dass auf dem Handy die Webapp gesperrt ist … Logo + ein Satz …
   App-Store-Button und Play-Store-Button … optional ein kleiner Hinweis"*
   (owner, 2026-09-23).

   ── WHERE THE LINE IS, AND WHY IT IS NOT `max-width` ALONE ────────────────
   A phone is not a narrow window; it is a SMALL one, in whichever way it is
   held. An iPhone 16 Pro Max is 430 across in portrait and 932 across in
   landscape — the same phone, and a width rule lets the second one through
   into a screen laid out for a desk. So the gate asks about both sides:

     · under 720 wide      → every phone in portrait;
     · under 520 tall AND a coarse pointer → every phone in landscape, while a
                             short window on a mouse-driven machine is left
                             alone (a laptop with the dock and a browser
                             toolbar can be 600 tall and perfectly fine);
     · under 901 wide AND a fine pointer → a laptop window dragged narrower
                             than the desk layout itself. 901 is not a new
                             number: it is where `@media (min-width: 901px)`
                             turns the sheet, the rooms and the whole desk
                             layout ON, so below it this host was falling back
                             to a phone column nobody wants it to have. The
                             owner, 2026-09-23, with a screenshot of exactly
                             that: *"auch wenn es ein Laptop ist und man es zu
                             klein zieht den Browser, sollte die Sperre dann
                             aufkommen."*

   The pointer is what keeps the two apart. An iPad mini is 744 in portrait and
   1133 in landscape and reports a COARSE pointer, so it passes 901 without
   being asked — which is the promise the sentence on the gate makes.

   ── AND IT HIDES EVERYTHING ELSE ─────────────────────────────────────────
   `body > *` rather than a list: the shell is a boot screen, a sign-in card, a
   bar, a courtyard, a view, five dialogs and a template, and a list of them is
   a list that goes stale the first time somebody adds a sixth dialog. */
/* ── AND NOTHING DRAGS SIDEWAYS ON THE WAY DOWN TO IPAD ──────────────────
   Measured at 744 (iPad mini, portrait): the document scrolled 109px to the
   right. Nothing on the page wants that — it is the growth chart's hover tips
   and a mark's label reaching past the edge, both decorations that are
   absolutely positioned against a box narrower than they are. `clip` and not
   `hidden`: hidden on the root makes it a scroll container, and this host has
   sticky rows and a fixed room inside it. */
html { overflow-x: clip; }

.phone-gate { display: none; }

/* `html.arriving` is a password reset, a magic link or a provider's return —
   index.html sets it before this file paints, and those have to finish on
   whatever device the mail was opened on. */
@media (max-width: 719px), (max-height: 519px) and (pointer: coarse), (max-width: 900px) and (pointer: fine) {
  html:not(.arriving) body > *:not(.phone-gate) { display: none !important; }
  html:not(.arriving), html:not(.arriving) body { overflow: hidden; height: 100%; }
  html:not(.arriving) .phone-gate {
    display: grid; place-items: center;
    min-height: 100dvh; padding: 28px 22px calc(28px + env(safe-area-inset-bottom));
    background: var(--bg); color: var(--fg);
  }
}

.pg-in {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: 14px; max-width: 30rem;
}
/* The seal at the size the boot screen draws it: this is the same arrival. */
.phone-gate .chop { width: 68px; height: 68px; border-radius: 18px; font-size: 21px; }
.pg-brand {
  margin: 2px 0 0; font-family: 'Fraunces', Georgia, serif;
  font-size: 30px; font-weight: 600; letter-spacing: .01em;
}
.pg-lead { margin: 0; font-size: 18px; line-height: 1.45; max-width: 24ch; }
.pg-stores { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 6px; }
/* The marketing site's badge, in this host's tokens: a dark pill with the mark
   on the left and two lines beside it (apps/web/site.css, `.store`). */
.pg-store {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 10px 16px; border-radius: 14px;
  background: var(--fg); color: var(--bg); text-decoration: none;
  box-shadow: var(--panel);
}
.pg-store svg { width: 22px; height: 22px; flex: none; }
.pg-store span { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.1; }
.pg-store small { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; opacity: .78; }
.pg-store b { font-size: 15px; font-weight: 600; }
/* Not a link and not pretending to be one, for as long as there is no build. */
.pg-store.is-soon {
  background: color-mix(in srgb, var(--fg) 12%, var(--card));
  color: var(--fg-2); box-shadow: inset 0 0 0 1px var(--hair);
}
.pg-hint { margin: 8px 0 0; font-size: var(--fs-fine); color: var(--muted); max-width: 30ch; }

/* ══ 平板 THE DESK ON AN IPAD ═══════════════════════════════════════════════
   744 to 999: the desk layout, in a room that has no width to spare. The
   owner, 2026-09-23, with a screenshot of this band: *"design ist noch nicht
   ganz responsive."*

   WHAT GIVES WAY IS THE ARCHITECTURE, NOT THE SCREEN. Every place hangs its
   room outside the sheet — the Progress wall's lattice at -148px, the reading
   room's two walls of books at 164 each — and those offsets are gutters that
   exist at 1280 and do not exist at 744, so the room came inside and lay over
   the page. A room that cannot stand beside the paper is not drawn; the paper
   is the screen and always was. The camp, the League and a round need none of
   this: they were measured at 744 and left alone.

   The two-column screens fold to one for the same reason a phone would: at
   372px a column, the Progress panels were four words wide. */
@media (min-width: 744px) and (max-width: 999px) {
  /* `--room-x` is the gutter each room reserves for its own walls — 224 in the
     reading room, 148 on the Progress wall. Hiding the walls without giving
     that back is a page with 448px of nothing around 296px of Library, which
     is what the first attempt at this looked like. */
  /* The Progress room: no lattice walls, no lamp, one column. */
  body.at-stats .screen::before,
  body.at-stats .screen::after,
  body.at-stats .room-lamp,
  body.at-stats .room-floor { display: none; }
  body.at-stats:not(.in-court):not(.in-intro) #view {
    --room-x: var(--s5); --room-b: var(--s6);
  }
  body.at-stats .screen { grid-template-columns: minmax(0, 1fr); }
  /* One column is not enough on its own: six panels are PLACED in column 2 by
     the wide layout, and a placement past the last explicit column makes the
     browser grow an implicit one — which is how the first attempt came out as
     92px beside 580px. They come back into the flow, in the order the wide
     room reads them. */
  body.at-stats .pg-hero, body.at-stats .gw-block,
  body.at-stats .sk-block, body.at-stats .id-block,
  body.at-stats .bl-block, body.at-stats .tn-block {
    grid-column: 1 / -1; grid-row: auto;
  }
  /* The reading room: no bookcases and no ivy, so the cells get the width. */
  body.at-library .lib-wall,
  body.at-library .lib-ivy,
  body.at-library .lib-lantern,
  body.at-library .room-floor { display: none; }
  body.at-library:not(.in-court):not(.in-intro) #view:has(.lib-wall) {
    --room-x: var(--s5); --room-b: var(--s6);
  }
  /* The class and the camp keep their ground; the map's parchment clears the
     bar, which floats over it and was landing on the greeting. */
  body.in-court #view { padding-top: calc(50px + env(safe-area-inset-top)); }
}

/* 级 The level chips name the level (#2160). The word is smaller than the
   digit so nine chips and "All" still fit the rack's one row. */
.levels .level .lv-hsk {
  font-style: normal; font-size: .72em; font-weight: 600;
  letter-spacing: .04em; margin-right: .28em; opacity: .8;
}
/* With the word in, ten compartments at 14px of padding ran past the frame on
   the right at desk width; the rack holds them at 6px and a 4px gap. */
@media (min-width: 744px) {
  .levels-rack .level { padding-inline: 6px; gap: 4px; min-width: 0; }
}

/* ── 开 A RUNG OPENS WITH A BOOM (#2394) ────────────────────────────────────
   *"wie im Trainingscamp Grammatik auf einmal boom als Gebäude da erscheint und
   der Screen wackelt ein bisschen. Dann ein kleines Pop-Up."* The building
   that just opened drops onto its terrace, a ring of dust goes out from its
   foot, the camp shakes once as it lands, and one line rises under the gate
   and fades on its own. Played once per rung (views.js `cheer`); nothing asks
   to be dismissed and nothing is laid over the screen.

   The drop animates `scale` and `translate` as their own properties, so the
   building's `transform: translate(-50%, -100%)` anchor is left alone and the
   foot stays on the coordinate it was planned for. */
.camp-b--boom .camp-art {
  transform-origin: 50% 100%;
  animation: camp-boom 900ms var(--ease) both;
}
.camp-b--boom .camp-art::after {
  content: '';
  position: absolute; left: 50%; bottom: 0;
  width: 140%; aspect-ratio: 4 / 1;
  translate: -50% 40%;
  border-radius: 50%;
  border: calc(3 * var(--cu)) solid color-mix(in srgb, var(--seal) 55%, transparent);
  opacity: 0;
  animation: camp-dust 900ms 420ms ease-out both;
  pointer-events: none;
}
.camp-b--boom .camp-below { animation: camp-below-in 500ms 520ms var(--ease) both; }
.camp--shake { animation: camp-shake 520ms 380ms linear both; }

@keyframes camp-boom {
  0%   { scale: .2; translate: 0 -60%; opacity: 0; }
  45%  { scale: 1.08; translate: 0 0; opacity: 1; }
  62%  { scale: .96 1.04; }
  80%  { scale: 1.02 .98; }
  100% { scale: 1; translate: 0 0; opacity: 1; }
}
@keyframes camp-dust {
  0%   { opacity: .9; scale: .4; }
  100% { opacity: 0; scale: 1.25; }
}
@keyframes camp-below-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes camp-shake {
  0%, 100% { translate: 0 0; }
  15% { translate: -6px 2px; }
  30% { translate: 5px -2px; }
  45% { translate: -4px 1px; }
  60% { translate: 3px -1px; }
  80% { translate: -1px 0; }
}

/* The line. Under the gate, over the camp, centred: a `status`, read once by
   a screen reader, gone after four seconds or on a click. Placed in the camp
   screen's own box rather than fixed to the window: the panel it sits in is a
   containing block of its own, and a fixed line inside it was drawn nowhere. */
.camp-screen { position: relative; }
.camp-cheer {
  position: absolute; left: 50%; top: 34%;
  translate: -50% 0;
  z-index: 40;
  display: grid; justify-items: center; gap: 2px;
  padding: 12px 22px 13px;
  border-radius: var(--r-pill);
  background: var(--seal); color: #fff;
  box-shadow: var(--lift);
  text-align: center;
  cursor: pointer;
  animation: camp-cheer 4200ms 700ms var(--ease) both;
}
.camp-cheer b { font-family: var(--serif, inherit); font-size: var(--t-h3); font-weight: 600; }
.camp-cheer-k {
  font-size: var(--t-over); font-weight: 700; letter-spacing: .08em;
  text-transform: uppercase; opacity: .82;
}
@keyframes camp-cheer {
  0%   { opacity: 0; translate: -50% 14px; scale: .9; }
  10%  { opacity: 1; translate: -50% 0; scale: 1.04; }
  16%  { scale: 1; }
  86%  { opacity: 1; translate: -50% 0; }
  100% { opacity: 0; translate: -50% -8px; visibility: hidden; }
}

@media (prefers-reduced-motion: reduce) {
  /* No drop and no shake: the building is simply there, and the line fades in
     and out in place. */
  .camp-b--boom .camp-art, .camp-b--boom .camp-below, .camp--shake { animation: none; }
  .camp-b--boom .camp-art::after { display: none; }
  /* The global rule above squeezes every animation to a millisecond, which
     would land this one on its last, invisible frame at once. So it has none:
     it stands still, and learn.js takes it away after the same four seconds. */
  .camp-cheer { animation: none; }
}

/* The limits sheet's ✕ (#2395), after the camp's own pane rules so the head's
   room for it survives `body.at-training .deck-limits .dl-head { padding: 0 }`. */
body.at-training .deck-limits .dl-head { padding-left: 44px; }
body.at-training .deck-limits .dl-close { top: var(--s5); left: var(--s5); }

/* ── 导 THE WALK THROUGH THE MAP (#2341, #2396) ─────────────────────────────
   tour.js. The spot's own shadow is the dim, so the lit place is a hole in it
   with the seal's ring round the edge; the transparent layer under it takes
   every click that is not the card's, so the map does not move while the tour
   is talking about it. */
.tour { position: fixed; inset: 0; z-index: 60; }
.tour-dim { position: absolute; inset: 0; }
.tour-spot {
  position: fixed;
  border-radius: 18px;
  box-shadow: 0 0 0 2.5px var(--seal), 0 0 0 9999px rgba(12, 11, 8, .58);
  pointer-events: none;
  transition: left var(--slow) var(--ease), top var(--slow) var(--ease),
              width var(--slow) var(--ease), height var(--slow) var(--ease);
}
.tour-card {
  position: fixed;
  width: min(340px, calc(100vw - 24px));
  padding: 18px 18px 16px;
  border-radius: var(--r-panel);
  background: var(--card); color: var(--fg);
  box-shadow: var(--lift), 0 24px 60px -30px rgba(0, 0, 0, .6);
  transition: left var(--slow) var(--ease), top var(--slow) var(--ease);
  animation: tour-in var(--base) var(--ease) both;
}
.tour-card h2 { margin: 2px 0 6px; font-family: var(--serif); font-size: var(--t-h2); }
.tour-n { margin: 0; font-size: var(--t-cap); font-weight: 600; letter-spacing: .04em; color: var(--muted); }
.tour-line { margin: 0; font-size: var(--t-body); line-height: 1.45; color: var(--fg-2); }
.tour-acts { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; }
.tour-acts .btn { margin-left: auto; }
.tour-skip {
  border: 0; background: none; padding: 6px 2px; cursor: pointer;
  font: inherit; font-size: var(--t-label); color: var(--fg-2);
}
.tour-skip:hover, .tour-skip:focus-visible { color: var(--fg); text-decoration: underline; }
@keyframes tour-in { from { opacity: 0; translate: 0 8px; } to { opacity: 1; translate: 0 0; } }
/* Between two pages (the map and the profile, #2444, #2445) the dim stays and
   the spot and the card wait for the next page to draw. */
.tour-moving .tour-spot, .tour-moving .tour-card { visibility: hidden; }
@media (prefers-reduced-motion: reduce) {
  .tour-spot, .tour-card { transition: none; animation: none; }
}
