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> |
||
|---|---|---|
| .. | ||
| button.html | ||
| components.html | ||
| README.md | ||
| tokens.md | ||
unified-design-system
The concrete, frozen design tokens (colors, spacing, type, radius, shadow
policy) that implement safe-design-rules' 28 abstract rules identically
across every one of kuhy's repos — Flutter, web/React, and Python/Tkinter.
One palette everywhere (including Flutter launcher-icon glyphs), not just
shared rules. Use whenever building or fixing UI in any of kuhy's repos —
this is what a design audit's violations should be fixed against, and what
new UI should be built from instead of inventing new values. Full frozen
value table in tokens.md; annotated component references in
components.html (9 components: Button/Card/Input/Chip/
Dialog/Switch/Divider/Checkbox/SnackBar) and button.html
(the earlier single-component exemplar that set the doc format).
Read tokens.md for the full frozen value table before touching any
theme/CSS/style file.
Why one palette, not per-app branding
Apps are visually unified down to the exact hex values, not just the same
rules with different accents. This also means the Flutter launcher-icon
family (the app-icon skill/generator) uses the same accent for every app's
glyph — apps are distinguished by glyph shape, not color.
Per-stack implementation pattern
Flutter
Build ThemeData from explicit ColorScheme.dark(...)/.light(...)
using the exact hex values in tokens.md — not ColorScheme.fromSeed, which
algorithmically derives tones and won't reproduce hand-picked values. Add:
- A
ThemeExtensionfor roles M3 doesn't have (success/warning —erroris alreadydanger), e.g.AppStatusColors(success:, warning:). Only add this if the app actually has success/warning status indicators — grep for existingColors.green/Colors.orange-style usage first; don't add it speculatively (it becomes untested dead code otherwise). AppSpacing/AppRadius/AppTextSizestatic const classes mirroring the scales intokens.md.- Wire every screen through
Theme.of(context)— the dominant violation across every Flutter audit was rawColors.*literals bypassing an existing (or newly added) theme. Grep forColors\./Color(0xafter wiring the theme and route every hit through a theme role.
ThemeData buildTheme() => ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
surface: Color(0xFF211D1B), // ink
surfaceContainerHighest: Color(0xFF38312E), // ink-raised-2
onSurface: Color(0xFFECEAE9), // text-on-dark
onSurfaceVariant: Color(0xFFAAA09A), // muted-on-dark
outline: Color(0xFF463E3A), // line-dark
primary: Color(0xFFB8862E), // accent
onPrimary: Color(0xFF211D1B), // on-fill — NOT text-on-dark, see below
error: Color(0xFFE2585F), // danger
onError: Color(0xFF211D1B), // on-fill
),
extensions: const [AppStatusColors(
success: Color(0xFF8A9A3C),
onSuccess: Color(0xFF211D1B), // on-fill
warning: Color(0xFFE0A63C),
onWarning: Color(0xFF211D1B), // on-fill
)],
);
onPrimary/onError/onSuccess/onWarning are all on-fill (#211D1B,
dark), never onSurface's near-white — see tokens.md's on-fill entry:
every filled semantic color under-contrasts with near-white text.
Web (CSS custom properties)
One :root block (plus a light/dark override block as needed) with these
variable names — kept identical across repos so the vocabulary transfers:
:root {
--bg: #211d1b; --surface-1: #2b2624; --surface-2: #38312e;
--border: #463e3a; --text: #eceae9; --text-muted: #aaa09a;
--accent: #b8862e; --success: #8a9a3c; --warning: #e0a63c; --danger: #e2585f;
--on-fill: #211d1b; /* text/icons ON a filled accent/success/warning/danger surface */
--on-scrim: #eceae9; /* text/icons on a dark translucent overlay/backdrop — fixed, not theme-tied */
--radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px;
--space-1: 4px; --space-2: 8px; --space-3: 16px; --space-4: 24px;
--space-5: 32px; --space-6: 48px;
--font-sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", monospace;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f4f3; --surface-1: #fcfbfb; --surface-2: #fcfbfb;
--border: #e0dad7; --text: #211d1b; --text-muted: #70625b;
/* --on-fill / --on-scrim stay fixed — the fills and the scrim don't change with page theme */
}
}
body { font-size: 16px; font-family: var(--font-sans); }
button.primary { background: var(--accent); color: var(--on-fill); }
Don't reach for a single "on-accent" variable for both cases (an earlier
dufs-cloud draft did, and it broke the moment the accent went from blue to
gold): a filled button needs --on-fill (dark), a caption over a photo-tile
scrim needs --on-scrim (near-white) — same-looking near-white token, wrong
one silently fails contrast on fills.
Every hardcoded color/spacing/radius/shadow value in component CSS should
route through one of these variables. No #fff/#000/rgba(0,0,0,…)
literals — use var(--text)/var(--bg) or an ink-tinted rgba.
Python/Tkinter
No ttk.Style() in any of these apps currently — introduce (or, for the
screen-locker/wake_alarm/diet_guard family, extend) a shared constants
module/dataclass with the same token names, and have every tk.Label/
tk.Button/tk.Frame read from it instead of inline hex literals:
@dataclass(frozen=True)
class Tokens:
bg: str = "#211D1B"
fg: str = "#ECEAE9"
muted: str = "#AAA09A"
field_bg: str = "#2B2624"
accent: str = "#B8862E"
success: str = "#8A9A3C"
warning: str = "#E0A63C"
danger: str = "#E2585F"
on_fill: str = "#211D1B" # text on an accent/success/warning/danger bg — NOT fg
font_family: str = "Arial"
A shared _button(bg=...) helper that always sets fg=self._colors.fg is
the exact bug this caught: fg (near-white) is correct when bg is
field_bg/bg (a dark neutral surface), but any button whose bg is
accent/success/warning/danger needs fg=self._colors.on_fill
instead — pick fg from bg, don't hardcode it per call site.
For screen-locker/wake_alarm/diet_guard specifically: this lives on
gatelock.LockConfig (the shared token source already vendored in this same
~/utils monorepo, see ../gatelock/) rather than a
per-repo duplicate, since all three already depend on it. Extending
LockConfig requires production verification
(/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 (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.
Do NOT
- Don't use
ColorScheme.fromSeedfor 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=0on anything focusable, or sethighlightbackgroundwhen you meanthighlightcolor. - 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
tokens.md— round to the nearest step instead. - Don't add a
ThemeExtension/status-color role speculatively — check the app actually uses that status (success/warning/etc.) before adding it. - Don't touch
../gatelockwithout the production-path verification above; a broken import there takes down 3 services at once.