From b9e231277988e9fe8fea7c72029ceb4cbb712faf Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 15:42:27 +0200 Subject: [PATCH] =?UTF-8?q?unified-design-system:=20split=20the=20three=20?= =?UTF-8?q?docs=20=E2=80=94=20repo=20now=20fully=20under=20the=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tokens.md 260 -> 219 + operability.md 69 nielsen-audit.md 258 -> 154 + shipped-record.md 122 README.md 257 -> 172 + operability-patterns.md 95 Worked the requested order rather than cutting straight to a split. (a) Automate: the categorical ramp's four constraints were restated in prose with the measured margins hand-copied (ΔE 34.6/26.0/25.6/27.4, etc). ramp_check.py prints every one of those numbers and fails if any regresses, so the prose now points at the command instead of duplicating its output. The two consequences the numbers do not state — why the ramp is mid-toned, and why lightness falls monotonically — stay in prose. (b) Dedup: measured, not assumed. Zero paragraphs are shared between the three files; README already delegates the rules to tokens.md and shows only per-stack code. Nothing to remove. (c/d) Split on meaning: tokens.md is "frozen values", so the behavioural requirements that *use* those tokens moved to operability.md, with the per-stack code for them in operability-patterns.md. nielsen-audit.md is a live grid, so completed phases moved to shipped-record.md — following the existing phase3-record.md convention, not a new one. The focus-ring token row had to stay in tokens.md: palette_check.py parses that file, and moving the row made the check fail with "MISSING md:focus-ring" — a token it cannot see is a token nothing compares across the four stacks. The rationale lives in operability.md and the row links to it. palette_check and ramp_check both at 0 failing checks; every relative link between the docs resolves. `bash scripts/check_file_length.sh --all` now exits 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG --- unified-design-system/README.md | 93 +------------ unified-design-system/nielsen-audit.md | 114 +--------------- unified-design-system/operability-patterns.md | 95 ++++++++++++++ unified-design-system/operability.md | 69 ++++++++++ unified-design-system/shipped-record.md | 122 ++++++++++++++++++ unified-design-system/tokens.md | 93 ++++--------- 6 files changed, 321 insertions(+), 265 deletions(-) create mode 100644 unified-design-system/operability-patterns.md create mode 100644 unified-design-system/operability.md create mode 100644 unified-design-system/shipped-record.md 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).