Fit the meal gate on 1366x768 with a full day logged, and stop the auto-scroll

gatelock 0.4.0 (pinned here) stops the viewport following the app's own
focus_set(): on 2026-08-03 that moved the screen locker's lock screen to
mid-content and back with nobody touching the keyboard, and this gate uses
the same viewport. Scrolling now happens only on the user's own keypress,
wheel or click.

That makes fitting the screen the gate's own responsibility rather than
something a scrollbar covers for, and with a day of meals logged this one did
not: 770px of content in a 739px pane. Trimmed the vertical rhythm (the
title is one step down the scale, and the gaps around the description,
suggestions, macros and footer are one step tighter) for 718px, with room for
the dashboard to keep growing.

Its layout test now requires the gate to *fit* rather than merely be
reachable by scrolling, empty and populated. Dropped the 1024x600 tier: no
machine runs it, this gate cannot reach it without dropping content, and a
target nobody uses is a constraint that only ever costs.

The probe presses Tab before moving focus, because a bare focus_set() no
longer scrolls -- it would have been testing a path no user can take. The
Tk fakes learned pack_forget(), which the viewport calls to take the
scrollbar down whenever the content fits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 16:00:13 +02:00
parent 1702685afd
commit 4a5e350baf
8 changed files with 64 additions and 38 deletions

View File

@ -101,7 +101,7 @@ repos:
additional_dependencies:
- pytest
- requests
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync
- mcp
- pydantic

View File

@ -22,11 +22,10 @@ from typing import TYPE_CHECKING
from gatelock import LockConfig, ScrollableSurface, escape_text_tab_trap
from diet_guard._gatelock_buttons import make_button
from diet_guard._gatelock_spacing import MD, SM, XS
from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import (
BODY,
CAPTION,
DISPLAY,
LABEL,
SUBTITLE,
TITLE,
@ -186,7 +185,7 @@ def _build_desc(parent: tk.Frame) -> tk.Text:
# bg) the moment it took focus, exactly inverting the affordance.
**_COLORS.focus_kwargs(),
)
text.pack(pady=(XS, SM))
text.pack(pady=(XS, XS))
_escape_text_tab_trap(text)
return text
@ -208,7 +207,7 @@ def _build_suggestion_box(parent: tk.Frame) -> tk.Listbox:
# visual indication of where focus was.
**_COLORS.focus_kwargs(),
)
box.pack(pady=(0, SM))
box.pack(pady=(0, XS))
return box
@ -227,7 +226,7 @@ def _build_amount_row(
fg=FG,
).pack()
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, SM))
row.pack(pady=(XS, XS))
amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount)
amount_entry.pack(side="left", ipady=XS)
_build_unit_selector(row, vars_, on_unit_change)
@ -310,7 +309,7 @@ def _build_macro_section(
).pack(side="left")
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, SM))
row.pack(pady=(XS, XS))
macros = _MacroEntries(
kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal),
protein=_macro_cell(root, row, "P", vars_.entries.protein),
@ -332,7 +331,7 @@ def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(MD, 0))
).pack(pady=(SM, 0))
tk.Label(
parent,
textvariable=vars_.dashboard,
@ -374,7 +373,7 @@ def build_layout(
tk.Label(
body,
text="🍽 Diet Gate",
font=(_FONT, DISPLAY, "bold"),
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(0, XS))
@ -386,7 +385,7 @@ def build_layout(
fg=FG,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(0, MD))
).pack(pady=(0, SM))
desc_text = _build_desc(body)
suggestion_box = _build_suggestion_box(body)
@ -415,14 +414,14 @@ def build_layout(
fg=_ACCENT,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(XS, SM))
).pack(pady=(XS, XS))
make_button(
body,
text="Log & Continue",
variant="primary",
command=callbacks.on_submit,
).pack(pady=(XS, SM))
).pack(pady=(XS, XS))
# Manual pull for a meal already logged on another device (the phone) but
# not yet propagated to this machine -- saves re-typing it to unlock.
@ -432,7 +431,7 @@ def build_layout(
variant="secondary",
command=callbacks.on_fetch_sync,
bold=False,
).pack(pady=(0, SM))
).pack(pady=(0, XS))
status_label = tk.Label(
body,

View File

@ -239,6 +239,13 @@ def _probe_viewport(
target = _deepest_focusable(inner)
last_visible = False
if target is not None:
# A real keypress first, then the focus it would have moved. Since
# 2026-08-03 gatelock only scrolls to follow focus the *user* moved:
# it used to follow programmatic focus too, and because the apps
# re-focus a widget on every repaint, the screen scrolled itself to
# mid-content and back while the user was doing nothing. A bare
# focus_set() here would therefore test a path no user can take.
target.event_generate("<Key-Tab>", when="now")
target.focus_set()
root.update()
root.update_idletasks()

View File

@ -125,10 +125,16 @@ class FakeWidget:
def __init__(self, *args: object, **kwargs: object) -> None:
self.configured: dict[str, object] = dict(kwargs)
self.packed = False
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
self.packed = True
return self
def pack_forget(self, *args: object, **kwargs: object) -> None:
"""Un-pack. The viewport hides its scrollbar whenever content fits."""
self.packed = False
def place(self, *args: object, **kwargs: object) -> FakeWidget:
return self
@ -161,6 +167,9 @@ class FakeWidget:
def cget(self, key: str) -> object:
return self.configured.get(key, "")
def focus_get(self) -> None:
"""Nothing holds focus in a fake tree."""
# Geometry queries. The scroll viewport asks for these to size itself and
# to decide whether content still fits; zero means "nothing measured yet",
# which is the honest answer for a fake with no layout.
@ -313,6 +322,9 @@ class FakeCanvas(FakeWidget):
def cget(self, key: str) -> object:
return self.configured.get(key, "")
def focus_get(self) -> None:
"""Nothing holds focus in a fake tree."""
class FakeStyle:
"""A no-op stand-in for ``ttk.Style`` -- records calls, applies nothing."""

View File

@ -24,11 +24,13 @@ import pytest
from diet_guard.tests._gate_layout_probe import measure
# The screen sizes tokens.md requires: 1366x768 fully usable (the primary
# machine), 1024x600 degrading gracefully. Both are landscape and SHORT --
# phone-portrait test sizes exercise neither constraint, which is why the
# overflow survived a test suite that already pinned surface sizes.
_REQUIRED_SIZES = [(1366, 768), (1024, 600)]
# The screen the gate must fit: the primary machine's 1366x768 panel. It is
# landscape and SHORT -- phone-portrait test sizes exercise neither
# constraint, which is why the overflow survived a test suite that already
# pinned surface sizes. Panels below 768px get gatelock's best-effort
# compaction (``gatelock._density``) but are not gated: no machine runs one,
# and the gate cannot be trimmed to 600px without dropping content.
_REQUIRED_SIZES = [(1366, 768)]
@pytest.mark.parametrize(("screen_w", "screen_h"), _REQUIRED_SIZES)
@ -59,12 +61,18 @@ def test_overflow_is_always_scrollable(screen_w: int, screen_h: int) -> None:
assert result.last_widget_visible_after_focus, result.describe()
def test_fresh_boot_fits_the_primary_screen() -> None:
"""With nothing logged, the gate fits 1366x768 without scrolling.
@pytest.mark.parametrize("populated", [False, True])
def test_gate_fits_the_primary_screen(*, populated: bool) -> None:
"""The gate fits 1366x768 without scrolling, empty or with a day logged.
Scrolling is a correctness backstop, not the intended daily experience: an
empty form that already needs scrolling on the primary machine means the
layout has drifted and the next addition will be worse.
Scrolling is a correctness backstop, not the intended experience -- and
since 2026-08-03 it is also *only* reachable by the user's own keypress or
click, because a viewport that followed the app's own ``focus_set()``
scrolled the screen under a user who had touched nothing. A gate that
needs scrolling therefore needs the user to discover that it does.
``populated`` is the worst case: the dashboard grows one line per logged
meal, so a layout that fits on a fresh boot can still overflow by dinner.
"""
result = measure(1366, 768, populated=False)
result = measure(1366, 768, populated=populated)
assert not result.needs_scroll, result.describe()

View File

@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any
import pytest
from diet_guard import _gatelock_buttons, _gatelock_ui
from diet_guard import _gatelock_buttons, _gatelock_typography, _gatelock_ui
from diet_guard._gatelock_ui import UNIT_GRAMS, UNIT_ITEMS
from diet_guard.tests import _gate_keyboard_probe
from diet_guard.tests.conftest import _FAKE_TK, FakeRadiobutton
@ -194,11 +194,11 @@ class TestTypeScaleIsPixels:
@pytest.mark.parametrize(
"size",
[
_gatelock_ui.DISPLAY,
_gatelock_ui.SUBTITLE,
_gatelock_ui.BODY,
_gatelock_ui.LABEL,
_gatelock_ui.CAPTION,
_gatelock_typography.DISPLAY,
_gatelock_typography.SUBTITLE,
_gatelock_typography.BODY,
_gatelock_typography.LABEL,
_gatelock_typography.CAPTION,
],
)
def test_sizes_are_negative(self, size: int) -> None:
@ -211,12 +211,12 @@ class TestTypeScaleIsPixels:
def test_scale_matches_the_design_system_magnitudes(self) -> None:
"""The magnitudes are still tokens.md's 32/24/20/16/14/12 scale."""
assert abs(_gatelock_ui.DISPLAY) == 32
assert abs(_gatelock_ui.TITLE) == 24
assert abs(_gatelock_ui.SUBTITLE) == 20
assert abs(_gatelock_ui.BODY) == 16
assert abs(_gatelock_ui.LABEL) == 14
assert abs(_gatelock_ui.CAPTION) == 12
assert abs(_gatelock_typography.DISPLAY) == 32
assert abs(_gatelock_typography.TITLE) == 24
assert abs(_gatelock_typography.SUBTITLE) == 20
assert abs(_gatelock_typography.BODY) == 16
assert abs(_gatelock_typography.LABEL) == 14
assert abs(_gatelock_typography.CAPTION) == 12
class TestRealFocusRing:

View File

@ -4,7 +4,7 @@ version = "1.0.0"
description = "Tkinter/systemd log-to-unlock meal gate with calorie/macro budget tracking"
requires-python = ">=3.10"
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync",
"requests>=2.31.0",
]

View File

@ -4,7 +4,7 @@ bandit>=1.7.0
codespell>=2.2.0
coverage>=7.4.0
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
mcp>=1.9
mypy>=1.8.0
pre-commit>=3.6.0