StudySignal design system

Rendered from the real app.css and signal-theme.css — not a copy. If a component looks wrong here it is wrong in the app. Written reference: STYLEGUIDE.md. Gaps and the remediation plan: GAPS.md. Live at studysignal.ai/design/styleguide.

good — copy this known gap dead / duplicated

Colour app.css:13–285

Live values, read from the running stylesheet. Toggle the theme (bottom right) and watch which swatches move — anything that doesn't move in a component is hardcoded.

Surfaces

Text — with live contrast against --stl-bg-2

TokenValueContrastSample

--stl-text-subtle was #6b7288 until 2026-08-17 — 4.01:1, below the AA floor, across 138 uses. Pinned now by tests/text-contrast.test.js. --stl-text-faint is decorative only; it is not rated for body copy.

Signal — the product's core palette

The mid-tones are for dots, chips and borders. The -text siblings are for large numerals and stat labels — the mid-tones visibly recede above ~40px. The -rgb triplets exist for rgba() composition and are used at six lines in the whole stylesheet while ~150 sites hardcode the same value.

Accent & rainbow

--stl-rainbow

signal-theme.css retunes this

app.css declares a lavender accent; signal-theme.css overrides it to the green→yellow→red ramp and wins by load order, not specificity. So var(--stl-accent) renders green today — check that before using it on a destructive surface (it lit up .stl-btn--danger green once). Anything hardcoding #8b86ff did not follow and is stale by definition.

Typography app.css:18–21

Geist for body, Space Grotesk for display, Geist Mono for labels and figures. Base is 15px / 1.55 / -0.01em on .stl-app.

Faces

--stl-font-*

The signal shows what you guessed

Body — Geist. Practice with a green/yellow/red confidence signal on every question, so you can see where you got it right by luck, not only where you got it wrong.

Label — Geist Mono, 11px, .08em, uppercase

Figures — tabular: 1234567890 · 0.00 · 1,240

Plus one special-purpose face: Caveat (handwriting) for marketing annotations only — loaded as a glyph subset (text= param, ~3KB) for the hero’s “Try it out live”. Never for UI text.

There is no type scale. Sizes are raw across the codebase — 9, 9.5, 10.5, 11, 12.5, 13, 13.5, 14, 15, 15.5px and 1.25rem, plus eight competing clamp() overrides on .stl-h1 alone. Also: --stl-font-mono is declared and then spelled out inline 30+ times. Use the token.

Spacing & radius app.css:109–140

Intent tokens — prefer these inside a card

Same kind of thing? --gap-tight. Same area, different parts? --gap-normal. Distinctly separate areas? --gap-section.

Raw steps

Radius

gap The button radius is 9px — between --r-sm and --r-md, with no token. Pills are written as both 999px and 9999px; --r-pill is canonical.

Width & measure app.css:141–175

The rule: only .stl-main sets page width. Inner elements may cap measure (in ch) but never shell (in px). Overlays that float above the page are the one exemption.

ShellWidthScreens
--shell-focus440pxauth cards, confirm dialogs, single-purpose panels
--shell-read760pxpicker, quiz, results, review, score, history
--shell-work1000pxteacher — panels plus a side rail
--shell-data1280pxadmin — tables and database views
MeasureWidthFor
--measure-title18chh1
--measure-lede56chsubheads, intro paragraphs
--measure-body44chbody copy, notes
--measure-meta30chcaptions, truncated labels

Motion app.css:176–187

TokenValueFor
--ease-outcubic-bezier(.16, 1, .3, 1)screen transitions
--ease-springcubic-bezier(.2, .6, .2, 1)hover states
--dur-fast120ms:active rebound, removals
--dur-base200msmost hover transitions
--dur-slow280msdecorative reveals

23 prefers-reduced-motion blocks exist. gap Eleven animations have no escape — including .stl-dot-fx, which fires on the single most-repeated interaction in the product. Add a block whenever you add motion.

Signal vocabulary tests/signal-vocabulary.test.js

Pinned by test, not by taste. Change a word and the suite fails. The phrase leads; the colour is the subtext. Never inverted.

Orderdata-signalPhraseSubtext
1greenKnow itGreen
2yellowMaybeYellow
3redDon’t knowRed

The apostrophe is U+2019 and it is pinned. ’ in HTML, ’ in JS strings. Bare ASCII fails. Four different apostrophes were live in the same two words at once on 2026-08-17.

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

Seven surfaces render these states. Anything user-visible that names the signal goes into the test in the same commit — a partial invariant test converts “we haven’t checked” into “we verified this.”

Signal control app.css:4144–4232

.stl-signal reference pattern

index.html:1199

How sure are you? Picking one locks in your answer.

The full shape, copied from the quiz: a <footer class="stl-quiz__signal"> wrapper, the help line above the buttons (it is the question; the buttons are the answer), and role="group" aria-label="Confidence" on the grid. Help copy is “How sure are you? Picking one locks in your answer.” — its .is-error state swaps in red, and the min-height reserves the room so the swap doesn’t reflow.

Structure is always chip + lbl + sub — three children. The mobile grid places them by name (grid-template-areas: "chip lbl sub"), so dropping __sub silently collapses the mobile row. One shipped exception, pinned by test: the home-page hero demo keeps all three children but suppresses the colour word with .stl-how__art .stl-signal__sub { display:none } — CSS-level, never markup-level. The hero also uses data-demo-signal where the quiz uses data-signal.

Copy the chip. It is a 12px circle filled with currentColor, glowing at 0 0 12px currentColor. All colour flows from the one --green/--yellow/--red modifier. That is the cleanest token usage in the stylesheet.

gap The per-colour hover halos hardcode their rgba() and do not flip in light mode. The click animation has no reduced-motion guard. The transition rule and the @media (hover:none) block are each declared twice.

Hero live demo app.css:12437–12551 · index.html:1697+

Since v2 (2026-08-18) the home-page hero is not a picture of the product — it is the product: a playable question with four answers, the three confidence buttons, a verdict, and a reset. Not re-rendered here because it is a one-off composition scoped under .stl-how__art, not a reusable component — see it live on the home page.

What to know before touching it

.stl-how__art .stl-demo

It reuses the real primitives with scoped overrides: .stl-choice as plain <button>s (states .is-demo-correct / .is-demo-wrong), and the real .stl-signal with data-demo-signal and the colour word suppressed — .stl-how__art .stl-signal__sub { display:none }, pinned by signal-vocabulary.test.js. The card drifts on stlMockFloat, pauses on hover and :focus-within (a pattern worth copying), and has a reduced-motion escape. Three floating pills (.stl-float--g/--y/--r) retire when the verdict shows.

gap Two ID-coupled selectors do component work here (#how-demo-reset, :has(#how-demo-result:not([hidden]))) — same pattern GAPS.md already flags. Every .stl-demo value is now declared twice: base rules mid-file, hero overrides ~250 lines later.

Answer choice app.css:3535–3648

.stl-choice

app.js:9383

In the live quiz the only state the renderer ever applies is .is-selected; correctness appears in review via [data-state] on the <li>. A third form ships on the home-page hero: .stl-choice on a plain <button> (no radio) with .is-demo-correct / .is-demo-wrong. Three vocabularies for one idea — see GAPS.md.

gap No :focus-visible at all. The component relies on :focus-within with a 2px solid var(--stl-accent-soft) ring — 12%-alpha, effectively invisible. This is the weakest focus indicator in the product. .is-correct also hardcodes #22c55e, a fourth green — the palette is one green per theme, spelled as a token.

Difficulty tier app.css:1907–2143, 14953+

.stl-tier state-coverage bar

app.js:5174

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.

Note the selection signals with colour and fill only — never a heavier edge (decision 2026-08-07: an outset ring reads as a 2px border and, because it paints outside the border box, nudges anything laid out against it).

The fifth tile is Tailored — --tailored, rendered as role="radio" (the four buckets are checkboxes) with data-tier-mode="tailored", deliberately not data-tier. Selected, it takes the rainbow gradient-border treatment; the signal dots it once carried were removed (design call — cleaner without them). When Tailored is on, all four bucket tiles render unselected on purpose.

gap ARIA and CSS are separate code paths: the markup renders aria-checked, the CSS styles .is-active. They can drift silently.

Outcomes app.js:11091 · app.css:2759–2851

Signal × correctness. The chip carries the colour, so the label must not repeat the colour word.

.stl-pill[data-result]

fully light-themed
Mastery Misconception Lucky Gap Fluke Cold

mastery Know it + correct · misconception Know it + wrong · lucky Maybe + correct · gap Maybe + wrong · fluke Don’t know + correct · cold Don’t know + wrong.

drift The same six outcomes render on the same screen in two different palettes — .stl-results__meter-seg--* uses the tokens, .stl-review-tab__dot--* hardcodes #34d399 / #f87171 / #fbbf24. The gap amber (#f59e0b) has no token at all.

Button app.css:4964–5320

Hierarchy

one --primary per screen

Sizes & shapes

--sm / --hero / --pill-rainbow

gap --sm sets 13px at line 2974; the mobile query at 10184 then sets min-height:44px; font-size:14px on every .stl-btn with no opt-out. So --sm is silently full-size on mobile. (--dev opts out; --sm never did.) Resize this page below 640px to see it.

States

tab to see focus

gap Primary/outline/pill have no :disabled of their own, so the base rule restates an inset white line inside the rainbow border. --icon is referenced in the CSS and never defined. __count is defined twice with incompatible treatments. --danger and --dev hardcode reds that duplicate --stl-red-rgb.

Action pair app.css:1725–1775

The back-button contract: back is an equal-width sibling of the primary action. Equal footprint, unequal fill.

Pair — one primary action

.stl-actionpair

Solo — zero or 2+ actions

.stl-actionpair--solo

Back navigation is hierarchical, not historical. Every screen declares one parent in SCREEN_PARENT, 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).

A third form exists: --stack (one column, primary first, back beneath) — the only arrangement where the primary leads.

This replaced 29 back affordances across 19 screens using 17 labels, 3 icon systems, 6 positions and 6 visual weights. gap Teacher, admin, learn and the signal trainer were out of scope and are still inconsistent — five back affordances remain app-wide.

Card app.css:1214–1243

.stl-card / --hero

--hero drops all chrome ≤640px

Plain card

Surface, hairline, --r-lg, clamped padding.

Hero card

Adds a 165° slate gradient and a three-layer shadow. The screen’s main container.

gap --hero has a light-theme shadow swap; plain .stl-card does not, so its white inset top-highlight is invisible on a white canvas. Toggle the theme and compare the two above.

Field app.css:1502–1544

.stl-field / .stl-label / .stl-input / .stl-hint

index.html:2343

The canonical shape is a <label class="stl-field"> wrapping everything — implicit labelling, so no for/id pairing to keep in sync. The label text is a <span class="stl-label">, the hint a <span class="stl-hint">.

gap No :disabled, no ::placeholder, no [aria-invalid] anywhere in the app — the error hint above is not wired to the input. .stl-input:hover sets rgba(255,255,255,.18) with no light override, so the border disappears on hover in light mode — toggle and hover to see it.

fork Admin runs a second, incompatible system (.stl-admin__input, 40px at 13.5px vs ~48px at 1.25rem here) with 72 controls and zero overlap — plus a second label system 1px different from .stl-label.

Switch app.css:3350–3409

.stl-switch

app.js:19915

Styles correctly off [aria-checked] rather than a class — the pattern the tiers and history chips should follow. gap No :hover, no :active, no :disabled, no aria-checked="mixed", and the thumb’s overshoot easing has no reduced-motion escape.

Pill & badge app.css:2651–2851

.stl-pill

best-themed component in the file
Neutral Easy Medium Hard Insane 2:14

Non-interactive, <span> only. Both data-attribute families have full light-theme overrides — one of very few components that do. drift It is not a generic badge: roughly eight badge/chip families exist, five of them painting the same live / needs-review / unpublished / archived concept in five different palettes.

Tabs app.css:11216–11331

.stl-tabbar use this one

index.html:2495

Each tab pairs with a .stl-tabbar__pane (role="tabpanel", tabindex="0") carrying its own .stl-tabbar__veil loading overlay — warm tabs never flash a spinner, cold ones never paint empty.

×6 Six tab implementations exist. .stl-tabbar, .stl-admin__tab and .stl-teacher__tab (dead) are byte-comparable ~55-line copies; .stl-review-tab, .stl-radar__sw-tab and .stl-add-image__tab are pill/segmented. Three of the six signal selection with .is-active instead of aria-selected — half the tab strips in the app are invisible to assistive tech. None has arrow-key roving tabindex. The header comment in app.css already says admin migrates onto this one “later.”

Step nav app.css:2575–2647

.stl-stepnav complete states

index.html:1233

Three buttons, one axis. The forward slot holds exactly one button at a time: “Skip this question” on the newest question, “Next” once you’ve gone back onto an answered one — mutually exclusive because handleSkip() writes a skip at the current index, so offering Skip on an answered question would offer to erase it. Back is always present and merely disabled on question 1, never hidden, so the row cannot change width.

Hover, :focus-visible, :disabled (which also transparently kills the underline), and a reduced-motion escape for the chevron. The most complete state set in the quiz.

Loader & toast app.css:8949–9011 · 9939–9973

.stl-loader

one spinner, four selectors

gap There are no skeletons. The loading pattern is a veil over a height-reserved container ([aria-busy="true"] { min-height: 220px }) — sound, but implemented twice identically for admin and teacher. --solid is documented in the CSS and never defined. The ring’s conic stops are a fifth palette, including an orange found nowhere else in the product.

.stl-lock / .is-paylocked

paywall, app.js applyLocks()

A control that costs something wears a lock and opens the upgrade sheet instead of doing its job. Markup declares the capability (data-cap="teacher.send"); one pass (applyLocks()) stamps .is-paylocked from the server’s capability list; one delegated capture-phase handler intercepts the click. Adding a locked button is one attribute.

It is not a disabled button. No reduced opacity, no muted fill, no pointer-events: none, never the disabled attribute — a locked primary keeps its full weight, because it still does something when you click it. “You cannot do this” and “this costs money” are different messages and only one is true. The glyph takes currentColor at reduced emphasis, so no fourth meaning joins green/yellow/red (Rule 1), and it is an inline child, so nothing shifts (the 2026-08-07 ring rule). The price reaches a screen reader through an .sr-only suffix inside the button’s own accessible name.

The class is .is-paylocked, not .is-locked — that one was already taken by the quiz grid-in input, where it means “answer committed”.

.stl-toast

142 call sites, one variant
Link copied

gap The app’s only transient feedback channel, and it has one variant — so 'Note save failed (HTTP 500)' and 'Link copied' render identically. No tones, no icon, no stacking, no dismiss, no undo, no reduced-motion guard.

Placement & composition STYLEGUIDE.md §5

Where things go, not how they look — placement drifts faster than styling (29 back affordances was a placement problem). The short version; full contracts in STYLEGUIDE.md §5.

RuleContract
Back buttonsFoot of the screen, equal-width ghost sibling of the primary (.stl-actionpair). Hierarchical via SCREEN_PARENT + [data-back]; labels written by applyBackLabels() — never hardcoded. Mobile adds the top-bar chevron. An action bar holds document actions; navigation lives top-left, one per screen.
ActionsOne --primary per screen. A second primary means one of them isn’t.
Question before answerHelper text that asks sits above its controls (the signal help line); hints that explain sit below their input.
WidthOnly .stl-main sets shell width; measure in ch, overlays exempt.
Overlays & toastsModals via showAdminModal (portal + trap + scroll lock + focus return built in). Floating panels are frosted glass — var(--stl-panel) (~92% tint) + backdrop-filter: blur(14px), never a bare --stl-surface-* translucency. Toasts bottom-centre, one at a time, toast(msg, tone).
Mobile ≤640Header hides; per-screen .stl-mobile-bar; hero cards full-bleed; CTAs in the thumb zone; ≥44px targets; safe-area insets.
Stable geometryState change never resizes or moves neighbours: disabled-not-hidden Back, one forward button, reserved min-height/min-width on swappable text. No 0-blur rings on hover/selection (reads as a border-weight jump). No interactive surface inside a 3D-projected layer — Safari re-rasters projected layers on any child repaint, so contents visibly change size. Clickable = flat.

What we don’t have GAPS.md

Nothing rendered below, because none of it exists. Full detail and the four-phase plan are in GAPS.md.

MissingWhat happens instead
Type scaleRaw sizes, 11 distinct values, 8 competing h1 clamps
Shared field systemTwo forked systems, ~20px height delta, 72 admin controls with zero overlap
Validation statesZero [aria-invalid], ::placeholder, :invalid, :checked app-wide
Empty state10 bespoke, 4 border treatments, 5 paddings
Table base7 systems + 1 grid pseudo-table; no zebra anywhere
Badge base~8 families; 5 palettes for one status concept
Toast tonesErrors and confirmations look identical
Focus trap / scroll lock1 trap, 2 lock sites, 8 Escape handlers, no overlay stack
Accessible progress5 bars, zero role="progressbar"
SkeletonsVeil over a height-reserved container (×2 implementations)
Breadcrumbs5 different back affordances
TooltipOne, admin-only, hover-only, no aria-describedby
Popover base3 hand-rolled; no Popover API, no anchor positioning
AvatarMobile only, 34px, initial-only, no sizes
Accordion / alert / segmented baseBespoke; segmented control exists 3 ways
z-index / elevation / icon scalesHand-assigned: 50 · 60 · 120 · 200 · 300 · 1500 · 9999
Light theme for admin & teacherAdmin: 8 spot fixes. Teacher: zero (its one rule is for dead CSS)

Before you commit

  • Colour comes from a token. rgba() uses var(--stl-*-rgb), never a literal triplet.
  • Mono font is var(--stl-font-mono) — it is spelled out inline 30+ times already; don’t add the 31st.
  • Spacing uses --gap-* intent tokens inside cards, --s-* elsewhere. Radius uses --r-*.
  • Only .stl-main sets shell width. Inner elements cap measure in ch.
  • 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 lifts on hover, 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 passing (99+ files; 2 intentional skips: archive, svg-render).
  • ?v= cache tag bumped in all four places, as the last edit before the deploy.
  • Lane claimed in LANES.md; git status --short shows only your files.