unified-design-system: split the three docs — repo now fully under the cap

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 15:42:27 +02:00
parent 06ca32ce6d
commit b9e2312779
6 changed files with 321 additions and 265 deletions

View File

@ -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 `<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.
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

View File

@ -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 `<Return>` 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 **`<Return>` 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

View File

@ -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 `<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.

View File

@ -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`/`<Button-1>`/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.

View File

@ -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 `<Return>` 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 **`<Return>` 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`.

View File

@ -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`/`<Button-1>`/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).