From ec01a7deb8e5a638ac560e28ba4b52055c89c627 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 09:58:52 -0700 Subject: [PATCH] design: the last VM-side colours come from tokens (ThemeBrush) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DESIGN.md | 4 ++ Helpers/ThemeBrush.cs | 40 +++++++++++++++++++ Services/NotificationService.cs | 10 ++--- Themes/Tokens.xaml | 4 ++ ViewModels/MainViewModel.Audio.cs | 14 +++---- .../MainViewModel.Streaming.Operations.cs | 12 ++++-- ViewModels/MainViewModel.Streaming.cs | 26 ++++++++---- ViewModels/MainViewModel.cs | 4 +- ytLive.Tests/OnAirSignTests.cs | 6 +-- 9 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 Helpers/ThemeBrush.cs diff --git a/DESIGN.md b/DESIGN.md index b4112a7..1534b75 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -53,6 +53,10 @@ | `error` | `#ef4444` | Error text and icons | | `error-container` | `#93000a` | Error surface (health banner, toast) — keeps error visually distinct from on-air red | | `on-error` | `#ffdad6` | Text on an error surface | +| `info-container` | `#3a3b52` | Neutral notice surface (info toast) | +| `success-container` | `#1d5c38` | Success notice surface (success toast) | +| `warning-container` | `#b8860b` | Warning surface — health banner, warning toast | +| `test-container` | `#9c6f1c` | Test-stream chrome: the gold top bar and glow | **The rule:** red means "live or recording" and nothing else. Never a Send button, a delete action, a brand flourish, or a general accent — that was the defect in the first Stitch draft. diff --git a/Helpers/ThemeBrush.cs b/Helpers/ThemeBrush.cs new file mode 100644 index 0000000..9765a14 --- /dev/null +++ b/Helpers/ThemeBrush.cs @@ -0,0 +1,40 @@ +using System.Windows; +using System.Windows.Media; + +namespace ytLive.Helpers; + +/// Resolves a design token for code that cannot bind — view-model state colours (the +/// top bar, the health banner, the meters, toast tints). The values live once, in +/// Themes/Tokens.xaml; this reads them from the application resources. +/// +/// Why not a Brush field per colour: a second list in C# would drift from the theme. Why not +/// OutputPalette: that one exists for the frame-pump thread, which must not touch resources; these +/// properties are read by bindings on the UI thread. +/// +/// Without an Application (unit tests, design-time) a neutral grey comes back: visibly wrong +/// rather than silently plausible, and never a crash. +internal static class ThemeBrush +{ + private static readonly SolidColorBrush Missing = Frozen(Color.FromRgb(0x80, 0x80, 0x80)); + + /// The brush for a token key, e.g. ThemeBrush.Get("Brush.Primary"). + public static SolidColorBrush Get(string key) + => Application.Current?.TryFindResource(key) as SolidColorBrush ?? Missing; + + /// The token's colour as a browser-style hex string, for APIs that take one + /// (Notification.Wpf's NotificationColor.FromHex, for instance). + public static string Hex(string key) + { + var c = Get(key).Color; + return c.A == 255 + ? $"#{c.R:x2}{c.G:x2}{c.B:x2}" + : $"#{c.A:x2}{c.R:x2}{c.G:x2}{c.B:x2}"; + } + + private static SolidColorBrush Frozen(Color colour) + { + var brush = new SolidColorBrush(colour); + brush.Freeze(); + return brush; + } +} diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs index 65a2e68..d6a91ac 100644 --- a/Services/NotificationService.cs +++ b/Services/NotificationService.cs @@ -20,10 +20,10 @@ public sealed class NotificationService : INotificationService internal static readonly IReadOnlyDictionary Cards = new Dictionary { - [AppNotificationSeverity.Info] = ("#3a3b52", 4), - [AppNotificationSeverity.Success] = ("#1d5c38", 4), - [AppNotificationSeverity.Warning] = ("#b8860b", 8), - [AppNotificationSeverity.Error] = ("#8f1f1f", 0), + [AppNotificationSeverity.Info] = (ThemeBrush.Hex("Brush.InfoContainer"), 4), + [AppNotificationSeverity.Success] = (ThemeBrush.Hex("Brush.SuccessContainer"), 4), + [AppNotificationSeverity.Warning] = (ThemeBrush.Hex("Brush.WarningContainer"), 8), + [AppNotificationSeverity.Error] = (ThemeBrush.Hex("Brush.ErrorContainer"), 0), }; private readonly NotificationManager _manager = new(); @@ -54,7 +54,7 @@ public sealed class NotificationService : INotificationService .Create(title, message) .InArea(AreaName) .WithBackground(NotificationColor.FromHex(backgroundHex)) - .WithForeground(NotificationColor.FromHex("#FFFFFF")); + .WithForeground(NotificationColor.FromHex(ThemeBrush.Hex("Brush.OnSurface"))); if (severity == AppNotificationSeverity.Error) builder.NeverExpires(); diff --git a/Themes/Tokens.xaml b/Themes/Tokens.xaml index 33471ff..609a7b5 100644 --- a/Themes/Tokens.xaml +++ b/Themes/Tokens.xaml @@ -43,6 +43,10 @@ + + + + #0b1326 diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs index b4403c7..07b25c2 100644 --- a/ViewModels/MainViewModel.Audio.cs +++ b/ViewModels/MainViewModel.Audio.cs @@ -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"), }; /// 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"); /// Mic connection state, driven by the mixer's MicConnected/MicFailed /// events and the startup device check (see StartMicCaptureAsync). @@ -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) diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 8788c2c..3d0bd67 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -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(); diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index ee4c664..4693e4a 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -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 /// 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. - 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"); /// Raises every flag the top bar renders, so any state change re-renders /// exactly the world that is now true. @@ -273,8 +285,8 @@ public partial class MainViewModel : ViewModelBase private set => SetProperty(ref _healthIssueBanner, value); } - /// Banner background: app red (#8f1f1f) for an error issue, amber - /// (#b8860b) for a warning. Transparent when no banner is shown. + /// Banner background: the error surface for an error issue, the warning surface + /// for a warning (both tokens). Transparent when no banner is shown. public string HealthIssueBackground { get => _healthIssueBackground; diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 8dcfa34..984bde0 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -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)!; diff --git a/ytLive.Tests/OnAirSignTests.cs b/ytLive.Tests/OnAirSignTests.cs index 07c3ec9..7a696bc 100644 --- a/ytLive.Tests/OnAirSignTests.cs +++ b/ytLive.Tests/OnAirSignTests.cs @@ -37,16 +37,16 @@ public sealed class OnAirSignTests var vm = new MainViewModel(); Assert.Equal(StreamStatus.Offline, vm.StreamStatus); - Assert.Equal("#555555", vm.OnAirBrush); + Assert.Equal("#5b6478", vm.OnAirBrush); var raised = 0; vm.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(MainViewModel.OnAirBrush)) raised++; }; vm.StreamVisibility = "Public"; - Assert.Equal("#555555", vm.OnAirBrush); + Assert.Equal("#5b6478", vm.OnAirBrush); vm.StreamStatus = StreamStatus.Streaming; - Assert.Equal("#e94560", vm.OnAirBrush); + Assert.Equal("#ff5451", vm.OnAirBrush); vm.StreamVisibility = "Private"; Assert.Equal("#22c55e", vm.OnAirBrush);