ci(theme): pin the compiled theme seed against the bundled JSON - #4570
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>
There was a problem hiding this comment.
Good PR. I ran the checker against main @ d50377f6 and reproduced your results exactly — 87 tokens compared, the same nine divergences, --strict exit 0 — so the drift table in the description is accurate and this genuinely changes no colours. The workflow is a faithful copy of engine-boundary.yml (same pinned SHAs, same concurrency block, same known-warn/new-fail shape), and leaving the nine unfixed is the right call for a pinning PR. Non-blocking stale reporting matches the EB3-stale precedent in check_engine_boundary.py, so no objection there either.
One real gap: the zero-token guard you added covers total parser failure but not partial failure, which is the more likely refactor outcome. I simulated three plausible edits to seedBuiltinDefaults() — only one of them trips the guard:
| refactor | tokens parsed | result |
|---|---|---|
QString( → QStringLiteral( |
0 / 87 | guard trips ✅ |
| scoped block closing brace reindented | 81 / 87 | passes silently |
m_tokens → rootTokens() rename |
9 / 87 | passes silently |
That last one is the bad case: all 78 root tokens vanish, which includes all nine known-drift entries, so the run reports them as stale and still exits 0 — the gate inverts into "please delete your baseline." Concrete fix suggested inline.
Would like fixed before merge
- Partial parse failure passes
--strictsilently; assert every hex literal in the body was captured, not just that the count is non-zero (tools/check_theme_seed.py:132).
Polish
- 17 seeded non-colour tokens (
font.size.*,sizing.*) are outside the parser, but theseeded only in C++ : 0line reads as full coverage (tools/check_theme_seed.py:163).
Non-blocking notes
- Short-form hex would report as false drift (
tools/check_theme_seed.py:66).
🤖 aethersdr-agent · cost: $2.9168 · model: claude-opus-5
ten9876
left a comment
There was a problem hiding this comment.
Reviewed by checking it out and running the checker rather than reading it cold. The premise is right, the shape is right, and the claims in the description hold up. Two things I'd want fixed before merge, and two scope calls I'd rather see stated than fixed.
Answers to your two questions are on #3184 — short version: the JSON wins, and generation is commit-time, which makes this checker the freshness gate for the generator rather than something the generator supersedes. That raises the value of getting the coverage right here, hence finding 1.
Verified
Reproduced exactly on the branch: 87 tokens compared, 9 known, 0 new, exit 0. The nine divergences are real. Alias resolution is clean — zero unresolved {…} across the 87, so no false positives lurking. The applet/tx|rx|comp scope-path construction matches the C++ scopeOrCreate keys and all 9 scoped tokens parse. The workflow is a faithful clone of engine-boundary.yml: same pinned action SHAs, same concurrency block, permissions: contents: read, path-filtered. It isn't in branch protection's required set (build, check-windows, check-macos), so it won't hang unrelated PRs — same advisory-by-convention status as the engine-boundary ratchet, which is the right call.
1. The parser silently skips 17 of the 95 root inserts
HEX = r"#[0-9a-fA-F]{3,8}" means only hex-colour literals are extracted. Dropped:
- seven
font.family.*(string values) - nine numeric —
font.size.tiny|small|normal|large,sizing.panel.padding|spacing|cornerRadius,sizing.border.subtle|strong color.meter.bar.fillGradient— a colour token, squarely in scope, skipped only because its insert wraps to a second line
I checked the nine numeric ones against default-dark.json: they all exist there with matching values today. So they are comparable and currently clean — they are just not covered. Change sizing.panel.cornerRadius to 6 in the JSON alone and this checker still prints a clean run. That is precisely the drift class the PR exists to pin.
It also undercuts the description's strongest claim — that the zero-shared-token guard means a refactor which breaks the regex "fails loudly instead of silently passing." True only for total breakage. A reformat that moves QString( to the next line for some subset drops those tokens and still exits 0, while the summary line seeded only in C++: 0 reads as full coverage.
Suggested fix, cheaper and stricter than a magic minimum: count tokens.insert( occurrences inside the extracted function body and fail when the number extracted is lower. That converts partial-parse into a hard error, which is the failure mode you correctly identified as the whole risk of a parser-based gate — it just needs to cover partial as well as total.
2. stale never fails, even under --strict
The description says the baseline "may only shrink" and that a repaired token is reported "so it gets removed rather than quietly padding the baseline." But main() returns 1 only on new. A repaired token prints an advisory line inside a green job's log and stays in KNOWN_SEED_DRIFT indefinitely — nobody reads a passing job.
Since engine-boundary.yml is the cited model and its baselines genuinely only shrink, make stale non-zero under --strict too. If you'd rather keep it advisory, drop the ratchet language from the docstring so the two agree.
3 and 4 — scope calls, fine to leave, worth saying out loud
69 JSON tokens have no seed entry at all — color.slice.dim.*, color.button.*.disabled, color.highlight.rx|tx|fg|message. That's arguably a sharper form of the failure this PR describes: the pre-v2 user's theme falls back not to a stale value but to nothing. It's printed as a bare count and never gated, so it can grow without a signal. Note in the docstring why it isn't gated. It also stops being a question once the generator emits all 147 — see the scope note on #3184.
default-light.json isn't checked. The description reasons from both bundled themes agreeing; only dark is gated.
Verdict
Fix 1 and 2 and this is good to merge. 1 is the one that matters — a coverage gap in a gate is worse than no gate, because the green check is read as an assurance the tool isn't actually making. Neither is more than a few lines.
— Claude (AI dev partner)
|
Nigel — reconciling my review with AetherClaude's, since two agent reviews landed on this within two hours and one line of mine would send you the wrong way if you applied both literally. We agree on substance. Both reviews independently found the same primary defect (the parser's partial-parse blind spot) and the same 17 uncovered seeded tokens — same breakdown, seven Where I was wrong. I suggested asserting on The two only reconcile in a specific order, which is worth being explicit about since they assert different invariants:
Doing both at once, or the second without the first, breaks. If you keep the scope hex-only, my step 2 simply doesn't apply — take AetherClaude's and relabel the counter to "colour tokens compared" per its second comment. One token stays out of reach under either: One compounding interaction neither review caught alone. AetherClaude observed that a partial parse which loses the root tokens makes all nine Net: two blocking items, unchanged in count. Use AetherClaude's fix for the first. — Claude (AI dev partner) |
Three review findings, one compounding hazard:
* Completeness guard (AetherClaude's suggestion, taken as written after the
review reconciliation): every QString("#hex") literal in the function body
must be captured -- 87 of 87 today, an equality not a floor. A partial
parse (m_tokens rename, scoped-brace reindent) previously surfaced as
"stale" baseline entries on a green run.
* stale now FAILS under --strict. Together with the above this closes the
compounding case where a parser regression turns the whole baseline stale
and the green job instructs the next contributor to empty it.
* Hex spellings are normalised before comparison (#fff == #ffffff, opaque
leading alpha stripped) so identical colours can never report as drift.
Scope is now stated honestly: colour tokens only. The 17 non-hex seeds
(font.family/size, sizing, and color.meter.bar.fillGradient -- the latter
invisible to any literal-based guard) are documented as out of scope, with
the invariant-ordering note from the reconciliation for whoever widens it.
Verified red-before/green-after: m_tokens rename -> captured 9 of 87, exit
1; scoped-brace reindent -> guard fires; injected stale entry + --strict ->
exit 1; clean tree -> 87 compared, 9 known drifts, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both blocking items addressed in 2fadb56, in the order the reconciliation comment prescribes:
Also picked up the two additive points: hex normalisation before comparison ( Green run on the branch: 87 colour tokens compared, 9 known drifts, 0 new, exit 0. |
…nciple IX. The hardening added in 2fadb56 did not survive adversarial testing. Each fix below is verified red against the unhardened parser, not merely green after. 1. The completeness guard was tautological. `expected` counted the same QString("#hex") pattern the capture regexes matched, so any other spelling dropped out of both in lockstep. Respelling one seed as QStringLiteral("#deadbe") -- the idiom already used for scopeOrCreate() in that same function -- took the token out of the comparison, left the guard silent, and reported "drifted (NEW): 0" with real drift in the binary. A colour seed is now anything whose VALUE carries a hex literal, whatever it is wrapped in (QString, QStringLiteral, QColor, or a bare string), so a respelling is CAPTURED rather than merely detected. The guard is now an accounting identity with no shared pattern to go stale: every hex literal in the body is either inside an insert we parsed or outside every insert (the meter gradient's five QColor stops), and a mismatch hard-fails. 2. The regexes matched commented-out code, in both directions. A commented `// TODO: m_tokens.insert("color.button.border.disabled", QString("#deadbe"));` produced a phantom NEW divergence and failed --strict on a PR that changed no compiled value; conversely a commented-out old line could supply the compared value while a differently-spelled live line carried the drift, giving a false clean run. Comments are now blanked (string-literal aware, offsets preserved) before any matching. 3. The guard hard-failed legitimate edits with a wrong diagnosis. Comparing dict cardinality against a raw literal count meant a local `const QString kAccent = QString("#00b4d8");` reported "captured 87 of 88 -- the parser is out of date" when nothing had mis-parsed. Duplicate seeds are now diagnosed for what they are, and unrelated literals no longer inflate the expectation. 4. Parsing no longer depends on formatting. The scoped-block terminator was the literal `\n \}`, so reindenting a block dropped its tokens; the body was found by a trailing-brace regex. Both now use paren/brace matching, and a token's scope comes from the nearest preceding scopeOrCreate() rather than from indentation. 5. Corrected two inaccurate claims. "colours present only in JSON: 69" counted every flattened JSON key, including font.size.*, sizing.*, the font.family.* sub-keys and each waterfall colormap's type/angle/stops; restricted to actual colours it is 25, which matches the ~24 unseeded tokens found in the aethersdr#3184 verification pass. The docstring said three non-colour tokens exist on both sides; it is nine (4x font.size.*, 5x sizing.*). It also implied widening the value pattern would cover font.family.* -- it would not: the JSON stores those as compound objects, so the keys never intersect and it needs a shape-aware rule. Verified: 10 adversarial mutations, each asserted in the required direction -- wrapper respelling, bare-string value, commented-out insert, comment masking a live drift, legitimate local const, duplicate seed, reindented scope block, renamed receiver, a root insert after the scoped blocks, and repairing a known drift (still fails as stale). Clean tree unchanged: 87 tokens compared, 9 known, 0 new, exit 0. Refs aethersdr#3184, aethersdr#4570.
ten9876
left a comment
There was a problem hiding this comment.
Approving, with the fixes pushed as 3dc5d0c3 rather than handed back as another round of review notes — Jeremy's call, since this had already been through three rounds and the remaining work was mechanical.
Disclosure up front: I wrote that commit, so this approval is not an independent review of it. Nigel, please read 3dc5d0c3 as a proposal on your branch and say if you disagree with any of it — you know this checker better than I do and I'd rather revert a hunk than have it land over your objection.
What the last round got wrong, including two things I told you to do
The hardening in 2fadb56d did what the previous reviews asked. The problem is that one of those reviews was mine and it was wrong.
The completeness guard I endorsed was tautological. I told you to take the expected = len(re.findall(rf'QString\("{HEX}"\)', body)) snippet "as written," and explicitly retracted my own tokens.insert(-counting version. But that guard counts the same pattern the capture regexes match, so any other spelling drops out of both in lockstep. Verified: respelling one seed as QStringLiteral("#deadbe") — the idiom already used for scopeOrCreate() a few lines down in that same function — took the token out of the comparison entirely, left the guard silent, and reported drifted (NEW): 0 with real drift compiled into the binary. That defeated the PR's whole purpose, and my endorsement is why it shipped that way.
The fix is better than the guard I originally proposed, too: a colour seed is now anything whose value carries a hex literal in any wrapper, so a respelling is captured rather than merely detected. Case 1 now reports the drift correctly instead of hard-failing on a count.
My advice to widen the parser to font.family.* was also wrong. Those can't be compared key-for-key: the JSON stores them as compound objects (font.family.ui = {family, size, color}, flattened to font.family.ui.family) while the C++ side inserts a flat string, so the keys never intersect. A wider value pattern would cover none of them — it needs a shape-aware rule. The docstring now says so, and corrects "three non-colour tokens on both sides" to nine (4× font.size.*, 5× sizing.*).
Also fixed
- Comments were matched, both directions — a commented-out insert produced a phantom NEW divergence and failed
--stricton a PR that changed nothing, and conversely a commented-out old line could supply the compared value while a differently-spelled live line carried the drift. Comments are now blanked before matching, string-literal aware, offsets preserved so line numbers don't shift. - The guard false-failed legitimate edits. A local
const QString kAccent = QString("#00b4d8");reported "captured 87 of 88 — the parser is out of date" when nothing had mis-parsed. Duplicate seeds are now diagnosed for what they are. - Parsing no longer depends on formatting. The scoped-block terminator was the literal
\n \}, so reindenting a block dropped its tokens. Body extraction and call parsing now use brace/paren matching, and a token's scope comes from the nearest precedingscopeOrCreate()rather than from indentation. - The mislabeled stat. "colours present only in JSON: 69" counted every flattened JSON key. Restricted to actual colours it's 25 — which independently matches the "~24 unseeded tokens" from the verification pass on #3184, a good sign both numbers are now right.
Verification
Ten adversarial mutations, each asserted in the required direction rather than just run:
| case | required | result |
|---|---|---|
respell QStringLiteral + real drift |
fail | ✅ caught as NEW drift |
| bare-string value + drift | fail | ✅ caught as NEW drift |
| commented-out bogus insert | clean | ✅ ignored |
| comment masking a live drift | fail | ✅ live drift caught |
legitimate local const QString hex |
clean | ✅ no false failure |
| same token seeded twice | fail | ✅ named as a duplicate |
| reindented scope block | clean | ✅ still 87 |
renamed m_tokens receiver |
clean | ✅ still 87 |
| root insert after scoped blocks | fail | ✅ no misattribution |
| repair a known drift | fail | ✅ still fails as stale |
Clean tree unchanged: 87 compared, 9 known, 0 new, exit 0. The Theme seed vs bundled JSON job is green on 3dc5d0c3.
And then stop
Worth saying plainly since this is round four: a regex parser for C++ has no fixed point. Every round has found a new way to fool it, and another round would find another. I'd merge this and put the remaining energy into #3184 item 2 — once the seed is generated, this checker's job collapses to "is the generated file fresh," and every failure mode above stops existing because there's no hand-written C++ left to parse. Your two questions are answered on #3184 (JSON wins; commit-time generation with a freshness gate), so that path is unblocked.
Thanks for the checker — the drift it found was real, and the KNOWN_SEED_DRIFT shape is what makes the generator landable against a floor.
— Claude (AI dev partner)
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. 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>
…e resolve (#4573) #3184 item 3 ("theme round-trip correctness — `cssFragment` empty-return, gradient `center`, export scope-loss"). Grouped as one PR since they share a shape: **each loses data without reporting anything.** The file still loads, the stylesheet still applies, so nothing announces the loss. Independent of #4570 (theme-seed drift gate) — different files, no conflict. ## 1. Radial gradients lost their centre on every save The writer emitted `centerX`/`centerY` as two scalars. The reader only ever looked for a `center` **array** — which is also the shape this file's own format comment documents: ``` // { "type": "radial-gradient", "center": [0.5, 0.5], "radius": 0.7, ... ``` So the centre silently reverted to the `{0.5, 0.5}` default. `radius` uses the same key on both sides and **did** survive, which made this look like a half-working feature rather than a format mismatch. **Worse than the audit reported:** it's in `scopeToJson()`, so it hit the **normal save path**, not just export. Fixed both directions: the writer emits the documented array, and the reader still accepts the old `centerX`/`centerY` shape so a theme written by a pre-fix build keeps its gradient instead of being stranded. The next save rewrites it correctly. ## 2. Exporting the active theme dropped every scoped token `exportThemeToFile()` walked `m_tokens` and wrote a flat top-level `tokens` object. But `m_tokens` is a reference into the **root scope only** — the header says so: > `m_tokens` is a reference into the root scope's token hash so every legacy `m_tokens.foo` call > site (96 of them pre-refactor) compiles unchanged Every child scope lives in the scope tree and was simply absent. On the bundled dark theme that is **9 of 12 scoped tokens** — all the per-applet slider/knob/toggle overrides for `applet/tx`, `applet/rx`, `applet/comp`. The exported file still **loaded**, because the reader has a legacy flat-`tokens` fallback. That is exactly what kept this invisible: the operator got a theme file back that opened cleanly and had quietly lost its per-applet differentiation. Export now calls `scopeToJson()` — the same serialiser `saveCurrentThemeAs()` uses — so the two cannot disagree about what a theme file contains, and ~55 lines of duplicated token-walk go away. That deduplication is also why fix (1) only needed applying once. ## 3. An unknown token in a QSS template failed silently `cssFragment()` returned an empty string for an unresolvable token. In a template that yields `color: ;` — Qt discards the malformed declaration and the widget keeps its previous appearance. No error, no log line, and a theme that looks "nearly right", which is considerably harder to diagnose than an obviously missing colour. It **still returns empty** — substituting a placeholder would be worse, since Qt would then apply a *wrong* colour rather than none — but now warns, **once per token**. Warning once matters: `resolveFor()` runs on every theme change and every tracked-stylesheet reapply, so an unguarded warning would flood the log. ## Verification Three cases in `theme_manager_test`, each verified **red against the unfixed code** rather than just green after: | revert | failures | |---|---| | gradient fixes only | exactly the **4 centre** assertions — `radius` keeps passing, as it should | | export fix only | exactly the **2 export** assertions (`scopes` key absent; scoped token missing) | | nothing (fixes in place) | **0** | `theme_manager_test` passes; `ctest -R "theme|s_meter|colour|color|style|applet"` is **5/5**. Full tree builds on Windows/MSVC. ## Note The `cssFragment` warning may surface pre-existing typos in current stylesheet templates the first time someone runs this — that is the point, but it means the log could get noisier before it gets quieter. If any turn up they are real bugs that were previously invisible; happy to fix them in a follow-up rather than let them block this. Refs #3184. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
…'t become paths (#4575) #3184 item 4 — the last of the four. Two unrelated defects in the same area. > ⚠ **Stacked on #4573** (`b16f6ba2`), which touches the same file. The diff against `main` > therefore includes that PR's commit; review `e206f0e1` alone, or merge #4573 first. ## 1. "Reset to default" restored DARK values while editing a light theme `ensureFactoryLoaded()` was hardcoded to `:/themes/default-dark.json` **and** was a one-shot latch, so the factory snapshot never reflected what the operator was actually editing. Pressing Reset on Default Light restored the dark value for **96 of the 147 root tokens the two bundled themes share**: | token | Light's real value | what Reset gave you | |---|---|---| | `color.background.0` | `#f5f5f8` | `#0f0f1a` | | `color.accent` | `#0088b0` | `#00b4d8` | | `color.accent.danger` | `#c02020` | `#ff4d4d` | | `color.text.primary` | … | … *(and 92 more)* | `color.background.0` is the vivid one — Reset turned a near-white background near-black. The baseline is now chosen from the active theme, and the snapshot **re-loads when the base changes** instead of latching for the process (clearing first, so entries from the other base can't leak through). For a user theme there is no recorded parentage, so background luminance decides — a fork of Light resets to Light's values. That is a heuristic and I've said so in the comment, but it is the closest honest answer available and it is right for the case that matters. ## 2. A theme name typed by the operator went straight into a filename `saveCurrentThemeAs()` and `renameTheme()` both build `<themes-dir>/<name>.json` with no validation. `saveCurrentThemeAs("../escape")` **succeeded** before this change — verified in a test, not theorised. `importThemeFromFile()` already guards this exact case, and says why: > `Sanitise so we can't path-traverse via a malicious name field — slashes get replaced with` > `underscores.` The rule simply was not applied at the two entry points where a human types the name. **These refuse rather than substitute** — deliberately the opposite of what import does. Import's name comes from a file's JSON with nobody watching, so substituting beats stranding a valid import. Here the operator typed it, and silently saving their theme under a name they did not choose is worse than telling them no. Both separators are rejected on every platform: `\` is not a separator on Unix, but a theme file is portable and a name containing one becomes a traversal the moment it is opened on Windows. ## Verification Five cases in `theme_manager_test`, each verified **red against the unfixed code**, independently: | revert | failures | |---|---| | baseline selection only | exactly the **2 light-theme** assertions — the dark ones keep passing | | separator guards only | **5**, including `../escape` being accepted *and appearing in `availableThemes()`* | | nothing | **0** | A legitimate name with punctuation (`Nigel's Theme (v2)`) still saves, so the guard rejects separators and not ordinary characters. `ctest -R "theme|s_meter|colour|color|style|applet"` **5/5**; full tree builds on Windows/MSVC. ## That completes the four 1. ✅ `audit_colours.py` Windows fix — merged as #4475 2. 🔄 seed table generation — groundwork in #4570; generator blocked on two design questions 3. ✅ round-trip correctness — #4573 4. ✅ this Refs #3184. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
#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 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 at all** — `slice.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. --- ## Review round (maintainer, 2026-07-31 — commit `eb4df8f5`) Reviewed, and the follow-ups were pushed to this branch rather than handed back. The generator design was right; what was missing was the scaffolding around it — four of the five blockers were in files this PR didn't touch. **Corrections to the description above:** the count of never-seeded tokens is **24**, not 25 (104 → 128); it was wrong in three source comments too, and is now fixed everywhere. The "stacked on #4570" note is obsolete — #4570 landed, and this branch has since been merged with current `main`. The CHANGELOG offer is taken up: the entry is in this PR. The injected-fault table's "emitted 120 tokens" is actually 121 (there are seven gradient tokens, not six — `color.meter.bar.fillGradient` is one), and that assertion has been replaced outright, see below. **Fixed in `eb4df8f5`:** - **`tests/theme_seed_test.cpp` (new).** The premise that "the seed is not observable through the public API" holds only for `theme_manager_test`, which links `resources.qrc`. `container_widget_test` already links ThemeManager *without* the theme resource — there the seed **is** the rendered palette, which is the resource-missing fallback path #3184 named. The new target pins the nine formerly-drifted values, alias resolution, one token from each previously unseeded family, all seven gradients, the `seedScopedToken()` scope tree, and the non-colour token types. Mutation-tested: reverting only the nine values → 9 failures; deleting only the six colormap gradients → 6 failures; unmodified → PASS. The CI gate proves the *generator* agrees with the JSON; it cannot prove the emitted C++ reaches `m_tokens` at runtime — a target that omits `ThemeSeedGenerated.cpp` from its source list passes the gate and ships a transparent UI. - **`theme-seed-check.yml` `paths:`** did not include `ThemeSeedGenerated.cpp` or `gen_theme_seed.py` — the two files the gate exists to protect. A hand-edit of the "DO NOT EDIT" file never ran the check. Both added; the header comment (still describing the hand-maintained seed and `KNOWN_SEED_DRIFT`) rewritten. - **`docs/theming/slider-knob-tokens.md` and `toggle-button-tokens.md`** are the "adding the next control namespace" playbooks, and both still instructed contributors to hand-add entries to `seedBuiltinDefaults()` — re-creating the dual source of truth this PR removes. Both now say: edit the JSON, regenerate. - **`CHANGELOG.md`** under `[Unreleased]`, per the ruling on #3184 — covering both the corrected slice colours and the 24 tokens that previously resolved transparent. - **Generator hardening.** An alias naming a missing primitive was passed through and emitted as `QString("{color.teal.500}")` — an invalid QColor, i.e. transparent — with the gate exiting 0; now a hard error. `resolve()` didn't descend into lists, so an alias in a gradient stop survived to `QColor("{...}")`; lists are now walked. `--check` compared token *counts* with `<`, so a fault duplicating one token and dropping another passed; it now compares the `(scope, token)` sets and names what's missing. Output is byte-identical on the current tree. - **Merged current `main`** (settings sources moved to `${AETHER_SETTINGS_SOURCES}` under RFC #4603) — resolved across all nine targets, which also removed the duplicated `ThemeSeedGenerated.cpp` line in the `ENABLE_ASR` block. - **Stale ctor comment** claiming the seed "lacks the `waterfall.colormap` gradients" — it no longer does; rewritten to the reason that still holds. **Verified:** full tree builds (2563/2563, zero errors, Linux/GCC/Qt6); `ctest -R "theme|s_meter|colour|color|style|applet|container"` 7/7; `gen_theme_seed.py --check` and `check_theme_seed.py --strict` pass, and fail on each injected fault above. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
…4583) Fixes #4520 — the frequency-display font reverts to the default after a band change. Independent of the theme-seed stack (#4570/#4582) and of #4573/#4575 — different mechanism, no overlap beyond the file. ## The theme is saved. Scope resolution isn't. `applyStyleSheet()` installs an event filter so a later `QEvent::ParentChange` re-resolves the template against the widget's real scope chain. That covers the **direct** case — the widget itself gets reparented — but **the filter is only installed on tracked widgets**, so when an *untracked ancestor* is reparented, the tracked child inside it hears nothing. Real construction code produces exactly that shape (`VfoWidget.cpp:982-998`): ```cpp m_freqStack = new QStackedWidget; // no parent m_freqLabel = new QLabel; // no parent applyStyleSheet(m_freqLabel, ...); // resolves at ROOT — wrong scope m_freqStack->addWidget(m_freqLabel); // ParentChange on the LABEL, but the stack // is still an orphan // ...later, the stack joins VfoWidget's layout // ParentChange on the STACK — untracked, // so the label never re-resolves ``` The label keeps root-scope values until the next `themeChanged()`. A band change is radio-authoritative — the radio drops and re-creates the pan's slice, destroying and rebuilding the `VfoWidget` — so the fresh label comes up root-resolved again. **That is why the font vanishes on band change, and why touching anything in the Theme Editor appears to fix it.** ## The fix `eventFilter` also re-resolves on `Show` / `ShowToParent` / `Polish`. `Show` is the one that catches this: it arrives when the widget first becomes visible, by which point it is unavoidably in its final chain however it got there. `Polish` covers widgets styled but never shown; `ParentChange` stays as the earliest signal for the direct case. All four fire a handful of times per widget per session — never during paint or drag. **Fixed here rather than in `VfoWidget` deliberately.** This exact bug was already fixed once for `RxApplet`, by parenting the stack up front: > `RxApplet.cpp:572-576` — *"an unparented stack makes its children resolve root theme tokens at* > *startup and miss the applet/rx scope (#4159)"* Patching each construction site as it gets reported means fixing it a third time; several other widgets are styled before being parented. ## Verification | | | |---|---| | red-before (ParentChange only) | **1 failure** — exactly the ancestor assertion. The existing direct-reparent case keeps passing, as it should. | | green-after | pass | | `ctest -R "theme\|s_meter\|colour\|color\|style\|applet\|vfo\|spectrum"` | **11/11** | Full tree builds on Windows/MSVC. ## Two caveats **Scope:** this only bites when the override lives at a **non-root** scope (`spectrum/vfo` or `spectrum`) — which is what the Theme Inspector's click-to-edit workflow produces. An edit made with the container combo on root resolves everywhere and never showed the symptom. The reported behaviour matches the scoped-edit case. **Not verified on a real Flex 8400 doing an actual band change** — I have a 6700/6300 and an HL2. The mechanism is reproduced in test; the on-air repro is @HRui-Z's. Worth a confirmation from them before closing the issue. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Groundwork for #3184 item 2 ("generate the seed table from the resource at build time — the
high-leverage one; kills the drift class permanently").
This PR changes no colours. It proves the drift class is real, pins it, and stops it widening,
so the generator lands against a floor and gets a regression test for free.
The drift already happened
ThemeManager::seedBuiltinDefaults()compiles a copy of the default theme into the binary so theUI works with zero theme files on disk. It is kept in sync with
resources/themes/default-dark.jsonby hand, and the source says so twice — including this warning:
Nine tokens on
main@d50377f6disagree today:default-dark.jsoncolor.accent.dim#0090e0#0070c0color.slice.a#ff4040#00d4ffcolor.slice.b#ff8c00#ff40ffcolor.slice.c#ffd040#40ff40color.slice.d#40c060#ffff00color.slice.e#00b4d8#ffa000color.slice.f#4080ff#00e0c0color.slice.g#c060ff#ff6080color.slice.h#ff60a0#b080ffThe slice colours explain themselves — the seed's own 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 situations nobody is
watching, which is what makes this worth a gate rather than a one-off correction.
What this adds
tools/check_theme_seed.py— parses both sides (resolving{color.red.500}aliases againstthe primitives palette) and diffs them. The nine existing divergences are frozen in
KNOWN_SEED_DRIFT;--strictfails on anything else. The list may only shrink: a token thatstops drifting is reported as stale so it gets removed rather than quietly padding the
baseline.
.github/workflows/theme-seed-check.yml— runs it on PRs touchingThemeManager.cpp,resources/themes/**, or the checker. Modelled directly onengine-boundary.yml: sameconcurrency block, same pinned action SHAs, same known-warn / new-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 regex fails loudly instead of silently passing. Thatfailure mode is the whole risk of a parser-based gate, so it is guarded explicitly.
Verification
All three paths exercised, not just the happy one:
maincolor.spectrum.grid→#deadbe)slice.a→ JSON value)Same results on Windows/Python 3.14 and on Linux/Python 3.12 — the version CI pins.
Deliberately not in this PR
Fixing the nine. Which side wins is a design call, not a mechanical one: the JSON is almost
certainly right (it is what every user actually sees, and both bundled themes agree), but the seed
is what pre-v2 user themes fall back to, so changing it is a visible change for those users. That
belongs with whoever owns the slice-colour audit the comment refers to — happy to do it in a
follow-up once someone says which way.
The generator itself. Once the seed is generated from the resource,
KNOWN_SEED_DRIFTemptiesand this checker becomes the test that the generator stays correct.
Refs #3184.