Skip to content

Commit 52b48fc

Browse files
DIMOSUSclaude
andauthored
Light theme: one palette owns every colour, and the title bar chooses which one (#200)
The light theme itself is the small part. The work was taking colour away from the 1100 places that decided it by hand and giving it to one file. **`UiThemePalette` is the only place a colour is built.** 139 roles named for the job they do — `ControlSurface`, `TextSecondary`, `CurveExcessPhase`, `MarkerFirstArrival`, `PlotZoomBoxStroke` — each answered by BOTH themes. The properties are `required`, so a role cannot be added to one theme and forgotten in the other, and `UiPalette` is the static face of whichever palette is in force. Call sites, `.Designer.cs` included, read `UiPalette.<Role>`; plot code converts with the new `.ToOxy()`. What that swept up: 604 `Color.FromArgb` literals and 346 `Color.White` in 22 designer files, 148 `SystemColors.ControlLight` label foregrounds, ~290 OxyPlot colours across 19 files, the band-strip tints, the fader cap gradients, the meter, the trash cursor. `UiPaletteCoverageTests` now fails the build on a colour built out of numbers or framework names anywhere under `source/`; its allow list names the six files that keep theirs and why (the user's own curve colours, PDF sheets on paper, OxyPlot's defaults compared against). It has already earned its keep: it caught the three literals the Auto Crossover dialog brought in when main was merged, which no eye was going to find in a 140-file branch. **Near-duplicates collapsed into roles rather than being carried over twice.** Twelve greys of text became a ladder of six; two button fills (50,55,80 and 46,51,67) and the dialog button grey became one `ButtonBackground`; nine greens became `Success`; four ambers became `Warning`. The plot surfaces went from the four TODO.md complained about to two: `GraphSurface` (main, wizard, Virtual DSP) and `GraphSurfaceMuted` (the previews). The satellite plots — Time Alignment, `AngleCalibrationDialog`, `ImpulseWindowPreview`, `OverlayTargetSettingsDialog`, `MeasurementHistoryWindow` — no longer carry their own chrome: they go through `PlotModelStyle.CreatePreviewModel` and `AddAxis`/`StyleAxis`, which is now the single home of what a plot looks like. **The twelve overlay slots got designed colours.** They came from `new Random(3)`, and that colour becomes a curve colour the moment the slot is captured into. Seeded, so deterministic, but nobody chose it: five of the twelve sit under 3:1 against the DARK plot as shipped — slot 12 reads 1.23:1, slot 6 1.56:1 — and on white the bright green slot 4 falls to 1.47:1. They are `OverlaySlotDefaults` in the palette now, twelve per theme, the weakest 4.26:1. The dark slot colours therefore change; a slot that already holds a capture keeps the colour in its file. **The theme is chosen at startup.** A designer reads the palette inside `InitializeComponent`, so `Program.Main` calls `UiPalette.Use` before the first control exists; the gear button in the title bar opens Settings, and switching the theme offers a restart. `Application.Restart` cannot do that here — the shell cancels its first close to await the audio aborts, so the replacement would meet a `SingleInstanceGuard` the old process still owns — so the request is recorded and carried out after `Application.Run` returns and the guard is released. The choice lives in its own `appearance.json`, written through `AtomicFile` so a failed write leaves the previous theme rather than an empty file, and a failed write does not offer a restart into a theme that was not saved. The gear is drawn rather than typed: at 13 px the U+2699 glyph falls back to the colour emoji font and reads as a flower. Candidates were rendered side by side — the glyph with and without the text-presentation selector, in Segoe UI Symbol and in Segoe MDL2 Assets, against a drawn one — and only the drawn one is legible at that size. It takes the button's own ink, so it follows the theme. `DarkComboBox`, `DarkNumericUpDown` and `DarkScrollBars` became `Themed*` — the scrollbars now ask the OS for the light or dark native theme to match, with the app-mode value chosen per Windows build (ordinal 135 is `AllowDarkModeForApp` on 1809 and `SetPreferredAppMode` from 1903, where ForceLight is 3, not 1). Held to the same floors as the dark theme where it is honest to say so: `UiPaletteContrastTests` runs its whole table against both palettes, plus every `Curve*`/`Marker*` role, all twelve Virtual DSP channel colours and all twelve overlay slot defaults against the plot surface. The dark analysis curves predate that floor — the weakest, violet HD3 on deep blue, reads 1.58:1 — so dark is held where it stands, REFERENCE.md says so instead of claiming parity, and the gap is written down in TODO.md rather than quietly retuned. Deliberately untouched: colours the user chose (captured overlay slots, Virtual DSP channels in a saved session, the EQ Wizard target) stay exactly as they are in their files. What follows the theme is the colour a slot or target STARTS in. Tuning sheet PDFs are printed on paper and have no theme. Verified beyond the test suite with a scratchpad render harness: every constructible panel, dialog and control drawn to a bitmap and hashed before and after the sweep, so the dark theme is provably unchanged except where a merge was intended — the diffs are the merged values and nothing else. The same harness drew both themes of the shell, the panels, the settings dialog and the title bar for the visual passes. One test-harness fix rides along. `FirConstructorTests` failed once on CI and never locally, and the state it reported — design and kernel replaced, `RebuildPending` already false, the button still disabled — has exactly one cause: the panel publishes that flag one statement before `ApplyRendering` re-enables the button, which is invisible while both run on the UI thread. It stops being invisible when the continuation resumes on the thread pool, and WinForms only installs a `WindowsFormsSynchronizationContext` while the process-wide `AutoInstall` is on — which a test running beside it can turn off. `StaTest.Run` installs the context itself now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3738743 commit 52b48fc

155 files changed

Lines changed: 3927 additions & 2478 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,16 @@ Enforced by `.editorconfig`; notable deviations from common C# defaults:
218218
- CRLF line endings, 4-space indent, Allman braces, braces always.
219219
- New non-UI code uses file-scoped namespaces (see `Program.cs`, `ModeController.cs`).
220220
- Keep static WinForms controls in `.Designer.cs`. For genuinely dynamic controls, use a designer-defined `TableLayoutPanel` or `FlowLayoutPanel`; avoid absolute 96-DPI coordinates because controls created after `InitializeComponent` miss designer autoscaling.
221+
- **Colour comes from the palette, by role.** `UiThemePalette` holds every colour the app paints, named for the
222+
job it does (`ControlSurface`, `TextSecondary`, `CurveExcessPhase`), and answers it in BOTH themes — the
223+
properties are `required`, so a new role cannot be added to one theme and forgotten in the other. Call sites,
224+
including `.Designer.cs` files, read `UiPalette.<Role>`; plot code converts with `.ToOxy()`. Nothing anywhere
225+
else builds a colour out of numbers or framework names, and `UiPaletteCoverageTests` fails the build if it
226+
does (its allow list names the few exceptions and why: the user's own curve colours, PDF sheets on paper,
227+
OxyPlot's defaults compared against). The theme is chosen once in `Program.Main` before the first control is
228+
built, because a designer reads the palette inside `InitializeComponent`; changing it asks for a restart.
229+
Plot chrome has one home, `PlotModelStyle``ApplyChrome`/`CreatePreviewModel` and `AddAxis`/`StyleAxis`;
230+
do not set an axis colour by hand.
221231
- Buttons, checkboxes and radio buttons are `ReleaseClickButton` / `ReleaseClickCheckBox` / `ReleaseClickRadioButton`, never the WinForms types they derive from — in the designer as well as in code. WinForms puts a `WindowFromPoint` ownership check in its mouse-release click path — one of several conditions, alongside cancelled validation and `ButtonBase`'s own press/capture state — so any window overlapping that one pixel (a tooltip, above all — measured: 20 of 20 clicks lost) takes the click silently while the control still paints its press. `ReleaseClick*` works around that hit-test failure and only it, and does not decide anything itself: when the release point belongs to another window, the release is handed to the framework at another free point on the same control, so the framework still answers whether a click is due and every other condition it withholds one under keeps applying. A release outside the control, or a control covered edge to edge, is left exactly as it arrived. A menu is then opened through `DropDownMenu`, which posts the show clear of the mouse message and guards it against the focus change opening it causes.
222232
- Comments stay under roughly 10% of code lines; every line of them is read, and paid for, by each agent that opens the file. See **Comments** below.
223233
- Every WinForms container scales with `AutoScaleMode.Dpi` and `AutoScaleDimensions = (96, 96)` — do not go back to `AutoScaleMode.Font`. Font autoscaling is anisotropic: at 120 DPI it widens boxes by the average character width (7→8, ×1.14) while the glyphs themselves grow ×1.25, so labels, radios and buttons were clipped across the app at 125%. DPI scaling uses one uniform `DeviceDpi / 96` on both axes, which is the ratio the text grows by. Designer slack therefore scales with the text: leave a few pixels beside a label rather than sizing a box to its exact 96-DPI extent.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ is set to show no animations (Settings → Accessibility → Visual effects).
278278
- **Signal Generator**, **Measurement History** with per-entry working state, a
279279
compact Mic/Loop level meter, and four audio backends (MME Compatibility, ASIO,
280280
WASAPI Shared and Exclusive) with backend-specific channel routing
281+
- **Dark and light themes** — the gear button in the title bar opens Settings;
282+
the theme colours every panel, dialog, graph and curve
281283

282284
## Gallery
283285

REFERENCE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ read-out refuses rather than guesses, what a number was measured against.
1111

1212
## Contents
1313

14+
- [Application Settings](#application-settings)
1415
- [Graph Zoom and Limits](#graph-zoom-and-limits)
1516
- [Mode Settings](#mode-settings)
1617
- [Phase and Group Delay](#phase-and-group-delay)
@@ -60,6 +61,36 @@ read-out refuses rather than guesses, what a number was measured against.
6061

6162
---
6263

64+
## Application Settings
65+
66+
The gear button in the title bar, beside minimize/maximize/close, opens the
67+
settings that belong to the whole application rather than to one mode (a mode
68+
keeps its own settings behind **Mode Settings...**).
69+
70+
**Theme****Dark** (the default) or **Light**. The theme decides every colour
71+
the program paints: panels, dialogs, the plot surface, the axes and grid, the
72+
curves, the level meter and the faders. Text and panels are held to the same
73+
contrast floors in both themes. The light theme also holds every curve at the
74+
3:1 floor for a graphical object; a few of the dark analysis curves (the third
75+
and fourth harmonic above all) have sat under it since long before the light
76+
theme existed, and they are left as they are rather than retuned here.
77+
78+
The theme is read once, when Resonalyze starts, because every control takes its
79+
colours as it is built. Changing it therefore offers a restart; decline it and
80+
the new theme simply appears the next time you open the program. The choice is
81+
stored in `appearance.json` next to the rest of your settings.
82+
83+
Three things the theme deliberately does **not** touch:
84+
85+
- **Colours you chose yourself** — a captured overlay slot's colour, a Virtual DSP
86+
channel's colour in a saved session, the EQ Wizard target colour. They live in
87+
your files and stay exactly as you set them. What does follow the theme is the
88+
colour a slot or a target STARTS in: each of the twelve overlay slots has its
89+
own, picked per theme and held apart from its neighbours, so a curve captured
90+
in the light theme is born readable on white.
91+
- **Tuning sheets** — a PDF is printed on paper, which has no theme.
92+
- **The figures in this documentation**, which were taken in the dark theme.
93+
6394
## Graph Zoom and Limits
6495

6596
The analysis plot, the Time Alignment previews, the EQ Wizard and the Virtual DSP

TODO.md

Lines changed: 13 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -433,27 +433,6 @@ next field session rather than in a register nobody else can tick.
433433
would change appearance everywhere, not just while loading. Owner looked at a
434434
rendered comparison on 2026-08-24 and chose to leave it; take it as its own
435435
change with its own visual pass, not as a rider on something else.
436-
- [ ] **A light theme is wanted eventually, and 650 colour assignments do not go
437-
through `UiPalette`** — 504 `Color.FromArgb` literals across the `.Designer.cs`
438-
files plus 146 `SystemColors.ControlLight` label foregrounds.
439-
The contrast work (#116) named the roles the app paints with — `AccentFill`,
440-
`TextDisabled`, the `Graph*` chrome — and put the graph surface and the accent
441-
buttons' fill under the palette, so the SEAMS now exist:
442-
`PlotModelStyle.ApplyChrome` is the one place a plot's colours are decided,
443-
and `UiPaletteContrastTests` re-measures whatever values a second theme
444-
brings. What is left for the theme itself is the designer sweep, and it is
445-
smaller than the count suggests: only **35 distinct values** appear in those
446-
files and four of them cover 329 of the 504, so a dark→light map plus a
447-
runtime pass over the control tree covers most of it. The judgment part is
448-
not the chrome but the CURVES: `OxyColors.White` sums, white THD traces,
449-
light-grey source curves and the user's own overlay slot colours (persisted
450-
as `ColorArgb` in overlay files, so they cannot be rewritten) all need a
451-
second palette or a luminance-adaptive fallback before a light plot is
452-
readable. Do not start this as a colour swap; start it as a curve-palette
453-
design. Half the palette also still carries PHYSICAL names (`AccentBlueSoft`,
454-
`TextSecondaryAlt`, `SuccessGreenSoft`): rename them to their roles as they
455-
are touched rather than in one sweep — `AccentBlueSoft` is the accent MARK
456-
(links, focus borders, selection markers), which is the one that matters.
457436
- [ ] **Do not let the shell re-assign a docked panel's `Padding`.** Both tool
458437
panels declare `Padding = new Padding(6)` in their OWN designer, where it scales
459438
with the rest of the arrangement; `Form1.Designer.cs` used to set the same
@@ -466,31 +445,19 @@ next field session rather than in a register nobody else can tick.
466445
this defect returning. `ItsPadding_ScalesWithTheArrangement` in both layout
467446
suites pins the panel's own half of it; nothing can pin the shell's.
468447

469-
- [ ] **The app paints plots on FOUR different surfaces, three of them designer
470-
literals.** `UiPalette.GraphSurface` (50,55,100) covers the main plot and the
471-
EQ wizard; Virtual DSP's two views sit on (40,44,80)
472-
(`VirtualCrossoverPanel.Designer.cs`), the option/Time Alignment/history
473-
previews on (32,36,46), and the target preview on (55,58,65). Whether that is
474-
intentional or drift, nobody decided it recently — and it is not cosmetic:
475-
a FIXED chrome colour reads at a different strength on each (the first grid
476-
measured 1.33:1 on the main plots and 1.59:1 on Virtual DSP, which is how it
477-
looked). The grid and the plot border are white-with-alpha now, so they no
478-
longer care; anything else added to a plot has to make the same choice, and a
479-
light theme has to reach all four surfaces. Decide the count first: one
480-
surface token, or a named few.
481-
- [ ] **The satellite plots still carry their own chrome literals.** The Time
482-
Alignment previews, `AngleCalibrationDialog`, `ImpulseWindowPreview`,
483-
`OverlayTargetSettingsDialog` and `MeasurementHistoryWindow` set their own
484-
white text and grid colours rather than going through
485-
`PlotModelStyle.ApplyChrome`. They are readable as they are, so this is
486-
tidiness, not a defect — but they are the reason a plot colour still has more
487-
than one home.
488-
- [ ] **`WarningRed` on a dark surface measures 4.3:1 as text.** It is a FILL
489-
today (meter bars, the fader groove), where no text threshold applies, so
490-
nothing is wrong now — but it reads as the palette's "red" and the next
491-
status line that reaches for it would land under the floor. `ErrorSoft`
492-
(already lifted to 4.6:1) is the text-carrying red; keep them apart, or give
493-
`WarningRed` a text-safe sibling if it is ever needed for one.
448+
- [ ] **Five dark curves sit under the 3:1 floor on the plot surface.** The
449+
light theme is measured at 3:1 by `UiPaletteContrastTests`; the dark curve set
450+
predates that floor and is held where the owner tuned it, so the same test
451+
uses 1.55:1 there — only a regression guard. The weakest are `CurveHarmonic3`
452+
(1.58), `CurveHarmonic4` (1.70), `CurveArrayMicrophone` (2.52), `CurveMuted`
453+
(2.85) and `CurveLiveTransfer` (2.98) against `GraphSurface`. Lifting them is a
454+
visual pass on the dark theme with the owner, not a rider on other work.
455+
- [ ] **`Danger` on a dark surface measures 4.3:1 as text.** It is a FILL today
456+
(meter bars, the fader groove), where no text threshold applies, so nothing is
457+
wrong now — but it reads as the palette's "red" and the next status line that
458+
reaches for it would land under the floor. `Error` (4.6:1) is the
459+
text-carrying red; keep them apart, or give `Danger` a text-safe sibling if it
460+
is ever needed for one.
494461
- [ ] **`ChromeTitleBar` caches the DPI scale once at `Initialize`.** No
495462
`DpiChanged` handling: moving the window to a monitor with different DPI
496463
(PerMonitorV2) leaves the bar height, button widths and tab layout at the old

source/LiveSpectrum/LiveSpectrumController.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ private void UpdateOverloadAnnotation(PlotModel model)
720720
TextFlowDirection = TextFlowDirection.TopDown,
721721
FontSize = 12,
722722
FontWeight = 700,
723-
TextColor = OxyColor.FromRgb(255, 170, 0),
723+
TextColor = UiPalette.Warning.ToOxy(),
724724
TextHorizontalAlignment = OxyPlot.HorizontalAlignment.Center
725725
});
726726
}
@@ -799,7 +799,7 @@ private void UpdateCaptureProgressAnnotation(PlotModel? model)
799799
TextPosition = new DataPoint(0.01, 0),
800800
TextFlowDirection = TextFlowDirection.TopDown,
801801
FontSize = 12,
802-
TextColor = OxyColor.FromRgb(150, 165, 190),
802+
TextColor = UiPalette.TextSecondary.ToOxy(),
803803
TextHorizontalAlignment = OxyPlot.HorizontalAlignment.Left
804804
};
805805
captureProgressOwner = model;
@@ -815,8 +815,8 @@ private void UpdateCaptureProgressAnnotation(PlotModel? model)
815815
? $"{state}{seconds:0} s, {frames} frames, {clipped} clipped"
816816
: $"{state}{seconds:0} s, {frames} frames";
817817
captureProgressAnnotation.TextColor = clipped > 0
818-
? OxyColor.FromRgb(255, 190, 80)
819-
: OxyColor.FromRgb(150, 165, 190);
818+
? UiPalette.Warning.ToOxy()
819+
: UiPalette.TextSecondary.ToOxy();
820820
}
821821

822822
model.Annotations.Add(captureProgressAnnotation);

source/Options/ACOpt.Designer.cs

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)