mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 12:27:47 +01:00
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>
258 lines
12 KiB
Markdown
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.
|