mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 13:47:43 +01:00
Some checks failed
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
design_system tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Markdown across every repo had accumulated a dozen ad-hoc names (refactor_claude_todo.md x23, DESIGN_AUDIT_TODO.md, NEXT_SESSION, HANDOFF, PLAN, SPEC) with nothing distinguishing a live task from a finished record. An audit found docs asserting work was pending that shipped months earlier, and one asserting completion while the work was open. Four namespaces now: README.md, CLAUDE*.md, DOCS*.md, TODO*.md. Task files carry the literal "REMOVE ME AFTER FINISH" and are deleted when the work lands. Enforced bidirectionally -- a TODO without the marker fails, AND any file carrying the marker that is not named TODO*.md fails. Without the reverse rule a task can be renamed into the permanent namespace and quietly live forever, which is the failure being fixed. md_naming/ mirrors the file_length topology and imports its tables (EXCLUDED_DIRS, THIRD_PARTY_REPOS, VENDORED_ANYWHERE, GENERATED_PATTERN) rather than restating them, so the two gates cannot disagree about the same file. 71 tests, 100% branch coverage. stale.py warns on TODOs past 90 days but never fails: no machine can prove a task is finished, so the honest mechanism is a nudge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
220 lines
10 KiB
Markdown
220 lines
10 KiB
Markdown
# 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.py` parses all four token sources — this file,
|
||
> `web_ui/src/tokens.css`, `design_system/lib/src/tokens.dart` and gatelock's
|
||
> `LockConfig` — 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 no `paths:` 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 `#211D1B` so
|
||
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: pairwise CIE ΔE ≥ 20
|
||
under normal vision *and* simulated deuteranopia/protanopia/tritanopia, ≥ 3:1
|
||
against **both** backgrounds, monotonically decreasing lightness, and muted
|
||
saturation.
|
||
|
||
**Do not copy the measured margins into this file.** Run the check — it prints
|
||
every one of them and fails if any regresses:
|
||
|
||
```
|
||
python3 unified-design-system/scripts/ramp_check.py
|
||
```
|
||
|
||
Two consequences worth keeping in prose, because the numbers alone do not say
|
||
them. The both-backgrounds rule is the binding constraint: colours legible on
|
||
a near-black *and* a near-white field only exist around L\* 44–59, which is why
|
||
the ramp is mid-toned rather than spanning light to dark. And because lightness
|
||
falls monotonically, hue and lightness encode redundantly, so `cat-1…6` still
|
||
reads as an ordinal scale in greyscale or under severe CVD.
|
||
|
||
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`/`-2` fill
|
||
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)`
|
||
|
||
## 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.
|
||
|
||
## Focus ring
|
||
|
||
| Token | Value | Notes |
|
||
| ------------ | ---------- | -------------------------------------------- |
|
||
| `focus-ring` | `#B8862E` (= `accent`) | 2px, offset 1px. |
|
||
|
||
Why the platform default is not good enough, and the Tk
|
||
`highlightcolor`/`highlightbackground` trap, are in
|
||
[`operability.md`](operability.md#the-focus-ring-token).
|
||
|
||
## Operability and screen size
|
||
|
||
Keyboard-only reachability and the small-screen budget are requirements, not
|
||
suggestions: several of these apps are lockers that hold a global input grab,
|
||
so a control only a pointer can reach is a bricked machine. Both, plus the
|
||
`focus-ring` token, are in [`operability.md`](operability.md).
|