# 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. ## Pointer-free + small-screen: per-stack patterns The rules themselves are in [`tokens.md`](tokens.md) (*Pointer-free operability*, *Screen size*). This is how to implement them per stack. Both are machine-checked — a static lint in pre-commit plus a render test in each repo's suite — so treat these as the shape the checks expect. ### Flutter ```dart // Reach: never a bare GestureDetector for an action. InkWell(onTap: select, child: cell) // focusable + Enter/Space // A destructive action needs a focusable control, not just onLongPress: IconButton(icon: Icon(Icons.delete), onPressed: confirmDelete) // App-level accelerators. On the Chrome --app desktop surface, avoid chords // Chrome reserves (Ctrl+N/T/W/Shift+N) — they never reach Flutter. Shortcuts(shortcuts: { SingleActivator(LogicalKeyboardKey.keyS, control: true): SaveIntent(), SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), }, child: Actions(actions: {...}, child: child)); // Size: budget height, clamp width. final short = MediaQuery.sizeOf(context).height < 700; // 768 minus chrome ConstrainedBox(constraints: BoxConstraints(maxWidth: kProseMaxWidth), child: prose); ``` Any screen whose content can grow needs a `SingleChildScrollView`/`ListView` — a `Column` with one `Expanded` child does not scroll, it just collapses the flexible child until it is unusable. Bottom sheets need `isScrollControlled: true` plus an inner scroll view. Note on **Flutter web** (todo-app and diet-guard's desktop surface are the web build in a Chrome `--app` window, not the Linux embedder): the semantics tree is not built unless assistive tech is detected, so `Semantics` is not a substitute for real focusability — use focusable widgets, and treat `Semantics` as additive. ### Web `:focus-visible` must be styled everywhere, and never removed: ```css :focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } /* Never `outline: none` without an equally visible replacement. */ ``` Verify at the required viewports with no horizontal scroll and no *inner* scrollbar: `1366x768` and `1024x600` at minimum. Prefer a compact baseline that grows, over a desktop layout that must shrink. ### Python/Tkinter Font sizes are **negative** (see `tokens.md` — positive means points and inflates everything ~37%). The focus ring and both scales live on `gatelock.LockConfig` so all four lockers inherit them: ```python # focused ring — NOT highlightbackground, which is the *unfocused* ring widget.configure(highlightcolor=cfg.focus_ring, highlightthickness=2) widget.configure(font=(cfg.font_family, -cfg.type_body)) # negative == pixels ``` Tk specifics that are easy to get wrong, all verified against Tk 8.6's own class bindings: - `tk.Button` binds `` on X11 but **not** ``; `Entry`/`Spinbox` bind `` to nothing. Bind `` yourself for submit. - `tk.Text` traps `` (inserts a literal tab, refocuses itself) and makes `` a no-op. Rebind both to move focus, or the box is a dead end. - `tk.Canvas` has no class key bindings, so a Canvas scroll viewport is not even a focus stop. Bind ``/``/arrows, **and** add a `` handler on children that scrolls the focused child into view — clipping does not unmap children, so focus walks into invisible fields by default. - `OptionMenu`/`Menubutton` default to `takefocus=0`. On lock surfaces they are banned anyway (a posted menu steals the grab); use radio buttons. - `ttk.Notebook` needs an explicit `enable_traversal()` for `Ctrl+Tab` / `Ctrl+PageDown` / `Alt`+mnemonic. - Verify the focus ring by *walking* it (`tk_focusNext`), not by reading code. Measure the height rather than eyeballing it — this is how the meal-gate overflow was found, and it is what the render half of the gate does: ```bash xvfb-run -s "-screen 0 1366x768x24" python3 -m your_pkg.tests.measure_layout # assert frame.winfo_reqheight() <= available_pane_height ``` Measure both empty **and** fully populated (a day of meals logged, history rows present) — the empty case is not the worst case. ## Do NOT - Don't use `ColorScheme.fromSeed` for the unified palette — it can't reproduce hand-picked hex values exactly. - Don't pass a positive font size to Tk — that's points, ~37% oversized. - Don't make long-press, hover, or drag the only route to an action. - Don't set `highlightthickness=0` on anything focusable, or set `highlightbackground` when you meant `highlightcolor`. - Don't centre-anchor content that can overflow — it clips symmetrically, taking the header and the submit button with it. - Don't tune a layout against a 1080p screen, and don't test responsiveness only at phone-portrait sizes; neither exercises 1366x768 or 1024x600. - 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.