fix(theme): reset-to-factory follows the active base; theme names can't become paths - #4575
fix(theme): reset-to-factory follows the active base; theme names can't become paths#4575nigelfenton wants to merge 2 commits into
Conversation
…e resolve aethersdr#3184 item 3. Each of these loses data without reporting anything — the file still loads, the stylesheet still applies, so nothing announces the loss. **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 (and this file's own format comment documents the array). So a radial gradient came back at the {0.5, 0.5} default. `radius` uses the same key on both sides and DID survive, which made the loss look like a half-working feature rather than a format mismatch. Worse than reported in the audit: this was in `scopeToJson()`, so it affected the NORMAL SAVE path, not just export. The writer now emits the documented array. The reader also 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 in the correct form. **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 — 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 and applet/comp. The exported file still LOADED, because the reader has a legacy flat-`tokens` fallback. That is what kept this invisible: the operator got a theme back that opened cleanly and had quietly lost its per-applet differentiation. Export now calls `scopeToJson()` — the same serialiser `saveCurrentThemeAs()` uses — so the two can no longer disagree about what a theme file contains, and the ~55-line duplicated token-walk is gone. 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 far harder to diagnose than an obviously missing colour. It still returns empty (substituting a placeholder would be worse — Qt would 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. Tests: three cases in theme_manager_test, each verified RED against the unfixed code — reverting the gradient fixes alone fails exactly the 4 centre assertions (radius keeps passing, as it should); reverting only the export fix fails exactly the 2 export assertions. Green with the fixes: theme_manager_test passes, and ctest -R "theme|s_meter|colour|color|style|applet" is 5/5. Full tree builds on Windows/MSVC. Refs aethersdr#3184. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ome paths aethersdr#3184 item 4. Two unrelated defects in the same area. **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** — including color.background.0, which turned a near-white background near-black. The baseline is now chosen from the active theme: the bundled light JSON for Default Light, the dark one for Default Dark, and for a user theme the background luminance decides (a fork of Light resets to Light's values). The snapshot re-loads when the base changes rather than latching for the process, and clears first so entries from the other base can't leak through. Luminance is a heuristic — a user theme records no parentage — but it is the closest honest answer available, and it is right for the case that matters: a theme forked from Light. **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, so a name containing a path separator wrote outside the themes directory. `saveCurrentThemeAs("../escape")` succeeded before this change — verified, not theorised. importThemeFromFile() already guards this exact case and says why ("sanitise so we can't path-traverse via a malicious name field"); the rule simply was not applied at the two entry points where a human types the name. These REFUSE rather than substitute, which is the opposite of what import does, deliberately: import's name comes from a file's JSON with nobody watching, so silently substituting is better than stranding a valid import. Here the operator typed it, and saving their theme under a name they did not choose is worse than telling them no. Both separators are rejected on every platform — a theme file is portable, and '\' becomes a traversal the moment it is opened on Windows. Tests: five cases in theme_manager_test, verified RED against the unfixed code independently — reverting only the baseline selection fails exactly the two light-theme assertions (the dark ones keep passing); reverting only the separator guards fails five, including `../escape` being accepted and appearing in availableThemes(). A legitimate name with punctuation ("Nigel's Theme (v2)") still saves, so the guard rejects separators and not ordinary characters. Green after: theme_manager_test passes, ctest -R "theme|s_meter|colour|color|style|applet" 5/5, full tree builds on Windows/MSVC. Stacked on aethersdr#4573 — same file, and that PR's export dedup is what this builds on. Refs aethersdr#3184. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed e206f0e1 alone (the #4573 commit is excluded from the notes below). Both defects are real and the framing is right: I confirmed :/themes/default-light.json is in resources/resources.qrc, that its primitives resolve to exactly the values the new assertions pin (color.background.0 → #f5f5f8, color.accent → #0088b0, vs #0f0f1a/#00b4d8 for dark), and that importThemeFromFile() already replaces both separators — so the "same rule, two missing entry points" story checks out, as does refusing rather than substituting where the operator typed the name. The re-snapshot-on-base-change keyed on m_factoryBaselinePath with a clear() first is the right shape.
Nothing here blocks on design. Three small things and a couple of polish notes.
Would like fixed before merge
- Missing space in the adjacent-string-literal concat in both new warnings — the log reads "refusing name witha path separator" (two inline comments, both with suggestions).
const_cast<ThemeManager*>(this)->color(...)is unnecessary;color(const QString&)is alreadyconst.
Polish
- Header comment on
m_factoryTokensstill says "loaded once from:/themes/default-dark.jsonat construction" — the thing this PR just stopped being true. saveCurrentThemeAs()trims only for the emptiness check, so"My Theme "still becomesMy Theme .json, unlikerenameTheme()which trims throughout.
Non-blocking notes
- The luminance discriminator reads the live, edited
color.background.0, so it can flip mid-session while the operator is editing — see inline; it's a heuristic either way, worth one line of comment rather than a code change. - CodeGuard's three CG-PATH-001 hits on
tests/theme_manager_test.cpp:960/966/975are false positives: those are the"../escape"/"sub/dir"/"back\\slash"literals in the negative tests asserting the guard refuses them. No action needed.
Nice work on the red-against-unfixed-code verification table — the "revert baseline selection only → exactly the 2 light assertions fail, dark keeps passing" split is exactly the evidence that makes this easy to trust.
🤖 aethersdr-agent · cost: $5.3224 · model: claude-opus-5
| const QColor bg = const_cast<ThemeManager*>(this) | ||
| ->color(QStringLiteral("color.background.0")); |
There was a problem hiding this comment.
ThemeManager::color(const QString&) const is already const (ThemeManager.h:112), so this const_cast isn't buying anything — and it makes the line read as if the lookup mutates state, which it doesn't.
| const QColor bg = const_cast<ThemeManager*>(this) | |
| ->color(QStringLiteral("color.background.0")); | |
| const QColor bg = color(QStringLiteral("color.background.0")); |
|
|
||
| const QColor bg = const_cast<ThemeManager*>(this) | ||
| ->color(QStringLiteral("color.background.0")); | ||
| if (bg.isValid() && bg.lightness() > 127) |
There was a problem hiding this comment.
Worth one line in the comment above: the discriminator reads the live, edited color.background.0, not the value the theme was loaded with. So an operator forking Default Dark and dragging the background past L=127 flips the baseline for all ~147 tokens mid-session — every subsequent Reset silently starts answering with Light's values, and m_factoryTokens gets cleared and re-snapshotted underneath them.
Not asking you to change the heuristic (there's no recorded parentage to consult, as you say). But reading the persisted file's background rather than the live token would make the baseline stable for the duration of an editing session, which is the property that actually matters here. If that's more than you want in this PR, just say so in the comment so the next reader knows it's deliberate.
| qCWarning(lcGui) << "ThemeManager::renameTheme: refusing name with" | ||
| "a path separator:" << trimmed; |
There was a problem hiding this comment.
Adjacent string literals concatenate with no separator, so this logs refusing name witha path separator:.
| qCWarning(lcGui) << "ThemeManager::renameTheme: refusing name with" | |
| "a path separator:" << trimmed; | |
| qCWarning(lcGui) << "ThemeManager::renameTheme: refusing name with " | |
| "a path separator:" << trimmed; |
| qCWarning(lcGui) << "ThemeManager::saveCurrentThemeAs: refusing name with" | ||
| "a path separator:" << newThemeName; |
There was a problem hiding this comment.
Same missing space as in renameTheme() — witha path separator.
| qCWarning(lcGui) << "ThemeManager::saveCurrentThemeAs: refusing name with" | |
| "a path separator:" << newThemeName; | |
| qCWarning(lcGui) << "ThemeManager::saveCurrentThemeAs: refusing name with " | |
| "a path separator:" << newThemeName; |
| // editor's Save As, where the operator types the name — did not, so a name | ||
| // containing a separator wrote outside the themes directory. Same rule, | ||
| // applied consistently. | ||
| if (containsPathSeparator(newThemeName)) { |
There was a problem hiding this comment.
Polish, pre-existing but adjacent to what you're touching: the emptiness check on the line above trims, and everything after this uses newThemeName raw — so "My Theme " writes My Theme .json and registers a trailing-space key in m_themePaths, while renameTheme() trims throughout. A const QString name = newThemeName.trimmed(); at the top of the function, used for the guard and the path, would make the two entry points agree. Fine to leave for a follow-up.
| // Factory-default snapshot, loaded once from `:/themes/default-dark.json` | ||
| // at construction. Drives the gradient editor's "Reset to default" | ||
| // button. Lazy-initialised so a totally missing resource bundle | ||
| // doesn't take the whole singleton down. |
There was a problem hiding this comment.
This comment is now the thing the PR fixed — it's no longer loaded once, and no longer from default-dark.json specifically.
| // Factory-default snapshot, loaded once from `:/themes/default-dark.json` | |
| // at construction. Drives the gradient editor's "Reset to default" | |
| // button. Lazy-initialised so a totally missing resource bundle | |
| // doesn't take the whole singleton down. | |
| // Factory-default snapshot of the bundled theme the ACTIVE theme | |
| // descends from — see factoryBaselinePath(). Drives the gradient | |
| // editor's "Reset to default" button. Lazily (re)loaded so a totally | |
| // missing resource bundle doesn't take the whole singleton down. |
|
Cisco CodeGuard — static analysis of this PR (3 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $5.4439 · model: claude-opus-5 |
#3184 item 4 — the last of the four. Two unrelated defects in the same area.
1. "Reset to default" restored DARK values while editing a light theme
ensureFactoryLoaded()was hardcoded to:/themes/default-dark.jsonand 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:
color.background.0#f5f5f8#0f0f1acolor.accent#0088b0#00b4d8color.accent.danger#c02020#ff4d4dcolor.text.primarycolor.background.0is 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()andrenameTheme()both build<themes-dir>/<name>.jsonwith novalidation.
saveCurrentThemeAs("../escape")succeeded before this change — verified in a test,not theorised.
importThemeFromFile()already guards this exact case, and says why: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 isportable 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:../escapebeing accepted and appearing inavailableThemes()A legitimate name with punctuation (
Nigel's Theme (v2)) still saves, so the guard rejectsseparators 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
audit_colours.pyWindows fix — merged as fix(tools): make audit_colours.py run to completion on a legacy-code-page console #4475Refs #3184.