Skip to content

feat(theme): Phase 2 gradient token support (linear + radial)#3084

Merged
ten9876 merged 1 commit into
mainfrom
auto/theme-phase2-gradients
May 24, 2026
Merged

feat(theme): Phase 2 gradient token support (linear + radial)#3084
ten9876 merged 1 commit into
mainfrom
auto/theme-phase2-gradients

Conversation

@ten9876

@ten9876 ten9876 commented May 24, 2026

Copy link
Copy Markdown
Collaborator

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

  • `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

  • Linux: theme_manager_test passes locally (worktree build, PASS)
  • 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

…, 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>
@ten9876 ten9876 requested review from a team as code owners May 24, 2026 23:26
@ten9876 ten9876 merged commit 030d82d into main May 24, 2026
4 checks passed
@ten9876 ten9876 deleted the auto/theme-phase2-gradients branch May 24, 2026 23:44
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant