feat(theme): Phase 2 gradient token support (linear + radial)#3084
Merged
Conversation
…, brush + cssFragment + resolve The RFC #3076 gradient token design lands. A color token's value can now be either a scalar (#rrggbb string) or a structured gradient object: { "type": "linear-gradient", "angle": 180, "stops": [ { "at": 0.0, "color": "#000000" }, { "at": 1.0, "color": "#ffffff" } ] } Radial form supported with "type": "radial-gradient" plus "center" (normalised 2-element array) and "radius". Conical deferred — Qt supports it but the use case in radio UI is thin. API extensions on ThemeManager: - QBrush brush(token, bounds = {}) — Qt brush wrapping the gradient mapped onto a paint rect; with an empty rect uses ObjectBoundingMode for stylesheet/QPalette use - QString cssFragment(token) — Qt-stylesheet syntax fragment ("#rrggbb" for scalars, "qlineargradient(...)" / "qradialgradient(...)" for gradients) - resolve(template) now routes through cssFragment(), so existing {{token}} stylesheet templating handles gradient-typed tokens transparently - color(token) keeps working on gradient tokens by returning the first stop as graceful fallback for callers that don't know about gradients yet Angle convention is CSS3-style (0deg = bottom→top, 90deg = left→right, 180deg = top→bottom, 270deg = right→left) so designers can pull values straight from DevTools / CSS. The conversion from CSS angle to Qt's qlineargradient(x1,y1,x2,y2) coordinates is factored into a single linearAngleToEndpoints() helper so brush() and cssFragment() stay in lock-step. default-dark.json bumped to v1.2. Adds the canonical waterfall colormap as the first gradient token — 8 stops covering the RF visualisation range from silent (black) through the cyan-green-yellow band to clipped (white). This validates the full gradient path end-to-end; no consumer wires to it yet (the waterfall widget still uses its hardcoded gradient — that conversion lands with the PanadapterWidget/WaterfallWidget migration in Phase 3). Tests: theme_manager_test exercises gradient parsing, brush construction, cssFragment emission, color() fallback, value() empty- on-gradient behaviour, and the resolve() integration path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
ten9876
added a commit
that referenced
this pull request
May 25, 2026
…nge (#3085) ## Summary Adds the widget→tokens reverse-map infrastructure that powers Phase 5's inspector mode. Phase 5's planned UX is "click the wrong-coloured part of the UI to edit its token" — that requires the manager to remember which tokens paint which widgets after \`resolve()\` substitutes them away. ## Public API additions \`\`\`cpp // Wrap setStyleSheet(resolve(...)) + record the reverse-map. void applyStyleSheet(QWidget*, const QString& template); // Stop tracking a widget (no more themeChanged re-applies). void clearWidgetTracking(QWidget*); // Inspector lookup: tokens referenced by the widget's last template. QStringList tokensForWidget(const QWidget*) const; // Stateless helper — list tokens a template references without applying. static QStringList extractReferencedTokens(const QString&); \`\`\` ## Behaviour notes - **Free live re-theme.** Stylesheet-painted widgets registered through \`applyStyleSheet\` get re-applied automatically when \`themeChanged\` fires. No consumer plumbing required. - **Automatic cleanup.** \`QObject::destroyed\` removes the widget's entry from the map — no dangling pointers. - **Iteration safety.** \`reapplyAllTrackedStyleSheets\` snapshots keys before iterating in case a re-apply triggers widget creation/destruction in side effects. - **Static \`extractReferencedTokens\`** — useful for audit tooling and the Phase 5 editor's "this template would reference these tokens" preview before any widget exists. ## Tests \`theme_manager_test\` extended to verify: - Applied stylesheet is the resolved form, not the template - Reverse-map records exactly the referenced tokens (deduplicated) - \`clearWidgetTracking\` detaches a widget - destroyed-signal cleanup happens (no crash, no leak) - \`extractReferencedTokens\` handles duplicates and empty \`{{}}\` placeholders Test target now uses \`QApplication\` (needed for QWidget signals) and links \`Qt6::Widgets\`. Runs under \`QT_QPA_PLATFORM=offscreen\` in CI. ## Behavior at runtime today Zero visible change. \`applyStyleSheet\` exists but no call site uses it yet. The pilot stylesheet conversion (likely \`SliceLabel\` or \`CommonStyles\`) is the next PR and will be the first consumer. ## Test plan - [x] Linux: theme_manager_test passes locally (worktree build, PASS) - [x] Linux: full AetherSDR target builds clean (391 link steps) - [ ] CI: build / check-paths / check-windows / CodeQL green ## Sequence note This PR is independent of #3084 (gradient support) — they touch the same files but different functions. Whichever lands first, the other rebases trivially. 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
ten9876
added a commit
that referenced
this pull request
May 25, 2026
## Summary **PR 2 of 5** in the Phase 2/3 wrap-up sweep. First paint-code migration, demonstrating the pattern that the remaining meters / panadapter / waterfall PRs follow. ## What changed [src/gui/MeterSlider.h](src/gui/MeterSlider.h) — header-only widget used by TCI + DAX applets for the 4 RX channels + TX combined level/gain controls. 8 hardcoded QColor literals in its paintEvent now resolve through ThemeManager. ## Pattern 1. \`#include "core/ThemeManager.h"\` 2. Connect \`ThemeManager::themeChanged → update()\` in ctor for live re-theme 3. In paintEvent: resolve theme tokens once into locals at the top, use throughout 4. Preserve alpha via \`QColor::setAlpha(N)\` on the resolved colour (translucent level-meter fills, gain-fill overlay) ## Token map | Old hex | New token | Notes | |---|---|---| | \`#0a0a18\` (background) | \`color.background.0\` | meter chrome | | \`#1e2e3e\` (border) | \`color.background.1\` | meter chrome | | \`#0080a0 alpha 120\` (safe level) | \`color.accent.dim\` α=120 | 3-stop heat | | \`#a0a020 alpha 120\` (approaching peak) | \`color.accent.warning\` α=120 | **visible shift: warmer amber** | | \`#c03030 alpha 120\` (clip) | \`color.accent.danger\` α=120 | 3-stop heat | | \`#00b4d8\` (thumb / accent fill) | \`color.accent\` | × 3 sites | The "approaching-peak" mid-range was the only intentional visual shift — old dim olive yellow → canonical bright amber. All other surfaces resolve to the same colour as before (or sub-perceptual canonicalisation shifts). ## Verified locally - [x] Linux build clean (7 affected translation units rebuild + link) - [x] AetherSDR launches without crash (offscreen QPA smoke) - [x] No visible regression in MeterSlider chrome ## Out of scope (planned for PR 3-5) - **ClientCompMeter / ClientLevelMeter / ClientEqOutputFader / ClientCompThresholdFader** — all have **file-scope \`const QColor\` initializations** + paint-event literals. The file-scope pattern needs a different conversion approach (static-inline functions resolving at call time, not at static-init time before ThemeManager is ready). PR 3 batches the remaining meter widgets with spectrum / panadapter / curves. - **Waterfall colormap** — PR 4 (first real consumer of the gradient token from #3084). - **Cleanup** — file-scope-const pattern across remaining widgets, audit verification — PR 5. 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 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
…sdr#3084) ## Summary Adds first-class gradient tokens to the theming subsystem per RFC aethersdr#3076 Phase 2. A color token can now be either a scalar (#rrggbb) or a structured gradient object describing a linear or radial gradient with N stops. ## Schema \`\`\`jsonc // Scalar (unchanged from Phase 1) \"color.accent\": \"#00b4d8\" // Linear gradient \"color.button.idle\": { \"type\": \"linear-gradient\", \"angle\": 180, \"stops\": [ { \"at\": 0.0, \"color\": \"#1a2330\" }, { \"at\": 1.0, \"color\": \"#0a0e14\" } ] } // Radial gradient \"color.led.armed\": { \"type\": \"radial-gradient\", \"center\": [0.5, 0.5], \"radius\": 0.7, \"stops\": [ ... ] } \`\`\` Conical gradients deferred — Qt supports them but the use case in radio UI is thin. ## API additions to ThemeManager - \`QBrush brush(token, bounds = {})\` — Qt brush wrapping the resolved gradient mapped onto a paint rect; empty rect = ObjectBoundingMode - \`QString cssFragment(token)\` — emits \`qlineargradient(...)\` / \`qradialgradient(...)\` for gradients, \`#rrggbb\` for scalars - \`resolve(template)\` now routes through \`cssFragment()\` — existing \`{{token}}\` templating handles gradients transparently - \`color(token)\` returns first stop's colour as graceful fallback on gradient tokens ## Angle convention CSS3-compatible: 0deg=bottom→top, 90deg=left→right, 180deg=top→bottom, 270deg=right→left. \`linearAngleToEndpoints()\` helper keeps brush() and cssFragment() in lock-step. ## default-dark.json v1.2 Adds \`color.waterfall.colormap\` as the first gradient token — 8-stop linear gradient covering the RF visualisation range. Validates the path end-to-end. **No consumer wired yet** — waterfall widget still uses its hardcoded gradient. That conversion lands with the Phase 3 panadapter/waterfall migration. ## Tests theme_manager_test exercises gradient parsing, brush construction, cssFragment emission, color() fallback, value() empty-on-gradient, resolve() routing. ## Behavior at runtime today Zero visible change. Foundation only. ## Test plan - [x] Linux: theme_manager_test passes locally (worktree build, PASS) - [x] Linux: full AetherSDR target builds clean - [ ] CI: build / check-paths / check-windows / CodeQL green 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 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
…nge (aethersdr#3085) ## Summary Adds the widget→tokens reverse-map infrastructure that powers Phase 5's inspector mode. Phase 5's planned UX is "click the wrong-coloured part of the UI to edit its token" — that requires the manager to remember which tokens paint which widgets after \`resolve()\` substitutes them away. ## Public API additions \`\`\`cpp // Wrap setStyleSheet(resolve(...)) + record the reverse-map. void applyStyleSheet(QWidget*, const QString& template); // Stop tracking a widget (no more themeChanged re-applies). void clearWidgetTracking(QWidget*); // Inspector lookup: tokens referenced by the widget's last template. QStringList tokensForWidget(const QWidget*) const; // Stateless helper — list tokens a template references without applying. static QStringList extractReferencedTokens(const QString&); \`\`\` ## Behaviour notes - **Free live re-theme.** Stylesheet-painted widgets registered through \`applyStyleSheet\` get re-applied automatically when \`themeChanged\` fires. No consumer plumbing required. - **Automatic cleanup.** \`QObject::destroyed\` removes the widget's entry from the map — no dangling pointers. - **Iteration safety.** \`reapplyAllTrackedStyleSheets\` snapshots keys before iterating in case a re-apply triggers widget creation/destruction in side effects. - **Static \`extractReferencedTokens\`** — useful for audit tooling and the Phase 5 editor's "this template would reference these tokens" preview before any widget exists. ## Tests \`theme_manager_test\` extended to verify: - Applied stylesheet is the resolved form, not the template - Reverse-map records exactly the referenced tokens (deduplicated) - \`clearWidgetTracking\` detaches a widget - destroyed-signal cleanup happens (no crash, no leak) - \`extractReferencedTokens\` handles duplicates and empty \`{{}}\` placeholders Test target now uses \`QApplication\` (needed for QWidget signals) and links \`Qt6::Widgets\`. Runs under \`QT_QPA_PLATFORM=offscreen\` in CI. ## Behavior at runtime today Zero visible change. \`applyStyleSheet\` exists but no call site uses it yet. The pilot stylesheet conversion (likely \`SliceLabel\` or \`CommonStyles\`) is the next PR and will be the first consumer. ## Test plan - [x] Linux: theme_manager_test passes locally (worktree build, PASS) - [x] Linux: full AetherSDR target builds clean (391 link steps) - [ ] CI: build / check-paths / check-windows / CodeQL green ## Sequence note This PR is independent of aethersdr#3084 (gradient support) — they touch the same files but different functions. Whichever lands first, the other rebases trivially. 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 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
…rsdr#3106) ## Summary **PR 2 of 5** in the Phase 2/3 wrap-up sweep. First paint-code migration, demonstrating the pattern that the remaining meters / panadapter / waterfall PRs follow. ## What changed [src/gui/MeterSlider.h](src/gui/MeterSlider.h) — header-only widget used by TCI + DAX applets for the 4 RX channels + TX combined level/gain controls. 8 hardcoded QColor literals in its paintEvent now resolve through ThemeManager. ## Pattern 1. \`#include "core/ThemeManager.h"\` 2. Connect \`ThemeManager::themeChanged → update()\` in ctor for live re-theme 3. In paintEvent: resolve theme tokens once into locals at the top, use throughout 4. Preserve alpha via \`QColor::setAlpha(N)\` on the resolved colour (translucent level-meter fills, gain-fill overlay) ## Token map | Old hex | New token | Notes | |---|---|---| | \`#0a0a18\` (background) | \`color.background.0\` | meter chrome | | \`#1e2e3e\` (border) | \`color.background.1\` | meter chrome | | \`#0080a0 alpha 120\` (safe level) | \`color.accent.dim\` α=120 | 3-stop heat | | \`#a0a020 alpha 120\` (approaching peak) | \`color.accent.warning\` α=120 | **visible shift: warmer amber** | | \`#c03030 alpha 120\` (clip) | \`color.accent.danger\` α=120 | 3-stop heat | | \`#00b4d8\` (thumb / accent fill) | \`color.accent\` | × 3 sites | The "approaching-peak" mid-range was the only intentional visual shift — old dim olive yellow → canonical bright amber. All other surfaces resolve to the same colour as before (or sub-perceptual canonicalisation shifts). ## Verified locally - [x] Linux build clean (7 affected translation units rebuild + link) - [x] AetherSDR launches without crash (offscreen QPA smoke) - [x] No visible regression in MeterSlider chrome ## Out of scope (planned for PR 3-5) - **ClientCompMeter / ClientLevelMeter / ClientEqOutputFader / ClientCompThresholdFader** — all have **file-scope \`const QColor\` initializations** + paint-event literals. The file-scope pattern needs a different conversion approach (static-inline functions resolving at call time, not at static-init time before ThemeManager is ready). PR 3 batches the remaining meter widgets with spectrum / panadapter / curves. - **Waterfall colormap** — PR 4 (first real consumer of the gradient token from aethersdr#3084). - **Cleanup** — file-scope-const pattern across remaining widgets, audit verification — PR 5. 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 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
Adds first-class gradient tokens to the theming subsystem per RFC #3076 Phase 2. A color token can now be either a scalar (#rrggbb) or a structured gradient object describing a linear or radial gradient with N stops.
Schema
```jsonc
// Scalar (unchanged from Phase 1)
"color.accent": "#00b4d8"
// Linear gradient
"color.button.idle": {
"type": "linear-gradient",
"angle": 180,
"stops": [
{ "at": 0.0, "color": "#1a2330" },
{ "at": 1.0, "color": "#0a0e14" }
]
}
// Radial gradient
"color.led.armed": {
"type": "radial-gradient",
"center": [0.5, 0.5],
"radius": 0.7,
"stops": [ ... ]
}
```
Conical gradients deferred — Qt supports them but the use case in radio UI is thin.
API additions to ThemeManager
Angle convention
CSS3-compatible: 0deg=bottom→top, 90deg=left→right, 180deg=top→bottom, 270deg=right→left. `linearAngleToEndpoints()` helper keeps brush() and cssFragment() in lock-step.
default-dark.json v1.2
Adds `color.waterfall.colormap` as the first gradient token — 8-stop linear gradient covering the RF visualisation range. Validates the path end-to-end. No consumer wired yet — waterfall widget still uses its hardcoded gradient. That conversion lands with the Phase 3 panadapter/waterfall migration.
Tests
theme_manager_test exercises gradient parsing, brush construction, cssFragment emission, color() fallback, value() empty-on-gradient, resolve() routing.
Behavior at runtime today
Zero visible change. Foundation only.
Test plan
73, Jeremy KK7GWY & Claude (AI dev partner)
🤖 Generated with Claude Code