feat(theme): generate the compiled seed from the bundled JSON - #4582
feat(theme): generate the compiled seed from the bundled JSON#4582nigelfenton wants to merge 2 commits into
Conversation
`ThemeManager::seedBuiltinDefaults()` compiles a copy of the default theme
into the binary so the UI is usable with no theme files on disk. It is kept
in sync with `resources/themes/default-dark.json` BY HAND, and the source
says so twice — including an explicit warning that a drifted seed diverges
"silently ... both layers resolve, the JSON wins, but the visible vs. seeded
values diverge for pre-PR user themes".
That drift has already happened. Nine tokens disagree on `main` today:
color.accent.dim seed #0090e0 JSON #0070c0
color.slice.a..h all eight differ (e.g. slice.a: seed #ff4040, JSON #00d4ff)
The slice colours are self-explaining: the seed comment reads "Preliminary
values — a dedicated slice-colour audit may tune these in a follow-up." The
JSON was later tuned; the seed never was. Both bundled themes agree slice A
is cyan/blue; only the compiled seed still says red.
Why it stayed hidden: on a normal run the JSON wins, so a drifted seed is
invisible. It only surfaces for a user whose theme predates a token — the
seed then supplies the value the JSON would have overridden — or when no
theme file loads at all. Both are exactly the cases nobody is watching.
This does NOT change any colour. It adds a checker that makes the drift
visible and stops it widening:
- `tools/check_theme_seed.py` parses both sides (resolving `{color.red.500}`
aliases against the primitives palette) and diffs them. The nine existing
divergences are frozen in KNOWN_SEED_DRIFT; `--strict` fails on anything
else. The list may only shrink — a token that stops drifting is reported
as stale so it gets removed.
- `.github/workflows/theme-seed-check.yml` runs it on PRs touching
ThemeManager.cpp, resources/themes/**, or the checker. Modelled on
engine-boundary.yml: same concurrency block, same pinned action SHAs,
same known-offenders-warn / new-offenders-fail shape.
The checker refuses to report a clean run if it parses zero shared tokens,
so a future refactor of `seedBuiltinDefaults()` that breaks the parser fails
loudly instead of silently passing.
This is deliberately a stopgap. The real fix — aethersdr#3184 item 2 — is to GENERATE
the seed table from the resource at build time so the two cannot disagree.
This pins the current state so that work lands against a floor, and gives it
a regression test for free.
Verified: all three paths exercised — known drift reported (exit 0), an
injected new divergence fails --strict (exit 1) and is listed separately
from the known nine, and a repaired token is flagged as stale. Same results
on Windows/Python 3.14 and on Linux/Python 3.12 (the CI version).
Refs aethersdr#3184.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aethersdr#3184 item 2 — "the high-leverage one; kills the drift class permanently." Builds on aethersdr#4570, whose checker becomes the freshness gate rather than being superseded. `ThemeManager::seedBuiltinDefaults()` was a hand-maintained copy of resources/themes/default-dark.json. Both failure modes its own comment warned about had already happened: * 9 tokens had DRIFTED — color.accent.dim and all eight color.slice.a..h. The seed said slice A was #ff4040 (red); both bundled themes say #00d4ff (cyan). * 25 tokens were NEVER SEEDED — slice.dim.a..h, highlight.*, button.*.disabled and the six waterfall.colormap.* gradients. Those resolved TRANSPARENT (alpha 0) on any theme predating them, which is strictly worse than a stale value. tools/gen_theme_seed.py now emits src/core/ThemeSeedGenerated.cpp from the resource, resolving {primitive} aliases to concrete values because the seed runs before any primitives palette exists. 209 hand-written lines become one call. All 128 tokens are emitted, up from 104. Per the design agreed on the issue: COMMIT-TIME, not build-time. The generated file is checked in and greppable — seedBuiltinDefaults() is code people demonstrably read, and its "Preliminary values — a dedicated slice-colour audit may tune these" comment is HOW the drift was diagnosed. tools/check_theme_seed.py is repurposed from a drift baseline into a staleness gate; its KNOWN_SEED_DRIFT list is deleted because the class of bug is gone, not because the nine were individually fixed. One structural note: the generated translation unit cannot see ThemeScope, whose definition is private to ThemeManager.cpp, so scoped tokens go through a new private seedScopedToken() helper instead of touching s->tokens directly. ## On testing The seed is NOT observable through the public API. The constructor loads Default Dark immediately after seeding, so by the time instance() returns the JSON has overwritten everything — which is exactly the invisibility that let the drift go unnoticed. I wrote C++ assertions for it, found they passed identically against the OLD hand-written seed, and removed them rather than ship a test that proves nothing. theme_manager_test carries a note explaining why, so the next person doesn't repeat the attempt. Verification lives where the property is observable: `--check` now asserts COMPLETENESS (every JSON token reaches the emitted table) independently of byte-equality, since byte-equality alone would happily pass a generator that silently dropped a family. Verified by injecting exactly that bug: dropping the waterfall gradients fails with "emitted 120 tokens but the theme defines 128", and passes again when restored. Staleness verified the same way — editing the theme without regenerating fails --strict, exit 1. ctest -R "theme|s_meter|colour|color|style|applet" 5/5. Full tree builds on Windows/MSVC. Refs aethersdr#3184. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Strong PR, and the write-up is the kind I wish more contributors wrote — the "I deleted the test because it passed against the broken code" section in particular. I checked the claims rather than taking them: gen_theme_seed.py --check reproduces ThemeSeedGenerated.cpp byte-for-byte, no token from the old hand-written seed is lost (95 root+scoped keys all present, 128 emitted), and the only value changes are exactly the nine documented (color.accent.dim + slice.a..h) — the three applet/* scoped triples come through identical. The seedScopedToken() shape for keeping ThemeScope out of the generated TU is the right call, and it's declared under private: not private slots:, so moc stays out of it. All nine checks green, including the seed gate itself.
Two things I'd like sorted before merge; the rest is polish.
Would like fixed before merge
theme-seed-check.ymlpaths:doesn't coverThemeSeedGenerated.cpporgen_theme_seed.py— the two files the gate now exists to protect. A hand-edit to the "DO NOT EDIT" file never runs the check.- Stray duplicate
ThemeSeedGenerated.cppline in theENABLE_ASRblock, mis-indented. Harmless today (CI is green) but clearly an unintended paste.
Polish
resolve()doesn't descend into lists, so a{primitive}alias in a gradient stop would emitQColor("{color.blue.500}")— invalid, i.e. transparent. No bundled theme does this today; it's the exact failure class this PR removes, re-entering through a side door.- The
--checkcompleteness assertion is weaker than the description implies — worth a comment adjustment rather than a code change.
Non-blocking notes
- Yes, add the CHANGELOG line here rather than in a follow-up — the slice-colour flip is user-visible.
- One thought on the "seed is unobservable" conclusion, which is true for
theme_manager_testbut not universally.
CodeGuard's five CG-PATH-001 findings are all in src/gui/MainWindow.cpp, which this PR doesn't touch — dropped as out-of-diff.
🤖 aethersdr-agent · cost: $6.6833 · model: claude-opus-5
| - "src/core/ThemeManager.cpp" | ||
| - "resources/themes/**" | ||
| - "tools/check_theme_seed.py" | ||
| - ".github/workflows/theme-seed-check.yml" |
There was a problem hiding this comment.
This paths: list predates the generator (it came in with #4570, when ThemeManager.cpp was the seed). After this PR the artifacts under the gate are ThemeSeedGenerated.cpp and gen_theme_seed.py, and neither triggers the job — so hand-editing the file whose banner says DO NOT EDIT, or changing the generator's emission logic, both land without the check ever running. ThemeManager.cpp can stay (cheap, and it's where seedBuiltinDefaults() lives), but the two new ones are the ones that matter.
The note added in theme_manager_test.cpp says the gate "runs it on every PR that touches either side" — this makes that true.
| - "src/core/ThemeManager.cpp" | |
| - "resources/themes/**" | |
| - "tools/check_theme_seed.py" | |
| - ".github/workflows/theme-seed-check.yml" | |
| - "src/core/ThemeManager.cpp" | |
| - "src/core/ThemeSeedGenerated.cpp" | |
| - "resources/themes/**" | |
| - "tools/gen_theme_seed.py" | |
| - "tools/check_theme_seed.py" | |
| - ".github/workflows/theme-seed-check.yml" |
| src/core/ThemeSeedGenerated.cpp | ||
| src/core/ThemeSeedGenerated.cpp |
There was a problem hiding this comment.
Duplicated line, and the second copy carries the wrong indentation for this block (4 spaces vs 8). CI is green because CMake deduplicates identical sources within a target, so this compiles either way — but it reads as a paste slip, and ENABLE_ASR defaults ON so it's in the default build.
| src/core/ThemeSeedGenerated.cpp | |
| src/core/ThemeSeedGenerated.cpp | |
| src/core/ThemeSeedGenerated.cpp |
| if isinstance(value, dict): | ||
| return {k: resolve(v, primitives, depth + 1) for k, v in value.items()} | ||
| return value |
There was a problem hiding this comment.
resolve() recurses into dicts and strings but passes lists through untouched — and gradient stops is a list of dicts. So a stop written as {"at": 0.5, "color": "{color.blue.500}"} would reach emit_gradient() unresolved and emit QColor("{color.blue.500}"), which QColor rejects → an invalid (transparent) stop, silently.
No bundled theme does this today (I checked — all 65 aliases in default-dark.json are scalar token values, none inside a list), so this is latent, not live. But it's the same silent-transparency failure this PR is removing, and the whole point of generation is that the next person can edit the JSON without thinking about the seed.
| if isinstance(value, dict): | |
| return {k: resolve(v, primitives, depth + 1) for k, v in value.items()} | |
| return value | |
| if isinstance(value, dict): | |
| return {k: resolve(v, primitives, depth + 1) for k, v in value.items()} | |
| if isinstance(value, list): | |
| return [resolve(v, primitives, depth + 1) for v in value] | |
| return value |
| data = json.loads(THEME_JSON.read_text(encoding="utf-8")) | ||
| expected = collect(data["scopes"]["root"]) | ||
| emitted = text.count("insert(") + text.count("seedScopedToken(") | ||
| if emitted < len(expected): |
There was a problem hiding this comment.
Two things this check doesn't do, worth reflecting in the comment above it since the PR description leans on it fairly hard:
expectedandemittedboth derive fromcollect(). It catches a drop in the emit loop (which is what you injected to verify it), but a bug inflatten()/is_compound()— e.g. a future compound shape misclassified so a whole namespace collapses to one key — shrinks both sides equally and passes clean. That's a plausible failure givenis_compound()already has one documented near-miss (thefont.familynamespace).<rather than!=means over-emission passes silently.
No change needed to the logic for (1) — a genuinely independent count would mean a second parser, which you correctly argued against in check_theme_seed.py. But the comment currently reads as if it catches any dropped family, and it catches emit-side drops specifically. != for (2) is free.
| // The seed's completeness and correctness are therefore verified where they | ||
| // ARE observable: tools/gen_theme_seed.py --check asserts the generated | ||
| // table matches the bundled JSON token-for-token, and the theme-seed CI gate | ||
| // runs it on every PR that touches either side. |
There was a problem hiding this comment.
The reasoning is right for this target, but the conclusion is slightly stronger than the tree supports: the seed is observable in targets that don't link the theme resource — container_widget_test's own CMake comment says exactly that ("ThemeManager's compiled-in seedBuiltinDefaults() means we don't need the theme resource here"). With no :/themes/default-dark.json to load, the constructor's load fails and the seeded values survive to instance().
That's the one place a C++ assertion would have real teeth — and notably it would have caught the drift, since color.slice.a there resolves to the seed value, not the JSON's. Not asking you to add it in this PR; the Python gate is the higher-leverage check and you've argued that well. But I'd soften "you cannot, from here" to "you cannot from this target, because it links the theme resource" so the next person doesn't read it as impossible in principle.
|
Cisco CodeGuard — static analysis of this PR (5 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $7.1942 · model: claude-opus-5 |
#3184 item 2 — "the high-leverage one; kills the drift class permanently."
What was wrong
seedBuiltinDefaults()was a hand-maintained copy ofdefault-dark.json. Both failure modes itsown comment warned about had already happened:
color.accent.dimand all eightcolor.slice.a..h. The seed saidslice A was
#ff4040(red); both bundled themes say#00d4ff(cyan).slice.dim.a..h,highlight.*,button.*.disabled,and the six
waterfall.colormap.*gradients. These resolved transparent (alpha 0) on anytheme predating them, which is strictly worse than a stale value.
What this does
tools/gen_theme_seed.pyemitssrc/core/ThemeSeedGenerated.cppfrom the resource, resolving{primitive}aliases to concrete values (the seed runs before any primitives palette exists).209 hand-written lines become one call. All 128 tokens emitted, up from 104.
Per the design agreed on the issue: commit-time, not build-time. The generated file is checked
in and greppable —
seedBuiltinDefaults()is code people demonstrably read, and its "Preliminaryvalues — a dedicated slice-colour audit may tune these" comment is how the drift was
diagnosed.
tools/check_theme_seed.pybecomes a staleness gate; itsKNOWN_SEED_DRIFTlist isdeleted because the class of bug is gone, not because the nine were individually patched.
One structural note: the generated TU can't see
ThemeScope(private toThemeManager.cpp), soscoped tokens go through a new private
seedScopedToken()helper.On testing — the honest version
The seed is not observable through the public API. The constructor loads Default Dark
immediately after seeding, so by the time
instance()returns the JSON has overwritten everything.That is precisely the invisibility that let the drift go unnoticed.
I wrote C++ assertions for it, discovered they passed identically against the old hand-written
seed, and removed them rather than ship a test that proves nothing.
theme_manager_testcarriesa note explaining why, so the next person doesn't repeat the attempt.
Verification lives where the property is observable —
--checknow asserts completeness(every JSON token reaches the emitted table) independently of byte-equality, because byte-equality
alone would happily pass a generator that silently dropped a family:
--strict, exit 1ctest -R "theme\|s_meter\|colour\|color\|style\|applet"5/5; full tree builds on Windows/MSVC.Behaviour change worth calling out
A user whose theme predates the slice tokens currently renders slice A red from the seed and
will flip to cyan. That is the correct value — both bundled themes agree — and the blast radius
is limited to pre-v2 themes. Per the issue discussion this wants a
CHANGELOG.mdline underwhichever release carries it; happy to add that here if you'd prefer it in-PR.
Refs #3184.