Skip to content

ci(theme): pin the compiled theme seed against the bundled JSON - #4570

Merged
ten9876 merged 3 commits into
aethersdr:mainfrom
nigelfenton:fix/theme-seed-drift-check
Jul 30, 2026
Merged

ci(theme): pin the compiled theme seed against the bundled JSON#4570
ten9876 merged 3 commits into
aethersdr:mainfrom
nigelfenton:fix/theme-seed-drift-check

Conversation

@nigelfenton

Copy link
Copy Markdown
Contributor

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 the
UI works with zero theme files on disk. It is kept in sync with resources/themes/default-dark.json
by hand, and the source says so twice — including this warning:

KEEP IN SYNC: ... If those primitives shift, update both sites or the seeded look will drift
from the JSON-defined look on bundled themes (silently — both layers resolve, the JSON wins,
but the visible vs. seeded values diverge for pre-PR user themes).

Nine tokens on main @ d50377f6 disagree today:

token compiled seed default-dark.json
color.accent.dim #0090e0 #0070c0
color.slice.a #ff4040 #00d4ff
color.slice.b #ff8c00 #ff40ff
color.slice.c #ffd040 #40ff40
color.slice.d #40c060 #ffff00
color.slice.e #00b4d8 #ffa000
color.slice.f #4080ff #00e0c0
color.slice.g #c060ff #ff6080
color.slice.h #ff60a0 #b080ff

The 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 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 rather than quietly padding the
    baseline.
  • .github/workflows/theme-seed-check.yml — runs it on PRs touching ThemeManager.cpp,
    resources/themes/**, or the checker. Modelled directly on engine-boundary.yml: same
    concurrency 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. That
failure 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:

case result
current main 9 known, 0 new → exit 0
inject a new divergence (color.spectrum.grid#deadbe) reported as NEW, separate from the nine → exit 1
repair a known one (slice.a → JSON value) reported as stale, prompting its removal

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_DRIFT empties
and this checker becomes the test that the generator stays correct.

Refs #3184.

`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-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.

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_tokensrootTokens() 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 --strict silently; 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 the seeded only in C++ : 0 line 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

Comment thread tools/check_theme_seed.py Outdated
Comment thread tools/check_theme_seed.py Outdated
Comment thread tools/check_theme_seed.py
@ten9876 ten9876 self-assigned this Jul 29, 2026

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 allcolor.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)

@ten9876

ten9876 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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 font.family.*, nine numeric across font.size.* / sizing.*, and the color.meter.bar.fillGradient insert. No contradiction there. AetherClaude's #fff vs #ffffff normalisation point is additive and I'd take it.

Where I was wrong. I suggested asserting on tokens.insert( occurrences in the function body and failing when fewer were extracted. I checked that against origin/main after the fact and it does not hold: the body contains 104 tokens.insert( calls but only 87 hex literals. That assertion fails CI immediately on a correct tree. AetherClaude's version is the right one and I've verified its equality claim exactly — QString("#hex") occurrences in the body: 87; parser-captured: 87. Take its inline suggestion as written.

The two only reconcile in a specific order, which is worth being explicit about since they assert different invariants:

  1. Now: adopt AetherClaude's hex-literal equality guard. Correct today, and correct as long as the checker's scope stays hex-only.
  2. Only if you widen the parser to cover the non-hex seeds (the bare integers, and plain QString("…") values): then upgrade to the insert-count invariant, because at that point 104 becomes the right expectation and the hex equality becomes too loose.

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: color.meter.bar.fillGradient inserts QVariant::fromValue(g) with no hex literal in the call at all, so it is invisible to both assertions. It needs explicit handling or an explicit note saying gradients are out of scope — silence is the thing worth avoiding.

One compounding interaction neither review caught alone. AetherClaude observed that a partial parse which loses the root tokens makes all nine KNOWN_SEED_DRIFT entries report as stale. I separately flagged that stale never fails under --strict. Together those are worse than either: the job goes green and prints an instruction to empty the baseline. That turns my finding 2 from tidiness into a real safety property — I'd now treat making stale fatal under --strict as blocking rather than optional.

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>
@nigelfenton

Copy link
Copy Markdown
Contributor Author

Both blocking items addressed in 2fadb56, in the order the reconciliation comment prescribes:

  1. Completeness guard — took AetherClaude's inline suggestion as written: equality against the QString("#hex") literal count (87 == 87 today), not the insert count. Red-tested both failure modes from the review: an m_tokens rename dies with captured 9 of 87, and a scoped-brace reindent fires the same guard instead of passing 81/87 silently.

  2. stale is now fatal under --strict — upgraded to blocking per the reconciliation's compounding case (partial parse ⇒ all-stale ⇒ green run that says "empty the baseline"). Verified: an injected never-drifts entry exits 1 with an explicit message telling you to remove the entry or fix the parser if tokens went missing rather than converged.

Also picked up the two additive points: hex normalisation before comparison (#fff == #ffffff, opaque leading alpha stripped — Qt's alpha is leading), and the counters/docstring now say colour tokens only, listing the 17 out-of-scope seeds explicitly. color.meter.bar.fillGradient gets its own callout as invisible to any literal-based guard, and the docstring carries the invariant-ordering note (hex-equality now; switch to insert-count only if the parser is ever widened) so the two guards don't get combined by a future editor.

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 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 --strict on 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 preceding scopeOrCreate() 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)

@ten9876
ten9876 enabled auto-merge (squash) July 30, 2026 10:12
@ten9876
ten9876 merged commit 5f7b706 into aethersdr:main Jul 30, 2026
8 checks passed
nigelfenton added a commit to nigelfenton/AetherSDR that referenced this pull request Jul 30, 2026
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>
@nigelfenton
nigelfenton deleted the fix/theme-seed-drift-check branch July 31, 2026 13:00
ten9876 added a commit that referenced this pull request Jul 31, 2026
…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>
ten9876 added a commit that referenced this pull request Jul 31, 2026
…'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>
ten9876 added a commit that referenced this pull request Aug 1, 2026
#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>
ten9876 added a commit that referenced this pull request Aug 1, 2026
…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>
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.

2 participants