# StudySignal — Design-system gaps, inconsistencies, and the plan

**Companion to [`STYLEGUIDE.md`](./STYLEGUIDE.md).** Written 2026-08-17 from a
full read of `web/app.css` (15,598 lines), `web/signal-theme.css`,
`web/index.html`, `web/app.js`, `web/beta/`.

The style guide says what exists. This says what doesn't, what contradicts
itself, and in what order to fix it.

**The one-line summary:** the *token layer* is unusually good — thoughtful,
documented, with intent-named spacing and four named shell widths. The
*component layer* is where it breaks down: the tokens are bypassed by literals
at roughly 150 sites, and the same idea has been re-implemented under a new
name five, six, seven times. Nothing here is a crisis. Most of it is one
mechanical pass plus a lint rule away from being permanently solved.

---

## 1. Tokens we don't have

| Missing | Consequence today |
|---|---|
| **Type scale** | Font sizes are raw everywhere: `9px`, `9.5px`, `10.5px`, `11px`, `12.5px`, `13px`, `13.5px`, `14px`, `15px`, `15.5px`, `1.25rem`, plus eight competing `clamp()` overrides fighting `.stl-h1` alone. There is no answer to "what size is a label?" |
| **Button radius** | The button radius is `9px`, between `--r-sm` (8) and `--r-md` (10). Written as a literal at `4964`, `5062`, `5065`. Needs `--r-btn`. |
| **Elevation / shadow scale** | Every shadow is bespoke. `rgba(0,0,0,.35/.45/.6)` and `0 24px 60px -12px` appear ad hoc. |
| **z-index scale** | Hand-assigned and collision-prone: header 50 · preview-banner 60 · msheet 120 · admin-modal 200 · confirm 300 · usermenu 1500 · floating-tip 9999 · boot-splash 9999 · countdown 9999. |
| **Amber token** | The `gap` outcome uses `#f59e0b` at three sites with no token — a seventh signal-adjacent colour. |
| **Blue / "insane" token** | The insane tier's blue is hardcoded as `#60a5fa` / `#2563eb` / `#93c5fd` across `app.css` and `signal-theme.css` independently. |
| **Surface-0** | `rgba(255,255,255,0.02)` appears at four sites — one step below `--stl-surface` (3.5%), no token. |
| **Icon size scale** | No convention. Icons are inline SVG at 13px, 16px, 20px, 30px as written. |
| **Border-width scale** | `1px`, `1.5px`, `2px`, `3px` as written. |

---

## 2. Components we don't have

Ranked by how often the absence costs something.

### 2.1 A field system that both halves of the app share — **the biggest one**

`.stl-input` (student/teacher) and `.stl-admin__input` (admin) are two
independent systems, not a skin. `.stl-input` is ~48px at `1.25rem/500` with a
4px focus ring; `.stl-admin__input` is `height: 40px` at `13.5px` with a 3px
ring. A ~20px height delta and a 6.5px type delta for the same semantic control.
The split is **total**: 72 admin-owned controls, zero shared, and zero
`.stl-input` anywhere in the admin screen.

Admin also runs **two label systems simultaneously** — 46 `.stl-label` (mono
11px) and 22 `.stl-admin__field-lbl` (mono 10px), 1px apart — and **four select
treatments** (`.stl-admin__select`, `.stl-admin__pager-size select`,
`.stl-msdd`, native).

### 2.2 Form validation and disabled states — essentially unmodelled

Across admin / teacher / marketing: `[aria-invalid]` **0/0/0** · `::placeholder`
**0/0/0** · `:invalid` **0/0/0** · `:checked` **0/0/0** · `:disabled` **1/2/4**.

`.stl-input` has no `:disabled`. `.stl-admin__input` has no `:hover` and no
`:disabled`. `.stl-hint.is-error` exists but is not wired to any input state.
`.stl-radio` and `.stl-checkbox` rely on native `accent-color` with no hover, no
focus-visible, no disabled.

### 2.3 A generic empty state

Ten bespoke implementations. Four border treatments, two text alignments, five
paddings, no shared class, no icon slot, no CTA convention.

### 2.4 A generic table

**Seven** table styling systems, plus a CSS-Grid pseudo-table
(`.stl-learn__cheat-table`) as an eighth visual language for tabular data.
Three collapse models (`collapse` / `separate 0` / `separate 3px`), three header
treatments, two border colours for the same print concept. **Zero use zebra
striping.** Only the admin table has a sticky header or a sort affordance.

### 2.5 A generic badge

Roughly eight badge/chip families, and **five separate palettes for the same
live / needs-review / unpublished / archived concept**: `.stl-admin__badge` ·
`.stl-import-card__chip` · `.stl-admin__kpi[data-kind]` · `.stl-mytests__status`
· `.stl-metrics__badge`. Plus four near-identical mono count chips
(note: the `.stl-metrics__*` family grew `__card--wide`, `__stats--four` and a
ranked `__row`/`__row-bar` list on 2026-08-26 for the Umami site-traffic
card — same anatomy and tokens, still undocumented in the guide alongside the
rest of the family)
(`.stl-admin__tab-count`, `.stl-teacher__tab-count`, `.stl-tabbar__count`,
`.stl-review-tab__count`).

### 2.6 Toast tones

`.stl-toast` has 142 call sites and one variant. `'Note save failed (HTTP 500)'`
and `'Link copied'` render as the same neutral dark pill. No success/error/warning
distinction, no stacking, no icon, no dismiss, no undo, no reduced-motion guard.

### 2.7 Overlay infrastructure

`.stl-admin-modal` is a real base and five overlays reuse it — but it provides
no **focus trap** (exactly one exists in the whole app, in `stlConfirm`), no
**focus return** (two call sites), no **scroll lock** (two call sites, with no
restore bookkeeping so nested open/close clobbers it), and no ARIA. Eight
independent Escape handlers, no overlay stack. `.stl-msheet` and `.stl-refsheet`
have no Escape at all (the latter gets it free from native `<dialog>`).

It is also **misnamed** — it's the generic base, not an admin component.

### 2.8 Accessible progress

Five progress-bar implementations, **none** using `<progress>` or
`role="progressbar"` (zero matches in the codebase).

### 2.9 Skeletons

None. Zero matches for `skeleton|shimmer`. The pattern is a veil over a
height-reserved container — which is fine, but it's implemented twice
identically (`.stl-admin__pane-veil` and `.stl-tabbar__veil`).

### 2.10 The smaller absences

**Breadcrumbs** — none, and *five* different back affordances instead
(`.stl-back-link`, `.stl-backcrumb`, `.stl-btn--back`/`--back-sm`,
`.stl-mobile-bar__btn[data-mobile-back]`, `.stl-add-modal__back`).
**Avatar** — one, mobile-only, 34px, first-letter-of-email, no image support, no
sizes; the desktop account menu shows a generic glyph instead.
**Tooltip** — one, admin-scoped, hover-only, no `aria-describedby`, no keyboard
trigger. Elsewhere: the native `title=` attribute.
**Popover** — no generic; three hand-rolled (`.stl-msdd__panel`,
`.stl-header__usermenu-panel`, `.stl-topicdd`). No Popover API, no anchor
positioning.
**Accordion** — no base. `.stl-faq` uses `<details>`; `.stl-smart__accrow` is a
bespoke mobile one.
**Alert / banner** — only `.stl-access__notice`, admin-only.
**Segmented control** — three, all different (`.stl-seg` is the most base-like).
**Also absent:** date picker, stepper, slider, file-upload base (a dropzone
exists, admin-only), `kbd` (exists inside the review modal only), code block,
divider.

**Now exists (2026-08-20, real screenshots 2026-08-25): `.stl-pm` — the
iPhone frame.** Marketing-only, on the how page's "signal in your
pocket" section; the screen inside is a real capture of the shipped app
(`img/app/*.jpg`). Sized entirely in `em` off `--stl-pm-w`. If you need
a phone mock, use this frame — a second phone frame is exactly the
re-implementation failure mode this file exists to prevent. (The full
HTML re-creation of the app's screens is archived in
`redesigns/homepage-app/phone-mock.css` if a live-HTML phone is ever
needed again.) Its store badge is `.stl-appstore` — Apple's official
SVG, self-hosted at `img/app-store-badge.svg`; only the href is fake
until the app clears review.

---

## 3. Inconsistencies

### 3.1 Palette drift — the product ships four greens, four reds, three yellows

| Intent | Token | Also shipping |
|---|---|---|
| green | `--stl-green` `#4ade80` | `#22c55e` (answer choice `.is-correct`, `.stl-admin__pill-toggle`), `#34d399` (`.stl-review-tab__dot`, `.stl-radar__focus`), `rgba(34,197,94,…)` |
| red | `--stl-red` `#fb7185` | `#f87171` (review tabs, radar focus, `.stl-btn--danger`), `#dc2626` (matrix cells) |
| yellow | `--stl-yellow` `#facc15` | `#fbbf24` (review tab dot), `rgba(234,179,8,…)` |
| "gap" amber | *none* | `#f59e0b`, `245,158,11` |
| loader ring | *none* | `#fb923c` — an orange found nowhere else |

The sharpest example: `.stl-review-tab__dot--*` (`8791–8795`) and
`.stl-results__meter-seg--*` (`4584–4586`) render **the same six outcomes on the
same screen in different greens, reds and yellows** — one hardcoded, one
tokenised.

### 3.2 The `-rgb` tokens are dead, and that's why light mode breaks

`--stl-green-rgb` / `-yellow-rgb` / `-red-rgb` were added *specifically* so
`rgba()` could stop hardcoding, and they have light-theme overrides. They are
used at **six lines** in 15,598. Roughly **150** literal
`rgba(74,222,128,…)` / `rgba(250,204,21,…)` / `rgba(251,113,133,…)` sites bake
the dark-theme hue in permanently.

**Light-theme coverage, product-core:** 1 of 21 families has explicit light
rules · 8 flip cleanly via tokens · **12 have partial or total breakage.**
**Admin** gets 8 reactive spot-fixes. **Teacher** gets one rule — and it's for
dead CSS, so the live teacher surface has **zero**.

Concretely broken on a white canvas: the `.stl-admin__select` chevron (stroke
`%23a3a8b8` baked into a data-URI) · `.stl-admin__pill-toggle:hover`
(`rgba(255,255,255,.3)`) · `.stl-msdd__panel` (hardcoded `#14151f` — a black
dropdown on a white page) · `.stl-msheet__scrim` · the whole `.stl-matrix` cell
palette (tuned for dark, values borrowed from the *light* token set) ·
`.stl-wk__freq--high/med/low` (six off-system hexes found nowhere else) ·
`.stl-input:hover` (border vanishes).

### 3.3 The same component, re-implemented

| Concept | Count | Detail |
|---|---|---|
| **Tab strip** | 6 | `.stl-tabbar` (generic), `.stl-admin__tab`, `.stl-teacher__tab` (dead) are byte-comparable ~55-line copies. `.stl-review-tab`, `.stl-radar__sw-tab`, `.stl-add-image__tab` are pill/segmented. **Three of six signal selection with `.is-active` instead of `aria-selected` — half the tab strips are invisible to assistive tech.** None has roving tabindex. |
| **Table** | 7 (+1 pseudo) | see §2.4 |
| **Badge / status chip** | ~8 (5 for one concept) | see §2.5 |
| **Back affordance** | 5 | see §2.10 |
| **Segmented control** | 3–4 | see §2.10 |
| **Loading veil** | 2 | identical recipe, admin adds a border |
| **Progress bar** | 5 | see §2.8 |
| **Filled/hollow segment grammar** | 3 | `.stl-twin__ticks i[data-t]`, `.stl-ledger__seg i[data-s]`, `.stl-sviz-res__seg i[data-s]` — same semantics, two attribute names, three geometries |
| **`sr-only` utility** | 2 | `.sr-only`/`.dc-sr-only` and `.stl-sr-only` (added later, with a comment saying the app had none). **Three usages total; zero in `app.js`'s 25k lines of rendered markup.** |
| **Print vocabulary** | 2 | `@media print` in `pt` and `.stl-wkpaper` in `px`, maintained separately — and they already disagree (table border `#999` on screen vs `#666` in print) |

### 3.4 Declared and then bypassed

- **`--stl-font-mono`** is declared at `app.css:21` and the literal
  `"Geist Mono", ui-monospace, "SF Mono", monospace` is spelled out **30+
  times** instead. `--stl-font-body` is spelled out at `533`, on `.stl-app`
  itself.
- **The gradient-border interior** is written as `#15161d` at 8 sites and
  correctly as `var(--stl-bg-2)` once. Each hardcoded site needs its own light
  override; three exist.
- **`--r-pill`** exists; `999px` and `9999px` are written as literals.

### 3.5 Defined twice, or referenced and never defined

*(2026-08-18 second pass, post-v2: three additions, one fixed. `stlSigGlow`
was **consumed but no longer defined** — v2 deleted the `.stl-mock` block that
housed the keyframes while `.stl-sviz-sig.is-pick` kept animating it; restored
next to its only consumer. Two dead `.stl-mock__*` reveal selectors and their
keyframes deleted. Still open below: `.stl-pill` is declared twice (`723` and
`2651`), and **every `.stl-demo` value is now declared twice** — base rules
mid-file, hero overrides ~250 lines later, no forward pointer on the base
block.)*

- `.stl-btn__count` — **defined twice** (`4897` and `7935`) with incompatible
  treatments. The later chip wins everywhere, including the site the first was
  written for.
- `.stl-signal__btn { transition }` — declared twice (`4149`, `4168`); the
  second silently overwrites the first.
- `.stl-signal__btn @media (hover:none)` — declared twice (`4226`, `10032`).
- `.stl-admin__textarea` — declared twice (`6882`, `6893`).
- `.stl-btn--icon` — **referenced** at `9018`, never defined, unused in markup.
- `.stl-loader--solid` — **documented** at `8944`, never defined.
- `.stl-view-toggle__icon--grid/--rows` — the icon-swap pattern has no CSS, so
  the view toggle's icon never changes.

### 3.6 Rules that cancel each other

`.stl-btn--sm` sets `13px` at line `2974`. The mobile query at `10184` sets
`.stl-btn { min-height: 44px; padding: 11px 16px; font-size: 14px }` 7,200 lines
later with no opt-out — so **`--sm` is silently a full-size button on mobile.**
`--dev` explicitly opts out at `10196`; `--sm` never did.

### 3.7 Visual state and ARIA state are separate code paths

`.stl-tier` renders `role="checkbox" aria-checked` (`app.js:5175`) but the CSS
styles `.is-active`. `.stl-history__chip` renders `aria-pressed` but styles
`.is-active`. Only `.stl-toggle`, `.stl-subject-pill`, `.stl-admin__kpi` and
`.stl-admin__pill-toggle` style directly off `[aria-pressed]`. Two independent
sources of truth for one state, and nothing fails if they drift.

`.stl-matrix__cell.is-selected` signals with a 2px inset ring and
`font-weight: 700` and **no colour change** — and the cells are `<td>`s with
click handlers, so there is no keyboard path to them at all.

### 3.8 Focus is the biggest a11y gap

48 `:focus-visible` rules exist. Missing on, at minimum: `.stl-link` (applied to
real `<button>`s in six places while explicitly stripping native chrome) ·
`.stl-choice` (has only a 12%-alpha `:focus-within`, effectively invisible) ·
`.stl-topicdd` toggle and options (a listbox with no keyboard affordance) ·
`.stl-signal__sig` (the review trainer's *primary control*) · `.stl-review-tab`
· `.stl-radar__sw-tab` · `.stl-add-image__tab` · `.stl-mobile-bar__btn` ·
`.stl-msheet__row` · `.stl-msdd__trigger` · `.stl-admin-modal__close` ·
`.stl-viewseg` · `.stl-refsheet__close` · `.stl-desmos-panel__close` ·
`.stl-results__qlink` · `.stl-results__domain-head` · `.stl-faq summary` ·
`.stl-featurecard` · `.stl-modechoice` · `.stl-mland__segbtn`.

And **`.stl-header__nav-link` actively sets `outline: none`** (`900–904`).

### 3.9 Reduced motion — eleven uncovered animations

23 blocks exist. Not covered: **`.stl-dot-fx`** — the 1.05s leap / teeter /
six-step blink that fires on the single most-repeated interaction in the
product · `.stl-toast` slide-in · `.stl-cd` countdown (skipped in JS only) ·
`.stl-msdd__panel` spring open · usermenu chevron rotate · header drawer slide ·
mobile-bar spring transitions · `.stl-launch` hover · the base `.stl-float`
drift · `.stl-startdots--idle` breathe · `.stl-tier` / `.stl-picker__tile` /
`.stl-choice` hover transforms.

### 3.10 `/beta/` is a second, drifted copy of the design system

`web/beta/index.html` carries ~450 lines of inline `<style>` with the whole
token set **redeclared under unprefixed names** (`--bg`, `--surface`, `--text`,
`--accent`…). Two hard drifts:

1. **`--accent: #8b86ff`** — the *retired* lavender. `signal-theme.css` moved
   the product to signal green on 2026-08-10; beta never followed. **`/beta` and
   `/` are visibly different brands today.**
2. `--font-display: "Space Grotesk"` pulled from a Google Fonts URL the main app
   doesn't load.

There is real maintenance evidence in there — a 10-line comment documenting a
WCAG AA fix that lives **only** in beta. `web/privacy/` and `web/terms/` follow
the same inline-`<style>` convention, so this is **four separate copies** of the
token set counting `redesigns/v0-original/app.css`.

### 3.11 Dead CSS — roughly 110 classes

Method: extracted all 1,333 `.stl-*` class names, grepped `index.html`,
`app.js`, `beta/index.html`, `auth.js`, `bank-loader.js`, `analytics.js`,
`test-icons.js`. 167 raw misses, 57 of which are JS-constructed (`'stl-wk__cell--' + tier`)
and live. **~110 genuinely dead.**

Largest clusters: `.stl-teacher__tabs` / `__tab` / `__tab-count` (~62 lines, and
the comment at `11221` says the name is retired while a test asserts it stays
out of the HTML — the CSS was never deleted, and both `app.css:337` and
`signal-theme.css` still carry light-theme fixes for it) · `.stl-stat` +
variants (~115 lines, and **ironically the best state coverage in the file** —
only `web/dist/`, a stale 2026-04-29 build artifact, references it) ·
`.stl-review-tabs` / `.stl-review-tab` + 6 dot variants · `.stl-review-card__stem`
/ `__choices` / `__choice*` (~65 lines, superseded by `.stl-quiz__*`) ·
`.stl-add-csv__table-wrap` · `.stl-admin__h1` / `__page-titles` / `__page-actions`
· `.stl-logo` + `__dot--g/y/r` · `.stl-boot-splash` · `.stl-how__kicker` /
`__teach` / `__teach-txt` / `__legal` · `.stl-learn__crumb` / `__calcwrap` /
`__back`. `signal-theme.css:259` styles `.stl-how__teach`, which has no markup.

### 3.12 Structural / process notes

- **`redesigns/` and `lib/` ship to production.** `CLAUDE.md` claimed
  `redesigns/` was "not shipped", but it is not in `EXCLUDES_REGEX`, so
  everything under it is live on studysignal.ai. Reconciled in `CLAUDE.md`
  2026-08-17 — the preview URLs are useful, so the doc was wrong, not the build.
  The rule that actually matters is **"would this be safe to serve publicly?"**,
  not "is anything importing it?"
- **`web/design/` ships, deliberately** (2026-08-17). `styleguide.html` is live
  at `/design/styleguide` and `STYLEGUIDE.md` / `GAPS.md` sit beside it. A
  style guide nobody can reach is a style guide nobody reads, and this one has
  to be checkable from a phone. The page is `noindex`, holds no data, hits no
  API, and contains nothing that isn't already inferable from `app.css` — which
  any visitor can fetch anyway. The other `web/*.md` dev docs (`NAV-AUDIT.md`,
  `BACK-BUTTON-AUDIT.md`, `ADMIN-PERF-PLAN.md`, `WIDTH-SYSTEM-PLAN.md`) ship for
  the same reason and are equally harmless.
- `web/*.bak-*` files (7 copies of `app.js`, 3 of `app.css`) are gitignored but
  **still tarballed by `vercel deploy`**, which reads the working tree. Two of
  them are ~950 KB.
- `.stl-admin__filters` uses `:has(#admin-filter-state)` — an **ID-coupled
  selector** doing layout work. `#mode-pick-student` does variant work the same
  way. *(2026-08-18: v2 added two more — `#how-demo-reset` and
  `:has(#how-demo-result:not([hidden]))` in the hero demo block.)*
- **The dead-CSS grep method is provably lossy** (2026-08-18): it never
  searched `tools/`, which is why the v2 `.stl-mock` deletion silently broke
  `tools/header-sweep-probe.html` (local-only, gitignored — no prod impact, but
  the future `dead-css.test.js` should enumerate its markup sources explicitly
  and state what it does NOT cover).
- **The style guide itself drifted within hours of being written** — v2 landed
  between the audit and the next morning, changing the signal help copy and
  position, killing the `.stl-mock` block, and adding the hero demo. Docs about
  the code are code: `tests/styleguide-sync.test.js` now pins the guide's demo
  markup to the app's (help wording + order, `role="group"`, vocabulary), so
  the next drift fails the suite instead of waiting for a screenshot.

---

## 4. The plan

Four phases. Each is independently shippable and each ends with a test that
stops the problem coming back — because the pattern in this repo's decision log
is right: **prefer ending a bug class to fixing its instance.**

Phase 0 is done (this document). Phases 1–4 are ordered by
leverage-per-unit-of-risk, and every one of them touches `app.css`, so they need
a claimed lane and cannot overlap with other CSS work.

### Phase 0 — Write it down ✅ *(done 2026-08-17)*

`STYLEGUIDE.md` · `GAPS.md` · `styleguide.html` (living, renders from the real
CSS) · a Notion **Design system** page in the StudySignal brain · a pointer rule
in `CLAUDE.md` and `AGENTS.md` so every agent consults it before writing CSS.

### Phase 1 — Token adoption *(mechanical, low risk, highest leverage)*

The whole of §3.2 is one find-and-replace. It fixes most of the light-theme
breakage in §3.2 as a side effect, which is why it goes first.

1. Replace ~150 literal `rgba(74,222,128,…)` / `rgba(250,204,21,…)` /
   `rgba(251,113,133,…)` with `rgba(var(--stl-green-rgb), …)` etc.
2. Replace the 30+ inline mono font stacks with `var(--stl-font-mono)`; fix
   `--stl-font-body` on `.stl-app`.
3. Replace the 8 `#15161d` gradient-border interiors with `var(--stl-bg-2)`,
   then **delete the three light-theme overrides they required.**
4. Add the missing tokens: `--r-btn: 9px`, a type scale, a z-index scale,
   `--stl-amber` + `-rgb`, `--stl-blue` + `-rgb`, `--stl-surface-0`.
5. Collapse the off-token palette: `#22c55e` / `#34d399` → `--stl-green`,
   `#f87171` / `#dc2626` → `--stl-red`, `#fbbf24` → `--stl-yellow`.
   `.stl-review-tab__dot--*` and `.stl-results__meter-seg--*` must agree.

**New test — `tests/token-hygiene.test.js`:** fails on a literal signal-colour
triplet in `rgba()`, on an inline `"Geist Mono"` stack, on `#15161d` /
`#8b86ff` / any retired-accent literal, and on `999px` where `--r-pill` exists.
Allowlist the deliberate exceptions (`.stl-wkpaper`'s white paper, the ambient
orb gradients) by line-range with a comment, so the allowlist itself is
reviewable.

### Phase 2 — Accessibility floor

1. **Focus.** Add `:focus-visible` to the ~20 components in §3.8. Remove the
   `outline: none` on `.stl-header__nav-link`. Give `.stl-choice` a real ring
   (its current one is 12%-alpha lavender). Give `--back` a pill-shaped ring.
2. **ARIA/CSS convergence.** Pick `[aria-*]` as the styling hook wherever both
   exist, so the visual state cannot drift from the announced state. Make the
   matrix cells real buttons.
3. **`sr-only`.** Collapse the two utilities into one; audit `app.js`'s rendered
   markup, which currently has zero.
4. **Progress.** Add `role="progressbar"` + `aria-valuenow` to the five bars.
5. **Reduced motion.** Cover the eleven in §3.9, starting with `.stl-dot-fx`.
6. **Overlays.** One shared open/close helper: focus trap, focus return, scroll
   lock with restore bookkeeping, Escape, and an overlay stack. Rename
   `.stl-admin-modal` → `.stl-modal` with the old name aliased for one release.

**New test — `tests/a11y-floor.test.js`:** every selector block containing
`cursor: pointer` or matching a known interactive prefix must have a
`:focus-visible` rule; no `outline: none` without an adjacent `:focus-visible`;
no `@keyframes` consumer without a `prefers-reduced-motion` escape. Model it on
`back-nav.test.js`, which already walks selector blocks this way.

### Phase 3 — Deduplicate, then delete

1. **Tabs → one.** Migrate `.stl-admin__tab` onto `.stl-tabbar` (the header
   comment at `11216` already says this is the plan). Convert `.stl-review-tab`,
   `.stl-radar__sw-tab`, `.stl-add-image__tab` to `aria-selected`. Add roving
   tabindex once, in the shared component.
2. **One loading veil**, one empty state, one badge base (with the five status
   palettes collapsed to one), one count chip.
3. **One field system.** Give `.stl-input` a `--sm` size that *is*
   `.stl-admin__input`, then retire the admin fork and the second label system.
4. **Delete the ~110 dead classes** (§3.11) — start with `.stl-teacher__tab*`
   and `.stl-review-card__choice*`, which have explicit retirement comments.
   Delete `web/dist/` from the tree; it's gitignored and stale, and it is the
   only thing keeping `.stl-stat` alive. Delete the `*.bak-*` files.
5. **One print vocabulary.** Derive `.stl-wkpaper`'s screen rules from the print
   rules rather than maintaining both.

**New test — `tests/dead-css.test.js`:** extract every `.stl-*` class from
`app.css`, grep the markup sources, fail on any with no consumer. Allowlist the
57 JS-constructed names explicitly, which also documents them.

### Phase 4 — Fill the gaps

1. **Toast tones** — `--success` / `--error` / `--warning`, an icon slot,
   stacking, dismiss. 142 call sites currently can't distinguish a failure from
   a copy confirmation.
2. **Light theme for admin and teacher.** After Phase 1 most of this is free;
   what's left is the enumerated list in §3.2 (select chevron, msdd panel,
   matrix cells, `wk__freq`).
3. **Table base** with optional zebra, sticky header and sort — then migrate the
   seven.
4. **Form validation states** — `[aria-invalid]`, `::placeholder`, `:disabled`,
   `:checked`, wired to `.stl-hint.is-error`.
5. **`/beta/` and the standalone pages** import the token layer instead of
   redeclaring it. At minimum, correct beta's accent so `/beta` and `/` are the
   same brand.
6. **Skeletons** where a veil reads as a stall (the admin panes).

### Sequencing note

Phases 1 and 3 both rewrite large regions of `app.css`. **Do not run them
concurrently with any feature work in that file** — claim the lane in
`LANES.md`, land the phase, deploy, release. Phase 2 is mostly additive and can
interleave. Phase 4 is per-component and can be done piecemeal.

---

## 5. What is already good — don't refactor these

Worth naming, because a gap list reads more negatively than the codebase
deserves:

- **The token layer** is genuinely well designed — intent-named spacing
  (`--gap-hug`…`--gap-hero`), four named shell widths with one governing rule,
  named measures in `ch`, motion tokens with documented intent, and long
  explanatory comments that record *why*.
- **`.stl-tier`** has the most complete interactive-state coverage in the file,
  including per-tier `:focus-visible`. It is the bar.
- **`.stl-admin__pager`** is the best-engineered component in the app —
  `:disabled` rather than hidden, `min-width: 11ch` so the position can't
  reflow, `aria-live`, and a comment explaining the focus behaviour.
- **`.stl-signal__chip`** flows all colour through `currentColor` from one
  modifier. That's the pattern the other 150 sites should copy.
- **`.stl-twin`** and **`.stl-oc`** have zero hardcoded colours.
- **`.stl-actionpair`**, `SCREEN_PARENT` and `applyBackLabels()` turned 29
  inconsistent back affordances into one learnable control, with a 16-assertion
  test.
- **The test suite as a design tool.** `signal-vocabulary`, `text-contrast`,
  `tint-contrast`, `css-signal-namespace`, `back-nav`, `header-tiers` pin design
  decisions as executable invariants. Every phase above proposes a new test
  because that habit is the reason this codebase is auditable at all.
