Commit Graph

65 Commits

Author SHA1 Message Date
41f014e7a7 ts_core: a framework-free TypeScript utility package
Some checks failed
palette drift / palette (push) Waiting to run
ts_core tests / test (push) Has been cancelled
`Clock` and a seeded mulberry32 RNG had each been reimplemented across the
game and web repos -- Clock twice (konbini-67, iron-and-anvil, doc comments
near-verbatim), the RNG four times. None of it is UI, so no existing shared
package owned it: `web_ui` ships `tokens.css` and React components, and a
game importing a UI package to get a seeded RNG is exactly the coupling
that makes people vendor their own copy instead.

The RNG deliberately exports **two** interfaces rather than reconciling
them into one. konbini-67 threaded mutable state through free functions;
sims3-clone and europe-county-map returned an object of closures. The
sequence is the contract here -- these repos have seeded tests and "same
seed -> identical world" guarantees, so changing how many times a helper
advances the generator silently re-rolls every saved world. A shared
package that did that would be worse than the duplication it replaces. The
core is shared, so both interfaces yield the same float sequence; that is
asserted, not assumed.

`rng.test.ts` pins the first five floats for seed 12345, captured from the
pre-extraction konbini-67 implementation rather than from this one -- a
golden test generated from the code under test only asserts that the code
matches itself. It earned its place immediately: the first constants I
wrote came from a truncated `toFixed(9)` probe and the test failed, which
is precisely the class of silent precision drift it exists to catch.

`hotline3d` is not a consumer and should not become one. Its generator uses
`%` and `Math.trunc` where mulberry32 uses `>>>`/`>>`; measured, it emits a
different sequence, so adopting this package would change its output.

29 tests, 100% branch/function/line/statement coverage, type-aware eslint,
and a committed dist/ with a CI freshness check -- the same bar web_ui
holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:42:41 +02:00
0dd4229432 unified-design-system: record the extraction audit, unstale the phase headings
Answers "did we extract every component we actually use?" now that Phases
1-3 have shipped.

The headline: the extractions hold up. No shipped component was a false
positive, and the reverse check -- were the donor copies actually deleted?
-- is clean except for billsplit's `_SectionHeader`, fixed separately in
testsAndMisc@54054e7. The one clear miss is `DeviceCodeDialog` x3, two
copies of which are byte-for-byte identical; it belongs in
`sync_settings_ui` rather than `design_system` because it depends on
GitHub auth types.

Also corrects two things the audit surfaced:

`nielsen-audit.md` described Phase 2 *and* Phase 3 as "not started". Both
shipped -- gatelock 0.5.0 and web_ui-v0.3.1. A planning doc that says "not
started" about finished work is worse than no doc, because the next
session reads it as a backlog and rebuilds what already exists. Both
headings now state what shipped, and the plan text is kept below as the
record of why. The Phase 2 note also spells out that screen-locker and
wake-alarm keeping local fan-out copies is deliberate (only two consumers
were bumped, to avoid restarting live systemd services) so it does not
read as a regression to be "fixed".

The audit also found a fourth shared package the phase briefs omit:
`sync_settings_ui`, consumed by four apps. It resolves most of the
`SettingsScreen` cluster, which the grid still listed as outstanding.

The remaining true duplicates (`_PrefsPersistence`, `Clock`, mulberry32
RNG) are non-UI utilities outside every package's remit, and are recorded
as such rather than as a reason to widen web_ui into a general utility
package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:29:53 +02:00
ccef40e817 unified-design-system: check one palette across all four token sources
`tokens.md` freezes the palette as prose and three packages re-express it
as code -- `web_ui/src/tokens.css`, `design_system`'s `tokens.dart`, and
gatelock's `LockConfig`. Nothing compared them, so "one palette
everywhere" held only for as long as four hand-maintained copies happened
to agree. A hex edited in one stack drifted silently in the other three.

`palette_check.py` parses all four and runs two halves:

  * agreement -- every mapped token carries the same hex in every
    non-exempt stack
  * completeness -- every colour parsed from any source must be mapped or
    exempted with a stated reason

The second half is the load-bearing one. A pure agreement check passes
happily when someone adds a token to one stack and never touches the map,
which is the drift most likely to actually happen; an unmapped token is
therefore a failure, not a skip. Absences are declared per token per stack
with a reason (Tk has no light theme and no borders; the categorical ramp
is web-only) so "this stack doesn't have it" is on the record rather than
a silent pass.

It found one real gap on its first run: `tokens.css` sets `--surface-2` to
`paper-raised` in the light block -- the light theme collapses both
elevation steps onto one surface -- which `tokens.md` documented only in
prose and no map covered. Now an explicitly compared row, so if either
half of that pair moves, it fails.

Proven to fail, not just to pass: drift injected into each of the four
sources in turn, plus an unmapped token, was caught every time with the
offending stack named; re-proven after the 250-line-cap split into
`palette_map.py`. Runs from its own workflow with deliberately no `paths:`
filter -- drift is a disagreement *between* packages, so a path-scoped
trigger would skip exactly the edit that causes it. That was not
hypothetical: `ramp_check.py` was reachable only from the
`web_ui/**`-filtered workflow, so a ramp edit in `tokens.md` or
`design_system` never ran it. It moves here too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:27:17 +02:00
5045f20c40 unified-design-system: record the Phase 3 gate
Some checks failed
web_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:11:03 +02:00
6714d13198 web_ui: v0.3.1 — scope check-json past JSONC tsconfigs
The tsconfigs carry comments (TypeScript allows them); check-json does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:08:48 +02:00
eaa5683ff1 web_ui: add the CI + pre-commit gate, and make pnpm lint actually run
Matches the Phase 1 design_system pattern the brief points at: own lint config,
own tests, own coverage gate enforced in CI.

The lint script was declared but unrunnable -- there was no eslint flat config,
and the build tsconfig excludes tests (correctly; they must not ship in dist/),
so type-aware linting had no project for them. Adds eslint.config.js and a
lint-only tsconfig that widens the include without emitting. Running it surfaced
one real defect: nth() interpolated a number into a template literal without
String(), which the consumers' configs would have flagged.

The gate also checks that the committed dist/ matches a fresh build. This is not
theoretical -- the check caught the stale dist/ produced by that very String()
fix on its first run. A stale dist/ in a tag ships old code to every consumer
with no error anywhere, which is the one failure mode this packaging choice
introduces. dist/ is excluded from the whitespace hooks: they would "fix"
generated output that the next build immediately reverts.

CI additionally re-runs the categorical ramp's constraint proof, so the palette
cannot regress silently either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:08:18 +02:00
56d0c3d420 unified-design-system: record Phase 3
Documents why FilterBar was not extracted (a name collision over disjoint
domains, not a duplicate), the categorical ramp decision, the npm-cannot-consume
finding, and the index-vs-distinct-value bug that unit tests could not see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 15:04:01 +02:00
086791f4f9 web_ui: document distinct-value stepping
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:59:41 +02:00
133c210b2e web_ui: step the slider to the next DISTINCT value (v0.3.0)
Caught by driving the real browser, not jsdom: on awesome-mcp-explorer's star
distribution the arrow keys did nothing at all. ~1500 of the ~2981 values are
0, so an index step landed inside the run of duplicates -- the index moved, the
value did not, and the thumb, the readout and the filter all stayed put.

steppedValue now walks to the next distinct value, so one arrow press is one
visible change on any distribution, however lumpy. The regression test uses the
shape that actually broke rather than the evenly-spaced fixture that hid it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:59:27 +02:00
2574b21acc design-system: add a categorical ramp; web_ui v0.2.0
awesome-mcp-explorer needs six mutually distinguishable colours for its A-F
grades, and the design system had no answer -- one accent, and semantic roles
that deliberately cluster in one hue wedge. Raised as its own decision (per
PHASE3-PROMPT) and resolved by designing the ramp rather than mapping grades
onto success/warning/danger, which would have collapsed six steps into three.

Chosen by search, not by eye, under four hard constraints:
  - pairwise CIE dE >= 20 under normal vision AND deuteranopia, protanopia and
    tritanopia (worst observed 26.0)
  - >= 3:1 against BOTH backgrounds, since the ramp does not re-theme. This is
    the binding constraint: such colours only exist in L* 44-59.
  - monotonic lightness 58 -> 44, so hue and lightness encode redundantly and
    the ramp survives greyscale as an ordinal scale
  - muted saturation, to sit with the rest of the palette

scripts/ramp_check.py re-runs the whole proof and exits non-zero on a
regression, so this is a gate rather than a paragraph. A seventh hue is not
available (best candidate reaches dE 17.9); group the tail into "other".

This ramp is the only token group that leaves the warm wedge, deliberately:
hue separation is the entire point of a categorical scale.

Also: tokens.test.ts guards every frozen value against drift, and
tokens.md was split (screen-size.md) to hold the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:50:43 +02:00
03bf1ed08b web_ui: shared web token + component layer (Phase 3)
The web counterpart to design_system (Dart): one :root block of frozen tokens
plus the components that were duplicated across repos. Extracted from
dufs-cloud and awesome-mcp-explorer, which both now consume it.

- tokens.css: the frozen palette, spacing/radius/type scales, shadow policy and
  focus ring, under the canonical variable names from unified-design-system.
  Also ships the global :focus-visible rule, rather than leaving each consumer
  to remember it.
- RangeSlider: reconciled from the two drifted copies. Takes the accessible DOM
  from awesome-mcp-explorer (real <button> thumbs with role="slider" and the
  aria-value* trio) and grab-the-nearer-thumb from dufs-cloud. Keyboard support
  (arrows/PageUp/PageDown/Home/End) is new: both donors were pointer-only, which
  the design system rules out, and an arrow-key-deaf role="slider" is an ARIA
  role that lies.
- quantile.ts + fuzzyMatch: the genuinely duplicated helpers.

FilterBar is deliberately absent. The two same-named components share no
structure -- a horizontal <select> strip over files vs a vertical tri-state chip
sidebar over servers, with disjoint FilterState, props and sort keys. It is the
name-collision-is-not-a-duplicate trap, so extracting it would ship a widget
nobody imports.

dist/ is committed (and un-ignored against the root rule) because Vite does not
transpile dependencies; consumers stay at zero config. Consumed via pnpm's
&path:/web_ui suffix -- npm cannot install from a subdirectory of a git repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:40:45 +02:00
d25f2885da gatelock: 0.5.0
Some checks failed
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
Phase 2 adds four exports (make_button, heading, row, WidgetGroup) plus a
wheel-scroll fix in ScrollableSurface. Minor, not patch: the surface grew.
Purely additive -- nothing was removed, so consumers still on the 0.4.1 pin
keep working unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:21:10 +02:00
d82107ab9a gatelock: bind the wheel per descendant in ScrollableSurface
Wheel-over-text did not scroll. Tk dispatches through *bindtags*, not the
parent chain -- a child's tags are (itself, its class, the toplevel, "all") --
so binding <Button-4>/<Button-5> on `content` was never consulted for an event
over a Label sitting on top of it. Content that fills its viewport covers that
frame completely, which is every screen this class exists for, so the mouse
wheel was dead exactly where a user points it.

Found while retiring leetcode-guard's hand-rolled _scrollable, which got this
right by accident: it used bind_all, which works but installs the handler on
every widget in the application, including other windows' scroll regions.
Binding per descendant inside the existing _track_focus walk keeps it scoped,
and reuses that walk's WeakSet so a repaint cannot stack duplicate handlers.

Verified both directions under xvfb (Button-5 scrolls down, Button-4 back up)
from an event generated on a Label, not on the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
2026-08-15 14:12:30 +02:00
c963cd88a8 gatelock: add shared button, heading/row and WidgetGroup
Phase 2 of the shared-component work. gatelock owned the tokens but stopped
short of composite widgets, so four gate apps each built the same ones and
drifted apart doing it.

widgets.py:
- make_button(), from diet-guard's _gatelock_buttons. Emphasis is a variant,
  not a colour pair, so the text colour is picked *from* the fill and a call
  site structurally cannot produce the fg-vs-on_fill contrast bug. Binds
  <Return>/<KP_Enter>, which Tk does not give tk.Button on X11, and takes its
  focus ring from LockConfig (Tk's default 1px black is invisible on #211D1B).
- heading() and row(), from leetcode-guard's _status_sections. row()'s wrap
  width is a parameter rather than the donor's module-level dict, which made
  every row's wrapping depend on whichever window rendered most recently.

widget_group.py:
- WidgetGroup, replacing ~916 lines of per-output fan-out across four repos.
  Takes the union of what they needed and the fixes only some of them had:
  first -> W|None (three donors could IndexError on zero outputs), TclError
  suppressed per copy (one donor did not guard at all), focus deliberately
  singular rather than fanned out, and kwargs typed `object` so command= is
  expressible. Stores (output_name, widget) pairs, which designs out a latent
  strict-zip ValueError in the donor that kept two parallel lists.

Additive only: nothing is removed from the consumers in this commit, so all
four gates keep working on their current pin. 100% branch coverage, real-Tk
tests under xvfb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1qGVUMjmtppci6Fjxs9xS
2026-08-14 23:54:52 +02:00
b9e54380db Add copy-paste session prompts for Phase 2 and Phase 3
Each is written to be pasted into a fresh session with nothing added.
That means carrying the answers the spec gate would otherwise demand --
goal, the decisions already made, boundaries, and a measurable done
condition -- and opening with an explicit override so the gate does not
fire a question round on a spec that is already complete.

Both point at nielsen-audit.md for context but do not depend on it to be
actionable, since a fresh session will not have read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:38:17 +02:00
b4f1b32cef Record the four palette migrations as done
focus_owner, kuhylog, epopeja_karta and billsplit are all on the frozen
palette now, so the "drift to correct" table becomes a record of what was
fixed rather than a to-do. diet-guard and macro-cam remain.

Writes down the two deliberate exceptions, because both look like
oversights to anyone who did not make them:

epopeja_karta imports AppPalette rather than buildDarkTheme(). Its nine
slots were already byte-identical to the frozen palette, so only the
transcription was local, and it is one dense screen with a test asserting
nothing scrolls -- the shared theme's larger type scale overflows it by
34px. Importing tokens leaves the rendered ThemeData identical, so that
test passes by construction rather than by tuning sizes until it fits.

billsplit keeps its four category-dot colours. They encode which category
an item is, not a theme role, and a single-accent system has no four
distinct hues to lend them. That is now flagged as the open
categorical-ramp question: the design system has no answer for "N
mutually distinguishable hues", and it needs one before any chart, tag or
category UI can be built on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:37:19 +02:00
480a8bdd82 Stop const-folding EmptyState's constructor out of coverage
Some checks failed
design_system tests / test (push) Has been cancelled
Removing the action slot made the constructor const-invocable, so the
test's `const EmptyState(...)` was evaluated at compile time and the
constructor line never executed -- 99%, caught by the pre-push gate
rather than by review, which is the point of having it.

Building the message through a helper keeps the invocation non-const.
That is also closer to real call sites, which interpolate runtime state
into the string rather than passing a literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:10:43 +02:00
3dab75fde5 Record which design_system exports actually have a live consumer
An extracted component with no importer is the failure mode the audit
exists to prevent, so leaving that implicit was the wrong call.
EmptyState and AppStatusColors ship ahead of their consumers -- both have
real donors waiting, and AppStatusColors has to exist for showError to
read the danger hue off the theme -- but neither is validated, and the
table now says so rather than letting the package read as fully proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:09:45 +02:00
522fa1dff0 Wire design_system into CI, and drop EmptyState's speculative action slot
The pre-push coverage gate only ran for whoever had the local hook
installed, which is deployment hygiene rather than a gate. Adds the
mirrored workflow sync_settings_ui already has, so the 100% bar is
enforced on push and PR.

EmptyState.action was invented surface: no donor had it. home_inventory's
three call sites are pure placeholders, and kuhylog's action-bearing
_empty was explicitly excluded from the survey as a different element.
Shipping a parameter nobody asked for on a widget that has no consumer
yet is the failure mode this package is supposed to avoid, so it goes,
with a comment recording why rather than leaving the next reader to
rediscover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:08:59 +02:00
cae0870fd0 Add the Nielsen coverage audit alongside design_system v0.1.0
The grid maps Nielsen's 10 GUI elements to what actually exists per stack,
and records the deferred clusters (GitHubMirrorScreen x5, SettingsScreen x6,
~20 list tiles, filter sheets) with repo, path and line count so none of it
needs rediscovering.

It also records three corrections the measurement turned up, because each
one changes what is worth doing next:

- The "343 raw color literals" are not spread through call sites; they are
  concentrated in the hand-copied theme files. todo had 35, all 35 inside
  theme.dart. Seven repos have zero literals outside their token file, so
  deleting it removes essentially all of them. The repos with genuine
  call-site leakage are billsplit, epopeja_karta and focus_owner.
- There are 4 AppStatusColors, not 5, and they disagree about their own
  field sets; the package's version is a superset so nobody loses a field.
- There are 3 section headers, not 5, and no _SectionLabel exists.
  epopeja_karta's is a different element (an eyebrow, not a section title)
  and was deliberately left out rather than force-fitted.

Also records that workout_app, cited as a consumer in the plan and in
several skill docs, does not exist on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:01:31 +02:00
e6c9af81e9 Add design_system: the shared Flutter component layer
unified-design-system/ froze the token layer as prose -- 1220 lines of
README, tokens.md and annotated HTML, and zero importable code. Every
Flutter repo transcribed the token table by hand, so the "shared" palette
was enforced only by discipline, and had already drifted: 10 divergent
theme.dart copies (todo and untools byte-identical, the other 8 each
unique), ~318 raw color literals, 4 hand-rolled AppStatusColors extensions
with inconsistent field sets, ~35 inlined AlertDialogs, ~38 raw SnackBar
sites with no helper anywhere.

This promotes the token layer into an importable component layer, so the
design system is enforced by the compiler and the package manager instead.
The components own the tokens: a consumer has nothing local left to edit.

Scales are the six-repo consensus, which is also what tokens.md specifies;
drifted repos get corrected to this rather than the reverse. Themes are
built from explicit ColorSchemes, never fromSeed -- the palette is
hand-picked for contrast, and secondary/tertiary are set explicitly so
SegmentedButton cannot fall back to stock Material teal.

Widgets are extracted only where two or more repos already had a
structurally similar implementation; SectionHeader reconciles untools'
trailing-action variant with billsplit's padded one as an opt-in union, so
either caller can adopt it with no visual change.

63 tests, 102/102 lines covered, gated at 100% on pre-push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 22:51:06 +02:00
ca81e091a1 Skip git-ignored files in the length gate, and vendored skill bundles
Three exemptions the first cut missed, all of them files no commit could
ever fix:

- Git-ignored files (todo's exported BACKLOG.md, logs, scratch dumps).
  These are rewritten wholesale by whatever produces them, so splitting one
  is meaningless, and pre-commit never sees them anyway. Applied to
  explicitly-named paths as well as --all, so the two modes cannot disagree
  about the same file.
- Vendored agent-skill bundles (.agents/skills, .claude/skills). A SKILL.md
  is a fixed-name file with frontmatter; splitting one breaks the format.
  Matched by directory name wherever it appears, since these get dropped
  into any repo.
- .utils, where CI checks out this repo. Without it a repo's lint job fails
  on utils' own long files, which that repo cannot fix.

Baseline accordingly drops from 657 to 597. Re-verified all eight fixtures:
over-cap fails, under-cap passes, generated/vendored/wordlist/git-ignored
exempt, prose still capped, and the gate still passes on its own source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 15:58:46 +02:00
c54211f112 Add file-length survey baseline (2026-08-14)
The measured distribution the 250-line cap was derived from, and the 657
files currently over it, so the baseline is reproducible rather than a
number in a commit message.

Stored as JSON, not a text list: a 651-line .txt would itself have failed
the gate, and the data belongs in a data file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 14:35:42 +02:00
c9bf289510 Add shared 250-line file-length gate
Files that cannot be read in one piece force re-reads and partial edits,
which is the largest avoidable cost in an LLM-assisted workflow. This adds
the enforcement side of a 250-line cap covering code AND prose.

- file_length/config.py: single source of truth for the cap and its
  exemptions (generated, markup, vendored, data-ish txt), shared by both the
  gate and the survey so the two cannot silently disagree.
- file_length/check.py: the check itself. Exits 1 and lists violations;
  adjudication is an exit code, not a judgement call.
- scripts/check_file_length.sh: thin wrapper pre-commit calls with staged
  files.

Verified against six fixtures: over-cap code fails, under-cap passes,
generated l10n (1417 lines) exempt, vendored pdfjs exempt, the 174800-line
wordlist exempt as data, and a 1101-line CLAUDE.md correctly fails since
prose is capped too.

Baseline at time of writing: 657 files over 250 lines across 29 repos.
Each repo carries a refactor_claude_todo.md prompt with its own list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 14:24:57 +02:00
284aa1e360 sync_settings_ui: assert the password form starts collapsed when Google is offered
Some checks failed
sync_settings_ui tests / test (push) Has been cancelled
Flagged during todo's migration: the "Google available" test asserted the
button renders and works, but never checked that the password/email
ExpansionTile actually starts collapsed behind it (initiallyExpanded:
!googleAvailable) -- the one behavior the old duplicated todo test covered
that this package's own suite didn't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-12 23:24:57 +02:00
23c693da4e Add sync_settings_ui: shared Firebase-only sync settings screen
Extracts the near-identical "Sync settings" UI duplicated across 5 apps
(todo, diet_guard_app, workout_app, wake_alarm/phone_app, home_inventory)
into a single tested package. Firebase sync (primary) + optional local
backup slot; GitHub mirror UI was dropped per kuhy's explicit decision
mid-build in favor of Firebase-only, so existing apps keep their local
GitHub code untouched, just disconnected from this shared screen.

100% line coverage, flutter analyze clean under very_good_analysis.
Generalized check_no_crdt_sync_override.sh to guard any utils git
dependency (crdt_sync, sync_settings_ui), not just crdt_sync by name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-12 23:10:17 +02:00
92f55cee5a gatelock 0.4.1: re-focus a surface that comes back after a blip
Some checks failed
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
LockWindow.grab_input() only ever focuses the entry once, on first grab
acquisition (_notify_focus_ready is one-shot). A surface torn down and
rebuilt later -- a monitor going dark and recovering, which flaky DDC/CI
power control does routinely -- gets a brand new widget that nothing
ever focuses. The grab stays held, so no other window can take focus
either: the entry renders correctly but is permanently keyboard-dead
until the process restarts.

RecoveryLoop.tick() now re-runs the same focus_surface(preferred_focus_
index()) call whenever a tick's surface delta reports a newly created
output, mirroring what LockWindow does on first acquisition. Bundled the
loop's five collaborators into a RecoveryCollaborators dataclass rather
than growing its constructor past ruff's five-argument limit.

Diagnosed against a week of wake-alarm's real journal history: the
dismiss-code box rendered normally every alarm morning but accepted no
keystrokes, tracking exactly with HDMI-0's recorded dark/recover blips
near 07:00 and 07:55 each day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGAEbavLSCAotBqhVfFhPc
2026-08-12 22:13:46 +02:00
5f64a8959b Sign in with Google, and stop trusting a revoked session
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
Two changes to the shared sync credential path, both driven by devices
that reported "Connected" while every sync failed.

Google sign-in (signInWithGoogle, both languages) replaces typing the
sync account's password on a fresh install. The account is linked once
from the PC with tool/link_google.py, which asserts the pinned uid --
without that assertion an unlinked identity is *signed up* rather than
refused, and the new uid is then denied every read and write by the
security rules.

The second change is the one with teeth. hasSession() is a presence
check, so a refresh token the server had revoked still read as a live
session: the desktop todo build showed "Connected" and then failed every
sync with `HTTP 400 (TOKEN_EXPIRED)`. A refresh that fails for a terminal
reason now clears the stored session, so hasSession() answers honestly.

Deliberately narrow: only TOKEN_EXPIRED, USER_DISABLED, USER_NOT_FOUND,
INVALID_REFRESH_TOKEN, INVALID_GRANT_TYPE and MISSING_REFRESH_TOKEN
clear it. A network error or a 5xx must not -- a device that signs
itself out whenever the wifi drops needs a manual sign-in on every
device to recover, which is worse than the stale banner this fixes.
Verified against the live endpoint, not just mocks: has_session flips
True -> False on a real rejection.

crdt_sync_dart 0.9.0 -> 0.10.0; the five apps pin the matching tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192EkoAyFBEWTvAN5vVgmSy
2026-08-11 23:58:02 +02:00
f902bbe0aa Let a mirror read survive the primary being down
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
MirrorSyncClient promised that reads consult both backends "so the
cutover order is irrelevant", but every read called self.primary
unguarded. A Firebase outage therefore produced no data at all and the
GitHub mirror was never consulted -- the union read silently degraded to
nothing, precisely when the fallback existed to help.

This is not hypothetical: screen-locker's workout sync hit it on
2026-08-10, where a DNS failure reading the primary took down the whole
locker rather than falling back.

Reads (list_directory, get_file_text, get_string_map) now degrade to the
mirror when the primary raises, and only fail when NEITHER backend can
answer -- returning an empty result would be indistinguishable from "no
data", which callers act on. Writes keep the old asymmetry on purpose: a
write the primary did not accept has not happened, so it must still fail
the tick.

A mirror that cannot bulk-read (GitHub) contributes nothing but is not a
failure, so it cannot by itself make a revision-map read raise. Both
language ports agree on that.

Same fix in the Dart port, which had all three read paths wrong too and
runs on the phone. Two older tests asserted the buggy read-fail-closed
behaviour and are renamed to describe what they actually exercise: the
both-sides-failed case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
2026-08-10 14:30:29 +02:00
52186aa5a4 aur-publish: make the launch check actually exercise a cold start
verify_installed() treated "already running" as a pass, so on any run where an
app was already open the strongest gate in the script silently became the
weakest: it proved only that the binary printed something. It now stops a
leftover instance first, so the check always tests a real cold start.

Killing that instance is fiddlier than it looks, and both naive forms are
broken here:

  * `pkill -x <name>` matches the kernel's 15-character comm field, and two of
    the three wrappers are longer (habit_stack_desktop is 19). It would have
    silently never fired -- confirmed by watching an instance survive it.
  * `pkill -f <path>` matches this script's own command line and the shell
    wrapping it, so it kills the run instead of the app. Observed as an exit
    144 with no output, twice.

So the pids come from ps, filtered on the installed /opt path, with our own
process and parent skipped.

Also threads the wrapper process name through the package table rather than
deriving it: diet-guard-app runs diet_guard_desktop, so name mangling produces
the wrong process.

Verified: with an instance deliberately running, the guard reports stopping it
and the launch check then passes on a genuine cold start; all three packages
still pass the full dry run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
2026-08-10 07:18:35 +02:00
cc19d568a3 Add aur-publish: hands-off AUR publishing for the Flutter apps
AUR registration is currently returning a bare nginx 503 server-side, so the
three packages (todo-flutter, habit-stack, diet-guard-app) are built and
verified but cannot be published yet. This runs the whole thing unattended
whenever signup reopens.

anubis_gate.py exists because the AUR sits behind Anubis: every HTML request
gets a proof-of-work challenge page carrying HTTP *200*, so a plain curl
reports 200 for a page that is actually unreachable. That is how the 503 got
misdiagnosed twice as a local/browser/IPv6 problem. The module solves the
challenge and reports the status of the real page behind it.

Fail-closed by construction: each package is built, namcap'd, installed and
LAUNCHED before it is pushed, and check_aur_rules() blocks the push on the
mechanically checkable submission rules (maintainer line, x86_64, no
replaces=, not already in an official repo, .SRCINFO matching the PKGBUILD).
Verified by injecting a replaces= violation: the push was blocked and
reported. A failing package is skipped; the others still publish.

Signup itself stays manual — it needs an email confirmation, and creating
accounts is not something this should do on the user's behalf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
2026-08-09 23:51:06 +02:00
6a080333c2 Let a desktop wrapper provision the sync account, no UI required
Desktop builds of todo/diet-guard/home_inventory are Flutter *web* in a
Chrome --app window, so FlutterSecureStorage resolves to the web backend:
AES-encrypted localStorage keyed inside the browser profile. That makes the
account unsettable from outside the page -- `secret-tool` writes the wrong
store (libsecret, which only the Linux build would read), and a Dart CLI
cannot even import flutter_secure_storage, since it is a Flutter plugin and
fails to resolve on the VM. Verified both, rather than assumed.

The one process that can read the filesystem *and* reach the page is the
wrapper already serving it -- the same seam diet-guard uses to keep the
GitHub token out of the browser. So this adds the shared half of a wrapper
route: the path constant, the opt-in env var, an opt-out marker key, and
accountFromWrapper(), which fetches and hands the body to the existing
FirebaseAccount.tryParse.

Additive only, so consumers adopt it per-app on their own schedule; nothing
forces a phone rebuild. Route handlers stay per-app because each wrapper owns
its own server.

Deliberately opt-in (CRDT_SYNC_SERVE_ACCOUNT): the route hands a credential
with database write access to anything that can reach the wrapper's port, so
it is set once to provision an install rather than left on. kSyncAccountOptOutKey
exists so "Disconnect" cannot be silently undone by the next launch
re-adopting the account.

Every failure path returns null rather than throwing -- an absent route is
the *normal* case -- so the caller degrades to "not configured", which the
settings screens already handle by asking.

254 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 21:33:22 +02:00
a4165e0dc8 Use dt.timezone.utc so the declared >=3.10 support is real
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
`dt.UTC` is a 3.11+ alias, but crdt-sync declares requires-python = ">=3.10"
and the CI matrix tests 3.10 — so `crdt-sync tests` has been failing on the
3.10 leg since before this branch (commit 493db693 failed the same way).
It collected as `AttributeError: module 'datetime' has no attribute 'UTC'`.

This is not only a test problem: `_firebase_auth._utcnow()` is library code,
so on 3.10 every token-expiry check would have raised at runtime. The three
consumers (diet-guard, screen-locker, wake-alarm) all declare >=3.10 too, so
honouring the floor is the right direction rather than raising it.

`dt.UTC is dt.timezone.utc` evaluates to True — the same singleton under two
spellings — so this is a pure rename with no behaviour change.

Verified on a real pyenv 3.10.16 venv, not just the 3.14 dev interpreter:
239 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 19:04:14 +02:00
6eef99d661 Add DeviceIdentity and a legacy device id for the uuid migration
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
Groundwork for moving diet-guard, screen-locker and wake-alarm off fixed
role ids ('pc'/'phone'/'desktop') onto per-install uuids, the scheme todo
and home_inventory already use. A role constant collides the moment a
second machine takes the same role, and a reinstall silently inherits the
previous install's CRDT identity.

The migration cannot rewrite history: the device id is baked into every HLC
stamp already written, is the record path segment and the revision key. So
a device that has just switched ids must still recognise its former id as
its own -- otherwise skip-own-writes stops matching the old path, and the
device pulls back and re-merges its own pre-migration log as though a peer
had written it. Idempotent under LWW, but pure wasted transfer on every
tick.

Both halves therefore take an optional legacy id and test set membership
(own_ids / ownIds) rather than equality against a single id:

- crdt-sync (Python) 0.5.1 -> 0.6.0: new crdt_sync._device with
  DeviceIdentity + load_device_identity (generate-or-load, persisted); the
  mkdir is deliberately unguarded, since handing back an unpersisted id
  would mint a fresh uuid every restart and strand a device dir each time.
  sync_log gains legacy_device_id.
- crdt_sync_dart 0.6.0 -> 0.7.0: DeviceIdentity value type; syncLog gains
  legacyDeviceId. Persistence stays app-side, where the existing uuid apps
  already keep it in SharedPreferences under 'crdt.nodeId'.

Both changes are backward compatible: the parameter defaults to null, which
reproduces the previous single-id behaviour exactly.

Each half has a positive test and a negative control proving the old path
*is* pulled when no legacy id is declared -- so the skip is verified to be
the parameter's doing, not an artifact of the fixture.

Python: 239 tests, 100% branch coverage. Dart: 248 tests, analyzer clean,
device.dart 11/11 and sync.dart 57/57 lines covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 16:54:33 +02:00
493db693e1 crdt-sync 0.5.1: name the packages, so pip can build again
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
`tool/` carries an __init__.py so its scripts import cleanly, which makes it a
second top-level package. setuptools' flat-layout auto-discovery refuses to
build at all in that case:

    error: Multiple top-level packages discovered in a flat-layout:
    ['tool', 'crdt_sync']

so `pip install crdt-sync @ git+...` failed outright. Naming the package
explicitly fixes the build and keeps one-shot migration scripts out of every
consumer's site-packages.

Verified by installing from a clean target directory and importing
mirror_client_for from the built wheel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 23:16:51 +02:00
fc99eff005 crdt_sync_dart 0.6.0: split Firebase settings by what is safe to publish
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
Every repo in this fleet is public, so where each value lives is a disclosure
decision rather than a style choice.

FirebaseProject holds apiKey + databaseUrl: these identify the *project*, and
the Web API key is a public identifier by design -- it ships inside every
Android APK, and the security rules, not the key, are what protect the data.
Safe to bake into app source, which keeps a reinstall zero-setup.

FirebaseAccount holds the email and password: these identify *a person*, so
they are entered once per device and kept in the platform keystore, never in
SharedPreferences and never in a repo.

The uid is deliberately absent from both. Nothing client-side needs it -- the
rules compare auth.uid server-side, so a wrong account is refused by the
database. A local copy would add a way to be wrong without adding protection.

Keyed at `firebase.account`, distinct from `sync.token` (the GitHub PAT) and
`firebase.credentials` (the refresh token), so all three coexist during the
mirrored cutover.

13 tests, 100% line coverage; 241 in the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 23:05:13 +02:00
6c9cc71ae1 crdt_sync_dart 0.5.1: a shared secure credential store
Every Flutter consumer already keeps its GitHub token in
flutter_secure_storage, which is where a Firebase refresh token belongs too:
it is the long-lived secret, while ID tokens expire in about an hour.

Takes read/write/delete closures rather than depending on
flutter_secure_storage directly -- this package is pure Dart, and a Flutter
plugin dependency would make it unusable from the command-line tools and from
plain `dart test`. The adapter is two lines at each call site.

Keyed at `firebase.credentials`, namespaced away from each app's `sync.token`
so both secrets coexist for the whole mirrored cutover.

Every load failure degrades to "not signed in" rather than throwing: an app
that threw here would be permanently unable to sync, with no way back short of
a reinstall.

10 tests, 100% line coverage; 228 in the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 23:01:54 +02:00
dd6e6d5571 crdt_sync 0.5.0: the shared config and the cutover seam
Everything an app needs to move from GitHub to Firebase without each one
reinventing the wiring.

Python:
- _config.py: FirebaseConfig.load() + firebase_client_for(app) +
  mirror_client_for(app, github). One config file, read in one place.
  Credential caches stay per-app (~/.config/<app>/firebase_auth.json) --
  one file written by several processes is a corruption waiting to happen.
- tool/preflight_firebase.py: validates the config before any data moves.
  Catches a wrong-region databaseUrl (which answers 404 with a correctUrl
  body rather than an error, so it would otherwise pass every check and
  break mid-migration) and confirms the uid in the ID token matches the uid
  the rules pin -- sign_in() returns None and discards localId, so the uid
  is decoded from the JWT.
- tool/interop_seed.py: the missing Python half of the cross-language
  check. interop_check.dart asserts it can see a record written by Python,
  but nothing ever wrote one, so it could only ever fail.

Dart:
- firebase_config.dart: the same config shape, kept free of dart:io so it
  works on web and Android, where the file does not exist at all and
  credentials belong in flutter_secure_storage.
- PersistedSyncStateStore: the revision cache, persisted. Without it a
  phone forgets every peer revision on each cold start and re-downloads
  everything -- the exact traffic the revision scheme exists to avoid, and
  what the free-tier headroom depends on.

Verified live against the real database: preflight passes all five checks,
the Dart smoke test passes end to end, and Python <-> Dart interop is OK.

Python 226 tests at 100% branch coverage; Dart 218 tests, both new files
at 100% line coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-04 22:59:48 +02:00
a89f152564 Let the gatelock workflows trigger on their own files
Some checks failed
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
The display fix in 161eccd could never prove itself: both workflows trigger
on `paths: ['gatelock/**']`, and the workflow files live under .github/, so
editing one changes nothing GitHub will act on. The tests job stayed red at
the last gatelock commit with the fix already pushed -- which is the worst
kind of green-looking red, because nothing was running to say otherwise.

A workflow is part of what it tests, so each now lists its own path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 18:36:47 +02:00
cfc4c295e4 crdt_sync: add the one-shot GitHub -> Firebase data migration
Reads through the GitHub API, not a local clone: the checkout on this
machine was five days stale while devices kept syncing, so seeding from it
would have silently rolled back everything written in between. Blobs are
fetched by sha rather than by path, so a concurrent push cannot swap the
content out from under the tree listing.

Writes through the real FirebaseSyncClient so the migration exercises the
same key escaping the apps will, then reads every blob back and compares
bytes before reporting success -- a migration that claimed success without
checking would be indistinguishable from one that dropped a file.

Retries connection resets and 5xx: GitHub drops keep-alive connections
routinely, and a half-completed migration is worse than one that never
started.

Three things are deliberately left behind, and stay in the GitHub repo
(archived, not deleted):
  - todo-sync/changesets/  pre-migration sqlite_crdt format, unread since
    2026-07-17
  - diet-guard-sync/devices/desktop/  a replica that stopped syncing on
    2026-07-22; verified to hold 0 records, 0 tombstones and 0 fields newer
    than both `pc` and `phone`. Carrying it over would cost a full
    re-download every tick forever, since a device that publishes no
    revision can never be skipped
  - READMEs and .gitkeep, which are not sync data

Run against the live database: 16 files, 956,272 bytes, all verified
byte-for-byte, and independently re-checked by listing the namespaces and
parsing records back out.
2026-08-03 18:31:18 +02:00
161eccd9a5 Give gatelock's test job a display, so its real-Tk tests actually run
Headless, the viewport, fit-check and keyboard tests skip -- 25 of them --
and take their coverage with them, which is why this job went red the moment
they landed: 100% is the bar and _fitcheck drops to 47%, _keyboard to 18%.

Skipping is the worse half. These tests exist because Tk's own focus
delivery and canvas scrolling cannot be mocked -- mocking them is exactly how
a viewport that scrolled itself passed every check for a week -- so a green
run that skipped them would be reporting nothing about the thing they guard.
xvfb-run gives the runner a display, which is what every local run already
used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 18:29:38 +02:00
02e2e13790 crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam
Extracts the three-method remote surface GitHub was reached through into a
`RemoteStore` interface, then implements it against Firebase Realtime
Database over REST, so apps can move backend with a constructor change.

Why REST and not the SDKs: official FlutterFire has no Linux desktop
support, and `wake_alarm`'s PC side is a dependency-free systemd job. The
RTDB REST endpoints are plain HTTPS and behave identically on Linux,
Android and headless. Firebase is a transport, never the datastore -- each
app keeps its local log as the source of truth, so offline-first survives.

Why RTDB and not Firestore: on the Spark plan RTDB bills only storage and
bandwidth with no per-operation quota, so a runaway loop cannot exhaust a
daily budget and silently stop syncing mid-day.

Two savings make the no-cost tier a guarantee rather than a hope. Measured
against the GitHub-backed history this replaces: 4894 of 5542 commits
(88.3%) were byte-identical no-ops, and every tick re-downloaded every
peer's whole log. Both are now driven by one sha256 revision -- an
unchanged log is not re-pushed, and an unchanged peer is not re-fetched.
That turns egress from "ticks x log size" (~700 MB/month, and growing with
the log) into "edits" (~40 MB/month against a 10 GiB cap).

Revisions are one node per device, written only by its owner, rather than
one shared map -- a whole-map write would erase every peer's entry, after
which those peers would look permanently unchanged and never be fetched
again. Per-device keys make that impossible rather than merely avoided.

`.` is illegal in an RTDB key and the REST `.json` is a format suffix, so
paths get a reversible `~XX` escape (`log.json` -> `log~2Ejson`). Lossy
extension-stripping would break `todo`, whose listings return filenames.

`MirrorStore` / `MirrorSyncClient` dual-writes during the cutover. Reads
consult *both* backends: with primary-only reads a migrated device would
never see an un-migrated peer's writes while still mirroring its own back
-- silent one-directional convergence with no error raised. Writes are
asymmetric on purpose: a primary failure fails the tick, a mirror failure
is logged and survived.

Auth is Firebase email/password over identitytoolkit REST, with mandatory
refresh-token rotation. No service-account key anywhere: it would bypass
the security rules and is trivially extractable from an APK.

Verified live against a real database, not only mocks: sign-in and refresh,
key escaping round-tripping byte-for-byte, an unchanged tick writing
nothing, two devices converging, a revoked session throwing rather than
returning null, and a log written by the Python client being read by the
Dart one.

No app is migrated by this commit; every app still runs on GitHub.

Dart: 194 tests. Python: 203 tests at 100% branch coverage. No lint
suppressions added.
2026-08-03 18:14:25 +02:00
28ced1d39a Cover the last branches of the viewport, the density scale and the keys
gatelock has never met its own 100% bar; _scrollable.py in particular shipped
with no tests at all, which is how a viewport that scrolled itself passed
every check for a week. The remaining gaps were the ones a reader would most
want proven:

* finalize() is documented idempotent -- a repaint calls it again, and an
  earlier version stacked a <FocusIn> handler per call.
* scroll_into_view holds still for a widget already fully on screen. Its
  margin logic has three outcomes and "hold" is the one a jittering viewport
  gets wrong.
* the type scale reads an existing Tk root rather than opening a second one
  next to a live lock window.
* bind_cancel rejects a non-callable at bind time, not on the keypress that
  finally needed Escape to work.

328 tests, coverage 99.44% -> 100.00%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 16:20:11 +02:00
ba549f8f05 gatelock 0.4.0: the viewport moves for the user and nobody else
On 2026-08-03 the screen locker's lock screen scrolled to mid-content and
back with nobody touching the keyboard, on a 1366x768 panel. Measured under
Xvfb with the real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145.

Two causes, both here:

* ScrollableSurface followed every <FocusIn>. The apps focus a widget on
  every repaint (and gatelock re-asserts focus from its recovery tick), and
  Tk delivers <FocusIn> to the focused widget's whole ancestor chain when the
  toplevel takes X focus -- so each repaint aimed a scroll at containers
  taller than the screen. Now a focus move scrolls only when it followed a
  real keypress, or a click on the widget that took focus, and only for a
  leaf. Tab onto a clipped field still reveals it; nothing else moves.
* finalize()'s reset-to-top cancelled the centring, because centring moved
  the canvas item down and the region's top edge with it. The region is now
  pinned to the viewport whenever the content fits, so the view cannot move
  at all, the scrollbar is taken down, and centring survives the repaint.

Content that does not fit is a layout defect, not a mode to live in, so
finalize() reports it at warning and gatelock.measure_fit() lets each app
gate on it before shipping (scripts/verify_screen_fits.py in all four).

Also: LockConfig's type/space scales now compact below 768px (_density),
which is where a screen has no room to be at design-system size and no
scrollbar to make up for it. 768 and up is verified to fit at full size, so
it is never compacted.

Tests: _scrollable had none; the regression is now stated as code
(programmatic focus must not move yview; Tab must). 323 pass, coverage
95.38% -> 99.44%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 15:50:51 +02:00
f0be8c5919 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>
2026-07-30 16:01:15 +02:00
206740889c gatelock 0.2.1: the grab predicate and recovery-loop fixes
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
Releases the two fixes from the previous commit so the four consumers
(screen-locker, diet-guard, wake-alarm, testsAndMisc) can pin them: holds_grab
no longer steals the grab back off our own posted menus, and a raising tick
no longer permanently stops the recovery loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:16:20 +02:00
39e52d07c2 Stop the recovery tick stealing our own grab, and fix pre-commit for the monorepo
`_holds_grab` asked `grab_current() is self._root`, but our own transient
children take the grab for themselves: a posted Tk menu grabs the menu
window, so the test read "the grab was lost" and `grab_set_global()` took it
back a second later, off our own widget mid-interaction. That is what made
screen-locker's sport selector unusable while locked on 2026-07-26. The
predicate is now `grab_current() is not None` -- any window we own means the
lock still has pointer and keyboard -- and it is public, `holds_grab`, so an
embedder can ask the same question instead of re-deriving it. Anything that
does not resolve against our widget map (a Tcl-created `ttk.Combobox`
popdown, a stale name) raises `KeyError` and counts as lost: fail closed,
because losing a popdown beats believing we hold a grab we do not. The
blind spot for a second `tk.Tk()` reporting its root as "." is unchanged from
the old test, and now written down.

`_verify` and `_drain` scheduled the next tick *after* running it, so any
exception ended all re-assertion of coverage, grab and VT for the rest of the
lock -- the exact fail-open this module exists to prevent, in a file whose
docstring is a monotonicity proof. The reschedule moves into `finally`, and
the warnings only fire when the reason changes: these states can last the
whole lock, and a 1Hz journal stream is not the alternative to silence.

The pre-commit side: both subproject configs were written as if cwd were the
subproject, but pre-commit runs hooks from the git root -- which is ~/utils,
not gatelock. Every pushed run since at least 2026-07-11 has been red, and
for compounding reasons: pylint and bandit could not find their config,
bandit's tests exclusion never matched, the pytest hook ran with no config at
all, and with no `files:` filter each config linted the *whole* monorepo, so
gatelock's mypy was type-checking crdt-sync. All paths are now root-relative,
each config is scoped to its own subproject, and the pytest hook goes through
a shared `scripts/run_subproject_tests.sh` that supplies the missing `cd` and
fails loudly if the subproject is gone.

Scoping via `files:` has one sharp edge -- rename the directory and the regex
matches nothing, so every hook passes on zero files, green and useless. An
`always_run` scope-guard hook turns that silent pass into a failure.

bandit 1.7.10 threw inside its AST visitor on Python 3.14 and *skipped* every
file while still reporting zero issues; 1.9.4 actually scans. Codespell was
also flagging a real typo the noise had buried.

Housekeeping the above turned up: `gatelock/.github/` and `crdt-sync/.github/`
were dead -- GitHub only reads the root `.github/workflows`, which already has
all four jobs -- and the two subproject `.gitignore`s were byte-identical to
each other and subsumed by the new root one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:08:41 +02:00
7327c287f4 gatelock: extract the shared escape-hatch core
Some checks are pending
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
Every locker here already had a bounded way out -- screen-locker's sick
mode, diet-guard's log-a-meal -- except the wake alarm, which had only the
challenge itself. Rather than copy sick mode a third time, the mechanism
moves into gatelock so all three consumers share one implementation and one
set of budget rules.

`_escape.py` owns the parts that are policy-free: rolling 7/30/90-day
budgets, the justification draft and its validation, the escalating lockout
that doubles with each recent use, HMAC-signed history entries, and the
"last ten, played back to you" summary. App-specific policy (screen-locker's
commitment penalty and workout debt) stays in the consumer.

Entries that fail HMAC verification are KEPT rather than dropped. Dropping
them looks safer but is not: `verify_entry_hmac` also returns False when the
key is merely unreadable, so "discard what you cannot verify" would turn a
chmod on the key file into an unlimited escape hatch. They stay in the
budget count and are reported through a `tampered` counter instead.

278 tests, 100% branch coverage, no lint suppressions.
2026-07-25 15:35:20 +02:00
a9b65812d7 gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration
On 2026-07-25 the PC appeared frozen and needed an SSH rescue. Three defects
stacked:

1. DP-0 came up `connected` with a valid EDID and no mode/CRTC -- physically
   dark, while X still reserved its 3840px slice of the root window.
2. diet-guard-gate and workout-locker each mapped an override-redirect window
   spanning the WHOLE root (6400x1440+0+0), centring their controls on the
   primary output -- the dark one. The escape UI was invisible.
3. They deadlocked on the X grab, and the loser logged "another app -- e.g. a
   fullscreen game -- holds it". No game was running; the holder was the other
   locker. That guess sent the diagnosis the wrong way for the whole outage.

The library had no concept of an individual output: setup() sized the window
from winfo_screenwidth(), the union bounding box of every output.

What changes:

* _outputs.py  An output is LIVE only if connected AND has a mode/CRTC.
               RandR (crtc==0 or mode==0) and an xrandr --query parser derive
               that predicate independently; verified to agree exactly on the
               real DP-0/HDMI-0 layout. Fails closed three distinct ways.
* _surfaces.py One window per live output at its own geometry, and the only
               module allowed to create/move/destroy windows. Explicit
               geometry replaces attributes(fullscreen=True), which an
               override-redirect window is invisible to anyway.
* _detect.py   RandR events + Tk <Configure> + xrandr polling, coalesced. The
               RandR thread runs on a private X connection and may only call
               queue.put -- Tk is not thread-safe.
* _recovery.py Re-asserts coverage, placement, grab and VT on a timer. Proven
               monotonic: it may only ADD coverage or RE-ACQUIRE the grab. An
               ast test asserts the file contains none of grab_release /
               restore_vt_switching / close / destroy / quit / withdraw, while
               disable_vt_switching and grab_set_global ARE present. Yanking
               every cable therefore does not unlock anything.
* _arbiter.py  N-app priority: wake_alarm > screen_locker > diet_guard.
               Liveness is proved by flock-on-death -- no heartbeat, no PID
               scan, no clock. A two-axis strength check stops rank ever
               weakening the lock: an app stands down only for an incumbent
               at least as strong on BOTH grab kind and VT disabling.
* _guards.py   wait_for_x_server replaces wait_for_display and gates ONLY on
               an X server existing. Output count must never gate arming:
               zero live outputs means lock without showing, not decline to
               lock. That inversion is what left the machine unlocked.
* _window.py   Arms first, renders second. Names the real grab holder instead
               of blaming a fullscreen game. Releases the arbiter on close so
               the alarm -> workout handoff cannot strand the next app.

Two bugs found by running it, not reading it:

* acquire_holder() opened the holder file "w", which truncates at open() --
  before the lock attempt. A losing contender erased the incumbent's claim, so
  the app that most needed to know who held the screen found an empty file.
* tk.Toplevel over a MagicMock sends real tkinter into infinite mock recursion;
  the suite hung instead of failing, and an unpatched setup() would have run
  setxkbmap against the developer's own machine. conftest now has an autouse
  hermetic fixture; the suite went from >120s to 0.7s.

235 tests, 100% branch coverage, ruff (select=ALL) and mypy --strict clean.
No suppressions added: Protocols replace Any for python-xlib, public attribute
access replaces ._data, and type-only Protocols live under TYPE_CHECKING.

Consumers still pin gatelock-v0.1.1 and are unmigrated; nothing ships until a
tag and a reinstall into system python.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:30:03 +02:00
a50d27688f Consolidate unified-design-system docs into this monorepo
Moves the design-system rules, frozen token table, and annotated
component references out of ~/.claude/skills/ and the two Claude
Artifacts into a proper subdir here, matching the existing
gatelock/crdt-sync/guard-lib pattern of one-subdir-per-shared-library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:23:47 +02:00