Skip to content

feat(theme): generate the compiled seed from the bundled JSON - #4582

Open
nigelfenton wants to merge 2 commits into
aethersdr:mainfrom
nigelfenton:feat/theme-seed-generator
Open

feat(theme): generate the compiled seed from the bundled JSON#4582
nigelfenton wants to merge 2 commits into
aethersdr:mainfrom
nigelfenton:feat/theme-seed-generator

Conversation

@nigelfenton

Copy link
Copy Markdown
Contributor

#3184 item 2 — "the high-leverage one; kills the drift class permanently."

Stacked on #4570 (6c599007), whose checker this repurposes into the freshness gate rather
than superseding. Review 7250974f alone, or merge #4570 first.

What was wrong

seedBuiltinDefaults() was a hand-maintained copy of default-dark.json. Both failure modes its
own comment warned about had already happened:

  • 9 tokens had driftedcolor.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 at allslice.dim.a..h, highlight.*, button.*.disabled,
    and the six waterfall.colormap.* gradients. These resolved transparent (alpha 0) on any
    theme predating them, which is strictly worse than a stale value.

What this does

tools/gen_theme_seed.py emits src/core/ThemeSeedGenerated.cpp from 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 "Preliminary
values — a dedicated slice-colour audit may tune these"
comment is how the drift was
diagnosed
. tools/check_theme_seed.py becomes a staleness gate; its KNOWN_SEED_DRIFT list is
deleted 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 to ThemeManager.cpp), so
scoped 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_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, because byte-equality
alone would happily pass a generator that silently dropped a family:

injected fault result
drop the waterfall gradients FAIL — "emitted 120 tokens but the theme defines 128"
edit the theme, don't regenerate FAIL --strict, exit 1
neither pass

ctest -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.md line under
whichever release carries it; happy to add that here if you'd prefer it in-PR.

Refs #3184.

nigelfenton and others added 2 commits July 29, 2026 01:38
`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>

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.yml paths: doesn't cover ThemeSeedGenerated.cpp or gen_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.cpp line in the ENABLE_ASR block, 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 emit QColor("{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 --check completeness 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_test but 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

Comment on lines +17 to +20
- "src/core/ThemeManager.cpp"
- "resources/themes/**"
- "tools/check_theme_seed.py"
- ".github/workflows/theme-seed-check.yml"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- "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"

Comment thread CMakeLists.txt
Comment on lines +3556 to +3557
src/core/ThemeSeedGenerated.cpp
src/core/ThemeSeedGenerated.cpp

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
src/core/ThemeSeedGenerated.cpp
src/core/ThemeSeedGenerated.cpp
src/core/ThemeSeedGenerated.cpp

Comment thread tools/gen_theme_seed.py
Comment on lines +91 to +93
if isinstance(value, dict):
return {k: resolve(v, primitives, depth + 1) for k, v in value.items()}
return value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Comment thread tools/gen_theme_seed.py
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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things this check doesn't do, worth reflecting in the comment above it since the PR description leans on it fairly hard:

  1. expected and emitted both derive from collect(). It catches a drop in the emit loop (which is what you injected to verify it), but a bug in flatten()/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 given is_compound() already has one documented near-miss (the font.family namespace).
  2. < 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (5 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4582/src/gui/MainWindow.cpp:7140
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4582/src/gui/MainWindow.cpp:7174
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4582/src/gui/MainWindow.cpp:7279
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4582/src/gui/MainWindow.cpp:7280
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-4582/src/gui/MainWindow.cpp:7281

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant