feat(theme): inline Theme Editor + auto-persist + built-in protection#3176
Merged
Conversation
…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>
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>
14 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
TokenEditorWidget— single vertical stack of always-visible groups (color picker, gradient stops, font row). Disabled groups dim instead of disappearing.CompactColorPicker— 200×200 SV square + 12×200 hue strip + 200×12 alpha slider with checkerboard. ReplacesQColorDialogspawn. Square strip borders + chip-style thumbs (dark outer ring, white inner fill, 1-px tick) consistent across hue / alpha / gradient stop markers.[Inspect] [Profile: name] [status] [Reset to default]. Editing label is single-line rich-textEditing: token (kind).Cancel/OKat the right end; the dedicated bottom button row is gone.ThemeEditor.RecentColors/<theme>and reloads on theme switch.QFontComboBoxalso non-editable; dropdown font reduced 4 px.+/−.Behaviour
ThemeManager.onOkClickedsnapshots the buffer into aDeferredEdit, emitsrequestSaveAsBeforeCommit; the dialog runs Save As and callscompleteDeferredCommit()which restores the snapshot and writes to the new user copy. Buffer survives therefreshTokenList()that fires fromthemeChanged.ThemeManager::saveActiveTheme()called fromsetColor/setSizing/setGradient/setString. Wraps a sharedwriteThemeFile()helper extracted fromsaveCurrentThemeAs. No-op on built-ins. Closes the silent "edits gone after restart" bug.Naming
Editing: <theme>→Profile: <theme>.Colour→Colorthroughout the editor + gradient-edit prompts.Test plan
My Default Darkpre-filled; confirm, edit lands in new theme; cancel leaves Default Dark untouched🤖 Generated with Claude Code