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
4.3 KiB
Pointer-free + small-screen: per-stack patterns
Split out of 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 in Flutter, on
the web, and in Python/Tkinter.
The rules themselves are in 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
// 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:
: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:
# 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.Buttonbinds<space>on X11 but not<Return>;Entry/Spinboxbind<Return>to nothing. Bind<Return>yourself for submit.tk.Texttraps<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.Canvashas 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/Menubuttondefault totakefocus=0. On lock surfaces they are banned anyway (a posted menu steals the grab); use radio buttons.ttk.Notebookneeds an explicitenable_traversal()forCtrl+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:
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.