feat(theme): Phase 3 PR 2/5 — MeterSlider paint-code migration#3106
Merged
Conversation
First Phase 3 paint-code migration, demonstrating the pattern that
subsequent meter / panadapter / waterfall PRs follow.
MeterSlider (header-only widget used by TCI + DAX applets for the 4
RX channels + TX combined level/gain controls) had 8 hardcoded QColor
literals in its paintEvent. Each one now resolves through
ThemeManager:
- background.0 / background.1 → meter chrome
- color.accent → thumb line, triangle, gain fill
- color.accent.dim / .warning / .danger → 3-stop level-meter heat
(safe / approaching-peak / clip)
Pattern:
1. #include "core/ThemeManager.h"
2. Connect ThemeManager::themeChanged → update() in constructor for
live re-theme on theme switch
3. In paintEvent, resolve theme tokens once into local QColors at
the top, then use them throughout the paint code
4. Preserve alpha by calling QColor::setAlpha(N) on the resolved
colour (translucent level-meter fills, gain-fill overlay)
The "no-canonical-yellow-warning" decision: the prior hardcoded mid-
range fill colour was QColor(0xa0, 0xa0, 0x20, 120) — a custom dim
yellow that doesn't match any canonical token exactly. Mapped to
color.accent.warning which is canonical bright amber #ffb84d at alpha
120. Slight visual shift (warmer/brighter mid-range than before) —
acceptable; Phase 5 editor will let users tune.
Verified: Linux build clean (7 affected translation units rebuilt and
linked), AetherSDR launches without crash. Visible diff: minor —
level meter approaching-peak band shifts from dim olive yellow to
brighter amber. All other meter chrome resolves to the same colours
as before (or sub-perceptual shifts where canonical tokens absorbed
close variants).
Out of scope for this PR (planned for PR 3-5 of the sweep):
- 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
that resolve at call time, not at static-init time before
ThemeManager is ready). PR 3 batches the remaining meter
widgets with the spectrum / panadapter / curve widgets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ten9876
added a commit
that referenced
this pull request
May 25, 2026
…migration, canonical table closeout (#3117) ## Summary **PR 5 of 5** in the Phase 2/3 wrap-up — closes the mechanical migration sweep. 159 \`QColor\` literals migrated across 11 widget files via the now-mature \`migrate_paint_colours.py\` tool, plus a closing 50-entry canonical-table expansion. ## Files migrated (11) | File | Literals | Notes | |---|---|---| | \`SpectrumWidget\` | 40 | Residual paint code from #3113 | | \`ClientChainWidget\` | 20 | | | \`StripChainWidget\` | 20 | | | \`ClientRxChainWidget\` | 16 | | | \`StripRxChainWidget\` | 16 | | | \`WaveformWidget\` | 15 | rx + strip-mode share the same palette | | \`StripWaveform\` | 15 | | | \`KeyboardMapWidget\` | 8 | 10 \`categoryColor()\` returns left as-is (see Skips) | | \`ClientCompThresholdFader\` | 5 | Meter gradient stops deferred from #3116 | | \`ClientCompEditorCanvas\` | 3 | | | \`ClientGateCurveWidget\` | 1 | Translucent cyan fill deferred from #3116 | ## Canonical-table additions — 50 new close-mappings Three logical groups in \`tools/migrate_colours.py\`, each entry annotated with its sibling canonical value and ΔRGB so the rationale is auditable later: \`\`\`python # Spectrum paint code (25) \"#80d0ff\": \"color.accent.bright\", # peak hold light cyan \"#ff5858\": \"color.accent.danger\", # bright alert red \"#ffc040\": \"color.accent.warning\", # warning amber, ΔRGB≈0 from #ffb84d # … 22 more # Chain / waveform (11) \"#587890\": \"color.text.label\", # waveform centerline \"#ffd166\": \"color.accent.warning\", # peak-hold, ΔRGB≈1 from #ffd070 # … 9 more # Threshold fader / gate gradient stops (6) \"#2f9e6a\": \"color.accent.success\", # comp meter lo green \"#f2362a\": \"color.accent.danger\", # comp meter peak red \"#50b4dc\": \"color.accent.dim\", # gate curve translucent cyan # … 3 more \`\`\` ## Build verified clean - [x] AetherSDR builds clean - [x] All 308 targets build clean (every test target) - Two warnings surface (\`sliceColor\` / \`effectiveGridStepMhz\` unused) — both pre-existing on \`main\`, confirmed via local stash bisect ## Intentional skips (deferred to Phase 4) 1. **10 \`KeyboardMapWidget::categoryColor()\` returns** — semantically distinct hues for Frequency / TX / Audio / DSP / etc. categories. No shared visual language applies; converting would homogenize them and defeat the categorization. Left as raw \`QColor\`. 2. **1 \`ClientCompMeter\` no-go-zone tint (\`#3a1810\`)** — unique meter visual, no canonical equivalent worth absorbing. 3. **Slice indicator runtime theming** — \`SliceColorManager\` still reads from the compile-time \`kSliceColors[]\` table in \`SliceColors.h\`. Default-dark.json already has \`color.slice.a\` through \`.h\` tokens, but wiring \`SliceColorManager\` to read them requires touching \`SpectrumWidget\` overlay rendering + \`VfoWidget\` badges + \`RxApplet\`. Warrants its own PR. 4. **Waterfall colormap runtime theming** — \`color.waterfall.colormap\` gradient token is already shipped in default-dark.json (validating gradient-token support added in Phase 2), but the \`SpectrumWidget\` waterfall renderer hard-codes its colormap stop set. Conversion warrants its own PR. ## Phase 2/3 closeout This closes the **5-PR Phase 2/3 wrap-up** as constrained. Recap: | PR | What | Status | |---|---|---| | #3102 | Mass migration + token taxonomy | merged | | #3106 | MeterSlider pilot (Phase 3 PR 2/5) | merged | | #3113 | Paint-color tool + SpectrumWidget (PR 3/5) | merged | | #3116 | Comp/gate/curve widgets (PR 4/5) | merged | | **#XXXX** | **Spectrum + chain + waveform closeout (PR 5/5)** | **this PR** | Phase 4 picks up: default-light theme + slice indicator runtime theming + waterfall colormap runtime theming. ## Test plan - [ ] CI: build / check-paths / check-windows / CodeQL - [ ] Visual smoke: open the app and confirm the spectrum + chain + waveform widgets render to-within-ΔRGB-of-50 of current main (the 159 migrated literals resolve to canonical token values that fall within audited deviation thresholds) 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
… Theme.h helper (#3118) ## Summary Pre-consolidation the codebase had **eight near-duplicate \`QSlider\` stylesheet constants** scattered across the applet family. Same slider widget, different look in three adjacent applets: | Style | Files | Distinctive properties | |---|---|---| | A — Cyan handle | TxApplet, PhoneApplet, PhoneCwApplet, RxApplet | groove \`#203040\`, handle 10×10 \`#00b4d8\`, no fill | | B — Sub-page + bordered | WaveApplet | sub-page cyan, handle 12×12 light + cyan border | | C — Hover-cyan | AetherDspWidget, DspParamPopup | groove \`#304050\`, handle 12×12 light, hover→cyan | | D — Plain light | VfoWidget | groove \`#1a2a3a\`, handle 12×12 light, no hover | | E — Themed cyan | PanadapterApplet (CW sens) | already token-based but cyan-handle look | | F — Themed amber | StripFinalOutputPanel, StripWaveformPanel | sub-page amber for TX state | | G — Tiny gray | PanadapterApplet (Lo/Hi range) | tiny gray handle — **kept** as deliberate de-emphasis | | H — Plain light variant | SpectrumOverlayMenu | yet another local variant | ## The canonical look (per Jeremy's design call) > Sub-page fill from Wave-style value indicator + Plain light handle from Vfo-style. \`\`\` groove: 4px, {{color.background.1}}, radius 2px sub-page: {{color.accent}} (or caller-supplied token), radius 2px handle: 12×12, {{color.text.primary}}, radius 6px — no border, no hover \`\`\` ## API Two helpers added to [Theme.h](src/gui/Theme.h): \`\`\`cpp namespace Theme { QString primarySliderStyleTemplate(const QString& accentToken = \"color.accent\"); void applyPrimarySliderStyle(QWidget* slider, const QString& accentToken = \"color.accent\"); } \`\`\` \`applyPrimarySliderStyle()\` routes through \`ThemeManager::applyStyleSheet()\` so each slider gets: - Token-resolved colours - **Free live re-theme** on theme change via the reverse-map - Both horizontal and vertical orientation rules Variant tokens — the two transmit-adjacent Strip panels pass \`color.accent.warning\` for the amber sub-page that signals TX state. Same shape, different colour token; no per-call-site stylesheet needed. ## Files migrated (13) - \`Theme.h\` — new helpers + updated default \`QSlider\` rules in \`appStylesheetTemplate()\` - \`TxApplet\` / \`PhoneApplet\` / \`PhoneCwApplet\` / \`RxApplet\` — Style A → canonical - \`WaveApplet\` — Style B → canonical (handle simplified) - \`AetherDspWidget\` / \`DspParamPopup\` — Style C → canonical (hover state dropped) - \`VfoWidget\` — Style D → canonical (now also has the sub-page indicator) - \`PanadapterApplet\` (CW sens slider only) — Style E → canonical - \`SpectrumOverlayMenu\` — both local Style H variants → canonical - \`StripFinalOutputPanel\` / \`StripWaveformPanel\` — Style F → canonical with \`color.accent.warning\` amber ## Intentionally untouched (2) - **\`PanadapterApplet\` Lo/Hi range sliders** — Style G is a deliberate visual de-emphasis for secondary range controls; the tiny gray handle is a load-bearing visual cue, not drift. - **\`MeterSlider\`** — already its own widget for VU/level meters with its own canonical style (#3106). ## Net code reduction | | | |---|---| | Lines added | 108 | | Lines removed | 134 | | Net | **-26 lines** | | kSliderStyle constants deleted | 8 | | QSlider rules extracted out of dialog/widget mega-stylesheets | 3 | ## Build verified clean - [x] AetherSDR builds clean (615 ninja steps, no errors) - [x] All 320 targets including every test target ## Phase 4 readiness When the default-light theme ships, slider colours adapt automatically — every site reads through the same three tokens (\`color.background.1\`, \`color.accent\`/\`color.accent.warning\`, \`color.text.primary\`). No follow-up sweep needed. ## Test plan - [ ] CI: build / check-paths / check-windows / CodeQL - [ ] Visual smoke: confirm the TX Controls, Wave, RX, Phone, CW, Vfo, AetherDSP, Quindar editor, and Test Tone dialog all render the same canonical slider; confirm StripFinalOutput + StripWaveform sliders render with amber sub-page fill (TX-state visual) 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>
G6PWY-Chris
pushed a commit
to G6PWY-Chris/AetherSDR
that referenced
this pull request
Jun 22, 2026
…migration, canonical table closeout (aethersdr#3117) ## Summary **PR 5 of 5** in the Phase 2/3 wrap-up — closes the mechanical migration sweep. 159 \`QColor\` literals migrated across 11 widget files via the now-mature \`migrate_paint_colours.py\` tool, plus a closing 50-entry canonical-table expansion. ## Files migrated (11) | File | Literals | Notes | |---|---|---| | \`SpectrumWidget\` | 40 | Residual paint code from aethersdr#3113 | | \`ClientChainWidget\` | 20 | | | \`StripChainWidget\` | 20 | | | \`ClientRxChainWidget\` | 16 | | | \`StripRxChainWidget\` | 16 | | | \`WaveformWidget\` | 15 | rx + strip-mode share the same palette | | \`StripWaveform\` | 15 | | | \`KeyboardMapWidget\` | 8 | 10 \`categoryColor()\` returns left as-is (see Skips) | | \`ClientCompThresholdFader\` | 5 | Meter gradient stops deferred from aethersdr#3116 | | \`ClientCompEditorCanvas\` | 3 | | | \`ClientGateCurveWidget\` | 1 | Translucent cyan fill deferred from aethersdr#3116 | ## Canonical-table additions — 50 new close-mappings Three logical groups in \`tools/migrate_colours.py\`, each entry annotated with its sibling canonical value and ΔRGB so the rationale is auditable later: \`\`\`python # Spectrum paint code (25) \"#80d0ff\": \"color.accent.bright\", # peak hold light cyan \"#ff5858\": \"color.accent.danger\", # bright alert red \"#ffc040\": \"color.accent.warning\", # warning amber, ΔRGB≈0 from #ffb84d # … 22 more # Chain / waveform (11) \"#587890\": \"color.text.label\", # waveform centerline \"#ffd166\": \"color.accent.warning\", # peak-hold, ΔRGB≈1 from #ffd070 # … 9 more # Threshold fader / gate gradient stops (6) \"#2f9e6a\": \"color.accent.success\", # comp meter lo green \"#f2362a\": \"color.accent.danger\", # comp meter peak red \"#50b4dc\": \"color.accent.dim\", # gate curve translucent cyan # … 3 more \`\`\` ## Build verified clean - [x] AetherSDR builds clean - [x] All 308 targets build clean (every test target) - Two warnings surface (\`sliceColor\` / \`effectiveGridStepMhz\` unused) — both pre-existing on \`main\`, confirmed via local stash bisect ## Intentional skips (deferred to Phase 4) 1. **10 \`KeyboardMapWidget::categoryColor()\` returns** — semantically distinct hues for Frequency / TX / Audio / DSP / etc. categories. No shared visual language applies; converting would homogenize them and defeat the categorization. Left as raw \`QColor\`. 2. **1 \`ClientCompMeter\` no-go-zone tint (\`#3a1810\`)** — unique meter visual, no canonical equivalent worth absorbing. 3. **Slice indicator runtime theming** — \`SliceColorManager\` still reads from the compile-time \`kSliceColors[]\` table in \`SliceColors.h\`. Default-dark.json already has \`color.slice.a\` through \`.h\` tokens, but wiring \`SliceColorManager\` to read them requires touching \`SpectrumWidget\` overlay rendering + \`VfoWidget\` badges + \`RxApplet\`. Warrants its own PR. 4. **Waterfall colormap runtime theming** — \`color.waterfall.colormap\` gradient token is already shipped in default-dark.json (validating gradient-token support added in Phase 2), but the \`SpectrumWidget\` waterfall renderer hard-codes its colormap stop set. Conversion warrants its own PR. ## Phase 2/3 closeout This closes the **5-PR Phase 2/3 wrap-up** as constrained. Recap: | PR | What | Status | |---|---|---| | aethersdr#3102 | Mass migration + token taxonomy | merged | | aethersdr#3106 | MeterSlider pilot (Phase 3 PR 2/5) | merged | | aethersdr#3113 | Paint-color tool + SpectrumWidget (PR 3/5) | merged | | aethersdr#3116 | Comp/gate/curve widgets (PR 4/5) | merged | | **#XXXX** | **Spectrum + chain + waveform closeout (PR 5/5)** | **this PR** | Phase 4 picks up: default-light theme + slice indicator runtime theming + waterfall colormap runtime theming. ## Test plan - [ ] CI: build / check-paths / check-windows / CodeQL - [ ] Visual smoke: open the app and confirm the spectrum + chain + waveform widgets render to-within-ΔRGB-of-50 of current main (the 159 migrated literals resolve to canonical token values that fall within audited deviation thresholds) 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
… Theme.h helper (aethersdr#3118) ## Summary Pre-consolidation the codebase had **eight near-duplicate \`QSlider\` stylesheet constants** scattered across the applet family. Same slider widget, different look in three adjacent applets: | Style | Files | Distinctive properties | |---|---|---| | A — Cyan handle | TxApplet, PhoneApplet, PhoneCwApplet, RxApplet | groove \`#203040\`, handle 10×10 \`#00b4d8\`, no fill | | B — Sub-page + bordered | WaveApplet | sub-page cyan, handle 12×12 light + cyan border | | C — Hover-cyan | AetherDspWidget, DspParamPopup | groove \`#304050\`, handle 12×12 light, hover→cyan | | D — Plain light | VfoWidget | groove \`#1a2a3a\`, handle 12×12 light, no hover | | E — Themed cyan | PanadapterApplet (CW sens) | already token-based but cyan-handle look | | F — Themed amber | StripFinalOutputPanel, StripWaveformPanel | sub-page amber for TX state | | G — Tiny gray | PanadapterApplet (Lo/Hi range) | tiny gray handle — **kept** as deliberate de-emphasis | | H — Plain light variant | SpectrumOverlayMenu | yet another local variant | ## The canonical look (per Jeremy's design call) > Sub-page fill from Wave-style value indicator + Plain light handle from Vfo-style. \`\`\` groove: 4px, {{color.background.1}}, radius 2px sub-page: {{color.accent}} (or caller-supplied token), radius 2px handle: 12×12, {{color.text.primary}}, radius 6px — no border, no hover \`\`\` ## API Two helpers added to [Theme.h](src/gui/Theme.h): \`\`\`cpp namespace Theme { QString primarySliderStyleTemplate(const QString& accentToken = \"color.accent\"); void applyPrimarySliderStyle(QWidget* slider, const QString& accentToken = \"color.accent\"); } \`\`\` \`applyPrimarySliderStyle()\` routes through \`ThemeManager::applyStyleSheet()\` so each slider gets: - Token-resolved colours - **Free live re-theme** on theme change via the reverse-map - Both horizontal and vertical orientation rules Variant tokens — the two transmit-adjacent Strip panels pass \`color.accent.warning\` for the amber sub-page that signals TX state. Same shape, different colour token; no per-call-site stylesheet needed. ## Files migrated (13) - \`Theme.h\` — new helpers + updated default \`QSlider\` rules in \`appStylesheetTemplate()\` - \`TxApplet\` / \`PhoneApplet\` / \`PhoneCwApplet\` / \`RxApplet\` — Style A → canonical - \`WaveApplet\` — Style B → canonical (handle simplified) - \`AetherDspWidget\` / \`DspParamPopup\` — Style C → canonical (hover state dropped) - \`VfoWidget\` — Style D → canonical (now also has the sub-page indicator) - \`PanadapterApplet\` (CW sens slider only) — Style E → canonical - \`SpectrumOverlayMenu\` — both local Style H variants → canonical - \`StripFinalOutputPanel\` / \`StripWaveformPanel\` — Style F → canonical with \`color.accent.warning\` amber ## Intentionally untouched (2) - **\`PanadapterApplet\` Lo/Hi range sliders** — Style G is a deliberate visual de-emphasis for secondary range controls; the tiny gray handle is a load-bearing visual cue, not drift. - **\`MeterSlider\`** — already its own widget for VU/level meters with its own canonical style (aethersdr#3106). ## Net code reduction | | | |---|---| | Lines added | 108 | | Lines removed | 134 | | Net | **-26 lines** | | kSliderStyle constants deleted | 8 | | QSlider rules extracted out of dialog/widget mega-stylesheets | 3 | ## Build verified clean - [x] AetherSDR builds clean (615 ninja steps, no errors) - [x] All 320 targets including every test target ## Phase 4 readiness When the default-light theme ships, slider colours adapt automatically — every site reads through the same three tokens (\`color.background.1\`, \`color.accent\`/\`color.accent.warning\`, \`color.text.primary\`). No follow-up sweep needed. ## Test plan - [ ] CI: build / check-paths / check-windows / CodeQL - [ ] Visual smoke: confirm the TX Controls, Wave, RX, Phone, CW, Vfo, AetherDSP, Quindar editor, and Test Tone dialog all render the same canonical slider; confirm StripFinalOutput + StripWaveform sliders render with amber sub-page fill (TX-state visual) 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
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 — 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
Token map
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
Out of scope (planned for PR 3-5)
73, Jeremy KK7GWY & Claude (AI dev partner)
🤖 Generated with Claude Code