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);