diff --git a/README.md b/README.md
index a556191..218b448 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/unified-design-system/README.md b/unified-design-system/README.md
new file mode 100644
index 0000000..53a6667
--- /dev/null
+++ b/unified-design-system/README.md
@@ -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.
diff --git a/unified-design-system/button.html b/unified-design-system/button.html
new file mode 100644
index 0000000..f2fc5a7
--- /dev/null
+++ b/unified-design-system/button.html
@@ -0,0 +1,255 @@
+
+
+
+
+
+Button — unified-design-system
+
+
+
+
+
+
unified-design-system · format check
+
Button
+
+ 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 bar) get written the same way.
+
+
+
+
Annotated
+
+
+
+
+
+ Radius: radius-sm, 8px.
+ Same token as inputs and chips — reads as one family of
+ interactive elements, not a size picked for buttons alone.
+
+
+ Padding: 24px / 12px.
+ Horizontal is exactly 2× vertical (rule 22) — never tuned
+ per component.
+
+
+
+ Label: 16px, body.
+ A button is read, not decorative chrome, so it never drops
+ below the 16px floor.
+
+
+ Fill only, no shadow.
+ Dark surfaces never carry a shadow — weight comes from the
+ accent fill itself.
+
+
+
+
+
+
+
Variants
+
+
+ Secondary
+
+
+
+ Disabled
+
+
+
+
+ Fill vs. border-only is the only weight distinction — never two
+ filled buttons on one screen, so Log meal
+ (primary action) and Add item
+ (secondary) read as different weight at a glance.
+
+
+ ✓
+ Fixed: label is now on-fill
+ (#211d1b, 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. on-fill
+ is distinct from the near-white used on dark scrim overlays — see
+ tokens.md.
+
+ 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.
+
Radius: radius-sm, 8px. Same token as inputs and chips — one family of interactive elements, not a size picked for buttons alone.
+
Padding: 24px / 12px. Horizontal is exactly 2× vertical (rule 22) — never tuned per component.
+
+
Label: 16px, body. A button is read, not decorative chrome — never below the 16px floor.
+
Fill only, no shadow. Dark surfaces never carry a shadow — weight comes from the fill itself.
+
+
+
+
+
Secondary
+
Disabled
+
+
Fill vs. border-only is the only weight distinction — never two filled buttons on one screen, so Log meal (primary) and Add item (secondary) read as different weight at a glance.
+
+
+ ✓
+ Fixed this session: label is on-fill (#211d1b, 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.
+
Radius: radius-md, 12px. One step up from a button's 8px — a card contains interactive elements, so it must read as a larger unit than they are.
+
Elevation: fill step, not shadow.surface-1 against the page background — dark surfaces never use a shadow for depth.
+
Nested radius: 6px. Rule 24 — inner cell radius = outer (12px) − the gap to its edge (≈6px), not a reused constant.
+
Status cells use on-fill. The ✓/! cells sit directly on success/danger — this is the one place in the app that already got the on-fill rule right.
+
+
+
The status colors sit at mid brightness, so ink gives strong on-palette contrast — this is the real code comment in day_status_calendar.dart, written before today's on-fill token existed to name the pattern.
Not yet themed. No InputDecorationTheme is set in theme.dart — every TextField renders M3's default indigo focus color, off-palette.
+
Tkinter side too.gatelock's tk.Entry sets bg/fg but no relief/bd — Tk's default sunken bevel border, not the flat line-dark outline.
+
+
+
+ ⚠
+ Open finding, not fixed: the input shown above is the target 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.
+
+
+
+
Property
Value
Rule
+
+
Radius
8px
shared with buttons/chips
+
Border
1px line-dark
flat, no bevel
+
Label
12px caption
chrome, not read content
+
Fill on focus
surface-1 → surface-2
fill step, not a shadow/glow
+
+
+
+
+
+
+
+
+
Chip 04live
+
diet-guard/app/lib/widgets/slot_selector_row.dart
+
+
+
+
+
+
+
+ ✓9am
+
+
+ 🍎 Snack
+
+
+
Fill/border at 15%/40% alpha of the status color. Not a fixed chip palette — logged/due/upcoming reuse success/danger/muted, so a chip's meaning matches the calendar's.
+
Bug, still open: the Snack chip sets no backgroundColor/side at all, so it falls through to Flutter's own default — a literal pure-white #FFFFFF border, the one banned value in the whole system.
+
+
+
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. Snack just needs the same three properties applied — not a new pattern, a missed one.
Same radius (radius-md), different depth technique, and that's correct, 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.
+
+
+
Property
Value
Rule
+
+
Radius
12px
radius-md, one step above buttons
+
Web shadow
0 4px 8px rgba(ink,.16)
level 2 — bigger than a dropdown's level 1
+
Actions
right-aligned, 8px gap
space-2 on the spacing scale
+
+
+
+
+
+
+
+
+
Switch 06spec only
+
no live usage yet — locking the values in before the first one gets built
+
+
+
+
+
+
+
+
+
+
+
Track = accent, thumb = on-fill. Same fill/on-fill pairing as a button — a switch is a tiny filled control, not a new color relationship to invent.
+
Fully rounded track. 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.
+
+
+
Off state: track becomes surface-2, thumb becomes muted — a fill-step change, matching how every other "inactive" state in this system works (never a separate grey palette).
+ ⚠
+ Open finding, not fixed:settings_screen.dart uses const Divider() with no dividerTheme set app-wide — same missing-theme pattern as Input above, this time landing on Material's default grey rather than line-dark.
+
+
+
+
Property
Value
Rule
+
+
Weight
1px
hairline — never a filled bar
+
Color
line-dark / line-light
same border token as cards/inputs
+
Spacing
space-3 (16px) each side
never touches adjacent text
+
+
+
+
+
+
+
+
+
Checkbox 08live
+
dufs-cloud/web/src/index.css — .tile-select
+
+
+
+
+
+
+
+
+
Chip sits on a scrim, not a fill.rgba(ink,0.45) over a photo thumbnail — this is on-scrim territory, not on-fill, since the thumbnail behind it varies.
+
Open finding: the checkbox itself is an unstyled native <input type="checkbox"> — its checked-state color is the browser's default blue, off-palette. No custom checkbox styling exists yet.
+
+
+
The 8px-radius scrim chip behind it is deliberately correct (matches radius-sm, dark translucent per the scrim rule) — only the checkbox glyph inside it is unstyled.
+
+
+
+
+
+
Snack bar / toast 09spec only
+
no live usage yet — no repo has a toast/snackbar today
+
+
+
+
+
+
+ Upload failed — connection lost
+ Retry
+
+
Never danger-filled. A toast is transient chrome, not a status cell — surface-2 + danger-colored icon/text reads as urgent without becoming a full alert box.
+
One action max.Retry 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.
+
+
+
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.
+
+
+
+
+
+
+
+
diff --git a/unified-design-system/tokens.md b/unified-design-system/tokens.md
new file mode 100644
index 0000000..b4037f8
--- /dev/null
+++ b/unified-design-system/tokens.md
@@ -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.