Theme & Design Tokens โ
This project uses a small design system built with CSS custom properties to provide consistent spacing, colors, and components.
Palettes โ
warm: amber accents (default)cool: teal/cyan accents
How it works โ
- Accent palettes are switched by setting
data-themeon<html>towarmorcool.
Available tokens โ
- Colors:
--bg,--surface,--text,--muted,--accent-500, etc. - Layout:
--space-1,--space-2,--space-3,--radius - Utilities:
.btn,.btn-primary,.card,.muted,.theme-toggle
Usage โ
- Use
var(--accent-500)for primary accent color in components. - Use
.btn-primaryfor call-to-action buttons.
These tokens are defined in apps/web/src/styles.css. Storybook: run npm run storybook to open a live component/style explorer at http://localhost:6006. A snapshot test exists (see apps/web/src/__tests__/StyleGuide.test.jsx); run tests with npm test (Vitest).
Colour rules (each one exists because something shipped broken) โ
These are enforced, not preferences. The reason matters more than the rule: without it someone reasonably converges back the other way.
1. Zinc is the neutral scale. neutral-* is drift, and it is unverifiable. โ
Use zinc-* (and the --muted / --bg / --surface tokens, which already are zinc). Never neutral-*.
This is a testability constraint, not taste. axe-core cannot parse oklch(... none), and Tailwind v4 emits that none hue for achromatic colours, which is exactly the neutral-* scale. When axe cannot parse a colour it reports the element as incomplete, which never fails a build. So any text on a neutral-* surface is invisible to the contrast gate: it reports success on a check it never performed.
Same markup, only the card colour differing, measured 2026-08-14:
| Background | axe result |
|---|---|
bg-neutral-900 | incomplete, build passes |
bg-zinc-900 | violation at 1.7:1, build fails |
zinc-* carries a real hue number and parses fine. See #878.
2. A token that has to move in two directions is two tokens. โ
If a value serves both a fill (white text on it) and a text colour (it, on a dark surface), those pull opposite ways: lighten it for readable text and the fill's white text fails; darken it for the fill and the text fails. Neither value can satisfy both, so no amount of tuning fixes it. Split the token.
--danger: #e11d48 is the live example: white on it as a .btn-danger fill is 4.70 (passes), while the same value as text on --surface is 3.77 and inside a role badge 3.40 (both fail). One name, two jobs.
The contract: --danger-text / --success-text for color:, --danger / --success for background: and border-color:. Anchor the property name when sweeping this, because CSS property names are suffixes of each other: a match on color: var(--danger); also rewrites border-color: var(--danger);, which is how three borders briefly became pink rings around red fills in the very commit that wrote this contract.
This smell generalises past colour. When a single value has to move in two directions at once, the bug is that one name is serving two meanings.
3. Know which contrast bar actually applies. โ
- Normal text: 4.5:1. Large text: 3:1. Non-text (icons, borders that carry meaning): 3:1.
- Large text starts at 24px, or 18.66px when bold. The trap:
text-lg font-boldis 18px bold, which sits two thirds of a pixel under the cutoff, so it needs the full 4.5, not 3. That is precisely what made six lantern names fail while looking like headings. - Measure against the darkest ground the element renders on.
bg-zinc-950flatters by roughly 10% versusbg-zinc-900.
4. Verify rendered, not source. โ
A class can be syntactically perfect and never paint. checked:bg-amber-500 on a native <input type="checkbox"> cannot apply, because the browser draws its own box, so those checkboxes rendered browser-default blue in an otherwise-amber modal and every static check passed. Use accent-<colour> for native controls.
Accept a colour or layout change on a screenshot or a re-render, never on reading the class. Both of the real UI bugs found on 2026-08-13 looked correct in source.
What enforces this โ
The natural assumption is that the a11y gate covers contrast and a test would be belt-and-braces. It is the other way round. Anyone who assumes the gate has it covered will skip writing the test for the next colour map, so:
A data-driven unit test is the primary defence. apps/web/src/lib/__tests__/lanternNameContrast.test.js checks every lantern-name colour against the real grounds, walking the canonical adjective list so entry 32 is covered the day it lands. Copy this pattern for any colour map (statuses, categories, vibes, activity types).
The axe gate is the backstop. npm run test:stories runs axe with color-contrast gating (tooling/.storybook/preview.js). It is genuinely useful and it has four structural holes, every one of which reports green:
- It only sees what a story renders. A map of 31 colours behind a story that renders one is 30 unmeasured colours.
- Unparseable colours.
oklch(... none), i.e. the wholeneutral-*scale, comes backincomplete. See rule 1 above. - Exactly 1:1 is unfailable. axe reports it as incomplete because it assumes invisible text is a deliberate technique (screen-reader-only content, image replacement). Reasonable in general; catastrophic here. "Onyx Ray" at 1.01:1 was just barely bad enough to be catchable in principle; a hair closer and no gate we could configure would have seen it.
- Layered or composited backgrounds it cannot resolve are indeterminate, so a screen can sit at roughly 4:1 behind a passing story.
incomplete never fails a build, so all four look identical to a clean pass. A green suite is not evidence that a surface is clean; it is evidence that nothing it could measure was bad. The strongest evidence is a render pass that computes the composite itself and reports how many elements it examined.
So the gate catches things that are merely bad, on surfaces it can parse. Only a test catches things that are perfectly invisible.