utils/unified-design-system/operability-patterns.md
Krzysztof kuhy Rudnicki b9e2312779 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
2026-08-22 15:42:27 +02:00

96 lines
4.3 KiB
Markdown

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