Skip to content

feat(theme): inline Theme Editor + auto-persist + built-in protection#3176

Merged
ten9876 merged 24 commits into
mainfrom
auto/theme-enhancements
May 26, 2026
Merged

feat(theme): inline Theme Editor + auto-persist + built-in protection#3176
ten9876 merged 24 commits into
mainfrom
auto/theme-enhancements

Conversation

@ten9876

@ten9876 ten9876 commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Folds every editing interaction into the Theme Editor dialog (no spawned sub-dialogs), tightens the layout, and closes a long-standing persistence hole. Built-in themes (Default Dark / Default Light) are now read-only — the first edit forks them through Save As so the bundled defaults can't be silently shadowed.

Draft — more work is coming on top of this; opening early for visibility.

What's in this PR

Editor surface

  • New TokenEditorWidget — single vertical stack of always-visible groups (color picker, gradient stops, font row). Disabled groups dim instead of disappearing.
  • New CompactColorPicker — 200×200 SV square + 12×200 hue strip + 200×12 alpha slider with checkerboard. Replaces QColorDialog spawn. Square strip borders + chip-style thumbs (dark outer ring, white inner fill, 1-px tick) consistent across hue / alpha / gradient stop markers.
  • Inspect row carries [Inspect] [Profile: name] [status] [Reset to default]. Editing label is single-line rich-text Editing: token (kind).
  • Font row carries Cancel / OK at the right end; the dedicated bottom button row is gone.
  • Recent colors — 2×10 grid of 28×24 rounded swatches matching the hex swatch. Left-click applies; right-click removes the slot. Persists per-theme under ThemeEditor.RecentColors/<theme> and reloads on theme switch.
  • Font size combo non-editable, capped to 7–20 pt (larger sizes break the freq glyph cell + status-row widgets). QFontComboBox also non-editable; dropdown font reduced 4 px.
  • Gradient angle moved up into the stops header row alongside +/−.
  • Gradient strip markers overlay the bar (chip-thumb style) instead of a triangle band below — survives the 35 px fixed-height embed.
  • RGB row shrunk to fit inside the SV+hue block width via tighter spinbox padding + width 64→58.

Behaviour

  • OK/Cancel commit model — edits stay buffer-local until confirmed. Cancel reloads from ThemeManager.
  • Built-in theme guardonOkClicked snapshots the buffer into a DeferredEdit, emits requestSaveAsBeforeCommit; the dialog runs Save As and calls completeDeferredCommit() which restores the snapshot and writes to the new user copy. Buffer survives the refreshTokenList() that fires from themeChanged.
  • Auto-persist — added ThemeManager::saveActiveTheme() called from setColor / setSizing / setGradient / setString. Wraps a shared writeThemeFile() helper extracted from saveCurrentThemeAs. No-op on built-ins. Closes the silent "edits gone after restart" bug.

Naming

  • Editing: <theme>Profile: <theme>.
  • ColourColor throughout the editor + gradient-edit prompts.

Test plan

  • Edit a token on a user theme — confirm with OK, restart, change persists
  • Edit a token on Default Dark — Save As prompt appears with My Default Dark pre-filled; confirm, edit lands in new theme; cancel leaves Default Dark untouched
  • Verify recent-colors persist across restart and stay separate per theme
  • Right-click a recent swatch removes that slot
  • Gradient editing: drag stops, add/remove, change angle, change stop colors
  • Font row: pick family, pick size 7–20 pt, A↑/A↓ bumps clamp at 7 and 20
  • Confirm no Theme Editor regressions on light + dark + custom themes

🤖 Generated with Claude Code

ten9876 and others added 23 commits May 25, 2026 21:13
…itor, per-theme recents, auto-persist

Folds every editor interaction into the dialog itself (no more spawned
sub-dialogs), tightens the layout, and closes a long-standing
persistence hole.  Built-in themes ("Default Dark" / "Default Light")
are now read-only — the first edit forks them through Save As, so the
bundled defaults can't be silently shadowed.

UI structure
  * New TokenEditorWidget — single vertical stack of always-visible
    groups (color picker, gradient stops, font row).  Disabled groups
    dim instead of disappearing so the editing surface stays stable
    across token kinds.
  * New CompactColorPicker — 200×200 SV square + 12×200 hue strip +
    200×12 alpha slider with checkerboard.  Replaces QColorDialog
    spawn.  Square strip borders + square chip thumbs (dark outer ring,
    white inner fill, 1-px tick) consistent across hue / alpha and the
    gradient stop markers.
  * Inspect row carries: [Inspect] [Profile: name, vcentered] [status]
    [Reset to default].  Editing label is single-line (rich-text
    "Editing: token (kind)").
  * Font row carries Cancel / OK at the right end; the dedicated
    bottom button row is gone.

Editor surface
  * 2×10 Recent colors grid below the gradient stop list — 28×24
    rounded swatches matching the hex swatch (rounded clip, 6-px
    checkerboard, dark border).  Left-click applies to scalar
    buffer or selected gradient stop; right-click removes the slot.
    Persists per-theme under "ThemeEditor.RecentColors/<theme>" and
    reloads on theme switch.
  * Font size combo is non-editable and capped to 7–20 pt
    (larger sizes break the freq glyph cell + status row widgets).
    QFontComboBox is non-editable too; dropdown font reduced 4 px.
  * Gradient angle moved up into the header row alongside +/−.
  * Gradient strip markers overlay the bar (chip-thumb style)
    instead of pinning a triangle band below it — survives the
    35-px fixed-height embed inside TokenEditorWidget.
  * RGB row shrunk to fit inside the SV+hue block width via
    tighter spinbox padding (QSS) + width 64→58.

Behaviour
  * OK/Cancel commit model — all edits stay buffer-local until the
    operator confirms.  Cancel reloads from ThemeManager.
  * Built-in theme guard — onOkClicked snapshots the buffer into a
    DeferredEdit, emits requestSaveAsBeforeCommit; the dialog runs
    a Save As prompt and calls completeDeferredCommit() which
    restores the snapshot and writes to the new user copy.  Buffer
    survives the refreshTokenList() that fires from themeChanged.
  * Persistence — added ThemeManager::saveActiveTheme() called from
    setColor / setSizing / setGradient / setString.  Wraps a shared
    writeThemeFile() helper extracted from saveCurrentThemeAs.  No-op
    on built-ins so the read-only :/themes/ resource bundle is
    never written to.  Closes the silent "edits gone after restart"
    bug — every confirmed edit now lands on disk in
    ~/.config/AetherSDR/themes/<theme>.json.

Naming
  * "Editing:" theme label → "Profile:".
  * "Colour" → "Color" throughout the editor + gradient-edit prompts.

CMake
  * GUI_SOURCES adds TokenEditorWidget + CompactColorPicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First slice of the container-scoped tokenization refactor.  Replaces
the flat token namespace with a tree of named scopes that nest
arbitrarily and resolve via cascading lookup — child scopes inherit
every token from their parents until a level overrides it.  Adds a
primitives tier alongside the scopes so semantic tokens can resolve
through {alias} references to a shared palette in later steps.

No behaviour change at the UI level.  Every existing call site reads
the root scope by default, every existing setter writes to root, every
existing theme on disk auto-migrates from v1 → v2 on the next save.

Data layer
  * New ThemeScope struct (defined in .cpp; forward-declared in the
    header so the public API references scopes only by string path).
    Fields: name, full path, parent pointer, local-overrides QHash,
    std::map of named children.
  * std::map for children (not QHash) — QHash doesn't accept
    std::unique_ptr values; std::map's ordered iteration also lets
    the editor render the container tree in a stable order.
  * m_tokens is a C++ reference into m_rootScope->tokens, so every
    pre-existing m_tokens.foo call site (96 of them) compiles
    unchanged — the root scope IS the flat namespace as it existed
    before this refactor.
  * m_primitives flat map for the future palette tier.  Aliases
    resolve via the {primitive.key} string syntax inside scope
    tokens; populated by PR step 4.
  * scopeForPath() / scopeOrCreate() / lookupRaw() / resolveAlias()
    helpers cover the scope-walk + alias dereference paths used by
    the new public API.

JSON schema
  * v2 shape: { schemaVersion: 2, primitives: {...},
    scopes: { root: { tokens: {...}, scopes: {...} } } }.
  * v1 themes (current bundled :/themes/default-dark.json and
    default-light.json) auto-migrate on load — the flat `tokens` block
    flows into root scope.  Writer always emits v2 going forward.
  * Bundled themes stay v1 in the resource bundle (read-only) and
    migrate in-memory every startup.

Public API additions (all additive)
  * Widget-aware getters: color(widget, token), sizing(widget, token),
    font(widget, token), value(widget, token), brush(widget, token),
    cssFragment(widget, token), gradient(widget, token).
  * Scope-path setters: setColor(path, token, c), setSizing,
    setGradient, setString — bare-token overloads delegate here with
    path="" (root).
  * containerPaths() — every scope in the active theme, root → leaves.
  * containerPathFor(widget) — walks widget's Qt parent chain looking
    for the nearest "themeContainer" property; returns the declared
    path or "" for root.
  * theme::setContainer(widget, path) / containerOf(widget) helpers.

Destructor moved out-of-line so std::unique_ptr<ThemeScope> member
doesn't require the full type in ThemeManager.h.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tep 2/4)

Annotates the major shell widgets with their canonical container path
via `theme::setContainer()`.  Pure metadata for now — no tokens have
migrated into these scopes yet, so widget-aware lookups still resolve
to root scope and the UI renders identically.  Step 3 (the v2 editor)
uses these declarations to populate its container tree picker; step 4
will move tokens into the matching scopes.

Declarations:
  * SpectrumWidget       → "spectrum"           (panadapter + waterfall + slice triangles)
  * AppletPanel          → "applet"             (parent for every applet)
  * TxApplet             → "applet.tx"
  * RxApplet             → "applet.rx"
  * PhoneCwApplet        → "applet.digi"        (DIGI applet, file name predates rename)
  * HealthApplet         → "applet.hlth"
  * ThemeEditorDialog    → "dialog.themeEditor"

Hierarchy lookups walk `applet.tx` → `applet` → root, etc.  Widgets
without a declaration inherit their container from the nearest declared
ancestor via Qt's parent chain — so a QLabel inside TxApplet picks up
"applet.tx" automatically without per-control plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ry (refactor PR step 3a/4)

Plumbing for the v2 editor.  Two infrastructure pieces:

1. Declared-containers registry — theme::setContainer() now feeds
   `ThemeManager::registerDeclaredContainer(path)` which both adds the
   path to m_declaredContainers and pre-creates the scope in the tree.
   On every theme load, the registry is replayed (loadThemeFromPath()
   wipes the tree, then re-creates each declared path as an empty
   scope) so the editor's container tree picker can list a container
   even before any token in it has been overridden.

2. Widget-aware QSS resolution — applyStyleSheet() and
   reapplyAllTrackedStyleSheets() now route through a new
   `resolveFor(widget, template)` that calls the widget-aware
   `cssFragment(widget, token)` overload.  Each {{token}} placeholder
   resolves through the widget's container chain (containerPathFor →
   scope walk) instead of root-only.  Widgets with no declared ancestor
   still hit root, so behaviour is unchanged for everything that
   hasn't started using the new container declarations.

cssFragment(widget, token) walks the scope chain via lookupRaw() and
falls through to the bare-token overload when nothing in the chain
overrides — preserves the gradient + alpha + plain-hex formatting
paths exactly.

resolve(template) now delegates to resolveFor(nullptr, template) for
external callers that don't have a widget context (audit tooling,
factory swatch generation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First visible piece of the container-scoped refactor.  Adds a "Scope:"
combo to the Theme Editor, right between the Inspect row and the
filter input.  The combo lists "(root)" plus every container declared
via theme::setContainer() in step 2 (currently: spectrum, applet,
applet.tx, applet.rx, applet.digi, applet.hlth, dialog.themeEditor).

Picking a non-root scope:
  * Reloads the currently-selected token's buffer from that scope's
    resolved value (walks the chain — inherits ancestor overrides
    when the picked scope doesn't override the token itself).
  * Appends a cyan "@ <scope>" suffix to the Editing label so the
    operator can see at a glance where the next commit will land.
  * Routes OK commits through the scope-aware setters
    `setColor(path, token, c)` / `setSizing(path, …)` / `setGradient` /
    `setString`, so the override lands in the picked scope only.

Reads are scoped via new ThemeManager getters mirroring the widget-aware
overloads but taking a path string directly:
  * `colorAt(path, token)`, `sizingAt(…)`, `valueAt(…)`, `gradientAt(…)`
  * `isOverriddenAt(path, token)` — true iff `path` itself overrides
    (used by step 3c for the "inherited" badge).

Because step 3a wired widget-aware QSS resolution through `resolveFor`,
any override committed at e.g. "applet.tx" is immediately reflected in
every widget inside TxApplet (declared in step 2), while the rest of
the app keeps rendering the root value.  This is the first slice where
the refactor is genuinely visible — pick TxApplet's scope, change
color.accent, watch only TxApplet's accent shift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…idth

Moves the scope picker out of its own full-width row and onto the
inspect row, left of the Inspect button.  Final row layout:
  [Scope: combo] [Inspect btn] [Editing label] [status] [Reset btn]

Combo width is clamped via QFontMetrics to the longest container-path
label + 4 px text padding on each side + ~22 px for the dropdown arrow
and frame.  Per-combo QSS overrides Theme.h's 6/6 default padding so
the 4 px ask is what actually renders.  No horizontal stretch — combo
stays exactly that wide as the dialog grows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filter input + inspector status now share one row (filter on the
left at stretch 1, status on the right at stretch 1, 8 px spacing) —
gives each ~50 % of the body width.  Inspector messages no longer
have to compete with the Editing label or the scope picker for
horizontal real estate on the inspect row above.

Inspect row simplified to:
  [Scope: combo] [Inspect btn] [Editing label] [stretch] [Reset btn]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inspector status moves up to the inspect row (transient messages
sit alongside the Inspect button), Editing label moves down beside
the filter input.  Final layout:

  Inspect row: [Scope:] [Inspect] [Status…………] [Reset]
  Filter row:  [Filter input ½]   [Editing: token (kind) @ scope ½]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…R step 4/4)

Final slice of the container-scoped tokenization refactor.  Wires the
two-tier "primitive + semantic" model into both bundled themes and
makes every getter path alias-aware so {primitive.key} references
resolve through the palette before the value reaches the renderer.

Primitives extracted (16 entries per theme):
  * color.blue.300 / 500 / 700        — accents (bright / base / dim)
  * color.red.500                     — danger family (crst, .danger, slice.tx)
  * color.amber.500                   — warning family (thresh, peakHold, .warning)
  * color.green.500                   — success
  * color.gray.50 … 950               — neutrals (text + backgrounds + borders)

Semantic tokens converted to `{alias}` references where the palette
covers the value (e.g. `color.accent: "{color.blue.500}"`).  Unique
hand-tuned values (slice.* dim colours, gradient stops, etc.) stay
as literals — extracting them as primitives wouldn't reduce
duplication.  Rendered output is bit-identical to v1.3 / v1.0: the
theme_manager_test still asserts the same hex values for every
canonical token and passes.

Bundled themes bump to:
  * Default Dark   v1.3 → v1.4 (schemaVersion 1 → 2)
  * Default Light  v1.0 → v1.1 (schemaVersion 1 → 2)

ThemeManager getters routed through `lookupRaw(QString(), token)` so
alias expansion happens for every code path:
  * color(token), brush(token, bounds), cssFragment(token)
  * sizing(token), value(token), gradient(token)
The legacy direct-m_tokens reads are gone; QSS templates + paint code
both see the resolved primitive value, never the literal "{...}" string.

Net effect: designers editing a primitive (e.g. bumping
`color.blue.500` from `#00b4d8` to `#1eb9d8`) propagates to every
semantic token that aliases it — accent, border.accent, spectrum.trace,
meter.rms — without 4 separate edits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…R step 4b)

Replaces the flat QListWidget with a multi-column QTreeWidget whose
columns reflect the active scope's chain.  Matches the inheritance map
sketched during planning.

Column layout:
  When scope is (root):
    [Object] [Value]
  When scope is applet/tx:
    [Object] [root] [applet] [tx] [Value]
  When scope is dialog/themeEditor:
    [Object] [root] [dialog] [themeEditor] [Value]

Per-cell semantics:
  * Object       — token name + swatch icon (gradient swatch for
                   gradient tokens, flat for scalars).
  * root / mid / leaf scope columns — show the local override stored
                   at that exact scope, formatted as hex (color) /
                   "linear, 180°, 5 stops" (gradient) / "12 px" /
                   family name.  Scopes that inherit show italic
                   "inherited" in muted grey.
  * Value        — the resolved value walking from the active leaf
                   scope up to root.

Refreshed whenever the active container changes (`onContainerChanged`
now calls `refreshTokenList()` which rebuilds columns + repopulates
every row).  Filter and inspector subset still work — Qt's
QTreeWidget supports the same `setHidden(bool)` per-item we used on
QListWidget, the iteration just walks `topLevelItem(i)` instead of
`item(i)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nest

`scopeOrCreate()` splits paths on "/" to build the scope tree, but
step 2's widget declarations used "." as the separator.  Result:
"applet.tx" / "applet.hlth" / etc. registered as flat siblings of
"applet" under root instead of nested children of it — so the
inheritance chain skipped the intermediate scope and the columnar
view showed just one scope column for a 2-segment path.

Switched all declarations to "/":
  * TxApplet         → applet/tx
  * RxApplet         → applet/rx
  * PhoneCwApplet    → applet/digi
  * HealthApplet     → applet/hlth
  * ThemeEditorDialog → dialog/themeEditor
(AppletPanel "applet" and SpectrumWidget "spectrum" were single-segment
and stay unchanged.)

Picking "applet/hlth" in the editor now renders 5 columns
([Object] [root] [applet] [hlth] [Value]) and an override at the
applet scope is inherited by every applet.<name> child.

Also dropped the now-dead `gradientRowText` helper — its only caller
moved to inline formatting in step 4b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click a scope-column cell → focuses that scope in the picker AND
selects the row's token for editing.  Right-click a scope-column cell
that holds an override → "Clear override at <scope>" context menu
removes that level's override (cell flips back to italic "inherited"
and the resolved Value column updates as inheritance kicks back in).

Wiring:
  * QTreeWidget::itemClicked → onTokenCellClicked routes through the
    container combo so the rest of the editor (header label, scope
    indicator on Editing, buffer reload via setActiveContainerPath)
    stays in sync with the clicked column.
  * QTreeWidget::customContextMenuRequested → onTokenContextMenu
    builds a one-action QMenu rooted at the clicked cell.
  * ThemeManager gains `removeOverride(path, token)` — drops the
    local token from that scope's tokens hash, emits themeChanged,
    persists to disk via saveActiveTheme().  No-op when the scope
    inherits the token (nothing to drop).

Clicks on the Object column (col 0) and Value column (last col) are
ignored by the new handler — Object selection still routes through
the existing currentItemChanged → setToken flow, and Value is purely
informational.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pe (Step B)

Closes the loop between "I want to retheme this specific applet" and
the editor.  When the operator clicks a widget in Inspect mode, the
scope picker now jumps to that widget's container path automatically,
so any subsequent edit lands at the right level of the inheritance
chain instead of always at root.

Implementation:
  * onInspectorPicked() consults ThemeManager::containerPathFor(target)
    (same Qt-parent-chain walk used by widget-aware token resolution).
  * Maps the resolved path to a combo index and calls setCurrentIndex,
    which fires onContainerChanged → updates the editor's scope state +
    rebuilds the columnar view.
  * Inspector status text now appends "  ·  scope: applet/hlth" so the
    operator can confirm which scope the editor is now anchored to.

Click TX applet → Inspect → click anywhere inside it → scope flips to
"applet/tx" in the picker, columns reshape to root|applet|tx|Value,
and the next OK lands the override at applet/tx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `theme::setContainer(this, "<path>")` declarations to every
top-level applet, dialog, panel, title bar, and the main status bar.
Gives the container-scoped theming system breadth across the whole UI:
the operator can now pick `applet/dax`, `dialog/help`, `panel/connection`,
`titlebar`, `statusbar`, etc. in the editor's Scope dropdown and have
their override land in the right local namespace.

Applets (23 paths, each under `applet/`):
  amp, antgen, cat, chain, comp, deess, clientEq, gate, pudu, reverb,
  rxdsp, tube, dax, daxiq, eq, meter, mqtt, panadapter, phone,
  shackSwitch, tci, tuner, wave

Dialogs (30 paths, each under `dialog/`):
  aetherDsp, atuPreTune, audioDeviceChange, ax25Decode,
  clientDisconnect, connectedStations, dxCluster, dxClusterStartup,
  favoritesPicker, flexControl, gradientEditor, help, memory,
  midiMapping, mqttSettings, multiFlex, networkDiag, panLayout,
  profileIO, profileManager, propDashboard, radioSetup, shortcut,
  sliceTroubleshoot, spotSettings, support, swrSweepLicense, txBand,
  waveforms, whatsNew

Panels (5 paths, each under `panel/`):
  bandStack, connection, cwx, dvk, memoryBrowse

Chrome (under `root` directly so the chrome theme stays distinct from
the body it wraps):
  * TitleBar / FramelessWindowTitleBar / EditorFramelessTitleBar /
    ContainerTitleBar → "titlebar"
  * MainWindow's statusBar() → "statusbar"

`Strip*Panel` widgets and transient `*Popup` widgets intentionally
left without explicit declarations — they inherit from their host
applet / dialog through the Qt parent chain, which is the correct
behaviour (a strip panel hosted inside ClientCompApplet picks up
applet/comp automatically).

Bulk edits performed by an agent against a uniform pattern; build is
clean and theme_manager_test passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Menu

Inspect-mode clicks on either widget were returning nothing — both
paint through raw QPainter calls keyed off ThemeManager::color(), so
applyStyleSheet's reverse-map never sees them.  Add explicit
`declareWidgetTokens()` calls so the inspector surfaces the actual
paint palette when the operator clicks the VFO flag or the spectrum
overlay menu.

VfoWidget token list covers background ramp, text family, the four
accent shades, plus the eight slice-letter colours + slice.tx (the
collapsed-mode painter recolours by slice letter).

SpectrumOverlayMenu list is narrower — just the background, text,
accent, and border tokens that the menu chrome consumes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles typeface + point size + foreground colour into a single
ThemeFont token so a typographic role (ui / mono / freq / segment7 /
segment14 / weather / temp) is configured as one object instead of
scattered across three independent namespaces.

Data layer
  * New ThemeFont struct (family, size, color) in ThemeManager.h
    alongside ThemeGradient; both registered as Qt metatypes.
  * Parser: any JSON object with a `family` key (and no gradient
    `type` discriminator) parses as a ThemeFont — schema is
    `{family: "...", size: N, color: "#rrggbb"}` with optional size
    + color (defaults: size=0 / color invalid → caller's fallback).
  * Serializer: emits the same shape inside `scopes.<…>.tokens`,
    omitting size/color when unset.  exportThemeToFile path updated
    in parallel.
  * Backward compat: `value(token)` and `valueAt(path, token)`
    transparently return the .family field for ThemeFont tokens, so
    the ~35 existing readers that just want the family string keep
    working unchanged.  `font(token)` uses the compound's embedded
    size when the token resolves to a ThemeFont; falls through to
    the legacy font.family.ui + sizing(token) composition otherwise.
  * New accessors: `fontToken{,At}(path?, token)` returns the full
    ThemeFont; `setFontToken{,At}(path?, token, font)` writes one.

Bundled themes
  * Default Dark + Default Light: every `font.family.*` entry is now
    a compound object with sensible role defaults (size 12–14,
    foreground keyed to the existing role colour — body text uses
    color.text.primary's hex, segment / freq use the accent).

Editor
  * TokenEditorWidget recognises font.family.* tokens as compound:
    family combo, size combo, AND the CompactColorPicker are all
    enabled simultaneously when one is selected.
  * onColorPickerChanged routes through m_bufferColor when target is
    TargetFontFamily, so a colour drag updates the font's foreground
    in-buffer.
  * commitBufferToThemeManager writes the buffered family + size +
    color back as a ThemeFont via setFontToken().
  * Load path reads the compound through fontTokenAt() and falls
    back to colorAt("color.text.primary") for the picker when the
    token doesn't yet carry an embedded colour.

theme_manager_test continues to pass — the family field is what its
assertions read.  Reader-site colour migration (paint code that
currently pulls colour from color.text.* alongside the family) is a
follow-up; for now the embedded colour is editable + persisted and
will be wired into render paths as their call sites migrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous compound-font commit left both cssFragment() overloads
(bare-token and widget-aware) falling through to
`colorHexToCssFragment(v.toString())` for a ThemeFont — and a
custom-typed QVariant returns "" from toString(), so every QSS
template referencing `{{font.family.ui}}` / `{{font.family.freq}}`
resolved to `font-family: "";` after the bundled themes flipped to
the compound shape.  That broke at least:

  * Theme.h's app-wide template (font-family for the whole UI)
  * VfoWidget's frequency display (2 sites)
  * RxApplet's frequency-related labels (2 sites)

Empty font-family in QSS leaves widgets in a half-painted state that
can ripple into adjacent geometry calculations.  Crash trace from a
user report:

  QTreeWidgetItem::data
  QStyledItemDelegate::sizeHint
  QTreeView::sizeHintForColumn
  ...
  paintSiblingsRecursive

  → SEGV inside QVariant copy in libQt6Core during paint event.

Both cssFragment overloads now detect ThemeFont and return its
`.family` field — matches the existing value()/valueAt() compat path
that the ~35 non-QSS readers already use.  Restores `font-family:
"Inter"` in QSS output and removes the half-painted geometry path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…es the size

The freq label's QSS hardcoded `font-size: 26px` (VfoWidget) /
`28px` (RxApplet) so editing `font.family.freq.size` in the theme
editor had nowhere to land — the compound's size field was being
written and persisted but never consumed.

Two changes plug the gap:

1. cssFragment() / sizing() add a virtual fallback for
   `font.size.<role>` tokens.  When the token isn't defined directly,
   the helper looks up `font.family.<role>` and returns the embedded
   ThemeFont.size field.  QSS templates and paint code now have a
   stable name they can reference for the per-role size.

2. VfoWidget + RxApplet freq labels swap their hardcoded
   `font-size: 26px;` / `28px;` for `font-size: {{font.size.freq}}px;`,
   and the bundled themes bump `font.family.freq.size` from 14 → 26
   so the default render matches what it was before the refactor.
   Edits to the freq compound's size now visibly resize the freq
   label in both VfoWidget and RxApplet.

Edit fields (m_freqEdit) still hardcode their smaller sizes — they're
intentionally sized smaller than the label and don't track the role's
primary size.  If we need them parametric later, we'll add a
sub-role token rather than overload font.family.freq with two sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The root cause for "changing the font size in font.family.freq has
no effect": `onFontSizeChanged` early-exited without calling
markDirty() for TargetFontFamily.  The comment claimed the size combo
was "purely informational" for font.family.* tokens — true before
the compound migration, no longer true now that font.family.X stores
{family, size, color} together.  The OK button stayed disabled (or
the dirty gate blocked the commit) and the new size never reached
ThemeManager.

Also extends the size combo preset list with 24/28/32/36/48/60/72 so
the freq compound's default 26 is visible alongside other large
sizes, and makes the A↑/A↓ bump clamps target-aware: TargetNumeric
(font.size.*/sizing.*) still capped at 20 px so the legacy scalar
size tokens don't overflow status-row labels; TargetFontFamily allows
7-96 so segment/freq compounds can grow freely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Clear override at (root)" was deleting the token tree-wide because
root has no parent to inherit from.  Two layers of defense:

1. ThemeManager::removeOverride() now rejects path == root with a
   warning instead of removing the entry from the root scope's
   token hash.  Anything calling it (now or future) is protected.

2. ThemeEditorDialog::onTokenContextMenu() short-circuits before
   showing the menu when the clicked column is the root column.
   The right-click only makes sense on a nested scope where there
   IS something to inherit from.

Reset-to-factory at root has its own dedicated button on the inspect
row — that's the correct affordance for "I want this token's
canonical bundled value back".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VfoWidget had paint tokens declared (via declareWidgetTokens, so the
inspector knows what tokens it consumes) but no container scope of
its own.  Inspect-clicks on the VFO flag resolved containerPathFor()
up the Qt parent chain to SpectrumWidget's "spectrum" — useful, but
not specific enough: the flag is a distinct theming surface from the
panadapter / waterfall it floats over.

Adds `theme::setContainer(this, "spectrum/vfo")` so:
  * Inspect-click on the flag flips the editor's scope picker to
    `spectrum/vfo` (5 columns: Object / root / spectrum / vfo / Value).
  * VFO-only overrides land at `spectrum/vfo` without bleeding into
    the rest of the panadapter or other widgets inside `spectrum`.
  * Unset tokens at vfo inherit from spectrum's overrides, which
    inherit from root — the cascade is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tor crash

Trying to adjust the status-bar's background colour (or any scope
change driven by the inspector) crashes inside QImageData::~QImageData
during QTreeWidgetItem destruction.  Call chain:

  ThemeInspector::eventFilter
  → widgetPicked
  → ThemeEditorDialog::onInspectorPicked
  → QComboBox::setCurrentIndex (synchronous signal)
  → onContainerChanged
  → refreshTokenList
  → m_tokenList->clear()  (destroys items + their icon QVariants)
  → QVariant::~QVariant
  → QPixmapIconEngine::~D0
  → QPixmap::~QPixmap
  → QImage::~QImage
  → QImageData::~QImageData → SEGV

Destroying QTreeWidget items (each with QPixmap-backed icon
QVariants for the colour swatches) inside the synchronous signal
cascade has been observed to crash in Qt6.  The fix breaks the
chain: onContainerChanged now defers the rebuild via
QTimer::singleShot(0, ...) so the inspector callback completes,
the signal stack unwinds, and the rebuild runs from a clean event-
loop iteration.

No regressions — refreshTokenList already didn't reapply the
inspector subset filter, so deferring it doesn't lose any state
that was preserved before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a small eyedropper button to the right of the Hex field in
CompactColorPicker.  Click to enter pick mode: cursor becomes a
crosshair, next mouse press anywhere on screen samples the pixel
under the cursor and applies it as the picker's new colour.  Esc
or any non-left button cancels.

Implementation uses the same grabMouse(Qt::CrossCursor) + grabKeyboard()
pair that QColorDialog::pickScreenColor() uses internally — Qt's
platform integration handles the Wayland-portal / X11-XGrabPointer /
Win32-SetCapture mechanics under the hood, so cross-application picks
work on every platform where the standard QColorDialog eyedropper
already does.

Two implementation details worth knowing:

* The sample fires on MouseButtonRelease, not Press, and the grabs are
  released FIRST so QScreen::grabWindow captures the clean composited
  image without the crosshair cursor in the captured pixel.
* The eyedropper reads only RGB from the screen; the picker's alpha
  slider remains the source of truth for opacity.  Picking a colour
  from a transparent surface preserves the user's current alpha rather
  than picking up the underlying compositor's blended value.

Icon is painted at device-pixel resolution from a QPainter sketch
(bulb + diagonal stem + drop) so it stays crisp on HiDPI displays;
themed from the widget palette's WindowText colour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 marked this pull request as ready for review May 26, 2026 07:57
@ten9876 ten9876 requested review from a team as code owners May 26, 2026 07:57
Pushed in via the inline Theme Editor — 26px is heavier than needed
for the VFO + RX Controls frequency labels at the new compact panel
width.  20px reads cleanly without crowding adjacent rows.

Default Light theme intentionally unchanged in this commit — that
file's freq sizing will get its own pass once the Light theme's
overall density tuning settles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 merged commit aedcd11 into main May 26, 2026
4 checks passed
@ten9876 ten9876 deleted the auto/theme-enhancements branch May 26, 2026 08:22
aethersdr-agent Bot added a commit that referenced this pull request May 26, 2026
… Principle VIII.

Adds smoke coverage for the v2 surface area introduced in #3176 that the
existing flat-token tests didn't reach.  All additions land in
tests/theme_manager_test.cpp — the CMake target already links every TU
the new assertions touch (ThemeManager.cpp, AppSettings.cpp,
LogManager.cpp, AsyncLogWriter.cpp), so no CMakeLists change is needed.

New blocks:

JSON loader/writer
- v2 primitives + {alias} resolution end-to-end through color() /
  cssFragment() / resolve() — guards against the cssFragment-empty-for-
  ThemeFont class of bug that surfaced mid-PR.
- flattenTokens discriminator routing: a single v1 file with a gradient
  ({"type":...}), a compound font ({"family":...}), and a plain nested
  group is imported and each leaf is type-checked.  Pins the
  discriminator order in flattenTokens().
- v1 → v2 migration round-trip: import v1 file, mutate via setColor
  (triggers auto-save), re-read on-disk JSON to assert schemaVersion=2 +
  scopes.root.tokens wrapper, then reload and confirm values survive.

Scope tree (pure in-memory on the built-in Default Dark; saveActiveTheme
silently fails for built-ins so each block leaves no disk side-effects)
- setColor at a nested scope does not touch root.
- Inheritance walk: token defined at scopeB read from scopeB/leaf
  resolves to scopeB's value.
- removeOverride at a nested scope drops the override; reads fall back
  to parent.
- removeOverride at root is the defensive no-op (would otherwise delete
  the token tree-wide).
- scopeOrCreate("a/b/c") wires up the full chain, verified via
  containerPaths() + isOverriddenAt() on each intermediate.
- Each scope-tree block uses a unique container path (scopeA / scopeB /
  scopeC / deep) so state from earlier blocks can't bleed into later
  assertions.

Compound font tokens
- setFontToken → fontToken round-trip preserves all three fields.
- value() on a ThemeFont downgrades to .family (the ~35 legacy callers).
- cssFragment("font.size.freq") + sizing("font.size.freq") virtual
  lookups return the compound's embedded size — the bug fixed mid-PR.
- Persistence: the v1→v2 migration block doubles as the compound-on-disk
  check (asserts {family, size, color} JSON shape, not a nested scope).

Editor-surface smoke (deferred-commit + per-theme recent-colors) is
deferred to a follow-up that can stand up the GUI dialog layer in its
own test target.

Blast radius: skipped — additive test-only change, no production
symbols touched.
ten9876 pushed a commit that referenced this pull request May 27, 2026
… factory-loader v2 (#3199)

Four targeted fixes to the Theme Editor surface picking up the Tier 3
"editor UX polish" bullets from
[#3184](#3184). Hand-tested
across both bundled themes and a forked user theme; `theme_manager_test`
gains coverage for the per-applet cascade and the new v2-aware factory
snapshot.

## 1. Filter persistence across scope changes

`refreshTokenList()` rebuilds the token tree from scratch but never
re-applied the current text/inspector subset filter — so switching the
scope dropdown (or any path that triggers a refresh) caused the filter
to silently fall back to "show everything" while the `QLineEdit` kept
the text it was apparently filtering by.

One-line fix: call `filterTokensTo(m_activeSubset)` at the end of
`refreshTokenList` so the rebuilt rows immediately honour the filter.

## 2. Theme-delete-time cleanup of recent-color buckets

Per-theme recent-color grids persist under the `AppSettings` key
`ThemeEditor.RecentColors/<theme name>`. Deleting the theme file used to
leave those entries behind forever — a small but real leak called out in
#3184.

`onDeleteThemeClicked` now removes the matching key after the theme file
deletion succeeds. `TokenEditorWidget::recentColorsKeyFor` is promoted
from private to public-static so the dialog can call the canonical key
formatter directly (no string-duplication risk).

## 3. Reset-to-default semantics at nested scopes

**At root scope** Reset still loads the bundled factory value into the
edit buffer for the user to confirm via OK — that behaviour is
preserved.

**At nested scopes** (`applet/tx`, `applet/rx`, `applet/comp`, …) the
previous behaviour was incorrect: Reset loaded the factory value into
the buffer, and OK then wrote that value as a *new* override at the
nested scope. Per #3184 the intended semantic at a non-root scope is
"clear my override here so the scope falls back to inheriting from its
parent" — i.e. exactly what right-click → Clear Override on the
scope-chain column already does.

`onResetClicked` now branches:
- **root scope**: existing factory-load-into-buffer behaviour
- **nested scope**: calls `removeOverride()` directly, reloads the
editor's buffer + picker from the now-inherited value, emits
`tokenChanged` so the parent dialog repopulates the matching row (so the
scope column flips to italic *"inherited"* AND the Value column reflects
the now-resolved value — without that signal the Value column would stay
stale, as observed during testing).

`refreshResetButton` also branches: at root scope it enables based on
`hasFactoryValue` (existing), at nested scope it enables only when
there's actually an override at this scope to clear — mirrors the
right-click → Clear Override gating, so the button visually reflects
whether clicking it has any effect.

## 4. Factory-snapshot v2 schema awareness (uncovered during testing)

While verifying Fix #3 at root scope, discovered that Reset there was
*also* broken — but for an unrelated reason that predates this PR.

`ThemeManager::ensureFactoryLoaded()` reads `:/themes/default-dark.json`
to build the `m_factoryTokens` map that `factoryColor` / `factoryString`
/ `factorySizing` / `hasFactoryValue` all read from. Its single
`flattenTokens` call read from `doc.object().value("tokens")` — which is
the **v1 schema** shape. In v2 (the canonical shape since
[#3176](#3176)) the tokens
live under `scopes.root.tokens`; the v1 path produces an empty
`m_factoryTokens` against any v2 theme.

Downstream effect: `hasFactoryValue` returns false for every token in a
v2 theme → `refreshResetButton` disables the Reset button at root scope
(or with the existing styling, leaves it visually indistinguishable from
enabled — silent failure either way) → `factoryColor` returns invalid
QColor. **Silently broken across every v2 theme since #3176 landed in
March.**

`ensureFactoryLoaded` now branches on `schemaVersion`:
- **v2**: reads `primitives` + `scopes.root.tokens`, then resolves
`{primitive}` aliases inline before storing in `m_factoryTokens`, so
`factoryColor` sees concrete hex values without needing a second lookup
pass (matches the live runtime resolver — single-hop).
- **v1**: existing path preserved for older themes still on disk pending
auto-migration.

`theme_manager_test` gains assertions on `hasFactoryValue` +
`factoryColor` for `color.accent` (alias-resolved) and
`color.background.0` to lock this in against future schema bumps.

## Verification

Windows 11, MSVC + Ninja + Qt 6.10.3, branched from
`aethersdr/AetherSDR` main at `4ee99f78`.

| Check | Result |
|---|---|
| Filter persistence — type "accent", switch scope to `applet/tx` |
Filter stays applied (list does NOT re-expand) ✓ |
| Nested-scope Reset — forked theme, `applet/tx`,
`color.slider.foreground` (`#ff4d4d` override) → click Reset | Picker
reloaded `#ff4d4d` → `#00b4d8`, tx column flipped from override to
italic *"inherited"*, Value column updated to `#00b4d8`, Reset button
disabled ✓ |
| Root-scope Reset — same theme, `(root)`, changed `color.accent` to
magenta `#a12c66`, OK'd, then Reset | Picker flipped magenta → factory
blue `#00b4d8`, `• unsaved` indicator appeared, OK + Cancel re-enabled,
token list row still showed `#a12c66` (uncommitted buffer) ✓ |
| `theme_manager_test` — new factory-snapshot + per-applet cascade
assertions | Pass ✓ |
| `theme_manager_test` — 4 pre-existing failures
(`importThemeFromFile`-related, Windows-vs-CI env issue) | Also present
on clean `main` at the same source line numbers (398/400/451/590 there →
419/421/472/611 here, +21 from inserted test block). Not introduced by
this PR. Happy to investigate separately. |

## Out of scope (follow-ups)

- The `importThemeFromFile` test failures are not investigated here —
they predate this PR and the root-cause looks
Windows-environment-specific (`QTemporaryDir` / `XDG_CONFIG_HOME`
interaction).
- Tooltips / hint text explaining what Reset does at root vs nested
could further close the UX gap, but the enable-state gating already
gives correct feedback.

cc @ten9876 @jensenpat @chibondking

73 Nigel G0JKN

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…aethersdr#3176)

## Summary
Folds every editing interaction into the Theme Editor dialog (no spawned
sub-dialogs), tightens the layout, and closes a long-standing
persistence hole. Built-in themes (Default Dark / Default Light) are now
read-only — the first edit forks them through Save As so the bundled
defaults can't be silently shadowed.

**Draft** — more work is coming on top of this; opening early for
visibility.

## What's in this PR

### Editor surface
- New **`TokenEditorWidget`** — single vertical stack of always-visible
groups (color picker, gradient stops, font row). Disabled groups dim
instead of disappearing.
- New **`CompactColorPicker`** — 200×200 SV square + 12×200 hue strip +
200×12 alpha slider with checkerboard. Replaces `QColorDialog` spawn.
Square strip borders + chip-style thumbs (dark outer ring, white inner
fill, 1-px tick) consistent across hue / alpha / gradient stop markers.
- Inspect row carries `[Inspect] [Profile: name] [status] [Reset to
default]`. Editing label is single-line rich-text `Editing: token
(kind)`.
- Font row carries `Cancel` / `OK` at the right end; the dedicated
bottom button row is gone.
- **Recent colors** — 2×10 grid of 28×24 rounded swatches matching the
hex swatch. Left-click applies; right-click removes the slot. Persists
**per-theme** under `ThemeEditor.RecentColors/<theme>` and reloads on
theme switch.
- Font size combo non-editable, capped to **7–20 pt** (larger sizes
break the freq glyph cell + status-row widgets). `QFontComboBox` also
non-editable; dropdown font reduced 4 px.
- Gradient angle moved up into the stops header row alongside `+/−`.
- Gradient strip markers overlay the bar (chip-thumb style) instead of a
triangle band below — survives the 35 px fixed-height embed.
- RGB row shrunk to fit inside the SV+hue block width via tighter
spinbox padding + width 64→58.

### Behaviour
- **OK/Cancel commit model** — edits stay buffer-local until confirmed.
Cancel reloads from `ThemeManager`.
- **Built-in theme guard** — `onOkClicked` snapshots the buffer into a
`DeferredEdit`, emits `requestSaveAsBeforeCommit`; the dialog runs Save
As and calls `completeDeferredCommit()` which restores the snapshot and
writes to the new user copy. Buffer survives the `refreshTokenList()`
that fires from `themeChanged`.
- **Auto-persist** — added `ThemeManager::saveActiveTheme()` called from
`setColor` / `setSizing` / `setGradient` / `setString`. Wraps a shared
`writeThemeFile()` helper extracted from `saveCurrentThemeAs`. No-op on
built-ins. Closes the silent "edits gone after restart" bug.

### Naming
- `Editing: <theme>` → `Profile: <theme>`.
- `Colour` → `Color` throughout the editor + gradient-edit prompts.

## Test plan
- [ ] Edit a token on a user theme — confirm with OK, restart, change
persists
- [ ] Edit a token on Default Dark — Save As prompt appears with `My
Default Dark` pre-filled; confirm, edit lands in new theme; cancel
leaves Default Dark untouched
- [ ] Verify recent-colors persist across restart and stay separate per
theme
- [ ] Right-click a recent swatch removes that slot
- [ ] Gradient editing: drag stops, add/remove, change angle, change
stop colors
- [ ] Font row: pick family, pick size 7–20 pt, A↑/A↓ bumps clamp at 7
and 20
- [ ] Confirm no Theme Editor regressions on light + dark + custom
themes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
… factory-loader v2 (aethersdr#3199)

Four targeted fixes to the Theme Editor surface picking up the Tier 3
"editor UX polish" bullets from
[aethersdr#3184](aethersdr#3184). Hand-tested
across both bundled themes and a forked user theme; `theme_manager_test`
gains coverage for the per-applet cascade and the new v2-aware factory
snapshot.

## 1. Filter persistence across scope changes

`refreshTokenList()` rebuilds the token tree from scratch but never
re-applied the current text/inspector subset filter — so switching the
scope dropdown (or any path that triggers a refresh) caused the filter
to silently fall back to "show everything" while the `QLineEdit` kept
the text it was apparently filtering by.

One-line fix: call `filterTokensTo(m_activeSubset)` at the end of
`refreshTokenList` so the rebuilt rows immediately honour the filter.

## 2. Theme-delete-time cleanup of recent-color buckets

Per-theme recent-color grids persist under the `AppSettings` key
`ThemeEditor.RecentColors/<theme name>`. Deleting the theme file used to
leave those entries behind forever — a small but real leak called out in
aethersdr#3184.

`onDeleteThemeClicked` now removes the matching key after the theme file
deletion succeeds. `TokenEditorWidget::recentColorsKeyFor` is promoted
from private to public-static so the dialog can call the canonical key
formatter directly (no string-duplication risk).

## 3. Reset-to-default semantics at nested scopes

**At root scope** Reset still loads the bundled factory value into the
edit buffer for the user to confirm via OK — that behaviour is
preserved.

**At nested scopes** (`applet/tx`, `applet/rx`, `applet/comp`, …) the
previous behaviour was incorrect: Reset loaded the factory value into
the buffer, and OK then wrote that value as a *new* override at the
nested scope. Per aethersdr#3184 the intended semantic at a non-root scope is
"clear my override here so the scope falls back to inheriting from its
parent" — i.e. exactly what right-click → Clear Override on the
scope-chain column already does.

`onResetClicked` now branches:
- **root scope**: existing factory-load-into-buffer behaviour
- **nested scope**: calls `removeOverride()` directly, reloads the
editor's buffer + picker from the now-inherited value, emits
`tokenChanged` so the parent dialog repopulates the matching row (so the
scope column flips to italic *"inherited"* AND the Value column reflects
the now-resolved value — without that signal the Value column would stay
stale, as observed during testing).

`refreshResetButton` also branches: at root scope it enables based on
`hasFactoryValue` (existing), at nested scope it enables only when
there's actually an override at this scope to clear — mirrors the
right-click → Clear Override gating, so the button visually reflects
whether clicking it has any effect.

## 4. Factory-snapshot v2 schema awareness (uncovered during testing)

While verifying Fix aethersdr#3 at root scope, discovered that Reset there was
*also* broken — but for an unrelated reason that predates this PR.

`ThemeManager::ensureFactoryLoaded()` reads `:/themes/default-dark.json`
to build the `m_factoryTokens` map that `factoryColor` / `factoryString`
/ `factorySizing` / `hasFactoryValue` all read from. Its single
`flattenTokens` call read from `doc.object().value("tokens")` — which is
the **v1 schema** shape. In v2 (the canonical shape since
[aethersdr#3176](aethersdr#3176)) the tokens
live under `scopes.root.tokens`; the v1 path produces an empty
`m_factoryTokens` against any v2 theme.

Downstream effect: `hasFactoryValue` returns false for every token in a
v2 theme → `refreshResetButton` disables the Reset button at root scope
(or with the existing styling, leaves it visually indistinguishable from
enabled — silent failure either way) → `factoryColor` returns invalid
QColor. **Silently broken across every v2 theme since aethersdr#3176 landed in
March.**

`ensureFactoryLoaded` now branches on `schemaVersion`:
- **v2**: reads `primitives` + `scopes.root.tokens`, then resolves
`{primitive}` aliases inline before storing in `m_factoryTokens`, so
`factoryColor` sees concrete hex values without needing a second lookup
pass (matches the live runtime resolver — single-hop).
- **v1**: existing path preserved for older themes still on disk pending
auto-migration.

`theme_manager_test` gains assertions on `hasFactoryValue` +
`factoryColor` for `color.accent` (alias-resolved) and
`color.background.0` to lock this in against future schema bumps.

## Verification

Windows 11, MSVC + Ninja + Qt 6.10.3, branched from
`aethersdr/AetherSDR` main at `4ee99f78`.

| Check | Result |
|---|---|
| Filter persistence — type "accent", switch scope to `applet/tx` |
Filter stays applied (list does NOT re-expand) ✓ |
| Nested-scope Reset — forked theme, `applet/tx`,
`color.slider.foreground` (`#ff4d4d` override) → click Reset | Picker
reloaded `#ff4d4d` → `#00b4d8`, tx column flipped from override to
italic *"inherited"*, Value column updated to `#00b4d8`, Reset button
disabled ✓ |
| Root-scope Reset — same theme, `(root)`, changed `color.accent` to
magenta `#a12c66`, OK'd, then Reset | Picker flipped magenta → factory
blue `#00b4d8`, `• unsaved` indicator appeared, OK + Cancel re-enabled,
token list row still showed `#a12c66` (uncommitted buffer) ✓ |
| `theme_manager_test` — new factory-snapshot + per-applet cascade
assertions | Pass ✓ |
| `theme_manager_test` — 4 pre-existing failures
(`importThemeFromFile`-related, Windows-vs-CI env issue) | Also present
on clean `main` at the same source line numbers (398/400/451/590 there →
419/421/472/611 here, +21 from inserted test block). Not introduced by
this PR. Happy to investigate separately. |

## Out of scope (follow-ups)

- The `importThemeFromFile` test failures are not investigated here —
they predate this PR and the root-cause looks
Windows-environment-specific (`QTemporaryDir` / `XDG_CONFIG_HOME`
interaction).
- Tooltips / hint text explaining what Reset does at root vs nested
could further close the UX gap, but the enable-state gating already
gives correct feedback.

cc @ten9876 @jensenpat @chibondking

73 Nigel G0JKN

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.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.

1 participant