Skip to content

fix: make the status colours follow the theme instead of staying dark-calibrated - #1717

Merged
laurentiu021 merged 3 commits into
mainfrom
fix/status-colors-theme-aware
Aug 6, 2026
Merged

fix: make the status colours follow the theme instead of staying dark-calibrated#1717
laurentiu021 merged 3 commits into
mainfrom
fix/status-colors-theme-aware

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

Closes #1629.

The problem

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.

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:

old value on a light card
Good #22C55E 1.65:1
Warning #F59E0B 1.55:1
Info #3B82F6 2.66:1
Elevated #F87171 2.00:1
Bad #EF4444 2.72:1
Neutral #9AA0A6 1.91:1

The fix

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 — it was the one route that bypassed the fix.

So StatusColors now names that brush instead of duplicating a colour, and HexToBrushConverter resolves a key against the live theme resources using the same lookup OutputKindToBrushConverter already 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:

  • Neutral → TextMuted, which Apply writes from the preset (12 distinct values across presets), not from StatusPalette. So it is theme-aware, just via a different route.
  • Elevated → 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. A test pins the aliasing so it stays a decision, not an accident.

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

HealthAnalyzer assigned eight raw hex literals directly, bypassing StatusColors entirely — and with a drifted palette (#06D6A0 not #22C55E, #FF6B6B not #EF4444), exactly the divergence centralising was meant to prevent. Routed through the same semantic keys. Grepped the codebase afterwards: no ColorHex = "#... 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. ThemeStatusBrushTests gains 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:

  1. TextMuted is not in StatusPalette (set separately by Apply).
  2. Good-vs-Bad cannot be checked with WCAG contrast: that formula measures luminance, and light Success #166534 vs Danger #B91C1C are 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

  • 26/26 on the contrast + contract harness (the old-vs-new table above is its output).
  • 8/8 on a harness driving the real producers — SFC verdict parsing, HealthAnalyzer verdicts — asserting the semantic mapping is right and no emitted value carries a #.
  • All four projects rebuild --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.xaml brushes that were live via runtime TryFindResource; deleting them would have broken console colouring.

…-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.
@laurentiu021
laurentiu021 merged commit 4fdb9e4 into main Aug 6, 2026
5 of 6 checks passed
@laurentiu021
laurentiu021 deleted the fix/status-colors-theme-aware branch August 6, 2026 15:15
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.

[Bug]: Cross-app - StatusColors hex constants bypass the theme entirely

1 participant