/* ─────────────────────────────────────────────────────────────────────────────
   The Teaching Platform — an application frame, not a document.

   ── WHAT CHANGED, AND WHY IT IS NOT THE SITE ────────────────────────────────
   The first version of this file borrowed site.css's row/rail grid: a narrow
   rail carrying a section number, a wide column carrying the content, hairlines
   between. That is a beautiful way to read a page top to bottom once, and it is
   the wrong shape for a tool. A teacher opens this between two lessons already
   knowing what they came for; the rail spent 190px of every row on a number
   they never read, and the one-column stack meant the answer to "who has
   stopped" was two scrolls below the answer to "how is the class doing".

   So: a fixed sidebar, a dense card grid, and everything that matters above the
   fold. The references are Mixpanel and Gorgias for the shell, 7shifts for the
   superlatives row, 15Five for the profile header, Duolingo for the stat tiles.

   WHAT DOES NOT CHANGE is the palette. tokens.css is shared with the marketing
   site and `docs/brand.md` tracks it across five consumers now — no hex is
   declared in this file. The avoid-list still applies in full: no neon, no
   confetti, no mascot, no gradients doing work a hairline could do.

   ── THE ONE STRUCTURAL IDEA ─────────────────────────────────────────────────
   EVERY SCREEN IS ABOUT ONE LESSON. The class is on lesson 7; the readiness
   ring, the group's weak items, each learner's row and each learner's page all
   answer "…for lesson 7". That is why `--lesson-bar` is a real element in the
   frame rather than a filter tucked into a corner: it is the thing the whole
   surface is conditioned on, and a reader who cannot see it cannot read
   anything else correctly.
   ──────────────────────────────────────────────────────────────────────────── */

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
  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, not instead of it: Fraunces has no CJK
   glyphs, so a Chinese heading falls straight past it — and what it fell to
   was whatever the machine happened to have. The face is already downloaded on
   this page (the chop is set in it), so naming it here costs nothing and is
   what makes 登录 the same serif as 故事 above it. */
h1, h2, h3 { font-family: 'Fraunces', Georgia, 'Noto Serif SC', serif; font-weight: 600; margin: 0; line-height: 1.15; }
h1 { font-size: clamp(24px, 2.2vw, 32px); letter-spacing: -.01em; }
h2 { font-size: 17px; }
h3 { font-size: 15px; }
p { margin: 0; }
a { color: inherit; }
.hz { font-family: 'Noto Serif SC', 'Songti SC', serif; }
[hidden] { display: none !important; }
.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;
}
/* Figures in a column that do not line up are decoration. */
.num, td, th, .kpi-n, .stat-n { font-variant-numeric: tabular-nums; }

:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; border-radius: 4px; }
/* #view is focused after every route change so a keyboard user lands at the top
   of what they navigated to. It carries tabindex="-1" and is unreachable by
   Tab, so the ring can never be the only thing telling somebody where they are. */
#view:focus, #view:focus-visible { outline: none; }

/* ── The frame ───────────────────────────────────────────────────────────── */

/* 264px, up from 232 on 2026-08-29. Not taste: the school and the teacher sit
   in ONE row at the foot now, and two names with a picture each need about
   200px between them. At 232 both truncated to "Sprachschul…" and "Frau W…",
   which is a row that has stopped saying anything. Measured against the widest
   real pair we have — "Sprachschule PaJo" and "Herr Kirner". */
/* `--top-offset` is 0 except under `?fixtures`, where the banner is fixed and
   platform.js sets it to the banner's measured height. Everything that is
   viewport-tall reads it, so the frame is the same height either way. */
.app {
  display: grid; grid-template-columns: 264px 1fr;
  min-height: calc(100dvh - var(--top-offset, 0px));
  margin-top: var(--top-offset, 0px);
}

.side {
  background: var(--card);
  border-right: 1px solid var(--hair);
  padding: 16px 12px;
  display: flex; flex-direction: column; gap: 18px;
  position: sticky; top: var(--top-offset, 0px); height: calc(100dvh - var(--top-offset, 0px));
}
/* The mark on the left, the language on the right, on the sidebar's first
   line — `space-between` rather than a margin, so the switch stays pinned to
   the edge whether the wordmark is showing or, on a phone, is not. */
.side-top { display: flex; align-items: center; gap: 10px; justify-content: space-between; padding: 4px 8px 0; }
.side-brand { display: flex; align-items: center; gap: 10px; text-decoration: none; }
.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;
}
.side-brand .brand { font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-size: 16px; }

/* The class switcher. A `select` and not a bespoke menu: it is the one control
   that must work identically on a phone, and the platform ships no menu
   primitive it could reuse. */
.class-pick {
  width: 100%; padding: 9px 11px; border-radius: 10px; border: 1px solid var(--hair);
  background: var(--bg-sunken); color: var(--fg); font: inherit; font-weight: 500;
}

.side nav { display: flex; flex-direction: column; gap: 2px; }
.side nav a {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 11px; border-radius: 9px; text-decoration: none;
  color: var(--fg-2); font-weight: 500;
}
.side nav a:hover { background: var(--bg-sunken); color: var(--fg); }
.side nav a[aria-current] { background: var(--bg-sunken); color: var(--fg); }
.side nav .ic { width: 16px; text-align: center; opacity: .75; font-size: 13px; }

/* ── The class, unfolded in the sidebar ────────────────────────────────────
   The row keeps its whole width as a link — the chevron is laid over its right
   end rather than taking a column, so "Schüler" still reads as one target and
   the arrow is the exception you have to aim for. Which is the right way round:
   nine times out of ten the row is being clicked to go somewhere. */
.nav-row { position: relative; display: flex; }
.nav-row > a { flex: 1 1 auto; padding-right: 34px; }
.nav-toggle {
  position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
  border: 0; background: transparent; padding: 5px; border-radius: 7px;
  color: var(--muted); cursor: pointer; display: flex;
}
.nav-toggle svg { width: 14px; height: 14px; fill: none; stroke: currentColor;
  stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
  transition: transform .16s ease; }
.nav-toggle[aria-expanded="true"] svg { transform: rotate(90deg); }
.nav-toggle:hover { background: var(--hair); color: var(--fg); }
.nav-kids {
  display: flex; flex-direction: column; gap: 1px; margin: 2px 0 4px;
  /* A class of twenty would push the invite button and the sign-out off the
     bottom of a laptop screen. The list scrolls; the furniture stays put. */
  max-height: min(46vh, 420px); overflow-y: auto;
}
/* Indented to the depth of the parent's own label, so the two columns of text
   line up and the list reads as belonging to the row above it. */
.side nav .nav-kids a { padding: 5px 10px 5px 12px; gap: 9px; font-weight: 400; font-size: 13px; }
.nav-kids a span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-none { padding: 4px 12px; margin: 0; }
/* ── WHO COUNTS TODAY ─────────────────────────────────────────────────────
   A menu of faces, not a `<select multiple>`: the rows carry avatars, and a
   native multi-select loses your ticks the moment the pointer slips. */
.who-pick { position: relative; }
.who-pick > summary {
  display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
  list-style: none; padding: 7px 12px; border-radius: 10px;
  border: 1px solid var(--hair); background: var(--card);
  font-size: var(--fs-fine); font-weight: 500; color: var(--fg-2); white-space: nowrap;
}
.who-pick > summary::-webkit-details-marker { display: none; }
.who-pick > summary:hover { color: var(--fg); }
.who-pick > summary svg { width: 13px; height: 13px; fill: none; stroke: currentColor;
  stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.who-pick[open] > summary svg { transform: rotate(180deg); }
.who-pick[data-on] > summary {
  border-color: color-mix(in srgb, var(--amber) 60%, transparent);
  background: color-mix(in srgb, var(--amber) 12%, var(--card)); color: var(--fg);
}
.who-menu {
  position: absolute; right: 0; top: calc(100% + 6px); z-index: 20;
  min-width: 240px; max-height: 60vh; overflow-y: auto;
  background: var(--card); border: 1px solid var(--hair); border-radius: 12px;
  box-shadow: var(--shadow); padding: 8px;
}
.who-acts { display: flex; gap: 6px; padding: 2px 4px 8px; }
.who-row {
  display: flex; align-items: center; gap: 9px; padding: 7px 8px;
  border-radius: 8px; cursor: pointer; font-size: 13px;
}
.who-row:hover { background: var(--bg-sunken); }
.who-row input { accent-color: var(--seal); width: 15px; height: 15px; }
.who-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.side-label {
  padding: 0 11px; font-size: var(--fs-fine); color: var(--muted);
  letter-spacing: .06em; text-transform: uppercase; font-weight: 600;
}
.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 10px; padding: 0 4px; }

/* The school and the person, ONE PER LINE.
   
   ── WHY THEY ARE NOT SIDE BY SIDE ANY MORE ──────────────────────────────────
   They were, with a hairline between them, and the note that used to stand here
   explained that the two halves wrap rather than truncate — "Sprachschul…"
   beside "Frau W…" is a row that has stopped saying anything. That was right
   about truncation and wrong about the layout: half a 190px sidebar is 90px, so
   BOTH halves wrapped, nearly always, and "Sprachschule PaJo" broke after
   "Sprachschule" with the teacher's name floating beside the first line of it.
   
   Stacked, each name gets the whole width — which is enough for almost every
   real one — so the wrap the old comment defended stops happening at all. The
   hairline moved to the top of the pair, where it separates them from the
   buttons instead of from each other. */
.side-id {
  display: flex; flex-direction: column; gap: 7px; min-width: 0;
  padding-top: 11px; border-top: 1px solid var(--hair);
}
.side-id > * { min-width: 0; }

.side-you { display: flex; align-items: center; gap: 8px; font-size: var(--fs-fine); color: var(--muted); min-width: 0; }
.side-you span { overflow-wrap: break-word; }
.side-you .avatar { flex: 0 0 auto; }

/* Settings and sign out, hairlined apart. Plain text rather than buttons: two
   more controls in .btn chrome at the foot of a sidebar is more furniture than
   the sidebar has room for. The language switch is its own line below — see the
   note in pages/index.html. */
.side-links { display: flex; align-items: center; font-size: var(--fs-fine); }
.side-links a, .side-links button {
  color: var(--muted); text-decoration: none; background: none; border: 0;
  padding: 0; font: inherit; cursor: pointer; white-space: nowrap;
}
/* `> a, > button` and not `> * + *`, which is what this was and which never
   drew a line: the reset above is 0,1,1 and a universal child selector is
   0,1,0, so `border: 0` won on SPECIFICITY — moving it earlier in the file
   changed nothing. The first child is the language switch and gets no divider,
   which is the same thing `+ *` would have said. */
.side-links > a, .side-links > button {
  border-left: 1px solid var(--hair); padding-left: 9px; margin-left: 9px;
}
/* The first one has nothing to be separated FROM. It never did — the rule above
   has always hit every child — but the first child used to be the language
   switch, a <span>, which this selector does not match. Moving that out from
   under it left a hairline hanging in front of "Einstellungen" with nine pixels
   of indent behind it. */
.side-links > :first-child { border-left: 0; padding-left: 0; margin-left: 0; }
.side-links a:hover, .side-links button:hover { color: var(--fg); }
.side-links a[aria-current] { color: var(--fg); font-weight: 600; }

.lang { display: inline-flex; gap: 6px; align-items: center; font-size: var(--fs-fine); color: var(--muted); white-space: nowrap; }

.lang a { color: var(--muted); text-decoration: none; }
.lang a[aria-current] { color: var(--fg); font-weight: 600; }
.lang i { opacity: .4; font-style: normal; }

.main { min-width: 0; display: flex; flex-direction: column; }

/* ── The lesson bar: what every number on the page is about ─────────────── */
.topbar {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  padding: 12px 24px;
  background: color-mix(in srgb, var(--bg-sunken) 90%, transparent);
  backdrop-filter: saturate(1.3) blur(8px);
  border-bottom: 1px solid var(--hair);
}
.crumbs { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: var(--fs-fine); min-width: 0; }
.crumbs a { color: var(--fg-2); text-decoration: none; }
.crumbs a:hover { color: var(--fg); text-decoration: underline; }
.topbar .spacer { flex: 1 1 auto; }
.lesson-bar { display: flex; align-items: center; gap: 8px; }
.lesson-bar label { font-size: var(--fs-fine); color: var(--muted); font-weight: 500; white-space: nowrap; }
.lesson-bar select {
  padding: 7px 10px; border-radius: 9px; border: 1px solid var(--hair);
  background: var(--card); color: var(--fg); font: inherit; font-weight: 500;
}

.wrap { padding: 22px 24px 48px; display: flex; flex-direction: column; gap: 18px; }
/* TOP, not `flex-end`. The title carries a second line under it and the
   controls on the right do not, so bottom alignment pushed the teacher strip
   and the invite button down to sit level with "HSK 3 · 2 Schüler" — a row of
   things that belong to the heading, hanging off the subtitle instead. */
.head { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
.head h1 { margin-top: 0; }
/* The right-hand cluster keeps its own centre line so the avatars, the name
   and the button agree with each other rather than each finding its own top. */
.head .who-teaches, .head > .btn, .head > .spacer + .btn { align-self: center; }
.head .sub { color: var(--muted); font-size: var(--fs-fine); }
.head .spacer { flex: 1 1 auto; }

/* Who teaches this class, in the corner of its overview. Wraps to a second row
   before it squeezes the invite button — a class with four teachers is a real
   thing and the button beside it is the one people came to press. */
.who-teaches {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  font-size: var(--fs-fine); color: var(--fg-2); min-width: 0;
}
.who-teaches .one { display: flex; align-items: center; gap: 7px; min-width: 0; }
.who-teaches .one > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ── Cards ───────────────────────────────────────────────────────────────── */
.card {
  background: var(--card); border: 1px solid var(--hair);
  border-radius: 14px; padding: 16px;
}
.card > header {
  display: flex; align-items: baseline; gap: 10px;
  margin: -2px 0 14px;
}
.card > header .spacer { flex: 1 1 auto; }
.card > header .hint { color: var(--muted); font-size: var(--fs-fine); }

/* ── The info dot, and the panel behind it ──────────────────────────────────
   A quiet outline circle beside the card's title, not a filled badge: it is
   available rather than advertised, and a page with four of them must not
   look like a page with four warnings on it.

   The panel is the native popover, centred like a dialog rather than tethered
   to the dot. Anchor positioning would put it under the button, which is nicer
   and is still Chromium-only; a paragraph of running text reads better centred
   anyway, and there is nothing behind it worth keeping in view. */
.info-b {
  border: 0; background: transparent; padding: 0; margin-left: 7px; cursor: pointer;
  color: var(--muted); display: inline-flex; align-items: center; align-self: center;
  border-radius: 50%;
}
.info-b svg { width: 17px; height: 17px; 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 {
  border: 1px solid var(--hair); border-radius: 14px; background: var(--card);
  color: var(--fg); padding: 22px 24px 18px; max-width: min(560px, calc(100vw - 40px));
  box-shadow: var(--shadow); margin: auto;
  /* The lesson's panel now opens with a chart above four paragraphs, which on a
     laptop is taller than the window. A popover that overflows the viewport
     simply has its top cut off — there is no page behind it to scroll. */
  max-height: min(86vh, 780px); overflow-y: auto;
}
.info-p::backdrop { background: rgba(26, 24, 19, .22); }
.info-p h3 { font-family: 'Fraunces', Georgia, serif; font-size: 17px; font-weight: 600; margin: 0 0 10px; }
.info-p p { margin: 0 0 11px; font-size: 13.5px; line-height: 1.62; color: var(--fg-2); }
/* ── The forgetting curve inside the panel ────────────────────────────────
   Full width of the panel, above the prose: the reader arrived asking what
   green means, and the bands answer it before the first sentence does. */
.fc { display: block; width: 100%; height: auto; margin: 2px 0 16px; }
/* ── The skill filter over the curve ──────────────────────────────────────
   Chips rather than a radio group, because more than one can be on: "Zeichen
   erkennen und Zeichen schreiben" is a real question and a radio cannot ask
   it. The same shape as `.pill` so it reads as part of the same family. */
.curve-pick { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 14px; }
.curve-pick button {
  border: 1px solid var(--hair); background: var(--card); color: var(--fg-2);
  font: inherit; font-size: var(--fs-fine); font-weight: 500;
  padding: 5px 12px; border-radius: 999px; cursor: pointer;
}
.curve-pick button:hover { color: var(--fg); border-color: color-mix(in srgb, var(--fg) 22%, transparent); }
.curve-pick button[aria-checked="true"] {
  background: var(--bg-sunken); color: var(--fg); font-weight: 600;
  border-color: color-mix(in srgb, var(--fg) 18%, transparent);
}
/* ── A POINT THAT NAMES ITSELF ────────────────────────────────────────────
   The hanzi sits in the circle and the percentage beside it, so the reading
   that used to live on a line under the chart is on the point. Both are muted
   until the pointer arrives — forty percentages printed at once is a wall of
   numbers over a curve, and the one being pointed at is the one being asked
   about. */
.fc-pt { cursor: default; }
/* The hairline back to the curve, for a bubble that had to step aside. Without
   it a pill floating clear of the line reads as a value, and it is not. */
.fc-tie { stroke: color-mix(in srgb, var(--fg) 22%, transparent); stroke-width: 1.2; }
.fc-face { font-family: 'Noto Serif SC', serif; font-size: 17px; fill: #14200F;
  pointer-events: none; }
.fc-pt .fc-dot.is-poor ~ .fc-face { fill: #5A1A0E; }
.fc-pct {
  font-family: 'Inter', sans-serif; font-size: 14px; font-weight: 600;
  fill: var(--fg-2); opacity: 0; transition: opacity .12s ease; pointer-events: none;
}
.fc-pt:hover .fc-pct, .fc-pt:focus-visible .fc-pct { opacity: 1; }
.fc-pt:hover .fc-dot, .fc-pt:focus-visible .fc-dot { stroke: var(--fg); stroke-width: 2.5; }
.fc-pt:focus-visible { outline: none; }
/* ── THE POINTED-AT CIRCLE RIDES OVER ITS NEIGHBOURS ──────────────────────
   SVG has no z-index: paint order is document order, so a hovered circle
   halfway along the line stays half-buried under the next one however it is
   styled. `isolation` did nothing here and was the wrong tool. The right one
   is to move the element — `wireCurve` appends the hovered group to the end of
   the svg, which is the top of the stack, and that is the whole fix. */
.fc-pt.is-hot .fc-dot { stroke: var(--fg); stroke-width: 2.5; }
.fc-pt.is-hot .fc-pct { opacity: 1; }
.fc-band.is-good { fill: color-mix(in srgb, var(--growth-4) 20%, transparent); }
.fc-band.is-fair { fill: color-mix(in srgb, var(--amber) 20%, transparent); }
.fc-band.is-poor { fill: color-mix(in srgb, var(--seal) 12%, transparent); }
.fc-rule { stroke: color-mix(in srgb, var(--fg) 10%, transparent); stroke-width: 1; }
.fc-line { fill: none; stroke: var(--fg-2); stroke-width: 2.2; stroke-linecap: round; }
.fc-tick { font-family: 'Inter', sans-serif; font-size: 10px; fill: var(--muted); }
.fc-axis { font-family: 'Inter', sans-serif; font-size: 10px; fill: var(--muted); }
.fc-dot { stroke: var(--card); stroke-width: 1.5; }
.fc-dot.is-good { fill: var(--growth-4); }
.fc-dot.is-fair { fill: var(--amber); }
.fc-dot.is-poor { fill: var(--seal); }
.info-p .btn { margin-top: 6px; }
/* WITHOUT THE POPOVER API the panel is an ordinary div and would sit open in
   the middle of the card. The pages say what they say without it, so it is
   hidden outright rather than shown broken. */
@supports not selector(:popover-open) { .info-p { display: none; } }

/* Two counts climbing — see `growth()` in charts.js. */
.gr-line { fill: none; stroke-width: 3; stroke-linejoin: round; stroke-linecap: round; }
.gr-end { font-family: 'Inter', sans-serif; font-size: 19px; font-weight: 600; }
.gr-key { display: flex; gap: 16px; font-size: var(--fs-fine); color: var(--fg-2); }
.gr-key i { display: inline-block; width: 18px; height: 3px; border-radius: 2px;
  margin-right: 7px; vertical-align: middle; }
/* ── The timeline hangs off the tiles it explains ─────────────────────────
   One border round both, so the two counts and the line that produced them
   read as one object. It was a card of its own further down the page for a
   day; a chart filed away from its numbers is a chart nobody opens. */
.who .stats-6 { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
/* …unless two charts hang below as their own pair of boxes. */
.who:has(.who-pair) .stats-6 { border-radius: 12px; margin-bottom: 14px; }
.who-gr {
  border: 1px solid var(--hair); border-top: 0; border-radius: 0 0 12px 12px;
  background: var(--card); padding: 14px 16px 8px;
}
/* ── TWO FIXED ROWS, SO THE TWO CARDS ARE THE SAME HEIGHT ────────────────
   The head was one wrapping row, so whichever card had the longer legend broke
   onto a second line and grew — two charts side by side at visibly different
   sizes, reported as the diagram "spacking about". Caption and legend on row
   one, the reading on row two, always both, never wrapping differently. */
.gr-head {
  display: grid; grid-template-columns: auto 1fr auto auto; gap: 4px 12px;
  align-items: center; margin-bottom: 6px;
}
.gr-head .spacer { display: none; }
.gr-head .gr-read { grid-column: 1 / -1; grid-row: 2; }
.gr-cap { font-size: var(--fs-fine); color: var(--muted); font-weight: 500; }
/* The hovered day, in the head. Its own tabular figures so the two counts do
   not jitter sideways as the pointer crosses the line. */
.gr-read { font-size: var(--fs-fine); color: var(--fg); font-weight: 600;
  font-variant-numeric: tabular-nums; white-space: nowrap; }
.gr-read small { color: var(--muted); font-weight: 400; }
.who-gr .dayline { margin: 0; }
/* Nearly seven to one rather than the daily charts' five, so the viewBox is
   never the constraint at the width this strip actually takes — see
   `growth()`, which draws its own date labels inside that box. */
.who-gr .dayline-svg { max-height: 420px; }
.gr-tick { font-family: 'Inter', sans-serif; font-size: 17px; fill: var(--muted); }

/* `align-items: start` and not the default `stretch`: a card with two rows of
   content beside a card with ten should be two rows tall, not ten with eight
   rows of nothing under it. Grid stretching is the right default for equal
   siblings and the wrong one for a dashboard, where every card holds a
   different amount. */
.grid { display: grid; gap: 14px; align-items: start; }
.g-4 { grid-template-columns: repeat(4, 1fr); }
.g-3 { grid-template-columns: repeat(3, 1fr); }
.g-2 { grid-template-columns: repeat(2, 1fr); }
.g-hero { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); }
@media (max-width: 1180px) { .g-hero, .g-3 { grid-template-columns: 1fr; } .g-4 { grid-template-columns: repeat(2, 1fr); } }
/* THREE TILES ARE NOT THREE OF ANYTHING ELSE. `.g-3` drops to one column at
   1180 because three columns of prose or of settings are unreadable that
   narrow — but the metric tiles are three charts, and stacked they are a
   screen and a half of scrolling for what is meant to be one glance. Two up
   with the third across the bottom keeps them comparable. */
@media (max-width: 1180px) and (min-width: 721px) {
  .grid.g-3[data-kpis] { grid-template-columns: repeat(2, 1fr); }
  .grid.g-3[data-kpis] > :nth-child(3) { grid-column: span 2; }
}
@media (max-width: 720px)  { .g-4, .g-2 { grid-template-columns: 1fr; } }

/* ── KPI tiles ───────────────────────────────────────────────────────────── */
.kpi { display: flex; flex-direction: column; gap: 4px; }
.kpi-l { font-size: var(--fs-fine); color: var(--muted); font-weight: 500; }
.kpi-n { font-family: 'Fraunces', Georgia, serif; font-size: 30px; font-weight: 600; line-height: 1; }
.kpi-n small { font-size: .46em; color: var(--muted); font-family: 'Inter', sans-serif; font-weight: 500; }
.kpi-h { font-size: var(--fs-fine); color: var(--fg-2); }
.kpi-spark { margin-top: 6px; }

/* ── The readiness ring ──────────────────────────────────────────────────── */
/* One number, and it is the number the whole product is for: how ready is this
   room for the thing I am about to teach. A ring rather than a bar because it
   has to read as a single verdict at three metres, from the back of a desk. */
.ring-row { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
.ring { position: relative; width: 132px; height: 132px; flex: 0 0 auto; }
.ring svg { transform: rotate(-90deg); display: block; }
.ring-mid {
  position: absolute; inset: 0; display: grid; place-content: center; text-align: center;
}
.ring-mid b { font-family: 'Fraunces', Georgia, serif; font-size: 30px; font-weight: 600; display: block; line-height: 1; }
.ring-mid span { font-size: var(--fs-fine); color: var(--muted); }
.ring-legend { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.ring-legend div { display: flex; align-items: center; gap: 9px; font-size: var(--fs-fine); }
.ring-legend i { width: 9px; height: 9px; border-radius: 3px; flex: 0 0 auto; }
.ring-legend b { font-variant-numeric: tabular-nums; }

/* ── Learner rows ────────────────────────────────────────────────────────── */
.people { display: flex; flex-direction: column; }
.person {
  display: grid;
  grid-template-columns: 40px minmax(140px, 1.4fr) 96px minmax(120px, 1fr) 84px 74px 20px;
  align-items: center; gap: 14px;
  padding: 11px 8px; border-radius: 10px;
  text-decoration: none; color: inherit;
  border-top: 1px solid var(--hair);
}
.person:first-of-type { border-top: 0; }
.person:hover { background: var(--bg-sunken); }
.person-h {
  display: grid;
  grid-template-columns: 40px minmax(140px, 1.4fr) 96px minmax(120px, 1fr) 84px 74px 20px;
  gap: 14px; padding: 0 8px 8px;
  font-size: var(--fs-fine); color: var(--muted); font-weight: 500;
}
.person-name { font-weight: 500; min-width: 0; }
.person-name small { display: block; color: var(--muted); font-weight: 400; font-size: var(--fs-fine); }
.person .r { text-align: right; font-variant-numeric: tabular-nums; }
.person .chev { color: var(--muted); text-align: right; }
@media (max-width: 900px) {
  .person, .person-h { grid-template-columns: 36px 1fr 70px; }
  .person > :nth-child(n+4), .person-h > :nth-child(n+4) { display: none; }
}
/* A person with buttons beside them: the settings page's colleagues, requests
   and invitations. These were inline grids, and on a phone the two buttons
   took the row and left the name a column one word wide ("signed / up /
   yesterday") with an address cut off under a button (#2161). Below 560px the
   buttons move under the name, where they have the width. */
.person.person-act { grid-template-columns: 40px minmax(0, 1fr) auto; cursor: default; }
.person.person-act.sm { grid-template-columns: 32px minmax(0, 1fr) auto; }
.person.person-act.bare { grid-template-columns: minmax(0, 1fr) auto; }
.person.person-act .person-name { overflow-wrap: anywhere; }
@media (max-width: 560px) {
  .person.person-act, .person.person-act.sm { grid-template-columns: 32px minmax(0, 1fr); row-gap: 8px; }
  .person.person-act:not(.bare) > :nth-child(3) {
    grid-column: 2 / -1; justify-self: start; display: flex; flex-wrap: wrap; gap: 6px;
  }
}

.avatar {
  width: 40px; height: 40px; border-radius: 50%; object-fit: cover;
  background: var(--bg-sunken); display: grid; place-items: center;
  font-weight: 600; color: var(--fg-2); font-size: 14px; flex: 0 0 auto;
  border: 1px solid var(--hair);
}
.avatar.lg { width: 76px; height: 76px; font-size: 26px; border-radius: 20px; }
.avatar.sm { width: 26px; height: 26px; font-size: 11px; }

/* ── Pictures: the school's logo and the teacher's own face ────────────────
   A logo is not a face. It has a shape of its own, often wide, often with
   transparency, and cropping it to a circle would cut the name off — so it gets
   a box it fits INSIDE rather than a frame it fills. That is also why
   picture.js draws it `contain` and the avatar `cover`. */
/* ── THE SLOT IS THE INSTRUCTION ──────────────────────────────────────────
   It was 108×76, close enough to a photo frame that the first school to use it
   put a photograph of its building in. The picture had not been cropped —
   `contain` was already right — but the SHAPE was telling somebody what to
   bring, and it was saying "a picture" rather than "a mark".

   2.5:1 now, and the same proportion the invite sheet gives it, so a logo that
   fits one fits the other and a teacher sees on the settings page roughly what
   a student will see in the chat. A square mark still sits whole inside it;
   the empty width around one is the point rather than a flaw. */
.logo-box {
  width: 170px; height: 68px; border-radius: 12px;
  background: var(--bg-sunken); border: 1px solid var(--hair);
  display: grid; place-items: center; flex: 0 0 auto;
  /* `contain`, so a wide mark and a square one both sit whole in the same box
     and the row below does not shift height between two schools. */
  object-fit: contain; padding: 8px 12px;
  font-family: 'Fraunces', Georgia, serif; font-weight: 600;
  font-size: 26px; color: var(--fg-2);
}

/* ── An invitation ─────────────────────────────────────────────────────────
   The only card on this surface with a coloured edge, and it earns it: for a
   teacher who has just signed in with nothing granted, this is the difference
   between a working tool and an empty one. Everything else here is quiet on
   purpose, so one loud thing stays loud. */
.offer-card { border-color: color-mix(in srgb, var(--seal, #C8452E) 55%, var(--hair)); }
/* A whole seal-coloured edge since 2026-10-06, not a thick stripe down the
   left (an Impeccable audit: side stripes read as a template). */
.offer {
  display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
  border-top: 1px solid var(--hair); padding: 12px 0;
}
.offer > div:first-child { flex: 1 1 220px; }
.offer strong { font-weight: 500; }
.offer small { display: block; color: var(--fg-2); font-size: var(--fs-small); margin-top: 2px; }
/* Decline first in the DOM and on screen. "Yes" being the easier reach is how a
   consent screen becomes a formality. */
.offer-acts { display: flex; gap: 8px; }

.pic { display: flex; gap: 12px; align-items: flex-start; }
.pic-acts { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
/* The file input is `hidden` inside its label, so the label IS the button.
   Nothing here fights that — no pointer-events tricks, no absolute positioning. */
.pic-acts .btn { cursor: pointer; }
.pic-note { margin: 0; min-height: 1.2em; }

/* The school, at the foot of the sidebar with the teacher's own name and face.
   It was under the Gushi mark at the top; up there it read as a second brand,
   and a teacher said so. Which school you are in is a fact about YOU, so it
   sits with the other one. Height-capped and free in width, because a logo's
   aspect ratio is the one thing a stylesheet must not decide. */
/* #2156: the mark sits in the SAME 26px column as the teacher's face in the
   row under it, so the school's name and the person's name start on one
   vertical line, and the name wraps balanced (two even lines, never a lone
   "V."). A logo too wide for that column (`.wide`, decided from its natural
   size in `paintMe`) gets a line of its own above the name, at a height where
   its lettering can be read. */
.side-school {
  display: grid; grid-template-columns: 26px minmax(0, 1fr); align-items: center;
  column-gap: 8px; row-gap: 6px; min-width: 0;
  font-size: var(--fs-fine); color: var(--fg-2); line-height: 1.3;
}
.side-school img { width: 26px; height: 26px; object-fit: contain; }
.side-school.wide { grid-template-columns: minmax(0, 1fr); }
.side-school.wide img {
  width: auto; height: auto; max-width: 100%; max-height: 30px;
  object-fit: contain; object-position: left center; justify-self: start;
}
/* No logo: the school's initial on a rounded square, so the column is never
   empty and a school still does not read as a person (faces are round). */
.side-mark {
  width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center;
  background: var(--bg-sunken); border: 1px solid var(--hair);
  font-size: 11px; font-weight: 600; color: var(--fg-2);
}
/* `break-word`, not `anywhere`: the second lets the browser break inside a
   word when measuring min-content, so flex shrank this half to a few pixels
   and rendered "Sprachschul / e Mitte". This breaks at spaces and only goes
   inside a word when one word alone is too wide. */
.side-school span:last-child { overflow-wrap: break-word; text-wrap: balance; font-weight: 500; }

/* Progress bar inside a row: readiness for the current lesson. */
.bar { height: 7px; border-radius: 4px; background: var(--growth-0); overflow: hidden; display: flex; }
.bar i { display: block; height: 100%; }
.bar .met { background: var(--growth-4); }
.bar .part { background: var(--growth-2); }

.pill {
  display: inline-block; padding: 2px 8px; border-radius: 999px;
  background: var(--bg-sunken); border: 1px solid var(--hair);
  font-size: var(--fs-fine); color: var(--fg-2); font-weight: 500; white-space: nowrap;
}
.pill.warn { background: color-mix(in srgb, var(--amber) 16%, transparent); border-color: transparent; color: #6B4A08; }

/* "Admin", beside a name. Deliberately quiet: it marks one row in a list of
   colleagues, and a badge that shouts turns a working arrangement into a rank. */
.tag {
  display: inline-block; padding: 1px 7px; border-radius: 999px;
  background: color-mix(in srgb, var(--seal) 12%, transparent);
  color: var(--seal-text); font-size: var(--fs-fine); font-weight: 600;
  letter-spacing: .02em; vertical-align: 1px;
}

/* The foot of a card: what you can do to the whole thing. One line of text and
   the buttons on the right, hairlined off from the settings above.
   
   Nothing here is red until it is armed. A permanently red panel is one people
   stop seeing; `.btn-armed` is what marks the moment something is about to
   happen, and it is on the button that was pressed. */
.acts {
  display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
  margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--hair);
}
.acts .fine { margin: 0; }
.acts .spacer { flex: 1 1 auto; }
/* A disabled button still has to be readable — it is carrying a `title` that
   says why, and a control faded to the point of looking absent is one nobody
   hovers. `.btn:disabled` alone is .5. */
.acts .btn:disabled { opacity: .45; }
.pill.good { background: color-mix(in srgb, var(--growth-4) 18%, transparent); border-color: transparent; color: #2C5B33; }
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--amber); }

/* ── Superlatives (7shifts) ──────────────────────────────────────────────── */
.supers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
@media (max-width: 900px) { .supers { grid-template-columns: 1fr; } }
.super { display: flex; align-items: center; gap: 11px; }
.super .t { font-size: var(--fs-fine); color: var(--muted); }
.super .n { font-weight: 600; }
.super .v { font-size: var(--fs-fine); color: var(--fg-2); }

/* ── Item cards: the product's own content ───────────────────────────────── */
.gcards { display: flex; flex-wrap: wrap; gap: 9px; }
.gcard {
  margin: 0; padding: 10px 12px 9px; min-width: 84px; max-width: 158px;
  background: var(--bg-sunken); border: 1px solid var(--hair); border-radius: 11px;
  display: flex; flex-direction: column; gap: 1px; align-items: flex-start;
}
.gcard-h { font-family: 'Noto Serif SC', serif; font-size: 24px; line-height: 1.15; }
.gcard-p { font-size: var(--fs-fine); font-weight: 500; }
.gcard-m { font-size: var(--fs-fine); color: var(--muted); line-height: 1.35; }
.gcard-r { font-size: var(--fs-fine); color: var(--fg-2); font-variant-numeric: tabular-nums; margin-top: 2px; }
.gcard.tiny { min-width: 0; padding: 7px 9px; }
.gcard.tiny .gcard-h { font-size: 19px; }
.py1 { color: var(--t1); } .py2 { color: var(--t2); } .py3 { color: var(--t3); }
.py4 { color: var(--t4); } .py5 { color: var(--t5); }

/* ── Stat tiles on the learner page (Duolingo) ───────────────────────────── */
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--hair);
         border: 1px solid var(--hair); border-radius: 12px; overflow: hidden; }
@media (max-width: 720px) { .stats { grid-template-columns: repeat(2, 1fr); } }
.stat { background: var(--card); padding: 13px 14px; }
.stat-n { font-family: 'Fraunces', Georgia, serif; font-size: 24px; font-weight: 600; line-height: 1; }
.stat-l { font-size: var(--fs-fine); color: var(--muted); margin-top: 5px; }

/* ── The ramp ────────────────────────────────────────────────────────────── */
.s0 { background: var(--growth-0); }
.s1 { background: var(--growth-1); }
.s2 { background: var(--growth-2); }
.s3 { background: var(--growth-3); }
.s4 { background: var(--growth-4); }

/* ── A YEAR THAT SCROLLS, AND KNOWS ITS MONTHS ───────────────────────────
   Fifty-three columns squeezed into a card gives squares too small to aim at;
   at a readable size the year is wider than the page, so it scrolls. The rules
   mark where each month starts — the same job the Monday lines do on the daily
   charts, for the same reason: a shape with no ruler is not a measurement. */
.cal-wrap { overflow-x: auto; overflow-y: hidden; padding-bottom: 4px; }
.grid-cal { display: grid; grid-template-columns: repeat(var(--weeks), 16px);
  grid-auto-flow: column; gap: 3px; min-width: max-content; position: relative; }
.cal-month {
  border-left: 1px solid color-mix(in srgb, var(--fg) 13%, transparent);
  margin-left: -2px; pointer-events: none;
}
.cal-months {
  display: grid; grid-template-columns: repeat(var(--weeks), 16px); gap: 3px;
  min-width: max-content; margin-top: 6px;
  font-size: var(--fs-fine); color: var(--muted);
}
.cal-months span { white-space: nowrap; }
.grid-cal .cell { aspect-ratio: 1; border-radius: 2px; min-width: 0; }
.grid-cal .cell[data-ahead] { background: transparent; }
.legend { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: var(--fs-fine); margin-top: 9px; }
.legend .cell { width: 10px; height: 10px; border-radius: 2px; }

.stack { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--growth-0); }
.stack .seg { display: block; }
.seg-mastered { background: var(--growth-4); }
.seg-holding  { background: var(--growth-3); }
.seg-learning { background: var(--growth-1); }
.seg-slipping { background: var(--amber); }
.mrow { display: grid; grid-template-columns: 88px 1fr auto; gap: 12px; align-items: center; }
.mrow + .mrow { margin-top: 8px; }

/* ── The HSK levels, the way the app lists them ───────────────────────────
   A finished level is one line and the current one opens out by skill: HSK 1
   and 2 being done is a fact you read once, and saying it four times each is
   the wall the first draft of this was. */
.hsk-lv { padding: 12px 0; border-top: 1px solid var(--hair); }
.hsk-lv:first-of-type { border-top: 0; padding-top: 0; }
.hsk-h { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
.hsk-h b { font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600; }
.hsk-lv.is-here .hsk-h b { color: var(--seal-text); }
.hsk-lv .cnt small { color: var(--muted); }

/* ── The year grid answers on a point ─────────────────────────────────────
   Each square is a button when the caller asks for it. Same size, same colour;
   what changes is that it can be tapped, which is the whole of it on a phone
   where `title` shows nothing at all. */
.grid-cal.is-live .cell { border: 0; padding: 0; cursor: pointer; }
.grid-cal.is-live .cell:hover { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--fg) 40%, transparent); }
.grid-cal.is-live .cell:focus-visible { outline: 2px solid var(--seal); outline-offset: 1px; }
.cal-read { min-height: 1.3em; margin: 8px 0 0; }
/* A skill with nothing on the curve still draws its box, so the chip that
   selected it does not look broken for having been obeyed. */
.empty-line { margin: 0; padding: 46px 0; text-align: center; }
/* The axis caption under the curve, with the info dot that explains it. */
.curve-axis { display: flex; align-items: center; gap: 2px; margin: 10px 0 0; }

/* ── The to-do under the curve ────────────────────────────────────────────
   Three rows, worst first, and the order is the order of the work. The count
   leads because "eleven gone" is the number a teacher plans a lesson around;
   the characters follow so the plan can be made without opening anything. */
.todo { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--hair); }
.todo-h { font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600; margin: 0 0 10px; }
.todo-row {
  display: grid; grid-template-columns: 2.4em 1fr; gap: 4px 12px;
  align-items: baseline; padding: 9px 0;
}
.todo-row + .todo-row { border-top: 1px solid var(--hair); }
.todo-n { font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-weight: 600;
  font-variant-numeric: tabular-nums; }
.todo-row.is-poor .todo-n { color: var(--seal-text); }
.todo-row.is-fair .todo-n { color: #8A5D06; }
.todo-row.is-s0 .todo-n { color: var(--muted); }
.todo-l { font-size: var(--fs-fine); color: var(--fg-2); }
.todo-items { grid-column: 2; display: flex; flex-wrap: wrap; align-items: flex-start; }
/* Each item carries the deck it came from — 天 gone by ear and 天 gone by hand
   are two different ten minutes, and a merged list without the label is a pile. */
/* Each item is as wide as its label needs, not as wide as its square: "Lesen"
   and "Schreiben" ran into each other at 34px and read as one word. */
.todo-items { gap: 14px 12px; }
.todo-one { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 34px; }
.todo-one small { font-size: 11px; color: var(--muted); white-space: nowrap; padding: 0 2px; }
.todo-one > button {
  min-width: 34px; height: 34px; padding: 0 8px; border-radius: 7px; border: 0; cursor: pointer;
  font-family: 'Noto Serif SC', serif; font-size: 18px; display: grid; place-items: center;
  background: var(--growth-0); color: color-mix(in srgb, var(--paper-fg) 72%, transparent);
}
.todo-one > button.poor { background: color-mix(in srgb, var(--seal) 22%, var(--card));
  color: var(--seal-text); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 38%, transparent); }
.todo-one > button.fair { background: color-mix(in srgb, var(--amber) 42%, var(--card)); color: #5A3D06; }
.todo-more { align-self: center; margin-left: 4px; }

/* ── The Dauerbrenner, ranked ─────────────────────────────────────────────
   A list rather than a grid of cards: the question is "which is the worst",
   and a ranking answers it by its own shape — number, item, and a bar whose
   length is the count against the leader. */
.rank-list { list-style: none; margin: 0; padding: 0; }
.rank-row {
  display: grid; grid-template-columns: 26px 1.8em auto 5.5em 1fr 8em 6.5em;
  gap: 14px; align-items: center; padding: 11px 0;
}
/* ── TICKED OFF, AND IT STAYS TICKED ─────────────────────────────────────
   A teacher works down this list in a lesson. Green is "done", and it is the
   only green on a card that is otherwise about failure — which is why it reads
   at a glance. */
.rank-tick {
  width: 24px; height: 24px; border-radius: 7px; padding: 0; cursor: pointer;
  border: 1.5px solid var(--hair); background: var(--card);
  display: grid; place-items: center; color: transparent;
}
.rank-tick svg { width: 14px; height: 14px; fill: none; stroke: currentColor;
  stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.rank-tick:hover { border-color: color-mix(in srgb, var(--fg) 30%, transparent); color: var(--muted); }
.rank-row.is-done .rank-tick {
  background: var(--growth-4); border-color: var(--growth-4); color: #fff;
}
.rank-row.is-done .rank-h, .rank-row.is-done .rank-m { opacity: .55; }
.rank-row.is-done .rank-bar i { background: var(--growth-4); }
.rank-row.is-done .rank-n { color: #2C5B33; }
.rank-row + .rank-row { border-top: 1px solid var(--hair); }
.rank-i { font-family: 'Fraunces', Georgia, serif; font-size: 15px; color: var(--muted); }
.rank-h { font-family: 'Noto Serif SC', serif; font-size: 21px; }
.rank-p { font-size: var(--fs-fine); }
.rank-m { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-bar { height: 7px; border-radius: 4px; background: var(--growth-0); align-self: center; }
.rank-bar i { display: block; height: 100%; border-radius: 4px; background: var(--seal); }
.rank-n { font-size: var(--fs-fine); color: var(--fg-2); text-align: right;
  font-variant-numeric: tabular-nums; }
@media (max-width: 900px) {
  .rank-row { grid-template-columns: 26px 1.8em auto 1fr; row-gap: 2px; }
  .rank-p, .rank-bar { display: none; }
  /* The count wrapped into the tick's 26px column and read "3 …" on a phone
     (#2161). It goes under the character, where its whole sentence fits. */
  .rank-row .rank-n { grid-column: 3 / -1; text-align: left; }
}

/* ── The last box: a paragraph, not a panel ───────────────────────────────
   Nothing in it is a control and it should not look like one. Running text at
   reading width, and the hint in the header says as much. */
/* ── A SECTION THAT FOLDS ────────────────────────────────────────────────
   The heading is the control, and the chevron says so. `.sect` keeps the type
   it had; what is new is that the whole line is a target. */
.sect-box { margin: 0; }
.sect-box > summary {
  display: flex; align-items: center; gap: 10px; cursor: pointer; list-style: none;
  border-radius: 8px;
}
.sect-box > summary::-webkit-details-marker { display: none; }
.sect-box > summary:focus-visible { outline: 2px solid var(--seal); outline-offset: 4px; }
.sect-box > summary .sect { margin-bottom: 0; }
.sect-box > summary:hover .sect-chev { color: var(--fg); }
/* The chevron rides with the CAP HEIGHT of a Fraunces heading, not with its
   line box — centred on the box it looked high, because the serif's ascenders
   leave space above the letters that the arrow was sitting in. Two pixels down
   is where the eye puts it. */
.sect-chev {
  width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2;
  stroke-linecap: round; stroke-linejoin: round; color: var(--muted);
  transition: transform .16s ease; flex: 0 0 auto; position: relative; top: 2px;
}
.sect-box[open] > summary .sect-chev { transform: rotate(90deg); }
/* THE SECTION'S OWN COLUMN. `.wrap` gives its children 18px of gap, and
   wrapping the cards in a section took them out of it — so every card in a
   folded section sat flush against the next, reported with a screenshot of two
   boxes touching. The section repeats the rule for its own contents. */
.sect-in { margin-top: 14px; display: flex; flex-direction: column; gap: 18px; }
/* A section's own bottom margin, so two folded headings are not on top of each
   other either. `.wrap`'s gap handles the rest. */
.sect-box + .sect-box { margin-top: 4px; }

/* The comparison bar in "HSK n, skill by skill": the fill is the share, the
   colour is the distance to the strongest skill. */
.mrow .bar-fair .met { background: var(--amber); }
.mrow .bar-poor .met { background: var(--seal); }
.mrow .pct { font-size: var(--fs-fine); font-weight: 600; min-width: 3em; text-align: right; }
.pct-good { color: #2C5B33; }
.pct-fair { color: #8A5D06; }
.pct-poor { color: var(--seal-text); }
.mrow { grid-template-columns: 88px 1fr auto auto; }

.reach-p { max-width: 62ch; margin: 0 0 12px; color: var(--fg-2); line-height: 1.65; }
.reach-p:last-child { margin-bottom: 0; }
/* The info dot beside a tile label sits on the baseline with it. */
.stat-l .info-b { margin-left: 5px; vertical-align: -3px; }
.mrow .lab { font-size: var(--fs-fine); color: var(--fg-2); }
.mrow .cnt { font-size: var(--fs-fine); color: var(--muted); min-width: 3.4em; text-align: right; }

/* ── The lesson grid on a learner's page ─────────────────────────────────── */
/* One square per item of the current lesson: what they have, what they have
   not. It is the answer to the question the user kept coming back to — not
   "how are they doing" but "how far are they with THIS". */
/* Room to breathe. At a 5px gap a row of two- and three-character words reads
   as one long string of hanzi — the boxes have to be separable before their
   colours mean anything. */
.items { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 4px; }
/* THE SQUARE IS A BUTTON NOW — it opens the same dialog the course view's
   tiles do — so every rule here has to reach one. Written as `.items > *` so
   the next thing that goes in this row is styled by being in it, rather than by
   being the right element: the first version of this change left the element
   selector behind and the whole lesson went colourless in one commit. */
/* A SQUARE THAT GROWS INTO A PILL. A one-character item is a square; 开心 and
   自行车 are two and three, and they used to be truncated to their first hanzi
   — a row of words that rendered as a row of characters. */
.items > * {
  min-width: 38px; height: 38px; padding: 0 11px;
  border-radius: 9px; display: grid; place-items: center;
  font-family: 'Noto Serif SC', serif; font-size: 19px; font-style: normal;
  line-height: 1;
  color: color-mix(in srgb, var(--paper-fg) 72%, transparent);
  border: 0; background: var(--growth-0); cursor: pointer; font-weight: 500;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--paper-fg) 7%, transparent);
  transition: transform .1s ease;
}
.items > *:active { transform: scale(.94); }
.items > *:hover { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--fg) 32%, transparent); }
.items > *:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; }

/* ── Controls ────────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  padding: 8px 14px; border-radius: 10px; border: 1px solid var(--hair);
  background: var(--card); color: var(--fg); font: inherit; font-size: var(--fs-fine);
  font-weight: 500; cursor: pointer; text-decoration: none; white-space: nowrap;
}
.btn:hover { border-color: color-mix(in srgb, var(--fg) 26%, transparent); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--seal); border-color: var(--seal); color: var(--seal-on); }
.btn-primary:hover { background: var(--seal-press); border-color: var(--seal-press); }
.btn-lg { padding: 11px 18px; font-size: var(--fs-small); border-radius: 11px; }

/* THE ONE BUTTON IN THE SIDEBAR THAT IS ASKED FOR RATHER THAN NEEDED.
   Tinted, not filled: `.btn-primary` red would make Feedback the loudest thing
   on a screen whose actual work is a class and a roster, and a sidebar with a
   red button in it reads as an alarm. This is the seal at a twelfth of its
   strength — enough that the eye finds it, not enough that it competes with the
   confirm buttons that really are the primary action of a page.
   The dot is a pseudo-element and not a character: `applyCopy()` writes
   textContent onto everything with a `data-k`, so any child of this button
   would be deleted the first time somebody switched language. */
.btn-feedback { background: var(--seal-soft); border-color: color-mix(in srgb, var(--seal) 26%, transparent); color: var(--seal-text); }
.btn-feedback::before {
  content: ''; width: 6px; height: 6px; border-radius: 50%;
  background: var(--seal); flex: 0 0 auto;
}
.btn-feedback:hover {
  background: color-mix(in srgb, var(--seal) 18%, transparent);
  border-color: var(--seal);
}
/* A button that has been armed and will remove somebody on the next press. The
   seal, because this palette has exactly one colour that means stop and reusing
   it is cheaper than inventing a second one — and a destructive confirmation
   that looks like every other button is one nobody reads. */
.btn-armed { background: var(--seal); border-color: var(--seal); color: var(--seal-on); }
.btn-armed:hover { background: var(--seal-press); border-color: var(--seal-press); }
/* The confirmation lives on the button that was pressed. Six saves on one page
   and a floating toast cannot say which one it means. */
.btn-saved, .btn-saved:disabled {
  background: var(--growth-4); border-color: var(--growth-4);
  color: #10200C; opacity: 1;
}

label { display: block; font-size: var(--fs-fine); font-weight: 500; margin-bottom: 5px; color: var(--fg-2); }
input, select {
  width: 100%; padding: 10px 12px; border-radius: 10px;
  border: 1px solid var(--hair); background: var(--card); color: var(--fg);
  font: inherit; font-size: var(--fs-small);
}
.code-input { font-family: 'SF Mono', Menlo, Consolas, monospace; letter-spacing: 8px; text-align: center; font-size: 21px; }

/* ── Sign-in ─────────────────────────────────────────────────────────────── */
/* ── THE DOOR, ON THIS HOST ─────────────────────────────────────────────────
   The card floats on the landing page's picture. The scene's own rules are in
   /assets/scene.css, shared with the marketing site; what is here is only the
   two things that are different about carrying it on a sign-in page.

   FIXED, NOT ABSOLUTE. The scene is exactly one viewport tall by its own
   `--h: 100svh`, and `position: absolute` would stretch it to whatever the
   section grew to — on a short window with the code step open, that is taller
   than the viewport, and the frame's cover maths would then leave paper along
   the bottom edge. Fixed pins it to the window it was measured against, which
   is also what a background should do while somebody scrolls a form. */
/* ── NO BACKGROUND ON THIS SECTION, AND THAT IS A FIX (2026-09-05) ─────────
   It carried `background: var(--bg)`, which is opaque, is the full height of
   the viewport, and sits after `#scene` in the DOM — so it painted flat cream
   over the whole drawing. The sign-in card has been described in the README as
   sitting "in the middle, over the two figures", with a whole paragraph on why
   lifting it off them was tried and reverted; none of that had been visible
   for as long as this rule has been here, and nothing failed, because a
   picture that does not draw looks exactly like a page with no picture.
   `#scene` is fixed and covers the viewport, so removing this leaves no gap. */
.signin { position: relative; min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
/* ── THE PICTURE NEVER TAKES A CLICK ──────────────────────────────────────
   `pointer-events: none`, and it is not a nicety — leaving it off cost the
   whole top of the dashboard. `#scene` is FIXED and, once it moved behind the
   app, 400px tall over the header; it is a positioned element and `.app` is
   not, so it painted above the page's own content and swallowed every pointer
   in that band. The range picker did nothing. "Schüler einladen" did nothing.
   Both were reported as separate bugs and were the same transparent rectangle.

   The rule, since this is the second decorative layer on this surface: a thing
   that is `aria-hidden` is invisible to a screen reader, and it has to be
   invisible to a mouse for the same reason — it is not part of the interface. */
#scene { position: fixed; inset: 0; z-index: 0; pointer-events: none; }

/* ── The loading screen: the mark, and nothing else ────────────────────────

   THE PICTURE LEFT THIS SCREEN ON 2026-09-05, and the drawing moved to the
   dashboard instead. A full landscape behind a spinner promises a page that
   has already arrived; on the two occasions the boot lasts long enough to be
   seen, what was on screen was a finished-looking view with no content in it.
   Empty and centred says "one moment", which is the true thing.

   AND IT TURNS NOW, WHICH REVERSES THE NOTE THAT USED TO BE HERE. That note
   argued a spinner promises a measurable wait and this one is usually over
   before it has turned once — which is a good argument and is why the turn is
   slow, unhurried and paired with the breath rather than replacing it. The
   owner asked for a mark with life in it; a 44px square fading in place read
   as a rendering hiccup rather than as a wait.

   IT ROTATES IN THE PLANE, never on Y. 故 and 事 are TEXT — `.chop` is a
   styled span, there is no logo asset anywhere in packages/ — so a card flip
   shows the characters mirrored at the halfway point, which for a Chinese
   product is the one wrong frame to put on screen.

   `platform.css`'s global reduced-motion rule at the foot of this file kills
   both animations without either of them asking. */
.boot { position: relative; z-index: 1; min-height: 100dvh; display: grid; place-items: center; }
.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 picture, behind the dashboard ─────────────────────────────────────

   A BAND, NOT A WASH, and the layout is the reason. `.wrap` has no max-width,
   so its cards span the whole column and the only bare ground on a dashboard
   is 24px of side padding and 18px between cards. A full-page picture at any
   opacity would be a picture visible in the gutters — which is not subtlety,
   it is a background nobody can see paying for a repaint on every scroll.

   So it is masked to the top of the page, where the header and the crumbs sit
   on translucent chrome and there is real room. It is gone by the first card.

   OPACITY AND A MASK, NOT A RECOLOUR. `scene.css` says in its own header that
   it carries what is intrinsic to the drawing and nothing about what sits on
   it, and every colour in the SVG is a hardcoded hex — so dimming from the
   host is both the only lever and the correct one. It is dimmed hard: the
   drawing's own paper is #F4F1E9 against this page's #EDE9DF, so an
   undimmed scene reads as a lighter rectangle rather than as a picture. */
/* THE BAND HAS TO BE GIVEN ITS OWN HEIGHT, or it shows the wrong part of the
   picture. `.scene` is `--h: 100svh` and `.frame` does cover-maths against it,
   so on a tall window the top 300px of a full-height scene is nothing but sky
   — the first version of this was a grey stripe, correctly rendered and
   completely pointless. Sizing the scene to the band puts the ridge line,
   the pines and the mist inside it. */
#scene.app-scene {
  --h: 400px;
  inset: 0 0 auto 0;
  height: 400px;
  /* Dialled twice. At .34 over 460px the two FIGURES cleared the bottom edge,
     which on an empty class — a screen that is mostly bare ground — turned the
     wash into an illustration somebody had to look at. The band stops above
     them now: what is left is ridge, mist and pines. */
  opacity: .26;
  -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 150px, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 0, #000 150px, transparent 100%);
}
/* The sidebar is `--card` and opaque, so the band would stop dead at its edge.
   Letting the picture run the full width and putting the sidebar back on top
   keeps the seam where a seam belongs — at a border, not in the middle of a
   drawing. */
.app-scene ~ .app > .side { position: sticky; z-index: 1; }
.signin-card {
  position: relative; z-index: 1;
  /* CENTRED, WHICH MEANS THE TWO PEOPLE ARE BEHIND IT, and that is the
     choice rather than an oversight. Lifting the card to reveal them was
     tried: the drawing puts their heads at 70% of its height, the crop is
     cover, so where they land on screen depends on the window's aspect ratio —
     and every lift that cleared them on a 900px-tall laptop cut them off at
     the neck on something else. Two headless figures under a login box is
     worse than none. The middle of this composition is deliberately empty
     (see the note in site.css); the card sits in the emptiness it was drawn
     around, and the open book still shows below its lower edge. */
  width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--hair);
  border-radius: 18px; padding: 26px;
  /* Three short lines that break where the sentence does, not where the box
     runs out: "per E-/Mail" across two lines is a hyphen the reader has to
     undo. */
  text-wrap: pretty;
  /* It has to lift off the drawing now. A hairline was enough on flat paper;
     over the mist it read as a shape cut out of the picture. */
  box-shadow: 0 24px 60px -28px rgba(26, 24, 19, .45);
}
/* The seal and the product's name as one lockup, the only thing on the card
   that says which product this is (no kicker over the heading, 2026-10-06). */
.signin-brand {
  margin: 0; display: flex; align-items: center; gap: 10px;
  font-family: var(--serif, Georgia, serif); font-size: 17px; font-weight: 600; color: var(--fg);
}
.signin-brand .chop { margin: 0; }
/* The language switch sits under the hairline of the fine print rather than
   floating in a corner of the window: signed out, the card IS the page. */
.signin-lang { margin: 18px 0 0; padding-top: 14px; border-top: 1px solid var(--hair); }
.signin form > * + * { margin-top: 13px; }
/* The one line under a field that says what it is for. Inside the row, so the
   form's own rhythm above does not put thirteen pixels between a label and its
   explanation. */
.signin form .fine, .signin form .rule { margin: 6px 0 0; }
.signin-title { margin-top: 4px; font-size: 26px; }

/* ── THE TWO DOORS (2026-09-24) ─────────────────────────────────────────────
   A segmented pair on a sunken track, the pattern Mobbin shows on nearly every
   card that offers both. The selected half is lifted onto the card's own
   colour rather than filled seal red: red is the submit button's, and two red
   things on one card is two things shouting "press me". */
.auth-tabs {
  display: grid; grid-template-columns: 1fr 1fr; gap: 3px; padding: 3px;
  margin-top: 16px; border-radius: 11px; background: var(--bg-sunken);
  border: 1px solid var(--hair);
}
.auth-tabs [role=tab] {
  border: 0; border-radius: 8px; padding: 8px 10px; background: transparent;
  font: inherit; font-size: var(--fs-small); font-weight: 500; color: var(--muted); cursor: pointer;
}
.auth-tabs [role=tab][aria-selected=true] {
  background: var(--card); color: var(--fg);
  box-shadow: 0 1px 2px rgba(26, 24, 19, .12), 0 0 0 1px var(--hair);
}
.signin form { margin-top: 18px; }

/* "Forgot password?" on the label's own line, right-aligned. */
.label-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.label-row label { margin-bottom: 5px; }
.link-btn {
  border: 0; background: none; padding: 0; font: inherit; font-size: var(--fs-fine);
  color: var(--seal-text); cursor: pointer; text-decoration: underline; text-underline-offset: 2px;
}

/* Show / hide, inside the field's right edge. This was the word "Show" until
   #2446, and the reason was a good one: an eye has two readings (visible now /
   make visible) and a word has one. The owner asked for the eye on every
   password in all three products, so it is the eye, and the second reading is
   settled the way the phone settles it: open while the dots show, struck
   through while the letters do, and a name that says what a press does. The
   glyph is a mask so it takes the button's ink. 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: 6px; 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");
}

/* A rule under a password field: grey while unmet, jade with a tick once met,
   seal only when the two typings actually disagree. */
.rule { font-size: var(--fs-fine); color: var(--muted); }
.rule::before { content: '\25CB\00A0'; }
.rule.is-met { color: var(--jade); }
.rule.is-met::before { content: '\2713\00A0'; }
.rule.is-off { color: var(--seal-text); }
.rule.is-off::before { content: '\2715\00A0'; }
/* Good news said under a form (a mail went out, a password was set). */
.msg.msg-ok { color: var(--fg-2); }
.msg { font-size: var(--fs-fine); color: var(--seal-text); }
/* A refusal, not a hint. Every other `.msg` on this page is "that did not work,
   try again"; this one means "this account will never work here", and it has to
   look different or it reads as something to retry. */
.msg-stop {
  border: 1px solid var(--seal, #C8452E); border-left-width: 3px;
  border-radius: 8px; padding: 10px 12px; margin-top: 12px;
  font-size: var(--fs-small); line-height: 1.45;
}
/* The pass, named back to the person who scanned it. Not `.msg`, which is red
   on this card and means "that did not work" — this one means "this is what you
   are about to join", and it is the only line on the sign-in card that is ever
   good news. Jade at the edge, nothing at the fill: a filled panel would
   outweigh the form it introduces. */
.msg-pass {
  border: 1px solid var(--jade); border-left-width: 3px;
  border-radius: 8px; padding: 10px 12px; margin-top: 12px;
  color: var(--fg-2); font-size: var(--fs-small); line-height: 1.45;
}
.lead { color: var(--fg-2); }
.fine { color: var(--muted); font-size: var(--fs-fine); }

/* ── The invite dialog ───────────────────────────────────────────────────── */
dialog {
  border: 1px solid var(--hair); border-radius: 18px; padding: 0;
  background: var(--card); color: var(--fg); max-width: 420px; width: calc(100% - 32px);
  box-shadow: 0 24px 60px -24px rgba(26, 24, 19, .4);
}
dialog::backdrop { background: rgba(26, 24, 19, .38); backdrop-filter: blur(2px); }
/* `showModal()` focuses the dialog itself, and the global focus ring then draws
   a seal-red box around the whole thing — which reads as an error state on a
   dialog whose whole job is to be calm and held up in front of a room. The
   controls inside it keep their rings. */
dialog:focus, dialog:focus-visible { outline: none; }
/* Wide enough to draw on. 420px is right for a form and useless for aiming a
   circle at a chart, so the dialog grows for the one view that needs it and
   goes back afterwards. */
dialog.wide { max-width: min(980px, calc(100% - 32px)); }
.dlg { padding: 22px; display: flex; flex-direction: column; gap: 14px; }

/* The screenshot, as a button. It reads as a picture rather than a control on
   purpose — the hover ring is the only affordance, because a picture wearing
   button chrome in a row of real buttons is a fourth thing to parse. */
.fb-thumb {
  padding: 0; border: 1px solid var(--hair); border-radius: 6px; background: none;
  line-height: 0; cursor: zoom-in; overflow: hidden; flex: 0 0 auto;
}
.fb-thumb:hover { border-color: var(--seal); }
.fb-thumb img { height: 52px; display: block; }

/* Where the drawing happens. Capped and scrollable: a dashboard screenshot is
   several thousand pixels tall and fitting it to the dialog would make every
   mark a smudge. */
.fb-paper {
  max-height: 60vh; overflow: auto; border: 1px solid var(--hair);
  border-radius: 10px; background: var(--bg-sunken); line-height: 0;
}
#fb-canvas {
  display: block; width: 100%; height: auto; cursor: crosshair;
  /* Without this the first drag scrolls the page out from under the finger,
     which is the whole of the experience on a tablet. */
  touch-action: none;
}
.dlg h2 { font-size: 20px; }
/* The QR is the point of this dialog, so it gets the room. White plate behind
   it whatever the ground: a scanner wants maximum contrast and paper is not
   white. The quiet zone is drawn by padding, not by the encoder. */

.toast.ok { background: var(--growth-4); color: #10200C; }
.toast {
  position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
  z-index: 200; max-width: min(640px, calc(100vw - 32px));
  padding: 12px 16px; border-radius: 12px;
  background: var(--seal); color: var(--seal-on);
  font-size: var(--fs-fine); font-weight: 500;
  box-shadow: 0 12px 32px -12px rgba(26, 24, 19, .5);
}

/* ── States ──────────────────────────────────────────────────────────────── */
.empty { border: 1px dashed var(--hair); border-radius: 12px; padding: 26px; text-align: center; }
.empty h3 { color: var(--fg); }
.empty p { color: var(--muted); font-size: var(--fs-fine); margin-top: 6px; }
.loading { color: var(--muted); font-size: var(--fs-fine); padding: 24px; }

/* ── On a phone the sidebar is a drawer (#2161) ─────────────────────────────
   Until 2026-09-26 the sidebar turned into one row below 860px and scrolled
   sideways: the mark, the language, the class picker and the two nav entries
   fitted, and Feedback, Settings, the school, the name and Sign out were off
   the right edge with nothing saying so. Jonas: *"Feedback und Settings button
   etc. fehlen im Handyformat."*

   Now the SAME aside slides in from the left, laid out exactly as on a desk,
   so every control it has is there and none had to be re-created (a second
   copy for the phone is a copy somebody forgets to extend). A bar at the top
   of the page carries the menu, the mark, where you are, and the two buttons
   pressed most: invite and feedback. The lesson bar stays where it is, under
   it, because every number on the page is about that lesson. */
.mbar, .side-scrim, .mbar-btn.side-close { display: none; }
.mbar-btn {
  display: inline-grid; place-items: center; flex: 0 0 auto;
  width: 40px; height: 40px; border-radius: 11px; padding: 0;
  border: 1px solid var(--hair); background: var(--card); color: var(--fg-2); cursor: pointer;
}
.mbar-btn svg { width: 20px; height: 20px; fill: none; stroke: currentColor;
  stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.mbar-btn:hover { color: var(--fg); border-color: color-mix(in srgb, var(--fg) 26%, transparent); }
/* Feedback keeps its tint on the phone too: the one control asked for rather
   than needed (see `.btn-feedback`). */
.mbar-fb { background: var(--seal-soft); color: var(--seal-text);
  border-color: color-mix(in srgb, var(--seal) 26%, transparent); }

@media (max-width: 860px) {
  .app { grid-template-columns: 1fr; }
  .side {
    position: fixed; z-index: 90; top: var(--top-offset, 0px); left: 0; bottom: 0;
    width: min(320px, 86vw); height: auto; overflow-y: auto; overscroll-behavior: contain;
    border-right: 1px solid var(--hair);
    box-shadow: 0 0 60px -20px rgba(26, 24, 19, .5);
    transform: translateX(-104%); visibility: hidden;
    transition: transform .22s ease, visibility 0s linear .22s;
    padding-bottom: max(16px, env(safe-area-inset-bottom));
  }
  body.nav-open .side { transform: none; visibility: visible; transition: transform .22s ease; }
  /* The picture behind the dashboard sets the sidebar to `position: sticky`
     (`.app-scene ~ .app > .side`), which would pin the drawer into the flow. */
  .app-scene ~ .app > .side { position: fixed; z-index: 90; }
  .side-scrim {
    display: block; position: fixed; inset: 0; z-index: 80;
    background: rgba(26, 24, 19, .34);
  }
  .side-scrim[hidden] { display: none; }
  .mbar-btn.side-close { display: inline-grid; width: 34px; height: 34px; margin-left: 4px; }
  .side-top { padding-right: 0; }
  /* The class list can take the drawer's height: nothing else competes for it. */
  .nav-kids { max-height: none; }
  body.nav-open { overflow: hidden; }

  .mbar {
    display: flex; align-items: center; gap: 10px;
    position: sticky; top: var(--top-offset, 0px); z-index: 50;
    padding: 10px 12px; padding-top: max(10px, env(safe-area-inset-top));
    background: var(--card); border-bottom: 1px solid var(--hair);
  }
  .mbar .side-brand { flex: 0 0 auto; }
  .mbar-title {
    flex: 1 1 auto; min-width: 0; font-weight: 600; font-size: var(--fs-small);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* Under the phone bar the lesson bar scrolls away with the page: two sticky
     bars would be a fifth of a phone's height spent on chrome. */
  .topbar { position: static; padding: 10px 14px; gap: 10px; }
  .crumbs { flex: 1 1 100%; }
  .wrap { padding: 16px 12px 40px; }
}
@media (max-width: 860px) and (prefers-reduced-motion: reduce) {
  .side, body.nav-open .side { transition: none; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   THE 2026-09-05 REDESIGN: a window, six lanes, and a tick
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── The window picker ─────────────────────────────────────────────────────
   Segmented, and the first control of its kind on this surface. A `select`
   was the obvious reuse — the class and lesson pickers are both native selects
   and there are good reasons written above each — but this one is different in
   kind: it is the control the page invites a teacher to play with, and three
   days is an option nobody discovers behind a closed menu. */
.range {
  display: inline-flex; align-self: flex-start; gap: 2px; padding: 3px;
  border: 1px solid var(--hair); border-radius: 11px; background: var(--card);
}
.range button {
  border: 0; background: transparent; color: var(--fg-2);
  font: inherit; font-size: var(--fs-fine); font-weight: 500;
  padding: 6px 12px; border-radius: 8px; cursor: pointer; white-space: nowrap;
}
.range button:hover { color: var(--fg); background: var(--bg-sunken); }
.range button[aria-checked="true"] {
  background: var(--seal); color: var(--seal-on); font-weight: 600;
}
@media (max-width: 720px) { .range { flex-wrap: wrap; } }

/* ── "Bald" ────────────────────────────────────────────────────────────────
   `.tag` in the neutral rather than the seal, because seal on this surface
   means "you are an admin" and reusing it here would make coming-soon read as
   a status somebody holds. The marketing site's `.store.is-soon` makes the
   same argument in one line: never a control that leads nowhere. */
.tag.soon {
  background: var(--bg-sunken); border: 1px solid var(--hair);
  color: var(--muted); font-weight: 500;
}
/* A disabled control still has to be readable — it carries a `title` that says
   why, and one faded to the point of looking absent is one nobody hovers. */
.card > header .btn:disabled { opacity: .5; }

/* ── The lanes ─────────────────────────────────────────────────────────────
   One row per modality. A grid rather than flex so every lane's bar starts at
   the same x — four bars that begin in four places cannot be compared, which
   is the only thing this section is for. */
.lanes { display: flex; flex-direction: column; gap: 14px; }
.lane {
  display: grid; gap: 4px 12px; align-items: center;
  grid-template-columns: 118px minmax(90px, 1fr) 48px auto;
  padding-top: 14px; border-top: 1px solid var(--hair);
}
.lane:first-child { padding-top: 0; border-top: 0; }
.lane-l { font-weight: 500; font-size: var(--fs-fine); }
.lane-bar {
  height: 7px; border-radius: 4px; background: var(--growth-0);
  overflow: hidden; display: flex;
}
.lane-bar i { display: block; height: 100%; }
.lane-bar .met { background: var(--growth-4); }
.lane-bar .part { background: var(--growth-2); }
.lane-n { font-variant-numeric: tabular-nums; font-weight: 600; text-align: right; }
.lane-n small { color: var(--muted); font-weight: 500; }
.lane-c { justify-self: start; }
.lane-items { grid-column: 2 / -1; display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.lane-more { color: var(--muted); }
/* Speaking, and the sentence saying why there is no number. Two columns of
   nothing beside an explanation reads as a failed render. */
.lane-soon { grid-template-columns: 118px 1fr; }
.lane-soon .fine { grid-column: 2; margin: 0; max-width: 62ch; }
/* On a learner's own page there is no room of people to average, so the bar
   goes and the squares take the width. */
.lane-solo { grid-template-columns: 118px auto 1fr; }
.lane-solo .items { grid-column: 1 / -1; }
.lane-solo .items-wide > * { min-width: 34px; padding: 0 9px; }
@media (max-width: 720px) {
  .lane, .lane-soon, .lane-solo { grid-template-columns: 1fr auto; }
  .lane-bar, .lane-items, .lane-soon .fine, .lane-solo .items { grid-column: 1 / -1; }
}

/* ── THREE VERDICTS, AND UNMET IS NOT ONE OF THEM ─────────────────────────
   Green at ninety and up, amber from seventy, red below — the two constants
   `itemBand` in views.js explains, which are the model's own rather than round
   numbers. It was the five-step growth ramp, which put four greens on the page
   at 22 pixels each and read as a texture instead of as three answers.

   `s0` is pale ground and means "no card here yet"; red means "there was a
   memory and it has gone". Painting the two alike tells a teacher to re-teach
   something nobody has ever seen. */
.items > *.good {
  background: color-mix(in srgb, var(--growth-4) 62%, var(--card));
  color: #14200F;
}
.items > *.fair {
  background: color-mix(in srgb, var(--amber) 42%, var(--card));
  color: #5A3D06;
}
.items > *.s0 { background: var(--growth-0); }
.items > *.poor, .items > *.weak, .bar .weak {
  background: color-mix(in srgb, var(--seal) 22%, var(--card));
  color: var(--seal-text);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seal) 38%, transparent);
}
.gcard.weak { border-color: color-mix(in srgb, var(--seal) 32%, transparent); }

/* ── "Im Unterricht behandelt", in the card's header ───────────────────────
   It sat at the end of the ring row, where the eye arrives last, and it is the
   only control on this card that does anything. In the header it is opposite
   the lesson name it is about, and it reads before the ring rather than after.

   Four steps and a readout. "Zwei Stunden drin, also etwa die Hälfte" is the
   sentence that asked for it: a checkbox cannot say that, and a slider invites
   37 %, which is a precision nobody has about their own lesson. */
.taught {
  display: flex; flex-direction: column; gap: 5px; align-items: flex-end;
  padding: 8px 12px; border-radius: 12px;
  border: 1px solid var(--hair); background: var(--bg-sunken);
}
.taught.on { background: color-mix(in srgb, var(--growth-4) 12%, var(--card)); border-color: transparent; }
.taught-l { font-size: var(--fs-fine); color: var(--muted); font-weight: 500; white-space: nowrap; }
.taught-row { display: flex; align-items: center; gap: 10px; }
.taught-steps { display: flex; gap: 3px; }
.taught-step {
  width: 34px; height: 20px; padding: 0; cursor: pointer;
  border: 1px solid var(--hair); background: var(--card); border-radius: 5px;
}
.taught-step:first-child { border-top-left-radius: 9px; border-bottom-left-radius: 9px; }
.taught-step:last-child { border-top-right-radius: 9px; border-bottom-right-radius: 9px; }
.taught-step.on { background: var(--growth-4); border-color: transparent; }
.taught-step:hover { border-color: var(--seal); }
.taught-v {
  font-size: var(--fs-body); font-weight: 600; font-variant-numeric: tabular-nums;
  min-width: 4ch; text-align: right;
}
.taught.on .taught-v { color: #2C5B33; }
.card > header .taught { align-self: center; }

/* The skills sit in the ring row now, under their own one-word label — the two
   paragraphs that used to explain them are gone, because pressing one explains
   it faster than a sentence about it can. */
.ring-skills { display: flex; flex-direction: column; gap: 8px; min-width: 0; flex: 1 1 220px; }
.skills-l { font-size: var(--fs-fine); color: var(--muted); font-weight: 500; }

/* ── The learner's headline strip ──────────────────────────────────────────
   The same `.stats` grid, given room to breathe and one emoji. It was six
   numbers in a row at 24px and it read as a receipt; this is the first thing
   a teacher sees about a person and it should look like it is about a person. */
/* Four figures now, not six — the strip is the glance, and the two lifetime
   totals and the mastery count that were in it belong to cards that define
   them. Four across holds down to a phone at two-up. */
.stats-6 { grid-template-columns: repeat(6, 1fr); }
@media (max-width: 1180px) { .stats-6 { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 720px)  { .stats-6 { grid-template-columns: repeat(2, 1fr); } }
.stats-6 .stat { padding: 16px 14px; }
.stats-6 .stat-n { font-size: 28px; display: flex; align-items: baseline; gap: 6px; }
/* Two charts share the row under the tiles, so each hangs off half of it. */
.who-pair { gap: 14px; margin-top: 0; align-items: stretch; }
.who-pair .who-gr { display: flex; flex-direction: column; }
.who-pair .who-gr { border-top: 1px solid var(--hair); border-radius: 12px; }
.stats-lg { grid-template-columns: repeat(6, 1fr); }
@media (max-width: 1180px) { .stats-lg { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 720px)  { .stats-lg { grid-template-columns: repeat(2, 1fr); } }
.stats-lg .stat { padding: 16px 14px; }
.stats-lg .stat-n { font-size: 30px; display: flex; align-items: baseline; gap: 6px; }
/* The one emoji on this surface, and it is the streak's because a streak is
   the one figure here a student would recognise from their own screen. Sized
   below the numeral: it is a mark beside the number, not a second number. */
.stat-mark { font-size: .7em; line-height: 1; }
.stat-s { font-size: var(--fs-fine); color: var(--muted); margin-top: 2px; }

/* The roster's readiness bar, now carrying its own number. */
.bar-pct { display: flex; align-items: center; gap: 9px; min-width: 0; }
.bar-pct .bar { flex: 1 1 auto; }
.bar-pct .r { flex: 0 0 auto; font-variant-numeric: tabular-nums; }

/* Two awards rather than three: below a three-day window "most consistent" is
   a coin toss with a rosette on it, so it is not drawn at all. */
.supers.two { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 900px) { .supers.two { grid-template-columns: 1fr; } }

/* ── The number, and what it was ───────────────────────────────────────────
   Direction and the previous value. Colour carries only the direction, and
   `↓` is NOT red: fewer minutes in a holiday week is not an alarm, and a
   dashboard that shouts at ordinary variance is one people stop believing. */
.kpi-d { font-size: var(--fs-fine); color: var(--muted); display: flex; align-items: center; gap: 5px; margin-top: 2px; }
.kpi-d i { font-style: normal; font-weight: 600; }
.kpi-d.up i { color: var(--growth-4); }
.kpi-d.down i { color: var(--fg-2); }
.kpi-d.level i { color: var(--muted); }

/* Awards: however many actually had somebody to name. */
.supers.n1 { grid-template-columns: 1fr; }
.supers.n2 { grid-template-columns: repeat(2, 1fr); }
.supers.n3 { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 900px) { .supers.n2, .supers.n3 { grid-template-columns: 1fr; } }

/* ── "Bald" INSIDE the button it is about ─────────────────────────────────
   It sat beside the button, which read as two controls — one dark, one a
   little grey pill somebody might try to press. Inside, it is a label on the
   thing it labels, and there is nothing next to it to mistake for an action.

   The tag has to hold its own colours here: `.btn-primary:disabled` fades the
   whole button, and a tag inheriting that fade on top of its own is a word
   nobody can read. */
.btn-soon { gap: 9px; }
.btn-primary.btn-soon:disabled { opacity: .62; }
.btn-primary.btn-soon .tag.soon {
  background: rgba(255, 255, 255, .82);
  border-color: transparent;
  color: var(--seal-text);
  font-weight: 600;
}

/* ═══════════════════════════════════════════════════════════════════════════
   COLOUR THAT MEANS SOMETHING
   ───────────────────────────────────────────────────────────────────────────
   The dashboard was one jade ramp on paper, and a page where everything is the
   same green is a page you read by squinting at numbers. What follows adds
   colour only where it carries a VERDICT — two thirds and one third — so the
   hue is the same claim the percentage makes and never decoration.

   Jade, amber and seal are the brand's own three, and `PaletteTests.swift`
   already asserts they are separable at ΔE2000 >= 15 and legible on paper.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── The readiness ring ────────────────────────────────────────────────────
   The number takes the band too, not just the arc. A green arc with a black
   numeral in it makes the reader do the mapping twice. */
.ring-good .ring-mid b { color: #2C5B33; }
.ring-fair .ring-mid b { color: #8A5A12; }
.ring-poor .ring-mid b { color: var(--seal-text); }

/* ── Which skills the ring is about ────────────────────────────────────────
   Toggles, not a segmented control: several can be on at once, which is the
   whole point, and `aria-pressed` says so where `aria-checked` would lie. */
.skills-lead { margin: 16px 0 9px; max-width: 68ch; }
.skills { display: flex; flex-wrap: wrap; gap: 7px; }
.skill {
  padding: 7px 12px; border-radius: 999px; cursor: pointer;
  border: 1px solid var(--hair); background: var(--card); color: var(--fg-2);
  font: inherit; font-size: var(--fs-fine); font-weight: 500;
  display: inline-flex; align-items: center; gap: 7px;
}
.skill:hover:not(:disabled) { border-color: var(--seal); color: var(--fg); }
.skill.on {
  background: var(--seal); border-color: transparent; color: var(--seal-on); font-weight: 600;
}
.skill:disabled { opacity: .5; cursor: default; }

/* ── The lane bars, coloured by their own verdict ──────────────────────────
   Four bars in one green told a teacher which was longest and nothing else.
   Now 91 % and 3 % do not look like the same kind of fact. */
.lane-good .lane-bar .met  { background: var(--growth-4); }
.lane-good .lane-bar .part { background: color-mix(in srgb, var(--growth-4) 42%, var(--card)); }
.lane-fair .lane-bar .met  { background: var(--amber); }
.lane-fair .lane-bar .part { background: color-mix(in srgb, var(--amber) 42%, var(--card)); }
.lane-poor .lane-bar .met  { background: var(--seal); }
.lane-poor .lane-bar .part { background: color-mix(in srgb, var(--seal) 34%, var(--card)); }
.lane-good .lane-n { color: #2C5B33; }
.lane-fair .lane-n { color: #8A5A12; }
.lane-poor .lane-n { color: var(--seal-text); }

.lane-note { grid-column: 2 / -1; margin: 0; }
@media (max-width: 720px) { .lane-note { grid-column: 1 / -1; } }

/* ── The items themselves ──────────────────────────────────────────────────
   Every entry in the lesson is on screen, so colour does the sorting the old
   shortlist did. ONE RULE, and it is the share of the room that knows the item
   — the same two-thirds / one-third bands as the ring and the lane bars, and
   the same number the card prints in its corner.

   The previous version coloured by "has everybody mastered it" and printed
   "how many are not weak", which are different questions; a card could say
   2/2 and still be grey, and nothing on screen explained why. */
.gcard.good {
  background: color-mix(in srgb, var(--growth-4) 11%, var(--card));
  border-color: color-mix(in srgb, var(--growth-4) 30%, transparent);
}
.gcard.good .gcard-r { color: #2C5B33; }
.gcard.fair {
  background: color-mix(in srgb, var(--amber) 13%, var(--card));
  border-color: color-mix(in srgb, var(--amber) 34%, transparent);
}
.gcard.fair .gcard-r { color: #8A5A12; font-weight: 600; }
.gcard.poor {
  background: color-mix(in srgb, var(--seal) 9%, var(--card));
  border-color: color-mix(in srgb, var(--seal) 34%, transparent);
}
.gcard.poor .gcard-r { color: var(--seal-text); font-weight: 600; }

.lane-items { max-height: 232px; overflow-y: auto; align-content: flex-start; padding-right: 2px; }

/* The roster's per-learner readiness, in the same three bands as the ring and
   the lanes — so a row that needs attention looks like one at a glance rather
   than only after reading its number. */
.bar-good .bar .met  { background: var(--growth-4); }
.bar-good .bar .part { background: color-mix(in srgb, var(--growth-4) 42%, var(--card)); }
.bar-fair .bar .met  { background: var(--amber); }
.bar-fair .bar .part { background: color-mix(in srgb, var(--amber) 42%, var(--card)); }
.bar-poor .bar .met  { background: var(--seal); }
.bar-poor .bar .part { background: color-mix(in srgb, var(--seal) 34%, var(--card)); }
.bar-good .r { color: #2C5B33; }
.bar-fair .r { color: #8A5A12; }
.bar-poor .r { color: var(--seal-text); }

/* ═══════════════════════════════════════════════════════════════════════════
   THREE SECTIONS, AND A KPI THAT OPENS
   ═══════════════════════════════════════════════════════════════════════════ */

/* The page answers three questions in order — how has the room been, is it
   ready for Thursday, who are they — and until now that order was implicit in
   the card sequence and a reader had to infer it. Small headings, because they
   are signposts between cards rather than titles competing with them. */
.sect {
  font-family: 'Fraunces', Georgia, serif; font-size: var(--fs-h3);
  font-weight: 600; margin: 12px 0 -4px;
}
.wrap > .sect:first-of-type { margin-top: 4px; }

/* ── A KPI that opens ──────────────────────────────────────────────────────
   `<details>`, so disclosure state, the keyboard and the arrow semantics come
   from the browser. `name="kpi"` makes them exclusive: opening one closes the
   other, and the section does not grow to twice its height. */
.kpi > summary {
  display: flex; flex-direction: column; gap: 4px; cursor: pointer;
  list-style: none; position: relative;
}
.kpi > summary::-webkit-details-marker { display: none; }
.kpi > summary:focus-visible { outline: 2px solid var(--seal); outline-offset: 4px; border-radius: 8px; }
.kpi-more {
  color: var(--muted); margin-top: 6px;
  display: inline-flex; align-items: center; gap: 5px;
}
.kpi-more::after { content: '▾'; font-size: .9em; }
.kpi[open] .kpi-more::after { content: '▴'; }
.kpi[open] { box-shadow: 0 10px 30px -22px rgba(28, 30, 24, .5); }
.kpi-open { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hair); }

/* The delta, with a face on it. The arrow at 12px was doing the work of a
   whole sentence and losing; an emoji is read before the words are. */
.kpi-d { font-size: var(--fs-fine); color: var(--fg-2); display: flex; align-items: center; gap: 6px; margin-top: 4px; font-weight: 500; }
.kpi-e { font-size: 15px; line-height: 1; }
.kpi-d.up { color: #2C5B33; }
.kpi-d.down { color: var(--fg-2); }

/* ── A bar per day ─────────────────────────────────────────────────────────
   Flex rather than SVG: every bar carries a title and a label and has to
   reflow when the card narrows, and an SVG would need all three placed by
   hand. Zero-days are drawn as empty tracks — they are the point of the
   picture, and a chart that skips them turns three sessions into a full week. */
.daybars { display: flex; align-items: flex-end; gap: 4px; height: 112px; }
.daybar { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 5px; height: 100%; }
.daybar-track {
  flex: 1 1 auto; display: flex; align-items: flex-end;
  background: var(--bg-sunken); border-radius: 5px; overflow: hidden;
}
.daybar-track i { display: block; width: 100%; background: var(--growth-3); border-radius: 5px; }
.daybar:hover .daybar-track i { background: var(--growth-4); }
.daybar-d { font-size: 10px; color: var(--muted); text-align: center; font-variant-numeric: tabular-nums; }
/* The last bar is usually a day that is not over, and a short bar there is
   different news from a short bar in the middle of the week. */
/* MARKED, NOT ALARMED. This was seal-red, which on a page where red means
   "the room does not have this" said today had gone badly when all it means is
   that today is not over. A ring around the track and a bold date do the same
   job without borrowing a colour that already has a meaning here. */
.daybar.is-today .daybar-track { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--fg-2) 34%, transparent); }
.daybar.is-today .daybar-d { color: var(--fg); font-weight: 600; }
@media (max-width: 900px) { .daybars { height: 88px; } .daybar-d { display: none; } }

/* ── The class in order ────────────────────────────────────────────────────
   An `<ol>` because it IS a ranking; the medals are decoration on top of a
   list that reads correctly without them. Only a non-zero score gets one —
   a gold medal for nought minutes is a joke at somebody's expense. */
.rank { list-style: none; margin: 16px 0 0; padding: 0; display: flex; flex-direction: column; }
.rank li {
  display: grid; grid-template-columns: 26px 28px 1fr auto; align-items: center; gap: 10px;
  padding: 7px 2px; border-top: 1px solid var(--hair);
}
.rank li:first-child { border-top: 0; }
.rank-i { font-size: 15px; text-align: center; color: var(--muted); font-variant-numeric: tabular-nums; }
.rank .avatar.sm { width: 28px; height: 28px; font-size: 11px; }
.rank-n { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-v { font-variant-numeric: tabular-nums; font-weight: 600; }
.rank-v small { color: var(--muted); font-weight: 400; font-size: var(--fs-fine); }

/* ── The compact roster: name, level, lesson ───────────────────────────────
   Minutes and last-active moved into the two KPI panels, which rank the same
   people by the same numbers. What is left is who is here and where they stand
   in the lesson. */
.person.person-c, .person-h.person-c {
  grid-template-columns: 40px minmax(140px, 1.6fr) 96px minmax(160px, 1.2fr) 20px;
}
/* On a narrow screen the lesson bar used to be HIDDEN with everything past
   the third column, so on a phone the readiness card's roster said who was
   in the room and nothing about the lesson it sits under (#2161: every
   function, on a phone too). It moves under the name instead, on a line of
   its own; only the header's column label goes. */
@media (max-width: 900px) {
  .person.person-c, .person-h.person-c { grid-template-columns: 36px 1fr 70px; row-gap: 6px; }
  .person-h.person-c > :nth-child(n+4) { display: none; }
  .person.person-c > .chev { display: none; }
  .person.person-c > .bar-pct { display: flex; grid-column: 2 / -1; }
}

/* ── The mark, turning, wherever something is being fetched ────────────────
   A view used to say "Wird geladen…" in grey 12px — on a dashboard whose every
   screen fetches for a moment, that is the most-seen state in the product and
   it looked like a sentence cut off. */
.loading { display: grid; place-items: center; gap: 10px; padding: 56px 24px; color: var(--muted); }
.chop.turning {
  width: 46px; height: 46px; border-radius: 12px; font-size: 14px;
  animation: boot-breathe 2.4s ease-in-out infinite, boot-turn 5.6s linear infinite;
}

/* ── The window picker, beside the people it is about ─────────────────────
   It was a full-width row under the title, six pills wide, and it was the
   loudest thing on a page whose job is a number. Three options and the size of
   the controls next to it: it sets the context for the head rather than
   announcing itself. */

/* ── The tick, in the top bar ─────────────────────────────────────────────
   Beside the lesson picker, because between them they answer one question:
   which lesson, and how far into it. Horizontal here where the card version
   stacked, so it sits on the top bar's single line. */
.taught-bar { display: flex; align-items: center; }
.taught-bar .taught {
  flex-direction: row; align-items: center; gap: 9px;
  padding: 5px 10px; border-radius: 10px;
}
.taught-bar .taught-step { width: 22px; height: 15px; }
.taught-bar .taught-v { font-size: var(--fs-fine); min-width: 5ch; }
@media (max-width: 900px) { .taught-bar .taught-l { display: none; } }

/* ── The class, inside the card that says how ready it is ─────────────────
   The ring and the people it is made of were two screens apart. */
.card-roster { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--hair); }
.card-roster-h { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.more { margin-top: 2px; }
.more > summary {
  cursor: pointer; list-style: none; padding: 9px 8px;
  font-size: var(--fs-fine); color: var(--fg-2); font-weight: 500;
  border-top: 1px solid var(--hair);
}
.more > summary::-webkit-details-marker { display: none; }
.more > summary::after { content: ' ▾'; color: var(--muted); }
.more[open] > summary::after { content: ' ▴'; }
.more > summary:hover { color: var(--fg); }

/* ── The class, inside the card that says how ready it is ─────────────────
   The ring and the people it is made of were two screens apart. */
.card-roster { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--hair); }
.card-roster-h { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.more { margin-top: 2px; }
.more > summary {
  cursor: pointer; list-style: none; padding: 9px 8px;
  font-size: var(--fs-fine); color: var(--fg-2); font-weight: 500;
  border-top: 1px solid var(--hair);
}
.more > summary::-webkit-details-marker { display: none; }
.more > summary::after { content: ' ▾'; color: var(--muted); }
.more[open] > summary::after { content: ' ▴'; }
.more > summary:hover { color: var(--fg); }

/* ── A phone, and the two rows that had grown past it ─────────────────────
   The top bar gained the tick beside the lesson picker and the head gained the
   window picker beside the teachers. Both rows already wrapped; what they did
   not do was let their CONTENTS shrink, so at 390px the second line ran off
   the right edge and took the whole page into horizontal scroll — on a
   dashboard, where sideways scrolling means half the numbers are simply gone.

   `min-width: 0` is the actual fix: a flex item defaults to `auto`, which is
   "never smaller than my content", and every one of these has content wider
   than a phone. */
@media (max-width: 720px) {
  .topbar { padding: 10px 16px; row-gap: 8px; }
  .topbar .spacer { display: none; }
  .lesson-bar, .taught-bar { flex: 1 1 100%; min-width: 0; }
  .lesson-bar select { flex: 1 1 auto; min-width: 0; }
  .taught-bar .taught { width: 100%; justify-content: space-between; }

  .wrap { padding: 16px 16px 40px; }
  .head { row-gap: 10px; }
  .head h1 { font-size: clamp(22px, 6.4vw, 30px); }
  /* The three controls each take a full line rather than sharing one badly:
     an invite button squeezed to four characters is a button nobody presses. */
  .head .range, .head .who-teaches, .head > .btn { flex: 1 1 100%; min-width: 0; }
  .head .range { justify-content: space-between; }
  .head .range button { flex: 1 1 0; }
  .card { padding: 14px; }
  .ring-row { gap: 16px; }
  .card-roster-h { flex-wrap: wrap; }
}
/* NO `overflow-x: hidden` ON THE ROOT, and it was here for an hour. It was
   added against an overflow that turned out not to exist — headless Chrome
   clamps a window to 500px, so a screenshot asked for at 390 is a 500px layout
   cropped, which looks exactly like content running off the edge. Measured,
   `scrollWidth` equals `clientWidth` and the only boxes past the fold are
   inside the scene's own clipped SVG.
   
   Worth the note because the rule is not free: `overflow-x` on html or body
   silently kills `position: sticky` on descendants in several engines, and the
   sidebar and the top bar are both sticky. A fix for a bug that was not there
   would have broken two that work. */

/* ═══════════════════════════════════════════════════════════════════════════
   THE SECOND PASS: a tile you can read at a glance, and one you can open
   ═══════════════════════════════════════════════════════════════════════════ */

/* The window picker beside the heading it governs, at the size of a caption.
   It had been a row of its own and then a control in the head; both times it
   announced itself more loudly than the numbers it qualifies. */
.sect-row { display: flex; align-items: baseline; gap: 12px; margin: 12px 0 -4px; }
.sect-row .sect { margin: 0; }
.sect-row .spacer { flex: 1 1 auto; }

/* ── The three awards ─────────────────────────────────────────────────────

   THE CATEGORY IS THE HEADING, and it had been the quietest thing in the card.
   It was set at `--fs-fine` in muted grey above a bold name, so the eye found
   "pauljac77" first and had to climb back up to learn what he had won. The
   three categories are the point — they are what a teacher learns to read the
   row by — so they take the weight: uppercase, tracked, in ink, above a
   hairline. The name is the answer, not the label.

   The icon becomes a badge in its own tinted disc rather than a glyph inline
   with the words, so the three are told apart before any of them is read. */
.supers { align-items: stretch; }
.super {
  display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px;
  align-items: start; padding: 2px;
}
.super-badge {
  width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center;
  font-size: 18px; line-height: 1; background: var(--bg-sunken); flex: 0 0 auto;
}
.super-body { min-width: 0; }
.super .t {
  display: block; font-size: 11px; font-weight: 700; letter-spacing: .1em;
  text-transform: uppercase; color: var(--fg-2);
  padding-bottom: 7px; margin-bottom: 8px; border-bottom: 1px solid var(--hair);
}
/* The name sits with a small face, so the three cards read as three PEOPLE at
   a glance and the reader is not matching a name to a portrait in the corner. */
.super .n {
  display: flex; align-items: center; gap: 8px;
  font-family: 'Fraunces', Georgia, serif; font-size: 17px; font-weight: 600;
}
.super .n span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.super .v { font-size: var(--fs-small); color: var(--fg); font-weight: 500; margin-top: 4px; }
.super .w { font-size: var(--fs-fine); color: var(--muted); margin-top: 5px; line-height: 1.35; }
/* Nothing to award: the badge greys out so the row still reads as three slots
   rather than as two cards and a gap. */
.super-none .super-badge { opacity: .45; filter: grayscale(1); }
.super-none .v { color: var(--fg-2); font-weight: 400; }

/* ── The skills, as a grid ────────────────────────────────────────────────
   Six pills of different word-lengths wrapped into a ragged two-and-a-half
   lines that changed shape with the language. Equal cells, three per row, so
   the same button is in the same place every time the card is drawn. */
.skills { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
@media (max-width: 1180px) { .skills { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px)  { .skills { grid-template-columns: 1fr; } }
.skill {
  justify-content: flex-start; gap: 6px; padding: 8px 12px; border-radius: 10px;
  text-align: left; overflow: hidden;
}
.skill-t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The tick holds its width whether or not it is showing, so a button does not
   shuffle sideways the moment it is pressed. */
.skill-c { width: 11px; flex: 0 0 auto; font-weight: 700; font-size: 12px; }
/* Nothing chosen: the ring shows a question mark, so the buttons say the state
   is deliberate rather than broken. */
.ring-none .ring-mid b { color: var(--muted); font-weight: 500; }

/* ── The tile: the item, the verdict, the faces ───────────────────────────
   It carried hanzi, pinyin, a gloss and a fraction — five lines per item and
   forty-four items in a lane. A gloss is what you look up, not what you scan
   by. Everything else is one tap away in the dialog below. */
.lane-items { display: flex; flex-wrap: wrap; gap: 7px; max-height: 258px; overflow-y: auto; align-content: flex-start; }
.tile {
  display: flex; flex-direction: column; align-items: center; gap: 5px;
  padding: 8px 9px 7px; min-width: 54px; cursor: pointer;
  border: 1px solid var(--hair); border-radius: 11px; background: var(--card);
  font: inherit; color: inherit;
}
.tile:hover { transform: translateY(-1px); box-shadow: 0 6px 14px -10px rgba(28,30,24,.6); }
.tile:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; }
.tile-h { font-family: 'Noto Serif SC', serif; font-size: 21px; line-height: 1.1; white-space: nowrap; }
.tile-faces { display: flex; gap: -2px; }
.tile-faces .face { display: block; margin-right: -5px; }
.tile-faces .face:last-child { margin-right: 0; }
.avatar.xs { width: 17px; height: 17px; font-size: 8px; border-width: 1.5px; }
/* The ring round each face is the whole point: green has it, red does not. */
.face.yes .avatar { border-color: var(--growth-4); }
.face.no  .avatar { border-color: var(--seal); }
.tile.good { background: color-mix(in srgb, var(--growth-4) 11%, var(--card)); border-color: color-mix(in srgb, var(--growth-4) 30%, transparent); }
.tile.fair { background: color-mix(in srgb, var(--amber) 13%, var(--card));    border-color: color-mix(in srgb, var(--amber) 34%, transparent); }
.tile.poor { background: color-mix(in srgb, var(--seal) 9%, var(--card));      border-color: color-mix(in srgb, var(--seal) 34%, transparent); }

/* ── The dialog: everything the tile dropped ──────────────────────────────── */
/* ── `minmax(0, 1fr)`, AND THAT WAS THE WHOLE BUG ──────────────────────────
   The close button and every learner's percentage were clipped at the right
   edge, and the obvious reading — "the dialog is too narrow" — was wrong. A
   `1fr` track has an automatic minimum of `min-content`, so a row with a long
   name in it refuses to shrink and pushes the whole grid past its container.
   Widening the dialog would have moved the clip a few pixels and kept it.

   The same trap as `min-width: 0` on a flex child, one layout system over. */
dialog#item { max-width: 480px; }
dialog#item::backdrop { background: rgba(28, 30, 24, .32); backdrop-filter: blur(2px); }
.item-dlg { min-width: 0; }
.item-dlg > * { min-width: 0; }
.item-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; min-width: 0; }
.item-lane { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-person > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-head .spacer { flex: 1 1 auto; }
.item-body { display: grid; place-items: center; text-align: center; gap: 4px; padding: 6px 0 16px; }
.item-h { font-family: 'Noto Serif SC', serif; font-size: 54px; line-height: 1.05; }
.item-p { font-size: 17px; font-weight: 500; }
.item-n { font-size: var(--fs-small); }
.item-m { color: var(--fg-2); max-width: 34ch; }
.item-share { margin-top: 8px; font-weight: 600; font-variant-numeric: tabular-nums; }
.good .item-share { color: #2C5B33; } .fair .item-share { color: #8A5A12; } .poor .item-share { color: var(--seal-text); }
.item-people { display: flex; flex-direction: column; max-height: 34vh; overflow-y: auto; }
.item-person {
  display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 11px;
  padding: 8px 6px; border-top: 1px solid var(--hair); text-decoration: none; color: inherit; border-radius: 8px;
}
.item-person:hover { background: var(--bg-sunken); }
.item-person.yes .avatar { border-color: var(--growth-4); border-width: 2px; }
.item-person.no  .avatar { border-color: var(--seal); border-width: 2px; }
.item-person small { color: var(--muted); font-variant-numeric: tabular-nums; }
.item-nav { display: flex; gap: 8px; justify-content: center; margin-top: 14px; }
.item-nav .btn { min-width: 74px; }


/* The roster page is a directory now — name and level and a way in. Everything
   else it used to carry moved to where it can be answered: minutes and
   last-active into the two KPI panels, the lesson into the readiness card. */
.person.person-d, .person-h.person-d {
  grid-template-columns: 40px minmax(160px, 1fr) 110px 20px;
}
@media (max-width: 900px) {
  .person.person-d, .person-h.person-d { grid-template-columns: 36px 1fr 90px 20px; }
}

/* ── The panel's own heading ──────────────────────────────────────────────
   The tile is the week, the panel is the month, and neither needs a control:
   two spans with one place each, which is one fewer decision than a switch
   between them and answers the same two questions. */
.kpi-span { display: flex; align-items: baseline; gap: 12px; margin-bottom: 10px; }
.kpi-span .spacer { flex: 1 1 auto; }
.kpi-total { font-weight: 600; font-variant-numeric: tabular-nums; }
.kpi-total small { color: var(--muted); font-weight: 400; font-size: var(--fs-fine); }

/* ── The month, day by day ────────────────────────────────────────────────
   Inside the panel, above the ranking. A month is where a line earns its
   place: thirty days is a shape — a holiday, a run, somebody tailing off —
   and that is not readable as a column of totals. Over a week it was a line
   through seven points that pushed the ranking below the fold.

   The viewBox is the shape it draws at, so it scales uniformly and circles
   stay circles; the earlier version stretched x seven times harder than y and
   read as a blurred photograph of a chart. */
.dayline { margin: 4px 0 16px; }
/* The header slot holds its width whether or not a day is in it, so the total
   beside it does not jump sideways as the pointer crosses the chart. */
.kpi-day { display: inline-flex; justify-content: flex-end; min-height: 24px; }
/* A PREVIEW IS NOT A CHOICE. Hovering shows what picking would give you, so it
   is the same chip in outline: no fill, no × , and nothing to press. The solid
   one means a day is actually selected and the list below is about it. */
.picked.is-preview {
  background: transparent; border-style: dashed;
  color: var(--fg-2); cursor: default; font-weight: 500;
}
.dayline-svg { display: block; width: 100%; height: auto; max-height: 150px; }
.dl-line { fill: none; stroke: var(--growth-4); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.dl-dot { fill: var(--card); stroke: var(--growth-4); stroke-width: 2; }
.dl-dot.is-today { fill: var(--growth-4); }
/* The hit areas are the whole day column, not the dot: aiming at a 4px circle
   is a fine motor task and this is a chart read while thinking about something
   else. Each carries a `<title>`, so the number is at the cursor with no
   script at all. */
/* The band stays as wide as it was and goes back to being invisible: it is the
   hit target, not the highlight. What lights up is the point — see `.dl-guide`
   and `.dl-dot.is-hot`. */
.dl-hit { fill: transparent; cursor: pointer; outline: none; }
.dayline-x {
  display: flex; justify-content: space-between; margin-top: 7px;
  font-size: var(--fs-fine); color: var(--muted);
}

/* ── The tile's icon, and the trend arrow ─────────────────────────────────
   Inline SVG for the icon and a text arrow for the trend, both for the same
   reason: an emoji is a different drawing on every platform and a different
   weight from the type beside it. These take `currentColor` and one stroke. */
.kpi > summary { flex-direction: row; align-items: flex-start; gap: 14px; }
.kpi-txt { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1 1 auto; }
/* ── THE TWO TILES CARRY THEIR OWN COLOUR ─────────────────────────────────
   They were both grey on grey, which made two different measurements look
   like one repeated card.

   Time takes `--t4`, the fourth-tone blue, and cards take the seal. NOT jade:
   it is the same hex as `--t3` and as the growth ramp's top, which on this
   page means "the room has it" — an icon in that green beside a number that is
   not a readiness figure borrows a meaning it does not have. The tone palette
   is the one set of hues here that carries no verdict.

   A soft gradient and a hairline ring so they read as objects rather than as
   flat swatches. */
.kpi-ic {
  width: 46px; height: 46px; flex: 0 0 auto; border-radius: 14px;
  display: grid; place-items: center;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .5);
}
.kpi-ic svg { width: 25px; height: 25px; display: block; }
[data-kpi="minutes"] .kpi-ic {
  background: linear-gradient(150deg,
    color-mix(in srgb, var(--t4) 30%, var(--card)),
    color-mix(in srgb, var(--t4) 12%, var(--card)));
  color: #2E5A8C;
}
[data-kpi="reviews"] .kpi-ic {
  background: linear-gradient(150deg,
    color-mix(in srgb, var(--seal) 22%, var(--card)),
    color-mix(in srgb, var(--seal) 9%, var(--card)));
  color: var(--seal-text);
}
.kpi-a { font-size: 15px; line-height: 1; font-weight: 700; }
/* DOWN IS RED — the owner's call over the note that used to sit here arguing
   for grey. A drop is a drop; the awards and the week around it give the
   context that argument was reaching for. */
.kpi-d.up { color: #2C5B33; }
.kpi-d.level { color: var(--muted); }
.kpi-d.down { color: var(--seal-text); }

/* The three awards are all about the last seven days, which was true and
   unstated — "+185 Karten" does not say over what. */
.supers-l {
  display: block; font-size: var(--fs-fine); color: var(--muted);
  font-weight: 500; margin-bottom: 12px;
}

/* Woche or Monat, above the chart it redraws. */
.span {
  display: inline-flex; gap: 2px; padding: 2px;
  border: 1px solid var(--hair); border-radius: 9px; background: var(--bg-sunken);
}
.span button {
  border: 0; background: transparent; color: var(--fg-2);
  font: inherit; font-size: 12px; font-weight: 500;
  padding: 4px 12px; border-radius: 7px; cursor: pointer; white-space: nowrap;
}
.span button:hover { color: var(--fg); }
.span button[aria-checked="true"] { background: var(--card); color: var(--fg); font-weight: 600; }

/* ── Both charts always on screen ─────────────────────────────────────────
   They are the section's two numbers drawn out, and hiding one behind a
   disclosure meant comparing minutes to cards by opening one, remembering it,
   and opening the other. What folds is the class list underneath. */
.kpi { display: flex; flex-direction: column; }
.kpi-top { display: flex; align-items: flex-start; gap: 14px; }
.kpi-open { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hair); }
.kpi .more > summary { padding-left: 2px; }

/* A hairline every seventh day, so a peak on a month chart can be placed. */
.dl-week { stroke: var(--hair); stroke-width: 1.5; }

/* ── A skill lane folds, and it has to LOOK like it folds ─────────────────
   FIRST: a `<details>` is not the grid its base rule makes it. `.lane` above
   is `display: grid` for the Sprechen row, which is a plain div and lays its
   label, bar and note out in columns. A `<details>` with that display puts the
   summary AND the items into grid cells, so the tiles drew straight over the
   bar they belong under. Restated here because the two rules are two hundred
   lines apart and the failure looked like a spacing bug rather than a display
   one.

   THE AFFORDANCE was a `▾` in the far-right column, which put it on its own
   line under the count with nothing beside it — a stray character rather than
   a control, and the disclosure went unnoticed.

   Three changes, and they are the ordinary ones: a triangle at the START of
   the row where a reader looks first and which turns when the row opens, the
   whole summary lifting on hover so the block reads as one pressable thing,
   and the count spelling out what opening gives you. The row was already the
   click target; nothing about the hit area changed, only whether it says so. */
details.lane { display: block; }
details.lane > .lane-items { margin-top: 10px; }
.lane > summary {
  display: grid; gap: 4px 12px; align-items: center; cursor: pointer;
  grid-template-columns: 118px minmax(90px, 1fr) 48px auto;
  list-style: none; padding: 8px; margin: -8px -8px 0; border-radius: 10px;
}
.lane > summary::-webkit-details-marker { display: none; }
.lane > summary:hover { background: var(--bg-sunken); }
.lane > summary:focus-visible { outline: 2px solid var(--seal); outline-offset: 2px; }
.lane-l { display: flex; align-items: center; gap: 8px; font-weight: 500; font-size: var(--fs-fine); }
/* A triangle drawn in borders rather than a glyph: `▸` and `▾` are different
   widths in this face, so the label shifted sideways every time a lane opened. */
.lane-l::before {
  content: ''; flex: 0 0 auto;
  border-left: 5px solid var(--muted);
  border-top: 4px solid transparent; border-bottom: 4px solid transparent;
  transition: transform .16s ease;
}
.lane[open] > summary .lane-l::before { transform: rotate(90deg); }
.lane > summary:hover .lane-l { color: var(--seal-text); }
.lane > summary:hover .lane-l::before { border-left-color: var(--seal); }
.lane-c { justify-self: start; white-space: nowrap; }
.lane > summary:hover .lane-c { color: var(--fg-2); }
.lane[open] > summary { margin-bottom: 4px; }

@media (max-width: 720px) {
  .lane > summary { grid-template-columns: 1fr auto; }
}

/* ── A picked day ─────────────────────────────────────────────────────────
   Clicking a point filters the list under the chart to that day. The chart
   already answered "when"; this makes it answer "and who, that day", which is
   the question a teacher has the moment they see a spike or a hole. */
.dl-dot.is-picked { fill: var(--seal); stroke: var(--seal); }
/* Suppressed at thirty points, but still in the DOM so hovering one can bring
   it back — the shape is carried by the line there, and thirty circles is a
   dotted line rather than thirty days. */
.dl-dot.is-quiet { opacity: 0; }
.dl-dot.is-hot { opacity: 1; fill: var(--growth-4); stroke: var(--card); stroke-width: 2.5; r: 5.5; }
.dl-dot.is-picked.is-hot { fill: var(--seal); }
.dl-guide { stroke: color-mix(in srgb, var(--fg) 26%, transparent); stroke-width: 1.5; }
.dl-guide.is-picked { stroke: color-mix(in srgb, var(--seal) 45%, transparent); }
.dl-guide[hidden] { display: none; }

/* ── The forecast's bars ────────────────────────────────────────────────────
   Jade for the days ahead, seal for the backlog, and the difference between
   them is the whole point of the picture: everything to the right of the
   first bar is work FSRS has scheduled, and the first bar is work it already
   asked for and did not get. Painting the two the same colour would turn a
   sentence a teacher can act on into a shape. */
.dl-bar { fill: var(--growth-3); }
.dl-bar.is-today { fill: var(--growth-4); }
.dl-base { stroke: var(--hair); stroke-width: 1.5; }
/* The backlog, above the chart and at its own scale — it is eight hundred
   against days of forty, and inside the chart it made fourteen days of bars
   into fourteen hairlines. */
.backlog {
  display: flex; align-items: baseline; gap: 9px; margin: 0 0 14px;
  padding: 10px 13px; border-radius: 10px;
  background: color-mix(in srgb, var(--seal) 9%, transparent);
  font-size: var(--fs-fine); color: var(--seal-text);
}
.backlog b { font-family: 'Fraunces', Georgia, serif; font-size: 22px;
  font-weight: 600; font-variant-numeric: tabular-nums; }
/* The way out is where the way in was — clicking the same point again — and
   this chip is the second one, because a reader who has forgotten which point
   they pressed should not have to hunt for it. */
.picked {
  display: inline-flex; align-items: center; gap: 7px;
  border: 1px solid color-mix(in srgb, var(--seal) 34%, transparent);
  background: color-mix(in srgb, var(--seal) 9%, var(--card));
  color: var(--seal-text); font: inherit; font-size: var(--fs-fine); font-weight: 600;
  padding: 3px 9px; border-radius: 999px; cursor: pointer;
}
.picked:hover { background: color-mix(in srgb, var(--seal) 16%, var(--card)); }
.picked span { font-weight: 400; opacity: .7; }

/* ── The invite dialog (#2157, #2158) ─────────────────────────────────────
   Two things a teacher does with it, side by side: READ THE CODE OUT (the
   plate on the left, the largest type on the platform) and SEND THE PICTURE
   (the right, which is the PNG itself: see sheet.js for why the dialog shows
   the exported blob rather than markup that looks like it).

   The DOM sheet that lived here is gone with the second renderer it needed.
   `.sheet-steps` stays below: the school's flyer block in settings numbers its
   steps with it. */
dialog.inv-dialog { max-width: min(860px, calc(100% - 32px)); }
.inv {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
  gap: 18px; align-items: stretch;
}
.inv-k {
  font-size: var(--fs-fine); color: var(--muted); font-weight: 600;
  letter-spacing: .08em; text-transform: uppercase;
}
/* The plate. Paper, like the picture beside it, and everything on one axis:
   label, code, date, button, each centred on the same line down the middle. */
.inv-code {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 12px; text-align: center; padding: 30px 22px;
  background: linear-gradient(168deg, var(--paper-card), var(--paper));
  border: 1px solid var(--hair); border-radius: 16px;
}
/* The one thing that has to be readable across a room. Two groups of four
   with a real gap between them, tracked, and the tracking taken back off the
   last letter of each group so the ink, not the advance, is what is centred. */
.inv-big {
  margin: 4px 0 0; display: flex; justify-content: center; flex-wrap: wrap; gap: 0 .42em;
  font-family: 'Fraunces', Georgia, serif; font-weight: 600; line-height: 1;
  font-size: clamp(34px, 9vw, 66px); color: var(--seal);
  font-variant-numeric: lining-nums; user-select: all;
}
.inv-big span { letter-spacing: .1em; margin-right: -.1em; }
.inv-exp { margin: 0; }
.inv-copy { margin-top: 6px; padding: 10px 18px; font-size: var(--fs-small); }
.inv-copy svg, .inv-foot svg { width: 15px; height: 15px; fill: none; stroke: currentColor;
  stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }

.inv-sheet { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.inv-sheet-h { display: flex; align-items: center; gap: 4px 8px; flex-wrap: wrap; }
.inv-sheet-h .spacer { flex: 1 1 auto; }
/* The picture's language: four short labels, the same family as `.curve-pick`
   at a smaller size, because it is a setting of the picture and not of the page. */
.inv-langs { display: inline-flex; gap: 3px; }
.inv-langs button {
  border: 1px solid transparent; background: none; color: var(--muted);
  font: inherit; font-size: 12px; font-weight: 600; padding: 3px 7px; white-space: nowrap;
  border-radius: 999px; cursor: pointer;
}
.inv-langs button:hover { color: var(--fg); }
.inv-langs button[aria-checked="true"] {
  background: var(--bg-sunken); color: var(--fg);
  border-color: color-mix(in srgb, var(--fg) 16%, transparent);
}
/* 4:5, the picture's own shape, reserved before it arrives so the dialog does
   not jump when the canvas finishes. */
.inv-pic {
  aspect-ratio: 4 / 5; border-radius: 12px; overflow: hidden;
  background: var(--bg-sunken); display: grid; place-items: center;
  box-shadow: 0 14px 30px -22px rgba(26, 24, 19, .55);
}
/* Once the picture is in, it decides the height: a long school name makes it
   taller than 4:5, and letterboxing it would show paper that is not on it. */
.inv-pic:has(img) { aspect-ratio: auto; }
.sheet-img { display: block; width: 100%; height: auto; }
.inv-acts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.inv-acts .btn:only-child { grid-column: 1 / -1; }
.inv-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  padding-top: 12px; border-top: 1px solid var(--hair); }
.inv-foot .spacer { flex: 1 1 auto; }
.inv-foot .fine { margin: 0; }
@media (max-width: 720px) {
  .inv { grid-template-columns: 1fr; }
  .inv-sheet { max-width: 340px; width: 100%; margin: 0 auto; }
  .inv-foot .fine { flex: 1 1 100%; }
}

/* Three numbered lines rather than one arrowed path. The school's flyer block
   in settings numbers its steps with these. */
.sheet-steps {
  list-style: none; counter-reset: s; margin: 16px 0 0; padding: 0;
  display: flex; flex-direction: column; gap: 7px; width: 100%;
}
.sheet-steps li {
  counter-increment: s; display: flex; align-items: center; gap: 10px;
  font-size: var(--fs-small); color: var(--fg); text-align: left;
}
.sheet-steps li::before {
  content: counter(s); flex: 0 0 auto;
  width: 20px; height: 20px; border-radius: 50%;
  background: var(--seal); color: var(--seal-on);
  font-size: 11px; font-weight: 700; display: grid; place-items: center;
}


/* Three small figures under a learner's chart, where the class page ranks who.
   The same `.stats` grid, at three columns and without the top border the
   six-tile identity strip carries — inside a card it is already framed. */
.stats-3 { grid-template-columns: repeat(3, 1fr); margin-top: 16px; }
@media (max-width: 560px) { .stats-3 { grid-template-columns: 1fr; } }
.stats-3 .stat-n { font-size: 21px; }

/* A learner's lane has no `part` band — one person either has an item or does
   not — so the bar is a single fill and the summary drops a column. */
.lane-solo { }

/* ── The school's flyer on the settings page ──────────────────────────────
   The sheet at a fifth of its size beside the three steps. The frame is the
   real A5 page (148 × 210 mm, 559 × 794 CSS px) scaled down, so the thumbnail
   is the flyer rather than a drawing of it; see `flyerBlock` in views.js.
   `pointer-events: none` on the frame so the whole picture is the link. */
.flyer-invite {
  display: flex; gap: 22px; flex-wrap: wrap; align-items: flex-start; margin-top: 10px;
}
.flyer-thumb {
  --s: .3; flex: 0 0 auto; display: block; overflow: hidden;
  width: calc(559px * var(--s)); height: calc(794px * var(--s));
  border: 1px solid var(--hair); border-radius: 6px; background: var(--card);
  box-shadow: 0 10px 26px -18px rgba(26, 24, 19, .55);
  transition: transform .15s ease, box-shadow .15s ease;
}
.flyer-thumb:hover { transform: translateY(-2px); box-shadow: 0 16px 32px -18px rgba(26, 24, 19, .6); }
.flyer-thumb iframe {
  width: 559px; height: 794px; border: 0; display: block;
  transform: scale(var(--s)); transform-origin: 0 0; pointer-events: none;
}
.flyer-how { flex: 1 1 260px; display: flex; flex-direction: column; gap: 12px; }
.flyer-how p { margin: 0; }
/* The student sheet's steps are one line each; these run to two. */
.flyer-steps { margin: 0; }
.flyer-steps li { align-items: flex-start; line-height: 1.45; }
/* The second way in, under the flyer: a heading one step below the section's. */
.sub-h { margin: 22px 0 0; font-size: 14px; font-weight: 600; }

/* ── Lernerfolg (#1970) ────────────────────────────────────────────────────
   Six figures over four weeks, a twelve-week line hanging off them (the same
   join the learner page's tiles and timeline use), and one row per learner.
   The row is a table on a desk and a small card on a phone, where each figure
   carries its own label (`data-l`) because there is no header row to read. */
.stats-succ .stat-n small { font-family: 'Inter', sans-serif; font-size: .5em; font-weight: 500; color: var(--muted); }
.stats-succ .kpi-d { margin-top: 6px; }
.stats-succ .stat-l { display: flex; align-items: center; }
.stats-succ .info-b { margin-left: 5px; }
.stats-succ .info-b svg { width: 15px; height: 15px; }
.succ-top { margin-bottom: 14px; }
.succ { display: flex; flex-direction: column; }
.succ-h, .succ-row {
  display: grid; align-items: center; gap: 12px; padding: 10px 8px;
  grid-template-columns: minmax(150px, 1.5fr) repeat(6, minmax(64px, .8fr)) minmax(170px, 1.5fr);
}
.succ-h { padding-top: 0; font-size: var(--fs-fine); color: var(--muted); font-weight: 500; }
.succ-row { text-decoration: none; color: inherit; border-top: 1px solid var(--hair); border-radius: 10px; }
.succ-row:hover { background: var(--bg-sunken); }
.succ-who { display: flex; align-items: center; gap: 9px; min-width: 0; font-weight: 500; }
.succ-who > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.succ-v { display: flex; flex-direction: column; gap: 1px; font-weight: 600; font-variant-numeric: tabular-nums; }
.succ-v small { font-weight: 400; color: var(--muted); font-size: var(--fs-fine); }
.succ-v .kpi-d { margin-top: 0; font-size: 12px; font-weight: 500; }
.succ-st { min-width: 0; }
.succ-st .pill { white-space: normal; }
/* On track, in the growth green at a tint: a verdict, not a trophy. */
.pill.ok { background: color-mix(in srgb, var(--growth-4) 14%, transparent); border-color: transparent; color: #2C5B33; }
.succ-verdict {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px;
  padding: 11px 13px; border-radius: 10px; background: color-mix(in srgb, var(--growth-4) 7%, var(--card));
}
.succ-verdict.is-risk { background: color-mix(in srgb, var(--amber) 9%, var(--card)); }
.succ-verdict p, .succ-why { margin: 0; font-size: var(--fs-small); color: var(--fg-2); }
.succ-why { padding-left: 18px; }
@media (max-width: 1180px) {
  .succ-h, .succ-row { grid-template-columns: minmax(130px, 1.3fr) repeat(6, minmax(58px, .8fr)) minmax(140px, 1.3fr); gap: 8px; }
}
@media (max-width: 960px) {
  .succ-h { display: none; }
  .succ-row { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 10px; padding: 12px 8px; }
  .succ-who, .succ-st { grid-column: 1 / -1; }
  .succ-v::before {
    content: attr(data-l); display: block; margin-bottom: 2px;
    font-size: 12px; font-weight: 500; color: var(--muted);
  }
}
.succ-v > span small { margin-left: 2px; }
/* Legends keep each label whole; on a phone the pair wraps as a pair. */
.gr-key { flex-wrap: wrap; row-gap: 4px; }
.gr-key > span { white-space: nowrap; }
@media (max-width: 960px) { .succ-row { align-items: start; } }
/* The reading line may wrap on a phone: held to one line it was wider than
   the screen and made the whole page scroll sideways (#2161). */
@media (max-width: 720px) { .gr-read { white-space: normal; } }
