# StudySignal — Style Guide

**Status:** canonical. Written 2026-08-17 from a full audit of `web/app.css`,
`web/signal-theme.css`, `web/index.html` and `web/app.js`; second pass
2026-08-18 after the v2 home page landed. Line numbers are as of `42ea48f` —
they drift, the selectors don't, so grep the selector if a number looks off.

> **Read this before writing any CSS or any HTML string in `app.js`.**
> If a component below already exists, use it. If it doesn't, read
> [`GAPS.md`](./GAPS.md) — the thing you're about to invent may already have
> been invented four times under four names.

**Live version:** **<https://studysignal.ai/design/styleguide>** — or open
[`styleguide.html`](./styleguide.html) straight from the repo. It links the
*real* `app.css` + `signal-theme.css`, so it cannot drift: if a component
changes, the guide changes with it.

It ships on purpose — a style guide nobody can reach is a style guide nobody
reads, and this one has to be checkable from a phone. It is `noindex`, holds no
data and no secrets, and calls no API.

<details>
<summary>Why the extensionless URL is wired the way it is (vercel.json can't hold comments)</summary>

`web/vercel.json` carries one **rewrite**, `/design/styleguide` →
`/design/styleguide.html`, plus a **redirect** from the trailing-slash form.
Three decisions are load-bearing:

- **Rewrite, not redirect.** `/design/styleguide` serves 200 directly instead
  of bouncing through the `.html` URL. The `.html` form still works — it is not
  redirected, deliberately, because a redirect *to* the clean URL plus a
  rewrite *from* it is the shape that loops if Vercel ever re-evaluates
  redirects after a rewrite.
- **Not `"cleanUrls": true`.** That flag is site-wide and would 308 every
  existing `.html` URL — `/beta/`, everything under `/redesigns/`, the app
  itself — changing canonical URLs across a live site to tidy one page.
- **One path at a time, not a wildcard.** `/design/:page` →
  `/design/:page.html` also swallows `STYLEGUIDE.md` and `GAPS.md` and rewrites
  them to `.md.html`, i.e. 404. Add a line per page.

**Keep the page's asset links relative** (`../app.css`). At
`/design/styleguide` the base directory is `/design/`, so `../app.css` resolves
to `/app.css` — correct. At `/design/styleguide/` it would resolve to
`/design/app.css` — which is exactly why the trailing-slash form redirects
rather than rewrites. Relative links are also what let the file open straight
from Finder.

</details>

---

## 0. The three rules that outrank taste

**1 — Green, yellow and red carry exactly three sanctioned meanings.**
(Rewritten 2026-08-18 — Joshua's ruling. The original rule said the signal
owned these colours exclusively; the shipped product had always used them
more widely, and the ruling was "this is fine — green is not sacred.")

The three sanctioned families:

1. **The confidence signal** — the primary meaning, and the only one that
   owns the *phrase + colour pairing* ("Know it / Green" etc., pinned by
   `signal-vocabulary.test.js`).
2. **The difficulty heat ramp** — easy = green, medium = yellow, hard = red,
   insane = blue. Score tiers, worksheet cells, composer bars, admin chips.
3. **Conventional status** — green = success/live/sent, red =
   destructive/error, yellow/amber = caution/pending. The LIVE chip, the
   login "sent" morph, danger buttons, warnings.

What is still *not* sanctioned: a **fourth** meaning. Don't reach for the
signal colours as decoration, category tints, or chart seasoning — if it
isn't confidence, difficulty, or conventional status, use the accent or a
neutral. And regardless of meaning, the colour is always spelled as a token
(`var(--stl-green)`, `rgba(var(--stl-green-rgb), …)`) — `token-hygiene.test.js`
enforces that.

**2 — Selected controls get lighter, never thicker — and so do hovered ones.**
A selected/active/hovered state signals with border *colour* and *fill* only —
never an added `box-shadow: 0 0 0 Npx` ring. A 0-blur ring painted flush
against a 1px border reads as the border jumping to 2px, i.e. "the button
changed shape" (tiers 2026-08-07; the hero demo's hover 2026-08-19 — Joshua
reported it as a Safari resize both times). Diffuse glows are fine; focus
rings, error rings and drag-over rings are legitimate and exempt. (Guarded by
`tests/back-nav.test.js` for selection and `tests/a11y-floor.test.js` for
hover.)

**3 — Any component with a `display:` rule needs a `[hidden]` companion.**
`.stl-x { display: flex }` beats `[hidden]`'s UA `display: none`, so the element
stays visible when JS hides it. `app.css:444–478` documents five separate ship
incidents from exactly this. There is a blanket rule at `app.css:479`
(`.stl-app [hidden] { display: none !important }`) — anything outside `.stl-app`
still needs its own.

---

## 1. Where styles live

| File | Role | Wins? |
|---|---|---|
| `web/app.css` | Everything. Single hand-rolled stylesheet, no bundler, no preprocessor. | base |
| `web/signal-theme.css` | Pure post-load override: retunes `--stl-accent` from lavender to signal green and `--stl-rainbow` to the green→yellow→red ramp. Deleting the `<link>` reverts the whole look. | **yes — by load order, not specificity** |
| `web/beta/index.html` | 450 lines of inline `<style>`, its own unprefixed token set. **Duplicated and drifted** — still on the retired lavender accent. | separate |
| `web/privacy/`, `web/terms/` | Same inline-`<style>` convention. | separate |

The specificity trap is documented at `signal-theme.css:72–79`: app.css's light
rules use `:root[data-theme="light"]` (0,3,1) which **beat** `html[…]` (0,2,1).
Every light rule in `signal-theme.css` is therefore written in the same
`:root[…]` form so the tie breaks on load order. **Match that form or your
override silently loses.**

Cache-busting: `?v=<tag>` appears in **four** places (`index.html` ×3,
`bank-loader.js` APP_SRC, and the reference-sheet tag inside `app.js`). Bump
them together, as the last edit before a deploy. `npm test` catches drift.

---

## 2. Tokens

All tokens live in `:root` at `app.css:13–187`, with a light-theme override
block at `218–285`. **A component built only from tokens gets light mode for
free.** Almost every light-mode bug in the app traces to a hardcoded value.

### 2.1 Surfaces and hairlines

| Token | Dark | Light | Use |
|---|---|---|---|
| `--stl-bg-deep` | `#050509` | `#f3f4f8` | page canvas |
| `--stl-bg` | `#0a0b12` | `#fafbfd` | inputs, wells |
| `--stl-bg-2` | `#0d0e18` | — | gradient-border interiors |
| `--stl-surface` | `rgba(255,255,255,.035)` | slate 3.5% | cards, rest-state controls |
| `--stl-surface-2` | `.055` | | hover |
| `--stl-surface-3` | `.075` | | pressed / selected |
| `--stl-panel` | `rgba(22,23,33,.92)` | `rgba(255,255,255,.92)` | **frosted-glass** floating panels — pair with `backdrop-filter: blur(14px)` |
| `--stl-hairline` | `.07` | ~2× in light | default border |
| `--stl-hairline-hi` | `.12` | `.18` | emphasised border, inputs, buttons |

Never pure black, never pure white. Dark theme lightens by adding white at low
opacity; light theme *darkens* by adding slate — same depth, opposite direction.

**The `--stl-surface-*` tokens are translucent by design and are for elements
ON the page.** Anything that FLOATS above the page — a menu, a dropdown, a
popover — is **frosted glass**: `background: var(--stl-panel)` (~92% tint)
**plus `backdrop-filter: blur(14px)`** on the panel (the blur can't live in a
colour token). The behind-content reads as a soft glow, never as legible text.
Calibration, from the user-menu incident (2026-08-18): 5.5% `--stl-surface-2`
let Joshua read the page through the menu; fully opaque lost the depth — *"I
still wanted some opacity."* ~92% + blur is the toast's recipe and now the
panel standard. In-flow disclosure panels that push content down (e.g. the
composer's topic panel) may stay lightly translucent — nothing is behind
them.

### 2.2 Text

`--stl-text` `#f0f2f7` · `--stl-text-muted` `#a8b0c4` · `--stl-text-subtle`
`#787e92` · `--stl-text-faint` `#494f60`.

`--stl-text-subtle` was `#6b7288` until 2026-08-17 — 4.01:1 on `--stl-bg-2`,
below the 4.5:1 AA floor, across 138 uses. Now 4.76:1 worst case. **Pinned by
`tests/text-contrast.test.js`.** Same value as `/beta/`'s `--subtle` on purpose.

### 2.3 Accent

`--stl-accent` · `--stl-accent-2` · `--stl-accent-soft` (12%) ·
`--stl-accent-line` (40%).

`app.css` declares these as lavender `#8b86ff`; **`signal-theme.css` overrides
`--stl-accent` to signal green `#4ade80`.** Consequence: anything reading
`var(--stl-accent)` is green today. This has bitten once — `.stl-btn--danger`
lit up green-rimmed on hover (`app.css:6670–6676` documents the fix). If you
use the accent on a destructive or non-signal surface, check what it renders as.

Anything hardcoding `#8b86ff` / `rgba(139,134,255,…)` did **not** follow the
theme change and is stale by definition.

### 2.3b The test colour — `--stl-tint` and the runtime accent override

**Inside an active test, "the accent" IS the test's colour.** When a test
starts, JS (`app.js` ≈1500) stamps two things on `<html>`:

- `--stl-tint` — the test's RGB triplet (SAT purple, ACT orange, PSAT blue…),
  brightened for the dark canvas, for `rgba()` composition;
- **the entire accent family** — `--stl-accent`, `-2`, `-soft`, `-line` — is
  overridden to that same colour.

So every component styled with `var(--stl-accent*)` automatically wears the
test colour during a quiz: **the selected answer choice** (`.stl-choice
.is-selected` — border `--stl-accent-line`, fill `--stl-accent-soft`, letter
badge `--stl-accent`; this is deliberate, Joshua 2026-08-18: *"the selected
[choice] is the same colour as the test you're on"*), focus rings, the error
halo's neighbours, and the ambient orb glow (`signal-theme.css` reads
`--stl-tint` directly). On neutral surfaces (picker, login, splash) the
accent is signal green — the splash explicitly neutralises `--stl-tint` to
white (`app.js` ≈1530).

Direct `--stl-tint`-family consumers, each scoped per element:
`--tile-tint` (picker tiles, assess tiles, tests rows — set inline from JS) ·
`--chip-tint` (composer type chips) · `--badge-tint` (test badges) ·
`--kpi-tint` (admin KPI corner washes) · the trainer's selected choice
(`rgb(var(--stl-tint, …))`). Light mode re-tunes tints via the block at
`app.css` ≈311 (per-test light values, pinned by `tint-contrast.test.js`).

**Rule of thumb:** if an element should follow the test the student is in,
style it with the accent family or compose from `--stl-tint` — never a
per-test literal. If it must NOT follow the test (a destructive button, a
signal surface), don't reach for the accent — that's the `.stl-btn--danger`
lesson in §2.3.

### 2.4 Signal colours — the product's core palette

| Token | Value | Use |
|---|---|---|
| `--stl-green` / `--stl-yellow` / `--stl-red` | `#4ade80` `#facc15` `#fb7185` | dots, chips, borders, small marks |
| `--stl-green-rgb` / `-yellow-rgb` / `-red-rgb` | `74,222,128` · `250,204,21` · `251,113,133` | **for `rgba()` composition** |
| `--stl-green-text` / `-yellow-text` / `-red-text` | `#86efac` `#fde047` `#fda4af` | large numerals, stat labels (mid-tones recede at 40px+) |

> **The `-rgb` tokens exist and are effectively unused.** They are consumed at
> six lines in the entire stylesheet (`13946–13948`, `13962–13964` — the assess
> lead card) while roughly **150** literal `rgba(74,222,128,…)` sites hardcode
> the same value. Those 150 sites do not flip in light mode. **Writing
> `rgba(var(--stl-green-rgb), .3)` instead of `rgba(74,222,128,.3)` is the
> single highest-leverage habit change in this codebase.**

There is currently **no token for the "gap" amber** (`#f59e0b`, used at `4587`,
`4671`, `8794`) and the loader ring introduces an orange (`#fb923c`) that exists
nowhere else. See GAPS.md §Palette drift.

### 2.5 Rainbow accent

`--stl-rainbow` (4-stop gradient) · `--stl-rainbow-soft` (16% alpha version) ·
`--stl-rainbow-mid` + `--stl-rainbow-mid-rgb` (the single-colour fallback for
`box-shadow` and `rgba()`, where a gradient can't apply).

`signal-theme.css` retunes this to green→yellow→red. The Chart.js radar polygon
builds the same gradient in canvas via `buildRainbowCanvasGradient` in `app.js`
— **both must change together.**

The navbar's selected state uses it too — icon chip border and the 2px
underline. That went grey on 2026-08-18 and came back the same day once the
options were seen side by side; `redesigns/nav-selected/` keeps the grey and
three hard-stop variants for whenever it comes up again.

The gradient-border trick, used ~10 times:
`background: linear-gradient(var(--stl-bg-2), var(--stl-bg-2)) padding-box,
var(--stl-rainbow) border-box; border: 1.5px solid transparent`.
Write the interior as `var(--stl-bg-2)`, not `#15161d` — the hardcoded form
appears at 8 sites and each needs its own light-theme override (three exist).

### 2.6 Spacing

Raw steps: `--s-1` 4 · `--s-2` 8 · `--s-3` 12 · `--s-4` 16 · `--s-5` 20 ·
`--s-6` 24 · `--s-7` 32 · `--s-8` 40.

**Prefer the intent tokens for vertical rhythm inside a card:**

| Token | px | When |
|---|---|---|
| `--gap-hug` | 8 | a title and its immediate subtitle |
| `--gap-tight` | 12 | items within one group (chip row, ledger row internals) |
| `--gap-normal` | 16 | default subsection separator |
| `--gap-section` | 20 | major break inside the same card (head → main, main → CTA) |
| `--gap-hero` | 32 | hero head → first interactive section (sparingly) |

Test: *"same kind of thing?"* → tight · *"same area, different parts?"* →
normal · *"distinctly separate areas?"* → section.

### 2.7 Radius

`--r-sm` 8 · `--r-md` 10 · `--r-lg` 14 · `--r-xl` 18 · `--r-pill` 9999.

**The button radius is `9px` and has no token** — it falls between `--r-sm` and
`--r-md` and is written as a literal at `4964`, `5062`, `5065`. Pills are
written as `999px` and `9999px` interchangeably; `--r-pill` is the canonical one.

### 2.8 Width — the one rule that actually holds

Only `.stl-main` sets page width. Inner elements may cap *measure* (in `ch`) but
never *shell* (in px). Overlays that float above the page — modals, dialogs, the
printable worksheet — are the sole exemption.

| Shell | px | Screens |
|---|---|---|
| `--shell-focus` | 440 | auth cards, confirm dialogs, single-purpose panels |
| `--shell-read` | 760 | picker, quiz, results, review, score, history |
| `--shell-work` | 1000 | teacher — panels plus a side rail |
| `--shell-data` | 1280 | admin — tables and database views |

Switched by `:has()` on `.stl-main` (`1182`, `1194`, `12215`). Measure:
`--measure-title` 18ch · `--measure-lede` 56ch · `--measure-body` 44ch ·
`--measure-meta` 30ch.

### 2.9 Motion

`--ease-out` `cubic-bezier(.16,1,.3,1)` — screen transitions.
`--ease-spring` `cubic-bezier(.2,.6,.2,1)` — hover states.
`--dur-fast` 120ms (`:active` rebound) · `--dur-base` 200ms (hover) ·
`--dur-slow` 280ms (decorative reveals).

There are 23 `prefers-reduced-motion` blocks. **Add one whenever you add
motion** — see GAPS.md for the eleven that are currently missing, including the
most-fired animation in the product.

### 2.10 Tokens that do not exist

No type scale · no elevation/shadow scale · no z-index scale · no icon-size
scale · no border-width scale. All are written as raw values. See GAPS.md §1.

---

## 3. The signal vocabulary — pinned, not a matter of taste

Source of truth: **`web/tests/signal-vocabulary.test.js`**. It is a pinning
test. Change the words and it fails.

| Order | `data-signal` | Phrase (leads) | Colour word (subtext) |
|---|---|---|---|
| 1 | `green` | **Know it** | Green |
| 2 | `yellow` | **Maybe** | Yellow |
| 3 | `red` | **Don't know** *(U+2019)* | Red |

**Structure:** every signal button is `chip + lbl + sub` — three children. The
mobile grid places them by name (`grid-template-areas: "chip lbl sub"`,
`app.css:10168–10171`), so dropping `__sub` silently collapses the mobile row.

**Hierarchy:** the phrase leads, the colour is the subtext. Never inverted.
`/beta/` shipped it backwards once; a negative assertion now guards it.

**The apostrophe is U+2019 and it is pinned.** Four different apostrophes were
live in the same two words simultaneously on 2026-08-17. Encoding by file type:
`&rsquo;` in HTML · `’` in JS strings. Bare ASCII `'` fails the test.

**Retired wordings, banned on any shipped surface:** *I understood it ·
Educated guess · No idea · Pretty sure · Knew it.*

**Six surfaces render these states** (was seven — the static marketing mock
died when v2 made the hero the real demo). Quiz buttons · home-page hero demo ·
marketing results visual · `/beta/` outcome matrix · in-app Signal trainer ·
the six outcome chips. **Anything user-visible that
names the signal goes into the test in the same commit that introduces it** —
a partial invariant test converts "we haven't checked" into "we verified this."
(Decision 2026-08-17.)

**The six outcomes** (`SIGNAL_STATE_PARTS`, `app.js:11118`) are signal ×
correctness:

| Outcome | Signal | Correct? |
|---|---|---|
| `mastery` | Know it | ✓ |
| `misconception` | Know it | ✗ |
| `lucky` | Maybe | ✓ |
| `gap` | Maybe | ✗ |
| `fluke` | Don't know | ✓ |
| `cold` | Don't know | ✗ |

The chip carries the colour, so the outcome label must **not** repeat the colour
word.

**One deliberate exception:** `.stl-startdots` orders red → yellow → green,
because it reads as a traffic light going green. It is the only inversion.

---

## 4. Components

Each entry: what it is · where it lives · how to write it · what it's missing.
"Missing" is not a complaint — it's the thing you will hit.

### 4.1 Button — `.stl-btn`

`app.css:4964–5320`. Base: `inline-flex`, `gap 8px`, `padding 9px 16px`,
`radius 9px`, `13.5px/500`, `--stl-surface` fill, `--stl-hairline-hi` border.
Hover lifts 1px with a tinted halo; `:active` presses 0.5px; `:focus-visible` is
a 2px `--stl-accent` outline at offset 2; `:disabled` is `opacity .45`.

| Modifier | Use |
|---|---|
| `--primary` | the one CTA on a screen. Rainbow gradient border, opaque interior. |
| `--outline` | secondary **that still carries brand chroma** — same gradient border, lighter fill. |
| `--neutral` | secondary with **no gradient**: surface fill + hairline border. Reach for this when it sits beside a gradient primary, so only one button wears brand colour. Added 2026-08-18 — the table used to call `--outline` "secondary", which left no neutral option and pushed people to one-off overrides. |
| `--ghost` | tertiary / dismissive. Transparent until hover. |
| `--pill-rainbow` | pill-shaped primary; `.stl-radar__cta` scales it to hero size. |
| `--pill` | **shape only** (radius). Composes with any variant; `--pill-rainbow` already bakes it in. |
| `--hero` | full-width, 52px min-height. |
| `--sm` | compact. **Cancelled on mobile** — see below. |
| `--back` | the paired back button. Pill, muted, chevron + `__lbl`. |
| `--arrow` | appends a `→` that slides on hover. |
| `--danger` | destructive. Currently hardcodes its own reds. |
| `--dev` | localhost-only affordance. Dashed, mono, uppercase. |
| `--signal` / `--mode` | wide pill pair used in the results footer. |

```html
<button type="button" class="stl-btn stl-btn--primary stl-btn--hero">Sign in</button>
```

**Missing:** `--icon` is referenced at `9018` but **never defined** (and unused).
`__count` is **defined twice** (`4897` and `7935`) with incompatible treatments;
the later chip wins. `--sm` sets `13px` at line `2974`, then the mobile query at
`10184` sets `.stl-btn { min-height: 44px; font-size: 14px }` 7,200 lines later
with no opt-out — so `--sm` is silently full-size on mobile (`--dev` opts out at
`10196`; `--sm` doesn't). `--back`'s focus ring is a rectangle around a pill.
There is **no base-level convention for pressed/selected buttons** — every
toggle re-invents `aria-pressed` / `.is-active` styling.

`.dc-btn` is carried in every selector list and has **zero markup anywhere** —
kept alive only by a literal-string assertion in `tests/back-nav.test.js:207`.

### 4.2 Action pair — `.stl-actionpair`

`app.css:1725–1775`. The back-button layout contract. Back is an equal-width
sibling of the primary action — *equal footprint, unequal fill*.

- Screen has **exactly one** primary action → pair form (`1fr 1fr`).
- Screen has **zero or 2+** → `--solo`, full-width row.
- `--stack` → one column, primary first, back beneath — the only arrangement
  where the primary leads (ships on the score screen, `index.html:1060`).
- `:has(> [hidden])` collapses to one column automatically.

Back navigation is **hierarchical, not historical**: every screen declares one
parent in `SCREEN_PARENT` in `app.js`, resolved per click by `backTarget()`.
Labels are written at render time by `applyBackLabels()` — **never hardcode a
destination in HTML.** One delegated `[data-back]` handler. Guarded by
`tests/back-nav.test.js` (16 assertions).

### 4.3 Card — `.stl-card` / `.stl-card--hero`

`app.css:1214–1243`. `--stl-surface`, `--r-lg`, `padding: clamp(--s-5, 3vw, --s-7)`.
`--hero` adds a 165° slate gradient and a three-layer shadow. **On mobile
(≤640) `--hero` drops all chrome** (`10738`) — transparent, no border, no
padding — so the content sits directly on the canvas.

`--hero` has a light-theme shadow swap; **plain `.stl-card` does not**, so its
white inset top-highlight is invisible on a white canvas.

### 4.4 Field — `.stl-field` / `.stl-label` / `.stl-input` / `.stl-hint`

`app.css:1502–1544`. `.stl-field` is `display: grid; gap: var(--s-2)`.
`.stl-label` is mono 11px, `.08em`, uppercase, `--stl-text-subtle`.
`.stl-input` is `padding 14px 16px`, `1.25rem/500`, `--stl-bg` fill.
`.stl-hint` takes `.is-error`.

The canonical shape (`index.html:2345`) is a **`<label class="stl-field">`
wrapping everything** — implicit labelling, no `for`/`id` pair to keep in sync:

```html
<label class="stl-field">
  <span class="stl-label">Email</span>
  <input type="email" class="stl-input" autocomplete="email" />
  <span class="stl-hint">We'll send a sign-in link.</span>
</label>
```

**Missing:** no `:disabled`, no `:invalid` / `[aria-invalid]`, no `::placeholder`
styling anywhere in the app. `.stl-input:hover` sets
`border-color: rgba(255,255,255,.18)` (`1528`) with no light override — the
border *disappears* on hover in light mode.

**There is a second, incompatible field system.** `.stl-admin__input` /
`__select` / `__textarea` (`6844–6897`) is 40px tall at 13.5px with a 3px focus
ring; `.stl-input` is ~48px at 1.25rem with a 4px ring. The split is total: 72
admin-owned controls, zero shared. Admin also runs two label systems at once
(46 `.stl-label` + 22 `.stl-admin__field-lbl`, 1px apart). See GAPS.md.

### 4.5 Signal control — `.stl-signal` ★

`app.css:4144–4232`, mobile `10157–10175`. The product. Three-column grid of
92px buttons (64px mobile, 56px at the smallest). The chip is a 12px circle
filled with `currentColor` and glowing at `0 0 12px currentColor` → `18px` on
hover — **colour flows entirely from the `--green`/`--yellow`/`--red` modifier
via `currentColor`. This is the cleanest token usage in the file. Copy it.**

The full quiz shape (`index.html:1194–1214`) — help line **above** the buttons,
inside a `.stl-quiz__signal` footer, with `role="group"` on the grid:

```html
<footer class="stl-quiz__signal">
  <p class="stl-quiz__signal-help">
    How sure are you? Picking one locks in your answer.
  </p>
  <div class="stl-signal" role="group" aria-label="Confidence">
    <button type="button" class="stl-signal__btn stl-signal__btn--green" data-signal="green">
      <span class="stl-signal__chip"></span>
      <span class="stl-signal__lbl">Know it</span>
      <span class="stl-signal__sub">Green</span>
    </button>
    <!-- yellow: Maybe / Yellow · red: Don&rsquo;t know / Red -->
  </div>
</footer>
```

The help line is the question and the buttons are the answer — it never renders
below them. `.stl-quiz__signal-help` (`app.css:4090`) reserves `min-height` so
its `.is-error` swap doesn't reflow. The wording is currently **not** pinned by
any test (the vocabulary test pins the button labels, not the help copy).

**One shipped exception to chip + lbl + sub, pinned by test:** the home-page
hero demo keeps all three children but suppresses the colour word in CSS —
`.stl-how__art .stl-signal__sub { display: none }` (`app.css:12499`, asserted
by `signal-vocabulary.test.js`). CSS-level, never markup-level. The hero also
uses `data-demo-signal` where the quiz uses `data-signal`.

**Missing:** the per-colour hover halos (`4214–4224`) hardcode their rgba and do
not flip in light mode. `.stl-dot-fx` — the click animation that fires on the
single most-repeated interaction in the product — has **no reduced-motion
guard** (`14845–14871`). The `transition` rule and the `@media (hover:none)`
block are each **declared twice** (`4149`/`4168`, `4226`/`10032`).

`tests/css-signal-namespace.test.js` enforces exactly one top-level
`.stl-signal { }` rule. The guided-review trainer is `.stl-rts`, not
`.stl-signal` — reusing the name once stacked the quiz vertically.

### 4.6 Answer choice — `.stl-choice`

`app.css:3535–3648`. A `<label>` wrapping a visually-hidden radio, a
`__letter` badge and `__text`. States: `:hover`, `.is-selected`,
`:focus-within`, `:active`, `.is-correct`, plus `[data-state="correct"|
"picked-wrong"]` on the review `<li>` and the chart variant.

**The selected choice wears the ACTIVE TEST's colour — that is the spec, not
an accident.** `.is-selected` styles with the accent family
(`--stl-accent-line` border, `--stl-accent-soft` fill, `--stl-accent` letter
badge), and during a quiz the accent family is overridden per test (§2.3b).
So on an SAT the selected answer is SAT purple, on an ACT it's ACT orange.
Never restyle the selected state with a fixed colour — you'd break the
test-identity thread that runs from the picker tile through the quiz.

**Missing:** no `:focus-visible` at all — the component relies on
`:focus-within` with a `2px solid var(--stl-accent-soft)` ring, i.e. **12% alpha
lavender, effectively invisible.** This is the weakest focus indicator in the
product. `.is-correct` hardcodes `#22c55e` — a *fourth green* in a product whose
thesis is three colours. Two vocabularies for the same idea (`.is-correct` on
the choice vs `[data-state]` on the review row).

### 4.7 Difficulty tier — `.stl-tier` ★ (the state-coverage reference)

`app.css:1907–2143` + per-tier colour at `15016+`. Richest state set in
the codebase: hover, per-tier hover, `:active`, `.is-active`, per-tier
`.is-active`, `.is-active:hover`, `:focus-visible`, **per-tier
`:focus-visible`**, `:disabled`, `[aria-disabled]`, disabled-hover reset,
`@media (hover:none)`, `view-transition-name`. **Use this as the bar.**

The comment blocks at `2008–2017` and `~15036` document two real
source-order bugs. Preserve them verbatim in any refactor.

**The fifth tile is Tailored** — `--tailored` (`2029–2099`), rendered as
`role="radio"` where the four buckets are checkboxes, with
`data-tier-mode="tailored"` deliberately *not* `data-tier`. Selected, it takes
the rainbow gradient-border treatment (`2076–2093`); the signal dots it once
carried were removed 2026-08-17 (design call: the tile reads cleaner without
them). When Tailored is on, all four bucket tiles render unselected on
purpose (`app.js:5169`).

**Caveat:** ARIA and CSS are separate code paths. `app.js:5175` renders
`role="checkbox" aria-checked`, but the CSS styles `.is-active`. They can drift
silently. Same pattern on `.stl-history__chip`.

### 4.8 Pill / badge — `.stl-pill`

`app.css:2651–2851`. Mono 10.5px uppercase, `padding 3px 9px`, `--r-pill`,
5px `::before` dot. Variants: `[data-d="easy|medium|hard|insane"]`,
`[data-result="mastery|misconception|lucky|gap|missed|fluke|cold"]`,
`--time`, `--timer`.

**Best-themed component in the file** — both data-attribute families have full
light-theme overrides (`2688`, `2822`). Non-interactive, `<span>` only.

It is **not** a generic badge. Seven other badge/chip families exist. See
GAPS.md.

### 4.9 Tabs — `.stl-tabbar` (the generic one)

`app.css:11216–11331`. `role="tablist"` / `role="tab"`, `aria-selected`, a 2px
`--stl-rainbow` `::after` faded by opacity, a mono count chip, `:focus-visible`,
and a `__veil` for the loading state.

**Use this one.** Five other tab implementations exist and three of them signal
selection with `.is-active` instead of `aria-selected`, i.e. **half the tab
strips in the app are invisible to assistive tech.** No implementation has
arrow-key roving tabindex. Full table in GAPS.md.

### 4.10 Overlays — `.stl-admin-modal`

`app.css:7372–7477`. Despite the name, **this is the generic modal base** —
`index.html:4067` says so and five consumers reuse it (`.stl-confirm`,
`.stl-add-modal`, `.stl-review-modal`, `#user-detail-modal`, `#admin-modal`).
Provides backdrop, panel, `.is-open` transition, `dvh` sizing,
`overscroll-behavior: contain`, and a reduced-motion escape.

It does **not** provide focus trap, focus return, scroll lock, or ARIA. There is
exactly one focus trap in the app (`stlConfirm`, `app.js:~822–875`) and exactly
two scroll-lock call sites. Eight independent Escape handlers, no overlay stack.
`z-index` is hand-assigned: header 50 · preview-banner 60 · msheet 120 ·
admin-modal 200 · confirm 300 · usermenu 1500 · floating-tip 9999 · boot 9999.

Two overlays bypass it entirely with native `<dialog>` (`.stl-desmos-panel`,
`.stl-refsheet`) and `.stl-msheet` is a third, unrelated mobile-sheet shell.

### 4.11 Toast — `.stl-toast`

`app.css:9939–9973`. Bottom-centre, 2200ms, `role="status" aria-live="polite"`.
**142 call sites in `app.js` — it is the app's only transient feedback channel.**

One variant. No tones, no stacking, no icon, no dismiss, no undo — so
`'Note save failed (HTTP 500)'` and `'Link copied'` render identically. Its
slide-in has no reduced-motion guard.

### 4.12 Loader — `.stl-loader`

`app.css:8949–9011`. One spinner design, unified across four selectors
(`.stl-loader`, `.stl-boot-veil__spinner`, `.stl-add-image__spinner`,
`.stl-btn.is-loading::after`). Sizes `--sm` 14 · default 18 · `--lg` 32 ·
`--xl` 56. Conic gradient + radial mask; reduced-motion halts the spin but keeps
the ring.

**There are no skeletons.** The loading pattern is a **veil over a
height-reserved container**: `.stl-admin__pane-veil` / `.stl-tabbar__veil` +
`[aria-busy="true"] { min-height: 220px }`. Two identical implementations of
that veil exist.

`--solid` is documented at `8944` and never defined. The conic ring's stops are
a fifth independent palette including an orange found nowhere else.

### 4.13 Pagination — `.stl-admin__pager` ★ (the engineering reference)

`app.css:6711–6770`. Prev / `Page N of M` / Next + a page-size select.
`:disabled` at the ends rather than hidden · `:focus-visible` · `min-width: 11ch`
on the position span so it can't reflow · `aria-live="polite"` · the table takes
focus on page change with `outline: none` and a comment explaining why.

**The most completely engineered component in the app.** Nothing else paginates.

### 4.14 Locked action — `.stl-lock` / `.is-paylocked`

`app.css` (last block) + `applyLocks()` in `app.js`. The paywall affordance for
the pricing restructure (Free / Signal Plus $10 / Teacher $28, 2026-09-19).

Markup declares the capability — `data-cap="teacher.send"` — and nothing else is
hardcoded at the call site. One pass (`applyLocks()`) stamps `.is-paylocked` from
the capability list the server put on the user object; one delegated
**capture-phase** handler intercepts the click and opens the upgrade sheet.
Bubble phase would be too late: the worksheet would already be generating behind
the sheet. Adding a locked button is one attribute.

**It is not a disabled button.** No reduced opacity, no muted fill, no
`pointer-events: none`, never `disabled` or `aria-disabled`. A locked primary
keeps its full weight, because it still *does* something when clicked. The glyph
is a lucide lock at `currentColor`, 0.72 opacity — no signal colour (Rule 1: no
fourth meaning) and no border-weight change (§4.1). Being an inline child, it
cannot shift anything laid out against the button. The price reaches a screen
reader through an `.sr-only` suffix inside the button's own accessible name; the
svg is `aria-hidden`.

**The class is `.is-paylocked`.** `.is-locked` was already taken by
`.stl-quiz__gridin-input`, where it means "answer committed". One name, two
meanings is how a styling change to one silently moves the other.

Fails open by design: if the capability list is absent — a client cached from
before this shipped, a signed-out visitor, a degraded billing lookup — nothing
locks. The client is never the gate anyway; the server re-checks every paid
write (`requireCap` → 402). Guard: `tests/paywall-locks.test.js`, which
*executes* the module out of `app.js` rather than grepping it.

### 4.15 Choice rows — `.stl-gen__opt`

Used by the Generate hub (`#stl-gen`), which is a `.stl-admin-modal` like
every other overlay — the shell owns the backdrop, panel, focus trap and
reduced-motion handling, and none of that is re-implemented.

A chosen option changes **fill and colour, never border weight** (rule 2).
These sit three-across in a `role="radiogroup"`, which is the case where a
heavier border on the selected one shifts its neighbours by a pixel and the
whole row twitches on every click. The fill and text colour carry the
meaning; the border colour only reinforces it. `aria-checked` is set in the
same breath as `.is-on`, so the state survives forced-colours mode, where
the fill does not.

**One button, the capability stamped at runtime.** The hub's Build button
takes `data-cap="gen.tailored"` or `"gen.worksheet"` from the chosen
output, the same way the worksheet builder's Create takes its cap from the
audience (§4.14). Anything that unlocks a control by REMOVING its
`data-cap` must call `clearLock(el)`: `applyLocks()` queries `[data-cap]`,
so a control that has lost the attribute is no longer in that query and
would keep its padlock forever.

### 4.15 Everything else, briefly

`.stl-header` (sticky, 60px + safe-area, hidden below 640 in favour of
`.stl-mobile-bar`) · `.stl-stepnav` (Back / Skip-or-Next: the forward slot holds exactly one button at a time, Skip on the newest question, Next on an answered one — mutually exclusive because `handleSkip()` writes a skip at the current index; the most complete state
set in the quiz: hover, focus-visible, disabled, reduced-motion) ·
`.stl-picker__tile` (per-tile `--tile-tint` set inline from JS — good pattern) ·
`.stl-results__row` (`--row-color` local var per outcome, focus ring inherits it
— also good) · `.stl-switch` (`role="switch"`, `aria-checked`; no hover, no
active, no disabled) · `.stl-seg` (the most base-like segmented control of the
three that exist) · `.stl-msdd` (worksheet multi-select; the comment says admin
filters adopt it next — they haven't) · `.stl-wkpaper` (white paper-on-dark
worksheet preview; re-declares the entire print vocabulary in px alongside the
`@media print` block in pt) · `.stl-results__qlink` (results quick-actions,
2026-08-26: share-sheet anatomy — circled 19px stroke icon in a 44px
`__qlink-ico` over an 11px `__qlink-lbl` caption, five-up
`repeat(5, minmax(0,1fr))` row; captions WRAP, never ellipsise; hover is
colour/fill only per §5.7; the borderless button carries its own
`:focus-visible` ring — a11y-floor forbids moving it to a child. Icon-only was
rejected: Worksheet and New test have no obvious glyph, and the caption is the
accessible name).


**Stoplight countdown — `.stl-cd` (redrawn 2026-09-30).** The wordmark's
three dots in a small capsule over a blurred veil (`color-mix` of `--stl-bg`
+ `backdrop-filter`), centred, no pole and no housing block. The capsule is
the `.stl-btn--pill-rainbow` padding-box/border-box trick, so GO swaps the
hairline border for `--stl-rainbow` without moving anything; dots are
`currentColor` at 18% unlit and the full signal token lit, with the glow
written once via `currentColor`. Every colour is a token — it flips for
light mode. It starts only after the screen's View Transition settles
(`_screenTransition` in app.js), because VT snapshots paint above every
z-index. Tempo is `CD_BEAT`; `tests/countdown-pace.test.js` ties the three CSS
durations to it.

**Admin → Tests assessment settings (2026-09-30).** The per-test Baseline and
Demo blocks are `.stl-tests-subject` rows (not a new row type), separated by
`.stl-tests-row__sechead` labels; the forms library reuses the Tests tab's
collapsible `.stl-desmos-section` shell with `.stl-desmos-topic` rows plus
`.stl-form-row__*` controls/flags, and the editor is an `.stl-admin-modal` with
the admin field system and a live `.stl-form-report`. Flags and report lines
use conventional-status colour only (§0 rule 1, family 3).

**Marketing pages share one shell.** `#how`, `#pricing`, `#faq` and `#teachers`
(2026-09-14) are all `.stl-card--hero.stl-how` on the `--shell-data` width, built
from `.stl-how__block / __h2 / __p / __feats / __pillars / __sec / __faq / __cta`
plus the teacher-band mocks (`.stl-teachx__row / __mock / __stu / __ticks`).
A new marketing page starts from those; page-scoped additions get their own
namespace (`.stl-tpg__*` for the teachers page: hero grid, the paper fan, the
topic × difficulty matrix mock) and live in one block at the end of `app.css`.
The `.stl-hwk` worksheet-builder toy is bound by `bindWorksheetMock(root)` in
`app.js` wherever it appears — don't copy the wiring.

**Plan cards** (`#pricing`) are `.stl-how__sec--plan` — the pillar card with a
modifier, never a `.stl-plan__*` family. The recommended plan is
`--feature`, which is a **fill and a border colour, never a heavier border**
(§2); a chunky border on the recommended plan is the single most common way
this component gets built wrong. `.stl-how__sec--plan .stl-btn` takes
`margin-top: auto` so every card's action sits on one line whatever the
feature lists do. A card with **two ways to pay** (Signal Plus: $10 monthly, or
$25 for three months) wraps both buttons in `.stl-how__pay`, which takes the
auto margin instead — two buttons each taking it would distribute the free
space *between* them and pull the card apart. Two ways to pay share a card;
they never become a fourth card, which would read as a fourth tier.

---

## 5. Placement & composition — how screens are assembled

Component specs say what a thing looks like; this section says **where things
go**, because placement drifts faster than styling (29 back affordances was a
placement problem, not a colour problem).

### 5.1 Back buttons

**Where:** at the **foot of the screen**, as an equal-width sibling of the
primary action — the `.stl-actionpair` contract (§4.2). *Equal footprint,
unequal fill*: back is the ghost, the CTA is the primary. Exactly one primary
action → pair form; zero or 2+ → `--solo` full-width row; `--stack` (primary
first, back beneath) only where the primary must lead. On mobile ≤640 the
top-bar chevron (`.stl-mobile-bar`) is the additional top-of-screen escape.

**How:** back is **hierarchical, not historical**. Every screen declares one
parent in `SCREEN_PARENT` (`app.js`); `backTarget()` resolves it per click;
`applyBackLabels()` writes the one label formula `"Back to {parent}"` into the
markup at render time. One delegated `[data-back]` handler. **Never hardcode a
destination or a label in HTML.** Guarded by `tests/back-nav.test.js`.

**The action-bar rule** (2026-08-17): *an action bar holds document actions;
navigation lives top-left, one per screen.* A worksheet's Print/Share bar
never contains "Back" — the single top-left control does navigation, and it is
context-aware, not duplicated.

### 5.2 Actions

**One `--primary` per screen.** Everything else is `--outline` or `--ghost`.
If you're adding a second primary, one of them isn't primary. Destructive
actions are `--danger` and never sit where the primary usually lives.

### 5.3 Question-before-answer

Helper text that asks something sits **above** the controls that answer it —
the quiz's "How sure are you?" line renders above the signal buttons, never
below (§4.5). Hints that explain an input sit below it (`.stl-hint` inside
`.stl-field`).

### 5.4 Width

Only `.stl-main` sets shell width (§2.8's four shells). Inner elements cap
measure in `ch`, never shell in px. Overlays are the sole exemption.

### 5.5 Overlays and transient UI

Modals open through `showAdminModal` — which portals to `<body>`, traps focus,
locks scroll (depth-counted) and returns focus on close. Don't hand-roll any
of that per modal. **Floating panels are opaque** — `background:
var(--stl-panel)`, never a `--stl-surface-*` translucency (§2.1). Toasts are bottom-centre, one at a time, 2200ms; use
`toast(msg, 'error'|'success')` when the kind of news matters. z-order:
header 50 · sheet 120 · modal 200 · confirm 300 (a proper scale is still a
listed gap).

### 5.6 Mobile ≤640

The global header hides; each screen gets a `.stl-mobile-bar` (close/back
chevron + title + accessory). Hero cards drop their chrome and go full-bleed —
the screen IS the card. Primary CTAs live in the thumb zone. Touch targets
≥44px, safe-area insets respected, hover-lifts neutralised under
`@media (hover: none)`.

### 5.7 Stable geometry under state change

A control that changes state must not change size or move its neighbours:
selection is colour+fill (rule 2); Back is *disabled*, never hidden, so the
stepnav row can't change width; the forward slot holds exactly one of
Skip/Next; the signal help reserves `min-height` for its error swap; the
pager position pins `min-width: 11ch`. When you add state, ask what it does
to the layout around it.

**And never put an interactive surface inside a 3D-projected layer**
(`perspective()` / `rotateX/Y`). Safari rasterises a projected, animated
layer once and re-rasterises it on *any* child repaint — a hover fill, a
chip glow, a selected state — so children visibly change size at
unpredictable moments. Three fixes chased individual triggers on the hero
demo before `089afc3` removed the projection itself; the triggers are
unbounded, the projection is the only bounded thing. Tilt is for decorative
surfaces only (`.stl-mock` keeps its). If it's clickable, it's flat.

## 6. Accessibility floor

**Focus.** Every interactive element needs a visible `:focus-visible`. The base
ring is `outline: 2px solid var(--stl-accent); outline-offset: 2px`. 48 rules
exist; roughly 20 interactive components have none, and
`.stl-header__nav-link` **actively sets `outline: none`** (`app.css:900–904`).

**Contrast.** `--stl-text-subtle` is pinned by `tests/text-contrast.test.js`;
per-test tints by `tests/tint-contrast.test.js`. Don't introduce a new grey
without checking it.

**ARIA state must match visual state.** If the CSS keys off `.is-active`, the
markup must still carry the right `aria-pressed` / `aria-checked` /
`aria-selected` — and vice versa. Several components maintain both
independently.

**Screen-reader text.** Two competing utilities exist — `.sr-only` /
`.dc-sr-only` (`5369`) and `.stl-sr-only` (`8449`, added later with a comment
saying the app had none). **Three usages total across the entire app, all in
`index.html`. Zero in the 25k lines of `app.js`-rendered markup.**

**Motion.** Add a `prefers-reduced-motion` block whenever you add motion.

**Touch.** Mobile controls get `min-height: 44px` (`10184`). `@media
(hover: none)` blocks neutralise hover transforms — add one for any new
hover-lift.

---

## 7. Conventions checklist — before you commit

- [ ] Colour comes from a token. `rgba()` uses `var(--stl-*-rgb)`, not a literal triplet.
- [ ] Mono font is `var(--stl-font-mono)`, not the spelled-out stack (it's spelled out 30+ times — don't add the 31st).
- [ ] Spacing uses `--gap-*` intent tokens inside cards, `--s-*` elsewhere.
- [ ] Radius uses `--r-*`. Pills use `--r-pill`.
- [ ] Width: only `.stl-main` sets shell width.
- [ ] The component has `:hover`, `:focus-visible`, `:active`, and `:disabled` where it can be in those states.
- [ ] If it has `display:`, it has a `[hidden]` companion.
- [ ] If it moves, it has a `prefers-reduced-motion` escape.
- [ ] If it has a hover transform, it has an `@media (hover: none)` reset.
- [ ] Selected state changes colour and fill, never border weight.
- [ ] Light mode checked — or the component is 100% token-driven so it flips for free.
- [ ] Anything naming the signal is added to `tests/signal-vocabulary.test.js` **in the same commit**.
- [ ] `cd web && npm test` green **at deploy time** (all files; 2 intentional skips: `archive`, `svg-render`).
- [ ] `?v=` cache tag bumped in all four places, as the last edit before deploy.
- [ ] Lane claimed in `LANES.md`; `git status --short` shows only your files.

---

## 8. Update protocol

This file is the written reference; `styleguide.html` is the living one. When
you add or change a component:

1. Update the relevant section here.
2. Add the component to `styleguide.html` so it renders from the real CSS.
3. If you closed a gap, strike it in `GAPS.md`.
4. Note it in the Notion **Activity log**; if it's a "why", also **Decisions**.

If this file disagrees with `app.css`, `app.css` is what ships — fix this file
and say so in the Activity log.
