fix: make the status colours follow the theme instead of staying dark-calibrated - #1717
Merged
Conversation
…-calibrated Closes #1629. StatusColors held six `const string` hex literals. A const is baked in at compile time, so ThemeService could never recompute them — and every *ColorHex property that feeds HexToBrushConverter carried them: System Health verdicts, disk health and wear percentages, drive temperatures, the Dashboard health score, Cleanup's SFC/DISM results and the network-health headline. On the light presets they rendered as pale text on near-white cards. Re-derived the contrast against the worst-case light card (soft-blossom Surface2 #FBCFE8) rather than trusting the reported figures, and it is worse than the issue estimated — all six fail AA: Good #22C55E 1.65:1 Elevated #F87171 2.00:1 Warning #F59E0B 1.55:1 Bad #EF4444 2.72:1 Info #3B82F6 2.66:1 Neutral #9AA0A6 1.91:1 ThemeService already recomputes the equivalent semantic brushes per mode (StatusPalette: Success/Warning/Info/Danger), and ThemeStatusBrushTests already guards them — this string-hex path was simply never migrated. So StatusColors now NAMES that brush instead of duplicating a colour, and HexToBrushConverter resolves a key against the live theme resources with the same lookup OutputKindToBrushConverter uses. After: 4.68-5.47:1 on the tinted light card, 5.27-9.25:1 on dark. Neutral maps to TextMuted, which Apply writes from the preset (12 distinct values across the presets) rather than from StatusPalette — verified, not assumed. Elevated maps to Warning: there is no separate "elevated" brush, and amber is the honest reading of "worse than fine, not yet failing"; the old light red was both illegible on a light card and easy to mistake for the failure colour. That aliasing is pinned by a test so it stays a decision. The static brush cache was the trap the issue called out: it holds frozen brushes, so caching a theme brush would keep serving the colour resolved at first render and the status text would stay dark-themed after switching preset. Only literal hex is cached now; a resource lookup is a dictionary hit anyway. Literal hex still works, which is why PingTarget's user-assignable per-series colours are untouched. The propagate sweep also found HealthAnalyzer assigning eight raw hex literals directly, bypassing StatusColors entirely — and with a drifted palette (#06D6A0 not #22C55E, #FF6B6B not #EF4444). Routed through the same semantic keys. Grepped the whole codebase afterwards: no `ColorHex = "#...` remains. Tests: 50 assertions across 10 files asserted the literal hex; they now assert the semantic constant, so the palette can move without touching tests. Extended ThemeStatusBrushTests with the AA/legibility/mode-divergence contract for the new keys. Two of my own new assertions failed on first run and were wrong, not the code: TextMuted is not in StatusPalette (it is set separately), and Good-vs-Bad cannot be checked with WCAG contrast because that measures luminance — light Success #166534 and Danger #B91C1C are equally dark at 1.10:1 while being obviously different hues, so it compares RGB distance instead. Verified: 26/26 on the contrast+contract harness, 8/8 on a harness that drives the real producers (SFC verdicts, network health) and asserts no emitted value carries a '#'. All four projects rebuild with 0 warnings. Not verifiable here: the on-screen result needs the app running, which is the secondary workstation's job. The contrast maths and the resolution contract are covered above.
CI caught a partial migration, and it was my error: the rewrite only matched
Assert.Equal("#..."), so every hex passed as an [InlineData] PARAMETER survived —
HealthScoreServiceTests failed with Expected "#F59E0B" / Actual "Warning".
Swept properly this time, matching hex in any position rather than one shape:
- [InlineData] parameters: HealthScoreServiceTests (6), DiskHealthReportTests (10),
FriendlyEventEntryDisplayTests (5)
- object-initialiser fixtures: HealthDiagnosticTests (1), TuneUpServiceTests (5).
These passed either way, but seeding a literal models data the app no longer
produces, which is how a stale fixture outlives a migration.
The sweep also surfaced a THIRD producer the issue never mentioned:
FriendlyEventEntry.SeverityColor had its own palette (#FF3B30/#FF6B6B/#FFD166/
#4CC9F0/#9AA0A6) feeding the same converter for the System Logs severity dots and
text — same defect, and drifted from StatusColors too. Routed through the semantic
keys; Critical keeps its own CriticalText key because the log list gives it a
distinct treatment from a plain error, and that key is already themed per mode.
Added the guard that makes this checkable rather than remembered:
NoProducer_EmitsAHardcodedColour drives every producer through its real code path
and fails with the offending call sites listed if any value starts with '#'. Two
grep-style sweeps had already missed cases here, so a test is the only thing that
closes it.
Verified: 16/16 on the producer harness (every emitted value is a key, including the
newly-found severity ones), and no hex literal remains anywhere in the test suite
outside the three legitimate cases — PingTarget's user-assignable series colours,
ConverterTests' explicit hex-path coverage, and the theme tests' own surface
constants. All four projects rebuild with 0 warnings.
Third pass, and the two remaining cases were ones no grep of the test suite could
have found:
- HealthDiagnostic._colorHex defaulted to "#9AA0A6" and TemperatureReading's null
case returned "#6B7B8F". Neither is an assertion — they are the model's own
fallbacks, i.e. the "waiting for data" and "no sensor" states, which are exactly
the values a user sees before anything else. Both now use StatusColors.Neutral.
- FriendlyEventEntryTests.SeverityColor_IsValidHex and
SystemHealthViewModelTests.Constructor_MemoryHealthColorHex_IsHexColor asserted
the FORMAT (`^#[0-9A-Fa-f]{6}$`, StartsWith "#") rather than a value, so there was
no literal to match. They pinned the exact contract this PR inverts, so both are
rewritten to assert the new one — a leading '#' now FAILS, which is the guard the
bug needed in the first place.
Swept all three patterns to closure this time — value literals, format assertions,
and producer defaults. What remains is out of scope and verified as such: the
LiveCharts series tints in ResourceHistory/BandwidthMonitor and NetworkSharedState's
per-target identity palette are chart-series identity (issue #1625), and
PingTarget's colour is user-assignable, which is why the converter keeps its hex
path.
Extended NoProducer_EmitsAHardcodedColour to drive the two defaults as well, so the
suite now covers every producer including the ones only CI caught.
Verified: 21/21 on the producer harness. All four projects rebuild with 0 warnings.
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.
Closes #1629.
The problem
StatusColorsheld sixconst stringhex literals. A const is baked in at compile time, soThemeServicecould never recompute them — and every*ColorHexproperty that feedsHexToBrushConvertercarried them: System Health verdicts, disk health and wear percentages, drive temperatures, the Dashboard health score, Cleanup's SFC/DISM results, and the network-health headline.I re-derived the contrast against the worst-case light card (soft-blossom
Surface2#FBCFE8) rather than trusting the reported figures — it's worse than the issue estimated. All six fail AA:#22C55E#F59E0B#3B82F6#F87171#EF4444#9AA0A6The fix
ThemeServicealready recomputes the equivalent semantic brushes per mode (StatusPalette: Success/Warning/Info/Danger), andThemeStatusBrushTestsalready guards them. This string-hex path was simply never migrated — it was the one route that bypassed the fix.So
StatusColorsnow names that brush instead of duplicating a colour, andHexToBrushConverterresolves a key against the live theme resources using the same lookupOutputKindToBrushConverteralready uses. Property names are unchanged, so not one of the 23 XAML binding paths moved.After: 4.68–5.47:1 on the tinted light card, 5.27–9.25:1 on dark.
Two mappings worth calling out, both verified rather than assumed:
TextMuted, whichApplywrites from the preset (12 distinct values across presets), not fromStatusPalette. So it is theme-aware, just via a different route.The cache trap
The issue flagged this and it was real: the converter's static cache holds frozen brushes, so caching a theme brush would keep serving the colour resolved at first render — status text would stay dark-themed after switching preset. Only literal hex is cached now; a resource lookup is a dictionary hit anyway.
Literal hex still resolves, which is why
PingTarget's user-assignable per-series chart colours are untouched.Propagate sweep found a second offender
HealthAnalyzerassigned eight raw hex literals directly, bypassingStatusColorsentirely — and with a drifted palette (#06D6A0not#22C55E,#FF6B6Bnot#EF4444), exactly the divergence centralising was meant to prevent. Routed through the same semantic keys. Grepped the codebase afterwards: noColorHex = "#...remains anywhere.Tests
50 assertions across 10 files asserted the literal hex. They now assert the semantic constant, so the palette can move without touching tests.
ThemeStatusBrushTestsgains the AA / legibility / mode-divergence contract for the new keys, plus a guard that fails if anyone reintroduces a#literal.Two of my own new assertions failed on first run — and the tests were wrong, not the code:
TextMutedis not inStatusPalette(set separately byApply).Success #166534vsDanger #B91C1Care equally dark (1.10:1) while being obviously different hues. It compares RGB distance instead.Recording that because a green suite on the first try would have meant those assertions weren't testing anything.
Verification
HealthAnalyzerverdicts — asserting the semantic mapping is right and no emitted value carries a#.--no-incremental: 0 errors, 0 warnings.Not verifiable on this workstation: the on-screen result needs the app running, which is the secondary workstation's job. The contrast maths and the resolution contract are covered above.
Not in this PR
The rest of the theming cluster (#1625 chart series tints, #1623 MetricBlue/MetricPurple, #1624 sidebar hover, #1628 FilterChip, #1622 forced-dark title bar) is visual and wants before/after screenshots per preset — tracked separately. Note also the near-miss precedent there: an earlier sweep flagged 9 "dead"
App.xamlbrushes that were live via runtimeTryFindResource; deleting them would have broken console colouring.