The palette-drift check parsed LockConfig's colour defaults; they live
on LockPalette in _theme.py since 0.8.0, so it read zero tokens and
failed closed as designed. The two new LockWindow handles had no test
reaching them, which CI's 100% bar caught and the local run did not.
gatelock/prompts/TODO-pylint-to-ten.md described this job with the
older design (raise the score, leave the gate); the gate is at 10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
Markdown across every repo had accumulated a dozen ad-hoc names
(refactor_claude_todo.md x23, DESIGN_AUDIT_TODO.md, NEXT_SESSION,
HANDOFF, PLAN, SPEC) with nothing distinguishing a live task from a
finished record. An audit found docs asserting work was pending that
shipped months earlier, and one asserting completion while the work
was open.
Four namespaces now: README.md, CLAUDE*.md, DOCS*.md, TODO*.md. Task
files carry the literal "REMOVE ME AFTER FINISH" and are deleted when
the work lands.
Enforced bidirectionally -- a TODO without the marker fails, AND any
file carrying the marker that is not named TODO*.md fails. Without the
reverse rule a task can be renamed into the permanent namespace and
quietly live forever, which is the failure being fixed.
md_naming/ mirrors the file_length topology and imports its tables
(EXCLUDED_DIRS, THIRD_PARTY_REPOS, VENDORED_ANYWHERE, GENERATED_PATTERN)
rather than restating them, so the two gates cannot disagree about the
same file. 71 tests, 100% branch coverage.
stale.py warns on TODOs past 90 days but never fails: no machine can
prove a task is finished, so the honest mechanism is a nudge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
dopamine-ux prompt 01. The token layer froze colour, spacing, radius and type,
but every animation in every app picked its own duration and curve out of the
air -- so "200ms ease-in-out" and "180ms ease-out" both existed and neither was
wrong, because nothing said what right was.
unified-design-system/motion.md is the freeze: four durations, three curves,
a haptic vocabulary mapped onto Flutter's HapticFeedback, and three sound cues,
each with its one-line rationale. Shipped as AppDuration/AppCurve in Dart and
--duration-*/--ease-* in CSS, so a consumer imports the scale instead of
transcribing it.
Three things worth stating, because they are the parts easy to get wrong:
- Reduced motion collapses the *tokens themselves* (CSS @media, Flutter
disableAnimations), so no consumer has to remember to branch.
- Haptics and sound are NOT motion and are deliberately left alone by that
preference. Suppressing animation is not a request to lose confirmation that
a tap landed; the sound opt-out is a separate, user-facing setting.
- Reward the recording, never the number recorded. The haptic fires on logging
a meal, not on hitting a calorie target -- the latter rewards eating less
rather than recording honestly, and corrupts the app's own data.
gatelock stays untouched: Tkinter has no animation framework, so motion there
is marked `n/a (tk)` rather than shipping a dead constant that reads as a
supported feature.
Tests assert the frozen values on both sides, and that the reduced-motion block
collapses every non-zero step -- verified by mutation: deleting one collapse
line fails the suite. flutter analyze / tsc / eslint clean, 68 Dart and 88 TS
tests pass, palette_check and the 250-line cap still exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
operability.md pointed at README.md#pointer-free--small-screen-per-stack-patterns,
written before that section was moved out of README into
operability-patterns.md. The link now targets the file that holds the content.
My earlier link check only validated the *file* part of each link and silently
ignored the fragment, which is why this survived. Re-checked with the anchor
compared against the target file's actual headings; all links in the directory
now resolve, fragment included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
tokens.md 260 -> 219 + operability.md 69
nielsen-audit.md 258 -> 154 + shipped-record.md 122
README.md 257 -> 172 + operability-patterns.md 95
Worked the requested order rather than cutting straight to a split.
(a) Automate: the categorical ramp's four constraints were restated in prose
with the measured margins hand-copied (ΔE 34.6/26.0/25.6/27.4, etc).
ramp_check.py prints every one of those numbers and fails if any
regresses, so the prose now points at the command instead of duplicating
its output. The two consequences the numbers do not state — why the ramp
is mid-toned, and why lightness falls monotonically — stay in prose.
(b) Dedup: measured, not assumed. Zero paragraphs are shared between the
three files; README already delegates the rules to tokens.md and shows
only per-stack code. Nothing to remove.
(c/d) Split on meaning: tokens.md is "frozen values", so the behavioural
requirements that *use* those tokens moved to operability.md, with the
per-stack code for them in operability-patterns.md. nielsen-audit.md is a
live grid, so completed phases moved to shipped-record.md — following the
existing phase3-record.md convention, not a new one.
The focus-ring token row had to stay in tokens.md: palette_check.py parses
that file, and moving the row made the check fail with "MISSING md:focus-ring"
— a token it cannot see is a token nothing compares across the four stacks.
The rationale lives in operability.md and the row links to it.
palette_check and ramp_check both at 0 failing checks; every relative link
between the docs resolves.
`bash scripts/check_file_length.sh --all` now exits 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
_recovery.py 321 -> 242 + _recovery_types 47 + _grabwatch 84
_surfaces.py 380 -> 250 + _placement 136 + _surface_types 54 + _textmirror 70
Two guards had to be repaired first, because both would have gone quiet
rather than red:
1. palette-drift CI was ALREADY BROKEN by the earlier _window.py split.
palette_check.py reads LockConfig's colour defaults from a hardcoded
gatelock/_window.py, and LockConfig had moved to _config.py — so it parsed
0 tk tokens and reported "10 failing check(s)" where HEAD reports 0. Fixed
the path, and made parse_tk raise instead of returning {} when LockConfig
is not found: the empty dict is what let every tk comparison pass
vacuously while the drift guard was off for the whole stack.
2. test_recovery_invariants' symbols() read one hardcoded filename, so
splitting _recovery.py would have narrowed the "no weakening call"
invariant — the one that exists because of the 2026-07-25 unlock — to
whichever half kept the name, with the suite still green. It now globs the
whole _recovery*/_surfaces* family and fails if the glob matches nothing.
Verified it discriminates: a throwaway _recovery_probe.py calling
restore_vt_switching made the test fail, and removing it restored green.
GrabWatch is a small object rather than a free function because holds_grab
and its once-per-cause warning share state; _placement holds the geometry
questions and the re-assertion pass, which can only ever add visibility.
_surfaces keeps an explicit __all__: the autofixer pruned the mirror_text_widgets
re-export once and `import gatelock` broke, so the intent is now stated.
389 passed at 100% branch coverage; gatelock.__all__ is the same 58 names as
HEAD; palette_check back to 0 failing checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
`ts_core` (@kuhyx/ts-core) and `github_device_auth` close the two clusters
the audit flagged as genuinely missed. Six shared packages now, not four.
Both extractions turned up the same lesson, worth writing down: the copy
that looks canonical is not always the one to take. Each cluster had a
behaviour living in exactly one repo that a naive "extract the tidiest
version" would have silently deleted --
* diet-guard's configurable device-flow endpoints, without which its
desktop web build cannot authenticate at all (no CORS headers on
GitHub's endpoints)
* wake-alarm's transient-socket retry, without which an approved grant
is lost if GitHub closes the connection at the wrong moment
Both are now in the shared package, so all four apps gained the fix the
other three never had. Same shape in ts_core: the RNG ships two interfaces
rather than one reconciled shape, because the generated sequence is the
contract and re-rolling every saved world would be a worse outcome than
the duplication.
sims3-clone is recorded as the one consumer still pending -- another
session had uncommitted work there, so migrating it would have entangled
two sets of changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
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
`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
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
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
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
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
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
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
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>
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>