Design tokens¶
Status: Implemented Last reviewed: 2026-04-18 Source of truth:
static/css/base.csslines 27-340 (@layer base { :root { ... } }+ theme overrides)
Scope¶
Catalogue of every CSS custom property (var(--*)) defined in the design system. New templates and CSS must pick from these tokens — never hardcode colors, sizes, shadows, radii, or motion timings.
This file is enumerative. Pick a token, copy the name, use it. If you can't find a token that fits, the answer is to add one to base.css and document it here, not to hardcode a value.
The hard rule¶
Never write a literal #hex, rgb(…), rgba(…), oklch(…), or hardcoded px / rem value in a template style= attribute or component CSS. The only place literal values live is static/css/base.css (token definitions) and Bootstrap-overriding selectors that consume --bs-* variables.
Two narrow exceptions:
- Border widths (1px, 2px) — there is no --border-width-* token yet; literal 1px is acceptable until one is added.
- 0 — 0, 0px, 0rem, none are always fine.
Anything else: use a token, or add one.
How to pick a token¶
Decision order — start at the most semantic, fall back only when nothing fits:
- Semantic surface tokens (
--bg-*,--text-*,--border-*) — these auto-adapt to light/dark mode. Default choice for component backgrounds, text, and borders. - Semantic feature tokens (
--focus-ring-*,--shadow-*,--radius-*,--space-*,--font-size-*,--duration-*,--ease-*) — for non-color properties. - Brand-aliased colors (
--brand-*) — for accent / interactive / branded surfaces. Auto-resolves to teal (default) or blue when[data-brand="blue"]is set on an ancestor. - Status palettes (
--success-*,--warning-*,--error-*,--info-*) — for status-bound visuals (charts, alerts, validation messages). - Direct color primitives (
--neutral-*,--teal-*,--blue-*,--accent-*) — last resort. If you reach for these, ask first whether a semantic token should exist instead.
If you find yourself writing var(--neutral-200) for a border, you should have written var(--border-primary). The semantic tokens are the contract; the primitives are the implementation.
Catalogue¶
Typography (--font-size-*)¶
| Token | Value | Use for |
|---|---|---|
--font-size-xs |
0.6875rem (11px) |
counters, timestamps |
--font-size-sm |
0.75rem (12px) |
badges, table headers |
--font-size-base |
0.8125rem (13px) |
body, tables, sidebar |
--font-size-md |
0.875rem (14px) |
card headers, emphasis |
--font-size-lg |
1rem (16px) |
section titles |
--font-size-xl |
1.125rem (18px) |
page titles |
--font-size-2xl |
1.25rem (20px) |
main headings |
--font-size-3xl |
1.5rem (24px) |
display, KPI secondary |
--font-size-4xl |
1.75rem (28px) |
display, KPI primary |
Spacing (--space-*)¶
--space-0 (0) · --space-1 (4px) · --space-2 (8px) · --space-3 (12px) · --space-4 (16px) · --space-5 (20px) · --space-6 (24px) · --space-8 (32px) · --space-10 (40px) · --space-12 (48px) · --space-16 (64px) · --space-20 (80px) · --space-24 (96px).
Use for margin, padding, gap, and any layout dimension. Never write a literal margin: 12px — write margin: var(--space-3).
Radii (--radius-*)¶
| Token | Value | Use for |
|---|---|---|
--radius-sm |
6px |
small elements, badges |
--radius-md |
8px |
buttons, inputs, default |
--radius-lg |
12px |
cards, modals |
--radius-xl |
16px |
large features |
--radius-2xl |
20px |
hero sections |
--radius-full |
9999px |
pills, avatars |
Shadows (--shadow-*)¶
--shadow-xs · --shadow-sm · --shadow-md · --shadow-lg · --shadow-xl · --shadow-2xl. Use exclusively — never write box-shadow: 0 1px 2px rgba(...) directly. Cards typically use --shadow-sm, modals --shadow-lg or --shadow-xl.
Motion (--duration-*, --ease-*)¶
Durations: --duration-instant (100ms) · --duration-fast (150ms) · --duration-base (200ms) · --duration-slow (300ms) · --duration-slower (500ms).
Easing: --ease-in-out (default) · --ease-out · --ease-in · --ease-bounce.
Compose: transition: opacity var(--duration-fast) var(--ease-out).
Focus¶
--focus-ring-color (defaults to var(--brand-600)) · --focus-ring-width (3px) · --focus-ring-offset (2px) · --focus-ring-shadow · --focus-ring-alpha.
Custom focus styles must compose these. Never hand-roll a focus outline color.
Breakpoints / containers¶
--breakpoint-sm (640px) · --breakpoint-md (768px) · --breakpoint-lg (1024px) · --breakpoint-xl (1280px) · --breakpoint-2xl (1536px). Mirrored as --container-* for max-width. Use these in @media queries instead of literals.
Color primitives — neutral, brand families, status¶
Neutral (text, surfaces, borders): --neutral-50 … --neutral-900 — 9-step grayscale.
Brand families (each 50–950, 11 steps):
- --teal-* — default brand (oklch).
- --blue-* — alternate brand ([data-brand="blue"]) (oklch).
- --accent-* — amber/orange accents (hex).
Status families (each 50–900, 9 steps): --success-*, --warning-*, --error-*, --info-* — all hex.
Hex aliases for chart libs that won't accept oklch: --teal-600-hex (#0d9488), --blue-600-hex (#2563eb).
Brand-aliased semantic colors (--brand-*)¶
--brand-50 … --brand-950 resolve to teal by default, blue when [data-brand="blue"] is on an ancestor. Use these when you want the accent colour to follow the user's brand pick — for buttons, links, focused inputs, branded chips.
Chart palettes¶
- Sequential / categorical:
--chart-color-1…--chart-color-6.--chart-color-1re-aliases to the brand hex automatically. - Named extras:
--chart-cyan-600,--chart-sky-600,--chart-indigo-500,--chart-violet-500,--chart-rose-500,--chart-amber-500. - Dashboard slots:
--chart-1…--chart-8(first three follow brand/info/warning, rest are fixed picks).
Semantic surface tokens — the default for most components¶
These are the tokens you will use 90% of the time. They auto-flip in dark mode without any extra work.
| Token | Light value | Dark value | Use for |
|---|---|---|---|
--bg-primary |
--neutral-50 |
--neutral-900 |
page background |
--bg-secondary |
white |
--neutral-800 |
section / panel surface |
--bg-tertiary |
--neutral-100 |
--neutral-700 |
subtle alt-row, code blocks |
--bg-card |
white |
--neutral-800 |
every card body |
--bg-card-header |
--neutral-100 |
--neutral-700 |
card-header background |
--bg-interactive |
--brand-50 |
--brand-900 |
hover/active brand surfaces |
--bg-interactive-hover |
--brand-100 |
--brand-800 |
hover state on interactive |
--text-primary |
--neutral-800 |
--neutral-100 |
body text |
--text-secondary |
--neutral-600 |
--neutral-300 |
secondary labels, meta |
--text-tertiary |
--neutral-500 |
--neutral-400 |
hints, disabled, captions |
--text-on-brand |
white |
--neutral-950 |
text on a brand-coloured surface |
--text-on-brand-muted |
oklch(1 0 0 / 0.8) |
oklch(0 0 0 / 0.8) |
secondary text on brand |
--border-primary |
--neutral-200 |
--neutral-700 |
default 1px borders |
--border-secondary |
--neutral-300 |
--neutral-600 |
stronger separators |
Brand and theme switching¶
Theme is controlled by data-theme on <html> (or any ancestor):
- data-theme="auto" (default) — follows prefers-color-scheme: dark.
- data-theme="light" — force light.
- data-theme="dark" — force dark.
Brand is controlled by data-brand on the same element:
- (none) — teal (default).
- data-brand="blue" — blue accent across --brand-* aliases.
Implication for components: if you only use semantic surface tokens (--bg-*, --text-*, --border-*, --brand-*), you get dark mode and brand switching for free. If you reach for primitive colours (--teal-600, --neutral-200), you bypass the theming system.
Common substitutions (audit cheatsheet)¶
The 2026-04 audit (see roadmap/backlog/ui-design-system-mechanical-fixes-2026-04.md) found ~52 hardcoded values. Map each to the right token:
| Hardcoded | Replace with |
|---|---|
#0d9488 |
var(--teal-600-hex) if a hex string is required (chart libs); var(--brand-600) for CSS |
rgba(0, 0, 0, 0.05) etc. |
one of --shadow-xs/sm/md/lg/xl/2xl |
80px (avatar/photo size) |
no token exists yet — propose adding --size-avatar-md; otherwise inline 1px style is acceptable |
20px (progress bar height) |
propose --size-progress-height; or use --space-5 if visually identical |
18px, 10px, 3px (icon/spacing) |
nearest --space-* token (--space-4/--space-2/--space-1) — pick the closest, don't introduce new pixel values |
#fff, white for backgrounds |
var(--bg-card) or var(--bg-secondary) (so dark mode works) |
#000, black for text |
var(--text-primary) |
Any literal grey #666 / #999 / etc. |
one of --text-secondary / --text-tertiary / --neutral-* |
Bootstrap bg-success / bg-warning etc. on badges |
badge-status-active / -pending / -inactive / -neutral (see ui/badges.md) |
Known deviations¶
apps/crm/templates/crm/prospect_detail.html:540— uses#0d9488andrgba(0,0,0,0.3)inline. Tracked in the 2026-04 audit backlog.apps/dentists/templates/dentists/dentist_detail.html:17—80pxphoto sizing. No avatar-size token exists yet; deferred until the catalogue grows or this becomes a recurring pattern (currently single-occurrence).apps/finance_workflow/templates/finance_workflow/checklist_list.html:59—20pxprogress bar height. Same: no progress-size token; defer.apps/register/templates/register/assembly_detail.html:158— fallback hex inside avar(). Strip the fallback; the variable is always defined.static/css/register.css,static/css/captable.css— pixel literals for icons / micro-spacing. Replace with--space-*substitutions per the cheatsheet.
Once the audit backlog item lands, this section should empty out. Re-running the design-tokens audit subagent should then return zero violations.
Adding a new token¶
If you genuinely need a value the catalogue doesn't cover:
- Decide which group it belongs to (spacing? colour? motion?). If it doesn't fit any, you probably shouldn't add it.
- Add the definition to
static/css/base.cssinside the matching group, with a comment naming the use case. - If it's a colour or surface, add the dark-mode override in both
[data-theme="dark"]and@media (prefers-color-scheme: dark) [data-theme="auto"]blocks. - Add a row to the relevant table in this file.
- Bump
Last reviewedat the top.
Never add a token "just in case." A token whose only consumer is one component is a hardcoded value with extra steps.