Repository navigation
The operator can maintain the twelve palette colors of each theme from Settings, with a measured contrast readout and a reset to default (#3577, arm B) - #3606
Conversation
…m Settings, with a measured contrast readout and a reset to default (#3577, arm B) Arm (B) of #3577. Arm (A) (#3589) fixed the two contrast defects the report named; this is the request itself: user-maintainable colors with a reset to default, in Lite and the Darling viewer. - A per-user theme-overrides.json (Lite: beside settings.json; the viewer: beside viewer-settings.json, local to the machine), keyed by theme then by palette color, holding only what the operator changed. - A closed list of twelve of the eighteen palette colors, declared once in PerformanceMonitor.Ui (ThemeColorOverrides.Slots) with the reason the other six are withheld; pinned in Lite.Tests. - ThemeManager regenerates a theme that carries overrides from its embedded XAML text (ThemeXamlRewriter) instead of merging an override dictionary, because the theme's own brushes and every style setter resolve their {StaticResource} at load and a merged dictionary cannot reach them. The compiled BAML stays the path for a theme without overrides. A bad file, key, value or parse falls back to the stock theme with a logged warning; nothing here can take the app down. - A Colors section (shared ThemeColorsPanel) under each Settings window's theme combo: swatch, hex, HSV picker, and the WCAG ratio against the surface each color renders on, colour-coded 4.5 / 3. Warns, never refuses. Apply / Reset to default / Open file; a FileSystemWatcher re-applies outside edits. - WcagContrast brings arm (A)'s out-of-tree script into the Ui project; the tests pin it to the numbers #3589 published. - Docs: Lite README "Themes and colors", a paragraph in the Darling README's viewer section. No Dashboard port (frozen twin).
| { | ||
| text = File.ReadAllText(OverridesFilePath); | ||
| } | ||
| catch (IOException) |
There was a problem hiding this comment.
ReloadFromDiskIfChanged only retries on IOException. Any other exception File.ReadAllText can throw for a file that still passes File.Exists — most notably UnauthorizedAccessException (an ACL change, an AV scan, a sync client like OneDrive/Dropbox briefly locking the file in a way that doesn't surface as IOException) — propagates uncaught out of this method.
This runs from ScheduleReloadFromDisk's DispatcherTimer.Tick handler, on the UI dispatcher thread (dispatcher.BeginInvoke(ScheduleReloadFromDisk)), so an unhandled exception here can crash the app rather than just skip a reload.
This directly contradicts the class's own stated contract in the header doc: "Nothing here can take the app down... An operator who breaks the file gets the stock theme and a log line, not a crash." Every other file read added in this PR (SettingsFileGuard.ReadText, SaveTheme, EnsureOverridesFileExists) catches the broad Exception, consistent with that contract — this is the one spot that doesn't.
Suggest widening the catch here to Exception (or at least adding UnauthorizedAccessException) and reporting through LogWarning the same way the sibling paths do, since a watcher failure is explicitly documented elsewhere as "a convenience... a failure... is a log line and nothing else."
There was a problem hiding this comment.
Agreed — it was the one read in the class outside the never-crash contract, and it sat on a Tick with nobody above it. Fixed in d92892f: the read is behind ThemeManager.OverridesFileReader, IOException still retries up to MaxReloadRetries, and anything else (or retries exhausted) is a LogWarning with the theme left exactly as it was; the Tick handler also carries a broad catch as the last line of defence. Pinned by ReloadFromDisk_WhenTheReadThrowsUnauthorizedAccess_LogsAWarningAndKeepsTheLoadedOverrides (plus the IOException-cap, unchanged-text and no-file cases) in Lite.Tests/ThemeColorOverrideTests.
There was a problem hiding this comment.
Reviewed the theme-color-overrides feature (arm B of #3577): the loader/save/regeneration path in PerformanceMonitor.Ui (ThemeColorOverrides, ThemeXamlRewriter, ThemeManager, WcagContrast, HsvColor), the shared ThemeColorsPanel, and the Lite/Darling wiring (App.xaml.cs, csproj embeds, SettingsWindow.xaml, README updates, and both test suites). No T-SQL in this PR. Overall this is very carefully built — the never-crash-on-a-bad-file discipline, the closed key list, the derived (not hand-maintained) brush-dependency map, and the extensive Lite/Darling parity tests are all solid, and I couldn't find a Lite/Darling drift issue or a security problem (the one Process.Start(UseShellExecute=true) call opens a fixed, app-owned config path, not attacker-controlled input; regex-built-from-user-keys is guarded by Regex.Escape and a closed key list).
- Correctness — one exception path breaks the feature's own "never crashes" contract.
ThemeManager.ReloadFromDiskIfChanged(PerformanceMonitor.Ui/ThemeManager.cs) only retries onIOExceptionwhen re-readingtheme-overrides.jsonafter an outside edit. Any other exceptionFile.ReadAllTextcan throw for a file that still passesFile.Exists(most plausiblyUnauthorizedAccessExceptionfrom an AV scan, ACL change, or a sync client's lock) propagates uncaught out of aDispatcherTimer.Tickcallback running on the UI thread — i.e., it can crash the app. Every other file read this PR adds (SettingsFileGuard.ReadText,SaveTheme,EnsureOverridesFileExists) catches broadly and logs, consistent with the class's own documented guarantee that "an operator who breaks the file gets... a log line, not a crash." This one path doesn't follow that pattern. Left an inline comment with the specific line.
Nothing else rose to blocking; happy to re-review once that's addressed.
…ilure, never a throw (#3577 review) Review on #3606: ReloadFromDiskIfChanged caught only IOException, and it runs from a DispatcherTimer.Tick on the UI thread with no caller above it - an UnauthorizedAccessException from an ACL change, an AV scan or a sync client's lock would have crashed the app over a file watcher, against the class header's own contract. - The read is behind ThemeManager.OverridesFileReader (File.ReadAllText in the app); IOException still retries up to MaxReloadRetries, and anything else - or retries exhausted - is LogWarning + the theme stays as it was. The method returns what it did (ReloadOutcome). - The Tick handler is the last line of defence: a broad catch around the whole reload, for whatever nobody anticipated in the rest of the path. - Pinned in Lite.Tests/ThemeColorOverrideTests: UnauthorizedAccess -> Failed, one warning naming it, loaded overrides untouched; IOException retries to the cap then warns; unchanged text is a no-op; no file configured is a no-op.
Remediated in d92892f: the watcher's reload reads through ThemeManager.OverridesFileReader and catches broadly (IOException retries to MaxReloadRetries, everything else is LogWarning and the theme stays as it was); the Tick handler is the last line of defence with its own broad catch. Pinned by ReloadFromDisk_WhenTheReadThrowsUnauthorizedAccess_LogsAWarningAndKeepsTheLoadedOverrides in Lite.Tests/ThemeColorOverrideTests.
|
Reviewed the diff (PerformanceMonitor.Ui: ThemeColorOverrides/ThemeXamlRewriter/ThemeManager/WcagContrast/HsvColor/ThemeColorsPanel, plus both apps' wiring, tests and READMEs). No T-SQL in this PR, so the collector-query conventions don't apply here. Correctness — traced the regeneration path (text rewrite → Lite/Darling parity — verified directly: both Security — Nothing substantive found — this is a clean, well-tested addition that reuses existing infra ( |
…them (#3577 CI) First CI run: all six RegeneratingATheme_MovesEveryBrushTheXamlDerivesFromAnOverriddenColor cases failed reading SolidColorBrush.Color from the runner's thread - the parsed brushes are unfrozen DispatcherObjects owned by the STA thread. The product regeneration itself parsed cleanly with no warnings on every theme; the test now extracts plain Color structs inside the STA body and asserts on those. No product change.
| public static IReadOnlyDictionary<string, Color> StockPalette(string theme) | ||
| { | ||
| var name = NormalizeThemeName(theme); | ||
| if (s_stockPalettes.TryGetValue(name, out var cached)) |
There was a problem hiding this comment.
Minor, non-blocking: s_stockPalettes caches by theme name and is never invalidated when ThemeXamlTextProvider is reassigned. In production this is harmless (the provider is set once, at startup, and never swapped), but it's a latent trap for any future test that calls StockPalette after overriding ThemeXamlTextProvider — the cached palette from an earlier call (possibly with a different provider) will be returned silently instead of being recomputed. Might be worth clearing the cache (or keying it off the provider instance) if StockPalette ever becomes something tests exercise directly.
There was a problem hiding this comment.
Agreed, and worth closing while the context is warm: #3617 makes the provider setter empty the cache (the doc comment carries this note's reasoning) and pins it with StockPalette_FollowsTheProvider_WhenTheProviderIsSwapped. No behaviour change in the apps, where the provider is set once.
There was a problem hiding this comment.
LGTM — reviewed the theme-color-overrides implementation (ThemeColorOverrides, ThemeXamlRewriter, ThemeManager's regeneration/watcher/reload path, WcagContrast, HsvColor, ThemeColorsPanel), the Lite/Darling wiring in both App.xaml.cs files and both SettingsWindow.xaml files, and both READMEs.
Checked specifically:
- Correctness: hex parsing/round-trip, WCAG luminance/ratio/band math, HSV round-trip, the regex-based XAML rewrite (exact
x:Keymatch, first-occurrence-only replace), the file watcher's debounce/retry/fingerprint logic (own-write vs. outside-edit detection), and the loader's absent/unreadable/per-entry warning semantics viaSettingsFileGuard— all consistent with the pinned test fixtures and each other. - Lite/Darling parity:
ThemeColorsPanelis hosted exactly once in eachSettingsWindow.xaml, in the same position beneath the theme combo; both apps embed theme XAML text under the same resource name; both setOverridesFilePath/LogWarning/LogInfobefore their firstThemeManager.Applyand callWatchOverridesFile()after, matching the "first paint carries overrides" claim.deprecated/Dashboardnever setsOverridesFilePath, so it stays on the pre-#3577 compiled-dictionary path unchanged, as documented. - Security: the overrides file path is always derived from the app's own per-user settings directory plus a fixed filename, never from user input;
Process.Startin "Open file" only ever targets that fixed path. - Docs: Lite's and Darling's README sections agree with each other and with the code on file location, contrast thresholds, and Apply/Reset/Open-file semantics.
Left one non-blocking inline note about ThemeManager.StockPalette's cache not invalidating if ThemeXamlTextProvider is ever swapped at runtime — latent, not currently reachable in production or tests.
No T-SQL/collector changes in this PR (pure WPF/C#), so the T-SQL style conventions don't apply here.
… review follow-up) (#3617) Non-blocking note on #3606: ThemeManager.StockPalette caches by theme name and the cache outlived a swap of ThemeXamlTextProvider, so a test that installed its own theme text could be handed the previous provider's palette silently. The setter now clears the cache; in the app the provider is set once and the clear never runs. Pinned by StockPalette_FollowsTheProvider_WhenTheProviderIsSwapped.
…wn page, and text on a status fill gets a measured ink in every theme (#3609) (#3627) Light shipped WarningColor #F57F17 at 2.47:1 and InfoColor #2eaef1 (the accent itself) at 2.32:1 against BackgroundColor; Cool Breeze's WarningColor was 2.09:1. All three sit under the 3:1 non-text floor the new Settings contrast readout (#3606) paints red, and WarningBrush is TEXT in the viewer (fleet counts, seat state, coverage note), so the bar was 4.5:1, not 3:1. - Light WarningColor -> #AE4F08 (4.99 page / 5.35 card / 4.77 well / 4.72 alt row), InfoColor -> #0369A1 (5.53 / 5.93 / 5.29 / 5.23; same 201-degree hue as the accent, decoupled from it). Cool Breeze WarningColor -> #9E4A0B (4.82 / 5.51; 4.41 / 4.08 where it is only a marker). Dark untouched. - No amber clears 4.5:1 on Light's page AND under the #1A1A1A the Recommendations badge painted on it (the band is empty), so the ink is a theme key, StatusForegroundBrush, the way #3589 gave the accent AccentForegroundColor: white on the light themes, #111217 on Dark. A brush with a literal, not a nineteenth <Color>, so the eighteen-color palette pins in ThemeColorOverrideTests stand. The Recommendations badge (Lite, viewer, Dashboard) and the viewer's alert / attention badges take it; the latter were hard-coded White, 1.41:1 on Dark's amber. - Values and the ink key mirrored to the Darling viewer and the deprecated Dashboard so ThemeParityLiteDarlingTests and the Dashboard ThemeParityTests hold (70 and 67 shared keys, 0 mismatches). - Lite.Tests/ThemeStatusContrastTests pins every theme's four status colors at 3:1 on both page surfaces, Warning/Info at 4.5:1, the ink at 4.5:1 on every status fill, and the three consumers on the key. Text parse + WcagContrast, no WPF object; executed locally, fails on dev.
…spliced once Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2 worker's; each lane returned its entry to a buffer instead of touching this file, so that fifty-plus PRs did not each rebase the same twenty lines. This is the one splice. Every entry is one line (the archiver's compact() and the pins read them that way); riders fold into their parent's entry (#3599 under #3590, #3619 under #3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as #3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main is what is still pending. [Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the service-side wait sampler (#3645 / #3604), and the log-event classifier with its temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The other forty-eight are honesty fixes to existing surfaces and append to `### Fixed` after the wave-1 bullets, in PR-number order. Thirty-six reference definitions added for the issues the new entries cite and the index did not yet define; the [Unreleased] group is one ascending run again, which moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or older is touched; the archive script was not run. One editorial touch: the #3585 entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists only in the Darling MCP host). tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor 1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the 750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
…spliced once (#3672) Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2 worker's; each lane returned its entry to a buffer instead of touching this file, so that fifty-plus PRs did not each rebase the same twenty lines. This is the one splice. Every entry is one line (the archiver's compact() and the pins read them that way); riders fold into their parent's entry (#3599 under #3590, #3619 under #3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as #3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main is what is still pending. [Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the service-side wait sampler (#3645 / #3604), and the log-event classifier with its temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The other forty-eight are honesty fixes to existing surfaces and append to `### Fixed` after the wave-1 bullets, in PR-number order. Thirty-six reference definitions added for the issues the new entries cite and the index did not yet define; the [Unreleased] group is one ascending run again, which moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or older is touched; the archive script was not run. One editorial touch: the #3585 entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists only in the Darling MCP host). tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor 1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the 750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
Arm (B) of #3577 — the request itself. The report asked for two things: that the Cool Breeze selected tab and Dark's row marks stop being hard to read, and that the operator be able to maintain the colors themselves, with a reset to default. Arm (A) (#3589) did the first: it measured every text-on-accent pair and Dark's markers, gave the accent a per-theme ink key (
AccentForegroundColor) that every accent surface shares, and made the row marks theme brushes. This PR does the second, on top of that. NoCloseskeyword — the coordinator closes #3577 when this merges.Everything below is either built and measured, or stated as what could not be run here (no Windows on this lane; CI is the first execution of the tests).
What the operator gets
Settings → expand Colors (a collapsed section directly beneath the Color theme drop-down, in both Lite and the Darling viewer). For the theme currently on screen — it follows the combo's live preview — one row per adjustable palette color, grouped Backgrounds / Text / Accent / Rows / Status: a swatch, the hex, a picker button, and the measured WCAG contrast ratio against the surface that color renders on (page ↔ text, raised panel ↔ text, accent ↔ its ink, each status color ↔ page, alternating row ↔ text). Green ≥ 4.50:1, amber 3.00–4.49, red below 3.00, with a one-line note under the rows when anything is below 4.50. Apply writes the file and re-applies live (no restart). Reset to default removes the current theme's block and re-applies. Open file hands the JSON to the shell's editor; a
FileSystemWatcherre-applies what is saved there.The file: Lite
%LOCALAPPDATA%\PerformanceMonitorLite-Data\config\theme-overrides.json(besidesettings.json); the viewer%APPDATA%\PerformanceMonitorDarling\theme-overrides.json(besideviewer-settings.json— the viewer's existing per-user root is the roaming one, so the file goes where its siblings already are rather than inventing a second root). Keyed by theme, then by color, holding only what the operator changed:{ "Dark": { "AccentColor": "#3A7BD5", "AccentForegroundColor": "#FFFFFF" }, "CoolBreeze": { "WarningColor": "#B45309" } }The settled choices, and why each is what it is
Per theme. The base palettes differ — Dark's accent is a sky blue on near-black, Cool Breeze's a steel blue on white — so one override set cannot suit all three. A Dark override never touches Light; the tests pin the isolation.
A closed list of twelve of the eighteen palette colors —
BackgroundColor,BackgroundLightColor,BackgroundDarkColor;ForegroundColor,ForegroundDimColor;AccentColor,AccentForegroundColor;AlternatingRowColor;SuccessColor,WarningColor,ErrorColor,InfoColor. Declared once,ThemeColorOverrides.SlotsinPerformanceMonitor.Ui, with label, group, meaning and contrast partner per key; the loader, the panel, the README table and the tests all read it from there. The other six (AccentHoverColor,AccentPressedColor,BackgroundLighterColor,ForegroundMutedColor,BorderColor,BorderLightColor) are withheld on purpose and the doc comment says why: they are secondary tones each theme derives from a primary — a hover is the accent lightened a step, a border is the background lifted a step — and exposing them invites pulling a derived tone away from the one it derives from, which is how a hover ends up darker than its rest state. A test proves all eighteen exist in every theme file of both apps, so the exclusion is a choice and not an accident. The list is also the answer to the next request — "can I change just the selected tab?" That is the style layer, a setter inside one control template, and it is not on offer: arm (A) fixed the selected tab by giving the accent an ink key every accent surface shares, not by special-casing the tab, and the same discipline holds here. The operator adjusts the accent and its ink; every accent surface follows.Warn, don't refuse. A pair under 4.5:1 shows amber or red and the note; Apply still works. The operator asked to maintain their own colors; an app that overrules them on a threshold it measured is not giving them that. The one thing Apply declines is a value that is not a color at all — there is nothing to apply.
The file never takes the app down. It is read at startup, before the first window. Absent is a first run and silent. Malformed costs the whole file, an unknown theme block costs that block, an unknown key or bad value costs that one entry — each said once through the app's logger and skipped, and everything else in the file still applies. This reuses
SettingsFileGuard(#2425) rather than re-inventing its absent/unreadable discipline: a save over an unreadable file copies it aside first, and refuses when no copy can be made. A save is a read-modify-write of the document, so a block the loader skipped with a warning survives a save it had nothing to do with.The regeneration — and the hypothesis that turned out to be half right
The brief's hypothesis was that the theme's brushes are declared
Color="{StaticResource XColor}"and resolved at load, so overriding a Color after load would not move its brushes and the brushes would have to be regenerated. True — and not the whole story. Reading the dictionaries: every one of the 51 (Lite) / 59 (viewer) brushes is declared that way, and so is every control style and template. Window, TextBlock, Button, TabItem, DataGrid, ComboBox, ScrollBar, Menu — 184–200{StaticResource XBrush}setters per file against twoDynamicResource. WPF resolves aStaticResourceinside a compiled dictionary to the object: the Window style's Background setter holds the verySolidColorBrushinstanceBackgroundBrushresolved to, and a merged dictionary that redefines the key changes what the key means, not what the setter holds. So the design as written — load the theme, merge a generated dictionary of overridden Colors and re-emitted Brushes — would have recolored the ~700{DynamicResource}references in the app's own XAML and the code-behindFindResourcecalls, and left every styled control in the stock palette. A two-tone app. Mutating the loaded brushes in place does not work either: a value realized from a dictionary that belongs toApplication.Resourcesis sealed on the way out (ResourceDictionary.SealValuefreezes Freezables once the dictionary has an application owner), and a brush in a style setter is frozen again when the style seals.So the override is applied where it can reach everything: in the text, before the dictionary is parsed. Each app's project embeds its three theme files a second time as plain text (
<EmbeddedResource Include="Themes\*.xaml" LogicalName="Themes/%(Filename).xaml"/>, ~150 KB in a 277 MB tree). A theme with no overrides loads exactly as before — the compiled BAML, unchanged path, unchanged cost; the deprecated Dashboard and the tests never set a file path and get pre-#3577 behaviour byte for byte. A theme with overrides is regenerated:ThemeXamlRewriterrewrites the overridden<Color x:Key="…">declarations in the text,ThemeManagerparses the result withXamlReader.Parse, and every{StaticResource XColor}in the file — the brushes, the twoColorAnimation To=targets, anything added later — resolves against the new value because it is resolved inside the dictionary being built. One dictionary lands inMergedDictionaries, the same live swap the theme combo has always done. Any failure in the chain (text missing, parse error) is a logged warning and the stock dictionary.The dependency map is derived, not maintained. The loader needs no color→brush map at all — the re-parse follows every dependency the XAML declares.
ThemeXamlRewriter.BrushDependenciesreads the map out of the text (<SolidColorBrush x:Key="B" Color="{StaticResource C}") so the tests can prove the regenerated dictionary honours it:RegeneratingATheme_MovesEveryBrushTheXamlDerivesFromAnOverriddenColorruns against all six shipped theme files, overrides all twelve exposed colors to distinct sentinels (so a brush that picked up the wrong override fails, not just one that picked up none), rebuilds the dictionary the wayApplydoes, and asserts every derived brush — 26 in Dark, 35–42 in the light themes, including the{x:Static SystemColors.*Key}grid-selection brushes — carries exactly its color's sentinel, while every brush derived from a withheld color has not moved. The counts differ per theme (Light derives its border and muted brushes from the palette where Dark paints them literally), which is exactly the drift a hand-maintained map would have carried.What does not follow an override, by construction and documented in the README: the handful of component-specific brushes each theme paints with a fixed value — the plan viewer's panels, the time-range slicer, the deadlock graph,
WarningTextBrush,ApexBorderBrush(25 of Dark's 51). They were never derived from the palette.The GUI, and the picker choice
One shared
ThemeColorsPanel(UserControl inPerformanceMonitor.Ui), hosted by one line in each Settings window, so the two apps cannot drift. The rows are built in code fromThemeColorOverrides.Slots— twelve rows of five controls whose labels, groups and partners are already declared once on the slot list. Typing a hex or moving the picker updates the row's swatch and every readout that depends on it (a new page background re-measures every text and status row), but nothing is applied until Apply, because each Apply is a dictionary regeneration and the operator should choose when it happens. Only what differs from the stock palette is written, so a row typed back to its default is not stored and the file only ever says what the operator changed.Picker: three sliders (hue / saturation / brightness) over a small pure
HsvColorrecord plus a live swatch, in a Popup beside the row's button, writing the hex back into the row's text box. WPF has no color picker; a hue wheel, an eyedropper or a saved-swatches strip would each be more control than twelve rows justify, and the text box takes any#RRGGBBbrought from elsewhere. The sliders are stock WPF (the themes carry no Slider style) — functional, and visually plainer than the themed controls around them; noted, not hidden.No Alt mnemonics on Apply / Reset / Open file, on purpose. The panel shares its host window's access-key scope and both Settings windows have spent every natural letter (A, C, R, S, O, D, F, E, T, L, P, Y). A key on a letter nobody would guess is a worse affordance than none — the same reasoning as
Pause Co_llection's comment — and a duplicate would turn Alt+letter into "move focus". Pinned byTheColorsPanel_CarriesNoAccessKeys. The Expander header isColors, no underscore.Parity
Uicode, its own file under its own per-user root, its own embedded theme text. Viewer-local by design — a color is a preference of the person at the screen, not a fact about the fleet, so it goes to neither the store nor the control plane; a second seat keeps its own.Darling.Tests/ViewerThemeColorOverridesTestspins the location, the absence fromViewerAppSettings, the Storage and Service projects, and that the viewer hosts the shared panel with no private copy of the rows.ThemeManager, never setsOverridesFilePath, and so takes the compiled-dictionary path unchanged. ItsThemeParityTestscompare palette keys and values Lite↔Dashboard; this PR adds no keys and changes no values, and the six theme files are untouched.Tests (CI is their first run)
Lite.Tests/ThemeColorOverrideTests— the twelve spelled out; the six withheld spelled out; exposed ∪ withheld = exactly the 18 declarations of every theme file in both apps; every slot's partner is an exposed key and the brief's pairs are pinned by name. Hex: accepts#RRGGBB/RRGGBB/#AARRGGBB, rejects#FFF, names, junk;ToHexwrites upper-case and alpha only when translucent. Loader: a file with two withheld keys, an unknown key, three bad values, an unknown theme and a non-object block yields the two good entries and exactly eight warnings, no throw; per-theme isolation; missing file → empty and silent; five malformed shapes (trailing comma, array root,null, empty, prose) → empty, one warning, file untouched. Save: replaces only the named block, preserves an unknown sibling verbatim, writes keys in slot order; empty set removes exactly that block and leaves siblings; save over an unreadable file quarantines it and says so. Rewriter: byte-for-byte except the named declaration; warns on an undeclared key; the declaration regex matches all 18 in every file exactly once and every declared value parses. Regeneration: the sentinel test above on all six files; no-override regeneration reproduces the stock palette; unparseable text → null plus one warning, never a throw. Contrast: white/black 21.00, the arm-A fixtures (2.71, 5.39, 4.56, 1.99, 7.52, 9.49) computed by the class the readout uses and order-independent; band edges at 3.00 and 4.50. HSV: primaries decompose, six palette colors round-trip. Wiring: both projects embed the text under the nameThemeManager.ThemeTextResourceNameasks for; bothApp.xaml.csset the path and the log hooks before the firstApplyand start the watcher; both Settings windows host the panel beneath the theme combo. Every cross-app read sits inside trees thelitefilter already reaches (Darling/PerformanceMonitor.Darling.Viewer/**).Verification here:
dotnet build -c Release -p:EnableWindowsTargeting=trueonPerformanceMonitor.Ui,Lite,Lite.Tests,PerformanceMonitor.Darling.Viewer,Darling.Tests,DashboardandDashboard.Tests— 0 warnings, 0 errors each, after rebasing onto currentdev. The built assemblies were inspected for both the compiled BAML (themes/darktheme.baml) and the embedded text (Themes/DarkTheme.xaml) under the loader's exact names. The rewriter's and dependency-map regexes were emulated in Python against all six theme files (18 declarations each; 26–42 derived brushes; every exposed key depended on in every file). The contrast fixtures and the HSV round-trips were computed independently before being pinned. Screenshots are impossible from this lane.What it does not do
No style-layer overrides (one tab, one button). No store or control-plane persistence. No Dashboard port. No live preview before Apply. No repainting of the component-specific literal brushes named above. Not adjustable: the six withheld tones.
Out-of-lane findings (reported, not fixed)
WarningColor#F57F17 on #F5F7FA = 2.47,InfoColor#2eaef1 on #F5F7FA = 2.32. Cool Breeze:WarningColor2.09,InfoColor4.25,SuccessColor4.04,ErrorColor4.43. Dark passes all twelve. Arm (A)'s sweep measured Dark's markers and Cool Breeze's accent surfaces, not the light themes' status colors on the page;WarningTextBrushexists precisely because the warning amber is not legible as text on a light page, but the status brushes are also used as markers (dots, borders) where 3:1 is the target — and Light's two are below even that. Not repainted here: choosing new warning/info tones for two themes is a palette-design call, and the values must land in the Dashboard's copies too orThemeParityTestsfails. A fresh Light install will show two red rows in the Colors section; that is the measurement. The tests pin the eight surface/text/accent pairs green everywhere and all twelve green in Dark, and say why the light-theme status rows are not pinned.Sliderstyle, so the picker's sliders render in stock WPF chrome inside the popup.ThemeManager.Apply("<anything else>")has always fallen back to Dark's dictionary while recording the unknown name asCurrentTheme; the overrides path normalizes the name for its own lookups and leaves that behaviour alone.Claims in the brief that did not survive contact
%APPDATA%\PerformanceMonitorDarling, not%LOCALAPPDATA%; the file follows the root that exists.CHANGELOG.mduntouched; the entry is handed to the coordinator separately.