feat(theme): Phase 5 PR 3 — alpha pipeline + themeRegions + glass-mode backdrop#3148
Conversation
…ure + glass-mode backdrop
## Summary
Closes the alpha-handling correctness gap that PR 5/1's colour picker
exposed, lays the inspector-coverage groundwork for custom-paint
widgets, and adds a single-token "glass mode" backdrop that lets
operators dial app translucency without flipping every widget at once.
## Alpha pipeline fix
QColorDialog (opened via the Theme Editor) returned QColors with
alpha < 255, but every downstream step quietly stripped it:
* `ThemeManager::setColor()` used `QColor::name()` (defaults to
"#rrggbb") — alpha gone at storage time.
* `gradientCssFragment()` explicitly passed `QColor::HexRgb` for
stop colours.
* `cssFragment()` for scalars returned the raw string — Qt's
stylesheet parser doesn't accept "#aarrggbb", only `rgba(...)`.
* `saveCurrentThemeAs()` re-emitted stops via the same bare `.name()`.
New helpers `colorToTokenString()` and `colorHexToCssFragment()` route
all four sites:
* Storage uses `HexArgb` when alpha < 255, `HexRgb` otherwise (keeps
today's bundled-theme strings byte-identical).
* Stylesheet emit translates translucent hex to `rgba(r, g, b, a)`
so Qt actually honours the alpha at render time.
* JSON round-trip preserves the 8-digit form on save.
ThemeEditorDialog updates: the swatch icon now sits over a 2x2
checkerboard so translucent colours are visually distinguishable from
opaque ones at a glance, and the hex label shows the 8-digit form
when alpha < 255.
## themeRegions() infrastructure
Adds the RFC #3076 sub-region hook for custom-paint widgets:
```cpp
struct ThemeRegion {
QString token;
std::function<bool(QPoint localPos)> hitTest;
QString description;
};
void declareWidgetRegions(QWidget* widget, const QList<ThemeRegion>& regions);
QStringList tokensAtPoint(const QWidget* widget, const QPoint& localPos) const;
```
`tokensAtPoint()` returns just the tokens whose `hitTest()` matches
the click point. Falls back to the coarse `tokensForWidget()` list
when no region claims the point, so the inspector always surfaces
something useful for a tracked widget.
ThemeEditorDialog's inspector now routes through `tokensAtPoint()`
with proper coordinate-space mapping up the parent chain — clicks
on a deep child whose nearest themed ancestor declared sub-regions
get the narrowed hit-list.
SpectrumWidget — the biggest paint surface in the app — calls
`declareWidgetTokens()` with its 25 painted tokens (background tiers,
spectrum trace/peakHold/average/grid, all 8 slice colours + tx,
waterfall.colormap, text tiers, accents). Sub-region splits between
the panadapter, waterfall, and slice triangles are a follow-up that
benefits from being separately reviewable.
## Glass-mode backdrop
A new `color.background.app` token paints MainWindow's full client
area through a custom `paintEvent` override, with
`Qt::WA_TranslucentBackground` enabled. Default value is opaque
(`#0f0f1a` dark, `#f5f5f8` light) so existing installs see no visual
change — but operators can now edit the token via Theme Editor to
dial alpha down and watch the desktop show through wherever child
widgets don't have explicit opaque fills.
This is the unblocking architectural change for a future "glass"
theme variant. The audit work — adding explicit fills to widgets
that currently rely on MainWindow's opaque backdrop — is open-ended
and gets its own follow-up PR.
## Known follow-ups
* Glass-mode audit: identify and add opaque fills to widgets that
bleed desktop through (free-floating labels, header strips, etc.).
* SpectrumWidget sub-regions: split coarse declaration into
panadapter / waterfall / slice-triangle ThemeRegions via
declareWidgetRegions().
* Gradient editor: in-dialog stop-strip widget for editing
waterfall.colormap and slice.dim block (moved to PR 3b — bigger
UI surface, benefits from isolated review).
* MeterWidget + SliceLabel declareWidgetTokens() coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks @ten9876 — three-piece scope is well partitioned and the alpha-pipeline rationale is convincing. The bug table in the PR body matches what I see in the diff: every QColor::name() / HexRgb call on a translucent value was a silent strip, and routing all four through colorToTokenString() / colorHexToCssFragment() is the right fix. JSON round-trip stays byte-identical for opaque values (verified: c.alpha() == 255 branch returns plain #rrggbb), so bundled-theme strings won't churn.
A few things worth a look before merge:
1. WA_TranslucentBackground is unconditional (MainWindow.cpp:1416)
setAttribute(Qt::WA_TranslucentBackground, true);
setAutoFillBackground(false);This runs for every install, not just operators experimenting with translucent color.background.app. On Linux WMs without an active compositor (raw X11, KWin in non-compositing mode, some i3/dwm setups, certain Wayland fallback paths) the attribute can produce a window with garbage pixels, a fully-transparent client area, or platform-specific rendering glitches even when the backdrop colour is opaque — because the Qt backing store now allocates with alpha and the platform window is requested as translucent regardless of what we draw into it.
The "zero visual change" claim is true for KWin-compositing (your test environment) but isn't a property of WA_TranslucentBackground itself. Two safer options:
- Gate on token value: only flip the attribute when
color.background.apphasalpha < 255. This needs the QWindow to be re-realised on theme edit (hide()/show()or re-create), which is more work but keeps the "default install behaves exactly like main" guarantee. - Gate behind a per-user opt-in in AppSettings (e.g.
Theme/AllowTranslucentBackdrop) — small UX cost, big safety win for the audit phase.
Either path defends against silent regressions on the long tail of Linux desktop configurations we don't have on the bench. Worth a follow-up issue if you'd rather not block this PR on it.
2. SpectrumWidget token list will drift (SpectrumWidget.cpp:521)
The hardcoded list of 25 tokens in the constructor is invisible to anyone adding a new ThemeManager::instance().color("color.spectrum.foo") call in the paint code — they'll silently fall out of the inspector. Not a blocker (you've already noted the sub-region split as follow-up), but consider a // MAINTAINER: keep in sync with paintXxx() token reads comment so the next person hunting for "why doesn't the inspector see my new token" finds the answer fast.
3. Minor: QPainter scope (MainWindow.cpp:5519)
QPainter p(this);
...
p.fillRect(rect(), bg.isValid() ? bg : QColor("#0f0f1a"));
QMainWindow::paintEvent(event);QMainWindow::paintEvent is a QWidget no-op so this isn't actively broken, but the conventional pattern is to scope the painter ({ QPainter p(this); ... }) before calling the base — keeps you safe if the base ever grows a body. One-line fix.
Alpha-pipeline and themeRegions look solid otherwise. The parent-chain coordinate mapping in onInspectorPicked (mapping through global at each step) is the correct way to do that walk.
🤖 aethersdr-agent · cost: $6.7876 · model: claude-opus-4-7
…lat↔gradient conversion + frameless chrome (#3158) ## Summary Closes the last visible gap from [RFC #3076](#3076 Phase 5 — every theme token can now be edited end-to-end through the canonical frameless dialog flow. ## What's new ### Gradient editor `src/gui/GradientEditorDialog.{h,cpp}` — modal dialog with a custom `GradientStrip` widget: - Live preview over a checkerboard (translucent stops stay visible) - Triangle markers draggable to reposition; double-click strip body adds a stop (colour sampled at the click point); right-click marker deletes (refuses below 2 stops) - QListWidget with `+` / `−` / `Edit colour…` buttons for keyboard-driven editing - Angle spinbox for linear gradients - **`Reset to default`** button pulls from a lazy-loaded `:/themes/default-dark.json` snapshot via new `ThemeManager::factoryGradient()` ### Any-token flat ↔ gradient conversion Click any colour token row → small QMenu at cursor with two options reflecting current type: | Current | Menu options | |---|---| | Flat colour | "Edit flat colour…" + "Convert to gradient…" | | Gradient | "Edit gradient…" + "Convert to flat colour…" | `Convert to gradient` seeds a 2-stop gradient with the scalar at both ends — initial render is **bit-identical** to the flat output, so opening the editor doesn't perturb anything until a deliberate edit. `Convert to flat` seeds `QColorDialog` with the gradient's first stop. `ThemeManager` already overwrites the other-type entry on `setColor` / `setGradient`, so the data layer just routes through the right setter. ### Meter-bar gradient New token `color.meter.bar.fillGradient` — linear, angle 0° (bottom→top), 5 stops matching the original hardcoded palette (green / lime / amber / red / deep-red). - `ClientLevelMeter` reads via `ThemeManager::brush(token, stripBox)`, dropping its hand-rolled `QLinearGradient` block - `ClientCompMeter` switches to the same shared token, replacing the `kLevelLo` / `kLevelMid` / `kLevelHi` 3-stop assembly - Both widgets connect to `themeChanged` for live updates Themes can recolour every level meter in the app by editing one gradient. ### Frameless chrome Per `docs/style/dialog-patterns.md`, both editors now subclass `PersistentDialog`: - **Theme Editor** — frameless title bar, 8-axis edge resize, geometry persists under `ThemeEditorDialogGeometry`; MainWindow wireup collapsed from a 10-line lazy-construct dance to `showOrRaisePersistent(m_themeEditorDialog)` - **Gradient Editor** — same chrome; inherits frameless state from `AppSettings::FramelessWindow` at construction. Still modal so the parent's `dlg.exec()` flow is preserved. ### Inspector pauses for modal child dialogs The cyan tracking overlay was leaking on top of the gradient editor (and `QColorDialog`). Fixed: `ThemeInspector::eventFilter` now early-returns and hides its overlay whenever `QApplication::activeModalWidget()` is non-null. The inspector resumes automatically the moment the modal closes. ## Test plan - [x] Builds clean (`--target all`) - [x] `color.waterfall.colormap.fire` → "Edit gradient…" → strip editor opens; drag/add/delete stops repaint the panadapter waterfall live; "Reset to default" restores the bundled stops - [x] `color.accent` → "Convert to gradient…" → strip editor opens seeded with a 2-stop same-colour gradient; edits flow to every `applyStyleSheet`-tracked widget - [x] `color.meter.bar.fillGradient` → gradient editor; edits recolour slice level meters AND compression meters together - [x] Inspector mode + open gradient editor → overlay hides while modal is up, resumes on close - [x] Theme Editor frameless chrome matches the rest of the dialogs ## Follow-ups already on the roadmap - Glass-mode audit (widgets that bleed desktop through with the PR #3148 backdrop) - `SpectrumWidget` sub-region splits via `declareWidgetRegions()` (panadapter / waterfall / slice triangles) - `MeterWidget` + `SliceLabel` `declareWidgetTokens()` coverage - Phase 5 PR 4: font + sizing pickers, draft layer, delete/rename, reset-to-default-per-token - Phase 6: `.aethertheme` import/export + drag-and-drop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e backdrop (aethersdr#3148) ## Summary Closes the alpha-handling correctness gap that PR aethersdr#3130's colour picker exposed, lays the inspector-coverage groundwork for custom-paint widgets, and adds a single-token "glass mode" backdrop that lets operators dial app translucency without flipping every widget at once. ## Alpha pipeline fix The colour picker returned `QColor` with `alpha < 255`, but every downstream step quietly stripped it: | Step | Bug | |---|---| | `setColor()` | `QColor::name()` defaults to `#rrggbb` — alpha lost at storage | | `gradientCssFragment()` | explicit `QColor::HexRgb` for stops | | `cssFragment()` | returned raw "#aarrggbb" — Qt SS parser ignores it | | `saveCurrentThemeAs()` | bare `.name()` on gradient stops on save | New helpers `colorToTokenString()` and `colorHexToCssFragment()` route all four: - Storage: `HexArgb` when alpha < 255, `HexRgb` otherwise (bundled-theme strings stay byte-identical) - Stylesheet emit: translates translucent hex to `rgba(r, g, b, a)` so Qt actually honours alpha - JSON round-trip: preserves the 8-digit form on save Theme Editor swatch now sits over a 2×2 checkerboard so translucent colours are visually distinguishable; hex label shows the 8-digit form when alpha < 255. ## `themeRegions()` infrastructure Per [RFC aethersdr#3076](aethersdr#3076): ```cpp struct ThemeRegion { QString token; std::function<bool(QPoint localPos)> hitTest; QString description; }; void declareWidgetRegions(QWidget* widget, const QList<ThemeRegion>& regions); QStringList tokensAtPoint(const QWidget* widget, const QPoint& localPos) const; ``` `tokensAtPoint()` returns just the tokens whose `hitTest()` matches the click point. Falls back to the coarse `tokensForWidget()` list when no region claims the point — guarantees the inspector always surfaces something useful. ThemeEditorDialog's inspector now routes through `tokensAtPoint()` with proper coordinate-space mapping up the parent chain. SpectrumWidget calls `declareWidgetTokens()` with its 25 painted tokens (background tiers, spectrum trace/peakHold/average/grid, all 8 slice colours + tx, waterfall.colormap, text tiers, accents). Sub-region splits between panadapter, waterfall, and slice triangles are a follow-up. ## Glass-mode backdrop A new `color.background.app` token paints MainWindow's full client area through a custom `paintEvent` override, with `Qt::WA_TranslucentBackground` enabled. Default is opaque (`#0f0f1a` dark, `#f5f5f8` light) — existing installs see zero visual change. But operators can now edit the token via Theme Editor and dial alpha down to watch the desktop show through wherever child widgets don't have explicit opaque fills. This is the unblocking architectural change for a future "glass" theme variant. ## Test plan - [x] Builds clean (`--target all`) - [x] Visual regression check — `color.background.app` defaulted opaque; no diff vs main when theme isn't edited - [x] Alpha picker: dialled `color.accent` down to ~50% opacity — Qt now honours rgba() - [x] Glass-mode A/B: alpha on `color.background.app` showed compositor desktop wallpaper through unclaimed regions (KWin / KDE Plasma X11) - [x] Inspector: clicks on the spectrum/waterfall area now report `SpectrumWidget: 25 tokens` (was: "no tokens registered") - [ ] Glass-mode audit: identifying the widgets that bleed desktop when they shouldn't — separate follow-up PR ## Follow-ups already on the roadmap - Glass-mode audit — add opaque fills to widgets that don't paint their own background (separate PR, open-ended scope) - SpectrumWidget sub-region splits via `declareWidgetRegions()` (panadapter / waterfall / slice triangles) - Gradient editor — in-dialog stop-strip widget for waterfall.colormap and slice.dim (PR 3b) - Phase 5 PR 4: font + sizing pickers, draft layer, delete/rename, reset-to-default 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lat↔gradient conversion + frameless chrome (aethersdr#3158) ## Summary Closes the last visible gap from [RFC aethersdr#3076](aethersdr#3076 Phase 5 — every theme token can now be edited end-to-end through the canonical frameless dialog flow. ## What's new ### Gradient editor `src/gui/GradientEditorDialog.{h,cpp}` — modal dialog with a custom `GradientStrip` widget: - Live preview over a checkerboard (translucent stops stay visible) - Triangle markers draggable to reposition; double-click strip body adds a stop (colour sampled at the click point); right-click marker deletes (refuses below 2 stops) - QListWidget with `+` / `−` / `Edit colour…` buttons for keyboard-driven editing - Angle spinbox for linear gradients - **`Reset to default`** button pulls from a lazy-loaded `:/themes/default-dark.json` snapshot via new `ThemeManager::factoryGradient()` ### Any-token flat ↔ gradient conversion Click any colour token row → small QMenu at cursor with two options reflecting current type: | Current | Menu options | |---|---| | Flat colour | "Edit flat colour…" + "Convert to gradient…" | | Gradient | "Edit gradient…" + "Convert to flat colour…" | `Convert to gradient` seeds a 2-stop gradient with the scalar at both ends — initial render is **bit-identical** to the flat output, so opening the editor doesn't perturb anything until a deliberate edit. `Convert to flat` seeds `QColorDialog` with the gradient's first stop. `ThemeManager` already overwrites the other-type entry on `setColor` / `setGradient`, so the data layer just routes through the right setter. ### Meter-bar gradient New token `color.meter.bar.fillGradient` — linear, angle 0° (bottom→top), 5 stops matching the original hardcoded palette (green / lime / amber / red / deep-red). - `ClientLevelMeter` reads via `ThemeManager::brush(token, stripBox)`, dropping its hand-rolled `QLinearGradient` block - `ClientCompMeter` switches to the same shared token, replacing the `kLevelLo` / `kLevelMid` / `kLevelHi` 3-stop assembly - Both widgets connect to `themeChanged` for live updates Themes can recolour every level meter in the app by editing one gradient. ### Frameless chrome Per `docs/style/dialog-patterns.md`, both editors now subclass `PersistentDialog`: - **Theme Editor** — frameless title bar, 8-axis edge resize, geometry persists under `ThemeEditorDialogGeometry`; MainWindow wireup collapsed from a 10-line lazy-construct dance to `showOrRaisePersistent(m_themeEditorDialog)` - **Gradient Editor** — same chrome; inherits frameless state from `AppSettings::FramelessWindow` at construction. Still modal so the parent's `dlg.exec()` flow is preserved. ### Inspector pauses for modal child dialogs The cyan tracking overlay was leaking on top of the gradient editor (and `QColorDialog`). Fixed: `ThemeInspector::eventFilter` now early-returns and hides its overlay whenever `QApplication::activeModalWidget()` is non-null. The inspector resumes automatically the moment the modal closes. ## Test plan - [x] Builds clean (`--target all`) - [x] `color.waterfall.colormap.fire` → "Edit gradient…" → strip editor opens; drag/add/delete stops repaint the panadapter waterfall live; "Reset to default" restores the bundled stops - [x] `color.accent` → "Convert to gradient…" → strip editor opens seeded with a 2-stop same-colour gradient; edits flow to every `applyStyleSheet`-tracked widget - [x] `color.meter.bar.fillGradient` → gradient editor; edits recolour slice level meters AND compression meters together - [x] Inspector mode + open gradient editor → overlay hides while modal is up, resumes on close - [x] Theme Editor frameless chrome matches the rest of the dialogs ## Follow-ups already on the roadmap - Glass-mode audit (widgets that bleed desktop through with the PR aethersdr#3148 backdrop) - `SpectrumWidget` sub-region splits via `declareWidgetRegions()` (panadapter / waterfall / slice triangles) - `MeterWidget` + `SliceLabel` `declareWidgetTokens()` coverage - Phase 5 PR 4: font + sizing pickers, draft layer, delete/rename, reset-to-default-per-token - Phase 6: `.aethertheme` import/export + drag-and-drop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes the alpha-handling correctness gap that PR #3130's colour picker exposed, lays the inspector-coverage groundwork for custom-paint widgets, and adds a single-token "glass mode" backdrop that lets operators dial app translucency without flipping every widget at once.
Alpha pipeline fix
The colour picker returned
QColorwithalpha < 255, but every downstream step quietly stripped it:setColor()QColor::name()defaults to#rrggbb— alpha lost at storagegradientCssFragment()QColor::HexRgbfor stopscssFragment()saveCurrentThemeAs().name()on gradient stops on saveNew helpers
colorToTokenString()andcolorHexToCssFragment()route all four:HexArgbwhen alpha < 255,HexRgbotherwise (bundled-theme strings stay byte-identical)rgba(r, g, b, a)so Qt actually honours alphaTheme Editor swatch now sits over a 2×2 checkerboard so translucent colours are visually distinguishable; hex label shows the 8-digit form when alpha < 255.
themeRegions()infrastructurePer RFC #3076:
tokensAtPoint()returns just the tokens whosehitTest()matches the click point. Falls back to the coarsetokensForWidget()list when no region claims the point — guarantees the inspector always surfaces something useful.ThemeEditorDialog's inspector now routes through
tokensAtPoint()with proper coordinate-space mapping up the parent chain.SpectrumWidget calls
declareWidgetTokens()with its 25 painted tokens (background tiers, spectrum trace/peakHold/average/grid, all 8 slice colours + tx, waterfall.colormap, text tiers, accents). Sub-region splits between panadapter, waterfall, and slice triangles are a follow-up.Glass-mode backdrop
A new
color.background.apptoken paints MainWindow's full client area through a custompaintEventoverride, withQt::WA_TranslucentBackgroundenabled. Default is opaque (#0f0f1adark,#f5f5f8light) — existing installs see zero visual change.But operators can now edit the token via Theme Editor and dial alpha down to watch the desktop show through wherever child widgets don't have explicit opaque fills. This is the unblocking architectural change for a future "glass" theme variant.
Test plan
--target all)color.background.appdefaulted opaque; no diff vs main when theme isn't editedcolor.accentdown to ~50% opacity — Qt now honours rgba()color.background.appshowed compositor desktop wallpaper through unclaimed regions (KWin / KDE Plasma X11)SpectrumWidget: 25 tokens(was: "no tokens registered")Follow-ups already on the roadmap
declareWidgetRegions()(panadapter / waterfall / slice triangles)🤖 Generated with Claude Code