/* The component layer.
 *
 * Load after tokens.css and before the page's own sheet. Everything here is
 * --tau-* only, by rule: this file is the third stylesheet but must never
 * become a third vocabulary.
 *
 * What belongs here: a concept the app renders on more than one surface. The
 * split that produced two divergent vocabularies was style.css and report.css
 * each building the same eight concepts separately — a turn, a card, a meter,
 * a legend, a band, a sparkline, a confirmation, four dimensions. Those eight
 * are defined once, below. A page sheet may lay them out; it may not redefine
 * them.
 *
 * Sections
 *   1. micro-label      6. turn shell
 *   2. theme toggle     7. origin / provenance
 *   3. buttons          8. legend
 *   4. band + meters    9. trajectory sparkline
 *   5. card            10. avatar · field · confirm · offline
 *                      11. chip · dot · stat-tile · tray · list-row
 *                      12. global nav
 *                      13. rail
 *                      14. menu (dropdown + account menu)
 */

/* Bare utility, not a numbered section — needed here because report.html
   never loads style.css (where the workspace's own copy lives), yet the
   shared nav markup this file styles (.tau-nav-local) already depends on
   toggling this class from api.js's renderNavLocal. Without it, report.html
   had no rule for the class at all. */
.hidden { display: none !important; }

/* ---------- 1. micro-label ---------- */

/* Ten near-identical uppercase labels existed across the sheets at four sizes
   and three tracking values. One is enough.

   SENTENCE CASE since 2026-08-22 (m3-lab §6b). M3's type scale has no all-caps
   role — that was M2's overline — and the eight surviving uppercase treatments
   across these sheets were demoting a label through four channels (size,
   weight, colour, caps) where three already did it. Caps also cost real
   legibility at 10-11px, which is where every one of them sat.
   The two places caps SURVIVE are not labels: .turn-speaker and the auditor's
   mono eyebrow, where caps carry a voice rather than a rank. */
.eyebrow {
  font-size: var(--tau-text-label-md);
  font-weight: 600;
  color: var(--tau-ink-label);
}

/* ---------- 2. theme toggle ---------- */

/* Sized to --tau-target rather than to its glyph. An icon-only control is the
   exact case where a comfortable-looking button ships at 20px. */
.theme-toggle {
  width: var(--tau-target);
  height: var(--tau-target);
  flex-shrink: 0;
  padding: 0;
  display: grid;
  place-items: center;
  /* Headers here align on the baseline; a grid-centred glyph would hang low. */
  align-self: center;
  border: 1px solid transparent;
  border-radius: var(--tau-r-pill);
  background: none;
  color: var(--tau-ink-soft);
  font-size: var(--tau-text-lg);
  line-height: 1;
  cursor: pointer;
  font-family: inherit;
}
.theme-toggle:hover {
  background: var(--tau-hover-surface);
  border-color: var(--tau-line);
  color: var(--tau-ink);
}

/* Login has no header to sit in. */
.theme-toggle-floating {
  position: fixed;
  top: var(--tau-s3);
  right: var(--tau-s3);
  z-index: 1;
}

/* Where no sibling already claims the free space with margin-left:auto. */
.theme-toggle-end { margin-left: auto; }

/* ---------- 3. buttons ---------- */

/* min-height is --tau-target rather than padding-derived, so a button can never
   ship under the floor by being given tighter padding later. */
.btn {
  font: inherit;
  font-size: var(--tau-text-md);
  font-weight: 600;
  line-height: 1;
  /* An <a class="btn"> is a real use of this component (a button whose action
     is "go somewhere"), and without this it carries the UA underline. */
  text-decoration: none;
  border: 1px solid transparent;
  /* One shape for every variant since 2026-08-22 (m3-lab §3). M3 ranks button
     variants by FILL, never by corner — .btn-primary was a pill while quiet,
     danger and tertiary were --tau-r-md, so shape was carrying a rank signal
     that fill already carries. */
  border-radius: var(--tau-r-pill);
  padding: 0 var(--tau-s4);
  /* Drawn at 40px, not at the 44px target. The floor is met by the
     transparent expansion below, so a control reaches it without the box
     growing to suit. */
  min-height: var(--tau-h-control);
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--tau-s2);
  cursor: pointer;
  transition: background var(--tau-dur-short) var(--tau-ease-standard), border-color var(--tau-dur-short) var(--tau-ease-standard), transform var(--tau-dur-short) var(--tau-ease-standard);
}
/* The target expansion. Invisible, changes nothing drawn, and it is why the
   heights above are allowed to be smaller than --tau-target. */
.btn::after, .avatar-trigger::after, .menu-trigger::after {
  content: ""; position: absolute; left: 0; right: 0;
  top: 50%; transform: translateY(-50%);
  height: var(--tau-target); pointer-events: none;
}
.btn:disabled { opacity: var(--tau-disabled); cursor: not-allowed; }
.btn:active:not(:disabled) { transform: scale(0.97); }

/* A raised primary action reads as "press me," not just "forest text on a
   rect" — the elevation scale below applies to the one button per screen
   that's actually the primary move, not to every button. The radius that
   used to live here moved to .btn: every variant is a pill now. */
.btn-primary {
  background: var(--tau-forest);
  color: var(--tau-on-forest);
  box-shadow: 0 3px 8px -3px color-mix(in oklab, var(--tau-forest) 45%, transparent);
}
.btn-primary:hover:not(:disabled) { background: var(--tau-forest-lift); }

.btn-quiet {
  background: var(--tau-surface);
  color: var(--tau-forest);
  border-color: var(--tau-line-strong);
}
.btn-quiet:hover:not(:disabled) { background: var(--tau-surface-2); }

/* The auditor button wears the auditor's hue because pressing it summons that
   voice — the only place a non-forest accent is allowed on a control. */
.btn-auditor {
  background: var(--tau-auditor-bg);
  color: var(--tau-auditor);
  border-color: color-mix(in oklab, var(--tau-auditor) 30%, transparent);
}
.btn-auditor:hover:not(:disabled) { border-color: var(--tau-auditor); }

/* Chip height. Under --tau-target as drawn, at it as a target — .btn::after
   covers this variant too, so the old "pointer-dense toolbars only" caveat no
   longer applies and has been removed rather than left as folklore. */
.btn-sm { min-height: var(--tau-h-chip); font-size: var(--tau-text-sm); padding: 0 var(--tau-s3); }

/* The confirm button on an irreversible action, and the only filled control in
   the app that isn't forest. Attention rather than caution: deleting an
   assignment is a real one-way action, not a conversation-starter — the
   caution/attention split this app holds everywhere else applies to the
   teacher's own actions too, not just to student signals. */
.btn-danger { background: var(--tau-attention); border-color: var(--tau-attention); color: var(--tau-surface); }
.btn-danger:hover:not(:disabled) { background: color-mix(in oklab, var(--tau-attention) 85%, black); border-color: color-mix(in oklab, var(--tau-attention) 85%, black); }

/* No fill, no border — but still a button (padding, rounded hover fill),
   not a bare underlined link, for an action that sits inline with real
   buttons elsewhere on the same row. */
.btn-tertiary { background: none; border-color: transparent; color: var(--tau-forest); }
.btn-tertiary:hover:not(:disabled) { background: color-mix(in oklab, currentColor var(--tau-state-hover), transparent); }

/* ---------- 3b. icons ----------
   M3-style, self-hosted (icons.js) — see that file for why not the Material
   Symbols webfont. currentColor by default, so an icon inside a button or a
   coloured chip always matches its own text without a separate colour rule. */
.tau-icon { width: 16px; height: 16px; flex-shrink: 0; }

/* ---------- 3c. disclosure ----------
   Any content that's referenced repeatedly rather than read once (a rubric,
   a teacher's note, a long turn list folded behind "show all N") — a real
   label, not default browser <details> chrome, and forest/auditor-tinted
   even closed so it reads as "clickable" at rest, not just on hover. That
   at-rest tint is the point: a plain grey chip here would be indistinguishable
   from a static, non-interactive one (a stat chip, a flag label). Icon is a
   real chevron element (rotates on open), not a text glyph.
   Shared base + a colour modifier rather than near-identical copies per
   caller — the prompt (sage/forest) and a teacher's note (auditor) are the
   same interaction wearing a different voice. */
.acard-disclosure { margin: 9px 0 0; }
.acard-disclosure-prompt { --tau-disclosure-bg: var(--tau-origin-together-bg); --tau-disclosure-fg: var(--tau-forest); }
.acard-disclosure-note   { --tau-disclosure-bg: var(--tau-auditor-bg);         --tau-disclosure-fg: var(--tau-auditor); }
.acard-disclosure-toggle {
  cursor: pointer; user-select: none; list-style: none;
  display: inline-flex; align-items: center; gap: 7px;
  max-width: 100%;
  padding: 7px 12px;
  border-radius: var(--tau-r-pill);
  background: var(--tau-surface-2);
  font-size: 12.5px; font-weight: 600; color: var(--tau-disclosure-fg);
  transition: background var(--tau-dur-short) var(--tau-ease-standard), border-radius var(--tau-dur-short) var(--tau-ease-standard);
}
.acard-disclosure-toggle::-webkit-details-marker { display: none; }
.acard-disclosure-toggle:hover { background: var(--tau-surface-3); }
/* The note chip carries the auditor tint even closed, not just its text —
   colour is what signals "this carries weight" at a glance. The rubric chip
   stays neutral: it's not competing for attention, it's just recall. */
.acard-disclosure-note .acard-disclosure-toggle { background: var(--tau-auditor-bg); }
.acard-disclosure-note .acard-disclosure-toggle:hover { background: color-mix(in oklab, var(--tau-auditor-bg) 75%, var(--tau-ink) 8%); }
.acard-disclosure-icon { width: 12px; height: 12px; flex-shrink: 0; transition: transform var(--tau-dur-short) var(--tau-ease-standard); }
details[open] > .acard-disclosure-toggle .acard-disclosure-icon { transform: rotate(180deg); }
details[open] > .acard-disclosure-toggle {
  display: flex; width: 100%;
  background: var(--tau-disclosure-bg); color: var(--tau-ink);
  border-radius: var(--tau-r-md) var(--tau-r-md) 0 0;
}
.acard-disclosure p {
  margin: 0; padding: 12px 14px;
  background: var(--tau-disclosure-bg); border-radius: 0 0 var(--tau-r-md) var(--tau-r-md);
  font-size: var(--tau-text-md);
  line-height: 1.65; color: var(--tau-ink);
  animation: tau-reveal var(--tau-dur-medium) var(--tau-ease-decelerate);
}
.acard-disclosure-prompt p { font-family: var(--tau-font-serif); }

/* ---------- 4. band chip + meters ---------- */

/* Plain language leads; SAMR rides alongside in .band-sub, never inside the
   chip. The pip is a second channel so the band survives greyscale. */
.band {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: var(--tau-s1) 11px;
  border-radius: var(--tau-r-pill);
  font-size: 12.5px;
  font-weight: 600;
  box-shadow: inset 0 0 0 1px color-mix(in oklab, currentColor 16%, transparent);
}
.band .band-pip { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.75; }
.band-1 { background: var(--tau-band-1-bg); color: var(--tau-band-1-fg); }
.band-2 { background: var(--tau-band-2-bg); color: var(--tau-band-2-fg); }
.band-3 { background: var(--tau-band-3-bg); color: var(--tau-band-3-fg); }
.band-4 { background: var(--tau-band-4-bg); color: var(--tau-band-4-fg); }
.band-sub { font-size: var(--tau-text-sm); color: var(--tau-ink-faint); }

/* Quiet variant — added 2026-07-29. Same colour + pip as .band-1..4, no chip
   chrome (fill/inset border). For a dense row (dashboard roster) that
   already carries a genuine actionable chip beside it — a SAMR band is
   classification, not something to act on, and giving it the same filled-
   pill weight as a real alert is what buried the alert in the first place.
   The pip still carries the colour for at-a-glance scanning; only the
   background/box-shadow that made it read as equally salient is dropped.
   With no fill, no border and (since 2026-07-29) no pip, this variant is
   plain text — so it is sized as text: it inherits its container's font-size
   instead of keeping .band's 12.5px, and drops to weight 500. In the roster
   it was rendering smaller than the uppercase column header above it, which
   made the row's most important sentence its least legible element.
   Consumers that want it smaller still set their own font-size. */
.band-plain { background: none; box-shadow: none; padding: 0; font-size: inherit; font-weight: 500; }

/* Same SAMR ramp as .band-1..4 above, applied as a border-left accent instead
   of a filled pill — for surfaces that want the level to read as a stripe on
   an existing element (a card, a chip, a collapsible row) rather than its own
   badge. One mapping, two treatments, both documented here — not a second
   copy of the ramp. Consumers set their own border-left-width. */
.level-passive { border-left-color: var(--tau-band-1-fg); }
.level-reactive { border-left-color: var(--tau-band-2-fg); }
.level-directive { border-left-color: var(--tau-band-3-fg); }
.level-transformative { border-left-color: var(--tau-band-4-fg); }

/* Five discrete steps, because the engine emits 1–5. A continuous bar would
   claim a precision the model does not have — see the locked scoring decision. */
.steps { display: flex; gap: 3px; }
.steps i { flex: 1; height: 8px; border-radius: 2px; background: var(--tau-surface-3); }
.steps i.on { background: var(--tau-sage); }
.steps-sm i { height: 6px; }

/* The continuous meter is for quantities that genuinely are continuous — a
   budget consumed, a proportion of concepts. Never for a dimension score:
   .steps is the component for those. Unfilled track is a lighter step of the
   same ramp, so state reads across the whole bar rather than the fill floating
   on empty space. */
.meter-track {
  display: block;
  height: 7px;
  background: var(--tau-meter-track);
  border-radius: 4px;
  overflow: hidden;
}
.meter-fill { display: block; height: 100%; background: var(--tau-forest); border-radius: 4px; }

/* ---------- 4b. four dimensions ---------- */

/* The engine emits four dimensions at 1–5. This used to render as a
   four-column strip (name + score) sitting above a separate 2×2 grid (name
   again + explanation), so the name and the PQ/SU/CS/OC code each appeared
   twice and the score was split from the reasoning behind it. One quadrant
   grid now carries all three: name once, score as a single instrument,
   one callout line. No value-keyed colour anywhere in the block: a 2 must
   not render in the same red as an error, because a level is a position on
   a path, not a verdict. */
/* No overflow: hidden — a quad has no background of its own to clip against
   the rounded border, and hiding overflow here was clipping the dimension
   tooltip against the grid's own bounding box instead of letting it float
   above the card. */
.dim-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  border: 1px solid var(--tau-line);
  border-radius: var(--tau-r-md);
}
.dim-quad {
  padding: var(--tau-s4) var(--tau-s5);
  border-right: 1px solid var(--tau-line);
  border-bottom: 1px solid var(--tau-line);
  display: flex;
  flex-direction: column;
  gap: var(--tau-s3);
}
.dim-grid .dim-quad:nth-child(2n) { border-right: none; }
.dim-grid .dim-quad:nth-last-child(-n+2) { border-bottom: none; }
/* Zone 1 — the full name, said once. No PQ/SU/CS/OC badge: that was a code a
   reader had to learn, and the full name already fits on one line. The info
   button sits inline rather than on its own head row — same tooltip, no
   extra row height. */
.dim-quad-name {
  display: flex; align-items: center; gap: var(--tau-s2);
  font-size: var(--tau-text-lg); font-weight: 600; color: var(--tau-ink);
}
/* Zone 2 — the score as one instrument: the number and its five-pip .steps
   bar read as a single fact on one row, not stacked as two. Forest,
   uniformly, regardless of value — brand identity on the number's face, not
   a verdict painted onto a low score (the report hero's band pill is the one
   place score value gets its own colour). */
.dim-quad-score { display: flex; align-items: center; gap: var(--tau-s4); }
.dim-quad-score-n { display: flex; align-items: baseline; gap: 3px; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.dim-quad-score-n .n { font-size: var(--tau-text-2xl); font-weight: 700; color: var(--tau-forest); line-height: 1; }
/* The denominator is never optional — a bare "2" is a mark, "/5" is a
   position. Same reason the total always carries its band. */
.dim-quad-score-n .of { font-size: var(--tau-text-sm); color: var(--tau-ink-faint); }
.dim-quad-steps { flex: 1; }
.dim-quad-steps i { height: 9px; }
/* Zone 3 — the one thing this dimension is calling out: a quiet evidence
   clause plus the coaching sentence, joined in one paragraph instead of two
   stacked ones. Same sage rule the old .dim-nudge used for "this is the
   takeaway" — now load-bearing instead of competing with a second paragraph
   above it. Neutral, and deliberately not keyed to the score: reinforcing
   and coaching nudges must not read as good/bad via colour. */
.dim-quad-callout {
  font-size: var(--tau-text-sm);
  line-height: var(--tau-leading-snug);
  color: var(--tau-ink);
  border-left: 2px solid var(--tau-sage);
  padding-left: 10px;
}
.dim-evidence { color: var(--tau-ink-soft); }

@media (max-width: 640px) {
  .dim-grid { grid-template-columns: 1fr; }
  .dim-grid .dim-quad:nth-child(2n) { border-right: none; }
  .dim-grid .dim-quad:nth-last-child(-n+2) { border-bottom: 1px solid var(--tau-line); }
  .dim-grid .dim-quad:last-child { border-bottom: none; }
}

/* ---------- 5. card ---------- */

/* Six card-shaped things existed across the sheets. The differences that
   actually carried meaning were the edge rules; everything else was drift. */
.card {
  background: var(--tau-surface);
  border: 1px solid var(--tau-line);
  border-radius: var(--tau-r-md);
  padding: var(--tau-s4) 18px;
  display: flex;
  flex-direction: column;
  gap: 9px;
  /* Depth, not just a boundary — a hairline says "here is an edge," a soft
     shadow says "this is raised off the page."
     ELEVATED, not both, since 2026-08-22 (m3-lab §8): M3's card variants are
     filled, outlined OR elevated and no variant carries an outline and a
     shadow together. The border stayed additive when the content pane was
     white and the shadow had nothing to cast against; --tau-shell gives it a
     ground now. .card-quiet is the filled variant, and any card that needs a
     drawn edge takes .card-outlined below rather than getting both back. */
  border-color: transparent;
  box-shadow: var(--tau-shadow);
}
.card-lg { border-radius: var(--tau-r-lg); padding: var(--tau-s5) 22px; gap: var(--tau-s3); }
/* M3's outlined variant: an edge INSTEAD of elevation, never as well. For a
   card that sits on white, where a shadow has nothing to cast against. */
.card-outlined { border-color: var(--tau-line); box-shadow: none; }

/* The one panel per screen that's the actual takeaway (the report's score,
   not its tabs) gets one more step of both radius and elevation, so shape
   itself signals "this is the important one" before anything is read. */
.card-hero { border-radius: var(--tau-r-xl); border-color: transparent; box-shadow: var(--tau-shadow-lift); }
/* Recessed rather than raised — for a block sitting inside another card. */
.card-quiet { background: var(--tau-surface-2); border-color: transparent; }
.card-grid { display: grid; gap: var(--tau-s4); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }

/* Urgency earns a rule on the card edge, not a recoloured card — a recoloured
   card makes an ordinary deadline look like an error state. An inset rule,
   not a border-left: .card is borderless-and-shadowed now, and a hard border
   sitting on top of a soft shadow was exactly the flat-vs-raised seam the
   depth pass was trying to erase everywhere else. Composes with the plain
   .card elevation (var(--tau-shadow)) rather than card-hero's, since edge
   classes are only ever applied to plain-tier cards today (the assignment
   card) — revisit if a hero-tier card ever needs an edge too. */
.card-edge-caution   { box-shadow: var(--tau-shadow), inset 3px 0 0 var(--tau-caution); }
.card-edge-attention { box-shadow: var(--tau-shadow), inset 3px 0 0 var(--tau-attention); }
/* The auditor's own form, squared on the ruled side. Matches .turn-auditor so
   the second voice looks the same wherever it speaks. */
.card-edge-auditor {
  border-left: 3px solid var(--tau-auditor);
  border-radius: 0 var(--tau-r-md) var(--tau-r-md) 0;
  background: var(--tau-auditor-bg);
}

.card-head { display: flex; align-items: baseline; gap: var(--tau-s3); flex-wrap: wrap; }
.card-head h3, .card-head h4 { font-size: var(--tau-text-md); font-weight: 600; }

/* ---------- 6. turn shell ---------- */

/* One conversation turn, wherever it is read: live in the chat, or replayed on
   the report and the teacher's transcript. Speaker label above the bubble
   rather than inside it, so a screen reader announces who is talking before
   what they said. */
.turn { display: flex; flex-direction: column; gap: 5px; max-width: 72%; }

/* A percentage is the right cap for a student turn (short, and it should hug
   its own edge) and the wrong one for an AI turn, which is where the long prose
   is: 72% of a wide workspace runs past 100 characters a line. The ch cap holds
   the measure inside the 45–75 range whatever the window does. The auditor is
   left alone — its full-bleed form is a locked decision below. */
.turn-coach { max-width: min(72%, 68ch); }

/* Was --tau-ink-faint. At 10.5px uppercase that sits below the threshold where
   you register the speaker before reading the bubble, which leaves attribution
   resting entirely on which side the bubble is on. */
.turn-speaker {
  font-size: 10.5px;
  letter-spacing: var(--tau-track-caps);
  text-transform: uppercase;
  color: var(--tau-ink-soft);
  font-weight: 600;
}
.msg {
  padding: 12px var(--tau-s4);
  border-radius: var(--tau-r-lg);
  line-height: var(--tau-leading-body);
  font-size: var(--tau-text-lg);
  white-space: pre-wrap;
  position: relative;
}

/* A rendered AI turn. `pre-wrap` above is what a plain-text bubble needs, and
   exactly what a rendered one must not have — the blank lines between blocks
   would be preserved on top of the margins below. Student turns keep it. */
.msg-prose { white-space: normal; }
.msg-prose > :first-child { margin-top: 0; }
.msg-prose > :last-child { margin-bottom: 0; }
.msg-prose p { margin: var(--tau-s3) 0; }

/* A heading inside a chat bubble is a lead-in, not a document level: same size
   as the body, carrying weight and a little space above it and nothing else. */
.msg-prose .md-head { font-weight: 600; margin-top: var(--tau-s4); }

.msg-prose ul, .msg-prose ol { margin: var(--tau-s3) 0; padding-left: var(--tau-s5); }
.msg-prose li { margin: var(--tau-s1) 0; padding-left: var(--tau-s1); }
.msg-prose li::marker { color: var(--tau-ink-faint); }
.msg-prose strong { font-weight: 650; }

.msg-prose code {
  font-family: var(--tau-font-mono);
  font-size: 0.88em;
  background: var(--tau-surface-2);
  border-radius: var(--tau-r-sm);
  padding: 1px 5px;
}
.msg-prose pre {
  margin: var(--tau-s3) 0;
  padding: var(--tau-s3);
  background: var(--tau-surface-2);
  border-radius: var(--tau-r-sm);
  overflow-x: auto;
}
.msg-prose pre code { background: none; padding: 0; white-space: pre; }
.msg-prose hr { border: none; border-top: 1px solid var(--tau-line); margin: var(--tau-s4) 0; }

.turn-student { align-self: flex-end; align-items: flex-end; }
.turn-student .msg { background: var(--tau-forest); color: var(--tau-on-forest); border-bottom-right-radius: 4px; }

.turn-coach { align-self: flex-start; }
.turn-coach .msg { background: var(--tau-surface); border: 1px solid var(--tau-line); border-bottom-left-radius: 4px; }

/* The auditor is separated from the coach by form as well as hue: squared, a
   ruled edge, a monospace label, full width. Hue alone would not survive a
   colour-blind reader, and it must never look like a chat partner — it isn't one. */
.turn-auditor { align-self: stretch; max-width: 100%; }
.turn-auditor .turn-speaker { color: var(--tau-auditor); font-family: var(--tau-font-mono); letter-spacing: var(--tau-track-label); }
.turn-auditor .msg {
  background: var(--tau-auditor-bg);
  border-left: 3px solid var(--tau-auditor);
  border-radius: 0 var(--tau-r-sm) var(--tau-r-sm) 0;
  color: var(--tau-ink);
}
/* Permanent, undismissable framing: this is what makes "How am I doing?" safe
   to press. Remove it and the button becomes a score request. */
.auditor-disclaimer {
  display: block;
  margin-top: 7px;
  font-size: var(--tau-text-sm);
  color: var(--tau-ink-soft);
}

/* A turn the server has not confirmed yet. Dimmed, never removed — every turn
   is persisted as sent, so the state may say "not yet", never "lost". */
.turn-pending .msg { opacity: var(--tau-disabled); }

/* A turn a later edit replaced. Distinct from .turn-pending: this one did
   happen, it just isn't the version that counts anymore — struck through
   rather than dimmed, so the two states can't be mistaken for each other. */
.msg-superseded { opacity: 0.6; text-decoration: line-through; }
.queued { display: inline-flex; align-items: center; gap: 6px; font-size: var(--tau-text-xs); color: var(--tau-ink-faint); }
.queued i { width: 6px; height: 6px; border-radius: 50%; background: var(--tau-caution); flex: none; }

/* A pulled quote from a transcript, on the report or in a teacher's note. */
.quote {
  border-left: 2px solid var(--tau-sage);
  padding: var(--tau-s1) 0 var(--tau-s1) 14px;
  color: var(--tau-ink-soft);
  font-size: var(--tau-text-md);
  display: flex;
  flex-direction: column;
  gap: 5px;
}
.quote-src { font-size: var(--tau-text-xs); color: var(--tau-ink-faint); font-family: var(--tau-font-mono); }

/* ---------- 7. origin / provenance ---------- */

/* Authorship, not quality — so nothing in this ramp is ordered good-to-bad and
   no arrangement of it can accuse a student. Same you → together → coach
   vocabulary as the conversation map, so the two visuals teach one encoding.
   The hue sits in the fill and the text stays ink: sage and faint grey both
   fail contrast as text, and the chip's own words are the channel a
   colour-blind reader gets. */
.prov-bar { display: flex; height: 22px; border-radius: var(--tau-r-sm); overflow: hidden; }
.prov-you      { background: var(--tau-origin-you); }
.prov-together { background: var(--tau-origin-together); }
.prov-coach    { background: var(--tau-origin-coach); }

.origin-chip {
  font-size: var(--tau-text-xs);
  padding: 1px var(--tau-s2);
  border-radius: var(--tau-s2);
  white-space: nowrap;
  color: var(--tau-ink);
}
.origin-student-born, .origin-prior { background: var(--tau-origin-you-bg); }
.origin-synthesized { background: var(--tau-origin-together-bg); }
.origin-ai-born     { background: var(--tau-origin-coach-bg); }

/* ---------- 8. legend ---------- */

/* Three legends existed, one per visual. A legend is the same object every
   time: a row of samples, each sample paired with words. Swatch and rule are
   separate because a line-based chart must show dash and weight, not a block
   of colour — colour is never the only channel. */
.legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--tau-s2) var(--tau-s5);
  font-size: 12.5px;
  color: var(--tau-ink-soft);
}
.legend-item { display: inline-flex; align-items: center; gap: var(--tau-s2); }
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; flex: none; }
.legend-item svg { flex: none; }
.legend-divider { width: 1px; height: 14px; background: var(--tau-line); flex: none; }

/* ---------- 9. trajectory sparkline ---------- */

/* Direction of travel, next to the total — the shape alone, no caption. A
   text delta ("+2 since draft 2") used to travel with this by locked rule;
   dropped because the hero eyebrow now names the draft and the ring shows
   the current score, so the line's own rise or fall was the one thing left
   to say and saying it twice added nothing. */
.traj { display: inline-flex; align-items: center; gap: var(--tau-s3); }
.traj-svg { width: 100%; height: 46px; overflow: visible; display: block; }
.traj-line { fill: none; stroke: var(--tau-sage); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* Between two cycles the line is not a measurement, it is a gap. */
.traj-split { stroke: var(--tau-line); stroke-width: 1; stroke-dasharray: 2 3; }
.traj-dot { fill: var(--tau-sage); stroke: var(--tau-surface); stroke-width: 2; }
.traj-dot-now { fill: var(--tau-forest); }

/* ---------- 10. avatar · field · confirm · offline ---------- */

.avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--tau-surface-2);
  color: var(--tau-forest);
  display: grid;
  place-items: center;
  font-size: var(--tau-text-sm);
  font-weight: 700;
  flex-shrink: 0;
}
.avatar-sm { width: 26px; height: 26px; font-size: var(--tau-text-xs); }
.avatar-lg { width: 40px; height: 40px; font-size: var(--tau-text-md); }

.field { display: flex; flex-direction: column; gap: var(--tau-s1); }
.field > label { font-size: var(--tau-text-sm); color: var(--tau-ink-soft); }
.field input, .field textarea, .field select {
  font: inherit;
  font-size: var(--tau-text-md);
  color: var(--tau-ink);
  background: var(--tau-surface);
  border: 1px solid var(--tau-line-strong);
  border-radius: var(--tau-r-md);
  padding: 10px var(--tau-s3);
  min-height: var(--tau-target);
}
.field textarea { resize: vertical; }
/* Both channels. A 1px border hue change is not a focus indicator — the rule
   designsystem.md already states for the composer, applied here 2026-08-22
   (m3-lab §2). The outline:none this replaces was suppressing tokens.css's own
   :focus-visible baseline. */
.field input:focus, .field textarea:focus, .field select:focus {
  outline: var(--tau-focus-ring);
  outline-offset: var(--tau-focus-gap);
  border-color: var(--tau-forest);
}
.field-help { font-size: var(--tau-text-xs); color: var(--tau-ink-faint); }

/* A checkbox row — the horizontal counterpart to .field, which stacks a label
   above a control and is the wrong axis for a tick box. Promoted here from
   admin.html's page sheet (2026-08-15) when the class research-consent modal
   in dashboard.html became its second surface. */
.field-check { display: flex; gap: var(--tau-s3); align-items: flex-start; }
.field-check input { margin-top: 3px; width: 16px; height: 16px; accent-color: var(--tau-forest); flex-shrink: 0; }
.field-check label { font-size: var(--tau-text-sm); font-weight: 600; color: var(--tau-ink); cursor: pointer; }
.field-check .field-help { display: block; font-weight: 400; margin-top: 2px; }

/* A persistent label for a field whose neighbour is close enough in meaning
   that a placeholder alone (which vanishes on focus) isn't enough to keep
   them apart — see product-design-review/references/content-design.md. */
.micro-label { font-size: var(--tau-text-xs); font-weight: 600; color: var(--tau-ink-soft); display: block; margin-bottom: 5px; }
.req-mark { color: var(--tau-caution); margin-left: 3px; }

/* The field that names the thing everywhere else in the app refers back to
   (assignment lists, notifications, the coach) outranks its plain-text
   siblings — borrows .tray-title's own size/weight convention rather than
   inventing a new one. See references/content-hierarchy.md layer 3. */
.title-field { font-size: var(--tau-text-lg); font-weight: 600; }

.eyebrow-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--tau-s3); }
.link-btn { font-family: inherit; font-size: var(--tau-text-xs); font-weight: 600; color: var(--tau-forest); background: none; border: none; padding: 0; cursor: pointer; }
.link-btn:hover { text-decoration: underline; }

/* Lowest-priority, optional content — pulled down in weight to match, so it
   doesn't compete visually with required fields above it. */
.eyebrow.deemphasized { color: var(--tau-ink-faint); }
textarea.deemphasized { border-color: var(--tau-line); color: var(--tau-ink-soft); }

/* New-assignment form — dashboard.html's modal is the only consumer now;
   teacher.js's own inline duplicate (the collapsible variant this rule used
   to also serve) was retired 2026-08-03 in favor of linking to this one. */
.new-assignment-form { display: flex; flex-direction: column; gap: 10px; }
.form-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.form-row label { font-size: 13px; color: var(--tau-ink-soft); }
.draft-slot-rows { display: flex; flex-direction: column; gap: 6px; }
.draft-slot-row { display: flex; align-items: center; gap: 8px; }
.draft-slot-label { font-size: 13px; color: var(--tau-ink-soft); width: 52px; flex-shrink: 0; }

/* Containing the due-date/draft-budget/per-draft-rows group makes the
   causal relationship visible (changing the budget regenerates the rows
   below it) instead of leaving three loose controls with no visual link —
   see references/interaction-ux.md, "status visibility for causal
   relationships". */
.assign-schedule-panel { background: var(--tau-surface-2); border: 1px solid var(--tau-line); border-radius: var(--tau-r-lg); padding: var(--tau-s4); display: flex; flex-direction: column; gap: var(--tau-s3); }

.scrim {
  position: fixed;
  inset: 0;
  background: var(--tau-scrim);
  display: grid;
  place-items: center;
  padding: var(--tau-s4);
  z-index: 10;
}
/* Used for irreversible steps, which for a student means submitting a draft.
   The list is the component's whole point: it names every consequence,
   including the ones nobody predicts. Keep it a list; a paragraph hides
   the last item. */
.confirm {
  background: var(--tau-surface);
  /* Elevated, and at M3's dialog corner (28dp) — the one surface where the
     radius carries "this is a separate plane." Added 2026-08-22, m3-lab §9. */
  border: 1px solid transparent;
  border-radius: var(--tau-r-dialog);
  box-shadow: var(--tau-shadow-lift);
  width: min(460px, 100%);
  padding: 22px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.confirm h4 { font-size: 17px; font-weight: 600; }
.confirm p { font-size: var(--tau-text-md); color: var(--tau-ink-soft); }
.confirm ul {
  margin: 0;
  padding-left: 18px;
  font-size: var(--tau-text-md);
  color: var(--tau-ink-soft);
  display: flex;
  flex-direction: column;
  gap: 5px;
}
.confirm ul b { color: var(--tau-ink); font-weight: 600; }
.confirm-acts { display: flex; gap: 9px; justify-content: flex-end; flex-wrap: wrap; }
/* Widened variant for a confirm dialog whose content is more than a warning
   and two buttons — e.g. the submit-draft flow, which also holds an essay
   textarea. */
.confirm-lg { width: min(620px, 92vw); }

/* Caution, not attention: nothing is lost. Every turn is persisted server-side
   as sent, so this bar is allowed to promise the work is safe — and must, or
   a student retypes an answer the server already has. */
.offline-bar {
  display: flex;
  align-items: center;
  gap: var(--tau-s3);
  flex-wrap: wrap;
  padding: 10px 18px;
  background: var(--tau-caution-bg);
  color: var(--tau-caution);
  border-bottom: 1px solid var(--tau-line);
  font-size: var(--tau-text-sm);
}
.offline-bar b { font-weight: 600; }
.offline-bar .offline-act { margin-left: auto; }

/* ---------- 11. chip · dot · stat-tile · tray · list-row ---------- */

/* The generic status pill. Distinct from .band: .band always carries a pip
   and is reserved for the SAMR level; .chip is for everything else that used
   to improvise its own pill — submission status, a flag's category, an arc
   or reflection badge, a role label. Six independent copies of this same
   shape existed across dashboard.html and teacher.html before this section. */
.chip {
  display: inline-block;
  font-size: var(--tau-text-xs);
  font-weight: 600;
  padding: 2px var(--tau-s2);
  border-radius: var(--tau-r-pill);
  letter-spacing: 0.3px;
}
.chip-neutral   { background: var(--tau-band-3-bg); color: var(--tau-band-3-fg); }
.chip-grey      { background: var(--tau-surface-2); color: var(--tau-ink-soft); border: 1px solid var(--tau-line); }
.chip-positive  { background: var(--tau-positive-bg); color: var(--tau-positive); }
.chip-caution   { background: var(--tau-caution-bg); color: var(--tau-caution); }
.chip-attention { background: var(--tau-attention-bg); color: var(--tau-attention); }
/* An aggregate-score observation (e.g. "this class trails the others"), not
   a behavioural flag or a deadline problem — tool-info, not caution/
   attention, so it can't outrank either of those on the same row. */
.chip-tool-info { background: var(--tau-tool-info-bg); color: var(--tau-tool-info); }

/* A filled circle indicator. Colour rides on currentColor so a parent sets
   color: var(--tau-caution) etc. rather than this component making that
   call — the same caution dot means "worth a chat" in a table cell, a
   sidebar row, and a tray header, and must always be the one the voice
   rules already settled on (never --tau-attention for a behavioural signal). */
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
.dot-sm { width: 6px; height: 6px; }
/* The one colour a behavioural "worth a chat" signal is ever allowed —
   never --tau-attention, which is reserved for a real deadline problem. */
.dot-caution { color: var(--tau-caution); }
/* The real deadline problem itself — a closed assignment with a student who
   never submitted. Never used for a behavioural signal; see .dot-caution. */
.dot-attention { color: var(--tau-attention); }

/* Label/value pair in a tinted, bordered box — a count with what it counts.
   Colour is set by the consumer via style or a chip-* class reused here for
   the tint, so one tile serves a caution stat and a neutral one alike. */
.stat-tile {
  border-radius: var(--tau-r-md);
  padding: var(--tau-s4) var(--tau-s4);
  flex: 1;
  min-width: 110px;
}
.stat-tile-clickable { cursor: pointer; }
.stat-tile .stat-tile-val { font-size: var(--tau-text-2xl); font-weight: 800; line-height: 1; }
.stat-tile .stat-tile-label { font-size: var(--tau-text-xs); font-weight: 600; margin-top: 5px; opacity: 0.85; }

/* The right-sliding drawer. Mechanically distinct from .scrim/.confirm
   (transform-based slide-in vs. a centred grid) rather than a duplicate of
   it — for a detail view that wants to stay beside the list it drilled
   from, not interrupt it. */
.tray-overlay {
  display: none;
  position: fixed;
  inset: 0;
  background: var(--tau-scrim);
  z-index: 100;
}
.tray-overlay.open { display: block; }
.tray {
  position: fixed;
  top: 0;
  right: 0;
  width: 400px;
  height: 100%;
  background: var(--tau-surface);
  border-left: 1px solid var(--tau-line);
  box-shadow: var(--tau-shadow-lift);
  z-index: 101;
  display: flex;
  flex-direction: column;
  transform: translateX(100%);
  transition: transform var(--tau-dur-medium) var(--tau-ease-decelerate);
  overflow: hidden;
}
.tray.open { transform: translateX(0); }
.tray-header {
  display: flex;
  align-items: flex-start;
  gap: var(--tau-s3);
  padding: var(--tau-s5) var(--tau-s5) var(--tau-s4);
  border-bottom: 1px solid var(--tau-line);
  flex-shrink: 0;
}
.tray-title { font-size: var(--tau-text-lg); font-weight: 700; color: var(--tau-ink); letter-spacing: -0.2px; flex: 1; }
.tray-subtitle { font-size: var(--tau-text-xs); color: var(--tau-ink-soft); margin-top: 3px; }
.tray-close {
  background: none;
  border: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--tau-ink-soft);
  cursor: pointer;
  padding: 6px;
  border-radius: 4px;
  flex-shrink: 0;
}
.tray-close:hover { background: var(--tau-hover-surface); color: var(--tau-ink); }
.tray-body { flex: 1; overflow-y: auto; padding: var(--tau-s5); display: flex; flex-direction: column; gap: var(--tau-s5); }
.tray-section-label {
  font-size: 10px;
  font-weight: 700;
  color: var(--tau-ink-soft);
  letter-spacing: 0.8px;
  margin-bottom: 6px;
}
.tray-section-body { font-size: var(--tau-text-md); color: var(--tau-ink); line-height: 1.6; }
.tray-steps { display: flex; flex-direction: column; gap: var(--tau-s2); margin-top: 0; padding: 0; list-style: none; }
.tray-steps li { display: flex; gap: var(--tau-s3); font-size: var(--tau-text-md); color: var(--tau-ink); line-height: 1.5; }
.tray-step-num {
  font-size: var(--tau-text-xs);
  font-weight: 700;
  color: var(--tau-forest);
  background: var(--tau-surface-2);
  width: 20px;
  height: 20px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  margin-top: 1px;
}

/* Avatar + name/meta stack + trailing action — a non-tabular row of
   students, for the surfaces that want a list rather than the sortable
   table.roster component. Lighter weight, same underlying job. */
.list-row {
  display: flex;
  align-items: center;
  gap: var(--tau-s3);
  padding: 10px var(--tau-s4);
  cursor: pointer;
  transition: background var(--tau-dur-short) var(--tau-ease-standard);
}
.list-row:hover { background: var(--tau-hover-surface); }
.list-row-name { font-size: var(--tau-text-sm); font-weight: 600; color: var(--tau-ink); }
.list-row-meta {
  font-size: var(--tau-text-xs);
  color: var(--tau-ink-soft);
  margin-top: 1px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.list-row-action { font-size: var(--tau-text-xs); color: var(--tau-forest); font-weight: 600; flex-shrink: 0; }

/* Bordered/surfaced variant — three near-identical bordered rows on
   dashboard.html (class drill, assignment summary, submission rows) had
   reinvented this exact chrome independently before this modifier existed. */
.list-row-boxed {
  background: var(--tau-surface);
  border: 1px solid var(--tau-line);
  border-radius: var(--tau-r-sm);
  margin-bottom: 6px;
}
.list-row-boxed:last-child { margin-bottom: 0; }

/* ---------- 12. global nav ---------- */

/* The one element that's the same on every student screen — index.html's
   home and workspace views, and report.html. Only the breadcrumb and the
   local toggle change; the mark, spacing and identity slot never do. The
   IA is strictly hierarchical (Assignment -> Draft -> Conversation and/or
   Report), so this is a breadcrumb plus a *contextual* toggle, never a
   tab bar implying the three screens are peers a student can jump between. */
/* M3's small top app bar (64dp), 2026-08-22 — m3-lab §7, the same change
   dashboard.html's own <header> took. Two parts:

   HEIGHT. 52px was sized to the wordmark. 64px is what lets the controls in
   the bar (the account chip, report.html's Pattern Guide) be 40px
   --tau-h-control objects without crowding the row, which §3 requires of them.

   NO DIVIDER AT REST. The bar states "there is content above" by taking a
   tonal step once something scrolls under it, rather than asserting it
   permanently with a hairline. .scrolled is set by the page that owns the
   scrolling element — index.html's home view scrolls .dash-main under a fixed
   bar, so app.js sets it there. Two pages deliberately do NOT:
   - the workspace view, where the bar sits directly above .chat-header, which
     is already a divider; a second one is two rules for one seam.
   - report.html, whose document body scrolls and whose bar is not sticky, so
     it leaves the viewport instead of ever having content pass under it. It
     keeps a resting hairline, page-scoped in report.css. */
.tau-nav {
  display: flex;
  align-items: center;
  gap: var(--tau-s4);
  height: 64px;
  padding: 0 var(--tau-s5);
  background: var(--tau-surface);
  border-bottom: 1px solid transparent;
  flex-shrink: 0;
  transition: background var(--tau-dur-medium) var(--tau-ease-standard);
}
.tau-nav.scrolled { background: var(--tau-surface-2); }
.tau-nav-mark {
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: 700;
  font-size: var(--tau-text-md);
  letter-spacing: -0.01em;
  color: var(--tau-ink);
  flex-shrink: 0;
}
.tau-nav-logo {
  height: 22px;
  width: auto;
  flex-shrink: 0;
  display: block;
}

/* Root and mid segments are plain or clickable; the current segment is the
   one place a student's eye should land — everything else stays quiet. */
.tau-nav-crumbs {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  font-size: var(--tau-text-sm);
  color: var(--tau-ink-faint);
  overflow: hidden;
  white-space: nowrap;
}
.tau-nav-crumbs .crumb-link {
  border: none;
  background: none;
  padding: 0;
  font: inherit;
  color: var(--tau-ink-soft);
  cursor: pointer;
}
.tau-nav-crumbs .crumb-link:hover { color: var(--tau-forest); text-decoration: underline; }
.tau-nav-crumbs .crumb-label { color: var(--tau-ink-soft); }
.tau-nav-crumbs .crumb-sep { opacity: 0.5; }
.tau-nav-crumbs .crumb-current {
  color: var(--tau-ink);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
}

.tau-nav-fill { flex: 1; min-width: var(--tau-s3); }

/* Scoped to one draft, never global — rendered only when a conversation and
   a report genuinely both exist for the draft currently on screen. Visually
   related to a segmented control, but it is state for "this draft," not a
   destination list for the whole app. */
.tau-nav-local {
  display: flex;
  gap: 3px;
  background: var(--tau-surface-2);
  padding: 3px;
  border-radius: var(--tau-r-pill);
  flex-shrink: 0;
}
.tau-nav-local-opt {
  border: none;
  background: none;
  padding: 5px 12px;
  border-radius: var(--tau-r-pill);
  font-size: 12px;
  font-weight: 600;
  color: var(--tau-ink-soft);
  cursor: pointer;
  transition: background var(--tau-dur-short) var(--tau-ease-standard),
              color var(--tau-dur-short) var(--tau-ease-standard),
              box-shadow var(--tau-dur-short) var(--tau-ease-standard);
}
/* Sage, not forest: this is wayfinding state ("which of the two views am I
   on"), not the primary action — forest stays reserved for the one thing
   that actually moves the student forward on a given screen. */
.tau-nav-local-opt.active {
  background: var(--tau-surface);
  color: var(--tau-sage);
  box-shadow: var(--tau-shadow);
  cursor: default;
}

/* Identity slot at the end of the nav. Self-contained rather than leaning
   on a page's own generic `button` reset — report.html has no such reset,
   which is exactly why this rendered as a bare native button there before
   it lived here. */
/* The slot the account menu mounts into. .account-name and .account-signout
   went with the flat name-plus-button strip this replaced — the only thing
   still borrowing .account-signout was report.html's Pattern Guide button,
   which is a button and now says so (.btn.btn-quiet.btn-sm). */
.account-chip { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }

/* ---------- 13. rail ---------- */

/* The vertical rail, wherever one appears: the teacher's persistent nav, the
   admin's section list, the workspace's session list, the student's home
   rail. Four surfaces had built this independently and agreed on nothing —
   four widths (292/290/240/224), four grounds, four active states, and two
   of them shared the class name `.sidebar` for different objects, inert only
   because dashboard.html is the one page that doesn't load style.css.
   Three of those four active states argued their case in a code comment and
   the cases contradicted each other; none of the three knew the others
   existed.

   Three regions, because a rail's contents fall into three groups and only
   one of them scrolls: .rail-head (identity, search, the action that makes a
   new thing), .rail-body (the list, the only region that can overflow), and
   .rail-foot (pinned via margin-top:auto). A rail with no head or no foot
   just omits them. */
.rail {
  width: var(--tau-rail-w);
  min-width: var(--tau-rail-w);
  /* A step quieter than the content it frames — this is structure, not
     content. The same move style.css made for .sidebar/.dash-rail in the
     2026-07-26 pass, now the rule rather than two copies of it. */
  background: var(--tau-surface-2);
  padding: var(--tau-s4) var(--tau-s3);
  display: flex;
  flex-direction: column;
  gap: var(--tau-s5);
  min-height: 0;
}
.rail-head { flex-shrink: 0; display: flex; flex-direction: column; gap: var(--tau-s3); }
.rail-body { flex: 1; overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: var(--tau-s5); }
/* Flex column, not a bare block — a foot holds stacked controls (the
   workspace's Submit draft, and anything that joins it) and they should fill
   the rail's width rather than shrink to their own text. */
.rail-foot { flex-shrink: 0; margin-top: auto; display: flex; flex-direction: column; gap: var(--tau-s2); }

/* A rail that doesn't run the full height of the viewport — admin's, which
   sits in a centred page grid and is shorter than its content. Left square
   and full-bleed it reads as a stray grey rectangle on the page ground, so
   this variant closes itself into a panel. Chrome, so it lives here and not
   in admin.html. */
.rail-inset { border-radius: var(--tau-r-md); }

.rail-group { display: flex; flex-direction: column; gap: 1px; }
.rail-group > .eyebrow { padding: 0 var(--tau-s3) var(--tau-s2); }

.rail-item {
  display: flex;
  align-items: center;
  gap: var(--tau-s3);
  width: 100%;
  /* Not advisory — two of the four rails this replaces shipped 34px and 36px
     rows against a token documented as a real minimum. */
  /* 48px and a pill since 2026-08-22 (m3-lab §5a) — M3's navigation-drawer
     item, whose selected state is a tonal fill and never an elevation. */
  min-height: 48px;
  padding: var(--tau-s2) var(--tau-s3);
  border: none;
  border-radius: var(--tau-r-pill);
  background: none;
  font: inherit;
  color: var(--tau-ink);
  text-align: left;
  cursor: pointer;
  transition: background var(--tau-dur-short) var(--tau-ease-standard);
}
/* A state layer, not a picked fill. The note this replaces recorded the dead
   end that produced: "--tau-hover-surface is unusable here: it resolves to
   --tau-surface-2, which is the rail's own background." An 8% overlay of the
   row's own ink darkens white, sage and shell by the same perceptual amount,
   so the rail can move grounds again without this rule being re-picked. */
.rail-item:hover { background: color-mix(in oklab, currentColor var(--tau-state-hover), transparent); }
.rail-item:active { background: color-mix(in oklab, currentColor var(--tau-state-press), transparent); }
.rail-item:focus-visible { outline: var(--tau-focus-ring); outline-offset: -2px; }

/* The selected row is the only white surface in the rail, so it reads as
   lifted out of the quiet ground and onto the same plane as the content it
   opens. Three channels — surface reversal, shadow, then colour — which is
   what lets the fill stay neutral. The rail this replaces on dashboard.html
   tinted its active row with `color-mix(--tau-forest 10%, --tau-surface)`,
   a seventh forest job the Hard Constraints don't allow; the tint existed
   because a neutral wash on a *white* rail is a near-invisible 3% lightness
   shift. On a toned rail the problem dissolves — white is a step up in
   lightness against surface-2, which is the contrast that rail wanted.
   Same construction as .tau-nav-local-opt.active above, which is the one
   place in the system that had already solved this. */
/* Tonal only. The shadow is gone (m3-lab §5a): on the shell the dashboard
   already overrode the fill to --tau-surface-2, leaving a tonal fill AND an
   elevation — two channels for one state, and the elevation was the one
   making nav out-pop the content it points at. */
.rail-item.active {
  background: var(--tau-surface);
  cursor: default;
}

.rail-item-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.rail-item-name {
  font-size: var(--tau-text-sm);
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.rail-item-sub {
  font-size: var(--tau-text-xs);
  color: var(--tau-ink-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Right-aligned trailing value — a count, a due date, a status word. */
.rail-item-meta {
  margin-left: auto;
  flex-shrink: 0;
  font-size: var(--tau-text-xs);
  font-weight: 600;
  color: var(--tau-ink-faint);
  font-variant-numeric: tabular-nums;
}
/* Forest under its "interactive text" job. Not a forest fill: a fill marks
   the one action that moves the page forward, never wayfinding state. */
.rail-item.active .rail-item-name { color: var(--tau-forest); font-weight: 600; }
.rail-item.active .rail-item-meta { color: var(--tau-forest); }

/* A row that filters the row above it rather than being its own destination
   (Open under All assignments, Worth a chat under All students). Indented
   and a step down in size so it doesn't visually tie with what it filters. */
.rail-item-nested { padding-left: var(--tau-s6); min-height: 38px; }
.rail-item-nested .rail-item-name { font-size: 12px; }

/* Identity block — who you are, at the top of a rail that has one. */
.rail-identity { display: flex; align-items: center; gap: var(--tau-s3); min-width: 0; }
/* .avatar's own ground is --tau-surface-2, which is the rail's ground — on a
   rail it would be a circle you can't see. One step further down the ramp. */
.rail .avatar { background: var(--tau-surface-3); }
.rail-who { display: flex; flex-direction: column; min-width: 0; }
.rail-who-name { font-size: var(--tau-text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rail-who-sub { font-size: var(--tau-text-xs); color: var(--tau-ink-soft); }

/* Empty state for a .rail-body that has nothing in it yet. */
.rail-empty { font-size: var(--tau-text-sm); color: var(--tau-ink-soft); padding: var(--tau-s2) var(--tau-s3); }

/* ---------- 14. menu ---------- */

/* One dropdown, wherever one appears: the dashboard's "+ Add" picker and the
   account menu on all five surfaces. Promoted here from dashboard.html, which
   is where this vocabulary was written and where it was correct to live while
   exactly one page had a header menu.
 *
   The account menu was the piecemeal one: dashboard.html hand-built a real
   dropdown, while admin/index/report/teacher rendered a flat strip of a name
   and a Sign out button from api.js — no panel, no keyboard model, and no
   room for the destinations a person with two roles needs. One component, one
   behaviour (tauMenu in api.js), one set of items decided from /api/me.

   Behaviour lives in api.js, not here, because the WAI-ARIA menu-button
   pattern is keyboard work: this file only has to make sure the states that
   pattern sets — [aria-expanded], [hidden], :focus-visible — are visible. */

.menu-wrap { position: relative; }

.menu-trigger {
  display: inline-flex; align-items: center; gap: 6px; min-height: var(--tau-h-control); padding: 0 var(--tau-s4);
  position: relative;
  border-radius: var(--tau-r-pill); border: 1px solid var(--tau-line); background: var(--tau-surface);
  font: inherit; font-size: var(--tau-text-sm); font-weight: 600; color: var(--tau-ink); cursor: pointer;
  transition: background var(--tau-dur-short) var(--tau-ease-standard), border-color var(--tau-dur-short) var(--tau-ease-standard);
}
.menu-trigger:hover, .menu-trigger[aria-expanded="true"] { background: var(--tau-hover-surface); }
.menu-trigger[aria-expanded="true"] { border-color: var(--tau-line-strong); }
/* The "+ Add" trigger — the one primary action in the header — needs
   .btn-primary's forest fill, but .btn-primary and .menu-trigger are equal
   specificity, so whichever rule comes later in the cascade would silently
   win the whole property set. A dedicated modifier avoids that fight. */
.menu-trigger.menu-trigger-primary { background: var(--tau-forest); border-color: var(--tau-forest); color: var(--tau-on-forest); }
.menu-trigger.menu-trigger-primary:hover, .menu-trigger.menu-trigger-primary[aria-expanded="true"] { background: var(--tau-forest-lift); border-color: var(--tau-forest-lift); }
.menu-trigger.menu-trigger-primary .menu-chevron { color: var(--tau-on-forest); opacity: 0.85; }
.menu-chevron { color: var(--tau-ink-faint); transition: transform var(--tau-dur-short) var(--tau-ease-standard); font-size: 10px; }
.menu-trigger[aria-expanded="true"] .menu-chevron { transform: rotate(180deg); }

/* The account trigger. A pill rather than the squared .menu-trigger so the
   identity control never reads as one more action button in the row.
 *
   No initials monogram: the name is the identity, and a generated two-letter
   circle was decoration standing in for one. Padding is symmetric now that
   nothing round sits inside the left edge. */
.avatar-trigger {
  display: inline-flex; align-items: center; gap: 7px; min-height: var(--tau-h-chip); padding: 4px 11px; border-radius: var(--tau-r-pill);
  position: relative;
  border: 1px solid var(--tau-line); background: var(--tau-surface); cursor: pointer;
  font: inherit;
  transition: background var(--tau-dur-short) var(--tau-ease-standard);
}
.avatar-trigger:hover, .avatar-trigger[aria-expanded="true"] { background: var(--tau-hover-surface); }
.avatar-trigger:focus-visible { outline: var(--tau-focus-ring); outline-offset: 2px; }
.avatar-trigger .whoami-name {
  font-size: var(--tau-text-sm); font-weight: 600; color: var(--tau-ink);
  /* A long name shortens rather than pushing the header into a second row. */
  max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.menu-panel {
  position: absolute; top: calc(100% + 8px); right: 0; min-width: 240px;
  background: var(--tau-surface); border: 1px solid transparent; border-radius: var(--tau-r-md);
  box-shadow: var(--tau-shadow-lift); padding: 6px; z-index: 40;
  animation: tau-reveal var(--tau-dur-medium) var(--tau-ease-decelerate);
}
.menu-panel[hidden] { display: none; }
/* An opening panel is a position change, not information. Readers who ask for
   less motion get the panel, not the slide. */
@media (prefers-reduced-motion: reduce) {
  .menu-panel { animation: none; }
}

.menu-item {
  display: flex; align-items: flex-start; gap: 10px; width: 100%; text-align: left; padding: 9px 10px;
  border-radius: var(--tau-r-sm); border: none; background: none; font: inherit; font-size: var(--tau-text-sm);
  font-weight: 600; color: var(--tau-ink); cursor: pointer; text-decoration: none;
}
/* Author `display: flex` above beats the UA stylesheet's `[hidden] { display:
   none }`, so a plain `.hidden = true` on a .menu-item silently does nothing
   without this — same fix .roster-preview[hidden] already needed. Caught by
   actually testing the New Class success view's two shortcut buttons live,
   not by reading the code: both stayed visible after being marked done. */
.menu-item[hidden] { display: none; }
.menu-item:hover { background: color-mix(in oklab, currentColor var(--tau-state-hover), transparent); }
/* Roving tabindex means the focused item is the only tabbable one, so focus
   is the sole indication of position in the menu — it has to be unmissable,
   and it must not rely on the hover wash alone to show it. */
.menu-item:focus-visible {
  outline: var(--tau-focus-ring);
  outline-offset: -2px;
  background: var(--tau-hover-surface);
}
.menu-item .mi-icon { flex-shrink: 0; width: 18px; text-align: center; margin-top: 1px; color: var(--tau-ink-soft); }
.menu-item .mi-body { display: flex; flex-direction: column; gap: 1px; }
.menu-item .mi-desc { font-weight: 400; font-size: var(--tau-text-xs); color: var(--tau-ink-faint); }

.menu-divider { height: 1px; background: var(--tau-line); margin: 5px 8px; }
.menu-account-head { padding: 8px 10px 10px; }
.menu-account-head .mn { font-size: var(--tau-text-sm); font-weight: 700; }
.menu-account-head .mr { font-size: var(--tau-text-xs); color: var(--tau-ink-faint); }
.menu-footnote { padding: 4px 10px 6px; font-size: var(--tau-text-xs); color: var(--tau-ink-faint); line-height: 1.45; }

/* ── Object action menus ────────────────────────────────────────────────────
   The second half of one system, not a second system. "+ Add" in the global
   header handles the case where the thing doesn't exist yet and has nothing to
   attach to; this handles the case where the object is the page you're on. Same
   tauMenu behaviour, same .menu-panel/.menu-item chrome, same icon set — the
   only difference is what the menu is anchored to, which is the distinction
   the two triggers below are shaped to carry. */

/* Icon-only trigger. Unlabelled on purpose: the object's own title sits
   immediately to its left and is already the label, which is what makes a
   global door labelled-and-primary and a local one small-and-adjacent. The
   accessible name comes from an aria-label naming that object.
   36px matches the .btn-sm row it sits in; ::after takes the hit region out to
   the real --tau-target minimum without inflating the visible box. */
.menu-trigger.menu-trigger-icon {
  width: 36px; min-height: 36px; padding: 0; justify-content: center; position: relative;
}
.menu-trigger.menu-trigger-icon::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  width: var(--tau-target); height: var(--tau-target); transform: translate(-50%, -50%);
}
.menu-trigger.menu-trigger-icon:focus-visible { outline: var(--tau-focus-ring); outline-offset: 2px; }

/* Groups a menu's items by what kind of action they are — scoped creation
   above, management below. Only earns its place when a panel holds both
   kinds; a single-group panel takes no label, same rule .eyebrow follows. */
.menu-group-label {
  padding: 7px 10px 3px; font-size: var(--tau-text-label-sm); font-weight: 650;
  color: var(--tau-ink-faint);
}

/* A destructive item states it in colour as well as wording ("Delete
   assignment" — the object is named, per the labeling rule). Attention, not
   caution: this is a real irreversible action, not a conversation-starter. */
.menu-item.menu-item-danger { color: var(--tau-attention); }
.menu-item.menu-item-danger .mi-icon { color: var(--tau-attention); }
.menu-item.menu-item-danger:hover { background: var(--tau-attention-bg); }

/* The row an object page's actions live in — same corner of every
   .content-header, so "where do I do things to this?" is learned once. */
.obj-actions { display: flex; align-items: center; gap: var(--tau-s2); flex-shrink: 0; }

/* Appearance as a menu row. This is the real shared toggle markup — theme.js
   wires every `.theme-toggle` on the page generically — laid out as a row via
   a modifier, not a second toggle implementation.
 *
   §2 sizes .theme-toggle as a square icon button (fixed width/height, grid
   centring). Those have to be undone explicitly here, not just overridden by
   .menu-item's display: a stale `width: var(--tau-target)` would squeeze the
   label into a 44px column. Two classes beat one, so this wins on specificity
   wherever it lands in the cascade. */
.menu-item.theme-toggle {
  width: 100%;
  height: auto;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  border: none;
  border-radius: var(--tau-r-sm);
  font-size: var(--tau-text-sm);
  color: var(--tau-ink);
}
.menu-item.theme-toggle:hover { border-color: transparent; }
.menu-item .theme-toggle-icon { margin-left: auto; font-size: 14px; line-height: 1; }

/* A named destination that doesn't exist yet. Rendered rather than omitted so
   the gap is a stated limitation instead of a thing the reader assumes is
   hiding somewhere they haven't looked. */
.menu-future {
  display: flex; align-items: flex-start; gap: 10px; padding: 9px 10px; border-radius: var(--tau-r-sm);
  color: var(--tau-ink-faint); font-size: var(--tau-text-sm); font-weight: 600; cursor: not-allowed;
}
.menu-future .mi-desc { color: var(--tau-ink-faint); }


/* ============================================================================
 * §17  HOME AGGREGATES — level composition, band distributions, chart chrome
 *
 * Graduated from dashboard-lab.html on 2026-08-14, verbatim apart from
 * the gutter token's rename and the two :root blocks, which
 * moved to tokens.css (§3, --tau-scale-N). The rationale comments came with
 * the rules deliberately: most of them record a construction that was tried
 * and measured wrong, and losing that is how the same shape comes back.
 *
 * Used by dashboard.html's renderHome(). The lab retains sections 3 and 4
 * (Students by level / Dimension bands) only because they still have no real
 * data behind them; everything visual now lives here.
 * ========================================================================== */

/* --- .card-tabs — THE TAB COMPONENT, wherever a card switches between views
   of itself. Moved out of dashboard.html 2026-08-17 when Movement's selector
   became tabs: the alternative was a second component that looked identical
   and was maintained separately, which is how two vocabularies for one control
   start. Class overview's Overview/Patterns/Trends and Movement's four
   dimensions are now the same object.

   Flat text with a forest underline, deliberately the quietest of the three
   selectors this system has. A chart's selector must not compete with the
   chart: .tau-nav-local is a destination switch and .trend-chip was a boxed
   card, and both read louder than the ribbons underneath them. */

.card-tabs { display: flex; gap: var(--tau-s5); border-bottom: 1px solid var(--tau-line); margin-bottom: var(--tau-s4); }
.card-tab { background: none; border: none; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; color: var(--tau-ink-soft); padding: 0 0 9px; margin-bottom: -1px; border-bottom: 2px solid transparent; transition: color var(--tau-dur-short) var(--tau-ease-standard); }
.card-tab:hover { color: var(--tau-ink); }
.card-tab.active { color: var(--tau-forest); border-bottom-color: var(--tau-forest); }
/* Count badge on a tab label — Class overview's Patterns tab, so a teacher
   knows there's something inside before clicking in. Omitted entirely at
   zero (renderClassAggregateCard), matching the app's existing
   omit-inert-zero idiom for .stat-tile rather than showing a dead "0". */
.card-tab .tab-count { display: inline-flex; font-size: 10.5px; font-weight: 700; background: var(--tau-surface-2); color: var(--tau-ink-soft); border-radius: var(--tau-r-pill); padding: 1px 7px; margin-left: 5px; }
.card-tab.active .tab-count { background: var(--tau-positive-bg); color: var(--tau-positive); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }

/* --- THE ORGANISMS' OWN CHROME. Moved out of dashboard.html 2026-08-17, with
   the renderers, and for the same reason: viz.js emits every one of these
   classes, so a page that mounts an organism and links only components.css was
   getting unstyled markup. Found by building viz-lab.html, which is what that
   page is for. Nothing here is dashboard-specific — the two containers that ARE
   (.overview-card, .drill-trace) stayed behind. */

.no-signal { color: var(--tau-ink-faint); font-size: var(--tau-text-sm); }
.agg-block-lbl { font-size: var(--tau-text-label-md); font-weight: 700; color: var(--tau-ink-soft); margin: 0 0 var(--tau-s2); }
.agg-sample-note { font-size: 11px; color: var(--tau-ink-faint); margin: 0 0 var(--tau-s2); }
.pattern-body { font-size: 13px; color: var(--tau-ink-soft); margin: 0 0 var(--tau-s3); line-height: 1.55; }
.pattern-body b { color: var(--tau-ink); }
.pattern-try {
  background: var(--tau-tool-info-bg); border-left: 3px solid var(--tau-tool-info);
  border-radius: 0 var(--tau-r-sm) var(--tau-r-sm) 0; padding: 11px 13px;
  font-size: 12.5px; color: var(--tau-ink-soft); line-height: 1.5; margin: 0 0 var(--tau-s3);
}
.pattern-try b { color: var(--tau-tool-info); }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--tau-s3); margin-bottom: var(--tau-s2); }
.section-head .eyebrow { margin-bottom: 0; }
.section-summary { font-size: 12px; color: var(--tau-ink-soft); }

/* --- .comp — the level composition of a room.
   The cohort form of report-lab.html's .ladder: same four rungs, same
   named-never-numbered rule. It takes the --tau-scale ramp rather than
   --tau-band-N, because four rungs plotted together is a chart and the
   band tokens are a chip palette — see the fills below. The difference is
   what sits on a rung — .ladder marks ONE student's level, .comp counts
   how many are on each. Built as a sibling rather than a variant
   because the ladder's is-here fill states "you are here", and a count
   on every rung would make four rungs all look like "here".

   The bar is proportional so the shape of the room is legible before a
   number is read. Not a chart: one categorical axis, four ordered
   cells, which is what the dataviz skill's form table sends to a
   segmented bar rather than to anything with an axis. */
/* THE SHARED CHART GRID. .comp and .dbar are two partitions of the SAME
   cohort, so a length should mean the same number of students in both.
   Equal columns alone does not deliver that — the two had different label
   and trailing widths, so their tracks came out ~300px and ~180px inside
   columns of identical width. These three values are what actually makes
   the scales one scale: both components lay out
   `lead | gutter | track | tail`, so the 1fr tracks resolve to the same
   px and 11 students is the same length on either chart.

   Change one of these and BOTH charts move. That is the point; it is also
   the hazard, so they live here rather than as numbers in two rulesets. */
/* TITLE ABOVE THE BAR, not beside it. A label column and a trailing value
   column together took ~60% of the width on the dimension side, leaving a
   146px track for a four-segment distribution at n=90 — most numerals fell
   out of the marks and into the tooltip. Moving both to a header line
   above gives the bar the full measure, and it makes the shared scale
   easier to hold rather than harder: both charts are now simply "gutter +
   everything else", so their tracks are the same width by construction. */
.comp { display: flex; flex-direction: column; gap: 3px; }
.comp-row { padding: 3px 0; }
/* The header line, shared by both charts: name left, value right. */
.viz-row-head {
  display: flex; align-items: baseline; gap: var(--tau-s3);
  margin-bottom: 3px;
}
.viz-row-head > :first-child { flex: 1; min-width: 0; }
/* The plot line. The leading cell is deliberately empty in .comp: it has
   no outside numerals, but the gutter has to be there or its track starts
   1.6rem left of the other chart's. */
.viz-plot {
  display: grid;
  grid-template-columns: var(--tau-chart-gutter) 1fr;
  align-items: center;
}
.comp-name { font-size: var(--tau-text-sm); font-weight: 600; color: var(--tau-ink); }
.comp-track { background: var(--tau-surface-2); border-radius: var(--tau-r-sm); height: 18px; overflow: hidden; }
.comp-fill { display: block; height: 100%; border-radius: var(--tau-r-sm); }
/* --tau-scale-N, not --tau-band-N-bg. The band tokens are a CHIP palette:
   four hues (280/205/242/162) at one lightness, built to identify one
   band at a time on one student's report. Four of them side by side as
   bar fills have no ordinal reading at all — L 0.948-0.953 across the
   whole set, a 0.005 spread. That is the same defect the dimension bars
   below were rebuilt to fix, and it was living one section up in the
   same column: two adjacent 1-4 scales in two contradictory colour
   languages, one of which ran opposite in lightness to the other.
   One ordinal ramp now serves both. See the token block below. */
.comp-1 .comp-fill { background: var(--tau-scale-1); }
.comp-2 .comp-fill { background: var(--tau-scale-2); }
.comp-3 .comp-fill { background: var(--tau-scale-3); }
.comp-4 .comp-fill { background: var(--tau-scale-4); }
/* Tabular so four rows read as a column of magnitudes. The unit is
   stated once by the section head; repeating "students" on four rows
   would be noise, which is the one place this departs from the
   count-never-stands-alone rule and does so deliberately. */
/* Ink, not the ramp. "A band count -> Ink numeral, tabular" — the
   distribution's SHAPE is the finding and the words beside it say so, so
   the numerals stay neutral (teacher-dashboard-design.md, "Colour:
   measured vs the tool talking"). These used to take --tau-band-N-fg,
   which is that table's own rule broken in the row it governs. */
/* On the header line now, right-aligned opposite the level name. */
.comp-count {
  font-size: var(--tau-text-sm); font-weight: 700; flex: 0 0 auto;
  font-variant-numeric: tabular-nums; color: var(--tau-ink);
}
/* The modal row is the headline, so it is the only one that reads as a
   statement rather than a row — weight, never colour alone: the ramp
   has already spent colour on level identity. */
.comp-row.is-modal .comp-name { font-weight: 700; }

/* "Not enough here" is off the ladder, always. Not band 0: it never
   shares a cell, a ramp position or a count with band 1
   (teacher-dashboard-design.md, "The unit of every aggregate").
   Encoded three ways at once so no single channel carries it — a rule
   above it, no ramp colour, an outlined rather than filled track. A
   reader who misses all three still reads the words. */
.comp-off {
  padding-top: var(--tau-s2);
  margin-top: var(--tau-s2);
  border-top: 1px dashed var(--tau-line-strong);
}
.comp-off .comp-name { color: var(--tau-ink-faint); font-weight: 500; }
.comp-off .comp-count { color: var(--tau-ink-faint); font-weight: 600; }
.comp-off-track { height: 18px; border-radius: var(--tau-r-sm); border: 1px dashed var(--tau-line-strong); }

/* --- The band distribution: a DIVERGING STACKED BAR, split at the 2|3
   seam. Replaces the four coloured count-cells, which failed for a
   measurable reason — see the note below.

   WHY THIS FORM. The data is an ordered-scale share (how a cohort
   distributes across 1..4), which is the Likert case: the dataviz
   skill's form table sends it to a diverging stacked bar centred on
   neutral. The neutral here is not invented — tau-dimensions.md
   already puts the seam between 2 and 3 ("two of the four bands
   describe distinct failures"). The form and the measure agree.

   The seam sits at the SAME x on all four rows, so the four dimensions
   are comparable at a glance: mass left of the line is the share of the
   room the behaviour didn't happen for. That comparison is the whole
   job of this section and no arrangement of cells delivered it.

   WHY THE COLOUR IS ONE HUE, NOT TWO. A diverging palette wants two
   hues. This system cannot supply a non-semantic pair, and that is
   checkable rather than a matter of taste:
     - The only pair passing all six checks is violet/green, and its
       green pole lands on oklch(0.46 0.13 162) — which is
       --tau-positive (oklch(0.50 0.10 158)) in all but name. Hard
       Constraints: semantic colour never touches a score.
     - Every warm hue is already a semantic tier (caution 68,
       attention 18).
     - Two cool hues do not separate: violet 280 vs blue 242 scores
       ΔE 0.9 (deutan). Hard fail.
   So POLARITY IS CARRIED BY POSITION (which side of the seam) and
   colour carries only the ordinal step. Position is the stronger
   channel for polarity anyway, and this avoids rendering a class's
   skepticism as red-vs-green, which is the verdict "voice is coach,
   not judge" exists to prevent.

   Hue 242 is the one hue in the system that is neither a semantic tier
   nor a voice (auditor 300, tool-info 205) — it is a band-ramp hue.
   Steps are monotonic in lightness 0.44/0.58/0.72/0.86, worst-adjacent
   CVD ΔE 13.4 against a target of 8. Dark mode is SELECTED against the
   dark surface (the ramp inverts), not flipped.

   Band 1 is the darkest step. More visual weight on absence is
   deliberate: sensitivity at the low end is this instrument's standing
   requirement. */
/* --- --tau-scale-N — THE ORDINAL RAMP. Graduates to tokens.css.
   One scale, used wherever four bands are PLOTTED TOGETHER as magnitude
   (.comp above, .dbar below). --tau-band-N-* keeps its own job and is
   unchanged: it IDENTIFIES one band, one at a time, as a chip on one
   student's report. The split is the rule —

     --tau-band-N-*  identifies a band.   One at a time. A chip.
     --tau-scale-N   orders four of them. Together. A chart.

   Validated with the ORDINAL mode, which is the right test for ordered
   tiers — an earlier pass used the categorical mode and shipped a light
   end at 1.48:1 against the surface, i.e. a band that washes out on
   white. Both ramps pass all four ordinal checks against the REAL tau
   surfaces (#FFFFFF / #1C1F21): monotone lightness, adjacent ΔL >= 0.06,
   light end clearing 2:1, single hue.

   The dark ramp is SELECTED, not flipped, and two of its steps moved in
   this pass for measured reasons. It was previously validated against
   the dataviz skill's default dark surface (#1a1a19) rather than tau's
   lighter #1C1F21, where its light end read 1.97:1 — under the 2:1
   floor. And step 3 was #4f7ea1, which cleared neither 4.5:1 with ink
   (3.63) nor with surface (3.81), so its numeral failed at 11px bold —
   the exact failure "dark mode is selected, not flipped" exists to
   catch, since the ramp inverts between themes but the numeral-colour
   rules did not.

   DIRECTION: band 1 is the step furthest from the ground in both themes
   — darkest on white, lightest on the dark surface. This is a stated
   amendment, not drift. Hard Constraints has "SAMR band foregrounds step
   down in lightness 1->4 — never reorder", which governs
   --tau-band-N-fg; this is a different token doing a different job, and
   it runs the other way on purpose. The finding this chart exists to
   carry is where the MASS sits at the low end, so weight belongs on
   absence: sensitivity at the low end is this instrument's standing
   requirement (tau-dimensions.md, "The scoring scale"). */

/* --- .dbar — the band distribution. A diverging stacked bar split at
   the 2|3 seam. The form is settled (teacher-dashboard-design.md,
   "The form"); what changed here is the geometry, which used to be
   arithmetic and is now structural.

   ONE TRACK, WIDTH n — EQUAL-LENGTH BARS, ALIGNED EDGES. Two earlier
   constructions were wrong in the same direction and are worth naming so
   neither comes back:

     1. A hardcoded 300px plot with a seam positioned at `left: <px>`,
        which used under half of a fluid card and let the axis drift from
        the data as an arithmetic result. Its axis LABELS were a separate
        space-between row across the whole card, so the label reading
        "the seam" floated at the card's centre while the line it named
        sat ~155px in.
     2. Proportional columns sized `minmax(0,{maxLo}fr)` /
        `minmax(0,{maxHi}fr)`, packed against a shared seam. This fixed
        the drift — the seam became a grid boundary nothing could detach —
        but it bought a cross-row seam alignment at the cost of ragged
        left and right ends, and that alignment served nothing: EACH BAR
        SITS IN ITS OWN CARD, so there is no continuous plot for a shared
        axis to run through.

   Now the track is always the full n, which is legitimate precisely
   because bands + off = n on every row (the invariant the whole section
   rests on, asserted in barGeometry). Every bar starts and ends at the
   same x; one student is the same width on every row, so segment length
   still reads as an absolute count AND shares are directly comparable;
   and the seam becomes a tick whose POSITION encodes the failure share,
   read against a common left edge — position along a common scale, which
   is the most accurate perceptual comparison available. */
/* gutter | the bar | the off-scale count. The middle column is the only
   proportional one and it is always the full n, so every bar starts and
   ends at the same x. The two fixed columns are identical on every row,
   so they cost that alignment nothing. */
.dbar { display: grid; align-items: center;
       grid-template-columns: var(--tau-chart-gutter) 1fr; }
.dbar-gutter { display: flex; justify-content: flex-end; overflow: visible; }
.dbar-track { position: relative; display: flex; align-items: center; min-width: 0; }
/* The seam. A real axis line, not a divider — drawn in ink rather than the
   hairline colour used for separators, and overrunning the bar top and
   bottom so it reads as an axis rather than as a gap between two fills. It
   sits above the fills because its POSITION is the finding: the further
   right it falls, the more of the room the behaviour didn't happen for. */
.dbar-seam {
  position: absolute; top: -4px; bottom: -4px; width: 1px;
  background: var(--tau-ink); pointer-events: none;
}
.dbar-seg {
  /* 18px, matching .comp-track exactly (2026-08-18). These are two partitions
     of ONE cohort in one column, and at 26px against the composition's 18px they
     read as two instruments that happen to be adjacent. The in-bar numeral is
     11.5px, which centres in 18px without clipping — checked, not assumed. */
  height: 18px; box-sizing: border-box;
  /* Flex is set inline, per segment: `flex: <count> 1 0`, so the segments
     divide the track in the ratio of their counts and the row always fills
     it exactly. Replaces `flex: 0 0 auto` + a percentage width (2026-08-14),
     which made the bar's total an arithmetic result that the min-width floor
     and the 2px separators could push short of the track.
     Shrink is now 1 rather than 0: a segment forced up to the 6px floor takes
     its extra from the others proportionally, which is a visible small count
     paid for across four large ones rather than a bar that stops early. */
  display: flex; align-items: center; justify-content: center;
  font-size: var(--tau-text-xs); font-weight: 700; font-variant-numeric: tabular-nums;
  min-width: 6px; overflow: hidden; white-space: nowrap;
}
/* The 2px surface gap between stacked segments, per the dataviz skill's
   mark specs. Between segments only — never at an outer end, and never
   at the seam, where the fill butts the axis. */
.dbar-seg + .dbar-seg { border-left: 2px solid var(--tau-surface); }
.dbar-seg.s1 { background: var(--tau-scale-1); color: var(--tau-scale-1-ink); }
.dbar-seg.s2 { background: var(--tau-scale-2); color: var(--tau-scale-2-ink); }
.dbar-seg.s3 { background: var(--tau-scale-3); color: var(--tau-scale-3-ink); }
.dbar-seg.s4 { background: var(--tau-scale-4); color: var(--tau-scale-4-ink); }
/* 4px rounded data-ends on the outer edges only — the seam edges stay
   square because they butt against the axis, not against space. The cap
   goes on whichever segment is actually outermost, which is not always
   band 1 or band 4: a row with nobody at band 4 ended square before,
   because the cap was pinned to a segment that wasn't rendered. */
.dbar-seg.cap-l { border-top-left-radius: 4px; border-bottom-left-radius: 4px; }
.dbar-seg.cap-r { border-top-right-radius: 4px; border-bottom-right-radius: 4px; }
.dbar-seg:focus-visible { outline: var(--tau-focus-ring); outline-offset: 2px; }

/* A count for a segment too narrow to hold it, placed just beyond the
   bar's outer end. Only bands 1 and 4 can use this — they are the
   extremities, so the position is stable and cannot collide with a
   neighbour. Which segments are too narrow is MEASURED after layout
   (fitLabels), not predicted from a px scale that no longer exists. */
.dbar-seg.is-tight .dbar-num { visibility: hidden; }
.dbar-out {
  display: none; flex: 0 0 auto; padding: 0 5px;
  font-size: var(--tau-text-xs); font-weight: 700; font-variant-numeric: tabular-nums;
  color: var(--tau-ink-soft); white-space: nowrap;
}
.dbar-out.is-shown { display: block; }

/* "Not enough here" is off the plot entirely — not a segment, not a
   cell, not band 0. It sits after a gap column, unfilled, dashed, and
   shorter than the bar: three channels saying off-scale, so no single
   one has to carry it. It stays adjacent to the bar end rather than in a
   far-right column, because the bar is short precisely BECAUSE these
   students could not be read, and adjacency is what joins those two
   facts. It is drawn on the SAME student-unit as the bands, so the four
   rows' off counts are comparable to each other and to the bands —
   which a ragged inline chip was not. */
.dbar-off-cell {
  height: 18px; box-sizing: border-box; border-radius: 3px; flex: 0 0 auto;
  border: 1px dashed var(--tau-line-strong);
  /* A fourth channel, added once the Thin-sessions shape showed the cell
     at half the plot's width: at that size the gap, the dash and the
     short height were not enough to stop a big empty box reading as a
     fifth band. Hatching says "not on this scale" at any size. */
  background-image: repeating-linear-gradient(135deg,
    var(--tau-line) 0 1px, transparent 1px 5px);
}
/* The break that puts it off the scale. 3px, and only when there is
   something to break away from — a zero cell adds nothing, so the bars
   stay exactly equal. */
.dbar-off-cell.has-any { margin-left: 3px; min-width: 5px; }

/* --- The scale strip. One decoder, sitting directly above the first
   bar, laid out in the bar's own order: 1 2 | 3 4, then off-scale after
   a break. That isomorphism is the point — once the key runs
   left-to-right in the same order the marks do, POSITION is the primary
   channel for which band a segment is and colour is redundant rather
   than load-bearing (Hard Constraints: "colour is never the only
   channel"; the marks themselves carry a COUNT, not a band identity).

   It replaces two separate things that were both in the wrong place: a
   pole row ("<- didn't happen" / "held up ->") whose text was already
   bands 1 and 4's own descriptors restated, and a legend that sat FOUR
   CARDS BELOW the chart it decodes, so a reader met the first bar with
   nothing to read it by (Gestalt proximity). The seam needs no label of
   its own now — the descriptors on either side say what it divides,
   which is what "the seam" was standing in for. */
.dbar-key {
  display: flex; align-items: center; flex-wrap: wrap; gap: 7px 13px;
  font-size: 10.5px; color: var(--tau-ink-soft);
}
.dbar-key-item { display: inline-flex; align-items: center; gap: 5px; }
.dbar-key-item i {
  width: 11px; height: 11px; border-radius: 2px; font-style: normal; flex: 0 0 auto;
}
.dbar-key-item .n { font-weight: 700; color: var(--tau-ink); font-variant-numeric: tabular-nums; }
/* --tau-ink-faint rather than --tau-line-strong, unlike the cell it
   stands for: at 11px a hairline dash in the border colour disappears
   against the dark surface, and this is the decoder — it has to be
   legible before anything it decodes is. */
.dbar-key-item.off i { background: none; border: 1px dashed var(--tau-ink-faint); }
/* A legend item that is also a trigger. The dotted underline is the
   conventional "there is a definition here" signal, so the affordance
   rides on the term itself rather than needing a second glyph beside it.
   ::after gives it the 44px hit area that --tau-target requires without
   changing how it sits in the strip. */
button.dbar-key-item {
  appearance: none; background: none; border: 0; padding: 0;
  font: inherit; color: inherit; cursor: pointer; position: relative;
}
button.dbar-key-item span {
  text-decoration: underline dotted var(--tau-line-strong); text-underline-offset: 3px;
}
button.dbar-key-item::after { content: ''; position: absolute; inset: -15px -6px; }
button.dbar-key-item:hover span,
button.dbar-key-item[aria-expanded="true"] span {
  color: var(--tau-ink); text-decoration-color: var(--tau-ink-faint);
}
button.dbar-key-item:focus-visible { outline: var(--tau-focus-ring); outline-offset: 3px; }
/* The strip's own copy of the axis — same ink, same job. */
.dbar-key-seam { width: 1px; align-self: stretch; min-height: 15px; background: var(--tau-ink-faint); }

/* The hover layer the dataviz skill requires on bar marks. Replaces
   `title=`, which is ~1s delayed, unstyled, and never fires on keyboard
   focus. Fixed-position and pointer-events:none so it can never sit
   between the cursor and the mark it describes. */
.dbar-tip {
  position: fixed; z-index: 60; pointer-events: none; opacity: 0;
  background: var(--tau-ink); color: var(--tau-surface);
  font-size: 11.5px; line-height: 1.45; padding: 6px 9px;
  border-radius: var(--tau-r-sm); box-shadow: var(--tau-shadow);
  max-width: 230px;
  transition: opacity var(--tau-dur-short) var(--tau-ease-standard);
}
.dbar-tip.is-on { opacity: 1; }
.dbar-tip b { color: var(--tau-surface); }

/* --- .info-dot — the affordance the descriptor rule hangs off. One per
   visualisation, in its section head.

   A BUTTON, and never a bare hover target on the chart itself: a
   hover-only decoder does not exist for touch or for keyboard, and a
   decoder nobody can reach is not a decoder. It answers to hover, focus
   AND click.

   The dot is 15px but its hit area is 44px via the ::after overlay —
   --tau-target is a real minimum, and shrinking a control below it to
   suit a type scale is the trade that rule exists to refuse. */
.info-dot {
  position: relative; appearance: none; background: none; padding: 0;
  border: 1px solid var(--tau-line-strong); border-radius: 50%;
  width: 15px; height: 15px; flex: 0 0 auto; cursor: pointer;
  font-size: 10px; font-weight: 700; line-height: 1; color: var(--tau-ink-faint);
  display: inline-flex; align-items: center; justify-content: center;
  transition: color var(--tau-dur-short) var(--tau-ease-standard),
              border-color var(--tau-dur-short) var(--tau-ease-standard);
}
.info-dot::after { content: ''; position: absolute; inset: -14.5px; }
.info-dot:hover, .info-dot[aria-expanded="true"] { color: var(--tau-ink); border-color: var(--tau-ink-faint); }
.info-dot:focus-visible { outline: var(--tau-focus-ring); outline-offset: 3px; }
.head-with-info { display: flex; align-items: center; gap: 7px; }

/* --- .viz-lede — the sentence the tool leads a visualisation with. One
   per chart, directly under the eyebrow, above the marks. It is the
   conclusion-first block: bold claim, then the counts that carry it.

   DELIBERATELY NOT --tau-tool-info BLUE, and this is the contested bit.
   It takes .pattern-try's exact STRUCTURE — a 3px left rule, same offset,
   same measure — so the two read as one family: blocks the tool wrote in
   its own words. What separates them is the hue, and the hue is doing the
   only job it can do here. Blue means "the tool authored this and you may
   discount it" (Hard Constraints: it is a voice, not a tier). This block
   is TEMPLATED off counts the bars below already show — it cannot claim
   anything the evidence doesn't back — so it is measured, not authored,
   and blue under a measured number tells a teacher the number is an
   opinion. teacher-dashboard-design.md's "Colour: measured vs the tool
   talking" puts the explainer at "neutral, no container" for exactly this
   reason, and the 2026-08-08 removal of a blue card carrying
   "Averaging 2.1 / 5" is the same mistake already made and corrected on
   this page.

   Neutral rule + ink text is what is left: the shape says "the tool is
   addressing you", the absence of hue says "and this part is measured". */
.viz-lede {
  border-left: 3px solid var(--tau-line-strong);
  padding: 1px 0 1px 13px; margin: 0 0 var(--tau-s4);
  font-size: 13px; line-height: 1.55; color: var(--tau-ink);
}
.viz-lede b { font-weight: 700; }

/* --- .dist-panel — the band-distribution panel. ONE card, four rows.
   NAMED .dist-*, NOT .dim-*, and that is load-bearing: dashboard.html's inline
   sheet already owns .dim-panel/.dim-row for the LEGACY three-column trend rows
   still rendered at the class and assignment tiers. Those rules set
   `display: flex` on the row, so while this component shared the name the
   <summary> shrink-wrapped and its open detail laid out BESIDE the bar instead
   of under it — a row that looked like it had not expanded. Renamed 2026-08-14.
   The legacy .dim-* goes away when those two tiers move to band distributions.

   ONE card, four rows. Was four cards, each with a title Was four cards, each with a title
   line, a plot line and its own disclosure row: ~530px of chrome for four
   numbers-and-a-bar. The rows now match .comp's shape one section over
   (name | proportional track | value), which is what makes the two halves
   of that line read as a pair — and cuts the section roughly in half.

   THE BAR IS BACK INSIDE THE <summary>, reversing a change made earlier
   in this file, and the reason it was moved out no longer holds. It was
   moved out because the chart being a disclosure trigger meant clicking a
   mark to inspect it COLLAPSED the thing you were inspecting, and the
   hover state said "this will expand" rather than naming the segment.
   With one row per dimension, clicking a bar expands detail ABOUT THAT
   BAR — the click and the mark now agree, the row-wide hover is correct
   feedback for a row-wide target, and the per-segment tooltip still
   carries the segment-specific reading on hover. */
.dist-panel {
  background: var(--tau-surface); border: 1px solid transparent;
  border-radius: var(--tau-r-md); box-shadow: var(--tau-shadow);
}
.dist-row { border-bottom: 1px solid var(--tau-line); }
.dist-row:last-child { border-bottom: 0; }
/* 5px, not 9px (2026-08-18) — a .comp-row pads 3px and the two charts stack in
   one column, so the extra 6px per row put the dimension rows on a visibly
   looser rhythm than the composition above them. --tau-target still floors the
   whole row at 44px, which is what the padding was standing in for; the floor
   does that job without also setting the rhythm. */
.dist-summary {
  display: block; padding: 5px var(--tau-s5); min-height: var(--tau-target);
  cursor: pointer; list-style: none;
  transition: background var(--tau-dur-short) var(--tau-ease-standard);
}
.dist-summary::-webkit-details-marker { display: none; }
.dist-summary:hover { background: var(--tau-hover-surface); }
.dist-summary:focus-visible { outline: var(--tau-focus-ring); outline-offset: calc(-1 * var(--tau-focus-gap)); }
/* No label column to size to any more — the name sits on its own line, so
   "Calibrated Skepticism" can never be the thing that squeezes the plot.
   Dimension names are fixed vocabulary with no synonyms and no per-surface
   rewording (Hard Constraints), so this is the arrangement that stops the
   longest name from costing every chart width. */
/* The token, not the number it happens to equal — .comp-name takes the same
   step, and a hardcoded 13px here is how the two drift apart on the next
   type change. */
.dist-row-name { font-size: var(--tau-text-sm); font-weight: 600; color: var(--tau-ink); }
/* The floor row states itself in weight, never colour — semantic colour
   never touches a band. The same device .comp-row.is-modal uses for the
   modal level, deliberately, so the two lists mark their notable row the
   same way. It replaces an uppercase "THE FLOOR OF THE FOUR" tag that no
   longer fits beside the name, and which the finding sentence above the
   panel already states in a full clause. */
.dist-row.is-floor .dist-row-name { font-weight: 800; }
.dist-row-go {
  font-size: 10px; color: var(--tau-ink-faint); text-align: right; line-height: 1;
  transition: transform var(--tau-dur-short) var(--tau-ease-standard);
}
.dist-row[open] .dist-row-go { transform: rotate(180deg); }
/* The dimension's definition — constant, quiet, and first. It is the
   least newsworthy line in the block and the one without which the rest
   doesn't parse, so it leads at low contrast rather than competing. */
.dist-what {
  font-size: 11.5px; color: var(--tau-ink-faint); line-height: 1.5;
  margin: 0 0 7px; max-width: 62ch;
}
/* Indented to the plot, so an open row's reading sits under the bar it
   reads rather than under the whole panel. */
.dist-row-detail {
  padding: 2px var(--tau-s5) var(--tau-s4) calc(var(--tau-s5) + var(--tau-chart-gutter));
}

/* --- Layout candidates for renderHome(). .home-cols is copied verbatim
   below and stays untouched; these are the proposed replacements, so the
   old and new proportions can be compared rather than one overwriting
   the other in place. */
/* Equal, so the two charts share a scale. Both are partitions of the SAME
   cohort — .comp splits it by level, .dbar splits it by band — so once
   their tracks are the same width, a length means the same number of
   students in both, and "11 at reactive" is directly comparable to
   "11 at band 2" by eye. Unequal columns made that comparison wrong while
   looking available, which is worse than not offering it.
   (Equal COLUMNS is necessary but not sufficient — see the note in the
   lab header about the label and trailing columns.) */
/* --- .cols-2up — two equal columns for a pair of marks that read as one
   object. Generalised out of .home-cols-2up 2026-08-18, when the assignment
   page's per-draft blocks needed the same thing: agency beside its dimensions,
   to stop three drafts running to three screens of vertical scroll.

   ONLY WHERE NEITHER HALF IS A FLOW. Home dropped its own 2-up on 2026-08-15
   for a measured reason — a flow needs horizontal room per stage, and at half
   width three columns crowd, the ribbons stop separating and the counts collide
   with the nodes. A draft block is bars only, which is why the pairing is
   available here and not there.

   CONTAINER-RELATIVE, not viewport-relative. .home-cols-2up breaks at a 1200px
   MEDIA query, which asks the wrong question: these columns care how wide their
   own box is, and the same grid sits in a full-width page and in a narrower
   card. A container query asks the box. */
.cols-2up {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: var(--tau-s6); align-items: stretch;
  container-type: inline-size;
}
/* STRETCH, not start. Bare columns can end at different heights and read as
   normal; two CARDS at different heights read as two unrelated boxes that
   happen to be adjacent, which is the opposite of what pairing them says. The
   same distinction .home-cols-top already records against .home-cols. */
.cols-2up > .col { min-width: 0; }
/* Below this the four-segment bar has no room to hold its numerals, and
   fitLabels starts pushing them all to the gutter. */
@container (max-width: 720px) {
  .cols-2up { grid-template-columns: 1fr; }
}
@supports not (container-type: inline-size) {
  @media (max-width: 1200px) { .cols-2up { grid-template-columns: 1fr; } }
}

.home-cols-2up {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: var(--tau-s6); align-items: start;
}
.home-cols-2up > .home-col { min-width: 0; }
/* Same 1200px breakpoint .home-cols already uses — one column below it,
   in the same reading order. */
@media (max-width: 1200px) { .home-cols-2up { grid-template-columns: 1fr; } }

/* The top row: the triage counts, and the same counts per class. Same
   1.45fr/1fr as .home-cols and for the same reason — but STRETCHED
   rather than start-aligned, which .home-cols cannot be. That rule pairs
   a grey band with a white card, where differing heights read as normal;
   here it is two grey bands side by side, and a short one beside a tall
   one reads as ragged rather than as two sections. */
/* 1fr / 1fr, where every other split on this page is asymmetric. Equal
   widths assert the two halves are a matched pair, and once the triage
   queues are rows rather than tiles that is exactly what they are — two
   lists of navigating count-rows over the same roster. The asymmetry was
   right while one side was a metric readout and the other an entity
   index; it stopped being right when they became the same component. */
.home-cols-top {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: var(--tau-s6); align-items: stretch;
}
.home-cols-top > .home-col { min-width: 0; }
@media (max-width: 1200px) { .home-cols-top { grid-template-columns: 1fr; } }
/* The tiles used to be vertically centred here to disguise the fact that
   their band started its content lower than the labelled band beside it.
   Giving that band its own eyebrow fixes the cause — both now start at the
   same y — so the centring is gone rather than kept as decoration. */

/* The count keeps display weight — triage is the top of this page's
   hierarchy — while the unit stays body-sized beside it, so the pair reads
   as one phrase rather than as a number with a caption under it. */
.stat-row-count { font-size: 12.5px; color: var(--tau-ink-soft); font-variant-numeric: tabular-nums; }
/* Right-aligned in a fixed box so the three numerals form a column. Without
   it the shared unit word right-aligns and the digits go ragged behind it,
   which is the one comparison this list exists to support. */
.stat-row-count b {
  font-size: 17px; font-weight: 800; margin-right: 3px;
  display: inline-block; min-width: 2.2ch; text-align: right;
}


/* --- Pattern group heading. The tier's only colour channel: it rides the
   GROUP LABEL on the ordinal ramp, never the count. Amber on "8 students held
   their ground" would state a caution the label contradicts, and amber already
   means "worth a chat" on the triage rows above — a surface carries a severity
   signal exactly once. The label text states the tier in words, so colour is
   never the only channel.
   Vocabulary is the student report's, verbatim (report-render.js): the same
   detectors must not acquire two names across two surfaces. */
.pattern-group-label {
  font-family: var(--tau-font-mono); font-size: var(--tau-text-label-md);
  font-weight: 700; margin-bottom: var(--tau-s3);
}
/* Per group, not per section. "No passive patterns" is a real and good
   finding when high-agency cards are showing — one shared all-clear state
   would report it as an empty section instead. */
.pattern-group-empty {
  font-size: var(--tau-text-sm); color: var(--tau-ink-faint);
  margin: 0; padding: var(--tau-s3) 0;
}

/* The white card a visualisation sits on, matching .pattern-card's chrome so
   the chart and the finding cards read as one family. */
.viz-card {
  background: var(--tau-surface); border: 1px solid transparent;
  border-radius: var(--tau-r-md); box-shadow: var(--tau-shadow);
  padding: var(--tau-s4) var(--tau-s5);
}
.viz-card-foot {
  font-size: 11.5px; color: var(--tau-ink-faint); line-height: 1.5;
  margin: var(--tau-s4) 0 0;
}
.viz-card-foot b { color: var(--tau-ink-soft); }


/* --- .flow-* / .trend-* — MOVEMENT. Graduated out of dashboard.html's inline
   sheet 2026-08-17. It had been single-surface when it was written for Home;
   it now renders on Home, the class Trends tab and the assignment card, and a
   second copy had already appeared in class-lab.html — which is the placement
   rule's own trigger (a concept on 2+ surfaces belongs here).

   NOTE FOR ANYONE RESTYLING THIS: the CSS is here but the RENDERER IS NOT.
   drawFlow()/flowSVG()/mountFlows() still live in dashboard.html. Geometry —
   node width, plot height, ribbon tension, the off-lane gap — is in those
   constants, not in this file. A visual change is one file only if it is a
   change to colour, type or chrome. */

/* --- .flow-* — movement over time, as a flow diagram. Added 2026-08-15,
   replacing Home's level composition strip and its band distribution bars.

   WHY THIS FORM IS ALLOWED WHERE A LINE IS NOT. A line asserts a rate of
   change through the gap between two readings, and nothing was measured in
   that gap. A ribbon asserts only membership: these N students held this
   reading, then that one. Every mark is a real count on a real day, which
   is how it clears the no-sparkline constraint rather than bending it.

   VERTICAL POSITION IS THE SCALE. Strongest reading at the top, so a rising
   ribbon means more of the student's own thinking on every diagram in the
   product. The axis is drawn, not captioned.

   NO LABELS DOWN BOTH SIDES — identity moves to the legend, which is the
   same .dbar-key the page already uses. At four band labels that are full
   sentences, printing them twice squeezes out the ribbons the chart is for.

   NO SEMANTIC COLOUR ON THE RIBBONS. They take the source node's ramp step.
   Green-up / red-down would grade the room on improvement, and a level is a
   position on a path, not a verdict. The state chip beside a dimension name
   is the one place a direction is stated, and it is stated in words. */
.flow-wrap { overflow-x: auto; }
.flow-mount { min-height: 1px; }
/* Drawn at 1:1 from the measured width — no width:100% on the SVG, because
   scaling a viewBox magnifies the type along with the geometry. */
.flow-svg { display: block; }
/* Light fills: the shape should read before the colour does. Four ramp steps
   at a heavier alpha let the darkest ribbons dominate a chart whose subject
   is movement, not magnitude. */
.flow-rib { fill-opacity: 0.32;
            transition: fill-opacity var(--tau-dur-short) var(--tau-ease-standard); }
/* Highlighting is class-driven, not :hover — the marks that light up are the
   hovered ribbon's students wherever else they appear, which no selector can
   express. traceFlow() sets .tracing on the svg and .lit on that path. */
.flow-svg.tracing .flow-rib { fill-opacity: 0.1; }
.flow-svg.tracing .flow-rib.lit { fill-opacity: 0.85; }
.flow-when { font-size: var(--tau-text-label-sm); font-weight: 700;
             fill: var(--tau-ink-faint); }
/* A count is measured, so the numerals stay ink and the words carry which
   one matters — the same rule the .dbar segments follow. */
.flow-n { font-size: 11px; font-weight: 700; fill: var(--tau-ink);
          font-variant-numeric: tabular-nums; }
.flow-key { margin-top: var(--tau-s3); }
.dist-row-detail .flow-key { margin-bottom: var(--tau-s3); }

/* --- .trend-lede — the finding above a flow. Chosen in viz-lab.html 2026-08-17
   from two candidates rendered side by side.

   NOT .viz-lede, and the difference is the left rule. That rule is a container
   device, and it earns its place there because a Composition or Distribution
   lede is a block of measured prose sitting among bars. Here the finding hands
   off to the blue action block directly beneath it, and two stacked rules read
   as two unrelated notices — which is exactly the complaint that started this.
   One paragraph, no container, and the handoff carried by the writing.

   Bold lead-in, plain continuation, ONE ink colour throughout. No tier colour
   on the state word: green-up/red-down grades a room on improvement, which is
   already forbidden on the ribbons this sits above. */
.trend-lede {
  font-size: var(--tau-text-md); line-height: 1.55; color: var(--tau-ink);
  margin: 0 0 var(--tau-s4); max-width: 68ch;
}
.trend-lede b { font-weight: 700; }

/* --- .trend-block — a distribution that opens onto its own trend. The cue is
 a labelled row under the chart, not a bare chevron: "How the class got here"
 names what is behind the disclosure, where a chevron alone leaves a teacher
 to discover it. Composes .dist-row so the open/closed chrome and the chevron
 rotation are the same component the dimension rows already use. */
.trend-block { border: 1px solid var(--tau-line); border-radius: var(--tau-r-md);
             background: var(--tau-surface); box-shadow: var(--tau-shadow); }
.trend-block > .dist-summary { padding: var(--tau-s4) var(--tau-s5); }
.trend-cue {
display: flex; align-items: center; gap: 7px;
margin-top: var(--tau-s3); padding-top: var(--tau-s3);
border-top: 1px solid var(--tau-line);
font-size: var(--tau-text-xs); font-weight: 600; color: var(--tau-forest);
min-height: 24px;
}
.trend-block[open] > .dist-summary .trend-cue { color: var(--tau-ink-soft); }


/* --- .trace-* — TRACE. One student's readings across a sequence of drafts.
   Added 2026-08-17. The fourth mark, and the reason it is not one of the other
   three is that both cohort marks encode a COUNT — ribbon width, segment length
   — and a count of one carries nothing. What is left is position across a
   sequence.

   The grid is subordinate to the sentence above it. A teacher reading four
   dimensions across three drafts should not have to find the story in twelve
   cells, so traceFinding() names the one row worth the conversation and .is-focus
   marks it — the same weight-only device .comp-row.is-modal and .dist-row.is-floor
   already use, and for the same reason: emphasis on a reading may not be colour,
   because colour here is the band ramp and nothing else.

   Cells are .steps at four pips, which is Distribution's own atom on a draft
   axis (designsystem.md: "band bars per draft, never Movement"). Two channels,
   never one — how many pips are filled AND which ramp step fills them. */
.trace { display: flex; flex-direction: column; gap: 2px; }

/* One grid, declared once, so the column edges line up down every row including
   the header and the agency strip. */
.trace-row, .trace-head, .trace-agency {
  display: grid;
  grid-template-columns: minmax(96px, 1.4fr) minmax(0, 3fr) minmax(74px, auto);
  align-items: center;
  gap: var(--tau-s4);
}
.trace-row, .trace-agency { padding: 5px 0; }
.trace-agency { border-bottom: 1px solid var(--tau-line); padding-bottom: var(--tau-s3); }
.trace-head { padding-bottom: 2px; }

.trace-row-name { font-size: 12.5px; font-weight: 600; color: var(--tau-ink); min-width: 0; }
.trace-row.is-focus .trace-row-name { font-weight: 800; }

.trace-cells { display: flex; gap: var(--tau-s3); min-width: 0; }
.trace-cell { flex: 1; min-width: 0; display: flex; align-items: center; }
.trace-col {
  font-size: var(--tau-text-xs); font-weight: 600; color: var(--tau-ink-faint);
  letter-spacing: 0.02em;
}

/* The pips take the band's own ramp step, so a cell's colour means the same
   thing it means on every bar and ribbon in the product. Unfilled stays the
   neutral track — a lighter step of the same surface, not a fifth colour. */
.trace-cell .steps { flex: 1; }
.band-step-1 i.on { background: var(--tau-scale-1); }
.band-step-2 i.on { background: var(--tau-scale-2); }
.band-step-3 i.on { background: var(--tau-scale-3); }
.band-step-4 i.on { background: var(--tau-scale-4); }

/* Off-scale, never band 0: an outline where the others have fill, the same
   detachment .comp-off and .dbar-off-cell use. */
.trace-off {
  flex: 1; height: 6px; border-radius: 2px;
  border: 1px dashed var(--tau-line-strong);
  color: transparent; font-size: 0; line-height: 6px;
}

/* An ordinal move in words, because the number of bands moved is the unit and
   an arrow alone would be colour-and-shape carrying a quantity. Never tinted
   green up / red down — semantic colour never touches a student's own reading. */
.trace-move {
  font-size: var(--tau-text-xs); font-weight: 600; color: var(--tau-ink-soft);
  text-align: right; white-space: nowrap;
}

/* ============================================================
   Document dialog — a long reference document read in place
   ============================================================
   Built for set-password.html's Terms of Use, where navigating to a terms
   page would abandon a single-use token and a half-filled form. Native
   <dialog>, so focus containment, Escape and the inert backdrop come from the
   browser instead of being re-implemented per page — this is deliberately not
   dashboard.html's openObjModal, which is a decision surface (title, body,
   confirm/cancel) rather than a reading one, and lives inside that page. */
.doc-dialog {
  width: min(46rem, calc(100vw - var(--tau-s6)));
  max-height: min(42rem, calc(100vh - var(--tau-s7)));
  padding: 0;
  border: 1px solid var(--tau-line);
  border-radius: var(--tau-r-lg);
  background: var(--tau-surface);
  color: var(--tau-ink);
  box-shadow: var(--tau-shadow-lift);
  flex-direction: column;
  overflow: hidden;
}
/* The layout mode belongs to the open state only. A bare `display:flex` on the
   element overrides the UA stylesheet's `display:none` for a closed <dialog>,
   which renders the whole document inline, unstyled, above the page — the
   exact class of bug designsystem.md's *Verifying visually* section says only
   a screenshot catches. It was there until one was taken. */
.doc-dialog[open] { display: flex; }
/* Centring restated rather than inherited: a modal <dialog> is centred by the
   UA with `margin:auto`, which style.css's `* { margin: 0 }` reset (line 1)
   wipes — leaving it pinned to the top-left corner. */
.doc-dialog { margin: auto; }
.doc-dialog::backdrop { background: #16191C5C; }

.doc-dialog-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--tau-s3);
  padding: var(--tau-s5) var(--tau-s6) var(--tau-s4);
  border-bottom: 1px solid var(--tau-line);
}
.doc-dialog-head h2 { margin: 0; font-size: var(--tau-text-xl); }

.doc-dialog-close {
  font: inherit; font-size: var(--tau-text-xl); line-height: 1;
  color: var(--tau-ink-faint);
  background: none; border: none; cursor: pointer;
  padding: var(--tau-s1) var(--tau-s2);
  border-radius: var(--tau-r-sm);
}
.doc-dialog-close:hover { color: var(--tau-ink); background: var(--tau-surface-2); }

.doc-dialog-body {
  overflow-y: auto;
  padding: var(--tau-s5) var(--tau-s6);
  font-size: var(--tau-text-md);
  line-height: 1.6;
  color: var(--tau-ink-soft);
}
/* Focusable so a keyboard user can scroll the document without tabbing to the
   close button first — the body is the content, and it is longer than a
   viewport by construction. */
.doc-dialog-body:focus-visible { outline: 2px solid var(--tau-forest); outline-offset: -2px; }
.doc-dialog-body h3 {
  margin: var(--tau-s6) 0 var(--tau-s2);
  font-size: var(--tau-text-lg); color: var(--tau-ink);
}
.doc-dialog-body h3:first-child { margin-top: 0; }
.doc-dialog-body p, .doc-dialog-body ul { margin: 0 0 var(--tau-s3); }
.doc-dialog-body ul { padding-left: var(--tau-s5); }
.doc-dialog-body li { margin-bottom: var(--tau-s2); }
.doc-dialog-body strong { color: var(--tau-ink); }

.doc-dialog-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--tau-s4);
  padding: var(--tau-s4) var(--tau-s6);
  border-top: 1px solid var(--tau-line);
  background: var(--tau-surface-2);
}
/* Which wording this is, stated where it is read rather than only stored:
   the version is what a person is agreeing to (see server/terms.js). */
.doc-dialog-version { margin: 0; font-size: var(--tau-text-xs); color: var(--tau-ink-faint); }
.doc-dialog-done { width: auto; padding-left: var(--tau-s5); padding-right: var(--tau-s5); }

/* Separated from the password fields above it: this is a different kind of
   decision, not a third input. */
.terms-check { margin-top: var(--tau-s4); }
