utils/unified-design-system
Krzysztof kuhy Rudnicki 682eaf6f8c
Some checks failed
github_device_auth tests / test (push) Has been cancelled
palette drift / palette (push) Has been cancelled
unified-design-system: record the two extractions this pass shipped
`ts_core` (@kuhyx/ts-core) and `github_device_auth` close the two clusters
the audit flagged as genuinely missed. Six shared packages now, not four.

Both extractions turned up the same lesson, worth writing down: the copy
that looks canonical is not always the one to take. Each cluster had a
behaviour living in exactly one repo that a naive "extract the tidiest
version" would have silently deleted --

  * diet-guard's configurable device-flow endpoints, without which its
    desktop web build cannot authenticate at all (no CORS headers on
    GitHub's endpoints)
  * wake-alarm's transient-socket retry, without which an approved grant
    is lost if GitHub closes the connection at the wrong moment

Both are now in the shared package, so all four apps gained the fix the
other three never had. Same shape in ts_core: the RNG ships two interfaces
rather than one reconciled shape, because the generated sequence is the
contract and re-rolling every saved world would be a worse outcome than
the duplication.

sims3-clone is recorded as the one consumer still pending -- another
session had uncommitted work there, so migrating it would have entangled
two sets of changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 16:17:34 +02:00
..
scripts unified-design-system: check one palette across all four token sources 2026-08-15 15:27:17 +02:00
button.html Consolidate unified-design-system docs into this monorepo 2026-07-22 21:23:47 +02:00
components.html Consolidate unified-design-system docs into this monorepo 2026-07-22 21:23:47 +02:00
extraction-audit-2026-08-15.md unified-design-system: record the two extractions this pass shipped 2026-08-15 16:17:34 +02:00
nielsen-audit.md unified-design-system: record the extraction audit, unstale the phase headings 2026-08-15 15:29:53 +02:00
phase3-record.md unified-design-system: record the Phase 3 gate 2026-08-15 15:11:03 +02:00
PHASE2-PROMPT.md Add copy-paste session prompts for Phase 2 and Phase 3 2026-08-14 23:38:17 +02:00
PHASE3-PROMPT.md Add copy-paste session prompts for Phase 2 and Phase 3 2026-08-14 23:38:17 +02:00
README.md Make lock surfaces keyboard-operable and safe on a 768px screen 2026-07-30 16:01:15 +02:00
screen-size.md design-system: add a categorical ramp; web_ui v0.2.0 2026-08-15 14:50:43 +02:00
tokens.md unified-design-system: check one palette across all four token sources 2026-08-15 15:27:17 +02:00

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; annotated component references in components.html (9 components: Button/Card/Input/Chip/ Dialog/Switch/Divider/Checkbox/SnackBar) and 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.
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:

: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:

@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/) 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 (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

// 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:

: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:

# 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 <space> on X11 but not <Return>; Entry/Spinbox bind <Return> to nothing. Bind <Return> yourself for submit.
  • tk.Text traps <Tab> (inserts a literal tab, refocuses itself) and makes <Shift-Tab> 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 <Prior>/<Next>/arrows, and add a <FocusIn> 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:

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.