From f0be8c59196992b3e430992c8fa9555c6bc79e38 Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Thu, 30 Jul 2026 16:01:15 +0200 Subject: [PATCH] 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 on X11 but never , 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) --- gatelock/gatelock/__init__.py | 6 + gatelock/gatelock/_keyboard.py | 103 +++++++++++ gatelock/gatelock/_scrollable.py | 299 +++++++++++++++++++++++++++++++ gatelock/gatelock/_window.py | 90 +++++++++- gatelock/pyproject.toml | 2 +- unified-design-system/README.md | 99 ++++++++++ unified-design-system/tokens.md | 102 +++++++++++ 7 files changed, 697 insertions(+), 4 deletions(-) create mode 100644 gatelock/gatelock/_keyboard.py create mode 100644 gatelock/gatelock/_scrollable.py diff --git a/gatelock/gatelock/__init__.py b/gatelock/gatelock/__init__.py index 7813ce6..5f144c4 100644 --- a/gatelock/gatelock/__init__.py +++ b/gatelock/gatelock/__init__.py @@ -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", diff --git a/gatelock/gatelock/_keyboard.py b/gatelock/gatelock/_keyboard.py new file mode 100644 index 0000000..c3d3201 --- /dev/null +++ b/gatelock/gatelock/_keyboard.py @@ -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 `` runs + ``tk::TextInsert %W \\t; focus %W; break``, so Tab inserts a literal tab and + refocuses the widget, and ```` 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 `` exists but there + is **no** ```` binding, and ``entry.tcl``/``spinbox.tcl`` bind + ```` 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("", _forward, add="+") + text.bind("", _backward, add="+") + # X11 delivers Shift+Tab as its own keysym, which alone misses. + text.bind("", _backward, add="+") + + +def bind_activate(widget: tk.Widget) -> None: + """Make Enter activate a button-like widget, as well as Space. + + Tk binds only ```` 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("", _activate, add="+") + widget.bind("", _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("", _cancel, add="+") diff --git a/gatelock/gatelock/_scrollable.py b/gatelock/gatelock/_scrollable.py new file mode 100644 index 0000000..70cd7ce --- /dev/null +++ b/gatelock/gatelock/_scrollable.py @@ -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 . +_WHEEL_UP = "" +_WHEEL_DOWN = "" +# 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("", self._on_content_resize, add="+") + self.canvas.bind("", 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 = ( + ("", self._page_up), + ("", self._page_down), + ("", self._scroll_home), + ("", 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("", self._line_up, add="+") + self.canvas.bind("", 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 ```` + 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("", 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() diff --git a/gatelock/gatelock/_window.py b/gatelock/gatelock/_window.py index caf5182..97b827b 100644 --- a/gatelock/gatelock/_window.py +++ b/gatelock/gatelock/_window.py @@ -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.""" diff --git a/gatelock/pyproject.toml b/gatelock/pyproject.toml index 0f9f9f1..39f689b 100644 --- a/gatelock/pyproject.toml +++ b/gatelock/pyproject.toml @@ -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 diff --git a/unified-design-system/README.md b/unified-design-system/README.md index 53a6667..0a9326a 100644 --- a/unified-design-system/README.md +++ b/unified-design-system/README.md @@ -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 `` 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. + ## 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 diff --git a/unified-design-system/tokens.md b/unified-design-system/tokens.md index b4037f8..e27eebe 100644 --- a/unified-design-system/tokens.md +++ b/unified-design-system/tokens.md @@ -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`/``/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.