/* ═══════════════════════════════════════════════════════════════════════════
   EqualPlay Dashboard - shared application stylesheet
   ---------------------------------------------------------------------------
   Single source of truth for all 12 desktop CMS screens. Extracted verbatim
   from the approved direction (designer-custom.html) so that every screen
   looks as if it were drawn in the same afternoon by the same hand.

   HOW TO USE
     <link rel="stylesheet" href="assets/tokens.css">   (tokens + .type-* first)
     <link rel="stylesheet" href="assets/app.css">      (this file, second)
   A screen should add almost no CSS of its own. If you need a new component,
   add it HERE so every other screen inherits it.

   RULES THIS FILE ENFORCES
     - Every colour / radius / stroke / shadow / spacing resolves to a token.
       Zero hex literals. Zero magic px, except where a physical pixel size is
       the thing itself (icon box, cell height, control height) and is noted.
     - Type comes from the 11 .type-* classes in tokens.css. This file never
       sets font-size, with two documented exceptions marked EXCEPTION below.
     - --color-neutral-700 on white is 4.48:1 and FAILS WCAG AA. Secondary text
       is --color-neutral-800 (6.29:1) or darker, everywhere.
     - White on --color-primary-300 is 3.10:1: legal only at 14px bold or
       larger (.btn). Anything smaller uses --color-primary-400 (4.85:1).

   SHAPE RULE (do not drift)
     --radius-full : every interactive element (nav, chip, button, pill, bar,
                     avatar, input, KPI)
     --radius-7    : surfaces (sidebar, panel, card, modal, user card)
     --radius-6    : nested surfaces inside a panel (textarea, inner tile)
     --radius-4    : data atoms (heatmap cell, tooltip, focus ring)
     --radius-3    : piano black key + checkbox only - narrower objects earn a
                     tighter corner; this is motivated, not drift
     --radius-2    : micro-swatches (legend chip, missing-icon placeholder)
     --radius-none : never alone - only ever paired to square ONE side
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── 1. Reset and page base ──────────────────────────────────────────────
   The page ground is warm cream (orange at 10% over white), not white: the
   student app floats white cards on a warm ground and so does this. A white
   page with hairline greys is the generic-SaaS look we are explicitly not. */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
/* Headings and paragraphs carry UA margins that silently inflate every panel
   header. Vertical rhythm here comes from flex/grid gaps only, never margins. */
h1, h2, h3, h4, h5, h6, p, figure { margin: 0; }

body {
  min-width: 960px;           /* below this the 14-day grid stops being readable */
  min-height: 100dvh;         /* dvh, not vh */
  font-family: var(--font-family);
  color: var(--color-neutral-1000);
  background: var(--color-primary-alpha10);
  text-wrap: pretty;
  -webkit-font-smoothing: antialiased;
}
button, input, select, textarea { font-family: inherit; }
button { cursor: pointer; }
img { display: block; }
a { color: var(--color-primary-400); }

/* ── 2. App shell ────────────────────────────────────────────────────────
   .app  = the two-column frame, sidebar + main. Fluid, never fixed.
   .main = the scrolling work area; it is a flex column so a screen can hand
           one child .grow-v and have it absorb all leftover viewport height
           (this is how a screen fills 1920x1080 without a dead band). */
.app {
  display: grid;
  grid-template-columns: clamp(178px, 16vw, 264px) 1fr;
  min-height: 100dvh;
  background: var(--color-primary-alpha10);
}
.main {
  padding: var(--space-6);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  min-width: 0;
}
/* Give this to the module row that should soak up remaining height. */
.grow-v { flex: 1 1 auto; min-height: 0; }

/* ── 3. Sidebar ──────────────────────────────────────────────────────────
   White rail floating on the cream ground, squared on the outer edge only. */
.side {
  background: var(--color-neutral-100);
  border-radius: var(--radius-none) var(--radius-7) var(--radius-7) var(--radius-none);
  box-shadow: var(--shadow-e2);
  padding: var(--space-6) var(--space-4) var(--space-4);
  display: flex;
  flex-direction: column;
  position: sticky;
  top: 0;
  align-self: start;
  max-height: 100dvh;
  overflow-y: auto;
}
.brand { padding: 0 var(--space-3) var(--space-5); }
.brand b    { display: block; color: var(--color-primary-300); letter-spacing: -.01em; }
.brand span { display: block; color: var(--color-secondary-300); }

.navgrp { margin-top: var(--space-4); }
.navgrp > h6 {                                   /* group label: 主選單 / 轉換 / … */
  margin: 0 0 var(--space-1);
  padding: 0 var(--space-3);
  color: var(--color-neutral-800);
  letter-spacing: .08em;
}
.nav {                                           /* one nav row */
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-full);
  border-left: var(--stroke-2) solid transparent;
  color: var(--color-neutral-1000);
  text-decoration: none;
}
.nav .ic  { width: 20px; display: flex; justify-content: center; opacity: .62; }
.nav .cnt { margin-left: auto; color: var(--color-neutral-800); }
.nav:hover { background: var(--color-primary-alpha10); }
.nav.on {                                        /* the screen's OWN nav item */
  background: var(--color-primary-100);
  border-left-color: var(--color-primary-300);
  color: var(--color-primary-500);
  font-weight: 700;
}
.nav.on .ic { opacity: 1; }
/* The count badge inherits --color-neutral-800, which is 4.48:1 on white (fine)
   but only 4.25:1 once the row is the active pill's --color-primary-100 - below
   AA at 12px. On the active row it takes the row's own --color-primary-500
   (5.48:1). Fires on 班別 2 / 學生 10 / 審閱修正 3 whenever they are current. */
.nav.on .cnt { color: var(--color-primary-500); }
.spacer { flex: 1; }

/* Sidebar footer identity card. */
.ucard {
  display: flex; align-items: center; gap: var(--space-2);
  background: var(--color-secondary-alpha10);
  border-radius: var(--radius-7);
  padding: var(--space-3);
}
.ucard .meta   { min-width: 0; }
.ucard .meta b { display: block; }
.ucard .org    { display: block; color: var(--color-neutral-800);
                 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ucard .ini    { background: var(--color-neutral-100); }
/* The logout link (every screen's markup: <a href="login.html" aria-label="登出">).
   A 24px box that never shrinks: its 16px icon alone was a 16x16 target, and at
   <=1180px the icon drew over the wrapped role badge (UI/UX audit E03/E04). */
.ucard > a[href="login.html"] {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 24px; min-height: 24px; flex-shrink: 0;
  /* Level with the name, not centred on the card: the one-line role badge
     is wider than the name column at every width, so a centred icon drew
     over it (#321 d, measured at 960 to 1440 px). */
  align-self: flex-start;
}
.role {                                          /* Educator / Admin badge */
  /* No nowrap: at one line the badge is wider than the card's name column
     at every rail width below ~1470px, and nowrap pushed it out of the card
     (#321 d). It wraps inside the column; the logout box sits level with the
     name, so nothing draws over it. */
  display: inline-block;
  background: var(--color-secondary-300);
  color: var(--color-neutral-100);
  border-radius: var(--radius-full);
  padding: 0 var(--space-2);
}
.ini {                                           /* circular initial avatar */
  width: 32px; height: 32px; flex: none;
  border-radius: var(--radius-full);
  background: var(--color-secondary-alpha10);
  color: var(--color-secondary-300);
  display: flex; align-items: center; justify-content: center;
  font-weight: 700;
}
.ini--sm { width: 26px; height: 26px; }

/* ── 4. Page header ──────────────────────────────────────────────────────
   .top holds the H1, its subtitle, search, the two circular icon buttons and
   the user pill. Every screen uses the same one. */
.top { display: flex; align-items: center; gap: var(--space-3); }
.top h1    { margin: 0; }
.top .sub  { color: var(--color-neutral-800); margin-top: var(--space-0); }
.grow { flex: 1; }

/* Pill search. The asset library has NO search icon, so a
   --color-neutral-300 square stands in and the label carries the meaning. */
.search {
  display: flex; align-items: center; gap: var(--space-2);
  width: clamp(180px, 22vw, 340px);
  height: 36px; padding: 0 var(--space-4);
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid var(--color-neutral-300);
  border-radius: var(--radius-full);
  color: var(--color-neutral-800);
}
.search:focus-within { border-color: var(--color-secondary-300); }
.qin { border: 0; background: transparent; outline: 0; width: 100%;
       color: var(--color-neutral-1000); font-family: var(--font-family); }
.qin::placeholder { color: var(--color-neutral-600); }
/* Honest placeholder for an icon the library does not ship. */
.icon-missing { width: 14px; height: 14px; flex: none;
                background: var(--color-neutral-300); border-radius: var(--radius-2); }

/* Circular icon button - white disc, used for notifications / settings / filter. */
.circ {
  width: 36px; height: 36px; flex: none;
  color: var(--color-neutral-1000);            /* not the browser's rgb(16,16,16) */
  border-radius: var(--radius-full);
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid var(--color-neutral-200);
  box-shadow: var(--shadow-e1);
  display: flex; align-items: center; justify-content: center;
  position: relative;
}
.circ:hover { border-color: var(--color-secondary-300); }
.circ--sm { width: 32px; height: 32px; }
.circ .dot {                                     /* unread marker */
  position: absolute; top: var(--space-1); right: var(--space-1);
  width: 8px; height: 8px; border-radius: var(--radius-full);
  background: var(--color-red-100);
  border: var(--stroke-0) solid var(--color-neutral-100);
}
/* User pill in the header.
   flex:none + nowrap are load-bearing, not cosmetic. A flex item shrinks by
   default, so on the screens whose H1 subtitle is long (review, student-detail,
   upload, recordings) the pill was squeezed until "張老師" broke one character
   per line - a 63px-tall pill inside a 36px control at a 1100px half-snapped
   window. The identity control is a fixed-size object; the pressure belongs on
   the title block and the search field, both of which reflow gracefully. */
.upill {
  display: flex; align-items: center; gap: var(--space-2);
  flex: none; white-space: nowrap;
  height: 36px; padding: 0 var(--space-2) 0 var(--space-1);
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid transparent;
  border-radius: var(--radius-full);
  box-shadow: var(--shadow-e1);
}
.upill .ini { width: 28px; height: 28px; }
.upill:hover { border-color: var(--color-primary-300); }

/* ── 5. Button family ────────────────────────────────────────────────────
   Buttons are pills. Always. Three tiers at two sizes.
     .btn        36px CTA        - .type-b2 (14px) + 700  → primary-300 solid
     .btn--ghost 36px secondary  - white + orange stroke
     .btn--quiet 36px tertiary   - white + neutral stroke
     .mini       26px row action - .type-b3 (12px) + 700
       .act-strong destructive/primary  → primary-400 solid (AA at 12px)
       .act-mid    secondary            → white + orange stroke
       .act-quiet  tertiary             → white + neutral stroke
   Every tier answers :hover, :active, :focus-visible and [disabled]. */
/* Any of these may be an <a> when the control navigates rather than acts
   (查看記錄, 前往審閱, 返回). text-decoration:none is therefore part of the
   component, not an override a screen should have to remember. */
.btn, .btn--ghost, .btn--quiet {
  height: 36px; padding: 0 var(--space-5);
  border-radius: var(--radius-full);
  border: var(--stroke-0) solid transparent;
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--space-2);
  font-weight: 700; text-decoration: none;
  white-space: nowrap;               /* a CTA label never wraps mid-word */
  transition: filter .12s ease, background-color .12s ease;
}
/* a11y: white on --color-primary-300 measures 3.10:1. Strict WCAG counts
   large text as 18.66px bold and .btn is 14px/700, so the 4.5 bar applies
   and primary-300 fails it on every screen. primary-400 = 4.85:1 and is
   still unmistakably the brand orange. Measured in-browser, not estimated. */
.btn        { background: var(--color-primary-400); color: var(--color-neutral-100); }
.btn:hover  { background: var(--color-primary-500); }
.btn:active { background: var(--color-primary-500); filter: brightness(.92); }
.btn--ghost { background: var(--color-neutral-100);
              border-color: var(--color-primary-300); color: var(--color-primary-400); }
.btn--ghost:hover  { background: var(--color-primary-alpha10); }
.btn--ghost:active { border-color: var(--color-primary-400); }
.btn--quiet { background: var(--color-neutral-100);
              border-color: var(--color-neutral-300); color: var(--color-neutral-900); }
.btn--quiet:hover  { border-color: var(--color-neutral-500); }

.mini {
  height: 26px; padding: 0 var(--space-3);
  border-radius: var(--radius-full);
  border: var(--stroke-0) solid transparent;
  font-weight: 700; text-decoration: none;   /* .mini is often an <a> - see above */
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--space-1);
  flex: none; white-space: nowrap;   /* never let a row action wrap its label */
  transition: filter .12s ease, background-color .12s ease;
}
.act-strong { background: var(--color-primary-400); color: var(--color-neutral-100); }
.act-strong:hover  { background: var(--color-primary-500); }
.act-mid    { background: var(--color-neutral-100);
              border-color: var(--color-primary-300); color: var(--color-primary-400); }
.act-mid:hover     { background: var(--color-primary-alpha10); }
.act-quiet  { background: var(--color-neutral-100);
              border-color: var(--color-neutral-300); color: var(--color-neutral-800);
              font-weight: 400; }
.act-quiet:hover   { border-color: var(--color-neutral-500);
                     color: var(--color-neutral-1000); }

/* One disabled treatment for the whole family: the palette's 100 step, per M17.
   Disabled controls stay in the layout and stay readable; they do not vanish. */
.btn[disabled], .btn--ghost[disabled], .btn--quiet[disabled],
.mini[disabled], .chip[disabled] {
  background: var(--color-neutral-200);
  border-color: var(--color-neutral-200);
  color: var(--color-neutral-800);
  cursor: not-allowed;
  box-shadow: none;
}

/* ── 6. Chips (filter / segmented control) ───────────────────────────────
   Lifted straight from the student song-library screen: selected = solid
   secondary-300, unselected = white with an orange hairline. */
.chips { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.chip {
  height: 32px; padding: 0 var(--space-4);
  border-radius: var(--radius-full);
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid var(--color-primary-300);
  color: var(--color-primary-400);
  display: inline-flex; align-items: center; gap: var(--space-2);
  /* A chip may be an <a> when it switches view rather than filters in place
     (class-detail's 本期功課 / 錄音 tabs). Same reason .btn and .mini already
     carry it: the UA underline is not part of this component. */
  text-decoration: none;
}
.chip:hover { background: var(--color-primary-alpha10); }
.chip.on {
  background: var(--color-secondary-300);
  border-color: var(--color-secondary-300);
  color: var(--color-neutral-100);
  font-weight: 700;
}
.chip.on:hover { background: var(--color-secondary-400); }
.chip.plain { border-color: var(--color-neutral-300); color: var(--color-neutral-800); }
.chip.plain:hover { background: var(--color-neutral-alpha10); }

/* ── 7. Dropdown menu ────────────────────────────────────────────────────
   Matches Figma 1:1802 (Frame 94): white card, generous radius, e3 shadow,
   hairline dividers, a check on the selected row. Add .open to show it. */
.menuwrap { position: relative; }
.menu {
  display: none;
  position: absolute; z-index: 40; top: calc(100% + var(--space-2)); right: 0;
  min-width: 186px;
  background: var(--color-neutral-100);
  border-radius: var(--radius-7);
  box-shadow: var(--shadow-e3);
  padding: var(--space-1) 0;
  overflow: hidden;
}
.menu.open { display: block; }
.menu button {
  display: flex; align-items: center; gap: var(--space-2);
  width: 100%; padding: var(--space-2) var(--space-4);
  background: transparent; border: 0; text-align: left;
  color: var(--color-neutral-1000);
}
.menu button + button { border-top: var(--stroke-0) solid var(--color-neutral-200); }
.menu button:hover    { background: var(--color-primary-alpha10); }
.menu button[aria-checked="true"] { color: var(--color-primary-400); font-weight: 700; }

/* ── 8. KPI stat pill ────────────────────────────────────────────────────
   The signature component of the product: a fully-rounded horizontal pill,
   circular icon bubble on the left, label over value on the right.
   .kpis is an auto-fit grid, so 4 pills reflow to 2x2 on a narrow window. */
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(212px, 1fr));
        gap: var(--space-3); }
/* ⚠️ 2026-08-21. The height stays FIXED at 64px on purpose — .lab + .val
   already fill it, and giving the pill vertical padding grew every tile on
   every screen (64 → 74, and student-detail's row to 132). What overflowed was
   an ACTION added inside one tile: see .kpi .valrow directly below. */
/* ⚠️ 2026-09-01 (R001 + R002). `height` became `min-height`, and only that.
   The paragraph above is still right about padding - none is added here - but a
   HARD height clips nothing and hides nothing: it lets the content draw
   straight out of the bottom of the pill.
   MEASURED on student-detail.html, four viewports:
     R002  未有資料 · 沒有對應端點 in one .val  -> valH 72 against tileH 64, three
           line boxes, 13px BELOW the pill, at 1194x834, 1024x768, 834x1194 AND
           1366x1024. It needed no iPad; a plain desktop window showed it.
     R001  未設要求 + the 設定要求 button       -> the same 72 against 64 at
           1194x834 and 834x1194, and NO spill at 1366x1024 or 1024x768: the
           auto-fit grid narrows the tile until the .valrow no longer fits on
           one line, which is why desktop review kept passing it.
   .kpis is a grid whose items stretch, so one tall tile still gives the whole
   row one height and the pills stay a set. */
.kpi {
  display: flex; align-items: center; gap: var(--space-3);
  min-height: 64px; padding: 0 var(--space-5) 0 var(--space-2);
  border-radius: var(--radius-full);
}
/* 本週達標次數 on student-detail.html carries a 設定要求 / 更改要求 button.
   .val is display:block, so a button placed after it wrapped to a third line
   and overflowed the pill; the explanatory <p> after the KPI row — laid out as
   though the tile ended at 64px — then covered the button's middle 18 pixels.
   It was VISIBLE and NOT CLICKABLE: elementFromPoint at its centre returned
   that paragraph, and a real click timed out. Only reachable once the tile has
   a requirement to show, which is the state a clean browser now lands in
   because assets/requirements.js seeds one for that student.
   One row instead of two keeps the tile at exactly 64px, and the gap is a
   token. Verified on all thirteen screens, before and after. */
.kpi .valrow { display: flex; align-items: center; gap: var(--space-2); }
.kpi .bub { width: 44px; height: 44px; flex: none; border-radius: var(--radius-full);
            background: var(--color-neutral-100);
            display: flex; align-items: center; justify-content: center; }
.kpi .lab { display: block; color: var(--color-neutral-900); }
.kpi .val { display: block; }
/* The unit suffix ("名", "項") inherits .val's family colour at 14px/400 and
   measured 4.03:1 over the tint - under the 4.5 bar. The palette has no
   darker red/yellow/green step to tint it with (red stops at -200), so the
   suffix takes the neutral. The NUMBER keeps the family colour, which is
   where the semantic coding actually lives; the unit is only a noun. */
.kpi .val small { color: var(--color-neutral-1000); }
.kpi--risk { background: var(--color-red-alpha10);
             border: var(--stroke-1) solid var(--color-red-100); }
.kpi--risk .val { color: var(--color-red-200); }
.kpi--late { background: var(--color-yellow-100); }
.kpi--ok   { background: var(--color-green-100); }
.kpi--all  { background: var(--color-primary-100); }
.kpi--info { background: var(--color-secondary-alpha10); }
.kpi--info .val { color: var(--color-secondary-300); }

/* ── 9. Panels, cards and section headers ────────────────────────────────
   .panel = white surface on the cream ground. .ph = its header: bold orange
   title with a chevron, a small caption underneath, then anything pushed to
   the right with .grow. Purple titles (.ttl--alt) mark data/progress panels. */
.panel {
  background: var(--color-neutral-100);
  border-radius: var(--radius-7);
  box-shadow: var(--shadow-e2);
  padding: var(--space-5);
  display: flex; flex-direction: column; min-height: 0;
}
/* Secondary panels in a module row: same surface, less air. */
.panel--tight { padding: var(--space-4); }
.panel--tight .ph { margin-bottom: var(--space-2); }
/* A notice sitting in a queue list is a row, not a page-level banner. */
.list .notice { padding: var(--space-2) var(--space-3); }
.ph { display: flex; align-items: flex-end; gap: var(--space-3);
      margin-bottom: var(--space-3); flex-wrap: wrap; }
/* CONTRAST: primary-300 on white is 3.10:1. WCAG treats >=18.66px BOLD as large
   text, where the bar is 3.0 - so the 700 weight is what makes this legal at
   .type-h6 (20px). It is also literally what the direction asks for: section
   headers in BOLD orange. Do not drop it back to 500. */
.ph .ttl { display: flex; align-items: center; gap: var(--space-2);
           color: var(--color-primary-300); font-weight: 700; }
.ph .ttl--alt { color: var(--color-secondary-300); }
/* ⚠️ .ph .ttl .ar REMOVED 2026-09-16 with its 39 chevrons (#175 item 2).
   The rule sized and mirrored a right-arrow glyph that sat inside a panel
   title. The title was not a link, had no href, no data-open and no role, so
   the glyph promised a full view that no panel delivered. DESIGN.md §4 gives
   that arrow one meaning: "Tertiary: a bare Electric Violet text link with a
   right-arrow icon" - it marks a LINK. The panels that do have a full view
   already carry a labelled text link beside the title (全部錄音, 審閱修正),
   which is the real affordance; the rest had no destination at all.
   The asset is still used, and still mirrored, by .chips .per-next img. */
.ph .cap { color: var(--color-neutral-800); margin-top: var(--space-0); }

/* Column layouts for module rows. Both auto-fit, so they reflow, never clip. */
.cols-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
          gap: var(--space-4); }
.cols-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
          gap: var(--space-4); }
.cols-4 { display: grid; grid-template-columns: repeat(auto-fit, minmax(272px, 1fr));
          gap: var(--space-4); }

/* .card = a lighter surface used INSIDE a panel (a list item, a job, a song). */
.card {
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid var(--color-neutral-200);
  border-radius: var(--radius-6);
  padding: var(--space-2) var(--space-4);
  display: flex; align-items: center; gap: var(--space-3);
}
.card:hover { border-color: var(--color-primary-300); }
.card .ttl { min-width: 0; flex: 1; }
.card .ttl b { display: block; overflow: hidden; text-overflow: ellipsis;
               white-space: nowrap; }
.card .ttl span { display: block; color: var(--color-neutral-800); }
.list { display: flex; flex-direction: column; gap: var(--space-2);
        overflow-y: auto; min-height: 0; flex: 1 1 auto; }

/* ── 10. Tables ──────────────────────────────────────────────────────────
   Not <table>: a grid, so a heat strip and a progress bar can share one row.
   A screen sets its own column template on the wrapper:
       <div class="tbl" style="--cols: 1fr 120px 96px;">
   and every .thead / .trow / .tfoot inside inherits it. */
.tbl { display: flex; flex-direction: column; min-height: 0; }
.tbl .thead, .tbl .trow, .tbl .tfoot {
  display: grid;
  grid-template-columns: var(--cols, 1fr);
  align-items: center; gap: var(--space-4);
  border-left: var(--stroke-2) solid transparent;
  padding-left: var(--space-3);
}
.tbl .thead { padding-bottom: var(--space-2); }
.tbl .thead > * { color: var(--color-neutral-800); }
.tbl .trow { height: 34px; }

/* .tbl--wrap : rows size to their content instead of being pinned at 34px.
   A roster table holds one short value per cell and 34px is right. A diagnostic
   table (review.html's low-confidence measures) holds a PHRASE - "E4 四分音符
   → 疑為 F4" - in a panel that is only half the window wide, so the text has to
   be allowed to take two lines. Pinned at 34px it did not clip, it OVERLAPPED
   the row beneath: the grid row stayed 34px while the text drew 54px, so three
   rows collided. Opt in per table; the default stays 34px. */
.tbl--wrap .trow { height: auto; min-height: 34px;
                   padding-block: var(--space-1); }
.tbl--wrap .trow > * { min-width: 0; }
/* #174 · A row whose action cell is narrower than its buttons pushed them
   past the panel and past the viewport: measured at clientWidth 1100 the last
   cell of #asgTbl resolved to 104px while its content needed 204px, so
   撤回功課 sat at left 1090 with 10 of its 74px on screen and the page grew a
   64px horizontal scrollbar. .tbl--wrap already lets a row grow taller, so
   the controls take a second line instead of leaving the panel. */
.tbl--wrap .trow .rowx { flex-wrap: wrap; row-gap: var(--space-1); }

.who { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
.who .ini { width: 26px; height: 26px; }
.nm  { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Navigating to a detail screen. The NAME is the link, never the whole row:
   an anchor wrapped around the row swallows that row's own buttons, and a
   keyboard user cannot tab past it. At rest the link is plain body colour so a
   dense table stays quiet; the underline arrives on hover and focus only. */
.rowlink { color: var(--color-neutral-1000); text-decoration: none; }
.rowlink:hover, .rowlink:focus-visible {
  color: var(--color-primary-400); text-decoration: underline;
}

/* Progress: bar + "n / total". The bar colour is inherited from row status. */
.prog { display: flex; align-items: center; gap: var(--space-2); }
.bar  { flex: 1; height: 8px; border-radius: var(--radius-full);
        background: var(--color-neutral-200); overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: var(--radius-full);
         background: var(--color-secondary-300); }
.num  { color: var(--color-neutral-900); white-space: nowrap; }

/* Status pill: SOLID fill, never an outline, never a tint-with-dark-text.
   Copy is fixed: 進度正常 / 進度落後 / 需要關注. */
.pill { display: inline-block; border-radius: var(--radius-full);
        padding: var(--space-0) var(--space-3); font-weight: 700; white-space: nowrap; }
/* HONEST DOWNGRADE: spec asked for red-100 solid + white = 3.66:1, below AA.
   red-200 + white = 5.66:1. Shape language (solid pill, white text) unchanged. */
.st-risk { background: var(--color-red-200);    color: var(--color-neutral-100); }
.st-late { background: var(--color-yellow-200); color: var(--color-neutral-1000); }
.st-ok   { background: var(--color-green-200);  color: var(--color-neutral-1000); }
.st-info { background: var(--color-secondary-300); color: var(--color-neutral-100); }
.st-mute { background: var(--color-neutral-200); color: var(--color-neutral-900); }

/* :has() - the row's status sets the loudness of the whole row.
   The eye descends from red to yellow to plain white; the student who needs
   help is the loudest thing on the page without anyone having to read. */
.tbl .trow:has(.st-risk) { background: var(--color-red-alpha10);
                           border-left-color: var(--color-red-200); }
.tbl .trow:has(.st-risk) .bar i { background: var(--color-red-200); }
.tbl .trow:has(.st-late) { background: var(--color-yellow-alpha10);
                           border-left-color: var(--color-yellow-200); }
.tbl .trow:has(.st-late) .bar i { background: var(--color-yellow-200); }
.tbl .trow:has(.st-ok) .bar i { background: var(--color-green-200); }
.tbl .trow:has(.st-ok) + .trow:has(.st-ok) {
  border-top: var(--stroke-0) solid var(--color-neutral-200); }
/* Hover cue only on rows that are NOT already status-tinted - a red row is
   loud enough, and repainting it on hover would flatten the ranking. */
.tbl .trow:hover:not(:has(.st-risk)):not(:has(.st-late)) {
  background: var(--color-primary-alpha10); }

/* Aggregate footer row - purple, because purple is the collective. */
.tbl .tfoot {
  height: 36px; margin-top: var(--space-2);
  background: var(--color-secondary-alpha10);
  border-radius: var(--radius-full) var(--radius-none) var(--radius-none) var(--radius-full);
  border-left-color: var(--color-secondary-300);
}
.tbl .tfoot .who b { color: var(--color-secondary-300); }
/* The footnote is prose, not a column value: let it run to the end of the row
   instead of being squeezed into one narrow cell and wrapping. */
.tbl .tfoot .note  { color: var(--color-neutral-800); grid-column: 3 / -1; }

/* ── 10b. Real <table> variant: .tbl--heat ───────────────────────────────
   WHY THIS EXISTS. The roster+heatmap is genuine tabular data (10 students
   x 14 days) and was built as independent CSS grids, one per row. Those
   LOOK aligned only while every cell has identical intrinsic width. They
   did not: the day-header keys filled their grid track while the cells
   were capped at max-width and centred, so each header sat 14.4px left of
   the column it labelled and was 28.9px wider - measured, not guessed.
   A real <table> has ONE column-width authority (the colgroup), so a
   header and its cells physically cannot drift. It also buys <th scope>,
   <caption>, and column-level weekend shading via <col>, which the grid
   version faked with a class on every single cell. */
.tbl--heat {
  display: table; width: 100%; table-layout: fixed;
  border-collapse: separate; border-spacing: var(--space-1) 0;
  margin-inline: calc(var(--space-1) * -1);
}
/* Neutralise the flex/grid rules inherited from .tbl */
.tbl--heat .trow, .tbl--heat .kbd, .tbl--heat .wkrow, .tbl--heat .tfootr {
  display: table-row; height: auto; gap: 0;
  padding-left: 0; border-left: 0;
}
.tbl--heat th, .tbl--heat td { padding: 0; vertical-align: middle; }
.tbl--heat tbody .trow { height: 34px; }
/* A failed practice read (dashboard.html sets data-unread): the day cells are
   not drawn, because a grey cell means 未練習 and the read did not say that. */
.tbl--heat[data-unread] .cell { visibility: hidden; }

/* Column widths live here and nowhere else. */
.tbl--heat .c-who  { width: clamp(104px, 9vw, 148px); }
.tbl--heat .c-prog { width: clamp(140px, 13vw, 196px); }
.tbl--heat .c-stat { width: clamp(72px, 7vw, 96px); }
.tbl--heat .c-act  { width: clamp(88px, 8vw, 112px); }

/* #28 · the 28-day (月) period. Measured at the 1280px floor: 32 columns share
   987px, and with a --space-1 gutter each day column comes out at 13.6px while
   a key carrying "31" needs 15.73px of its own - the date would sit on top of
   its neighbour. Two changes buy it back, both of them token values and both
   scoped to this period so the 7-day view is untouched: the gutter drops one
   spacing step, and 功課完成度 gives up 3vw it was not using. Result 17.3px. */
.tbl--heat.tbl--dense { border-spacing: var(--space-0) 0;
                        margin-inline: calc(var(--space-0) * -1); }
.tbl--heat.tbl--dense .c-prog { width: clamp(112px, 10vw, 156px); }
/* Weekend band: one rule paints the whole column, full height, always in
   register with the header above it because it IS the same column. */
.tbl--heat .c-wknd { background: var(--color-secondary-alpha10); }

.tbl--heat .hcap, .tbl--heat .wklab {
  color: var(--color-neutral-800); font-weight: 400; text-align: left;
  padding-bottom: var(--space-2);
}
/* Same trap as .key: the legacy .wklab (line ~649) is display:grid, which
   knocks a <th colspan=7> out of the table layout algorithm and collapses
   it to one narrow column - the label then wrapped one character per line.
   Any legacy class landing on a table cell must be forced back. */
.tbl--heat .wklab { display: table-cell; text-align: center; }
.tbl--heat .wkrow th, .tbl--heat .wkrow td { padding-bottom: var(--space-1); }
.tbl--heat .who { text-align: left; font-weight: 400;
                  padding-left: var(--space-3); }
.tbl--heat .cel { text-align: center; }
.tbl--heat .prog, .tbl--heat .stat, .tbl--heat .act { padding-left: var(--space-4); }

/* Status tint + accent, now on <tr>. The first cell carries the accent so it
   cannot be clipped by border-collapse. */
.tbl--heat tbody .trow:has(.st-risk) { background: var(--color-red-alpha10); }
.tbl--heat tbody .trow:has(.st-risk) .who { box-shadow: inset var(--stroke-2) 0 0 0 var(--color-red-200); }
.tbl--heat tbody .trow:has(.st-late) { background: var(--color-yellow-alpha10); }
.tbl--heat tbody .trow:has(.st-late) .who { box-shadow: inset var(--stroke-2) 0 0 0 var(--color-yellow-200); }
.tbl--heat tbody .trow:hover:not(:has(.st-risk)):not(:has(.st-late)) {
  background: var(--color-primary-alpha10); }

.tbl--heat tfoot .tfootr { background: var(--color-secondary-alpha10); }
.tbl--heat tfoot .who { box-shadow: inset var(--stroke-2) 0 0 0 var(--color-secondary-300); }
.tbl--heat tfoot .who b { color: var(--color-secondary-300); }
.tbl--heat tfoot .note { color: var(--color-neutral-800);
                         padding-left: var(--space-4); text-align: left; }

/* Cells and keys now share their column, so both simply fill it. */
.tbl--heat .cell { width: 100%; max-width: none; margin-inline: 0; }
.tbl--heat .dh { vertical-align: bottom; }
/* The <th> must stay display:table-cell or it leaves the table layout
   algorithm entirely - .key carries display:flex, so putting .key ON
   the <th> stacked all 14 headers at one x. The flex lives inside. */
.tbl--heat .dh .key { width: 100%; }

/* ── 11. Heatmap + the keyboard motif ────────────────────────────────────
   VISUAL MOTIF. A period is always a whole number of Monday-to-Sunday runs -
   7 days (週) or 28 days (月), see assets/period.js - so the date axis is drawn
   as a piano keyboard: Saturday and Sunday are the black keys, and their darker
   band runs the full height of the table. It is decoration that does work - you
   can see at a glance who only practises at weekends.
   Individual practice = orange ramp. Class aggregate = purple ramp.

   #28: the column COUNT is no longer written here. A repeat(14, 1fr) was one of
   four places the fortnight was hard-coded, and a period the teacher can change
   cannot have its width fixed in a stylesheet. grid-auto-flow: column gives one
   equal track per child, so the count lives only where the days do. */
.strip { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; height: 100%; }
.col   { display: flex; align-items: center; justify-content: center; height: 100%; }
.col--wknd { background: var(--color-secondary-alpha10); }   /* the black-key band */

.cell {
  width: 100%; max-width: 30px; margin-inline: auto; height: 22px;
  border-radius: var(--radius-4); display: block; position: relative;
}
.lv-0 { background: var(--color-neutral-200); }
.lv-1 { background: var(--color-primary-100); }
.lv-2 { background: var(--color-primary-200); }
.lv-3 { background: var(--color-primary-300); }
.lv-4 { background: var(--color-primary-400); }

/* A DAY THAT HAS NOT HAPPENED (teacher-dashboard#163, ruled 2026-09-10).
   The period stays a whole Monday-to-Sunday week, so on any day but Sunday
   the grid holds days in the future. They used to paint lv-0, which the
   legend calls 未練習 -- did not practise -- so a teacher opening the console
   on a Monday saw five days of apparent failure for every child.

   THIS MUST NOT LOOK LIKE A LEVEL. It is hollow rather than filled: no fill,
   a dashed hairline, so it reads as an empty slot on a calendar and not as
   the lightest square on a scale of practice. Outside the lv-0..4 range on
   purpose, because it is not a quantity of practice at all. */
.cell--future {
  background: transparent;
  border: var(--stroke-1) dashed var(--color-neutral-300);
}
.agg-0 { background: var(--color-neutral-200); }
.agg-1 { background: var(--color-secondary-100); }
.agg-2 { background: var(--color-secondary-200); }
.agg-3 { background: var(--color-secondary-300); }
.agg-4 { background: var(--color-secondary-400); }

/* EXCEPTION 1 of 2: a ::after pseudo-element cannot carry a .type-* class.
   These two values are byte-identical to .type-b3 (12px / 18px). */
.cell:hover::after, .cell:focus-visible::after {
  content: attr(data-tip); position: absolute; bottom: calc(100% + var(--space-1));
  left: 50%; transform: translateX(-50%); white-space: nowrap; z-index: 9;
  background: var(--color-neutral-1000); color: var(--color-neutral-100);
  font-size: 12px; line-height: 18px;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-4); box-shadow: var(--shadow-e3);
}

/* The keyboard date axis. */
.kbd { height: 46px; align-items: flex-start; }
/* ⚠️ R003 (2026-09-01). flex-end, not flex-start, and this is the SAME rule the
   table strip gets from `.tbl--heat .dh { vertical-align: bottom }` - #31's fix
   landed on the <table> heat strip (dashboard.html) and never reached the div
   grid one (#sdHeat on student-detail.html), which is exactly why Herman's
   ticket says "still wrong".
   MEASURED at 1194 and at 1366 before the change: white and black keys both
   started at keyTop 757, so the 46px white key ended at 803 and the 38px black
   key at 795, and every black-key label sat 8px HIGH (kwTop 756 vs 764, kdBot
   794 vs 802) - 六 日 8 9 visibly raised above 一二三四五. Bottom-aligning makes
   the shared key BOTTOM the shared baseline, whatever the two key heights are,
   which is the whole reason .key itself is justify-content: flex-end. Key
   heights are untouched: 38px is derived from the content and belongs to Agatha
   and Jeanet (see .key--black below). */
.kbd .col { align-items: flex-end; padding: 0; }
.key {
  width: 100%; height: 46px;
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid var(--color-neutral-300);
  border-top: var(--radius-none) solid transparent;
  border-radius: var(--radius-none) var(--radius-none) var(--radius-4) var(--radius-4);
  display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
  gap: var(--space-0);
  /* #31: bottom-aligned, NOT centred. Centring put each label in the middle of
     its own key, so a 46px white key and a 38px black key placed their dates
     exactly (46-38)/2 = 4px apart - the axis read as two rows of dates.
     Bottom-alignment makes the shared key bottom the shared baseline, so the
     dates line up whatever the two heights are.

     The padding is 0 by necessity, not by choice. Measured 2026-08-26: both
     labels are 38px of content, so the black key (38px) has NO slack - its top
     already sits 0.5px proud of its own key. Any bottom padding pushes it
     further over, re-creating the overflow 98007bd fixed. Whenever Agatha or
     Jeanet publish real key heights, a black key taller than its content earns
     a padding-bottom of var(--space-1) here, applied to BOTH keys together. */
  padding-bottom: 0;
}
.key--black {
  /* 38px, not 32px. Measured on the deployed page 2026-08-22: the label is two
     18px lines plus the gap, ~38px. At 32px it overflowed its own key by ~3px
     top AND bottom (.kw drew at y533 against a key top of y536; .kd ended at
     y571 against a key bottom of y568), and because both key types are
     bottom-aligned the weekend date sat 7px lower than the weekday date. This
     height is DERIVED FROM THE CONTENT, not chosen: the block below still
     records that key heights resolve to no token and belong to Agatha/Jeanet,
     so the white/black ratio is theirs to set. Raised as a fix to an overflow,
     not as a design proposal. */
  height: 38px;
  background: var(--color-neutral-1000);
  border-color: var(--color-neutral-1000);
  border-radius: var(--radius-none) var(--radius-none) var(--radius-3) var(--radius-3);
}
.kw { color: var(--color-neutral-800); }
.kd { font-weight: 700; color: var(--color-neutral-1000); }
.key--black .kw { color: var(--color-neutral-500); }
.key--black .kd { color: var(--color-neutral-100); }
/* One label per week of the period: one at 週, four at 月. Same reason as
   .strip above - the track count follows the children, not a literal. */
.wklab { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; }
.wklab span { color: var(--color-neutral-800); text-align: center; }

/* Intensity legend. */
.legend { display: flex; align-items: center; gap: var(--space-1);
          color: var(--color-neutral-800); }
.legend i { width: 16px; height: 12px; border-radius: var(--radius-2); display: block; }

/* ── 12. Forms ───────────────────────────────────────────────────────────
   Real <input>, real <label>, never a styled div. Single-line controls are
   pills to match the button family; the textarea uses radius-6. */
.field { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.lbl   { color: var(--color-neutral-900); font-weight: 500; }
.hint  { color: var(--color-neutral-800); }

.inp, .sel, .txa {
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid var(--color-neutral-300);
  color: var(--color-neutral-1000);
  padding: 0 var(--space-4);
  height: 36px;
  border-radius: var(--radius-full);
  width: 100%;
}
.txa { height: auto; min-height: 96px; padding: var(--space-3) var(--space-4);
       border-radius: var(--radius-6); resize: vertical; }
.inp::placeholder, .txa::placeholder { color: var(--color-neutral-600); }
.inp:hover, .sel:hover, .txa:hover { border-color: var(--color-neutral-500); }
.inp:focus, .sel:focus, .txa:focus { border-color: var(--color-secondary-300); outline: 0; }
.inp[disabled], .sel[disabled], .txa[disabled] {
  background: var(--color-neutral-200); color: var(--color-neutral-800);
  cursor: not-allowed; }
/* Native select arrow replaced with the library's own Icon_arrow__Down asset. */
.sel {
  appearance: none;
  background-image: url("icons/Icon_arrow__Down.svg");
  background-repeat: no-repeat;
  background-position: right var(--space-4) center;
  background-size: 12px 12px;
  padding-right: var(--space-8);
}
/* Error state: the border, an icon-free message and aria-invalid must agree. */
.field--err .inp, .field--err .sel, .field--err .txa { border-color: var(--color-red-200); }
.errmsg { color: var(--color-red-200); font-weight: 500; }

/* Checkbox / radio. 18px is the control's physical size, not a spacing value. */
.check { display: inline-flex; align-items: center; gap: var(--space-2); }
.check input {
  appearance: none; width: 18px; height: 18px; flex: none; margin: 0;
  border: var(--stroke-1) solid var(--color-neutral-400);
  border-radius: var(--radius-3);
  background: var(--color-neutral-100);
}
.check input[type="radio"] { border-radius: var(--radius-full); }
.check input:checked {
  background: var(--color-secondary-300);
  border-color: var(--color-secondary-300);
}
/* A checked box draws a tick (#321 a): a solid square alone did not say
   'ticked'. The mark is the library's Icon_check__Active glyph, recoloured to
   neutral-100 (Icon_check__OnColor.svg) because an image cannot take the
   page's CSS variables; white on secondary-300 is well above the 3:1 a
   non-text mark needs. Radios keep the filled dot. */
.check input[type="checkbox"]:checked {
  background-image: url("icons/Icon_check__OnColor.svg");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 12px 9px;
}
.check input:disabled { background: var(--color-neutral-200);
                        border-color: var(--color-neutral-300); }

/* ── 13. Modal / overlay ─────────────────────────────────────────────────
   Add .open to .overlay to show. Every paid or destructive action needs a
   confirmation modal (M16 global rule), so every screen will use this. */
.overlay {
  display: none;
  position: fixed; inset: 0; z-index: 60;
  background: var(--color-neutral-alpha10);
  backdrop-filter: blur(2px);
  align-items: center; justify-content: center;
  padding: var(--space-6);
}
.overlay.open { display: flex; }
.modal {
  background: var(--color-neutral-100);
  border-radius: var(--radius-7);
  box-shadow: var(--shadow-e3);
  width: min(520px, 100%);
  max-height: 90dvh;
  display: flex; flex-direction: column;
}
.modal-h { display: flex; align-items: center; gap: var(--space-3);
           padding: var(--space-5) var(--space-5) var(--space-3); }
/* CONTRAST: this borrowed .ttl's colour but NOT its weight, so every modal
   title rendered primary-300 at .type-h6's 20px/500 = 3.10:1 and failed AA -
   while the panel header two lines up carries the 700 that makes the same
   colour legal as large text. Measured in-browser across all 15 files: 25 of
   the 26 failures in the set were this one rule. Same rule, both places:
   a section header is BOLD orange, in a panel and in a modal alike. */
.modal-h .ttl { color: var(--color-primary-300); font-weight: 700; }
.modal-b { padding: 0 var(--space-5); overflow-y: auto; color: var(--color-neutral-900); }
.modal-f { display: flex; justify-content: flex-end; gap: var(--space-2);
           padding: var(--space-5); }
.modal-x { margin-left: auto; }

/* ── 14. Loading, empty and error states ─────────────────────────────────
   A skeleton mirrors the SHAPE of the finished content. Never a spinner. */
.sk {
  background: var(--color-neutral-200);
  background-image: linear-gradient(90deg,
      var(--color-neutral-200), var(--color-neutral-100), var(--color-neutral-200));
  background-size: 200% 100%;
  animation: sk-slide 1.4s linear infinite;
  border-radius: var(--radius-full);
  display: block;
}
.sk--line { height: 12px; }
.sk--pill { height: 26px; width: 72px; }
.sk--ini  { height: 26px; width: 26px; }
.sk--bar  { height: 8px; }
@keyframes sk-slide { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { .sk { animation: none; } }

/* Empty state: dashed well, honest wording, one clear way forward. */
.empty {
  border: var(--stroke-0) dashed var(--color-neutral-300);
  border-radius: var(--radius-6);
  padding: var(--space-7) var(--space-5);
  display: flex; flex-direction: column; align-items: center; gap: var(--space-2);
  text-align: center; color: var(--color-neutral-800);
}
.empty b { color: var(--color-neutral-1000); }

/* Inline notices. Left keyline + tinted ground, one per severity. */
.notice {
  display: flex; align-items: flex-start; gap: var(--space-3); flex-wrap: wrap;
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-6);
  border-left: var(--stroke-2) solid var(--color-neutral-300);
  background: var(--color-neutral-alpha10);
  color: var(--color-neutral-900);
}
.notice--err  { border-left-color: var(--color-red-200);    background: var(--color-red-alpha10); }
.notice--warn { border-left-color: var(--color-yellow-200); background: var(--color-yellow-alpha10); }
.notice--ok   { border-left-color: var(--color-green-200);  background: var(--color-green-alpha10); }
.notice--info { border-left-color: var(--color-secondary-300); background: var(--color-secondary-alpha10); }
.notice b { display: block; color: var(--color-neutral-1000); }
.notice > span { flex: 1 1 60%; min-width: 0; }
.notice > img { flex: none; }

/* ── 15. Utilities ───────────────────────────────────────────────────────── */
.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
      overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.mute { color: var(--color-neutral-800); }
.stack { display: flex; flex-direction: column; gap: var(--space-2); }
.rowx  { display: flex; align-items: center; gap: var(--space-2); }
/* Detail-screen back link. Was an inline style duplicated on both detail
   screens; the system owns this pattern, so it lives here. primary-500
   because -400 on the cream ground measures 3.88:1. */
.backlink { display: inline-flex; align-items: center; gap: var(--space-2);
            color: var(--color-primary-500); text-decoration: none;
            width: fit-content; }
.backlink:hover { text-decoration: underline; }
.skipto {                                        /* skip link, visible on focus */
  position: absolute; left: var(--space-3); top: var(--space-3); z-index: 80;
  transform: translateY(-200%);
  background: var(--color-secondary-300); color: var(--color-neutral-100);
  padding: var(--space-2) var(--space-4); border-radius: var(--radius-full);
}
.skipto:focus { transform: none; }

/* ── 16. Focus ───────────────────────────────────────────────────────────
   Custom-styled controls kill the UA ring, so every interactive element gets
   an explicit one. Purple, because purple is the structural colour. */
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: var(--stroke-1) solid var(--color-secondary-300);
  outline-offset: var(--space-0);
  border-radius: var(--radius-4);
}
.cell:focus-visible { outline-offset: var(--space-0); }

/* ── 17. Responsive ──────────────────────────────────────────────────────
   This is a web page, not a fixed canvas. It must hold from a half-snapped
   1100px window to a 2560px ultrawide. NEVER write a fixed body width or
   height, and never overflow:hidden the body. The 14-day grid is the binding
   constraint: below ~1100px the day cells stop being readable, so the layout
   re-ranks instead of shrinking further. */
@media (max-width: 1400px) {
  .kpi .bub { width: 38px; height: 38px; }
}
@media (max-width: 1180px) {
  /* Narrow the rail, keep the labels. An icon-only rail was tried and
     rejected: at a half-snapped window a teacher still benefits from words. */
  .app  { grid-template-columns: 178px 1fr; }
  .side { padding-inline: var(--space-3); }
  .side .brand b { font-size: 20px; }            /* EXCEPTION 2 of 2: this is
                     .type-h6's 20px, applied to a .type-h5 element so the
                     wordmark stops overflowing a 178px rail. Same scale. */
  .nav  { padding-inline: var(--space-3); gap: var(--space-2); }
  .main { padding: var(--space-4); gap: var(--space-3); }
  /* .ph .cap is NOT hidden here any more (UI/UX audit B10/C05, 2026-09-24):
     on most screens the caption is the only sentence that says where the
     panel's data came from, or that the read failed. */
}
@media (min-width: 1900px) {
  .main { max-width: 1880px; }                   /* stop rows stretching absurdly */
  .cell { max-width: 38px; }
}

/* ── 18. Auth page (login.html sits OUTSIDE the app shell) ───────────────
   No sidebar, no header: one centred pair of panels on the same cream ground.
   The left panel carries the keyboard motif so the product is recognisable
   before a single row of data exists. */
.auth { min-height: 100dvh; display: flex; align-items: center; justify-content: center;
        padding: var(--space-8) var(--space-6); }
.auth-wrap { display: grid; gap: var(--space-6); width: min(1040px, 100%);
             grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-items: stretch; }
.auth-brand { display: flex; flex-direction: column; justify-content: center;
              gap: var(--space-5); padding: var(--space-7) var(--space-6); }
.auth-brand .wm  { color: var(--color-primary-300); display: block; }
.auth-brand .sub { color: var(--color-secondary-300); display: block; }
.auth-brand p    { color: var(--color-neutral-900); }
/* The keys are the same days the heatmap draws - 28, the 月 period, which is
   this product's default. #28: the count is not written here either (see §11);
   grid-auto-flow gives one track per key, so the strip follows its own markup.
   [gate 2026-08-19] The claim that once stood here — that 56 / 36px are "exactly
   as .key / .key--black in §11" — was false and is removed: §11 measures 46px
   (.key, ~line 634) and 38px (.key--black, ~line 643 — was 32px until
   2026-08-22, raised because the label overflowed it). These two heights are
   this block's own, they resolve to no token, and nothing in §11 authorises
   them. Required removed by design-specs/login.md §3.1a(ii) whether or not A001
   lands; the values themselves are an open item for Agatha / Jeanet, not
   something to guess at here. */
.klines { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
          gap: var(--space-1); }
.klines i { display: block; height: 56px;
            background: var(--color-neutral-100);
            border: var(--stroke-0) solid var(--color-neutral-300);
            border-radius: var(--radius-none) var(--radius-none) var(--radius-4) var(--radius-4); }
.klines i.k--black { height: 36px; background: var(--color-neutral-1000);
                     border-color: var(--color-neutral-1000);
                     border-radius: var(--radius-none) var(--radius-none) var(--radius-3) var(--radius-3); }

/* ── 19. Contact sheet (index.html) ──────────────────────────────────────
   A reviewer's landing page: one tile per screen. The thumbnail is drawn from
   token-coloured blocks - no bitmap, so it can never go stale. Percentages
   inside a thumbnail are proportions of a schematic, the same licence the
   progress bar and the skeleton already have. */
/* index.html sits OUTSIDE the app shell, like login.html: it is a reviewer's
   surface, not a product screen, so it must NOT paint a sidebar with an active
   item that lies about where you are. It reuses .main for its rhythm and adds
   only a centring cap, so an ultrawide does not stretch one row of tiles. */
.sheetpage { width: 100%; max-width: 1880px; margin-inline: auto; }
/* auto-FILL, not auto-fit. auto-fit COLLAPSES the empty tracks in a ragged
   last row and stretches the survivors, so a final row of three tiles renders
   at double the width of every tile above it and reads as a bug. auto-fill
   keeps the empty tracks, so the short row is simply short.
   The max stays 1fr, NOT a fixed 320px: a definite max makes the browser
   compute the track COUNT from the max, which loses a whole column and leaves
   128px of dead ground on the right at 1920. Measured, not assumed. */
.sheet { display: grid; grid-template-columns: repeat(auto-fill, minmax(272px, 1fr));
         gap: var(--space-4); }
.tile { background: var(--color-neutral-100); border-radius: var(--radius-7);
        box-shadow: var(--shadow-e2); padding: var(--space-4);
        display: flex; flex-direction: column; gap: var(--space-2);
        color: var(--color-neutral-1000); text-decoration: none;
        border: var(--stroke-0) solid transparent; }
.tile:hover  { border-color: var(--color-primary-300); box-shadow: var(--shadow-e3); }
.tile .cap   { color: var(--color-neutral-800); }
.tile .ttl   { color: var(--color-primary-400); font-weight: 700; }
.thumb { display: flex; gap: var(--space-1); aspect-ratio: 16 / 10;
         background: var(--color-primary-alpha10); border-radius: var(--radius-6);
         border: var(--stroke-0) solid var(--color-neutral-200);
         padding: var(--space-2); overflow: hidden; }
.thumb .rail { width: 18%; flex: none; background: var(--color-neutral-100);
               border-radius: var(--radius-3); display: flex; flex-direction: column;
               gap: var(--space-0); padding: var(--space-1); }
.thumb .rail span { display: block; height: 4px; border-radius: var(--radius-1);
                    background: var(--color-neutral-200); }
.thumb .rail span.on { background: var(--color-primary-300); }
.thumb .body { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.thumb .body b, .thumb .body i, .thumb .body u {
  display: block; border-radius: var(--radius-3); }
.thumb .body b { height: 10%; background: var(--color-neutral-100); }
.thumb .body i { flex: 1; background: var(--color-neutral-100); }
.thumb .body u { height: 16%; background: var(--color-secondary-alpha10); }
.thumb .body i.acc { background: var(--color-primary-100); }
.thumb .body i.acc2 { background: var(--color-secondary-alpha10); }
.thumb .rowt { display: flex; gap: var(--space-1); flex: 1; min-height: 0; }
.thumb .rowt > * { flex: 1; }

/* ── 20. Content card grid (library.html) ────────────────────────────────
   The student app's song library is a card grid, not a table: a soft cover
   well, solid category tags, the title, then duration and difficulty. This is
   the same component at desktop density - the only screen in the set whose
   content is browsed rather than scanned.
   HONEST PLACEHOLDER: the asset library ships no cover artwork and no empty
   star, so the cover is a token-coloured well carrying Icon_music__Active and
   difficulty prints its filled stars followed by "n / 5". Nothing is drawn. */
.sgrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(232px, 1fr));
  gap: var(--space-4);
  align-content: start;
  overflow-y: auto; min-height: 0; flex: 1 1 auto;   /* scroll inside its panel */
}
.song {
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid var(--color-neutral-200);
  border-radius: var(--radius-7);
  box-shadow: var(--shadow-e1);
  padding: var(--space-3);
  display: flex; flex-direction: column; gap: var(--space-2);
  min-width: 0;
}
.song:hover { border-color: var(--color-primary-300); box-shadow: var(--shadow-e2); }
.song .cover {
  /* A banner, not a poster: the well is a placeholder, so giving it poster
     proportions would only buy more empty space and fewer visible cards. */
  aspect-ratio: 16 / 6;
  border-radius: var(--radius-6);
  background: var(--color-primary-alpha10);
  display: flex; align-items: center; justify-content: center;
}
.song .cover--alt { background: var(--color-secondary-alpha10); }
.song .nm2  { display: block; color: var(--color-neutral-1000); font-weight: 700;
              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song .meta2{ display: flex; align-items: center; gap: var(--space-3);
              flex-wrap: wrap; color: var(--color-neutral-800); }
.song .foot { display: flex; align-items: center; gap: var(--space-2); margin-top: auto; }
.song .tagrow { display: flex; align-items: center; gap: var(--space-1); flex-wrap: wrap; }
.stars { display: inline-flex; align-items: center; gap: var(--space-0); }
/* A withdrawn / unpublished item stays in the grid and stays readable. */
.song--off .cover { background: var(--color-neutral-200); }
.song--off .nm2   { color: var(--color-neutral-800); }
/* Orange solid tag - the "Exercise" label from the student library screen.
   White on --color-primary-400 is 4.85:1, so it is AA at .type-b3 (12px). */
.st-tag { background: var(--color-primary-400); color: var(--color-neutral-100); }

/* ── 20. File drop zone (upload.html) ────────────────────────────────────
   The asset library ships NO upload icon, so the zone leans on the photo
   asset plus a real <label for> driving a visually-hidden <input type=file>.
   Built on .empty, so the dashed well is literally the same object the
   empty state already uses - one shape, two jobs, no new vocabulary. */
.drop { min-height: 168px; justify-content: center;
        background: var(--color-primary-alpha10); }
.drop:hover, .drop:focus-within {
        border-color: var(--color-primary-300);
        background: var(--color-primary-100); }
.drop .fmt { color: var(--color-neutral-800); }
/* A file <input> is visually hidden and its <label> is the visible target, so
   the label cannot take :focus-visible itself. Any well that CONTAINS such a
   pair must therefore answer :focus-within, or a keyboard user tabs onto the
   control and nothing on screen moves. .drop already did; a plain .empty well
   holding a second picker did not. */
.empty:focus-within { border-color: var(--color-primary-300); }

/* ── 21. Score schematic (review.html) ───────────────────────────────────
   NOT an icon, and NOT a hand-drawn SVG. A diagram assembled from
   token-coloured blocks - exactly the licence .thumb, .bar and .sk already
   hold. It stands in for the real OSMD render, which a static mock cannot
   embed. Every colour, radius and stroke resolves to a token; the only raw
   numbers are physical drawing dimensions (staff-line pitch 12px, note-head
   13x10px) and percentage positions, which are proportions of a schematic.

     .score            scrolling score surface        (recognised output)
     .score--src         same object, paper-tinted    (the uploaded source)
     .sys              one system = four measures
     .msr / .msr--low / .msr--bad   a measure, tinted by engine confidence
     .stf              one five-line staff (two per measure = a grand staff)
     .nt               a note head. A real <button>: it is the per-note
                       correction affordance, so it must be focusable.        */
.score {
  flex: 1 1 auto; min-height: 0; overflow: auto;
  display: flex; flex-direction: column; gap: var(--space-6);
  background: var(--color-neutral-100);
  border: var(--stroke-0) solid var(--color-neutral-200);
  border-radius: var(--radius-6);
  padding: var(--space-4) var(--space-3);
}
.sys { display: grid; grid-template-columns: repeat(4, 1fr); }
.stf {
  position: relative; height: 49px;               /* 5 lines at 12px pitch */
  background: repeating-linear-gradient(
      var(--color-neutral-400) 0 1px, transparent 1px 12px);
}
.stf + .stf { margin-top: var(--space-4); }
.nt {
  position: absolute; width: 13px; height: 10px;  /* a note head, not a box */
  padding: 0; border: 0; border-radius: var(--radius-full);
  background: var(--color-neutral-1000);
  transform: translate(-50%, -50%);
}
/* Region of interest drawn over the source page, matching a flagged measure. */
.roi { position: absolute; pointer-events: none;
       border: var(--stroke-1) dashed var(--color-red-200);
       border-radius: var(--radius-2); }
/* The measure the teacher is currently correcting, tied to the .nt--sel note. */

/* ── Locked-on checkbox (added for settings.html) ─────────────────────
   A checkbox that is checked AND disabled - a value set by staff rather than
   by the user (the manually-verified charitable status, M28), or a notice
   that must not be switched off - previously rendered EXACTLY like an
   unchecked disabled box, because §12's :disabled rule lands after :checked
   and repaints the fill. It now keeps the filled reading at a muted neutral
   step, so "locked on" and "locked off" are distinguishable at a glance. */
.check input:checked:disabled { background: var(--color-neutral-500);
                                border-color: var(--color-neutral-500); }

/* ── Neutral KPI variant (student-detail.html) ──────────────────────────
   §8 shipped five KPI variants, none of them neutral, so a count of items in
   a DORMANT state (未指派席位) had to borrow --late. Yellow already means
   "即將到期" in the very table below it, and the same table paints 未指派 with
   .st-mute, so the KPI and the rows contradicted each other. This variant is
   .st-mute's ground at KPI scale: one meaning, one colour, both places. */
.kpi--mute { background: var(--color-neutral-200); }
.kpi--mute .val { color: var(--color-neutral-1000); }

/* ── 20. Prototype marker ────────────────────────────────────────────────
   The Pages site is public while the repository is private, so anyone with
   the URL lands here with no context. Every screen carries this so a
   visitor cannot mistake seeded demo data for a live class. position:fixed
   deliberately: it must not enter the flow, or it would change the
   viewport-fill and dead-band measurements every screen is verified on. */
.protobar {
  position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 90;
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  background: var(--color-yellow-100);
  color: var(--color-neutral-1000);
  border: var(--stroke-0) solid var(--color-yellow-200);
  border-radius: var(--radius-full);
  box-shadow: var(--shadow-e2);
  pointer-events: none;                 /* never blocks a control beneath it */
}
.protobar b { font-weight: 700; }
/* The marker is fixed, so anything reaching the bottom-right of the scroll
   passes underneath it - measured covering a panel header on
   recordings.html. Reserve its lane. This costs nothing
   visually: on a page that already scrolls it only extends the scroll, and it
   is not the mid-page empty band that padding must never be used to create. */
.main { padding-bottom: var(--space-10); }
.protobar .sep { color: var(--color-neutral-800); }
@media print { .protobar { display: none; } }

/* ── 21. Score editor (review.html) ──────────────────────────────────────
   A real ScoreJSON document editor, not a correction panel bolted to OMR.
   The starting document happens to be conversion output; the same surface
   authors from empty. Measured on the real source before building:
   durations 0/178 staves fail, so the gate is NOT arithmetic - it is that
   100% of 1115 notes carry no fingering, which no model can infer. */
.sed { display: grid; grid-template-columns: minmax(0,1fr) clamp(280px,24vw,340px);
       gap: var(--space-4); align-items: start; }
.sed-staff { display: flex; flex-direction: column; gap: var(--space-3); }
.sed-bar { border: var(--stroke-0) solid var(--color-neutral-200);
           border-radius: var(--radius-6); padding: var(--space-3);
           background: var(--color-neutral-100); }
.sed-bar.is-flagged { border-color: var(--color-yellow-200);
                      background: var(--color-yellow-alpha10); }
.sed-barhd { display: flex; align-items: center; gap: var(--space-2);
             margin-bottom: var(--space-2); color: var(--color-neutral-800); }
.sed-lane { display: flex; align-items: center; gap: var(--space-1);
            flex-wrap: wrap; padding: var(--space-1) 0; }
.sed-lane + .sed-lane { border-top: var(--stroke-0) dashed var(--color-neutral-200);
                        margin-top: var(--space-2); padding-top: var(--space-2); }
.sed-hand { width: 34px; flex: none; color: var(--color-neutral-800); }
.sed-note { display: inline-flex; flex-direction: column; align-items: center;
            gap: var(--space-0); min-width: 44px; padding: var(--space-1) var(--space-2);
            border: var(--stroke-0) solid var(--color-neutral-300);
            border-radius: var(--radius-4); background: var(--color-neutral-100);
            color: var(--color-neutral-1000); cursor: pointer;
            font-family: var(--font-family); }
.sed-note:hover { border-color: var(--color-primary-300);
                  background: var(--color-primary-alpha10); }
.sed-note[aria-pressed="true"] { border-color: var(--color-secondary-300);
                                 border-width: var(--stroke-1);
                                 background: var(--color-secondary-alpha10); }
.sed-note.is-rest { color: var(--color-neutral-600); }
.sed-note.needs-finger { border-style: dashed; }
.sed-note.wrong-hand { border-color: var(--color-red-200);
                       background: var(--color-red-alpha10); }
.sed-fg { min-height: 18px; }
.sed-fg.has { color: var(--color-secondary-300); font-weight: 700; }
.sed-fg.none { color: var(--color-neutral-500); }
.sed-panel { position: sticky; top: var(--space-4);
             border: var(--stroke-0) solid var(--color-neutral-200);
             border-radius: var(--radius-6); background: var(--color-neutral-100);
             padding: var(--space-4); }
.sed-fingers { display: grid; grid-template-columns: repeat(5,1fr); gap: var(--space-1); }
.sed-fingers button { height: 40px; border-radius: var(--radius-4);
                      border: var(--stroke-0) solid var(--color-neutral-300);
                      background: var(--color-neutral-100);
                      color: var(--color-neutral-1000); cursor: pointer;
                      font-family: var(--font-family); font-weight: 700; }
.sed-fingers button[aria-pressed="true"] { background: var(--color-secondary-300);
                                           color: var(--color-neutral-100);
                                           border-color: var(--color-secondary-300); }
.sed-meter { height: 8px; border-radius: var(--radius-full);
             background: var(--color-neutral-200); overflow: hidden; }
/* Animates transform, never width: width triggers layout on every frame.
   scaleX from a left origin is composited, so the fill is free to animate. */
.sed-meter i { display: block; height: 100%; width: 100%;
               background: var(--color-secondary-300);
               border-radius: var(--radius-full);
               transform-origin: left center;
               transform: scaleX(var(--p, 0));
               transition: transform .2s ease; }

/* ── 22. Capability marker ───────────────────────────────────────────────
   March's failure was documentation claiming function the code lacked. The
   cure is not prose: a screen DECLARES data-capability on <body>, and
   tools/verify-capability.mjs recomputes it from real handler counts and
   fails when the declaration disagrees. The claim cannot drift from the code
   without breaking the check.
   Only LIVE is marked. Eleven "this is a mock" badges would dilute the signal
   and tar the working screens; one positive marker tells a reviewer where to
   spend attention. */
body[data-capability="live"] .protobar::before {
  content: "可操作 · ";
  font-weight: 700;
  /* neutral-1000 on yellow-100, not green-200: that pair measured 1.74:1
     (UI/UX audit A12/B02, 2026-09-24). */
  color: var(--color-neutral-1000);
}

/* ── 23. Phase-1 hidden features — REMOVED at M33 (2026-08-26) ─────────
   This block hid 班別 / 機構與席位 / 錄音 whenever assets/flags.js set
   data-hide="phase1". M33 removed classes and organisations from the data
   model and their three screens were deleted, so there is no entry left to
   hide; 錄音 was never removed by M33 and is reachable again. flags.js no
   longer sets the attribute, and the selectors that read it are gone with it.
   The dashboard carries one static notice explaining where 班別 and 機構
   went; nothing here hides it. */

/* ── 24. Student picker + heatmap hairline (teacher-workflow cluster, 2026-08-19)
   APPEND-ONLY BLOCK. Nothing above this line is edited by this cluster.
   Every value is a token from assets/tokens.css or calc() over one; the single
   layout track (minmax 180px) follows the convention already used nine times
   in this file and is escalated in the cluster report, not invented quietly.

   .picker is a FILTERED CHECKBOX GROUP, not an ARIA combobox. Forty native
   <input type="checkbox"> with real <label> are announced correctly by every
   screen reader with zero focus management; an aria-activedescendant combobox
   is roughly 200 lines of keyboard handling this surface does not need. The
   cost — 40 tab stops — is mitigated by the filter and is normal for a
   checkbox group. */
.picker      { display: flex; flex-direction: column; gap: var(--space-2);
               min-width: 0; border: 0; padding: 0; margin: 0; }
.picker-bar  { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.picker-bar .search { width: clamp(160px, 18vw, 260px); }
.picker-sel  { display: flex; align-items: center; gap: var(--space-1); flex-wrap: wrap;
               min-height: var(--space-6); }
.picker-list {
  max-height: calc(var(--space-10) * 6);        /* 6 x 56px = 336px; ~9 rows before it scrolls */
  overflow-y: auto;
  border: var(--stroke-0) solid var(--color-neutral-200);
  border-radius: var(--radius-6);
  padding: var(--space-2);
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: var(--space-1);
  align-content: start;
}
.picker-list .check { padding: var(--space-1) var(--space-2);
                      border-radius: var(--radius-4); }
.picker-list .check:hover,
.picker-list .check:focus-within { background: var(--color-primary-alpha10); }
.picker .errmsg[hidden],
.picker .picker-bar[hidden],
.picker .picker-list[hidden] { display: none; }

/* ⚠️ THIS RULE IS INERT, AND ITS ORIGINAL REASON WAS WRONG. Measured on the
   deployed box 2026-09-17: .notice is a SINGLE class, (0,1,0), defined 505 lines
   above the global `[hidden] { display: none; }`, so the global already wins on
   source order and a <div class="notice" hidden> was already hidden without this.
   The rule is kept because removing it changes nothing and costs a review; the
   sentence is corrected because a false reason in the source is the defect
   AGENTS.md names.
   THE RULE, STATED NARROWLY, BECAUSE THE BROAD VERSION IS FALSE.
   `[hidden]` is (0,1,0) and this sheet global sits LAST, so it wins a TIE and
   nothing more. Source order decides a tie; it does not beat specificity.
     single class   .btn .panel .stack .field .chip   -> hidden WORKS
     compound       .nav .ic :116 - .kpi .valrow :387 - .ph .ttl :430
                    .menu.open :337 - .overlay.open :834   -> hidden FAILS
   The proof it is not theoretical is that the bespoke rules exist at all:
   .tbl .thead/.trow/.tfoot and .sgrid .song, and the eleven #sed* ids beside
   them, would be dead code under a global that covered everything. The block
   above them already says so in Chinese.
   The original sentence, kept as the record of what was believed:
   ".notice is display:flex, and a class beats the UA sheet [hidden]{display:none}."
   Without this rule a <div class="notice" hidden> paints an EMPTY coloured box that no
   teacher can dismiss, while el.hidden reads true in every DOM check. That exact defect
   shipped once (an empty orange warning open on every load of Settings, #207) and was
   caught a second time in review the next night. The .picker rules above fixed it for one
   component; this fixes it for every notice. Measured 2026-09-17. */
.notice[hidden] { display: none; }
/* .chip is a filter control elsewhere; inside .picker-sel it is a removable
   token, so it carries the ✕ image and must not stretch the row. */
.picker-sel .chip { gap: var(--space-1); }

/* ── 25. Heatmap cell hairline (teacher-workflow cluster, 2026-08-19)
   ACCESSIBILITY FIX, not decoration. lv-0 is --color-neutral-200 and measures
   1.24:1 against the white panel, so an unpractised day was indistinguishable
   from an absent cell for a low-vision reader. --color-neutral-600 measures
   3.28:1 against white and satisfies the 3:1 non-text bar; --color-neutral-500
   was tried first and measures 2.49:1, which does not. An inset box-shadow
   rather than a border so no cell changes size and no column re-flows. */
.cell { box-shadow: inset 0 0 0 var(--stroke-0) var(--color-neutral-600); }

/* The 未設 pill sits after the name inside .who, which is a flex row with
   min-width:0 — without flex:none the pill is the thing that shrinks. */
.who .pill { flex: none; }

/* …but on the heatmap it cannot share a line with the name at all. Measured at
   1280px: the .c-who track gives .who 115px, of which the avatar takes 26 and
   the pill 48; 吳偉聰 needs 48 and was ellipsised down to 13px — one character.
   A heatmap row whose student cannot be identified is worse than a taller row,
   so the header wraps and the pill drops beneath the name.
   The cost is bounded: when NO student has a requirement the pills are
   suppressed altogether (they would be on every row and carry no information),
   so the default state keeps its 34px rows and only the mixed state — which is
   a deliberate teacher action — grows. */
.tbl--heat .who { flex-wrap: wrap; row-gap: var(--space-0); }
.tbl--heat .who .nm { flex: 0 1 auto; }

/* A row-action cell holds three .mini buttons. Measured 2026-08-22 at 1872px:
   each is 74px with an 8px gap, so the row needs 238px and the track was
   clamp(150px,14vw,200px) - 38px short, and the cell wrapped to two lines
   (56px cell in a 64px row). The roster's own --cols now sizes that track
   clamp(238px,14vw,260px), taking the width from the 本期功課完成度 column,
   which measured 678px for a bar and "3 / 24" and had it to spare.
   THE WRAP STAYS as a fallback, not as the intended layout: below the
   Dashboard's stated 1280px floor the track can still be squeezed, and three
   buttons stacked beat three buttons clipped. Only students.html uses
   .actwrap. */
.actwrap { flex-wrap: wrap; row-gap: var(--space-1); }

/* ── 24. Login cluster (BUILD-PACKAGE §6, 2026-08-19) ────────────────────
   APPEND-ONLY BLOCK. Nothing above this line was edited. Every rule here
   either re-declares a property already declared above (so the cascade, not
   an edit, resolves it) or adds a new one. No new colour, spacing, radius,
   stroke or shadow value is introduced: every value below is a published
   token from assets/tokens.css.

   24a. The `hidden` attribute is only a UA `display:none`, so ANY author rule
   that sets `display` beats it. On this stylesheet that is at least five
   classes — .notice (flex), .sk (block), .stack (flex), .field (flex),
   .auth-wrap (grid) — which is why `el.hidden = true` silently does nothing
   on half the elements a screen would want to hide, and why §23 needed a
   bespoke #hiddenNotice[hidden] rule. One line ends the class of defect.

   ⚠️ Deliberately WITHOUT `!important`, and that is the whole design of it.
   `!important` would beat inline styles too, so any code that shows an
   element with `el.style.display = 'flex'` while the `hidden` attribute is
   still on it would break — silently, and in files other clusters are editing
   right now. Without it, this rule sits last in the sheet, so it beats every
   equal-specificity (0,1,0) class rule above — .notice, .sk, .stack, .field,
   .auth-wrap — while any inline display still wins. That is the correct
   precedence order.
   §23's bespoke #hiddenNotice[hidden] rule was deleted at M33 together with
   the rest of the phase-1 block; this one line is what covers that case. */
/* §27 · SETTINGS TABS (Herman, 2026-09-17)
   ─────────────────────────────────────────────────────────────────────────
   settings.html was seven stacked cards and a four-chip anchor strip, so a
   teacher scrolled a single column past six categories to reach the seventh.
   Herman ruled it "tabbed instead of everything in card", organised by what a
   teacher comes here to DO. One category is on screen at a time.

   WHY A SEGMENTED CONTROL AND NOT A SECOND VERTICAL RAIL. The screen already
   has a left nav. A second vertical rail beside it reads as a mistake, and at
   the 1100px floor it would eat the content width the tabs exist to give back.
   Measured: six Chinese labels at this type are ~576px against ~820px of
   content at 1100px, so a horizontal strip fits with room at the floor.

   WHY NOT REUSE .chips. The strip it replaces was .chips, and a loose row of
   pills reads as FILTERS - students.html and dashboard.html both use .chips
   for exactly that. One enclosing track turns the same pill vocabulary into
   navigation without inventing a component the product does not already
   speak. Radius, colour and type are the existing tokens.

   THE PANELS ARE HIDDEN WITH THE `hidden` ATTRIBUTE and no bespoke rule, and
   that is safe HERE for one reason only: `.tabpanel` is a SINGLE class, so it
   ties with the global `[hidden]` at (0,1,0) and the global - which sits below
   this section - wins on source order. VERIFIED BY A COMPUTED READ on the
   deployed page, not inferred: every hidden panel reports display:none, h=0.

   ⚠️ DO NOT GENERALISE THIS. An earlier version of this comment said the
   attribute simply works on a class-styled element. It does not.
   THE RULE, STATED NARROWLY, BECAUSE THE BROAD VERSION IS FALSE.
   `[hidden]` is (0,1,0) and this sheet global sits LAST, so it wins a TIE and
   nothing more. Source order decides a tie; it does not beat specificity.
     single class   .btn .panel .stack .field .chip   -> hidden WORKS
     compound       .nav .ic :116 - .kpi .valrow :387 - .ph .ttl :430
                    .menu.open :337 - .overlay.open :834   -> hidden FAILS
   The proof it is not theoretical is that the bespoke rules exist at all:
   .tbl .thead/.trow/.tfoot and .sgrid .song, and the eleven #sed* ids beside
   them, would be dead code under a global that covered everything. The block
   above them already says so in Chinese.
   So if `.tabpanel` ever gains a compound rule - `.tabs .tabpanel`, or
   `.tabpanel.active` - the attribute stops hiding it and NOTHING reports that:
   every panel renders at once while el.hidden still reads true. Give it a
   bespoke `[hidden]` rule at (0,2,0) the moment that happens. */
.tabs {
  display: flex; align-items: center; gap: var(--space-1);
  padding: var(--space-1);
  background: var(--color-neutral-200);
  border-radius: var(--radius-full);
  align-self: flex-start;
  max-width: 100%;
  overflow-x: auto;
  /* The track is the scroll container at narrow widths; the scrollbar itself
     is browser chrome we did not draw, so it is themed rather than left raw. */
  scrollbar-width: thin;
  scrollbar-color: var(--color-neutral-300) transparent;
}
.tab {
  flex: none;
  display: inline-flex; align-items: center; gap: var(--space-2);
  height: 34px; padding: 0 var(--space-4);
  border: 0; border-radius: var(--radius-full);
  /* The track colour, declared rather than inherited. `transparent` left the
     label sitting on whatever was behind it, which is both a real legibility
     question and what made the mechanical detector read this as #606060 on
     the page peach. Measured: neutral-900 on the track is 7.23:1, against
     5.13:1 for neutral-800 — and an unselected TAB is navigation a teacher
     is meant to read, not a disabled control. */
  background: var(--color-neutral-200); color: var(--color-neutral-900);
  font-weight: 700; white-space: nowrap; cursor: pointer;
  transition: background-color .16s ease, color .16s ease;
}
.tab:hover { background: var(--color-neutral-100); color: var(--color-neutral-1000); }
.tab[aria-selected="true"] {
  background: var(--color-primary-400);
  color: var(--color-neutral-100);
  box-shadow: var(--shadow-e1);
}
.tab[aria-selected="true"]:hover { background: var(--color-primary-400); color: var(--color-neutral-100); }

/* THE UNSAVED MARK. The save bar is shared by every tab, so a teacher who
   edits 顯示 and then opens 通知 can no longer see what they changed. The dot
   names the tab that is dirty. It is a mark, not a colour-only signal: the
   save bar still says how many tabs carry changes. */
.tab .dot {
  width: 8px; height: 8px; border-radius: var(--radius-full);
  background: var(--color-primary-400);
}
.tab[aria-selected="true"] .dot { background: var(--color-neutral-100); }

/* One category at a time, so a panel gets the whole width instead of a third
   of it. The two-column split is the panel's own business, not the grid's. */
.tabwrap { display: flex; flex-direction: column; gap: var(--space-4); min-height: 0; }
/* The panel is a CONTAINER and not a surface. Each category owns exactly one
   card; a card inside a card is two surfaces saying one thing, and the
   mechanical detector counted six of them before this. */
.tabpanel { display: flex; flex-direction: column; gap: var(--space-4); min-height: 0; }

[hidden] { display: none; }

/* ⚠️ ANYTHING ADDED BELOW THIS LINE LOSES THE PROTECTION OF THE RULE ABOVE.
   It works BY BEING LAST among equal-specificity (0,1,0) rules, so a section
   appended to the END of this file beats it, and an element carrying `hidden`
   goes on painting while the DOM reports hidden:true.
   MEASURED 2026-09-17: §27 (settings tabs) was first appended at the end of
   this file and all six settings tabpanels painted at once, stacked. Every
   programmatic check passed - `hidden` read true, one panel counted as
   visible - and only the screenshot showed six. Moving §27 above this line
   fixed it.
   Put new display rules ABOVE this line, or give them their own [hidden]
   guard at (0,2,0), which wins regardless of order. */

/* 24b. Design-review scaffolding on login.html, driven by the REVIEW_SCAFFOLD
   boolean appended to assets/flags.js. CSS rather than JS node-removal so the
   first frame is already correct; display:none also takes it out of the tab
   order and the accessibility tree, which is the point — with the flag on,
   login.html presented FOUR input[type=password] and FOUR controls whose
   entire accessible name was 登入.
   Reversibility: inert when flags.js sets no attribute (fail-open, same
   posture as §23). NOT a security mechanism. */
html[data-hide~="review"] [data-review-scaffold] { display: none; }

/* 24c. WCAG 2.2 non-text contrast, measured against #FFFFFF with the relative
   luminance formula — not estimated:

     --color-neutral-300  #D2D2D2  1.51:1   .inp/.sel/.txa resting border
     --color-neutral-400  #BBBBBB  1.92:1   .check input border
     --color-neutral-500  #A4A4A4  2.49:1   .inp:hover border
     --color-neutral-600  #8E8E8E  3.28:1   ::placeholder
     --color-neutral-700  #777777  4.48:1
     --color-neutral-800  #606060  6.29:1

   SC 1.4.11 needs 3:1 for a control boundary and SC 1.4.3 needs 4.5:1 for
   14px/400 text (placeholder text has no exemption). On a white modal ground
   that 1px pill border is the ONLY thing saying a control is there, and it
   measured 1.51:1. No new token and no open decision is needed: -700 clears
   the boundary bar with margin, and -800 is genuinely the nearest passing
   step for the placeholder because -700 misses 4.5:1 by 0.02.
   Hover must stay DARKER than rest — a hover state lighter than the resting
   border reads as a bug and drops back under 3:1.
   ⚠️ This changes every form on all 15 screens, not just login. It is a
   visible weight change; Agatha should see it before 4-5 September. It is a
   notification, not a blocker: shipping a 1.51:1 boundary is the worse
   outcome. (login.md §5.1, Q12.) */
.inp, .sel, .txa                   { border-color: var(--color-neutral-700); }
.inp:hover, .sel:hover, .txa:hover { border-color: var(--color-neutral-800); }
.inp::placeholder, .txa::placeholder { color: var(--color-neutral-800); }
.check input                       { border-color: var(--color-neutral-700); }

/* 24d. …and the consequence of 24c that must not be left unhandled.
   §12 sets `.inp:focus { border-color: secondary-300; outline: 0 }`, and that
   rule (0,2,0) beats §16's :where(...):focus-visible ring (0,1,0). So a text
   input's ENTIRE focus indicator is the border colour change. Against the old
   #D2D2D2 that swap was obvious; against the new #777777 the focused border
   measures only 1.70:1 relative to the resting one, well under SC 2.4.11's
   3:1 for a focus indicator against adjacent colours. The accessibility fix
   in 24c would have quietly destroyed the focus indicator on every text field
   in the product.
   Restore §16's real ring for these controls. Same specificity as
   `.inp:focus` but later in the sheet, so it wins for :focus-visible while
   `outline: 0` still applies to plain :focus. --color-secondary-300 on white
   is 7.61:1. No new value: both declarations are copied from §16. */
.inp:focus-visible, .sel:focus-visible, .txa:focus-visible {
  outline: var(--stroke-1) solid var(--color-secondary-300);
  outline-offset: var(--space-0);
}
/* §16 also sets border-radius: --radius-4 on every :focus-visible control,
   and because :where() contributes no specificity that rule already beats
   §12's --radius-full on .inp today. With no ring drawn nobody noticed; with
   the ring restored above, a pill control would gain a rounded-rectangle ring.
   Keep the ring the shape of the control it belongs to. .txa is --radius-6 in
   §12 and is left on §16's own value. */
.inp:focus-visible, .sel:focus-visible { border-radius: var(--radius-full); }


/* ── 24. Score hit layer (review.html, score-review cluster 2026-08-19) ───
   design-specs/score-review.md §12 numbers this block §23; §23 was already taken
   by the nav cluster's phase-1 block above, so it is §24 here. Nothing in that
   spec table changed — only the section number.

   OSMD draws the staff into an SVG. The SVG itself is not keyboard-navigable and
   has no per-note event surface worth binding, so selection lives in a layer of
   transparent <button>s positioned over each note head from
   getBoundingClientRect() — NEVER getBBox(), whose SVG user units ignore
   osmd.Zoom (measured: one note head is 19.018 wide at zoom 1 by both methods,
   but 28.018 vs 42.027 at zoom 1.5).

   TARGET SIZE, stated honestly: a note head is ~13x10 physical px (the same
   class of physical drawing dimension already recorded in §21 for the mock
   staff); expanded by --space-1 on each side a hit target is roughly 21x18,
   BELOW WCAG 2.2 SC 2.5.8's 24x24. It rests on that rule's spacing exception
   and on the keyboard path (roving tabindex + arrow keys) being a guaranteed
   route to every note. Targets are deliberately NOT inflated further:
   overlapping targets would make a teacher hit the neighbouring note, which is
   worse than a small one. A minimum-hit-size token does not exist in the set;
   raising one is score-review.md §16 Q4, not a value to invent here.

   A rest's target is its whole glyph box, because a rest's ink fills its box,
   and a note gives way where the two collide (issue #205). The rest targets are
   therefore larger than a note target, not smaller: they end where the printed
   rest ends.

   No new colour, radius, stroke or shadow values: every declaration below
   resolves to a token. */

.sed-toolbar { gap: var(--space-2); margin-bottom: var(--space-3); flex-wrap: wrap; }

/* Measure strip: real measure numbers, bar-duration state, per-measure repeat
   toggles, and the unmapped flag. Repeats are per measure because that is what
   the converter reads (measure.repeatStart / repeatEnd) — EDIT_MODE_SPEC.md
   calls them a "global override", but the code is the fact. */
.sed-msrs { display: flex; flex-wrap: wrap; gap: var(--space-2);
            margin-bottom: var(--space-3); }
.sed-msr { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2);
           padding: var(--space-1) var(--space-3);
           border: var(--stroke-0) solid var(--color-neutral-200);
           border-radius: var(--radius-4);
           background: var(--color-neutral-100); }
.sed-msr.is-current { background: var(--color-secondary-alpha10); }
/* #261 -- a bar whose durations do not add up is OUTLINED, because a teacher
   scanning the strip must find it without reading every cell. The colour is not
   the message: the cell beside it spells out 超出 N 拍 / 不足 N 拍 in words, for
   the same reason .sed-flag below does. Tokens only -- --stroke-1 and
   --color-red-100, both from Demos/design-system/tokens.css. Placed beside
   .is-current rather than appended to this file, because app.css ends with a
   global [hidden] rule that works BY BEING LAST. */
.sed-msr.is-off { border-color: var(--color-red-100);
                  border-width: var(--stroke-1);
                  background: var(--color-red-alpha10); }
/* Yellow (--color-yellow-200) is ~2.0:1 on white and may not carry meaning on
   its own anywhere on this page, so the flag is red AND spells out the count. */
.sed-flag { color: var(--color-red-200); font-weight: 700; }

.sed-osmd { position: relative; overflow: auto;
            background: var(--color-neutral-100);
            border: var(--stroke-0) solid var(--color-neutral-200);
            border-radius: var(--radius-6);
            padding: var(--space-4) var(--space-3); }
.sed-hit { position: absolute; inset: 0; pointer-events: none; }
/* planning #182, Herman Q1 (a): review.html?jobId= is view-only until 確認並儲存 -
   no note can be selected, so nothing can be edited. */
body[data-review-job] .sed-hit .hit { pointer-events: none; }
.hit { position: absolute; pointer-events: auto; padding: 0;
       background: transparent;
       border: var(--stroke-0) solid transparent;
       border-radius: var(--radius-2); cursor: pointer; }
.hit:hover { background: var(--color-primary-alpha10);
             border-color: var(--color-primary-300); }
.hit[aria-pressed="true"] { background: var(--color-secondary-alpha10);
                            border-color: var(--color-secondary-300);
                            border-width: var(--stroke-1); }
/* Suspected wrong-hand notes stay marked without hover: measured 5 in 1115, rare
   enough that every one is worth stopping the teacher. Unfingered notes get NO
   staff decoration — all 104 of the sample's notes are unfingered, so 104 dashed
   boxes would bury the staff under its own annotation. Fingering progress is
   carried by the KPI, the list filter, and the digits OSMD prints as they are
   entered: the same information stated positively, at zero cost. */
.hit.wrong-hand { border-style: dashed;
                  border-color: var(--color-red-200);
                  background: var(--color-red-alpha10); }

.sed-details { margin-top: var(--space-4); }
.sed-details > summary { cursor: pointer; color: var(--color-neutral-1000); }
.sed-rule { border: 0; border-top: var(--stroke-0) solid var(--color-neutral-200);
            margin: var(--space-4) 0; }
/* The two info icons in the 樂曲 pane sit beside their heading, not above it:
   without this the icon takes the first line on its own and the heading reads as a
   stray glyph. Icons from Demos/design-system/icons/ only — nothing hand-drawn. */
.sed-panel .lbl { display: flex; align-items: center; gap: var(--space-1); }

/* 24e. The fold, and the .protobar lane, on login.html — all four numbers
   below were MEASURED in Chromium at http://localhost:8095/login.html with
   the review scaffolding gated off, not computed from this stylesheet.
   `.auth` is used by login.html and nothing else (grepped), so this is a
   page-scoped rule wearing a shared selector.

   The problem. A007 adds ~80px at the bottom of the right panel, and the
   reviewers' new CTA — 留下聯絡資料 — is the last thing on it. The acceptance
   line is 1280x720 and 1366x768, but those are SCREEN sizes: once the OS
   taskbar and browser chrome are counted a 768px laptop gives roughly 630px
   of viewport, which is the figure login.md §2A works in. Measured at 630px:

     button bottom = 641px   viewport = 630px   -> 11px BELOW the fold.

   Why §2A's first fallback was tried and then rejected. Collapsing the
   paragraph and the trigger onto one `.rowx` does clear the fold (bottom
   moves to 597px). It also moves the button to the panel's right edge, at
   x 1035-1183 — and `.protobar` is fixed at x 982-1350, y 578-614. So the
   button lands UNDER the prototype marker: above the fold, and half of it
   invisible. `.protobar` is pointer-events:none so it still clicks, which
   is precisely what makes this the kind of defect a structural check passes
   and a person notices immediately. `.rowx` was reverted.

   What is done instead. `.auth` is `min-height:100dvh` + `justify-content:
   center`, so its padding-top only takes effect once the content OVERFLOWS —
   i.e. on exactly the short viewports that have the problem, and on no
   others. Taking it from --space-8 to --space-4 lifts the whole column 24px
   on a 630px viewport and changes nothing at 900px, where the content still
   centres. The button then measures:

     button 581-617   viewport 630   -> 13px above the fold
     button x 715-863 vs protobar x 982+   -> no horizontal overlap at all

   padding-bottom goes the other way, to --space-10: that is the same lane
   `.main` already reserves for `.protobar` in §20, which login.html never
   got because it is not a `.main`. It costs nothing on a page that already
   scrolls at this height.

   ⚠️ Below roughly 600px of viewport the CTA is under the fold again and no
   padding fixes that — the right panel's own content is ~577px tall. That is
   outside the stated targets and is reported rather than papered over. */
.auth { padding-top: var(--space-4); padding-bottom: var(--space-10); }

/* ⚠ The `hidden` attribute is only a UA `display:none`, so ANY author rule that
   sets display beats it. Five of this screen's toggled elements carry such a class:
   .rowx (flex), .chip (inline-flex), .empty (flex), .errmsg and the panes. Without
   this rule the "樂譜未能繪出" error, the "全部完成" notice and the unsaved
   pill would all paint on load, and the pitch error message could never be
   dismissed. Scoped to this screen's ids rather than a global
   `[hidden] { display: none !important; }`: that global rule is the right
   long-term cure and is already on the login cluster's list (BUILD-PACKAGE §6).
   If it lands, this block becomes redundant but stays harmless — it must not be
   duplicated as a second global copy. */
#sedRenderErr[hidden], #sedAllDone[hidden], #sedUnsaved[hidden], #sedEmpty[hidden],
#sedForm[hidden], #sedPaneNote[hidden], #sedPaneSong[hidden], #sedMember[hidden],
#sedNone[hidden], #sedPitchErr[hidden], #sedOsmdWrap[hidden] { display: none; }
/* The score pane scrolls horizontally when the zoom asks for a wider sheet. At
   125% and 150% the engraving needs more width than the pane has; squeezing it
   into the pane printed consecutive rests on top of each other and left their hit
   targets smaller than their own ink (issue #205, measured: one measure's printed
   width ran 333px / 300px / 268px at 100% / 125% / 150%). */
#sedOsmdWrap { overflow-x: auto; }

/* ============================================================================
   §26 · VERIFICATION GATE — repairs found by measuring, 2026-08-19
   Appended block. No rule above this line was edited.

   Three blocks in this sheet are numbered §24 (teacher-workflow ~1188,
   login ~1258, score-review ~1347) because three clusters wrote in parallel
   against specs that each claimed the next free number. Renumbering them now
   would mean editing three other owners' blocks, which the append-only rule
   forbids; this block takes §26 so the next writer has an unambiguous number.
   The duplication is cosmetic — the cascade is unaffected, since these are
   comments, not selectors.
   ========================================================================= */

/* §26a · Two WCAG 1.4.3 failures on review.html's note list, measured in
   Chromium with ancestor backgrounds composited (not assumed opaque):

     .sed-note.is-rest  #8E8E8E (--color-neutral-600) on #FFFFFF = 3.28:1
     .sed-fg.none       #A4A4A4 (--color-neutral-500) on #FFFFFF = 2.49:1
                                              on #FFE8DC (row tint) = 2.11:1

   Both carry real text: 休 is how a rest is identified in the list, and · is
   the "no fingering yet" placeholder. 12px/400, so the bar is 4.5:1 — the
   large-text exemption (24px, or 18.66px at 700) does not apply.

   Fixed by moving one step down the published neutral ramp to
   --color-neutral-800 (#606060): 6.29:1 on white, 5.34:1 on the row tint.
   No token invented, no new value, no geometry changed — and the greyed-back
   reading the design intends is preserved, since #606060 still sits five steps
   above the body colour --color-neutral-1000.
   The dashed border on .needs-finger and each button's aria-label are
   unchanged; this only stops colour being the failing channel. */
.sed-note.is-rest { color: var(--color-neutral-800); }
.sed-fg.none      { color: var(--color-neutral-800); }


/* ── 27. Name-list cells (impeccable critique P0, 2026-08-20) ─────────────
   APPEND-ONLY BLOCK. Nothing above this line was edited.

   .nm is authored for the roster, where it sits inside .who{display:flex}. A
   flex child is blockified, so .nm's overflow:hidden + text-overflow:ellipsis
   + white-space:nowrap (section 10) all take effect and a long name ellipsises.

   assignments.html's 指派對象 column puts .nm in a plain block cell, where it
   is an INLINE box. overflow and text-overflow do not apply to inline boxes, so
   every one of those three declarations is inert: the span neither clipped nor
   ellipsised, it simply drew its natural 229px width across the 138px cell and
   over the column beside it. Measured on three of four rows at 1280 and 1366.

   Truncating is the wrong answer here - the cell already reads as a summary
   (「吳偉聰、張浩然、周雅琳 及其他 7 名」), so an ellipsis would hide names the
   sentence exists to give. Let it wrap instead, paired with .tbl--wrap so the
   row can grow to hold the second line.

   Scoped to .tbl--names so the roster's ellipsis behaviour is untouched.
   No new colour, spacing, radius, stroke or shadow value. */
.tbl--names .trow > * { min-width: 0; }
.tbl--names .nm {
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
  overflow-wrap: anywhere;
}


/* ── 28. Drop-zone feedback (2026-08-21) ─────────────────────────────────
   APPEND-ONLY BLOCK. upload.html's .drop zone advertised drag-and-drop from the
   day it was written, with no dragover or drop handler anywhere in the project,
   so nothing happened when a file was dragged over it. The handler exists now;
   this is the only style it needs. Both values are published tokens. */
.drop.is-over { background: var(--color-primary-alpha10);
                border-color: var(--color-primary-400); }


/* ── 29. 資料來源標記 (.prov) + 狀態篩選 (2026-08-21) ────────────────────
   APPEND-ONLY BLOCK. Two things this sheet had no style for.

   (a) .prov — the per-panel provenance badge written by assets/wire.js. A
   teacher must never have to guess whether a figure came off the wire or out of
   the demo fixture, so every panel that a backend call can fill carries one.
   It is deliberately QUIET: a dot and a word at type-b3, in the same pill
   geometry as .chip, so it reads as a label on the panel and never competes
   with the data underneath it. Colour carries a second signal beyond the dot —
   the word itself differs — so this is not a colour-only distinction.

   Contrast, measured against the panel's --color-neutral-100 ground:
     neutral-1000 #333333 on green-alpha10 over white  = 11.9:1
     neutral-1000 #333333 on neutral-alpha10 over white = 11.2:1
   Both pass WCAG 1.4.3 AA at any size. The 1px borders are decorative — the
   text carries the meaning — but green-200 #1FC16B (2.5:1) and neutral-400
   #BBBBBB (2.0:1) are recorded here as the non-text values in play.

   (b) [data-filter] — the visibility half of assets/filter.js. Four .chip
   buttons on dashboard.html and students.html carried aria-pressed with nothing
   listening: they announced a state they did not have (WCAG 2.2 4.1.2) and did
   nothing when clicked. filter.js listens; this hides the rows it excludes.
   VISIBILITY ONLY — no colour, spacing, radius, stroke or shadow.

   Every value below resolves to a token in assets/tokens.css. */
.prov {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-0) var(--space-2);
  border-radius: var(--radius-full);
  border: var(--stroke-0) solid var(--color-neutral-400);
  background: var(--color-neutral-alpha10);
  color: var(--color-neutral-1000);
  font-family: var(--font-family);
  white-space: nowrap;
  vertical-align: middle;
}
.prov > i {
  width: 8px; height: 8px; flex: none;
  border-radius: var(--radius-full);
  background: var(--color-neutral-600);
}
.prov--live {
  border-color: var(--color-green-200);
  background: var(--color-green-alpha10);
}
.prov--live > i { background: var(--color-green-200); }

/* The local badge is a <button> whenever there is a reason to disclose, so the
   reason is reachable by keyboard and not by hover alone. */
button.prov { cursor: pointer; }
button.prov:hover { background: var(--color-neutral-200); }
button.prov:focus-visible {
  outline: var(--stroke-1) solid var(--color-secondary-300);
  outline-offset: var(--space-0);
}
.prov-why {
  display: block;
  width: 100%;
  margin-top: var(--space-1);
  color: var(--color-neutral-800);
}

/* (b) Row filtering. Scoped to .tbl so review.html's own data-filter chips,
   which mean something else entirely, are untouched. */
.tbl[data-filter="risk"] .trow:not(.tr--risk),
.tbl[data-filter="late"] .trow:not(.tr--late),
.tbl[data-filter="ok"]   .trow:not(.tr--ok) { display: none; }

/* The aggregate row is deliberately NOT filtered — app.css and filter.js both
   say the total ignores the chip, so it must stay visible exactly when it
   matters. It needs no rule of its own: dashboard.html's total is <tr
   class="tfootr"> and students.html's is <div class="tfoot">, and neither
   carries .trow, so neither is ever matched above. */

/* ═══ §29 · 篩選後隱藏 (2026-08-21) ═══════════════════════════════════════
   assets/filter.js 的第二段用這一個 class 收起不符合的項目。用 class 而不是
   [hidden]，是因為 assets/search.js 已經用了 [hidden]：兩條通道分開，文字搜尋
   與膠囊篩選才可以同時生效，而不是後按的那一個把前一個的決定抹走。
   只設可見性，不碰顏色、間距、圓角或線寬。 */
.filtered-out { display: none !important; }

/* ═══ §30 · 樂譜直接操作 (2026-08-21) ═════════════════════════════════════
   assets/score-drag.js 的視覺部分：拖曳中的音符、調色盤的拖曳影子、拖放目標。
   全部只設狀態與游標，顏色一律取自 token，沒有新的形狀語言。 */

/* 拖曳期間不要讓瀏覽器選字，也不要讓觸控捲動搶走手勢。 */
body.sed-dragging { user-select: none; -webkit-user-select: none; }
.sed-hit .hit { touch-action: none; }
.sed-hit .hit.is-dragging { cursor: grabbing; }
.sed-hit .hit:hover { cursor: grab; }

/* 拖曳中的音符與其譜上的符頭一起移動，所以兩者都要即時跟手，不能有過渡。 */
.sed-hit .hit.is-dragging,
.sed-osmd g.vf-notehead[style*="translate"] { transition: none; }

/* 調色盤已選定時，譜上的音符是可放下的目標。 */
body.sed-armed .sed-hit .hit { cursor: copy; }
.sed-hit .hit.is-target { background: var(--color-secondary-alpha10);
                          outline: var(--stroke-0) solid var(--color-secondary-300); }

/* 從調色盤拖出來的影子。跟著游標，不接收指標事件，否則 elementFromPoint
   只會找到影子自己，永遠找不到下面的音符。 */
.sed-ghost { position: fixed; z-index: 60; pointer-events: none;
             transform: translate(var(--space-2), var(--space-2));
             padding: var(--space-1) var(--space-2);
             border-radius: var(--radius-2);
             background: var(--color-secondary-300);
             color: var(--color-neutral-100);
             box-shadow: var(--shadow-e2); }

#sedDragHint:not(:empty) { color: var(--color-secondary-300); font-weight: 700; }

/* ═══ §31 · 拖曳中的音符 (2026-08-21) ═════════════════════════════════════
   拖曳時，原本那一個音符留在原位並變淡，用來標示起點——譜上沒有起點，就無從
   量度移動了多遠，而在五線譜上「移動了多遠」正正是全部意義所在。同時複製一個
   半透明的整個音符跟著游標走：符頭、符幹、符尾、附點與臨時記號是同一件東西。 */
.sed-ghost-note { opacity: .58; pointer-events: none; }
.sed-origin     { opacity: .26; }

/* ═══ §32 · 被搜尋收起的列，真的要收起 (2026-08-21) ═══════════════════════
   §23 的全域 `[hidden] { display: none; }` 對表格的列無效。特異性：
   `.tbl .trow` 是 (0,2,0)，`[hidden]` 是 (0,1,0)，所以 display:grid 贏，列照樣
   顯示。量到的結果：名冊十列，輸入一個不可能命中的字，hidden 屬性十列全部設好，
   而十列全部仍然在畫面上。屬性設了不等於收起了——這一條就是把兩者接上。
   assets/search.js 用 [hidden]，assets/filter.js 用 .filtered-out，兩條通道分開
   才可以同時生效，所以這裡只補 [hidden] 那一條。 */
.tbl .thead[hidden],
.tbl .trow[hidden],
.tbl .tfoot[hidden],
.sgrid .song[hidden] { display: none; }

/* ═══ §33 · 繪譜限制披露 (2026-08-21) ═══════════════════════════════════════
   The score editor places the same disclosure beside an affected note, in its
   list row, and in the affected measure. Rendering limitations use the
   secondary treatment; document/engraving mismatches use the red treatment. */
.sed-disclosure {
  display: flex;
  flex-basis: 100%;
  flex-direction: column;
  gap: var(--space-0);
  padding: var(--space-1);
  border: var(--stroke-0) solid var(--color-neutral-300);
  border-radius: var(--radius-2);
  color: var(--color-neutral-1000);
}
.sed-disclosure--render {
  border-color: var(--color-secondary-300);
  background: var(--color-secondary-alpha10);
}
.sed-disclosure--data {
  border-color: var(--color-red-200);
  background: var(--color-red-alpha10);
}
.sed-disclosure-tag {
  display: block;
  color: var(--color-secondary-300);
  font-weight: 700;
}
.sed-disclosure-pin {
  position: absolute;
  top: calc(-1 * var(--space-1));
  right: calc(-1 * var(--space-1));
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--space-3);
  height: var(--space-3);
  padding: var(--space-0);
  border: var(--stroke-0) solid var(--color-neutral-100);
  border-radius: var(--radius-full);
  background: var(--color-secondary-300);
  color: var(--color-neutral-100);
  font-weight: 700;
  pointer-events: none;
}
.sed-disclosure-pin--data {
  background: var(--color-red-200);
}


/* ═══ §34 · 直接操作提示 (2026-08-22) ═════════════════════════════════════
   The object carries its own affordance: a note/rest names the available action
   on hover, focus, and selection, while the palette exposes its drag source. */
.sed-gesture-help { display: block; margin-top: var(--space-1); }
/* The grip marks and grab cursors that used to sit here belonged to the two
   horizontal chip rows. §42 replaced those with the left rail and carries its
   own affordance, so the rules went with the markup they selected (#214). */

.sed-hit .hit:hover,
.sed-hit .hit:focus,
.sed-hit .hit[aria-pressed="true"] { z-index: 9; }
.sed-hit .hit-hint {
  display: none;
  position: absolute;
  left: 50%;
  bottom: calc(100% + var(--space-1));
  transform: translateX(-50%);
  white-space: nowrap;
  z-index: 9;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-4);
  background: var(--color-neutral-1000);
  color: var(--color-neutral-100);
  box-shadow: var(--shadow-e3);
  pointer-events: none;
}
.sed-hit .hit:hover .hit-hint,
.sed-hit .hit:focus .hit-hint,
.sed-hit .hit[aria-pressed="true"] .hit-hint { display: block; }

body.sed-armed .sed-hit .hit[data-rest="true"] {
  outline: var(--stroke-0) dashed var(--color-secondary-300);
}

/* ═══ §35 · 休止符調色盤 — 已移除 (2026-09-17, #214) ═══════════
   A violet second row of ten rest chips stood here. Guitar Pro 8's own palette
   (user guide p.106, "Note-related symbols in the palette") lists ONE Rest
   button beside the seven durations, not a duplicate row, so the row became a
   single toggle in §42 and every rule that selected it went with it. */

/* ═══ §36 · 批次選取 (2026-08-22) ═══════════════════════════════════════════
   The count is always visible, including when no note is selected. List rows
   participate in the same pointer gesture as the engraved hit targets. */
.sed-selection-count { margin: 0 0 var(--space-2); color: var(--color-secondary-400); }
#sedBatchHint { margin-bottom: var(--space-2); color: var(--color-secondary-400); }
.sed-staff .sed-note { touch-action: none; }
.sed-staff .sed-note { cursor: grab; }
body.sed-dragging .sed-staff .sed-note { cursor: grabbing; }

/* ── 20. Header menus authored for the dashboard (assets/panels.js) ───────
   The notification, account and 更多篩選 dropdowns. They reuse the published
   .menu surface above; only the two pieces .menu lacked are added here — a
   section heading and an unread dot — plus <a> parity with .menu button, since
   .menu only ever styled buttons and these menus contain links.
   TOKENS ONLY. The Dashboard carve-out (2026-08-09) relaxes iPad component
   geometry on this surface, not token compliance. */
.menu a {
  display: flex; align-items: center; gap: var(--space-2);
  width: 100%; padding: var(--space-2) var(--space-4);
  color: var(--color-neutral-1000); text-decoration: none;
}
.menu a:hover { background: var(--color-primary-alpha10); }
.menu a + a, .menu button + a, .menu a + button {
  border-top: var(--stroke-0) solid var(--color-neutral-200);
}
.mnu-head {
  padding: var(--space-2) var(--space-4);
  color: var(--color-neutral-700);
  border-bottom: var(--stroke-0) solid var(--color-neutral-200);
}
.menu .dot {
  width: var(--space-2); height: var(--space-2); flex: none;
  border-radius: var(--radius-full); background: var(--color-red-200);
}

/* ═══ §37 · 試聽 — review.html 的播放列與位置指示 (2026-08-26, issue #11) ══
   TOKENS ONLY. 只有四條規則，因為播放列本身沿用 .sed-toolbar / .mini /
   .chip，沒有一件新元件。

   為甚麼「正在播放」用主色（primary）而不是次色（secondary）：次色在這一頁
   已經代表「已選取」（.hit[aria-pressed="true"]），而選取與播放會同時出現
   ——播放選取範圍就是這個情況。兩者共用一種顏色就分不出來。播放用主色，
   而且描邊比 :hover 粗一級（--stroke-1 對 --stroke-0），所以與滑鼠經過也
   分得開。 */
.hit.is-playing {
  background: var(--color-primary-alpha10);
  border-color: var(--color-primary-300);
  border-width: var(--stroke-1);
}
/* 指示線與命中層是 .sed-osmd 的兩個絕對定位子元素，所以座標原點完全相同：
   line 的 left/top 直接取自按鈕的 style.left/top，不需要再換算。 */
.sed-playhead {
  position: absolute; width: var(--stroke-1);
  background: var(--color-primary-300);
  border-radius: var(--radius-1);
  pointer-events: none; z-index: 8;
}
.sed-play-warn { color: var(--color-red-200); font-weight: 700; }
#sedPlayPos { color: var(--color-neutral-700); }
/* ═══ §38 · 原稿頁面 (review.html, issue #12 同一次操作的一半, 2026-08-26) ═══
   The score editor becomes a split view: the page the teacher uploaded on the
   left, the engraving it produced in the middle, the correction panel on the
   right. Measured at 1280 before choosing the tracks — .sed is 1027px wide, of
   which the correction panel already takes 307 — so the source track is sized
   to leave the staff over 400px rather than to look generous.

   TWO WIDTHS, ON PURPOSE. A held page has something to read and gets the wider
   track (.sed--srcwide). With nothing held, the pane carries two short blocks
   of text, so it takes the narrower one and gives the staff the difference
   back. The pane is never removed: "the original was not retained, and here is
   why" is what the teacher needs at exactly the moment they would look for the
   page. Both tracks are grid geometry, which the token set does not express;
   every colour, radius, stroke and type style below is a token. The Dashboard
   carve-out (2026-08-09) relaxes iPad component geometry on this surface, not
   token compliance. */
/* FOUR TRACKS SINCE 2026-09-17 (#214). The rail is the first, 52px, and the
   correction panel pays for it: the palette now owns duration, dot and rest, so
   the panel carries less than it did. MEASURED, because the ticket's acceptance
   test is that the staff gets WIDER and a fourth track normally makes it
   narrower. Score panel width, before → after:
       1280px viewport   470.4 → 473.9      1920px viewport   976 → 984
   Do not widen the panel back without re-measuring both. */
.sed { grid-template-columns: 52px clamp(200px,17vw,260px) minmax(0,1fr)
                              clamp(236px,18.4vw,264px); }
.sed--srcwide { grid-template-columns: 52px clamp(240px,22vw,340px) minmax(0,1fr)
                                       clamp(236px,18.4vw,264px); }
.sed-src { position: sticky; top: var(--space-4); }
.sed-srcpage {
  display: block; width: 100%; max-width: 100%;
  border: var(--stroke-0) solid var(--color-neutral-200);
  border-radius: var(--radius-6);
  background: var(--color-neutral-100);
}
/* A PDF renders in the browser's own viewer, which has no intrinsic size to
   hug: with no height the <embed> collapses to nothing and reads as the blank
   box this pane exists to avoid. 520px measured against the track — an A4 page
   at this width engraves to about 390px including the viewer's own toolbar — so
   one page fits and further pages scroll inside the viewer, not the pane.
   Note for anyone verifying this headless: Chromium's PDF viewer does not paint
   in headless, so the embed screenshots as a dark rectangle there. Measured
   headed on 2026-08-26, it renders the page. */
.sed-srcpage--pdf { height: 520px; }
.sed-srcopen { display: inline-flex; margin-top: var(--space-2); }

/* ═══ §39 · 逐小節捲動對照 (review.html, 需求 #82 / issue #12, 2026-08-26) ═══
   The source pane stops being a picture and becomes a window onto one. Three
   rules of geometry and one frame; every colour, radius and stroke is a token.

   WHY THE WINDOW IS SHORT. assets/source-sync.js sets its height from the page
   itself, at a little over two of the page's own systems, and the height here
   is only the fallback for the moment before that runs. A source page laid out
   at this track's width is roughly as tall as the track is wide, so at full
   height it would have no scroll range at all and "scroll together" would be a
   claim with nothing behind it. The alternative — scaling the image up until it
   overflows — also overflows it sideways and cuts every system in half, and
   half a system cannot be compared against anything.

   THE FRAME IS PRIMARY, NOT YELLOW. --color-yellow-200 is about 2.0:1 on white
   and §21 already forbids it carrying meaning alone. The frame is the same
   primary that §37 gives the playing note, one stroke heavier, and it does not
   carry meaning alone either: the sentence under the window says in words what
   is aligned and what is not. */
.sed-srcview { position: relative; height: 320px; overflow: hidden; }
.sed-srcview--sync { overflow-y: auto; overflow-x: hidden; }
.sed-srcview .sed-srcpage { position: relative; }
.sed-srcmark {
  position: absolute; pointer-events: none; z-index: 2;
  border: var(--stroke-1) solid var(--color-primary-300);
  border-radius: var(--radius-2);
  background: var(--color-primary-alpha10);
}
.sed-srcsync { margin-top: var(--space-2); color: var(--color-neutral-800); }
.sed-srcsync--off { color: var(--color-red-200); }


/* ═══ §40 · 掃描前必讀（M33, 2026-08-26）════════════════════════
   THERE IS NO PUBLISHED MODAL SPEC. Demos/design-system publishes tokens and
   icons; it publishes no dialogue, no sheet and no legal-text component, and
   there is NO FIGMA FOR THE TEACHER DASHBOARD at all. So these three blocks are
   authored from tokens, and they are authored ON TOP of components this sheet
   already has rather than beside them: the container is the existing .modal
   (§13), the ticks are the existing .check, the scroll is .modal-b's own. What
   is new is only the three shapes that did not exist - a cost block, a bordered
   run of legal text, and a visibly separate group for the under-eighteen
   confirmations. EVERY VALUE IS A TOKEN. It still needs design review.

   WHY A SEPARATE GROUP FOR THE MINOR CONFIRMATIONS. M33 (d) and (e) ask a user
   under eighteen for two things nobody else is asked. Folding them into the
   same list would hide that something extra is being asked, and the extra
   thing is a guardian's consent. §21 forbids colour carrying meaning alone,
   so the group also carries its own sentence saying why it is there. */
.m33-cost {
  display: flex; flex-direction: column; gap: var(--space-2);
  background: var(--color-primary-alpha10);
  border-radius: var(--radius-5);
  padding: var(--space-4);
}
.m33-cost .r { display: flex; align-items: baseline; justify-content: space-between;
               gap: var(--space-4); }
.m33-cost .k { color: var(--color-neutral-800); }
/* The total is the one number a teacher meets afterwards if it is missed, so it
   is the only line in the block that is not neutral. Bold, as §13 requires of
   primary-300 text at this size. */
.m33-cost .tot { color: var(--color-primary-300); font-weight: 700; }
.m33-cost .fine { color: var(--color-neutral-800); }

.m33-terms {
  display: flex; flex-direction: column; gap: var(--space-4);
  border: var(--stroke-0) solid var(--color-neutral-300);
  border-radius: var(--radius-5);
  padding: var(--space-4);
}
.m33-terms h3 { margin: 0 0 var(--space-1); color: var(--color-neutral-1000); }
.m33-terms p  { margin: 0; color: var(--color-neutral-900); }

.m33-minor {
  display: none; flex-direction: column; gap: var(--space-3);
  background: var(--color-yellow-alpha10);
  border-radius: var(--radius-5);
  padding: var(--space-4);
}
.m33-minor.on { display: flex; }
.m33-minor .why { margin: 0; color: var(--color-neutral-1000); }

.m33-conf { display: flex; flex-direction: column; gap: var(--space-3); }
/* .check is inline-flex and centre-aligned, which is right for a one-line
   option and wrong for a two-line sentence: the box drifts to the middle of the
   text. These are sentences, so the box aligns to the first line. */
.m33-conf .check, .m33-minor .check { display: flex; align-items: flex-start; }
.m33-conf .check input, .m33-minor .check input { flex: none; margin-top: 2px; }

/* THE REASON THE BUTTON IS GREY, on its own line directly above the button.
   It is NOT inside .modal-f: §13's footer is one non-wrapping flex row, and a
   fifth item in it broke 取消 and 同意並開始掃描 onto three separate lines
   (measured at 1440x900). Kept out, it can never squeeze either button, and
   .modal-f stays exactly as every other modal in the product has it.
   #D00416 on #FFFFFF is 5.66:1 - AA at this size - and the colour is decoration
   only: the sentence carries the meaning (§21). Empty is the normal state, and
   an empty line must take no height, or the dialogue would jump each time the
   reason changed. */
#convModal #m33Why {
  margin: 0; padding: var(--space-3) var(--space-5) 0;
  text-align: right; color: var(--color-red-200);
}
#convModal #m33Why:empty { display: none; }

/* ═══ §41 · 2026-09-01 · Herman's own tickets against the live dashboard ═════
   Three small rules, each named by the ticket it answers. Nothing new is drawn:
   every value is a token and every shape already exists in this file. */

/* T001 · 上一期／下一期. The library ships ONE chevron and its path points LEFT,
   so 下一期 is the same asset mirrored - a reused asset, not a redrawn one.
   (This was the second site of that mirroring; .ph .ttl .ar was the first and
   went with #175 item 2 on 2026-09-16, so this is now the only one.)
   The disabled treatment is the whole button family's
   --color-neutral-200, matching .btn[disabled]. */
.chips .per-step img { display: block; }
.chips .per-next img { transform: scaleX(-1); }
.chips .per-step[disabled] { border-color: var(--color-neutral-200); }
.chips .per-step[disabled] img { opacity: 0.4; }
.chips .per-which { color: var(--color-neutral-800); }

/* R002 · 「· 沒有對應端點」 is a provenance note, not part of the value. Wrapped
   inside .val it was the third line of a 72px block in a 64px pill; on its own
   line under the value it reads as what it is. The pill is free to grow now
   (see .kpi above), so this is legibility, not the overflow fix. */
.kpi .val .kpi-why { display: block; color: var(--color-neutral-900); }

/* S004 · 通知偏好 的 電郵／平台內 兩欄置中.
   MEASURED at 1194x834: the 平台內 header computed text-align:start at left
   434.7 in a 72px column, and every checkbox in it sat flush at 434.7 - a
   centre of 443.7 against the column's 470.7, 27px out. 電郵 was 23px out the
   same way. Herman named only 平台內; centring one and leaving the other beside
   it would read as a slip, so the two move together.
   The columns are addressed by nth-child rather than by a class on each cell:
   there are 24 cells and this table's shape is 事件 + two boolean columns. */
.tbl--noti .thead > :nth-child(n+2),
.tbl--noti .trow  > :nth-child(n+2) {
  display: flex; justify-content: center; text-align: center;
}


/* ═══ §42 · 修正工具直列調色盤 (review.html, #214, 2026-09-17) ═══════════════
   WHAT THIS REPLACES. Two horizontal rows of twenty-one Chinese text chips,
   measured at 1280px on 2026-09-17: 330px tall, seven wrapped rows, and they
   offered durations only. Nine of the twenty-one were dotted duplicates.

   THE SHAPE IS COPIED FROM TWO REAL NOTATION EDITORS, NOT INVENTED.
     · Guitar Pro 8 user guide p.47: "Located on the left side of your window,
       the Edition palette gathers the main edition tools… You just need to
       click on a button for the corresponding element or attribute to be
       affected to the notes you have selected."  So: left, vertical, and
       select-then-click.
     · Guitar Pro 8 user guide p.106, "Note-related symbols in the palette",
       lists the group in order: seven durations, then ONE Rest, ONE Dot, ONE
       Double-dot, Triolet, tuplets, Tie. One modifier, never a doubled row.
     · MuseScore handbook, Using the palettes: both gestures, and "It is
       usually more efficient, however, to select the target elements in your
       score first and then click the palette item." Both are kept.
     · Guitar Pro p.108 on the triplet button: "the icon appears in blue" while
       the option is active — the palette shows its own state, which is why
       .on and .is-current below are two different things.

   WHY 52px AND NOT A ROOMIER RAIL. The ticket's acceptance test is that the
   staff ends up wider, and a fourth grid track normally makes it narrower. One
   34px column of cells plus padding is 52px, and §38 above records the two
   measurements that show the staff still gained.

   GLYPHS ARE BRAVURA, THE SMuFL REFERENCE FONT (SIL OFL 1.1), subset to the 25
   codepoints used here — 7,992 bytes, assets/fonts/equalplay-notation.woff2.
   ⚠️ IT IS NOT CALLED BRAVURA, AND THAT IS NOT A STYLE CHOICE. Subsetting makes
   a Modified Version and OFL clause 3 forbids one from carrying the Reserved
   Font Name, so the name table and this font-family both read EqualPlay
   Notation. The attribution and the rebuild recipe are in
   assets/fonts/equalplay-notation.LICENSE.txt. Every font-size
   and translate below is COMPUTED from the font's own glyphBBoxes, not eyeballed:
   SMuFL puts 4 staff spaces in 1 em, so a glyph of h staff spaces at font-size F
   is h/4 × F px tall, and its optical centre sits (top+bot)/2/4 em above the
   baseline. Each cell centres the baseline and then translates down by exactly
   that. Re-derive from assets/fonts/ if the glyph set changes; never hand-tune.
   The six durations share ONE font-size on purpose — a whole note really is a
   short wide oval next to a stemmed quarter, and normalising that away would
   make the palette lie about the notation.

   TOKENS ONLY for colour, radius, stroke and type. The cell box, the rail width
   and the glyph metrics are geometry, which the token set does not express; the
   Dashboard carve-out (2026-08-09) relaxes iPad component geometry on this
   surface, not token compliance. */
@font-face {
  font-family: 'EqualPlay Notation';
  font-style: normal;
  font-weight: 400;
  font-display: block;          /* a tofu box is not a fallback for a notehead */
  src: url('fonts/equalplay-notation.woff2') format('woff2');
}

.sed-pal {
  position: sticky; top: var(--space-4);
  /* NINETEEN CELLS IS TALLER THAN A SHORT VIEWPORT. A sticky column that is
     taller than the space it is pinned in puts its last cells permanently out
     of reach, so the rail scrolls inside itself once it runs out of room. */
  max-height: calc(100dvh - var(--space-4) * 2);
  overflow-y: auto;
  display: flex; flex-direction: column; gap: var(--space-3);
  padding: var(--space-2) var(--space-2) var(--space-3);
  border: var(--stroke-0) solid var(--color-neutral-200);
  border-radius: var(--radius-6);
  background: var(--color-neutral-100);
}
.sed-pal-grp { display: flex; flex-direction: column; gap: var(--space-1); }
/* The groups are separated by a rule rather than a heading: at 34px there is no
   room for a legible Chinese label, and Guitar Pro's own palette labels nothing
   either — each cell names itself through title and a visually-hidden span. */
.sed-pal-grp + .sed-pal-grp {
  padding-top: var(--space-3);
  border-top: var(--stroke-0) solid var(--color-neutral-200);
}
.sed-pal-cell {
  position: relative;
  display: grid; place-items: center;
  width: 34px; height: 34px; padding: 0;
  border: var(--stroke-0) solid var(--color-neutral-300);
  border-radius: var(--radius-4);
  background: var(--color-neutral-100);
  color: var(--color-neutral-1000);
  cursor: grab;
  font-family: var(--font-family);
  overflow: hidden;
}
body.sed-dragging .sed-pal-cell { cursor: grabbing; }
.sed-pal-cell:hover { border-color: var(--color-primary-300);
                      background: var(--color-primary-alpha10); }
.sed-pal-cell:focus-visible { outline: var(--stroke-1) solid var(--color-primary-300);
                              outline-offset: var(--space-0); }
/* ARMED — what the next click on a note will apply. Filled, like the pressed
   chip this rail replaces, so the meaning of "filled" did not change. */
.sed-pal-cell[aria-pressed="true"] {
  background: var(--color-primary-300);
  border-color: var(--color-primary-300);
  color: var(--color-neutral-100);
}
/* CURRENT — what the selected note already IS. A different signal from armed,
   because they answer different questions and are frequently both true. */
.sed-pal-cell.is-current {
  border-color: var(--color-secondary-300);
  border-width: var(--stroke-1);
  background: var(--color-secondary-alpha10);
}
.sed-pal-cell[aria-pressed="true"].is-current {
  background: var(--color-primary-300);
  border-color: var(--color-secondary-300);
}
/* Dotted whole and dotted thirty-second have no code in the converter's table
   (assets/vendor/ScoreJSON.js CODE_DIV), so with 附點 on they are refused
   rather than silently promoted to a quarter. Say so on screen. */
.sed-pal-cell[aria-disabled="true"] {
  opacity: 0.38; cursor: not-allowed;
  background: var(--color-neutral-100);
}
.sed-pal-hint { margin: 0 0 var(--space-2); color: var(--color-secondary-400); }
.sed-pal-hint:empty { display: none; }

/* ── The glyphs. Computed from Bravura's glyphBBoxes — see the header. ──── */
.sed-pal-cell .g { display: block; line-height: 0; font-family: 'EqualPlay Notation', serif;
                   font-weight: 400; font-style: normal; }
.sed-pal-cell .g::before { display: inline-block; }
.g-noteWhole::before       { content: "\E1D2"; font-size: 20.7px; transform: translateY(-0.001em); }
.g-noteHalfUp::before      { content: "\E1D3"; font-size: 20.7px; transform: translateY(0.365em); }
.g-noteQuarterUp::before   { content: "\E1D5"; font-size: 20.7px; transform: translateY(0.367em); }
.g-note8thUp::before       { content: "\E1D7"; font-size: 20.7px; transform: translateY(0.367em); }
.g-note16thUp::before      { content: "\E1D9"; font-size: 20.7px; transform: translateY(0.367em); }
.g-note32ndUp::before      { content: "\E1DB"; font-size: 20.7px; transform: translateY(0.442em); }
/* The whole and half rests are 0.58 staff spaces tall — a block on a staff
   line, which is what they are — so they take a larger size than the four
   below them or they read as a hyphen. */
.g-restWhole::before       { content: "\E4E3"; font-size: 44.0px; transform: translateY(-0.063em); }
.g-restHalf::before        { content: "\E4E4"; font-size: 44.0px; transform: translateY(0.070em); }
/* A STAFF LINE FOR THOSE TWO, AND ONLY BECAUSE THE PICTURE SAID SO. Both are
   the same 1.13 x 0.58 staff-space block; the whole rest hangs UNDER a line and
   the half rest sits ON one, and that is the entire difference. Centred alone in
   a 34px cell they were two identical dashes — measured by looking at the
   rendered rail, not by any assertion. The block is 44px x 0.58/4 = 6.4px tall,
   so the line sits 3.2px from its centre on the correct side. */
.sed-pal-cell .g-restWhole::after,
.sed-pal-cell .g-restHalf::after {
  content: ""; position: absolute; left: 50%;
  width: 20px; height: var(--stroke-0);
  background: var(--color-neutral-500);
  transform: translateX(-50%);
}
.sed-pal-cell .g-restWhole::after { top: calc(50% - 3.2px); }
.sed-pal-cell .g-restHalf::after  { top: calc(50% + 3.2px); }
.g-restQuarter::before     { content: "\E4E5"; font-size: 25.9px; transform: translateY(-0.001em); }
.g-rest8th::before         { content: "\E4E6"; font-size: 25.9px; transform: translateY(-0.039em); }
.g-rest16th::before        { content: "\E4E7"; font-size: 25.9px; transform: translateY(-0.161em); }
.g-rest32nd::before        { content: "\E4E8"; font-size: 25.9px; transform: translateY(-0.037em); }
.g-augmentationDot::before { content: "\E1E7"; font-size: 70.0px; transform: translateY(0em); }
.g-tuplet3::before         { content: "\E883"; font-size: 41.8px; transform: translateY(0.183em); }
/* Coverage group, #214 part 2. Same derivation as above: font-size = target px
   / (glyph staff spaces / 4), offset = (top + bottom) / 2 / 4 em. */
.g-textTie::before         { content: "\E1FD"; font-size: 55.9px; transform: translateY(-0.255em); }
.g-accidentalSharp::before   { content: "\E262"; font-size: 28.7px; transform: translateY(0.001em); }
.g-accidentalFlat::before    { content: "\E260"; font-size: 32.6px; transform: translateY(0.132em); }
.g-accidentalNatural::before { content: "\E261"; font-size: 29.6px; transform: translateY(0.003em); }
.g-gClef::before           { content: "\E050"; font-size: 14.8px; transform: translateY(0.220em); }
.g-fClef::before           { content: "\E062"; font-size: 22.3px; transform: translateY(-0.186em); }
.g-timeSigCommon::before   { content: "\E08A"; font-size: 36.0px; transform: translateY(0.001em); }
.g-ottavaAlta::before      { content: "\E511"; font-size: 25.4px; transform: translateY(0.227em); }
/* ottavaBassa (U+E512) IS THE STRING "8va". SMuFL names it "ottava bassa"
   because 8va was historically written below the staff too, so the up cell and
   the down cell rendered the SAME two characters and were indistinguishable.
   Found by looking at the rail, not by any assertion. ottavaBassaVb (U+E51C) is
   the "8vb" spelling and is what this cell needs. */
.g-ottavaBassa::before     { content: "\E51C"; font-size: 25.4px; transform: translateY(0.227em); }
/* SMuFL HAS NO KEY-SIGNATURE GLYPH, because a key signature IS a run of
   accidentals. Guitar Pro's own icon is a group of them, so this cell sets a
   flat and a sharp side by side — unmistakable against the single ♯ and the
   single ♭ two groups above, which is the only thing it has to be. */
.g-keySig::before          { content: "\E260\E262"; font-size: 22.0px; transform: translateY(0.05em);
                             letter-spacing: 1px; }

/* §43 · SCAN PAGE — GUIDED STEPS (Herman, 2026-09-24) ═══════════════════════
   upload.html, Option B of the scan-layout study: one centred card with three
   steps (選擇樂譜 / 收費與條款 / 轉換與結果), a 轉換紀錄 table under it, and the
   掃描前必讀 dialogue with each confirmation under its terms and the charge
   pinned in the footer.
   APPEND-ONLY. Nothing above this line was edited; where a rule above had to
   give way (the §40 terms box, the §40 #m33Why placement) it is overridden
   here with a more specific selector scoped to #convModal, and says so.
   EVERY VALUE IS A TOKEN. There is no Figma for the teacher dashboard and no
   published step or table component; this needs design review. */

/* the column: one card and the table, centred */
.scan-wrap { width: 100%; max-width: 920px; margin: 0 auto;
             display: flex; flex-direction: column; gap: var(--space-5); }

/* the step indicator: current = orange ring, done = green disc with a check */
.wiz-steps { list-style: none; margin: 0; padding: 0;
             display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); }
.wiz-steps li { display: flex; align-items: center; gap: var(--space-3);
                padding: var(--space-3) var(--space-4); border-radius: var(--radius-6);
                border: var(--stroke-0) solid var(--color-neutral-300);
                color: var(--color-neutral-800); min-width: 0; }
.wiz-steps li i { font-style: normal; font-weight: 700; flex: none;
                  width: 28px; height: 28px; border-radius: var(--radius-full);
                  display: grid; place-items: center;
                  background: var(--color-neutral-200); color: var(--color-neutral-900); }
.wiz-steps li.now { border-color: var(--color-primary-300); color: var(--color-neutral-1000); }
.wiz-steps li.now span { font-weight: 700; }
.wiz-steps li.now i { background: var(--color-primary-400); color: var(--color-neutral-100); }
.wiz-steps li.done i { background: var(--color-green-200); color: var(--color-neutral-1000); }

/* a pane per step. [hidden] needs its own rule: a class `display` beats it. */
.wiz-pane { display: flex; flex-direction: column; gap: var(--space-4); margin-top: var(--space-5); }
.wiz-pane[hidden] { display: none; }
.scan-wiz .drop { min-height: 196px; }
.wiz-rule { margin: 0; color: var(--color-neutral-900); }
#wizReject:empty, #wizCostNote:empty, #d1Result:empty, #upAssignOut:empty { display: none; }
#wizCostNote { margin: 0; color: var(--color-neutral-1000); }

/* step ②: the file rows */
.wiz-files { display: flex; flex-direction: column; gap: var(--space-2); }
.wiz-file { display: flex; align-items: center; gap: var(--space-3);
            padding: var(--space-3) var(--space-4); border-radius: var(--radius-6);
            border: var(--stroke-0) solid var(--color-neutral-300); }
.wiz-file .ext { flex: none; width: 40px; height: 40px; border-radius: var(--radius-4);
                 display: grid; place-items: center; font-weight: 700;
                 background: var(--color-secondary-alpha10); color: var(--color-secondary-400); }
.wiz-file .nm { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.wiz-file .nm b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wiz-file [data-file-pages] { flex: none; }

/* step ②: the cost breakdown — the total is the one orange figure */
.wiz-cost { display: grid; grid-template-columns: 1fr auto; gap: var(--space-2) 0;
            align-items: baseline; background: var(--color-primary-alpha10);
            border-radius: var(--radius-6); padding: var(--space-4) var(--space-5); }
.wiz-cost .v { text-align: right; padding-left: var(--space-6); }
.wiz-cost .tot { font-weight: 700; color: var(--color-primary-400);
                 border-top: var(--stroke-0) solid var(--color-primary-100);
                 padding-top: var(--space-2); }

/* step ②: the optional assignment, collapsed */
.wiz-opt { border-top: var(--stroke-0) solid var(--color-neutral-200); padding-top: var(--space-3); }
.wiz-opt summary { cursor: pointer; font-weight: 700; color: var(--color-neutral-1000); }
.wiz-opt[open] summary { margin-bottom: var(--space-3); }
.wiz-opt #convTargets, .wiz-opt #convMulti { margin-bottom: 0; }

.wiz-foot { display: flex; align-items: center; gap: var(--space-3); }

/* step ③: the ONE progress bar, and the result */
.wiz-prog { display: flex; flex-direction: column; gap: var(--space-3); }
.wiz-prog[hidden] { display: none; }
.wiz-progline { display: flex; align-items: center; gap: var(--space-3); }
.wiz-progline p, .wiz-prog > p { margin: 0; }
.wiz-result { display: flex; flex-direction: column; gap: var(--space-1);
              border: var(--stroke-0) solid var(--color-yellow-200);
              background: var(--color-yellow-alpha10);
              border-radius: var(--radius-6); padding: var(--space-4) var(--space-5); }
.wiz-result p { margin: 0; }
.wiz-foot[hidden] { display: none; }

/* 轉換紀錄 */
.scan-hist { width: 100%; border-collapse: collapse; }
.scan-hist th { text-align: left; font-weight: 500; color: var(--color-neutral-800);
                padding: var(--space-2) var(--space-3); }
.scan-hist td { padding: var(--space-3); border-top: var(--stroke-0) solid var(--color-neutral-200);
                color: var(--color-neutral-1000); }
.scan-hist td.act { text-align: right; }
.scan-hist [data-q-empty] td { text-align: center; padding: var(--space-6); color: var(--color-neutral-800); }
.scan-hist [data-q-empty][hidden] { display: none; }

/* M12: the one note line under the card */
.wiz-note { display: flex; align-items: center; gap: var(--space-2); margin: 0;
            color: var(--color-neutral-800); }

/* ── 掃描前必讀 ── */
#convModal .modal { width: min(680px, 100%); }
#convModal .modal-h { align-items: flex-start; }
#convModal .modal-h p { margin: var(--space-1) 0 0; }
#convModal .modal-b { padding-bottom: var(--space-4); }
/* Overrides §40's single bordered box: each term is now its own block with its
   confirmation directly under it, and turns green once that box is ticked. */
#convModal .m33-terms { border: 0; padding: 0; gap: var(--space-5); }
.m33-term { display: flex; flex-direction: column; gap: var(--space-2);
            padding-left: var(--space-4);
            border-left: var(--stroke-2) solid var(--color-neutral-300); }
.m33-term:has(input:checked) { border-left-color: var(--color-green-200); }
.m33-term h3 + p { margin-bottom: var(--space-1); }
.m33-term .check { display: flex; align-items: flex-start; font-weight: 700;
                   color: var(--color-neutral-1000);
                   background: var(--color-secondary-alpha10);
                   border-radius: var(--radius-5); padding: var(--space-3) var(--space-4); }
.m33-term .check input { flex: none; margin-top: 2px; }

/* the pinned footer: .modal-f sits outside .modal-b, so it never scrolls */
#convModal .m33-foot { align-items: center; gap: var(--space-3);
                       border-top: var(--stroke-0) solid var(--color-neutral-200);
                       background: var(--color-primary-alpha10);
                       border-radius: 0 0 var(--radius-7) var(--radius-7); }
.m33-sum { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--space-0); }
.m33-sum .tot { color: var(--color-primary-400); font-weight: 700; }
.m33-sum > span:empty { display: none; }
#convModal .m33-foot .btn, #convModal .m33-foot .btn--quiet { flex: none; }
#convModal #m33Accept { min-width: 9em; }
/* Overrides §40's placement of the refusal: it is a line in the footer's text
   column now, left-aligned, not a right-aligned line above the buttons. */
#convModal .m33-sum #m33Why { padding: 0; text-align: left; }
#convModal .m33-sum #m33CostNote { color: var(--color-neutral-900); }

/* ── register.html, moved out of style= attributes (map #338, 2026-09-26) ──
   Each rule was an inline style, and an inline style beats every normal rule, so
   each keeps that priority with !important: the page must look exactly as before.
   .reg-off is the hidden state the page script toggles (register.js). */
.reg-off { display: none !important; }
.reg-main { flex-direction: column !important; gap: var(--space-6) !important; }
.reg-list { list-style: none !important; padding: 0 !important; margin: 0 !important; }
.reg-panel { justify-content: center !important; gap: var(--space-4) !important; }
.reg-next { display: block !important; margin-top: var(--space-2) !important; }
.reg-row { gap: var(--space-3) !important; align-items: flex-start !important; }
.reg-submit { width: 100% !important; margin-top: var(--space-1) !important; }
.reg-self-start { align-self: flex-start !important; }

/* ── settings.html, moved out of style= attributes (map #338, 2026-09-26) ──
   Each rule was an inline style, and an inline style beats every normal rule, so
   each keeps that priority with !important: the page must look exactly as before.
   One exception: .set-errtext's display is NOT !important, because the page script
   shows and hides #inviteErrText / #inviteListErrText with style.display, and an
   !important class would beat that write. */
.set-mt3 { margin-top: var(--space-3) !important; }
.set-mb3 { margin-bottom: var(--space-3) !important; }
.set-start { align-self: flex-start !important; }
.set-mt4 { margin-top: var(--space-4) !important; }
.set-mt2 { margin-top: var(--space-2) !important; }
.set-code { letter-spacing: .16em !important; }
.set-g3-mt3 { gap: var(--space-3) !important; margin-top: var(--space-3) !important; }
.set-g3 { gap: var(--space-3) !important; }
.set-wrap-mt2 { flex-wrap: wrap !important; margin-top: var(--space-2) !important; }
.set-errtext { display: block; margin-top: var(--space-1) !important; }
.set-p0 { padding: 0 !important; }
.set-dim { opacity: .6 !important; }
.set-mt4-wrap { margin-top: var(--space-4) !important; flex-wrap: wrap !important; }
.set-mt4-sec { margin-top: var(--space-4) !important; color: var(--color-secondary-300) !important; }
.set-mt1-mb2 { margin-top: var(--space-1) !important; margin-bottom: var(--space-2) !important; }
.set-ml-auto { margin-left: auto !important; }
.set-mb2 { margin-bottom: var(--space-2) !important; }
.set-m0 { margin: 0 !important; }
.set-between { justify-content: space-between !important; align-items: baseline !important; }
.set-end { justify-content: flex-end !important; }
.set-g5-mt1 { gap: var(--space-5) !important; margin-top: var(--space-1) !important; }
.set-g4-mb4 { gap: var(--space-4) !important; margin-bottom: var(--space-4) !important; }
.set-g4 { gap: var(--space-4) !important; }
.set-g1 { gap: var(--space-1) !important; }
.set-wrap-mb1 { flex-wrap: wrap !important; margin-bottom: var(--space-1) !important; }
.set-wrap { flex-wrap: wrap !important; }
.set-cols-noti { --cols: minmax(0,1fr) 72px !important; }
.set-cols-invites { --cols: clamp(120px, 11vw, 168px) clamp(84px, 8vw, 110px) clamp(120px, 11vw, 168px) clamp(120px, 11vw, 168px) clamp(96px, 9vw, 128px) !important; }
.set-fieldset { border: 0 !important; padding: 0 !important; margin-top: var(--space-4) !important; }
.set-start-mt3 { align-self: flex-start !important; margin-top: var(--space-3) !important; }
.set-invlist { display: flex !important; flex-direction: column !important; min-height: 0 !important; margin-top: var(--space-5) !important; padding-top: var(--space-5) !important; border-top: 1px solid var(--color-neutral-200) !important; }
/* #343: the page script hides this card (hidden) when api.js is missing. The rule above
   keeps display:flex !important, which beat the global [hidden] rule, so the empty card
   stayed visible. Higher specificity (0,2,0) and !important: hidden wins. */
.set-invlist[hidden] { display: none !important; }

/* ── the i18n curtain (assets/i18n.js), moved out of an injected <style> (#346, 2026-09-26) ──
   Hides <body> only while a language swap is pending; kc-auth.js keeps its own
   inline curtain on <html>, so the two never share a property. */
html.eqp-i18n-wait body { visibility: hidden; }

/* ── the trial banner (trial-banner.js), copied out of its injected <style> (#346, 2026-09-26) ──
   Each surface serves its OWN copy of trial-banner.js (measured on the box 2026-09-26: two
   real files, not symlinks). The two copies are kept byte-identical by
   tools/verify-trial-banner-copy.mjs; full-app-prototype's js/trial-banner.js is canonical,
   and its rules live in its css/trial-banner.css. The script no longer injects these rules
   (#346 part 2.3), so this block is where the dashboard's banner gets its style. */
[data-eqp-trial]{position:fixed;top:0;left:0;right:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;gap:12px;box-sizing:border-box;padding:10px 16px;background:var(--color-yellow-100,#FFF3C4);border-bottom:1px solid var(--color-yellow-200,#F2D98A);color:var(--color-neutral-1000,#1A1A1A);font-family:var(--font-family,system-ui,-apple-system,"Noto Sans TC",sans-serif);box-shadow:0 1px 4px rgba(0,0,0,.10);}
[data-eqp-trial] .eqp-trial-msg{font-size:15px;line-height:1.45;font-weight:600;margin:0;text-align:center;}
[data-eqp-trial] .eqp-trial-x{flex:0 0 auto;cursor:pointer;font-family:inherit;font-size:14px;line-height:1;font-weight:700;padding:7px 14px;color:var(--color-neutral-1000,#1A1A1A);background:rgba(255,255,255,.72);border:1px solid var(--color-yellow-200,#F2D98A);border-radius:999px;}
[data-eqp-trial] .eqp-trial-dbg{flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;cursor:pointer;user-select:none;padding:5px 10px;border-radius:999px;background:rgba(255,255,255,.45);border:1px solid var(--color-yellow-200,#F2D98A);}
[data-eqp-trial] .eqp-trial-dbg input{width:16px;height:16px;margin:0;}
[data-eqp-trial] .eqp-trial-note{flex:0 1 auto;font-size:12px;font-weight:600;line-height:1.35;color:#5A4A00;margin:0;}
[data-eqp-trial] .eqp-trial-x:hover{background:#fff;}
[data-eqp-trial] .eqp-trial-x:focus-visible{outline:2px solid var(--color-primary-300,#B24A00);outline-offset:2px;}
@media (max-width:560px){
[data-eqp-trial]{flex-direction:column;gap:8px;padding:10px 12px;}
[data-eqp-trial] .eqp-trial-msg{font-size:14px;}
}

/* -- index.html and login.html, moved out of style= attributes (#359, 2026-09-29) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps
   that priority with !important: the pages must look exactly as before. */
.idx-wrap { flex-wrap: wrap !important; }
.idx-cta { display: inline-flex !important; align-items: center !important; padding-inline: var(--space-5) !important; }
.idx-title { color: var(--color-primary-300) !important; }
.lgn-nojs { font-family: sans-serif !important; margin: 24px !important; }

/* -- student-view.html and library.html, moved out of style= attributes (#359 group B, 2026-09-29) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that
   priority with !important. TWO ARE NOT: .sv-min's display and .sv-bar0's width. The page script sets
   #stuMin's style.display = 'flex' and #stuMinBar's style.width, and an !important class would beat
   that write. Later than .prog{display:flex}, same specificity, so .sv-min still hides at rest. */
.sv-mt4 { margin-top: var(--space-4) !important; }
.sv-w96 { width: 96px !important; }
.sv-bar0 { width: 0%; }
.sv-ovauto { overflow: auto !important; }
.sv-mt3-ov { margin-top: var(--space-3) !important; overflow: auto !important; }
.sv-mt3 { margin-top: var(--space-3) !important; }
.sv-mt2 { margin-top: var(--space-2) !important; }
.sv-mt0 { margin-top: var(--space-0) !important; }
.sv-span { grid-column: 1/-1 !important; }
.sv-g4 { gap: var(--space-4) !important; }
.sv-g3-wrap { gap: var(--space-3) !important; flex-wrap: wrap !important; }
.sv-min { display: none; max-width: 320px !important; margin-bottom: var(--space-3) !important; }
.lib-mt4 { margin-top: var(--space-4) !important; }
.lib-dim { opacity: .6 !important; }
.lib-mt1 { margin-top: var(--space-1) !important; }
.lib-ml-auto { margin-left: auto !important; }
.lib-mb2 { margin-bottom: var(--space-2) !important; }
.lib-span { grid-column: 1/-1 !important; }

/* -- dashboard.html, moved out of style= attributes (#359 group C, 2026-09-29) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that
   priority with !important. #noticeList's display:none is NOT a class: see dashboard.html. */
.dsh-mt4 { margin-top: var(--space-4) !important; }
.dsh-mt2 { margin-top: var(--space-2) !important; }
.dsh-dim { opacity: .6 !important; }
.dsh-ml-auto { margin-left: auto !important; }
.dsh-cols { --cols: 56px minmax(120px,1fr) minmax(120px,1fr) !important; }

/* These elements have an id or a data attribute, so their rule is a selector on it, not a class: assets/actions.js
   overwrites className on the req-local notice (n.className = 'notice notice--ok type-b3'), which would drop a class
   the old inline style never lost. An id / attribute selector survives any className write. */
#kpiProv { margin-bottom: var(--space-2) !important; }
#heatProv { margin-top: var(--space-1) !important; }
#heatPeriod { margin-top: var(--space-2) !important; }
#filterStatus { margin-bottom: var(--space-2) !important; }
#asgConfirm { margin-top: var(--space-4) !important; }
#asgMulti { margin-top: var(--space-2) !important; }
[data-notice="req-local"] { margin-bottom: var(--space-3) !important; }

/* -- assignments.html, moved out of style= attributes (#359, 2026-09-30) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that priority with
   !important, except a property the page script writes on the same element (see the commit). An element with an id
   gets an id selector, not a class: a script's className write drops a class, and an inline style never was.
   An element that starts display:none and is shown by a script that clears style.display is the hidden attribute. */
.asg-mt4 { margin-top: var(--space-4) !important; }
.asg-dim { opacity: .6 !important; }
.asg-mt3 { margin-top: var(--space-3) !important; }
.asg-mt2 { margin-top: var(--space-2) !important; }
.asg-mt0 { margin-top: var(--space-0) !important; }
.asg-ml-auto { margin-left: auto !important; }
.asg-g4-mt3 { gap: var(--space-4) !important; margin-top: var(--space-3) !important; }
.asg-g2-mt3-wrap { gap: var(--space-2) !important; margin-top: var(--space-3) !important; flex-wrap: wrap !important; }
#loopAssign { margin-top: var(--space-3) !important; }
#loopOut { margin-top: var(--space-4) !important; }
#loopPayload { overflow: auto !important; }
#loopUrl { flex: 1 !important; min-width: 220px !important; }
#loopReturn { margin-top: var(--space-4) !important; }
#loopReturnJson { overflow: auto !important; margin-top: var(--space-3) !important; }
#kpiProv { margin-bottom: var(--space-2) !important; }
#asgProv { margin-top: var(--space-1) !important; }
#asgFilterStatus { margin-bottom: var(--space-2) !important; }
#asgTbl { --cols: minmax(160px, 2.2fr) minmax(128px, 1.1fr) minmax(88px, .7fr) minmax(150px, 1.4fr) minmax(88px, .8fr) minmax(104px, .9fr) !important; }
#asgConfirm { margin-top: var(--space-4) !important; }
#asgMulti { margin-top: var(--space-2) !important; }
#asgAmendStatus { margin-top: var(--space-4) !important; }
#asgWithdrawStatus { margin-top: var(--space-4) !important; }

/* -- students.html, moved out of style= attributes (#359, 2026-09-30) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that priority with
   !important, except a property the page script writes on the same element (see the commit). An element with an id
   gets an id selector, not a class: a script's className write drops a class, and an inline style never was.
   An element that starts display:none and is shown by a script that clears style.display is the hidden attribute. */
.stu-mt4 { margin-top: var(--space-4) !important; }
.stu-end { justify-content: flex-end !important; }
.stu-w70 { width: 70% !important; }
.stu-w66 { width: 66% !important; }
.stu-w100 { width: 100% !important; }
.stu-dim { opacity: .6 !important; }
.stu-w64 { width: 64% !important; }
.stu-w58 { width: 58% !important; }
.stu-w52 { width: 52% !important; }
.stu-mt3 { margin-top: var(--space-3) !important; }
.stu-ml-auto { margin-left: auto !important; }
.stu-wrap-mt2 { flex-wrap: wrap !important; margin-top: var(--space-2) !important; }
#kpiProv { margin-bottom: var(--space-2) !important; }
#unlinkAsk { margin-bottom: var(--space-3) !important; }
#unlinkErr { margin-bottom: var(--space-3) !important; }
#unlinkErrText { display: block; margin-top: var(--space-1) !important; }
#unlinkStatus { margin-bottom: var(--space-2) !important; }
#reqProv { margin-bottom: var(--space-2) !important; }
#rosterProv { margin-bottom: var(--space-2) !important; }
#rosterGap { margin-bottom: var(--space-3) !important; }
#filterStatus { margin-bottom: var(--space-2) !important; }
#reqStatus { margin-bottom: var(--space-2) !important; }
#rosterTbl { --cols: clamp(126px, 12vw, 196px) clamp(88px, 8vw, 124px) minmax(150px, 1fr) clamp(88px, 8vw, 118px) clamp(84px, 8vw, 108px) clamp(238px, 14vw, 260px) !important; }
#reqPreview { margin-top: var(--space-4) !important; }
/* A notice without an id: assets/actions.js overwrites a notice's className (n.className = 'notice notice--ok type-b3'),
   which drops a class the old inline style never lost. A data attribute survives it. */
[data-stu="mb3"] { margin-bottom: var(--space-3) !important; }
[data-stu="mt4"] { margin-top: var(--space-4) !important; }

/* -- the 「伴奏」 switch, assets/backing.js (#355, 2026-09-29): the switch, its status and its error on one wrapping row -- */
.bk { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }

/* -- recordings.html, moved out of style= attributes (#359, 2026-09-30) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that priority with
   !important, except a property the page script writes on the same element (see the commit). An element with an id
   gets an id selector, not a class: a script's className write drops a class, and an inline style never was.
   An element that starts display:none and is shown by a script that clears style.display is the hidden attribute. */
.rec-ml-auto { margin-left: auto !important; }
.rec-dim { opacity: .6 !important; }
.rec-mt2 { margin-top: var(--space-2) !important; }
#recordingsTbl { --cols: minmax(80px, 1fr) minmax(96px, 1.8fr) minmax(68px, .8fr) minmax(56px, .7fr) minmax(112px, 1.3fr) minmax(140px, 1.7fr) minmax(176px, 1.6fr) !important; }
#recDeleteReasons { margin-top: var(--space-3) !important; border: 0 !important; padding: 0 !important; }
#recDeleteNeedReason { margin-top: var(--space-2) !important; }
#recDeleteStatus { margin-top: var(--space-4) !important; }

/* -- upload.html, moved out of style= attributes (#359, 2026-09-30) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that priority with
   !important, except a property the page script writes on the same element (see the commit). An element with an id
   gets an id selector, not a class: a script's className write drops a class, and an inline style never was.
   An element that starts display:none and is shown by a script that clears style.display is the hidden attribute. */
.upl-ml-auto { margin-left: auto !important; }
.upl-dim { opacity: .6 !important; }
.upl-g5 { gap: var(--space-5) !important; }
.upl-g2-mt3 { gap: var(--space-2) !important; margin-top: var(--space-3) !important; }
.upl-mt2 { margin-top: var(--space-2) !important; }
.upl-mt2-mw220 { margin-top: var(--space-2) !important; max-width: 220px !important; }
#convTargets { margin-top: var(--space-3) !important; }
#convMulti { margin-top: var(--space-1) !important; }
#m33Minor { margin-top: var(--space-4) !important; }
#cancelErr { margin-top: var(--space-2) !important; }
[data-upl="mt4"] { margin-top: var(--space-4) !important; }

/* -- review.html, moved out of style= attributes (#359, 2026-09-30) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that priority with
   !important, except a property the page script writes on the same element (see the commit). An element with an id
   gets an id selector, not a class: a script's className write drops a class, and an inline style never was.
   An element that starts display:none and is shown by a script that clears style.display is the hidden attribute. */
.rev-ml-auto { margin-left: auto !important; }
.rev-mt0 { margin-top: var(--space-0) !important; }
.rev-g4 { gap: var(--space-4) !important; }
.rev-g2 { gap: var(--space-2) !important; }
.rev-f1 { flex: 1 !important; }
.rev-w92 { width: 92% !important; }
.rev-w88-mt4 { width: 88% !important; margin-top: var(--space-4) !important; }
.rev-w90-mt4 { width: 90% !important; margin-top: var(--space-4) !important; }
.rev-g2-m3 { gap: var(--space-2) !important; margin: var(--space-3) 0 !important; }
.rev-mb3 { margin-bottom: var(--space-3) !important; }
.rev-mt3 { margin-top: var(--space-3) !important; }
#sedUnpublishedNotice { gap: var(--space-3) !important; }
#sedRenderErr { gap: var(--space-3) !important; }
#sedWrong { display: block !important; }
#sedDur { display: block !important; }
#sedEdits { display: block !important; }
#sedAllDone { gap: var(--space-3) !important; margin-top: var(--space-3) !important; }
#sedRhythmFacts { margin-top: var(--space-0) !important; }
#sedFingClear { margin-top: var(--space-2) !important; }
#sedDelete { margin-top: var(--space-3) !important; }
#sedTranspose { flex: 1 !important; }
[data-rev="g3-mb3"] { gap: var(--space-3) !important; margin-bottom: var(--space-3) !important; }

/* -- student-detail.html, moved out of style= attributes (#359, 2026-09-30) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that priority with
   !important, except a property the page script writes on the same element (see the commit). An element with an id
   gets an id selector, not a class: a script's className write drops a class, and an inline style never was.
   An element that starts display:none and is shown by a script that clears style.display is the hidden attribute. */
.sdt-ml-auto { margin-left: auto !important; }
.sdt-dim { opacity: .6 !important; }
.sdt-mtneg2 { margin-top: calc(-1 * var(--space-2)) !important; }
.sdt-mt2 { margin-top: var(--space-2) !important; }
.sdt-jw0 { width: 0%; }
.sdt-mt4 { margin-top: var(--space-4) !important; }
.sdt-mt3 { margin-top: var(--space-3) !important; }
.sdt-cols2 { --cols: 56px minmax(120px,1fr) minmax(120px,1fr) !important; }
#sdKpiProv { margin-bottom: var(--space-2) !important; }
#sdHeatProv { margin-top: var(--space-1) !important; }
#sdPeriod { margin-top: var(--space-2) !important; }
#sdHeat { --cols: clamp(104px,9vw,148px) minmax(0,1fr) clamp(140px,13vw,196px) clamp(88px,8vw,112px) !important; }
#sdJrnProg { width: calc(var(--space-10) + var(--space-8)) !important; }
#reqPreview { margin-top: var(--space-4) !important; }
#asgConfirm { margin-top: var(--space-4) !important; }
[data-sdt="mb3"] { margin-bottom: var(--space-3) !important; }
[data-sdt="mt4"] { margin-top: var(--space-4) !important; }

/* -- modals.html, moved out of style= attributes (#359, 2026-09-30) --
   Each rule was an inline style, and an inline style beats every normal rule, so each keeps that priority with
   !important, except a property the page script writes on the same element (see the commit). An element with an id
   gets an id selector, not a class: a script's className write drops a class, and an inline style never was.
   An element that starts display:none and is shown by a script that clears style.display is the hidden attribute. */
.mdl-ml-auto { margin-left: auto !important; }
.mdl-dim { opacity: .6 !important; }
.mdl-mt2 { margin-top: var(--space-2) !important; }
.mdl-mt4 { margin-top: var(--space-4) !important; }
.mdl-w80 { width: 80% !important; }
.mdl-w41 { width: 41% !important; }
.mdl-w72 { width: 72% !important; }
.mdl-w100 { width: 100% !important; }
.mdl-w54 { width: 54% !important; }
.mdl-cols1 { --cols: minmax(0, 1fr) clamp(72px, 8vw, 104px) !important; }
.mdl-cols2 { --cols: clamp(150px, 14vw, 210px) minmax(0, 1fr) clamp(140px, 13vw, 200px) clamp(110px, 10vw, 150px) !important; }
.mdl-cols3 { --cols: 56px minmax(120px,1fr) minmax(120px,1fr) !important; }
[data-mdl="mt4"] { margin-top: var(--space-4) !important; }

/* -- margin-top for markup built by scripts (#338, 2026-10-08) --
   assets/panels.js and assets/source-page.js built these with style="margin-top:var(--space-N)" inside HTML strings,
   which a CSP without 'unsafe-inline' for style attributes refuses. A class keeps the inline style's priority with !important. */
.rt-mt2 { margin-top: var(--space-2) !important; }
.rt-mt3 { margin-top: var(--space-3) !important; }
.rt-mt4 { margin-top: var(--space-4) !important; }

/* #399 keyboard note entry (assets/score-input.js): the status bar, the length keys and the entry cursor. Classes only. */
.sed-input-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-4);
                 margin: 0 0 var(--space-3); padding: var(--space-2) var(--space-3);
                 border: var(--stroke-1) solid var(--color-primary-300); border-radius: var(--radius-6);
                 background: var(--color-primary-alpha10); }
.sed-input-bar[hidden] { display: none; }
.sed-lenkeys { display: inline-flex; flex-wrap: wrap; gap: var(--space-2); }
.sed-lenkey { display: inline-flex; align-items: center; gap: var(--space-1); padding: 2px var(--space-2);
              border: var(--stroke-1) solid transparent; border-radius: var(--radius-full); }
.sed-lenkey.on { border-color: var(--color-primary-500); }
.sed-lenkey.is-na { opacity: .6; text-decoration: line-through; }
.sed-lenkey kbd { font: inherit; padding: 0 var(--space-1); border: var(--stroke-1) solid var(--color-neutral-300);
                  border-radius: var(--radius-2); }
.sed-input-keys { color: var(--color-secondary-400); }
.sed-hit .hit.is-input-cursor { outline: 2px dashed var(--color-primary-500); outline-offset: 2px; }
.sed-input-target { font-weight: 600; }
.sed-hit .hit.is-pitch-target { outline: 3px solid var(--color-primary-500); outline-offset: 2px; background: var(--color-primary-alpha10); }
.sed-hit .hit .sed-pt-mark { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); margin-top: var(--space-1);
                             color: var(--color-primary-500); white-space: nowrap; pointer-events: none; }
.sed-keyhelp { display: grid; grid-template-columns: max-content 1fr; gap: var(--space-1) var(--space-3); margin: 0 0 var(--space-3); }
.sed-keyhelp dt, .sed-keyhelp dd { margin: 0; }
.sed-keyhelp kbd { font: inherit; padding: 0 var(--space-1); border: var(--stroke-1) solid var(--color-neutral-300); border-radius: var(--radius-2); }
