Consolidate unified-design-system docs into this monorepo

Moves the design-system rules, frozen token table, and annotated
component references out of ~/.claude/skills/ and the two Claude
Artifacts into a proper subdir here, matching the existing
gatelock/crdt-sync/guard-lib pattern of one-subdir-per-shared-library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-22 21:23:47 +02:00
parent 63badeaed5
commit a50d27688f
5 changed files with 1030 additions and 0 deletions

View File

@ -51,3 +51,14 @@ published to PyPI). Consumed via pip by `screen-locker`, `diet-guard`, and
```
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock
```
## unified-design-system/
Not a package — the frozen design tokens (colors, spacing, type, radius,
shadow policy) and per-stack (Flutter/web/Tkinter) implementation patterns
that keep every one of kuhy's repos visually identical, plus annotated
component references (`components.html`, `button.html`). Read
[`unified-design-system/README.md`](unified-design-system/README.md) and
`tokens.md` before touching any theme/CSS/style file in any repo. The
`unified-design-system` Claude Code skill is a thin pointer here, not a
duplicate.

View File

@ -0,0 +1,158 @@
# unified-design-system
The concrete, frozen design tokens (colors, spacing, type, radius, shadow
policy) that implement `safe-design-rules`' 28 abstract rules identically
across every one of kuhy's repos — Flutter, web/React, and Python/Tkinter.
One palette everywhere (including Flutter launcher-icon glyphs), not just
shared rules. Use whenever building or fixing UI in any of kuhy's repos —
this is what a design audit's violations should be fixed *against*, and what
new UI should be built from instead of inventing new values. Full frozen
value table in [`tokens.md`](tokens.md); annotated component references in
[`components.html`](components.html) (9 components: Button/Card/Input/Chip/
Dialog/Switch/Divider/Checkbox/SnackBar) and [`button.html`](button.html)
(the earlier single-component exemplar that set the doc format).
Read `tokens.md` for the full frozen value table before touching any
theme/CSS/style file.
## Why one palette, not per-app branding
Apps are visually unified down to the exact hex values, not just the same
rules with different accents. This also means the Flutter launcher-icon
family (the `app-icon` skill/generator) uses the same accent for every app's
glyph — apps are distinguished by glyph shape, not color.
## Per-stack implementation pattern
### Flutter
Build `ThemeData` from **explicit** `ColorScheme.dark(...)`/`.light(...)`
using the exact hex values in `tokens.md` — not `ColorScheme.fromSeed`, which
algorithmically derives tones and won't reproduce hand-picked values. Add:
- A `ThemeExtension` for roles M3 doesn't have (success/warning — `error` is
already `danger`), e.g. `AppStatusColors(success:, warning:)`. Only add
this if the app actually has success/warning status indicators — grep for
existing `Colors.green`/`Colors.orange`-style usage first; don't add it
speculatively (it becomes untested dead code otherwise).
- `AppSpacing` / `AppRadius` / `AppTextSize` static const classes mirroring
the scales in `tokens.md`.
- Wire every screen through `Theme.of(context)` — the dominant violation
across every Flutter audit was raw `Colors.*` literals bypassing an
existing (or newly added) theme. Grep for `Colors\.` / `Color(0x` after
wiring the theme and route every hit through a theme role.
```dart
ThemeData buildTheme() => ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
surface: Color(0xFF211D1B), // ink
surfaceContainerHighest: Color(0xFF38312E), // ink-raised-2
onSurface: Color(0xFFECEAE9), // text-on-dark
onSurfaceVariant: Color(0xFFAAA09A), // muted-on-dark
outline: Color(0xFF463E3A), // line-dark
primary: Color(0xFFB8862E), // accent
onPrimary: Color(0xFF211D1B), // on-fill — NOT text-on-dark, see below
error: Color(0xFFE2585F), // danger
onError: Color(0xFF211D1B), // on-fill
),
extensions: const [AppStatusColors(
success: Color(0xFF8A9A3C),
onSuccess: Color(0xFF211D1B), // on-fill
warning: Color(0xFFE0A63C),
onWarning: Color(0xFF211D1B), // on-fill
)],
);
```
`onPrimary`/`onError`/`onSuccess`/`onWarning` are all `on-fill` (`#211D1B`,
dark), never `onSurface`'s near-white — see `tokens.md`'s `on-fill` entry:
every filled semantic color under-contrasts with near-white text.
### Web (CSS custom properties)
One `:root` block (plus a light/dark override block as needed) with these
variable names — kept identical across repos so the vocabulary transfers:
```css
:root {
--bg: #211d1b; --surface-1: #2b2624; --surface-2: #38312e;
--border: #463e3a; --text: #eceae9; --text-muted: #aaa09a;
--accent: #b8862e; --success: #8a9a3c; --warning: #e0a63c; --danger: #e2585f;
--on-fill: #211d1b; /* text/icons ON a filled accent/success/warning/danger surface */
--on-scrim: #eceae9; /* text/icons on a dark translucent overlay/backdrop — fixed, not theme-tied */
--radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px;
--space-1: 4px; --space-2: 8px; --space-3: 16px; --space-4: 24px;
--space-5: 32px; --space-6: 48px;
--font-sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", monospace;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f4f3; --surface-1: #fcfbfb; --surface-2: #fcfbfb;
--border: #e0dad7; --text: #211d1b; --text-muted: #70625b;
/* --on-fill / --on-scrim stay fixed — the fills and the scrim don't change with page theme */
}
}
body { font-size: 16px; font-family: var(--font-sans); }
button.primary { background: var(--accent); color: var(--on-fill); }
```
Don't reach for a single "on-accent" variable for both cases (an earlier
dufs-cloud draft did, and it broke the moment the accent went from blue to
gold): a filled button needs `--on-fill` (dark), a caption over a photo-tile
scrim needs `--on-scrim` (near-white) — same-looking near-white token, wrong
one silently fails contrast on fills.
Every hardcoded color/spacing/radius/shadow value in component CSS should
route through one of these variables. No `#fff`/`#000`/`rgba(0,0,0,…)`
literals — use `var(--text)`/`var(--bg)` or an `ink`-tinted rgba.
### Python/Tkinter
No `ttk.Style()` in any of these apps currently — introduce (or, for the
screen-locker/wake_alarm/diet_guard family, extend) a shared constants
module/dataclass with the same token names, and have every `tk.Label`/
`tk.Button`/`tk.Frame` read from it instead of inline hex literals:
```python
@dataclass(frozen=True)
class Tokens:
bg: str = "#211D1B"
fg: str = "#ECEAE9"
muted: str = "#AAA09A"
field_bg: str = "#2B2624"
accent: str = "#B8862E"
success: str = "#8A9A3C"
warning: str = "#E0A63C"
danger: str = "#E2585F"
on_fill: str = "#211D1B" # text on an accent/success/warning/danger bg — NOT fg
font_family: str = "Arial"
```
A shared `_button(bg=...)` helper that always sets `fg=self._colors.fg` is
the exact bug this caught: `fg` (near-white) is correct when `bg` is
`field_bg`/`bg` (a dark neutral surface), but any button whose `bg` is
`accent`/`success`/`warning`/`danger` needs `fg=self._colors.on_fill`
instead — pick `fg` from `bg`, don't hardcode it per call site.
For screen-locker/wake_alarm/diet_guard specifically: this lives on
`gatelock.LockConfig` (the shared token source already vendored in this same
`~/utils` monorepo, see [`../gatelock/`](../gatelock/)) rather than a
per-repo duplicate, since all three already depend on it. Extending
`LockConfig` requires production verification
(`/usr/bin/python3 -c "import gatelock"`) since it backs 3 live systemd
services — a dev-venv-only check is not sufficient.
## Do NOT
- Don't use `ColorScheme.fromSeed` for the unified palette — it can't
reproduce hand-picked hex values exactly.
- Don't add shadows to dark surfaces, or mix shadow + border depth on the
same element (see `tokens.md`'s shadow policy).
- Don't invent a new spacing/radius/font value outside the scales in
`tokens.md` — round to the nearest step instead.
- Don't add a `ThemeExtension`/status-color role speculatively — check the
app actually uses that status (success/warning/etc.) before adding it.
- Don't touch `../gatelock` without the production-path verification above;
a broken import there takes down 3 services at once.

View File

@ -0,0 +1,255 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Button — unified-design-system</title>
<style>
:root {
--ink: #211d1b; --ink-r1: #2b2624; --ink-r2: #38312e;
--line-dark: #463e3a; --text-dark: #ecEae9; --muted-dark: #aaa09a;
--paper: #f6f4f3; --paper-r: #fcfbfb;
--line-light: #e0dad7; --text-light: #211d1b; --muted-light: #70625b;
--accent: #b8862e; --success: #8a9a3c; --warning: #e0a63c; --danger: #e2585f;
--on-fill: #211d1b;
--bg: var(--paper); --surface-1: var(--paper-r); --surface-2: var(--paper-r);
--border: var(--line-light); --text: var(--text-light); --text-muted: var(--muted-light);
--radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px;
--sp-1: 4px; --sp-2: 8px; --sp-3: 16px; --sp-4: 24px; --sp-5: 32px; --sp-6: 48px;
--sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
--mono: ui-monospace, "Cascadia Code", monospace;
--arrow: color-mix(in srgb, var(--accent) 55%, var(--text-muted));
}
@media (prefers-color-scheme: dark) {
:root {
--bg: var(--ink); --surface-1: var(--ink-r1); --surface-2: var(--ink-r2);
--border: var(--line-dark); --text: var(--text-dark); --text-muted: var(--muted-dark);
}
}
:root[data-theme="dark"] {
--bg: var(--ink); --surface-1: var(--ink-r1); --surface-2: var(--ink-r2);
--border: var(--line-dark); --text: var(--text-dark); --text-muted: var(--muted-dark);
}
:root[data-theme="light"] {
--bg: var(--paper); --surface-1: var(--paper-r); --surface-2: var(--paper-r);
--border: var(--line-light); --text: var(--text-light); --text-muted: var(--muted-light);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: var(--sans); font-size: 16px; line-height: 1.5;
}
.page { max-width: 40rem; margin: 0 auto; padding: var(--sp-6) var(--sp-4) var(--sp-6); }
.eyebrow {
display: flex; align-items: center; gap: var(--sp-2);
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--text-muted); margin: 0 0 var(--sp-2);
}
.eyebrow::before {
content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--accent);
flex: none;
}
h1 {
font-size: 32px; letter-spacing: -0.2px; text-wrap: balance;
margin: 0 0 var(--sp-1); font-weight: 650;
}
.dek { color: var(--text-muted); margin: 0 0 var(--sp-6); max-width: 34rem; }
h2 {
font-size: 20px; margin: 0 0 var(--sp-3); font-weight: 600;
border-top: 1px solid var(--border); padding-top: var(--sp-4);
}
section + section { margin-top: var(--sp-5); }
/* ---- annotated diagram ---- */
.diagram-wrap { overflow-x: auto; }
.diagram {
position: relative; min-width: 620px; height: 420px;
margin: var(--sp-4) 0;
}
.diagram svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.diagram svg path {
fill: none; stroke: var(--arrow); stroke-width: 1.5;
marker-end: url(#arrowhead);
}
.btn-primary {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
background: var(--accent); color: var(--on-fill); border: none;
border-radius: var(--radius-sm); padding: 12px 24px;
font: inherit; font-size: 16px; cursor: default;
}
.callout {
position: absolute; width: 200px;
font-size: 14px; color: var(--text-muted);
}
.callout b { color: var(--text); font-weight: 600; }
.callout code {
font-family: var(--mono); font-size: 12.5px; color: var(--accent);
}
.c-tl { left: 0; top: 0; }
.c-tr { right: 0; top: 0; text-align: right; }
.c-bl { left: 0; bottom: 0; }
.c-br { right: 0; bottom: 0; text-align: right; }
/* ---- variants row ---- */
.variants {
display: flex; gap: var(--sp-4); align-items: flex-start; flex-wrap: wrap;
margin: var(--sp-3) 0 var(--sp-2);
}
.variant { display: flex; flex-direction: column; gap: var(--sp-2); align-items: flex-start; }
.variant .label { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.btn-ghost {
background: var(--surface-1); color: var(--text); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 12px 24px; font: inherit; font-size: 16px;
}
.btn-disabled {
background: var(--accent); color: var(--on-fill); opacity: 0.5;
border: none; border-radius: var(--radius-sm); padding: 12px 24px; font: inherit; font-size: 16px;
}
.flag {
display: flex; gap: var(--sp-2); align-items: flex-start;
background: color-mix(in srgb, var(--success) 12%, var(--surface-1));
border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border));
border-radius: var(--radius-sm); padding: var(--sp-3); font-size: 14px;
margin-top: var(--sp-3);
}
.flag b { color: var(--text); }
/* ---- spec table ---- */
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { text-align: left; padding: var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border); }
th { font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); font-weight: 600; }
td code, td span.val { font-family: var(--mono); font-size: 13px; }
.table-wrap { overflow-x: auto; }
footer {
margin-top: var(--sp-6); padding-top: var(--sp-4); border-top: 1px solid var(--border);
font-size: 14px; color: var(--text-muted);
}
footer strong { color: var(--text); }
</style>
</head>
<body>
<div class="page">
<p class="eyebrow">unified-design-system · format check</p>
<h1>Button</h1>
<p class="dek">
One component, every decision named, one sentence of why each. This is
the format for the exhaustive design doc — confirm it before the
remaining components (card, input, chip, dialog, switch, divider,
checkbox, snack&nbsp;bar) get written the same way.
</p>
<section>
<h2>Annotated</h2>
<div class="diagram-wrap">
<div class="diagram" id="diagram">
<svg viewBox="0 0 620 420" preserveAspectRatio="none">
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="var(--arrow)" />
</marker>
</defs>
<path d="M 150,70 C 220,110 260,150 292,182" />
<path d="M 470,70 C 400,110 372,150 350,175" />
<path d="M 150,350 C 220,310 260,270 292,240" />
<path d="M 470,350 C 400,310 372,270 350,240" />
</svg>
<p class="callout c-tl">
<b>Radius: <code>radius-sm</code>, 8px.</b>
Same token as inputs and chips — reads as one family of
interactive elements, not a size picked for buttons alone.
</p>
<p class="callout c-tr">
<b>Padding: 24px / 12px.</b>
Horizontal is exactly 2× vertical (rule 22) — never tuned
per component.
</p>
<button class="btn-primary" type="button">Log meal</button>
<p class="callout c-bl">
<b>Label: 16px, <code>body</code>.</b>
A button is read, not decorative chrome, so it never drops
below the 16px floor.
</p>
<p class="callout c-br">
<b>Fill only, no shadow.</b>
Dark surfaces never carry a shadow — weight comes from the
accent fill itself.
</p>
</div>
</div>
</section>
<section>
<h2>Variants</h2>
<div class="variants">
<div class="variant">
<span class="label">Secondary</span>
<button class="btn-ghost" type="button">Add item</button>
</div>
<div class="variant">
<span class="label">Disabled</span>
<button class="btn-disabled" type="button" disabled>Log meal</button>
</div>
</div>
<p style="font-size:14px;color:var(--text-muted);margin-top:var(--sp-2)">
Fill vs. border-only is the only weight distinction — never two
filled buttons on one screen, so <code style="font-family:var(--mono)">Log meal</code>
(primary action) and <code style="font-family:var(--mono)">Add item</code>
(secondary) read as different weight at a glance.
</p>
<div class="flag">
<span>✓</span>
<span><b>Fixed:</b> label is now <code style="font-family:var(--mono)">on-fill</code>
(<code style="font-family:var(--mono)">#211d1b</code>, dark), not
near-white — near-white measured ≈3.2:1 on this fill, below the 4.5:1
body-text target (true for every semantic fill, not just accent: all
four cluster in the same mid-light band). Dark text passes 5.8–9.7:1
on all four; it's a closed rule, not a per-color pick. <code style="font-family:var(--mono)">on-fill</code>
is distinct from the near-white used on dark scrim overlays — see
<code style="font-family:var(--mono)">tokens.md</code>.</span>
</div>
</section>
<section>
<h2>Spec table</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
<tbody>
<tr><td>Corner radius</td><td><span class="val">8px</span></td><td>shared with inputs/chips</td></tr>
<tr><td>Padding</td><td><span class="val">12px 24px</span></td><td>rule 22 — 2× ratio</td></tr>
<tr><td>Label size</td><td><span class="val">16px</span></td><td>rule 20 — body floor</td></tr>
<tr><td>Depth</td><td><span class="val">fill / border only</span></td><td>shadow policy</td></tr>
<tr><td>Primary fill</td><td><span class="val">accent #b8862e</span></td><td>tokens.md</td></tr>
<tr><td>Primary label</td><td><span class="val">on-fill #211d1b</span></td><td>4.5:1+ on all 4 fills</td></tr>
<tr><td>Secondary</td><td><span class="val">surface-1 + 1px border</span></td><td>tokens.md</td></tr>
<tr><td>Disabled</td><td><span class="val">opacity 0.5</span></td><td>no separate muted token</td></tr>
</tbody>
</table>
</div>
</section>
<footer>
Content is real: <strong>Log meal</strong> / <strong>Add item</strong> are
diet-guard's <code style="font-family:var(--mono)">meal_builder_screen.dart</code>
buttons — the exact pair flagged earlier as indistinguishable in weight.
If this format works, say go and the rest get written the same way.
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,483 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Components — unified-design-system</title>
<style>
:root {
--ink: #211d1b; --ink-r1: #2b2624; --ink-r2: #38312e;
--line-dark: #463e3a; --text-dark: #eceae9; --muted-dark: #aaa09a;
--paper: #f6f4f3; --paper-r: #fcfbfb;
--line-light: #e0dad7; --text-light: #211d1b; --muted-light: #70625b;
--accent: #b8862e; --success: #8a9a3c; --warning: #e0a63c; --danger: #e2585f;
--on-fill: #211d1b; --on-scrim: #eceae9;
--bg: var(--paper); --surface-1: var(--paper-r); --surface-2: var(--paper-r);
--border: var(--line-light); --text: var(--text-light); --text-muted: var(--muted-light);
--radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px;
--sp-1: 4px; --sp-2: 8px; --sp-3: 16px; --sp-4: 24px; --sp-5: 32px; --sp-6: 48px;
--sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
--mono: ui-monospace, "Cascadia Code", monospace;
--arrow: color-mix(in srgb, var(--accent) 55%, var(--text-muted));
}
@media (prefers-color-scheme: dark) {
:root {
--bg: var(--ink); --surface-1: var(--ink-r1); --surface-2: var(--ink-r2);
--border: var(--line-dark); --text: var(--text-dark); --text-muted: var(--muted-dark);
}
}
:root[data-theme="dark"] {
--bg: var(--ink); --surface-1: var(--ink-r1); --surface-2: var(--ink-r2);
--border: var(--line-dark); --text: var(--text-dark); --text-muted: var(--muted-dark);
}
:root[data-theme="light"] {
--bg: var(--paper); --surface-1: var(--paper-r); --surface-2: var(--paper-r);
--border: var(--line-light); --text: var(--text-light); --text-muted: var(--muted-light);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: var(--sans); font-size: 16px; line-height: 1.5;
}
.page { max-width: 42rem; margin: 0 auto; padding: var(--sp-6) var(--sp-4) var(--sp-6); }
.eyebrow {
display: flex; align-items: center; gap: var(--sp-2);
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--text-muted); margin: 0 0 var(--sp-2);
}
.eyebrow::before {
content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--accent);
flex: none;
}
h1 {
font-size: 32px; letter-spacing: -0.2px; text-wrap: balance;
margin: 0 0 var(--sp-1); font-weight: 650;
}
.dek { color: var(--text-muted); margin: 0 0 var(--sp-4); max-width: 36rem; }
nav.toc {
display: flex; flex-wrap: wrap; gap: var(--sp-2);
margin: 0 0 var(--sp-6); padding: var(--sp-3);
background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius-md);
}
nav.toc a {
font-size: 13px; color: var(--text-muted); text-decoration: none;
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
font-family: var(--mono);
}
nav.toc a:hover, nav.toc a:focus-visible { color: var(--text); background: var(--surface-2); outline: none; }
nav.toc a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.component { scroll-margin-top: var(--sp-4); }
.component + .component { margin-top: var(--sp-6); padding-top: var(--sp-5); border-top: 1px solid var(--border); }
h2 {
display: flex; align-items: baseline; gap: var(--sp-2);
font-size: 20px; margin: 0 0 var(--sp-1); font-weight: 600;
}
h2 .n { font-family: var(--mono); font-size: 13px; color: var(--text-muted); font-weight: 400; }
.status {
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
padding: 2px 7px; border-radius: 999px; font-weight: 600;
}
.status.live { color: var(--success); background: color-mix(in srgb, var(--success) 16%, transparent); }
.status.spec { color: var(--warning); background: color-mix(in srgb, var(--warning) 16%, transparent); }
.from { font-size: 13px; color: var(--text-muted); margin: 0 0 var(--sp-3); font-family: var(--mono); }
section.body { margin-top: var(--sp-3); }
section.body + section.body { margin-top: var(--sp-4); }
/* ---- annotated diagram ---- */
.diagram-wrap { overflow-x: auto; }
.diagram { position: relative; min-width: 560px; margin: var(--sp-3) 0; }
.diagram svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.diagram svg path { fill: none; stroke: var(--arrow); stroke-width: 1.5; marker-end: url(#arrowhead); }
.diagram svg path.danger-arrow { stroke: var(--danger); }
.callout { position: absolute; width: 190px; font-size: 13.5px; color: var(--text-muted); }
.callout b { color: var(--text); font-weight: 600; }
.callout code { font-family: var(--mono); font-size: 12px; color: var(--accent); }
.callout.bad code { color: var(--danger); }
.c-tl { left: 0; top: 0; } .c-tr { right: 0; top: 0; text-align: right; }
.c-bl { left: 0; bottom: 0; } .c-br { right: 0; bottom: 0; text-align: right; }
.c-ml { left: 0; top: 50%; transform: translateY(-50%); }
.c-mr { right: 0; top: 50%; transform: translateY(-50%); text-align: right; }
.stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
/* ---- shared control styles ---- */
button { font: inherit; }
.btn-primary {
background: var(--accent); color: var(--on-fill); border: none;
border-radius: var(--radius-sm); padding: 12px 24px; font-size: 16px; cursor: default;
}
.btn-ghost {
background: var(--surface-1); color: var(--text); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 12px 24px; font-size: 16px;
}
.btn-disabled { background: var(--accent); color: var(--on-fill); opacity: 0.5; border: none; border-radius: var(--radius-sm); padding: 12px 24px; font-size: 16px; }
.variants { display: flex; gap: var(--sp-4); align-items: flex-start; flex-wrap: wrap; margin: var(--sp-3) 0 var(--sp-2); }
.variant { display: flex; flex-direction: column; gap: var(--sp-2); align-items: flex-start; }
.variant .label { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.flag {
display: flex; gap: var(--sp-2); align-items: flex-start;
border-radius: var(--radius-sm); padding: var(--sp-3); font-size: 14px; margin-top: var(--sp-3);
}
.flag.ok { background: color-mix(in srgb, var(--success) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border)); }
.flag.warn { background: color-mix(in srgb, var(--warning) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--warning) 40%, var(--border)); }
.flag.bad { background: color-mix(in srgb, var(--danger) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--border)); }
.flag b { color: var(--text); }
.flag code { font-family: var(--mono); font-size: 12.5px; }
p.note { font-size: 14px; color: var(--text-muted); margin: var(--sp-2) 0 0; }
p.note code { font-family: var(--mono); font-size: 13px; color: var(--text); }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th, td { text-align: left; padding: 6px var(--sp-2); border-bottom: 1px solid var(--border); }
th { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); font-weight: 600; }
td .val { font-family: var(--mono); font-size: 12.5px; }
.table-wrap { overflow-x: auto; margin-top: var(--sp-2); }
footer { margin-top: var(--sp-6); padding-top: var(--sp-4); border-top: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
footer strong { color: var(--text); }
footer code { font-family: var(--mono); font-size: 13px; }
</style>
</head>
<body>
<div class="page">
<p class="eyebrow">unified-design-system · component spec</p>
<h1>Components</h1>
<p class="dek">
One decision, one sentence of why, per component — grounded in real
screens (diet-guard, dufs-cloud, screen-locker), not invented examples.
Where a component isn't built yet, that's stated rather than faked.
</p>
<nav class="toc" aria-label="Components">
<a href="#button">button</a><a href="#card">card</a><a href="#input">input</a>
<a href="#chip">chip</a><a href="#dialog">dialog</a><a href="#switch">switch</a>
<a href="#divider">divider</a><a href="#checkbox">checkbox</a><a href="#snackbar">snackbar</a>
</nav>
<svg width="0" height="0" style="position:absolute">
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="var(--arrow)" />
</marker>
</defs>
</svg>
<!-- ============ BUTTON ============ -->
<article class="component" id="button">
<h2>Button <span class="n">01</span> <span class="status live">live</span></h2>
<p class="from">diet-guard/app/lib/screens/meal_builder_screen.dart</p>
<section class="body">
<div class="diagram-wrap">
<div class="diagram" style="height:380px">
<svg viewBox="0 0 560 380" preserveAspectRatio="none">
<path d="M 130,60 C 200,100 240,140 272,172" />
<path d="M 430,60 C 360,100 332,140 310,165" />
<path d="M 130,320 C 200,280 240,240 272,220" />
<path d="M 430,320 C 360,280 332,240 310,220" />
</svg>
<p class="callout c-tl"><b>Radius: <code>radius-sm</code>, 8px.</b> Same token as inputs and chips — one family of interactive elements, not a size picked for buttons alone.</p>
<p class="callout c-tr"><b>Padding: 24px / 12px.</b> Horizontal is exactly 2× vertical (rule 22) — never tuned per component.</p>
<button class="btn-primary stage" type="button">Log meal</button>
<p class="callout c-bl"><b>Label: 16px, <code>body</code>.</b> A button is read, not decorative chrome — never below the 16px floor.</p>
<p class="callout c-br"><b>Fill only, no shadow.</b> Dark surfaces never carry a shadow — weight comes from the fill itself.</p>
</div>
</div>
<div class="variants">
<div class="variant"><span class="label">Secondary</span><button class="btn-ghost" type="button">Add item</button></div>
<div class="variant"><span class="label">Disabled</span><button class="btn-disabled" type="button" disabled>Log meal</button></div>
</div>
<p class="note">Fill vs. border-only is the only weight distinction — never two filled buttons on one screen, so <code>Log meal</code> (primary) and <code>Add item</code> (secondary) read as different weight at a glance.</p>
<div class="flag ok">
<span>✓</span>
<span><b>Fixed this session:</b> label is <code>on-fill</code> (<code>#211d1b</code>, dark), not near-white — near-white measured ≈3.2:1 on this fill, below the 4.5:1 floor. Dark text passes 5.8–9.7:1 on all four semantic fills; it's a closed rule, not a per-color pick.</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
<tbody>
<tr><td>Radius</td><td><span class="val">8px</span></td><td>shared with inputs/chips</td></tr>
<tr><td>Padding</td><td><span class="val">12px 24px</span></td><td>rule 22 — 2× ratio</td></tr>
<tr><td>Label</td><td><span class="val">16px on-fill</span></td><td>rule 20 floor + on-fill</td></tr>
<tr><td>Depth</td><td><span class="val">fill / border only</span></td><td>shadow policy</td></tr>
<tr><td>Disabled</td><td><span class="val">opacity 0.5</span></td><td>no separate muted token</td></tr>
</tbody>
</table>
</div>
</section>
</article>
<!-- ============ CARD ============ -->
<article class="component" id="card">
<h2>Card <span class="n">02</span> <span class="status live">live</span></h2>
<p class="from">diet-guard/app/lib/widgets/day_status_calendar.dart</p>
<section class="body">
<div class="diagram-wrap">
<div class="diagram" style="height:300px">
<svg viewBox="0 0 560 300" preserveAspectRatio="none">
<path d="M 110,50 C 170,90 200,110 226,128" />
<path d="M 450,50 C 390,90 360,110 334,128" />
<path d="M 110,250 C 170,210 200,190 226,175" />
<path d="M 450,250 C 390,210 360,190 334,175" />
</svg>
<div class="stage" style="width:280px;height:150px;background:var(--surface-1);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;gap:10px;padding:14px;">
<div style="width:28px;height:28px;border-radius:6px;background:var(--surface-2);"></div>
<div style="width:28px;height:28px;border-radius:6px;background:var(--success);color:var(--on-fill);display:grid;place-items:center;font-size:11px;font-family:var(--mono)">✓</div>
<div style="width:28px;height:28px;border-radius:6px;background:var(--danger);color:var(--on-fill);display:grid;place-items:center;font-size:11px;font-family:var(--mono)">!</div>
</div>
<p class="callout c-tl"><b>Radius: <code>radius-md</code>, 12px.</b> One step up from a button's 8px — a card contains interactive elements, so it must read as a larger unit than they are.</p>
<p class="callout c-tr"><b>Elevation: fill step, not shadow.</b> <code>surface-1</code> against the page background — dark surfaces never use a shadow for depth.</p>
<p class="callout c-bl"><b>Nested radius: 6px.</b> Rule 24 — inner cell radius = outer (12px) − the gap to its edge (≈6px), not a reused constant.</p>
<p class="callout c-br"><b>Status cells use <code>on-fill</code>.</b> The ✓/! cells sit directly on <code>success</code>/<code>danger</code> — this is the one place in the app that already got the on-fill rule right.</p>
</div>
</div>
<p class="note">The status colors sit at mid brightness, so <code>ink</code> gives strong on-palette contrast — this is the real code comment in <code>day_status_calendar.dart</code>, written before today's <code>on-fill</code> token existed to name the pattern.</p>
</section>
</article>
<!-- ============ INPUT ============ -->
<article class="component" id="input">
<h2>Input <span class="n">03</span> <span class="status live">live</span></h2>
<p class="from">diet-guard/app/lib/widgets/macro_input_row.dart · screen-locker/_ui_widgets.py</p>
<section class="body">
<div class="diagram-wrap">
<div class="diagram" style="height:220px">
<svg viewBox="0 0 560 220" preserveAspectRatio="none">
<path class="danger-arrow" d="M 120,40 C 180,70 220,90 252,105" />
<path class="danger-arrow" d="M 440,40 C 380,70 340,90 308,105" />
</svg>
<div class="stage" style="width:260px;">
<label style="font-size:12px;color:var(--text-muted);display:block;margin-bottom:4px;">Protein (g)</label>
<div style="height:44px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface-1);"></div>
</div>
<p class="callout c-tl bad"><b>Not yet themed.</b> No <code>InputDecorationTheme</code> is set in <code>theme.dart</code> — every <code>TextField</code> renders M3's default indigo focus color, off-palette.</p>
<p class="callout c-tr bad"><b>Tkinter side too.</b> <code>gatelock</code>'s <code>tk.Entry</code> sets <code>bg</code>/<code>fg</code> but no <code>relief</code>/<code>bd</code> — Tk's default sunken bevel border, not the flat <code>line-dark</code> outline.</p>
</div>
</div>
<div class="flag bad">
<span>⚠</span>
<span><b>Open finding, not fixed:</b> the input shown above is the <em>target</em> spec (radius-sm, flat 1px border, no bevel) — the actual rendered app still falls back to each toolkit's own default. Queued with the pilot-code palette migration, same class of bug as the Chip finding below.</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
<tbody>
<tr><td>Radius</td><td><span class="val">8px</span></td><td>shared with buttons/chips</td></tr>
<tr><td>Border</td><td><span class="val">1px line-dark</span></td><td>flat, no bevel</td></tr>
<tr><td>Label</td><td><span class="val">12px caption</span></td><td>chrome, not read content</td></tr>
<tr><td>Fill on focus</td><td><span class="val">surface-1 → surface-2</span></td><td>fill step, not a shadow/glow</td></tr>
</tbody>
</table>
</div>
</section>
</article>
<!-- ============ CHIP ============ -->
<article class="component" id="chip">
<h2>Chip <span class="n">04</span> <span class="status live">live</span></h2>
<p class="from">diet-guard/app/lib/widgets/slot_selector_row.dart</p>
<section class="body">
<div class="diagram-wrap">
<div class="diagram" style="height:260px">
<svg viewBox="0 0 560 260" preserveAspectRatio="none">
<path d="M 100,40 C 140,70 160,90 182,108" />
<path class="danger-arrow" d="M 460,40 C 420,70 400,90 378,108" />
</svg>
<div class="stage" style="display:flex;gap:14px;align-items:center;">
<span style="display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:999px;background:color-mix(in srgb, var(--success) 15%, transparent);border:1px solid color-mix(in srgb, var(--success) 40%, transparent);color:var(--success);font-size:14px;">
<span style="opacity:.72">✓</span>9am
</span>
<span style="display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:999px;background:transparent;border:1px solid #fff;color:var(--text);font-size:14px;">
🍎 Snack
</span>
</div>
<p class="callout c-tl"><b>Fill/border at 15%/40% alpha of the status color.</b> Not a fixed chip palette — <code>logged</code>/<code>due</code>/<code>upcoming</code> reuse <code>success</code>/<code>danger</code>/<code>muted</code>, so a chip's meaning matches the calendar's.</p>
<p class="callout c-br bad"><b>Bug, still open:</b> the <code>Snack</code> chip sets no <code>backgroundColor</code>/<code>side</code> at all, so it falls through to Flutter's own default — a literal pure-white <code>#FFFFFF</code> border, the one banned value in the whole system.</p>
</div>
</div>
<p class="note">The hour chips (left) do this correctly: icon at 72% opacity of the status color (rule 28), selected border 2px vs. unselected 1px at 40% alpha. <code>Snack</code> just needs the same three properties applied — not a new pattern, a missed one.</p>
</section>
</article>
<!-- ============ DIALOG ============ -->
<article class="component" id="dialog">
<h2>Dialog <span class="n">05</span> <span class="status live">live</span></h2>
<p class="from">dufs-cloud/web/src/index.css · screen-locker/_manual_workout_dialog.py</p>
<section class="body">
<div class="variants">
<div class="variant">
<span class="label">Web (floating over content)</span>
<div style="width:220px;padding:18px;border-radius:var(--radius-md);background:var(--surface-1);border:1px solid var(--border);box-shadow:0 4px 8px rgba(33,29,27,0.16);">
<div style="font-size:14px;margin-bottom:10px;">Delete 3 files?</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<span style="padding:6px 12px;border-radius:6px;border:1px solid var(--border);font-size:13px;">Cancel</span>
<span style="padding:6px 12px;border-radius:6px;background:var(--danger);color:var(--on-fill);font-size:13px;">Delete</span>
</div>
</div>
</div>
<div class="variant">
<span class="label">Tkinter (the whole screen)</span>
<div style="width:220px;padding:18px;border-radius:var(--radius-md);background:var(--ink);border:1px solid var(--line-dark);">
<div style="font-size:14px;margin-bottom:10px;color:var(--text-dark)">Log Manual Workout</div>
<span style="padding:6px 12px;border-radius:6px;background:var(--accent);color:var(--on-fill);font-size:13px;">SUBMIT</span>
</div>
</div>
</div>
<p class="note">Same radius (<code>radius-md</code>), different depth technique, and that's <em>correct</em>, not inconsistent: the web dialog floats over other content (shadow allowed, per policy), the screen-locker dialog fills the entire lock screen — there's nothing behind it to cast a shadow onto, so it's fill/border only, same as every other dark surface.</p>
<div class="table-wrap">
<table>
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
<tbody>
<tr><td>Radius</td><td><span class="val">12px</span></td><td>radius-md, one step above buttons</td></tr>
<tr><td>Web shadow</td><td><span class="val">0 4px 8px rgba(ink,.16)</span></td><td>level 2 — bigger than a dropdown's level 1</td></tr>
<tr><td>Actions</td><td><span class="val">right-aligned, 8px gap</span></td><td>space-2 on the spacing scale</td></tr>
</tbody>
</table>
</div>
</section>
</article>
<!-- ============ SWITCH ============ -->
<article class="component" id="switch">
<h2>Switch <span class="n">06</span> <span class="status spec">spec only</span></h2>
<p class="from">no live usage yet — locking the values in before the first one gets built</p>
<section class="body">
<div class="diagram-wrap">
<div class="diagram" style="height:180px">
<svg viewBox="0 0 560 180" preserveAspectRatio="none">
<path d="M 110,30 C 170,55 200,70 226,84" />
<path d="M 450,30 C 390,55 360,70 334,84" />
</svg>
<div class="stage" style="display:flex;align-items:center;gap:12px;">
<div style="width:44px;height:26px;border-radius:999px;background:var(--accent);position:relative;">
<div style="width:20px;height:20px;border-radius:50%;background:var(--on-fill);position:absolute;top:3px;right:3px;"></div>
</div>
</div>
<p class="callout c-tl"><b>Track = accent, thumb = on-fill.</b> Same fill/on-fill pairing as a button — a switch is a tiny filled control, not a new color relationship to invent.</p>
<p class="callout c-tr"><b>Fully rounded track.</b> The one place in this system a pill shape is correct: an on/off track has no other elements to stay visually consistent with, unlike a button.</p>
</div>
</div>
<p class="note">Off state: track becomes <code>surface-2</code>, thumb becomes <code>muted</code> — a fill-step change, matching how every other "inactive" state in this system works (never a separate grey palette).</p>
</section>
</article>
<!-- ============ DIVIDER ============ -->
<article class="component" id="divider">
<h2>Divider <span class="n">07</span> <span class="status live">live</span></h2>
<p class="from">diet-guard/app/lib/screens/settings_screen.dart · dufs-cloud/web/src/index.css</p>
<section class="body">
<div class="variants">
<div class="variant">
<span class="label">dufs-cloud (correct)</span>
<div style="width:220px;padding-bottom:10px;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-muted)">.topbar</div>
</div>
<div class="variant">
<span class="label">diet-guard (open finding)</span>
<div style="width:220px;padding-bottom:10px;border-bottom:1px solid #fff;font-size:13px;color:var(--text-muted)"><code style="font-family:var(--mono)">const Divider()</code></div>
</div>
</div>
<div class="flag bad">
<span>⚠</span>
<span><b>Open finding, not fixed:</b> <code>settings_screen.dart</code> uses <code>const Divider()</code> with no <code>dividerTheme</code> set app-wide — same missing-theme pattern as Input above, this time landing on Material's default grey rather than <code>line-dark</code>.</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Property</th><th>Value</th><th>Rule</th></tr></thead>
<tbody>
<tr><td>Weight</td><td><span class="val">1px</span></td><td>hairline — never a filled bar</td></tr>
<tr><td>Color</td><td><span class="val">line-dark / line-light</span></td><td>same border token as cards/inputs</td></tr>
<tr><td>Spacing</td><td><span class="val">space-3 (16px) each side</span></td><td>never touches adjacent text</td></tr>
</tbody>
</table>
</div>
</section>
</article>
<!-- ============ CHECKBOX ============ -->
<article class="component" id="checkbox">
<h2>Checkbox <span class="n">08</span> <span class="status live">live</span></h2>
<p class="from">dufs-cloud/web/src/index.css — <code>.tile-select</code></p>
<section class="body">
<div class="diagram-wrap">
<div class="diagram" style="height:180px">
<svg viewBox="0 0 560 180" preserveAspectRatio="none">
<path d="M 120,30 C 170,55 200,70 224,84" />
<path class="danger-arrow" d="M 440,30 C 390,55 360,70 336,84" />
</svg>
<div class="stage" style="width:32px;height:32px;border-radius:var(--radius-sm);background:rgba(33,29,27,0.45);display:grid;place-items:center;">
<input type="checkbox" checked style="margin:0;" />
</div>
<p class="callout c-tl"><b>Chip sits on a scrim, not a fill.</b> <code>rgba(ink,0.45)</code> over a photo thumbnail — this is <code>on-scrim</code> territory, not <code>on-fill</code>, since the thumbnail behind it varies.</p>
<p class="callout c-tr bad"><b>Open finding:</b> the checkbox itself is an unstyled native <code>&lt;input type="checkbox"&gt;</code> — its checked-state color is the browser's default blue, off-palette. No custom checkbox styling exists yet.</p>
</div>
</div>
<p class="note">The 8px-radius scrim chip behind it is deliberately correct (matches <code>radius-sm</code>, dark translucent per the scrim rule) — only the checkbox glyph inside it is unstyled.</p>
</section>
</article>
<!-- ============ SNACKBAR ============ -->
<article class="component" id="snackbar">
<h2>Snack bar / toast <span class="n">09</span> <span class="status spec">spec only</span></h2>
<p class="from">no live usage yet — no repo has a toast/snackbar today</p>
<section class="body">
<div class="diagram-wrap">
<div class="diagram" style="height:200px">
<svg viewBox="0 0 560 200" preserveAspectRatio="none">
<path d="M 100,30 C 160,55 190,75 220,95" />
<path d="M 460,30 C 400,55 370,75 340,95" />
</svg>
<div class="stage" style="width:280px;padding:14px 16px;border-radius:var(--radius-md);background:var(--surface-2);border:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;">
<span style="font-size:14px;">Upload failed — connection lost</span>
<span style="font-size:13px;color:var(--accent);white-space:nowrap;">Retry</span>
</div>
<p class="callout c-tl"><b>Never <code>danger</code>-filled.</b> A toast is transient chrome, not a status cell — <code>surface-2</code> + <code>danger</code>-colored icon/text reads as urgent without becoming a full alert box.</p>
<p class="callout c-tr"><b>One action max.</b> <code>Retry</code> here, styled as text, not a button — a toast disappears on its own; a second filled button would compete with the page's real primary action.</p>
</div>
</div>
<p class="note">Copy above is realistic (a dufs-cloud upload failure), not a placeholder — written to spec the tone (states what broke, offers the one useful next step) alongside the visual values.</p>
</section>
</article>
<footer>
9 of 9 components from the original punch list. <strong>Live</strong> means
grounded in a real screen — several of those also carry an open finding
(Input, Chip, Divider, Checkbox) that this doc surfaced but didn't
silently fix. <strong>Spec only</strong> (Switch, Snack bar) means no
usage exists yet; values are derived from the closed token rules, not
guessed. Next: apply the frozen warm palette + <code>on-fill</code>/<code>on-scrim</code>
split to the 3 pilots, then work the open findings above.
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,123 @@
# 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). 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.
## 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).
## 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.