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
13 KiB
Nielsen coverage audit
Jakob Nielsen's "10 GUI Design Elements Build Every User Interface" argues that essentially every UI is assembled from ~10 standardized elements, and that the value comes from reusing those conventions rather than re-inventing them. Re-implementing the same button in eight repos is the private version of the convention erosion he describes.
This file is the audit grid, not a build list. Shipping a widget nobody imports is the failure mode to avoid — 183 imported agent definitions were invoked zero times in 432 sessions. A component gets built only where two or more repos already contain a structurally similar implementation.
Written 2026-08-14, alongside design_system v0.1.0.
Scope
Fourteen Flutter repos, seven web repos, four Python/Tk guard apps. Out of
scope, verified rather than assumed: Unity (zero .uxml/.uss files exist
anywhere), games (their repeated identifiers are game constants like
FORGE_MAX_C, not UI), and the locker gate windows (their X-grab/input-hijack
invariants make the window non-reusable, though its leaf widgets are fair
game).
~/testsAndMisc/pomodoro_app and horatio no longer exist. workout_app
does not exist either — it is named in the source plan and in several skill
docs, but no directory under ~ matches *workout*. Anything citing it as a
consumer is stale.
The grid
| # | Nielsen element | Flutter | Web | Python/Tk |
|---|---|---|---|---|
| 1 | Buttons | ⚠️ themed only | ❌ 7 vocabularies | ⚠️ 4 factories, see Phase 2 |
| 2 | Input fields / forms | ✅ inputDecorationTheme |
❌ | ⚠️ |
| 3 | Menus | ❌ not specced | ❌ | ❌ |
| 4 | Links | ❌ not specced | ❌ | n/a |
| 5 | Dialogs | ✅ confirmDestructive() |
❌ | ⚠️ |
| 6 | Alerts / notifications | ✅ showToast/showError |
❌ | ❌ |
| 7 | Icons | ❌ not specced | ❌ | ❌ |
| 8 | Checkboxes / radio buttons | ⚠️ themed only | ❌ | ❌ |
| 9 | Tabs | ❌ not specced | ❌ | ❌ |
| 10 | Search | ❌ not specced | ⚠️ FilterBar ×2 |
n/a |
components.html specs 9 components but says nothing about menus, links,
icons, radio buttons, tabs, or search — six of Nielsen's ten. That is the
documentation gap; whether any of them deserves code still depends on the
two-repo extraction criterion, which most of them currently fail.
What v0.1.0 closed
| Export | Replaced |
|---|---|
AppPalette + scales |
10 divergent theme.dart copies; ~318 raw color literals |
buildLightTheme / buildDarkTheme |
the same, per repo |
AppStatusColors |
4 hand-rolled ThemeExtensions with inconsistent fields |
confirmDestructive() |
~35 inlined AlertDialogs |
showToast / showError |
~38 raw SnackBar sites, zero helpers |
EmptyState |
4 variants, one reusable |
SectionHeader |
3 near-identical private widgets |
Proof consumers todo and untools are migrated, verified and pushed.
Which exports have a live consumer
An extracted component with no importer is the failure mode this audit exists to prevent, so the honest status per export:
| Export | Live consumers |
|---|---|
| tokens, palette, themes | todo, untools |
confirmDestructive |
todo (2 sites) |
showToast / showError |
todo (3 sites) |
SectionHeader |
untools (2 sites) |
EmptyState |
none yet — donor is home_inventory (3 sites), unmigrated |
AppStatusColors |
none yet — 4 donors, all unmigrated |
Palette/token consumers beyond the two proof apps, added the same day:
focus_owner, kuhylog, epopeja_karta (tokens only) and billsplit.
The last two ship ahead of their consumers. That is defensible (both have
real donors waiting, and AppStatusColors must exist for showError to read
the danger hue off the theme) but it is not validated, and it should be
called that way until home_inventory or one of the status-color donors
migrates.
Corrections to the original plan, found by measuring
The plan's figures were mostly right but three were not, and the difference changes what is worth doing next:
- The "343 raw literals" are not spread through call sites. They are
concentrated in the hand-copied theme files. todo had 35, all 35 inside
theme.dart; untools had 36, of which 35 were intheme.dartand the last wasColors.transparentinkeyboard.dart— a sentinel, not a palette value. Deleting the theme file removes essentially all of them. There was no call-site sweep to do in either proof consumer, and the same is true of home_inventory, habit_stack, diet-guard, wake-alarm, macro-cam and kuhylog, which all have zero literals outside their token file. The repos with genuine call-site leakage arebillsplit(16, all ad hocColors.red/teal/black54),epopeja_karta(14, palette inlined inapp.dart) andfocus_owner(8). - There are 4
AppStatusColors, not 5, and they disagree about their own shape: home_inventory and diet-guard havesuccess+warning, habit_stack hassuccessonly, wake-alarm has both; home_inventory and habit_stack shiplightanddarkstatics, the other twodarkonly. The package's version is a superset (success/warning/danger/info/onStatus), so every consumer can adopt it without losing a field. - There are 3 section headers, not 5, and no
_SectionLabelexists. Two agreed ontitleMediumand were reconciled intoSectionHeader;epopeja_karta's is a different element —labelSmall+ primary color + letter-spacing, i.e. an eyebrow, not a section title. It was deliberately not folded in.
Deferred clusters
Actionable later; each line is repo + path + size, so none of this needs rediscovering.
Flutter — large reconciliations
| Cluster | Where | Size |
|---|---|---|
GitHubMirrorScreen ×5 |
todo, diet-guard, +3 | ~1551 lines, 586 differing between two copies |
SettingsScreen ×6 |
fleet-wide | — |
| List tiles | ~20 classes fleet-wide | — |
| Filter sheets | todo, dufs-cloud, +1 | name collision, not duplicates — dufs-cloud's uses no chips at all; judge structurally |
| Steppers, badges | fleet-wide | — |
The five GitHubMirrorScreen copies each carry a near-verbatim "kept
app-local rather than folded into sync_settings_ui" comment. That decision
deserves revisiting on its own, not smuggled into a component extraction.
Flutter — palette drift: DONE 2026-08-14
Standing decision: every app uses exactly the frozen palette; where copies
disagree, the most popular wins. That was unambiguous — 9 of 10 theme files
already used the gold #B8862E family. All four drifted repos are migrated.
| Repo | Drift that was fixed | How |
|---|---|---|
testsAndMisc/focus_owner |
entirely off-palette: cool #5B9DD9 accent, #1B1D21 field, #D9776B danger. Its doc comment claimed it matched the other com.kuhy.* apps; it did not. The same six constants were also duplicated verbatim in main.dart:16-21 — the file said they had been "lifted out", but the originals were never deleted. |
theme.dart now aliases AppPalette, so the ~80 call sites keep their names; both copies gone. Literals 15 → 2 (Colors.transparent). Verified on the Pixel 6a. |
kuhylog |
the only repo using ColorScheme.fromSeed (blue #3B82F6) — the opposite of what every other theme file documents. |
KuhylogTheme.of() delegates to buildLightTheme/buildDarkTheme. Kept as a wrapper for its visualDensity, flat cardTheme and scoreColor. Literals 1 → 0. |
epopeja_karta |
ColorScheme inlined in app.dart, no theme.dart. |
Imports tokens only — see below. Literals 9 → 0. |
billsplit |
ColorScheme.fromSeed(Colors.teal) plus 15 ad hoc literals that mostly bypassed the theme entirely. |
buildLightTheme(); error family → error/onError/errorContainer, muted text → onSurfaceVariant. Literals 15 → 4. |
diet-guard, macro-cam |
scales drifted from the six-repo consensus | still outstanding — adopt design_system |
Two deliberate exceptions, so nobody "fixes" them back:
epopeja_kartaadoptsAppPalette, notbuildDarkTheme(). Its nine slots were already byte-identical to the frozen palette, so only the transcription was local. It is one dense screen with a test asserting nothing scrolls, and the shared theme's larger type scale plusdividerTheme.spaceoverflows it by 34px. Typography there is a product decision; colour is the identity, and colour is what the system owns. Importing tokens leaves the renderedThemeDataidentical, so the no-scroll test passes by construction rather than by hitting a pixel target.billsplitkeeps its four category-dot colours (deepOrange/amber/blueGrey/tealfor alcohol/mixer/deposit/other). They encode category identity, not a theme role, and a single-accent system has no four distinct hues to lend. Folding them intoprimary/secondary/tertiarywould make the categories stop being distinguishable, which is their only job. This is the open categorical-ramp question — the design system currently has no answer for "N mutually distinguishable hues", and it needs one before any chart, tag or category UI can be built on it.
Remaining Flutter adopters
home_inventory, dufs-cloud, habit_stack, diet-guard, macro-cam,
wake-alarm/phone_app. Each drops its local theme.dart for the tag-pinned
dep, as todo, untools, focus_owner, kuhylog, epopeja_karta and billsplit now
do. These six were never off-palette — they transcribe the right values by
hand — so this is duplication cleanup, not a visual fix.
Phase 2 — Python/Tk (~/utils/gatelock), not started
Not a new package: gatelock already owns the tokens (LockConfig), the Tk
plumbing and 4 consumers. Add composite widgets, which is where its scope
currently stops.
widgets.py::make_button— canonical is diet-guard's_gatelock_buttons.py::make_button, whosevariantAPI picks text color from the fill and so structurally prevents thefg-vs-on_fillcontrast bug. leetcode-guard's_button(no<Return>binding) and screen-locker's inlinetk.Buttonare the regressions being retired.widgets.py::heading()/row()— fromleetcode_guard/_status_sections.py.- Export the existing
ScrollableSurfaceand deleteleetcode_guard/status_view.py::_scrollable, a hand-rolled reimplementation that losttakefocusand the focus ring. Straight accessibility repair. widget_group.py::WidgetGroup— the per-output fan-out reimplemented 4× (~916 lines: screen-locker 173, leetcode-guard 105, diet-guard 309, wake-alarm 329). Highest line-count win on this stack.
Proof consumers: diet-guard and leetcode-guard (donor and worst offender).
Bump only those two; leave screen-locker and wake-alarm on their current
pin — Phase 2 is additive, and restarting live services is blast radius for no
gain.
Verification gate — non-negotiable. gatelock backs live systemd services. After any change:
/usr/bin/python3 -c "import gatelock"against the real interpreter, not a dev venv. Do not touch the gate window's grab path. Then launch each gate underxvfb-run -s "-screen 0 1366x768x24"and confirm buttons render and<Return>activates them.
Phase 3 — TypeScript (~/utils/web_ui), not started
Lowest confirmed duplication (2 components, 2 repos) and the only stack needing a brand-new consumption mechanism, so it carries the worst effort-to-payoff ratio — sequenced last on purpose.
tokens.css— one:rootblock. Fixes the worst fork found: 7 repos define 7 different vocabularies for the same palette (--surface-1vs--panelvs--card;--textvs--inkvs--fgvs--bone; spacing as--space-Nvs--sp-Nvs--sp-xs). europe-county-map and iron-and-anvil have zero custom properties.RangeSlider— canonical is dufs-cloud's, whose purefractionFromPointer(rect, clientX)is testable without layout (jsdom has none). awesome-mcp-explorer's copy has drifted 196 lines.FilterBar+filter-sort.ts— reconcile dufs-cloud (174) vs awesome-mcp-explorer (225).
Consumption mechanism (decided 2026-08-14): a git dep
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.1.0", matching the Dart/Python
tag convention rather than introducing npm workspaces — but shipping a
prebuilt dist/ (tsc → ESM + .d.ts) committed in the tag. Raw .tsx in
node_modules would need per-repo bundler config, since Vite does not
transpile dependencies by default; a committed dist/ keeps consumers at zero
config.
Proof consumers: dufs-cloud and awesome-mcp-explorer.
Standing risks
- Version churn. Each new tag needs a
pubspec.yamlbump in every consumer.habit_stackis already 7 minor versions stale oncrdt_sync(v0.3.0 vs v0.10.0); the same drift will hit this package. - Reconciliation is judgement. Where copies diverged, a "best-of" choice can regress a consumer that depended on its local variant. Migrating proof consumers first exists to surface that early.