`tokens.md` freezes the palette as prose and three packages re-express it
as code -- `web_ui/src/tokens.css`, `design_system`'s `tokens.dart`, and
gatelock's `LockConfig`. Nothing compared them, so "one palette
everywhere" held only for as long as four hand-maintained copies happened
to agree. A hex edited in one stack drifted silently in the other three.
`palette_check.py` parses all four and runs two halves:
* agreement -- every mapped token carries the same hex in every
non-exempt stack
* completeness -- every colour parsed from any source must be mapped or
exempted with a stated reason
The second half is the load-bearing one. A pure agreement check passes
happily when someone adds a token to one stack and never touches the map,
which is the drift most likely to actually happen; an unmapped token is
therefore a failure, not a skip. Absences are declared per token per stack
with a reason (Tk has no light theme and no borders; the categorical ramp
is web-only) so "this stack doesn't have it" is on the record rather than
a silent pass.
It found one real gap on its first run: `tokens.css` sets `--surface-2` to
`paper-raised` in the light block -- the light theme collapses both
elevation steps onto one surface -- which `tokens.md` documented only in
prose and no map covered. Now an explicitly compared row, so if either
half of that pair moves, it fails.
Proven to fail, not just to pass: drift injected into each of the four
sources in turn, plus an unmapped token, was caught every time with the
offending stack named; re-proven after the 250-line-cap split into
`palette_map.py`. Runs from its own workflow with deliberately no `paths:`
filter -- drift is a disagreement *between* packages, so a path-scoped
trigger would skip exactly the edit that causes it. That was not
hypothetical: `ramp_check.py` was reachable only from the
`web_ui/**`-filtered workflow, so a ramp edit in `tokens.md` or
`design_system` never ran it. It moves here too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
13 KiB
Unified design tokens (frozen values)
Concrete instantiation of safe-design-rules' 28 rules. One identical
palette across every repo/stack (Flutter, web, Python/Tkinter) — not just
shared rules, the same hex values everywhere, including Flutter launcher-icon
glyphs (app-icon skill).
This table is checked, not trusted.
scripts/palette_check.pyparses all four token sources — this file,web_ui/src/tokens.css,design_system/lib/src/tokens.dartand gatelock'sLockConfig— and fails on any hex that disagrees across them, including a hex edited only here. It also fails on a colour token that no stack's map accounts for, so adding a token to one stack and forgetting the others is a CI failure rather than silent drift. Runs in.github/workflows/palette-drift.yml, deliberately with nopaths:filter: drift is a disagreement between packages, so a path-scoped trigger would skip exactly the edit that causes it. Editing any value below means editing it in all four places. Anchored on the shared icon charcoal#211D1Bso the UI palette and the icon family cohere instead of adding a second "near-black." Warm-tinted throughout (rule 10) — superseded from an earlier cool-tinted draft; every neutral is a byte-reversed (RRGGBB→BBGGRR) mirror of that draft's value, so relative lightness/contrast steps are unchanged, only the tint shifts from blue toward red/brown.
Neutrals
| Token | Hex | Notes |
|---|---|---|
ink |
#211D1B |
Dark bg. Same value as the icon charcoal. |
ink-raised-1 |
#2B2624 |
Dark elevated surface, step 1. |
ink-raised-2 |
#38312E |
Dark elevated surface, step 2 (≤12% luma vs bg). |
line-dark |
#463E3A |
Border on dark surfaces. |
text-on-dark |
#ECEAE9 |
Primary text on dark. Near-white, not pure. |
muted-on-dark |
#AAA09A |
Secondary/caption text on dark. |
paper |
#F6F4F3 |
Light bg. |
paper-raised |
#FCFBFB |
Light elevated surface — lighter than bg (rule 15). |
line-light |
#E0DAD7 |
Border on light surfaces (≥3:1 vs bg and card). |
text-on-light |
#211D1B |
Reuses ink — deliberate symmetry. |
muted-on-light |
#70625B |
Secondary/caption text on light. |
Accent + semantic roles
One set, used identically everywhere — UI, icons, everything. All four now cluster in the red-orange-yellow-olive wedge, distinguished mainly by lightness (danger 130 < accent 143 < success ~150 < warning 171 — a flagged, deliberate tradeoff, not an oversight): a colorblind/at-a-glance distinguishing risk the user accepted over keeping green/red as a semantic exception.
| Token | Hex |
|---|---|
accent |
#B8862E |
info |
#B8862E (= accent) |
success |
#8A9A3C |
warning |
#E0A63C |
danger |
#E2585F |
on-fill |
#211D1B (= ink) |
on-fill: text/icon color for anything drawn on top of a filled
accent/success/warning/danger surface (a primary button, a filled badge) —
never text-on-dark. All four fill colors sit in the same mid-light
lightness band (that's the accent/success/warning/danger clustering noted
above), so near-white text under-contrasts on every one of them (measured
2.2:1–3.6:1 against WCAG's 4.5:1 body-text floor); ink text passes on all
four (5.8:1–9.7:1). This is a closed rule, not a per-color judgment call —
never pick text color per fill, always on-fill.
Do not confuse on-fill with text on a scrim/overlay (a dark
semi-transparent backdrop behind a modal or a translucent control chip over
an image) — those stay text-on-dark (near-white), fixed regardless of
page theme, since the scrim itself is always dark. on-fill only applies to
the four opaque semantic fills above.
Categorical ramp
For charts, tags and category dots — anything that needs N mutually distinguishable colours with no inherent meaning. Never for semantic state: success/warning/danger still own that, and mixing the two vocabularies is what makes a "red" category read as an error.
| Token | Hex | L* |
|---|---|---|
cat-1 |
#C57293 |
58 |
cat-2 |
#398FC0 |
56 |
cat-3 |
#C85A32 |
52 |
cat-4 |
#228736 |
49 |
cat-5 |
#8D58BB |
47 |
cat-6 |
#686D2C |
44 |
This ramp deliberately leaves the warm wedge, and it is the only token group that does. Accent/success/warning/danger cluster in red-orange-yellow-olive and are separated mainly by lightness — a tradeoff that works when meaning carries the signal. A categorical ramp has no meaning to lean on, so hue separation is the requirement. Treating this as drift and "correcting" it back toward the wedge would destroy the only property it has.
Chosen by search under four hard constraints, not by eye
(unified-design-system/scripts/ramp_check.py re-runs the proof):
- Pairwise CIE ΔE ≥ 20 between every pair — measured under normal vision and simulated deuteranopia, protanopia and tritanopia. Worst observed: 34.6 normal, 26.0 deuteranopia, 25.6 protanopia, 27.4 tritanopia.
- ≥ 3:1 contrast against both backgrounds (
inkandpaper), since the ramp is theme-independent — it does not flip withprefers-color-scheme. This is the binding constraint: colours legible on both a near-black and a near-white field only exist in L* ≈ 44–59, which is why the ramp is mid-toned throughout rather than spanning light to dark. - Monotonically decreasing lightness (58 → 44), so hue and lightness encode
redundantly. The ramp therefore still reads as an ordinal scale in greyscale
or under severe CVD, which is what lets
cat-1…6carry an ordered scale (grades A→F) as well as an unordered one. - Muted saturation, to sit with the rest of the palette rather than vibrating against it.
Use them in order for ordered data, and by position for unordered data — never pick "the green one" because green means good, which is the semantic vocabulary leaking back in. Need a seventh category? That is a signal to group the tail into "other", not to extend the ramp: a 7th hue cannot clear ΔE 20 against all six under CVD within the L* 44–59 band.
Spacing scale (4px base)
xs 4 · sm 8 · md 16 · lg 24 · xl 32 · xxl 48
Every spacing/sizing value in a file should land on this scale. Round outliers to the nearest step rather than introducing a new one.
Corner radius scale
sm 8 (buttons, inputs, chips) · md 12 (cards) · lg 16 (sheets, dialogs).
Nesting (rule 24): an inner element's radius = outer radius − the gap between
inner and outer edges, computed per instance — not a fixed constant. E.g. an
lg (16px) container with 4px of padding before its child starts should give
that child a 12px radius, not reuse 16 or invent an unrelated value.
Typography scale
| Role | Size (px) | Tracking |
|---|---|---|
display |
32 | tight (e.g. −0.2px) |
title |
24 | tight (e.g. −0.1px) |
subtitle |
20 | default |
body |
16 | default — the floor (rule 20) |
label |
14 | loose (+0.05–0.1em), chrome only |
caption |
12 | loose (+0.1–0.2em), chrome only |
body (16px) is the minimum for anything a user actually reads.
label/caption are for UI chrome (timestamps, badges, tags) — never for
primary reading content.
Two typefaces max (rule 23): one system sans stack for all UI text
(-apple-system, "Segoe UI", Roboto, system-ui, sans-serif on web; platform
default on Flutter/Tk), one monospace stack reserved for code/data-dense
contexts only.
Line length (rule 21): prose/paragraph blocks capped at max-width: 40rem
(~640px, ~65–70 characters).
The sizes above are pixels. On Tk that means a negative number.
Tk encodes the unit in the sign of the font size: positive = points,
negative = pixels. So ("Arial", 16) is 16 points, not 16 pixels, and
renders ~37% larger than this scale specifies. Measured:
| Spec | size=16 (points) |
size=-16 (pixels) |
|---|---|---|
linespace |
26px | 19px |
ascent |
21px | 15px |
Always write the negative form in Tk — font=(family, -16) for body. Passing
the positive px value straight through inflates every string in the app by a
third, which is enough on its own to push a 768px-tall layout off-screen (it
did: it was the root cause of the diet-guard meal gate needing 974px in a 724px
pane). This is a silent failure — nothing errors, the text is just wrong-sized —
so it is checked by the gate rather than left to review.
Flutter fontSize and CSS px are already pixels; no sign convention there.
Buttons
Horizontal padding = 2× vertical (rule 22). Canonical: vertical 12px,
horizontal 24px (both on the spacing scale: 12 = 3×4, 24 = 6×4).
Shadow policy (rules 16, 26, 27)
Pick exactly one depth technique per surface — never a shadow and a lighter fill on the same element, never a border-only style next to a shadowed one at the same z-level.
- Dark surfaces: no shadows, ever. Elevation is
ink-raised-1/-2fill steps only. - Light surfaces: shadows allowed only on floating/overlay elements
(dropdown menus, modal dialogs) — never on inline cards or list rows.
Two tiers, blur = 2× offset, tinted from
ink(never pure black):- Level 1 (e.g. a dropdown):
0 2px 4px rgba(33, 29, 27, 0.12) - Level 2 (e.g. a modal):
0 4px 8px rgba(33, 29, 27, 0.16)
- Level 1 (e.g. a dropdown):
Icon-next-to-text contrast (rule 28)
Icons adjacent to text render at ~72% opacity of the text's color, or use the
muted role instead of the full-strength text role — never equal contrast.
Pointer-free operability (required)
Every action must be reachable and activatable with the keyboard alone. No mouse, no touchpad, no touch. This is not an accessibility nicety here: several of these apps are lockers that hold a global input grab with VT switching disabled, so a control only a pointer can reach is not an inconvenience — it is a bricked machine.
Six things must hold on every surface:
- No pointer-only handler. Any
onTap/onLongPress/<Button-1>/click handler needs a keyboard-reachable equivalent on the same action. A bareGestureDetectorand anonLongPress-only destructive action are both defects. Long-press in particular has no keyboard analogue — never make it the only path to an action. - Everything interactive is a focus stop. Widgets that opt out of the focus
ring (
takefocus=0, which Tk'sMenubutton/OptionMenudefault to) are unreachable. Verify by walking the real ring, not by reading the code. - Focus is visible. See the focus-ring token below — the platform default
is a black ring, which vanishes on
ink. - Focus never lands somewhere invisible. If a container scrolls, focus entering a clipped child must scroll it into view. Clipping does not remove a widget from the focus ring, so this is the default failure, not an edge case.
- Scrolling is keyboard-driven. A scroll container needs
PageUp/PageDownand arrow keys, not just a draggable thumb and a wheel. - Confirm/submit and cancel/back have accelerators.
Entersubmits,Escapecancels. On Tk, noteEnterdoes not activate atk.Buttonon X11 (onlySpacedoes) and is bound to nothing onEntry/Spinbox— you must bind it.
The focus-ring token
| Token | Value | Notes |
|---|---|---|
focus-ring |
#B8862E (= accent) |
2px, offset 1px. |
Platform defaults are wrong on this palette: Tk ships
highlightcolor="#000000" with highlightthickness=1, i.e. a black ring on
ink (#211D1B) — invisible. Set focus-ring explicitly. Never
highlightthickness=0 on something focusable.
⚠️ On Tk, highlightbackground is the unfocused ring and highlightcolor
is the focused one. Setting only highlightbackground inverts the
affordance — the widget outlines when it is not focused and goes black when it
is. Set highlightcolor.
Escape hatches vs. deliberate gating
Some apps intentionally resist dismissal (an alarm you must solve a challenge to silence, a meal gate you must log a meal to clear). That gating is a feature and this rule does not override it. What the rule requires is that the sanctioned paths — solve the challenge, use the budgeted escape hatch, submit the form — are fully keyboard-operable. Making a gate keyboard-reachable is not weakening it.
Screen size (required)
Moved to screen-size.md (250-line cap): the 1366x768 / 1024x600 targets, the
height-budgeting rules, and why centring unscrollable content clips
symmetrically. Still part of this spec — read it for any layout work.