utils/unified-design-system/README.md
Krzysztof Rudnicki f0be8c5919 Make lock surfaces keyboard-operable and safe on a 768px screen
gatelock v0.3.0. Every change here works around a Tk *default*, not an
application mistake -- which is why they belong in the shared backend rather
than being fixed four times.

On a lock surface these are not accessibility niceties. The lockers hold a
global input grab with VT switching disabled, so a control the keyboard cannot
reach, or a button clipped off the bottom of the screen, is a bricked machine.

LockConfig gains a type scale, a spacing scale and a focus-ring token:

- font() applies Tk's sign convention. Tk encodes the unit in the SIGN of the
  font size -- positive is points, negative is pixels -- so passing the
  design system's px values straight through rendered every string ~30%
  oversized (measured: body at 16pt has a 26px linespace where 16px gives 20).
  That alone pushed diet_guard's meal gate to 866px of content inside a 739px
  pane on a 1366x768 screen.
- focus_kwargs() sets highlightcolor, the *focused* ring. Tk's default is a 1px
  BLACK ring, invisible against the palette's #211D1B background. Note
  highlightbackground is the UNfocused ring: setting only that one inverts the
  affordance, which is what diet_guard was doing.

_keyboard.py collects three Tk defaults that each make a form unusable:

- tk.Text traps Tab (inserts a literal tab and refocuses itself) and no-ops
  Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- tk.Button binds <space> on X11 but never <Return>, so the key a user reaches
  for to submit does nothing.
- Escape needs binding at the toplevel to work wherever focus happens to sit.

_scrollable.py adds ScrollableSurface, a viewport that content can actually be
reached in:

- A place()-centred frame takes its *requested* size and is clipped
  SYMMETRICALLY, so overflow removes the heading and the submit button at the
  same time, with no scrollbar and no hint anything is missing.
- A bare tk.Canvas has no class key bindings, so ::tk::FocusOK rejects it as a
  focus stop and the only way to scroll is dragging the thumb with a pointer.
- Canvas clipping does not unmap children, so scrolled-out fields stay in the
  tab ring while Tk never scrolls to follow focus -- Tab lands on fields the
  user can neither see nor reveal.
- A Canvas holds content in a canvas *item*, so it never propagates the
  content's requested size; a toplevel told to size itself has nothing to grow
  to. _request_content_size() fixes that, clamped to the display.

center_when_fits keeps short screens looking exactly as they did, so the
viewport can replace a centred frame without a visual change.

A scrollable dialog Toplevel is deliberately NOT the mechanism: on an
override-redirect lock surface a second toplevel steals the Tk grab, which the
recovery tick then kills -- the 2026-07-26 failure where a frozen sport
selector logged a walk as table tennis.

unified-design-system gains the two rule families this implements (pointer-free
operability, 1366x768 usable / 1024x600 graceful), a focus-ring token, and the
px/pt sign convention written down so it cannot recur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:01:15 +02:00

258 lines
12 KiB
Markdown

# 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 `<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:
```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.