utils/unified-design-system
Krzysztof kuhy Rudnicki f7418969cf Freeze the motion, haptic and sound vocabularies, and ship them as tokens
dopamine-ux prompt 01. The token layer froze colour, spacing, radius and type,
but every animation in every app picked its own duration and curve out of the
air -- so "200ms ease-in-out" and "180ms ease-out" both existed and neither was
wrong, because nothing said what right was.

unified-design-system/motion.md is the freeze: four durations, three curves,
a haptic vocabulary mapped onto Flutter's HapticFeedback, and three sound cues,
each with its one-line rationale. Shipped as AppDuration/AppCurve in Dart and
--duration-*/--ease-* in CSS, so a consumer imports the scale instead of
transcribing it.

Three things worth stating, because they are the parts easy to get wrong:

- Reduced motion collapses the *tokens themselves* (CSS @media, Flutter
  disableAnimations), so no consumer has to remember to branch.
- Haptics and sound are NOT motion and are deliberately left alone by that
  preference. Suppressing animation is not a request to lose confirmation that
  a tap landed; the sound opt-out is a separate, user-facing setting.
- Reward the recording, never the number recorded. The haptic fires on logging
  a meal, not on hitting a calorie target -- the latter rewards eating less
  rather than recording honestly, and corrupts the app's own data.

gatelock stays untouched: Tkinter has no animation framework, so motion there
is marked `n/a (tk)` rather than shipping a dead constant that reads as a
supported feature.

Tests assert the frozen values on both sides, and that the reduced-motion block
collapses every non-zero step -- verified by mutation: deleting one collapse
line fails the suite. flutter analyze / tsc / eslint clean, 68 Dart and 88 TS
tests pass, palette_check and the 250-line cap still exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:28:02 +02:00
..
scripts gatelock: split _recovery.py and _surfaces.py, and fix two fail-open guards 2026-08-22 15:08:50 +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
motion.md Freeze the motion, haptic and sound vocabularies, and ship them as tokens 2026-08-22 17:28:02 +02:00
nielsen-audit.md unified-design-system: split the three docs — repo now fully under the cap 2026-08-22 15:42:27 +02:00
operability-patterns.md unified-design-system: split the three docs — repo now fully under the cap 2026-08-22 15:42:27 +02:00
operability.md Fix a dangling anchor left by the doc split 2026-08-22 16:03:20 +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 unified-design-system: split the three docs — repo now fully under the cap 2026-08-22 15:42:27 +02:00
screen-size.md design-system: add a categorical ramp; web_ui v0.2.0 2026-08-15 14:50:43 +02:00
shipped-record.md unified-design-system: split the three docs — repo now fully under the cap 2026-08-22 15:42:27 +02:00
tokens.md unified-design-system: split the three docs — repo now fully under the cap 2026-08-22 15:42:27 +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

The rules are in operability.md; the per-stack code that satisfies them — Flutter, web and Python/Tkinter — is in operability-patterns.md.

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.