Make lock surfaces keyboard-operable and safe on a 768px screen

gatelock v0.3.0. Every change here works around a Tk *default*, not an
application mistake -- which is why they belong in the shared backend rather
than being fixed four times.

On a lock surface these are not accessibility niceties. The lockers hold a
global input grab with VT switching disabled, so a control the keyboard cannot
reach, or a button clipped off the bottom of the screen, is a bricked machine.

LockConfig gains a type scale, a spacing scale and a focus-ring token:

- font() applies Tk's sign convention. Tk encodes the unit in the SIGN of the
  font size -- positive is points, negative is pixels -- so passing the
  design system's px values straight through rendered every string ~30%
  oversized (measured: body at 16pt has a 26px linespace where 16px gives 20).
  That alone pushed diet_guard's meal gate to 866px of content inside a 739px
  pane on a 1366x768 screen.
- focus_kwargs() sets highlightcolor, the *focused* ring. Tk's default is a 1px
  BLACK ring, invisible against the palette's #211D1B background. Note
  highlightbackground is the UNfocused ring: setting only that one inverts the
  affordance, which is what diet_guard was doing.

_keyboard.py collects three Tk defaults that each make a form unusable:

- tk.Text traps Tab (inserts a literal tab and refocuses itself) and no-ops
  Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- tk.Button binds <space> on X11 but never <Return>, so the key a user reaches
  for to submit does nothing.
- Escape needs binding at the toplevel to work wherever focus happens to sit.

_scrollable.py adds ScrollableSurface, a viewport that content can actually be
reached in:

- A place()-centred frame takes its *requested* size and is clipped
  SYMMETRICALLY, so overflow removes the heading and the submit button at the
  same time, with no scrollbar and no hint anything is missing.
- A bare tk.Canvas has no class key bindings, so ::tk::FocusOK rejects it as a
  focus stop and the only way to scroll is dragging the thumb with a pointer.
- Canvas clipping does not unmap children, so scrolled-out fields stay in the
  tab ring while Tk never scrolls to follow focus -- Tab lands on fields the
  user can neither see nor reveal.
- A Canvas holds content in a canvas *item*, so it never propagates the
  content's requested size; a toplevel told to size itself has nothing to grow
  to. _request_content_size() fixes that, clamped to the display.

center_when_fits keeps short screens looking exactly as they did, so the
viewport can replace a centred frame without a visual change.

A scrollable dialog Toplevel is deliberately NOT the mechanism: on an
override-redirect lock surface a second toplevel steals the Tk grab, which the
recovery tick then kills -- the 2026-07-26 failure where a frozen sport
selector logged a walk as table tennis.

unified-design-system gains the two rule families this implements (pointer-free
operability, 1366x768 usable / 1024x600 graceful), a focus-ring token, and the
px/pt sign convention written down so it cannot recur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-30 16:01:15 +02:00
parent 206740889c
commit f0be8c5919
7 changed files with 697 additions and 4 deletions

View File

@ -25,6 +25,7 @@ from gatelock._escape import (
EscapeTracker,
)
from gatelock._guards import assert_not_under_pytest, wait_for_x_server
from gatelock._keyboard import bind_activate, bind_cancel, escape_text_tab_trap
from gatelock._outputs import (
Output,
OutputEnumerator,
@ -36,6 +37,7 @@ from gatelock._outputs import (
)
from gatelock._recovery import RecoveryLoop, RecoveryReport
from gatelock._root import GateRoot
from gatelock._scrollable import ScrollableSurface
from gatelock._surfaces import (
SurfaceBuilder,
SurfaceDelta,
@ -70,14 +72,18 @@ __all__ = [
"RandrBackend",
"RecoveryLoop",
"RecoveryReport",
"ScrollableSurface",
"SurfaceBuilder",
"SurfaceDelta",
"SurfaceInfo",
"SurfaceSet",
"assert_not_under_pytest",
"bind_activate",
"bind_cancel",
"default_runtime_dir",
"disable_vt_switching",
"enumerate_outputs",
"escape_text_tab_trap",
"grab_strength",
"mirror_text_widgets",
"needs_backdrop_root",

View File

@ -0,0 +1,103 @@
r"""Keyboard-operability helpers for lock surfaces.
Every function here works around a **Tk default**, not an application mistake.
That is why they belong in the shared backend: the same three defects otherwise
recur in every locker, and each one is a lockout rather than an annoyance,
because a lock surface holds a global input grab with VT switching disabled.
There is no other window to click and no console to escape to.
The defaults, verified against Tk 8.6's own class bindings:
- ``tk.Text`` traps Tab. ``bind Text <Key-Tab>`` runs
``tk::TextInsert %W \\t; focus %W; break``, so Tab inserts a literal tab and
refocuses the widget, and ``<Shift-Tab>`` is bound to do nothing. The only
built-in exits are ``Ctrl+Tab``/``Ctrl+Shift+Tab``, which no UI advertises.
- ``tk.Button`` ignores Enter. On X11 ``bind Button <space>`` exists but there
is **no** ``<Return>`` binding, and ``entry.tcl``/``spinbox.tcl`` bind
``<Return>`` to ``{# nothing}``. So Space works and Enter -- the key a user
reaches for to submit -- does nothing at all.
- Focus is invisible. Tk ships ``highlightcolor="#000000"`` at
``highlightthickness=1``, i.e. a black ring on the palette's near-black
background. See :meth:`gatelock.LockConfig.focus_kwargs`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import tkinter as tk
def escape_text_tab_trap(text: tk.Text) -> None:
"""Make Tab / Shift-Tab move focus out of a ``tk.Text``.
Without this a keyboard-only user who tabs into a multi-line field cannot
get back out to the submit button, which on a lock surface means they
cannot satisfy the lock at all.
Returning ``"break"`` is what stops Tk's own class binding from also
running and inserting the tab character.
Args:
text: The widget to un-trap.
"""
def _forward(_event: tk.Event) -> str:
text.tk_focusNext().focus_set()
return "break"
def _backward(_event: tk.Event) -> str:
text.tk_focusPrev().focus_set()
return "break"
text.bind("<Tab>", _forward, add="+")
text.bind("<Shift-Tab>", _backward, add="+")
# X11 delivers Shift+Tab as its own keysym, which <Shift-Tab> alone misses.
text.bind("<ISO_Left_Tab>", _backward, add="+")
def bind_activate(widget: tk.Widget) -> None:
"""Make Enter activate a button-like widget, as well as Space.
Tk binds only ``<space>`` on X11, so every button in an unmodified lock UI
is Space-only. Both the main Return key and the keypad's Enter are bound,
since they are distinct keysyms.
Args:
widget: A widget exposing ``invoke()`` (Button, Checkbutton, ...).
"""
def _activate(_event: tk.Event) -> str:
widget.invoke()
return "break"
widget.bind("<Return>", _activate, add="+")
widget.bind("<KP_Enter>", _activate, add="+")
def bind_cancel(widget: tk.Misc, callback: object) -> None:
"""Bind Escape on a widget's toplevel to a cancel/back action.
Bound at the toplevel rather than the widget so Escape works wherever focus
currently sits, which is the point -- a user who has tabbed somewhere
unexpected still needs the way back.
This is for *cancel* affordances only. Some lockers deliberately refuse
dismissal (an alarm you must solve a challenge to silence); making their
sanctioned back/cancel button reachable does not weaken that, but do not
use this to add an exit a lock is designed not to have.
Args:
widget: Any widget in the target toplevel.
callback: Zero-argument callable invoked on Escape.
"""
if not callable(callback):
message = "callback must be callable"
raise TypeError(message)
def _cancel(_event: tk.Event) -> str:
callback()
return "break"
widget.winfo_toplevel().bind("<Escape>", _cancel, add="+")

View File

@ -0,0 +1,299 @@
"""A keyboard-scrollable, focus-following viewport for lock-surface content.
Lock surfaces cannot solve "content taller than the screen" the obvious way.
A scrollable dialog would be a second ``Toplevel``, and on an
override-redirect lock surface a second toplevel steals the Tk grab, which the
recovery tick then kills a second later -- the 2026-07-26 failure where a
frozen sport selector logged a walk as table tennis. So the viewport has to
live inside the surface's own widget tree, which is what this provides.
Three defects this exists to prevent, all of them Tk defaults rather than
oversights in any one app:
1. **Centered content clips symmetrically.** A ``place``-centered frame takes
its *requested* size, so when it is too tall the parent shears equal
amounts off the top *and* the bottom -- losing the header and the submit
button together, with no scrollbar and no hint anything is missing. This
top-anchors content inside a scroll region instead.
2. **A Canvas viewport is not keyboard-scrollable.** ``tk.Canvas`` has no
class-level key bindings at all, so ``::tk::FocusOK`` rejects it as a focus
stop and the only way to move the view is dragging the scrollbar thumb with
a pointer. This makes the canvas focusable and binds PageUp/PageDown,
Home/End and arrows.
3. **Focus walks into invisible widgets.** Canvas clipping does not *unmap* a
child, so every scrolled-out-of-view field stays ``winfo viewable`` and
stays in the tab chain, while Tk never scrolls to follow focus. Tabbing
therefore lands on fields the user cannot see and cannot bring into view.
:meth:`ScrollableSurface.track_focus` fixes that by scrolling the focused
widget into view.
Wheel scrolling is bound too, but as an addition -- never as the only path.
"""
from __future__ import annotations
import logging
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from gatelock._window import LockConfig
_logger = logging.getLogger(__name__)
# Tk reports wheel notches as Button-4/5 on X11 rather than <MouseWheel>.
_WHEEL_UP = "<Button-4>"
_WHEEL_DOWN = "<Button-5>"
# Scroll step in "units" for arrow keys; pages use the viewport height.
_ARROW_UNITS = 3
# Extra px kept visible around a widget scrolled into view, so a focused
# field never sits flush against the viewport edge.
_FOCUS_MARGIN_PX = 12
class ScrollableSurface:
"""A scroll viewport whose content is reachable by keyboard alone.
Build it inside a lock surface, pack widgets into :attr:`content`, then
call :meth:`finalize` once the content exists.
Attributes:
container: The frame to place/pack into the parent surface.
canvas: The clipping viewport. Focusable, unlike a bare Canvas.
content: The frame to build UI into.
"""
def __init__(
self,
parent: tk.Misc,
config: LockConfig,
*,
show_scrollbar: bool = True,
center_when_fits: bool = False,
) -> None:
"""Create the viewport.
Args:
parent: The surface (or notebook tab) to build inside.
config: Token source for colors and the focus ring.
show_scrollbar: Draw a scrollbar alongside the canvas. Even when
False the content stays keyboard-scrollable; the bar is a
pointer affordance and a visual hint, not the mechanism.
center_when_fits: Vertically centre the content while it fits, and
fall back to top-anchored scrolling once it does not. This lets
a viewport replace a ``place``-centred frame without changing
how short screens look, while removing the failure mode that
made centring dangerous -- a centred frame that overflows is
clipped at *both* edges at once, losing the heading and the
submit button together with no scrollbar to recover either.
"""
self._config = config
self._center_when_fits = center_when_fits
self.container = tk.Frame(parent, bg=config.bg)
self.canvas = tk.Canvas(
self.container,
bg=config.bg,
highlightthickness=config.focus_thickness,
highlightcolor=config.focus_ring,
highlightbackground=config.bg,
borderwidth=0,
# Tk gives Canvas no key bindings, so it is not a focus stop by
# default and the viewport becomes pointer-only. Opt in.
takefocus=True,
)
self._scrollbar: tk.Scrollbar | None = None
if show_scrollbar:
self._scrollbar = tk.Scrollbar(
self.container,
orient="vertical",
command=self.canvas.yview,
# A Scrollbar has key bindings, so it lands in the tab ring --
# and would otherwise keep Tk's black default ring, invisible
# against bg. It stays focusable rather than being opted out:
# its own Prior/Next/arrow class bindings are a legitimate
# second way to scroll.
**config.focus_kwargs(),
)
self._scrollbar.pack(side="right", fill="y")
self.canvas.configure(yscrollcommand=self._scrollbar.set)
self.canvas.pack(side="left", fill="both", expand=True)
self.content = tk.Frame(self.canvas, bg=config.bg)
# anchor="n": top-anchored, so overflow scrolls instead of shearing
# the header and the submit button off opposite edges.
self._content_id = self.canvas.create_window(
(0, 0), window=self.content, anchor="n"
)
self.content.bind("<Configure>", self._on_content_resize, add="+")
self.canvas.bind("<Configure>", self._on_canvas_resize, add="+")
self._bind_scroll_keys()
def _on_content_resize(self, _event: tk.Event) -> None:
"""Keep the scroll region matched to the content's real size."""
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
self._reposition()
def _on_canvas_resize(self, event: tk.Event) -> None:
"""Center the content horizontally and match the viewport width.
Matching the width is what lets ``wraplength`` and ``justify=center``
behave the same as they did before the viewport was introduced.
"""
self.canvas.itemconfigure(self._content_id, width=event.width)
self._reposition()
def _reposition(self) -> None:
"""Place the content window, centring vertically only while it fits."""
width = self.canvas.winfo_width()
top = 0
if self._center_when_fits:
spare = self.canvas.winfo_height() - self.content.winfo_reqheight()
top = max(0, spare // 2)
self.canvas.coords(self._content_id, width / 2, top)
def _bind_scroll_keys(self) -> None:
"""Bind keyboard and wheel scrolling.
PageUp/PageDown/Home/End go on the *toplevel* where possible: in a
fullscreen lock those keys have no competing meaning, and binding there
means they work wherever focus currently sits rather than only when the
canvas holds it. Arrows bind to the canvas only -- bound globally they
would fight Listbox, Spinbox and Entry navigation.
"""
paging = (
("<Prior>", self._page_up),
("<Next>", self._page_down),
("<Home>", self._scroll_home),
("<End>", self._scroll_end),
)
target: tk.Misc = self.canvas
try:
target = self.canvas.winfo_toplevel()
except (tk.TclError, KeyError, AttributeError):
# Reachable when the widget tree's root is not a real toplevel --
# notably under tests that mock the Tk root. Degrade to binding on
# the canvas rather than failing to build the viewport at all:
# paging then works while the viewport has focus, which is strictly
# better than no keyboard scrolling. Logged rather than swallowed
# so a production occurrence is visible.
_logger.warning(
"no toplevel for scroll viewport; paging keys bound to the "
"canvas only, so they work only while it holds focus"
)
for seq, handler in paging:
target.bind(seq, handler, add="+")
self.canvas.bind("<Up>", self._line_up, add="+")
self.canvas.bind("<Down>", self._line_down, add="+")
for seq, handler in (
(_WHEEL_UP, self._line_up),
(_WHEEL_DOWN, self._line_down),
):
self.canvas.bind(seq, handler, add="+")
self.content.bind(seq, handler, add="+")
def _page_up(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_scroll(-1, "pages")
def _page_down(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_scroll(1, "pages")
def _line_up(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_scroll(-_ARROW_UNITS, "units")
def _line_down(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_scroll(_ARROW_UNITS, "units")
def _scroll_home(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_moveto(0.0)
def _scroll_end(self, _event: tk.Event | None = None) -> None:
self.canvas.yview_moveto(1.0)
def track_focus(self, widget: tk.Misc | None = None) -> None:
"""Scroll the focused descendant into view, recursively binding.
Call after the content is built. Every descendant gets a ``<FocusIn>``
handler, because a clipped widget stays in the tab chain: without this,
Tab walks the user onto fields that are scrolled out of sight with no
keyboard way to reveal them.
Args:
widget: Subtree root to bind. Defaults to :attr:`content`.
"""
target = self.content if widget is None else widget
target.bind("<FocusIn>", self._on_descendant_focus, add="+")
for child in target.winfo_children():
self.track_focus(child)
def _on_descendant_focus(self, event: tk.Event) -> None:
"""Scroll so the newly focused widget is fully visible."""
self.scroll_into_view(event.widget)
def scroll_into_view(self, widget: tk.Misc) -> None:
"""Scroll the viewport minimally so ``widget`` is fully visible.
No-op when the content already fits, or when the widget is not laid
out yet (a zero height means Tk has not placed it).
"""
self.canvas.update_idletasks()
span = self._content_span()
view_h = self.canvas.winfo_height()
if span <= view_h or widget.winfo_height() <= 0:
return
top = widget.winfo_rooty() - self.content.winfo_rooty()
bottom = top + widget.winfo_height()
offset = self.canvas.canvasy(0)
margin = _FOCUS_MARGIN_PX
if top - margin < offset:
target = max(0, top - margin)
elif bottom + margin > offset + view_h:
target = min(span - view_h, bottom + margin - view_h)
else:
return
self.canvas.yview_moveto(target / span)
def _content_span(self) -> int:
"""Return the scrollable content height in px."""
box = self.canvas.bbox("all")
return (box[3] - box[1]) if box else 0
def finalize(self) -> None:
"""Settle geometry, wire focus-following, and reset to the top.
Idempotent, so it is safe to call again after the content changes.
"""
self.content.update_idletasks()
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
self._request_content_size()
self.track_focus()
self.canvas.yview_moveto(0.0)
self._reposition()
def _request_content_size(self) -> None:
"""Ask for a canvas big enough for the content, clamped to the screen.
A ``Canvas`` holds its content in a canvas *item*, not as a
geometry-managed child, so it never propagates the content's requested
size into its own. A toplevel told to size itself (``geometry("")``)
therefore has nothing to grow to and stays at its minimum -- which is
how a 862px-wide button row ended up sheared inside a 700px window even
after a viewport was added.
Requesting the content's size fixes that where there is room, and the
clamp keeps a window from growing off a small display; anything still
too big scrolls instead of clipping. On a lock surface the container is
``place``d at ``relwidth/relheight=1``, which overrides this request --
correctly, since there the surface size is the screen.
"""
wanted_w = self.content.winfo_reqwidth()
wanted_h = self.content.winfo_reqheight()
bar = self._scrollbar.winfo_reqwidth() if self._scrollbar else 0
self.canvas.configure(
width=max(1, min(wanted_w, self.canvas.winfo_screenwidth() - bar)),
height=max(1, min(wanted_h, self.canvas.winfo_screenheight())),
)
def fits(self) -> bool:
"""Whether the content currently fits without scrolling."""
self.canvas.update_idletasks()
return self._content_span() <= self.canvas.winfo_height()

View File

@ -49,6 +49,8 @@ _logger = logging.getLogger(__name__)
GrabKind = Literal["none", "local", "global"]
LockMode = Literal["soft", "hard"]
TypeRole = Literal["display", "title", "subtitle", "body", "label", "caption"]
SpaceStep = Literal["xs", "sm", "md", "lg", "xl", "xxl"]
# Periodic no-op so a grabbed, event-starved loop keeps handing control back
# to Python, letting SIGTERM/SIGINT be serviced promptly.
@ -105,10 +107,20 @@ class LockConfig:
must pick ``fg`` vs. ``on_fill`` based on the widget's own
background, never hardcode one for all buttons.
font_family: Default font family for lock-window widgets.
focus_ring: Color of the *focused* widget's highlight ring. Defaults to
``accent``, because Tk's own default is black -- invisible against
``bg``. Pass to ``highlightcolor``; note ``highlightbackground`` is
the *unfocused* ring, so setting that one inverts the affordance.
focus_thickness: Ring width in px. Never set 0 on a focusable widget.
type_display, type_title, type_subtitle, type_body, type_label,
type_caption: The type scale, in **pixels**. Do not pass these to
Tk directly -- use :meth:`font`, which applies the sign convention.
space_xs, space_sm, space_md, space_lg, space_xl, space_xxl: The 4px
spacing scale, in pixels. Use for ``padx``/``pady``/``ipadx``.
All color/font defaults come from the ``unified-design-system`` skill
(``~/.claude/skills/unified-design-system/references/tokens.md``) -- the
same palette used by every one of kuhy's apps, Flutter and web included.
All color/font defaults come from the ``unified-design-system`` docs
(``~/utils/unified-design-system/tokens.md``) -- the same palette used by
every one of kuhy's apps, Flutter and web included.
"""
mode: LockMode = "hard"
@ -131,6 +143,78 @@ class LockConfig:
danger: str = "#E2585F"
on_fill: str = "#211D1B"
font_family: str = "Arial"
focus_ring: str = "#B8862E"
focus_thickness: int = 2
# Type scale in PIXELS (unified-design-system tokens.md). Convert via
# font(); a raw positive value handed to Tk means *points*, ~37% bigger.
type_display: int = 32
type_title: int = 24
type_subtitle: int = 20
type_body: int = 16
type_label: int = 14
type_caption: int = 12
# 4px spacing scale, in pixels.
space_xs: int = 4
space_sm: int = 8
space_md: int = 16
space_lg: int = 24
space_xl: int = 32
space_xxl: int = 48
def type_px(self, role: TypeRole = "body") -> int:
"""Return the type-scale size for ``role``, in pixels."""
return int(getattr(self, f"type_{role}"))
def space(self, step: SpaceStep = "md") -> int:
"""Return the spacing-scale value for ``step``, in pixels."""
return int(getattr(self, f"space_{step}"))
def font(
self,
role: TypeRole = "body",
*,
bold: bool = False,
family: str | None = None,
scale: float = 1.0,
) -> tuple[str, int] | tuple[str, int, str]:
"""Return a Tk font tuple for a type-scale role, sized in **pixels**.
Tk encodes the unit in the *sign* of the size: positive means points,
negative means pixels. The design-system scale is in pixels, so passing
e.g. ``type_body`` (16) straight to Tk yields 16 *points* -- about 37%
larger than intended (measured: linespace 26px vs 19px). Inflating
every string by a third is enough on its own to push a layout off a
768px-tall screen, which is exactly what happened to the diet_guard
meal gate. Always build lock-window fonts through this method.
Args:
role: Type-scale role.
bold: Append Tk's ``"bold"`` weight.
family: Override the font family. Defaults to ``font_family``.
scale: Multiplier for display-only emphasis (e.g. an oversized
countdown). Kept explicit so outliers are visible rather than
hidden behind a fresh literal.
Returns:
A Tk font tuple with a negative (pixel) size.
"""
px = max(1, round(self.type_px(role) * scale))
name = family if family is not None else self.font_family
return (name, -px, "bold") if bold else (name, -px)
def focus_kwargs(self) -> dict[str, str | int]:
"""Return widget kwargs that make focus visible on this palette.
``highlightcolor`` is the *focused* ring; ``highlightbackground`` is the
unfocused one. Both are set so the widget shows a subdued edge when
unfocused and the accent ring when focused -- rather than Tk's default
black-on-``bg``, which reads as no ring at all.
"""
return {
"highlightcolor": self.focus_ring,
"highlightbackground": self.bg,
"highlightthickness": self.focus_thickness,
}
def resolved_overrideredirect(self) -> bool:
"""Return the effective overrideredirect setting."""

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "gatelock"
version = "0.2.1"
version = "0.3.0"
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
requires-python = ">=3.10"
dependencies = [] # pure stdlib — tkinter is bundled with Python

View File

@ -144,10 +144,109 @@ 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
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.
## Do NOT
- Don't use `ColorScheme.fromSeed` for the unified palette — it can't
reproduce hand-picked hex values exactly.
- Don't pass a positive font size to Tk — that's points, ~37% oversized.
- Don't make long-press, hover, or drag the only route to an action.
- Don't set `highlightthickness=0` on anything focusable, or set
`highlightbackground` when you meant `highlightcolor`.
- Don't centre-anchor content that can overflow — it clips symmetrically, taking
the header and the submit button with it.
- Don't tune a layout against a 1080p screen, and don't test responsiveness only
at phone-portrait sizes; neither exercises 1366x768 or 1024x600.
- Don't add shadows to dark surfaces, or mix shadow + border depth on the
same element (see `tokens.md`'s shadow policy).
- Don't invent a new spacing/radius/font value outside the scales in

View File

@ -98,6 +98,26 @@ contexts only.
Line length (rule 21): prose/paragraph blocks capped at `max-width: 40rem`
(~640px, ~65–70 characters).
### The sizes above are **pixels**. On Tk that means a negative number.
Tk encodes the unit in the *sign* of the font size: **positive = points,
negative = pixels**. So `("Arial", 16)` is 16 **points**, not 16 pixels, and
renders ~37% larger than this scale specifies. Measured:
| Spec | `size=16` (points) | `size=-16` (pixels) |
| ---- | ------------------ | ------------------- |
| `linespace` | 26px | 19px |
| `ascent` | 21px | 15px |
Always write the negative form in Tk — `font=(family, -16)` for `body`. Passing
the positive px value straight through inflates every string in the app by a
third, which is enough on its own to push a 768px-tall layout off-screen (it
did: it was the root cause of the diet-guard meal gate needing 974px in a 724px
pane). This is a silent failure — nothing errors, the text is just wrong-sized —
so it is checked by the gate rather than left to review.
Flutter `fontSize` and CSS `px` are already pixels; no sign convention there.
## Buttons
Horizontal padding = 2× vertical (rule 22). Canonical: vertical `12px`,
@ -121,3 +141,85 @@ 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
| 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.
⚠️ 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)
| Target | Requirement |
| ------ | ----------- |
| **1366x768** | Fully usable. Nothing clipped, nothing unreachable. This is the primary machine. |
| **1024x600** | Degrades gracefully — scrolls rather than clips. No hard minimum size above this. |
Rules:
1. **Budget the height, don't assume it.** 768px is the design target, not 1080.
Mixing a proportional content area with fixed chrome (`height = 0.7·H` above a
fixed-height button row) silently works at 1080 and fails at 768 — compute the
content budget as `available − measured chrome`.
2. **Any view that can grow must scroll.** Lists, forms, anything whose height
depends on data (a day's log, an error string, accumulated history). An
unbounded error message is a real overflow source.
3. **Never centre unscrollable content that can overflow.** A centre-anchored
container clips **symmetrically** when it is too tall — losing the header *and*
the submit button, with no scrollbar and no indication anything is missing.
Top-anchor it inside a scroll container instead.
4. **Clamp width as well.** Prose stays at the 40rem/640px cap (rule 21) and
single-purpose inputs get a sensible max — a 4-digit number field stretched to
1334px is a violation even though nothing is clipped.
5. **Don't let text scale break layout.** Rows that must not overflow use `Wrap`/
`Flexible` rather than a bare `Row`.
`1366x768` and `1024x600` are landscape and *short*. Phone-portrait test sizes do
not exercise either constraint, so both belong in the test matrix explicitly.