design: the last VM-side colours come from tokens (ThemeBrush)

The state colours the view-models hand to bindings — top bar, REC/ON-AIR dots, health banner,
meter bands, toast tints — were hex literals in C#. They now resolve from Themes/Tokens.xaml
through `Helpers/ThemeBrush`, so the values live in one place. Without an Application (unit tests)
the helper returns a neutral grey: visibly wrong rather than silently plausible.

Four tokens the code needed and the document lacked, added to DESIGN.md (so the doc-is-the-contract
guard demanded their brushes): info-container #3a3b52, success-container #1d5c38,
warning-container #b8860b, test-container #9c6f1c (the test-stream gold bar).

Also fixed by this: the top bar still painted the RETIRED brand red #e94560 when live — the
standard's #ff5451 now, and `OnAirSignTests` (which pinned the old value) says so. BarOn/BarOff
became properties rather than static fields, because a static field would resolve tokens before
the Application exists.

Verified: DesignTokensTests, GameMeterHonestyTests, NotificationAreaIntegrationTests,
OnAirSignTests, SessionTeardownTests — 38 tests green.
This commit is contained in:
2026-10-30 09:58:52 -07:00
parent 7adff685c0
commit ec01a7deb8
9 changed files with 93 additions and 27 deletions
+7 -7
View File
@@ -68,9 +68,9 @@ public partial class MainViewModel
public string MeterBrush => MeterLevel switch
{
< 0.6 => "#38bdf8",
< 0.8 => "#ffb95f",
_ => "#ff5451",
< 0.6 => ThemeBrush.Hex("Brush.Meter.Nominal"),
< 0.8 => ThemeBrush.Hex("Brush.Meter.Caution"),
_ => ThemeBrush.Hex("Brush.Meter.Peak"),
};
/// <summary>Previewes the volume position while the slider is dragged; the
@@ -201,7 +201,7 @@ public partial class MainViewModel
// ─── Mic status dot (the MIC button) ───
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
private static SolidColorBrush MicProblemBrush => ThemeBrush.Get("Brush.Secondary");
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
/// events and the startup device check (see StartMicCaptureAsync).</summary>
@@ -283,9 +283,9 @@ public partial class MainViewModel
public string GameMeterBrush => GameMeterLevel switch
{
< 0.6 => "#38bdf8",
< 0.8 => "#ffb95f",
_ => "#ff5451",
< 0.6 => ThemeBrush.Hex("Brush.Meter.Nominal"),
< 0.8 => ThemeBrush.Hex("Brush.Meter.Caution"),
_ => ThemeBrush.Hex("Brush.Meter.Peak"),
};
public void SetGameVolumeAdjusting(bool adjusting)
@@ -316,7 +316,9 @@ public partial class MainViewModel : ViewModelBase
return;
}
HealthIssueBackground = report.IsError ? "#93000a" : "#b8860b";
HealthIssueBackground = report.IsError
? ThemeBrush.Hex("Brush.ErrorContainer")
: ThemeBrush.Hex("Brush.WarningContainer");
HealthIssueBanner = report.Text;
}
@@ -486,8 +488,12 @@ public partial class MainViewModel : ViewModelBase
var live = IsLive;
// A live TEST broadcast wears gold, not the live red — the TEST badge +
// gold are the "this is a rehearsal" cue (TASK 41).
TopBarBackground = live ? IsTestStream ? "#9c6f1c" : "#ff5451" : "#16213e";
PreviewGlowBrush = live ? IsTestStream ? "#9c6f1c" : "#ff5451" : "Transparent";
TopBarBackground = live
? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary")
: ThemeBrush.Hex("Brush.SurfaceContainer");
PreviewGlowBrush = live
? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary")
: "Transparent";
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
if (!live) _healthPollTimer.Stop();
+19 -7
View File
@@ -29,7 +29,7 @@ public partial class MainViewModel : ViewModelBase
private string? _healthIssueBanner;
private string _healthIssueBackground = "Transparent";
private string _windowTitle = "LlamaCasty";
private string _topBarBackground = "#16213e";
private string _topBarBackground = ThemeBrush.Hex("Brush.SurfaceContainer");
private string _previewGlowBrush = "Transparent";
private Thickness _previewGlowThickness = new(0);
private string _liveElapsedText = "00:00:00";
@@ -87,10 +87,18 @@ public partial class MainViewModel : ViewModelBase
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
public string RecDotBrush => !IsRecording ? "#555555" : "#ff5451";
public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888";
public string RecDotBrush => !IsRecording
? ThemeBrush.Hex("Brush.OnSurfaceDisabled")
: ThemeBrush.Hex("Brush.Primary");
public string RecTextBrush => (IsLive || IsRecording)
? ThemeBrush.Hex("Brush.OnSurface")
: ThemeBrush.Hex("Brush.OnSurfaceDisabled");
public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55;
public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#ff5451";
public string OnAirBrush => !IsLive
? ThemeBrush.Hex("Brush.OnSurfaceDisabled")
: IsLivePrivate
? ThemeBrush.Hex("Brush.Positive")
: ThemeBrush.Hex("Brush.Primary");
// ─── REC / ON-AIR pills (TASK 18) ───
public bool RecordPillOn
@@ -222,7 +230,11 @@ public partial class MainViewModel : ViewModelBase
/// <summary>Reality dot color, one meaning per mode: gold = test, red = live,
/// green = recording. The pre-spec pulsing lamp never shipped; the word + bar
/// color carry the state and the dot just pins it.</summary>
public string RunningDotBrush => IsTestStream ? "#9c6f1c" : IsLive ? "#ff5451" : "#22c55e";
public string RunningDotBrush => IsTestStream
? ThemeBrush.Hex("Brush.TestContainer")
: IsLive
? ThemeBrush.Hex("Brush.Primary")
: ThemeBrush.Hex("Brush.Positive");
/// <summary>Raises every flag the top bar renders, so any state change re-renders
/// exactly the world that is now true.</summary>
@@ -273,8 +285,8 @@ public partial class MainViewModel : ViewModelBase
private set => SetProperty(ref _healthIssueBanner, value);
}
/// <summary>Banner background: app red (#8f1f1f) for an error issue, amber
/// (#b8860b) for a warning. Transparent when no banner is shown.</summary>
/// <summary>Banner background: the error surface for an error issue, the warning surface
/// for a warning (both tokens). Transparent when no banner is shown.</summary>
public string HealthIssueBackground
{
get => _healthIssueBackground;
+2 -2
View File
@@ -109,8 +109,8 @@ public partial class MainViewModel : ViewModelBase
}
}
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
private static SolidColorBrush BarOnBrush => ThemeBrush.Get("Brush.Positive");
private static SolidColorBrush BarOffBrush => ThemeBrush.Get("Brush.Primary");
private static SolidColorBrush CreateBrush(string hex)
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;