Color, typography, spacing, radius, shadow, and responsiveness. Sources: packages/ui/theme/colors.css, packages/ui/src/internal/, speedway app/styles/app.css. Full registers: kit-inventory.md and shell-nav-styles-inventory.md §3 under docs/evidence/20260807-0230-speedway-design-language/ in this repo.
Audience: an agent (or person) picking color, type, spacing, radius, or shadow.
§A Principles and mental models#
1. Three token layers (the load-bearing rule).
Palette ramps (Layer 0) → semantic slots (Layer 1) → app aliases (Layer 2). Ramps are full OKLCH 50-950 scales per hue family (slate chrome, blue brand, mint accent, green/amber/red/sky/violet status), "one ramp, not anchors with filler" (colors.css:14-21). Semantic slots are defined once per theme (versable-light/versable-dark): color roles, shadow ladder, radius scale, field dials, status soft/line pairs, chrome tokens, identity tokens. App aliases point INTO the semantic layer only. The dark-mode bug this prevents is documented at speedway app.css:50-59: palette values do not know what theme they are in. Only the semantic layer flips.
1a. An app's own tokens alias the kit's ramp, never a value of their own.
The standard shape is walmart's @theme static block: every app token name
(--color-paper, --color-ink, --color-signal-warn) points at a kit variable
(var(--p-canvas), var(--p-red-600)), so an app can keep its vocabulary while
the values cannot diverge from the kit's (walmart frontend/src/index.css:12-38,
under the owner ruling that "the newer kit look wins everywhere"). Speedway's
transitional aliases in app.css:11-48 are the same shape.
1b. App-level type overrides stay off the bare utility names the kit uses.
Kit components use plain Tailwind utilities (text-2xl, text-sm), so an app
that redefines the type scale in its own @theme re-sizes every kit component
with it: walmart's override rendered PageTitle at 52px instead of 24px until
it was scoped (frontend/src/index.css:44-51). Extend the scale under new
names, or scope the override to the app's own selectors.
2. Two color vocabularies, interactive and status, plus one for identity.
- Interactive:
SemanticColor= primary | secondary | accent | neutral | info | success | warning | error (internal/variants.ts:2-10), resolved through theSURFACE/BTN_COLORtables, never ad hoc Tailwind classes. - Status: six
StatusKinds (ok/warn/err/info/review/neutral) drawn from the--ok-soft/-linestyle CSS-var pairs, a parallel palette tuned as contrast pairs in both themes. - Identity:
--id-parttype-ink,--id-content-ink, and siblings say WHAT a thing is, "never how it's doing" (colors.css:231-236). Runtime/dynamic identity (a team color, a module color) goes through the accent system:accentStyle()sets--accent-coloron a wrapper andaccentClass.*reads it at fixed opacities (internal/accent.ts).
3. Contrast is measured, then encoded.
Filled controls read white text, so primary/secondary/accent/neutral were each deepened a step (owner round 2026-07-07, colors.css:151-155). Warning joined them on 2026-08-16 (owner ruling, doc 53 U3): white ink on a solid warning surface stepped to amber-600, about 3:1, the UI-component bar, with small text under AA accepted for chips and pills; the earlier dark-ink choice ("white on amber-500 reads 2.05:1") is why the surface moved rather than the ink alone, and soft warning surfaces keep amber ink (colors.css:168-174). Dark-mode muted text uses a 65% color-mix, "the first step clearing 4.5:1 at 12-14px" with the failed candidates recorded (speedway app.css:67-69). New tokens must arrive with their measurement.
4. Scales are short and shared.
Radius: --radius-box: 1rem (cards, modals), --radius-field/selector: 0.5rem (controls). Shadow: one ladder none|xs|sm|md|lg (SurfaceShadow), shared by Card and StatTile. Sizes: xs|sm|md|lg|xl everywhere (Button, Input, Spinner, modal widths). Do not mint new steps.
5. Type: two families, loaded by the consumer.
--font-sans: Inter, --font-mono: JetBrains Mono (kit theme/index.css:78-79). The consumer app loads the font files (speedway: Google Fonts preconnect in root.tsx:46-58, Inter 400-700, JetBrains Mono 400-500). Base is 14px / 1.5 line-height, antialiased. Mono is for identifiers (SKUs, ids, code), never decoration.
6. Theme mechanics.
Default light for everyone. Dark is a per-reader choice persisted in localStorage, stamped pre-paint by an inline script so there is no flash. OS preference is deliberately ignored (owner 2026-07-17). Themed assets swap via CSS visibility classes, not a themed img src. Both html and body carry the paper background so overscroll never flashes white.
7. Responsiveness is structural, not decorative.
Few breakpoints, each changing structure: 1200px (grids narrow, side panels hide), 1024px kit lg (sidebar becomes icon rail), 900px (single column). Content-level adaptation uses flex-wrap and minmax(0,1fr) tracks. Wide tables scroll horizontally inside their card instead of reflowing columns.
8. A tone at less than full ink is a rung on the tint ladder, never a number.
The kit uses a lighter shade of a tone everywhere: a hovered row, a selected row, a soft edge round an unselected segment, a hairline between options, a tone-tinted panel. Measured across packages/ui/src on 2026-08-18 and then aligned in the same batch: washes sit at /5 (the neutral row hover, hover:bg-base-content/5, on Table rows and every SchemaForm row, and the segment hover), /10 (a selected row, page-info's tone panels, a swatched option's hover), /15 (a selected row under the pointer) and 20% (the "shade" hover of outline and ghost buttons, theme/components.css:36-43); neutral edges at /10 (dividers, the button hairline), /15 (a container's edge: checklist, radio card, Select), /20 (a swatch or pill's rim) and /25 (that edge on hover); tone edges at 25% (a soft border, internal/surface.ts, an unselected segment), 50% (hover) and 70% (focus, a warning edge), full ink for a selected, warning or error edge, and for a coloured edge under the pointer one step towards the ink, an 85% mix with base-content (theme/fields.css, .field-edge-error and .field-edge-warning, shared by the field frame, Select and every grouped control), which reads darker in the light theme and lighter in the dark one so the step raises contrast in both. Those are the rungs, and like the type ramp (doc 12 §A1) they are decided here, never typed as a fresh number on a page. Reach for the rung by its role: faint wash /5 for hover on a neutral row, soft wash /10 for a selected row or a tone-tinted surface, strong wash /15 for the selected row under the pointer, shade 20% for a button's hover; hairline /10, edge /15, rim /20, edge on hover /25 for neutral borders; soft edge 25%, edge on hover 50%, edge on focus 70%, full for a tone's border. The accent system (internal/accent.ts, a runtime team or module colour) names the same rungs as bgFaint (6%), bgSoft (12%) and borderSoft (35%): the same roles at color-mix values tuned in 2026-07, so an accented surface and a toned one read as one family. Never invent /8 or /30 because it looked right, and never stack a wash on a wash: the ladder has one rung per role, and a surface that needs two is asking for a different component (a Card, a Chip). Since 2026-08-19 (owner, doc 70 E2) the repo lint nudges: pnpm lint:tokens warns on any opacity suffix on a semantic colour that is not a rung of this ladder or the ink ladder (doc 12 §A9; the sidebar's resting /75, doc 08 §B). A shade that looks wrong is feedback against the ladder, filed on the board, not a fresh number; a deliberate one-off is named as such in the commit. The first run counted 245 off-ladder suffixes already in the tree, which is the audit this nudge exists to drive down, and why it warns rather than fails.
§B Presets#
| Token group | Values | Source |
|---|---|---|
| Chrome | --color-paper/surface/surface-sunk/ink/ink-2/muted/faint/hairline/hairline-strong | colors.css Layer 1 |
| Status pairs | --ok-soft/-line, --warn-soft/-line, --err-soft/-line, --info-soft/-line, --review-soft/-line | colors.css Layer 1 |
| Field dials | --field-border(-hover/-focus), --field-bg(-disabled), --field-shadow | colors.css Layer 1 |
| Identity inks | parttype, normalize, content, attribute, source, scrape, fitment, file (soft/ink/line) | colors.css Layer 1 |
| Radius | box 1rem · field/selector 0.5rem | colors.css Layer 1 |
| Shadows | --shadow-xs/sm/md/lg | colors.css Layer 1 |
| Motion ease | --ease: cubic-bezier(0.16, 1, 0.3, 1) | speedway app.css:45 |
| Kicker idiom | 11px, 600 weight, 0.08em tracking, uppercase, muted | speedway app.css (5 named reuses) |
| Tint ladder | washes /5 faint (the neutral row hover) · /10 soft · /15 strong · 20% shade; neutral edges /10 hairline · /15 edge · /20 rim · /25 hover; tone edges 25% soft · 50% hover · 70% focus · full; a coloured edge on hover is an 85% mix towards the ink (.field-edge-*) | §A8; theme/fields.css (.field-segment), theme/components.css:36-43, internal/accent.ts |
§C Use cases#
- Speedway's Layer 2 aliases (
--paper, --surface, --ink, --muted, --accent, --ok/-tint/-ink, --warn, --danger, --idle,app.css:11-48) are explicitly transitional: "each block still dies when its screen migrates onto kit components." - Light theme is left untouched on principle: "a token nothing touches is a token that cannot regress" (
app.css:57-59). ::selectionuses accent-soft. Checkboxes/radios takeaccent-color: var(--accent).- Link styling is scoped off
.btnand.no-underlineso kit rows and nav links keep their own colors (app.css:115-128). - Consumer checklist: load the fonts, mount both theme blocks, point app tokens at semantic slots, adopt the kicker idiom, and never hand-pick palette steps in component code.