diff --git a/unified-design-system/README.md b/unified-design-system/README.md index 0a9326a..cff6271 100644 --- a/unified-design-system/README.md +++ b/unified-design-system/README.md @@ -144,96 +144,11 @@ per-repo duplicate, since all three already depend on it. Extending (`/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 +## Pointer-free + small-screen -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. +The rules are in [`operability.md`](operability.md); the per-stack code that +satisfies them — Flutter, web and Python/Tkinter — is in +[`operability-patterns.md`](operability-patterns.md). ## Do NOT diff --git a/unified-design-system/nielsen-audit.md b/unified-design-system/nielsen-audit.md index 9f6b35b..ef40142 100644 --- a/unified-design-system/nielsen-audit.md +++ b/unified-design-system/nielsen-audit.md @@ -137,116 +137,12 @@ The five `GitHubMirrorScreen` copies each carry a near-verbatim "kept app-local rather than folded into `sync_settings_ui`" comment. That decision deserves revisiting **on its own**, not smuggled into a component extraction. -### Flutter — palette drift: DONE 2026-08-14 +### Shipped work -Standing decision: every app uses **exactly** the frozen palette; where copies -disagree, the most popular wins. That was unambiguous — 9 of 10 theme files -already used the gold `#B8862E` family. All four drifted repos are migrated. - -| Repo | Drift that was fixed | How | -|---|---|---| -| `testsAndMisc/focus_owner` | **entirely off-palette**: cool `#5B9DD9` accent, `#1B1D21` field, `#D9776B` danger. Its doc comment claimed it matched the other `com.kuhy.*` apps; it did not. The same six constants were also **duplicated verbatim** in `main.dart:16-21` — the file said they had been "lifted out", but the originals were never deleted. | `theme.dart` now aliases `AppPalette`, so the ~80 call sites keep their names; both copies gone. Literals 15 → 2 (`Colors.transparent`). Verified on the Pixel 6a. | -| `kuhylog` | the only repo using `ColorScheme.fromSeed` (blue `#3B82F6`) — the opposite of what every other theme file documents. | `KuhylogTheme.of()` delegates to `buildLightTheme`/`buildDarkTheme`. Kept as a wrapper for its `visualDensity`, flat `cardTheme` and `scoreColor`. Literals 1 → 0. | -| `epopeja_karta` | `ColorScheme` inlined in `app.dart`, no `theme.dart`. | Imports **tokens only** — see below. Literals 9 → 0. | -| `billsplit` | `ColorScheme.fromSeed(Colors.teal)` plus 15 ad hoc literals that mostly bypassed the theme entirely. | `buildLightTheme()`; error family → `error`/`onError`/`errorContainer`, muted text → `onSurfaceVariant`. Literals 15 → 4. | -| `diet-guard`, `macro-cam` | scales drifted from the six-repo consensus | **still outstanding** — adopt `design_system` | - -Two deliberate exceptions, so nobody "fixes" them back: - -- **`epopeja_karta` adopts `AppPalette`, not `buildDarkTheme()`.** Its nine - slots were already byte-identical to the frozen palette, so only the - transcription was local. It is one dense screen with a test asserting - nothing scrolls, and the shared theme's larger type scale plus - `dividerTheme.space` overflows it by 34px. Typography there is a product - decision; colour is the identity, and colour is what the system owns. - Importing tokens leaves the rendered `ThemeData` identical, so the no-scroll - test passes by construction rather than by hitting a pixel target. -- **`billsplit` keeps its four category-dot colours** (`deepOrange`/`amber`/ - `blueGrey`/`teal` for alcohol/mixer/deposit/other). They encode *category - identity*, not a theme role, and a single-accent system has no four distinct - hues to lend. Folding them into `primary`/`secondary`/`tertiary` would make - the categories stop being distinguishable, which is their only job. **This - is the open categorical-ramp question** — the design system currently has no - answer for "N mutually distinguishable hues", and it needs one before any - chart, tag or category UI can be built on it. - -### Remaining Flutter adopters - -`home_inventory`, `dufs-cloud`, `habit_stack`, `diet-guard`, `macro-cam`, -`wake-alarm/phone_app`. Each drops its local `theme.dart` for the tag-pinned -dep, as todo, untools, focus_owner, kuhylog, epopeja_karta and billsplit now -do. These six were never off-palette — they transcribe the right values by -hand — so this is duplication cleanup, not a visual fix. - -### Phase 2 — Python/Tk (`~/utils/gatelock`), SHIPPED (gatelock 0.5.0) - -> **Status: done.** `make_button`, `heading`/`row`, `ScrollableSurface` and -> `WidgetGroup` all ship in gatelock 0.5.0 (`c963cd8`, `d82107a`, `d25f288`); -> diet-guard and leetcode-guard are the migrated proof consumers. -> screen-locker (v0.4.0) and wake-alarm (v0.4.1) keep local fan-out copies -> **by design** — only two consumers were bumped to avoid restarting live -> systemd services. That asymmetry is not a regression; do not "fix" it. -> Everything below this line is the original plan, kept as the record of why. - -Not a new package: gatelock already owns the tokens (`LockConfig`), the Tk -plumbing and 4 consumers. Add composite widgets, which is where its scope -currently stops. - -1. `widgets.py::make_button` — canonical is diet-guard's - `_gatelock_buttons.py::make_button`, whose `variant` API picks text color - *from* the fill and so structurally prevents the `fg`-vs-`on_fill` contrast - bug. leetcode-guard's `_button` (no `` binding) and screen-locker's - inline `tk.Button` are the regressions being retired. -2. `widgets.py::heading()` / `row()` — from `leetcode_guard/_status_sections.py`. -3. **Export the existing `ScrollableSurface`** and delete - `leetcode_guard/status_view.py::_scrollable`, a hand-rolled reimplementation - that lost `takefocus` and the focus ring. Straight accessibility repair. -4. `widget_group.py::WidgetGroup` — the per-output fan-out reimplemented 4× - (~916 lines: screen-locker 173, leetcode-guard 105, diet-guard 309, - wake-alarm 329). Highest line-count win on this stack. - -Proof consumers: `diet-guard` and `leetcode-guard` (donor and worst offender). -Bump **only those two**; leave screen-locker and wake-alarm on their current -pin — Phase 2 is additive, and restarting live services is blast radius for no -gain. - -> **Verification gate — non-negotiable.** gatelock backs live systemd -> services. After any change: `/usr/bin/python3 -c "import gatelock"` against -> the real interpreter, not a dev venv. Do not touch the gate window's grab -> path. Then launch each gate under `xvfb-run -s "-screen 0 1366x768x24"` and -> confirm buttons render and **`` activates** them. - -### Phase 3 — TypeScript (`~/utils/web_ui`), SHIPPED (web_ui-v0.3.1) - -> **Status: done** — see `phase3-record.md` for what shipped and, importantly, -> why `FilterBar` was *not* extracted (a name collision, not a duplicate). -> Both proof consumers (dufs-cloud, awesome-mcp-explorer) are migrated. -> Everything below this line is the original plan, kept as the record of why. - -Lowest confirmed duplication (2 components, 2 repos) and the only stack needing -a brand-new consumption mechanism, so it carries the worst effort-to-payoff -ratio — sequenced last on purpose. - -1. `tokens.css` — one `:root` block. Fixes the worst fork found: **7 repos - define 7 different vocabularies for the same palette** (`--surface-1` vs - `--panel` vs `--card`; `--text` vs `--ink` vs `--fg` vs `--bone`; spacing as - `--space-N` vs `--sp-N` vs `--sp-xs`). europe-county-map and iron-and-anvil - have **zero** custom properties. -2. `RangeSlider` — canonical is dufs-cloud's, whose pure - `fractionFromPointer(rect, clientX)` is testable without layout (jsdom has - none). awesome-mcp-explorer's copy has drifted 196 lines. -3. `FilterBar` + `filter-sort.ts` — reconcile dufs-cloud (174) vs - awesome-mcp-explorer (225). - -**Consumption mechanism (decided 2026-08-14):** a git dep -`"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.1.0"`, matching the Dart/Python -tag convention rather than introducing npm workspaces — but shipping a -**prebuilt `dist/`** (tsc → ESM + `.d.ts`) committed in the tag. Raw `.tsx` in -`node_modules` would need per-repo bundler config, since Vite does not -transpile dependencies by default; a committed `dist/` keeps consumers at zero -config. - -Proof consumers: `dufs-cloud` and `awesome-mcp-explorer`. +Palette drift, Phase 2 (Python/Tk) and Phase 3 (TypeScript) are all done. The +decisions behind them — including two deliberate exceptions that must not be +"corrected" back, and the remaining Flutter adopters — are in +[`shipped-record.md`](shipped-record.md). ## Standing risks diff --git a/unified-design-system/operability-patterns.md b/unified-design-system/operability-patterns.md new file mode 100644 index 0000000..d5ecdff --- /dev/null +++ b/unified-design-system/operability-patterns.md @@ -0,0 +1,95 @@ +# Pointer-free + small-screen: per-stack patterns + +Split out of [`README.md`](README.md), which covers the palette and the +per-stack *theming* pattern. This file is the other half: how to satisfy the +operability requirements in [`operability.md`](operability.md) in Flutter, on +the web, and in Python/Tkinter. + +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. diff --git a/unified-design-system/operability.md b/unified-design-system/operability.md new file mode 100644 index 0000000..3994bb9 --- /dev/null +++ b/unified-design-system/operability.md @@ -0,0 +1,69 @@ +# Pointer-free operability and screen size (required) + +Split out of [`tokens.md`](tokens.md), which freezes the palette and the +scales. These are the *behavioural* requirements that use those tokens — a +token file should be readable as a table of values, and these are rules with +reasons attached. + +Per-stack implementations of everything here are in +[`README.md`](README.md#pointer-free--small-screen-per-stack-patterns). + +## Pointer-free operability (required) + +**Every action must be reachable and activatable with the keyboard alone.** No +mouse, no touchpad, no touch. This is not an accessibility nicety here: several +of these apps are *lockers* that hold a global input grab with VT switching +disabled, so a control only a pointer can reach is not an inconvenience — it is +a bricked machine. + +Six things must hold on every surface: + +1. **No pointer-only handler.** Any `onTap`/`onLongPress`/``/click + handler needs a keyboard-reachable equivalent on the same action. A bare + `GestureDetector` and an `onLongPress`-only destructive action are both + defects. Long-press in particular has *no* keyboard analogue — never make it + the only path to an action. +2. **Everything interactive is a focus stop.** Widgets that opt out of the focus + ring (`takefocus=0`, which Tk's `Menubutton`/`OptionMenu` default to) are + unreachable. Verify by walking the real ring, not by reading the code. +3. **Focus is visible.** See the focus-ring token below — the platform default + is a black ring, which vanishes on `ink`. +4. **Focus never lands somewhere invisible.** If a container scrolls, focus + entering a clipped child must scroll it into view. Clipping does not remove a + widget from the focus ring, so this is the default failure, not an edge case. +5. **Scrolling is keyboard-driven.** A scroll container needs `PageUp`/`PageDown` + and arrow keys, not just a draggable thumb and a wheel. +6. **Confirm/submit and cancel/back have accelerators.** `Enter` submits, `Escape` + cancels. On Tk, note `Enter` does **not** activate a `tk.Button` on X11 (only + `Space` does) and is bound to nothing on `Entry`/`Spinbox` — you must bind it. + +### The focus-ring token + +`focus-ring` is `#B8862E` (= `accent`), 2px at 1px offset. It is defined in +[`tokens.md`](tokens.md#focus-ring) with the rest of the palette, because +`scripts/palette_check.py` parses that file — a token it cannot see is a token +nothing compares across the four stacks. + +Platform defaults are wrong on this palette: Tk ships +`highlightcolor="#000000"` with `highlightthickness=1`, i.e. a black ring on +`ink` (`#211D1B`) — invisible. Set `focus-ring` explicitly. Never +`highlightthickness=0` on something focusable. + +⚠️ On Tk, `highlightbackground` is the **unfocused** ring and `highlightcolor` +is the **focused** one. Setting only `highlightbackground` inverts the +affordance — the widget outlines when it is *not* focused and goes black when +it *is*. Set `highlightcolor`. + +### Escape hatches vs. deliberate gating + +Some apps intentionally resist dismissal (an alarm you must solve a challenge to +silence, a meal gate you must log a meal to clear). That gating is a feature and +this rule does not override it. What the rule requires is that the *sanctioned* +paths — solve the challenge, use the budgeted escape hatch, submit the form — are +fully keyboard-operable. Making a gate keyboard-reachable is not weakening it. + +## Screen size (required) + +Moved to `screen-size.md` (250-line cap): the 1366x768 / 1024x600 targets, the +height-budgeting rules, and why centring unscrollable content clips +symmetrically. Still part of this spec — read it for any layout work. diff --git a/unified-design-system/shipped-record.md b/unified-design-system/shipped-record.md new file mode 100644 index 0000000..583a270 --- /dev/null +++ b/unified-design-system/shipped-record.md @@ -0,0 +1,122 @@ +# Shipped record: palette drift, Phase 2, Phase 3 + +Companion to [`nielsen-audit.md`](nielsen-audit.md), which is the live audit +grid and the list of what is still open. This file is the history: work that +is **done**, kept because the *decisions* in it are load-bearing — the two +deliberate exceptions below exist so nobody "fixes" them back, and the +corrections found by measuring are why the current plan differs from the +original one. + +Same convention as [`phase3-record.md`](phase3-record.md), which records the +web phase in the same way. + +### Flutter — palette drift: DONE 2026-08-14 + +Standing decision: every app uses **exactly** the frozen palette; where copies +disagree, the most popular wins. That was unambiguous — 9 of 10 theme files +already used the gold `#B8862E` family. All four drifted repos are migrated. + +| Repo | Drift that was fixed | How | +|---|---|---| +| `testsAndMisc/focus_owner` | **entirely off-palette**: cool `#5B9DD9` accent, `#1B1D21` field, `#D9776B` danger. Its doc comment claimed it matched the other `com.kuhy.*` apps; it did not. The same six constants were also **duplicated verbatim** in `main.dart:16-21` — the file said they had been "lifted out", but the originals were never deleted. | `theme.dart` now aliases `AppPalette`, so the ~80 call sites keep their names; both copies gone. Literals 15 → 2 (`Colors.transparent`). Verified on the Pixel 6a. | +| `kuhylog` | the only repo using `ColorScheme.fromSeed` (blue `#3B82F6`) — the opposite of what every other theme file documents. | `KuhylogTheme.of()` delegates to `buildLightTheme`/`buildDarkTheme`. Kept as a wrapper for its `visualDensity`, flat `cardTheme` and `scoreColor`. Literals 1 → 0. | +| `epopeja_karta` | `ColorScheme` inlined in `app.dart`, no `theme.dart`. | Imports **tokens only** — see below. Literals 9 → 0. | +| `billsplit` | `ColorScheme.fromSeed(Colors.teal)` plus 15 ad hoc literals that mostly bypassed the theme entirely. | `buildLightTheme()`; error family → `error`/`onError`/`errorContainer`, muted text → `onSurfaceVariant`. Literals 15 → 4. | +| `diet-guard`, `macro-cam` | scales drifted from the six-repo consensus | **still outstanding** — adopt `design_system` | + +Two deliberate exceptions, so nobody "fixes" them back: + +- **`epopeja_karta` adopts `AppPalette`, not `buildDarkTheme()`.** Its nine + slots were already byte-identical to the frozen palette, so only the + transcription was local. It is one dense screen with a test asserting + nothing scrolls, and the shared theme's larger type scale plus + `dividerTheme.space` overflows it by 34px. Typography there is a product + decision; colour is the identity, and colour is what the system owns. + Importing tokens leaves the rendered `ThemeData` identical, so the no-scroll + test passes by construction rather than by hitting a pixel target. +- **`billsplit` keeps its four category-dot colours** (`deepOrange`/`amber`/ + `blueGrey`/`teal` for alcohol/mixer/deposit/other). They encode *category + identity*, not a theme role, and a single-accent system has no four distinct + hues to lend. Folding them into `primary`/`secondary`/`tertiary` would make + the categories stop being distinguishable, which is their only job. **This + is the open categorical-ramp question** — the design system currently has no + answer for "N mutually distinguishable hues", and it needs one before any + chart, tag or category UI can be built on it. + +### Remaining Flutter adopters + +`home_inventory`, `dufs-cloud`, `habit_stack`, `diet-guard`, `macro-cam`, +`wake-alarm/phone_app`. Each drops its local `theme.dart` for the tag-pinned +dep, as todo, untools, focus_owner, kuhylog, epopeja_karta and billsplit now +do. These six were never off-palette — they transcribe the right values by +hand — so this is duplication cleanup, not a visual fix. + +### Phase 2 — Python/Tk (`~/utils/gatelock`), SHIPPED (gatelock 0.5.0) + +> **Status: done.** `make_button`, `heading`/`row`, `ScrollableSurface` and +> `WidgetGroup` all ship in gatelock 0.5.0 (`c963cd8`, `d82107a`, `d25f288`); +> diet-guard and leetcode-guard are the migrated proof consumers. +> screen-locker (v0.4.0) and wake-alarm (v0.4.1) keep local fan-out copies +> **by design** — only two consumers were bumped to avoid restarting live +> systemd services. That asymmetry is not a regression; do not "fix" it. +> Everything below this line is the original plan, kept as the record of why. + +Not a new package: gatelock already owns the tokens (`LockConfig`), the Tk +plumbing and 4 consumers. Add composite widgets, which is where its scope +currently stops. + +1. `widgets.py::make_button` — canonical is diet-guard's + `_gatelock_buttons.py::make_button`, whose `variant` API picks text color + *from* the fill and so structurally prevents the `fg`-vs-`on_fill` contrast + bug. leetcode-guard's `_button` (no `` binding) and screen-locker's + inline `tk.Button` are the regressions being retired. +2. `widgets.py::heading()` / `row()` — from `leetcode_guard/_status_sections.py`. +3. **Export the existing `ScrollableSurface`** and delete + `leetcode_guard/status_view.py::_scrollable`, a hand-rolled reimplementation + that lost `takefocus` and the focus ring. Straight accessibility repair. +4. `widget_group.py::WidgetGroup` — the per-output fan-out reimplemented 4× + (~916 lines: screen-locker 173, leetcode-guard 105, diet-guard 309, + wake-alarm 329). Highest line-count win on this stack. + +Proof consumers: `diet-guard` and `leetcode-guard` (donor and worst offender). +Bump **only those two**; leave screen-locker and wake-alarm on their current +pin — Phase 2 is additive, and restarting live services is blast radius for no +gain. + +> **Verification gate — non-negotiable.** gatelock backs live systemd +> services. After any change: `/usr/bin/python3 -c "import gatelock"` against +> the real interpreter, not a dev venv. Do not touch the gate window's grab +> path. Then launch each gate under `xvfb-run -s "-screen 0 1366x768x24"` and +> confirm buttons render and **`` activates** them. + +### Phase 3 — TypeScript (`~/utils/web_ui`), SHIPPED (web_ui-v0.3.1) + +> **Status: done** — see `phase3-record.md` for what shipped and, importantly, +> why `FilterBar` was *not* extracted (a name collision, not a duplicate). +> Both proof consumers (dufs-cloud, awesome-mcp-explorer) are migrated. +> Everything below this line is the original plan, kept as the record of why. + +Lowest confirmed duplication (2 components, 2 repos) and the only stack needing +a brand-new consumption mechanism, so it carries the worst effort-to-payoff +ratio — sequenced last on purpose. + +1. `tokens.css` — one `:root` block. Fixes the worst fork found: **7 repos + define 7 different vocabularies for the same palette** (`--surface-1` vs + `--panel` vs `--card`; `--text` vs `--ink` vs `--fg` vs `--bone`; spacing as + `--space-N` vs `--sp-N` vs `--sp-xs`). europe-county-map and iron-and-anvil + have **zero** custom properties. +2. `RangeSlider` — canonical is dufs-cloud's, whose pure + `fractionFromPointer(rect, clientX)` is testable without layout (jsdom has + none). awesome-mcp-explorer's copy has drifted 196 lines. +3. `FilterBar` + `filter-sort.ts` — reconcile dufs-cloud (174) vs + awesome-mcp-explorer (225). + +**Consumption mechanism (decided 2026-08-14):** a git dep +`"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.1.0"`, matching the Dart/Python +tag convention rather than introducing npm workspaces — but shipping a +**prebuilt `dist/`** (tsc → ESM + `.d.ts`) committed in the tag. Raw `.tsx` in +`node_modules` would need per-repo bundler config, since Vite does not +transpile dependencies by default; a committed `dist/` keeps consumers at zero +config. + +Proof consumers: `dufs-cloud` and `awesome-mcp-explorer`. diff --git a/unified-design-system/tokens.md b/unified-design-system/tokens.md index 1025084..cc389b5 100644 --- a/unified-design-system/tokens.md +++ b/unified-design-system/tokens.md @@ -93,23 +93,24 @@ the signal. A categorical ramp has no meaning to lean on, so hue separation *is* the requirement. Treating this as drift and "correcting" it back toward the wedge would destroy the only property it has. -Chosen by search under four hard constraints, not by eye -(`unified-design-system/scripts/ramp_check.py` re-runs the proof): +Chosen by search under four hard constraints, not by eye: pairwise CIE ΔE ≥ 20 +under normal vision *and* simulated deuteranopia/protanopia/tritanopia, ≥ 3:1 +against **both** backgrounds, monotonically decreasing lightness, and muted +saturation. -1. **Pairwise CIE ΔE ≥ 20** between every pair — measured under normal vision - *and* simulated deuteranopia, protanopia and tritanopia. Worst observed: 34.6 - normal, 26.0 deuteranopia, 25.6 protanopia, 27.4 tritanopia. -2. **≥ 3:1 contrast against both backgrounds** (`ink` and `paper`), since the - ramp is theme-independent — it does not flip with `prefers-color-scheme`. - This is the binding constraint: colours legible on both a near-black and a - near-white field only exist in L\* ≈ 44–59, which is why the ramp is - mid-toned throughout rather than spanning light to dark. -3. **Monotonically decreasing lightness** (58 → 44), so hue and lightness encode - redundantly. The ramp therefore still reads as an ordinal scale in greyscale - or under severe CVD, which is what lets `cat-1…6` carry an ordered scale - (grades A→F) as well as an unordered one. -4. **Muted saturation**, to sit with the rest of the palette rather than - vibrating against it. +**Do not copy the measured margins into this file.** Run the check — it prints +every one of them and fails if any regresses: + +``` +python3 unified-design-system/scripts/ramp_check.py +``` + +Two consequences worth keeping in prose, because the numbers alone do not say +them. The both-backgrounds rule is the binding constraint: colours legible on +a near-black *and* a near-white field only exist around L\* 44–59, which is why +the ramp is mid-toned rather than spanning light to dark. And because lightness +falls monotonically, hue and lightness encode redundantly, so `cat-1…6` still +reads as an ordinal scale in greyscale or under severe CVD. Use them **in order** for ordered data, and *by position* for unordered data — never pick "the green one" because green means good, which is the semantic @@ -200,61 +201,19 @@ at the same z-level. Icons adjacent to text render at ~72% opacity of the text's color, or use the `muted` role instead of the full-strength text role — never equal contrast. -## Pointer-free operability (required) - -**Every action must be reachable and activatable with the keyboard alone.** No -mouse, no touchpad, no touch. This is not an accessibility nicety here: several -of these apps are *lockers* that hold a global input grab with VT switching -disabled, so a control only a pointer can reach is not an inconvenience — it is -a bricked machine. - -Six things must hold on every surface: - -1. **No pointer-only handler.** Any `onTap`/`onLongPress`/``/click - handler needs a keyboard-reachable equivalent on the same action. A bare - `GestureDetector` and an `onLongPress`-only destructive action are both - defects. Long-press in particular has *no* keyboard analogue — never make it - the only path to an action. -2. **Everything interactive is a focus stop.** Widgets that opt out of the focus - ring (`takefocus=0`, which Tk's `Menubutton`/`OptionMenu` default to) are - unreachable. Verify by walking the real ring, not by reading the code. -3. **Focus is visible.** See the focus-ring token below — the platform default - is a black ring, which vanishes on `ink`. -4. **Focus never lands somewhere invisible.** If a container scrolls, focus - entering a clipped child must scroll it into view. Clipping does not remove a - widget from the focus ring, so this is the default failure, not an edge case. -5. **Scrolling is keyboard-driven.** A scroll container needs `PageUp`/`PageDown` - and arrow keys, not just a draggable thumb and a wheel. -6. **Confirm/submit and cancel/back have accelerators.** `Enter` submits, `Escape` - cancels. On Tk, note `Enter` does **not** activate a `tk.Button` on X11 (only - `Space` does) and is bound to nothing on `Entry`/`Spinbox` — you must bind it. - -### The focus-ring token +## Focus ring | Token | Value | Notes | | ------------ | ---------- | -------------------------------------------- | | `focus-ring` | `#B8862E` (= `accent`) | 2px, offset 1px. | -Platform defaults are wrong on this palette: Tk ships -`highlightcolor="#000000"` with `highlightthickness=1`, i.e. a black ring on -`ink` (`#211D1B`) — invisible. Set `focus-ring` explicitly. Never -`highlightthickness=0` on something focusable. +Why the platform default is not good enough, and the Tk +`highlightcolor`/`highlightbackground` trap, are in +[`operability.md`](operability.md#the-focus-ring-token). -⚠️ On Tk, `highlightbackground` is the **unfocused** ring and `highlightcolor` -is the **focused** one. Setting only `highlightbackground` inverts the -affordance — the widget outlines when it is *not* focused and goes black when it -*is*. Set `highlightcolor`. +## Operability and screen size -### Escape hatches vs. deliberate gating - -Some apps intentionally resist dismissal (an alarm you must solve a challenge to -silence, a meal gate you must log a meal to clear). That gating is a feature and -this rule does not override it. What the rule requires is that the *sanctioned* -paths — solve the challenge, use the budgeted escape hatch, submit the form — are -fully keyboard-operable. Making a gate keyboard-reachable is not weakening it. - -## Screen size (required) - -Moved to `screen-size.md` (250-line cap): the 1366x768 / 1024x600 targets, the -height-budgeting rules, and why centring unscrollable content clips -symmetrically. Still part of this spec — read it for any layout work. +Keyboard-only reachability and the small-screen budget are requirements, not +suggestions: several of these apps are lockers that hold a global input grab, +so a control only a pointer can reach is a bricked machine. Both, plus the +`focus-ring` token, are in [`operability.md`](operability.md).