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:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>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
|
||||
/// <c>Themes/Tokens.xaml</c>; this reads them from the application resources.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
///
|
||||
/// <para>Without an Application (unit tests, design-time) a neutral grey comes back: visibly wrong
|
||||
/// rather than silently plausible, and never a crash.</para></summary>
|
||||
internal static class ThemeBrush
|
||||
{
|
||||
private static readonly SolidColorBrush Missing = Frozen(Color.FromRgb(0x80, 0x80, 0x80));
|
||||
|
||||
/// <summary>The brush for a token key, e.g. <c>ThemeBrush.Get("Brush.Primary")</c>.</summary>
|
||||
public static SolidColorBrush Get(string key)
|
||||
=> Application.Current?.TryFindResource(key) as SolidColorBrush ?? Missing;
|
||||
|
||||
/// <summary>The token's colour as a browser-style hex string, for APIs that take one
|
||||
/// (Notification.Wpf's <c>NotificationColor.FromHex</c>, for instance).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,10 @@ public sealed class NotificationService : INotificationService
|
||||
internal static readonly IReadOnlyDictionary<AppNotificationSeverity, (string BackgroundHex, double ExpirationSeconds)> Cards
|
||||
= new Dictionary<AppNotificationSeverity, (string, double)>
|
||||
{
|
||||
[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();
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
<SolidColorBrush x:Key="Brush.Error" Color="#ef4444"/>
|
||||
<SolidColorBrush x:Key="Brush.ErrorContainer" Color="#93000a"/>
|
||||
<SolidColorBrush x:Key="Brush.OnError" Color="#ffdad6"/>
|
||||
<SolidColorBrush x:Key="Brush.InfoContainer" Color="#3a3b52"/>
|
||||
<SolidColorBrush x:Key="Brush.SuccessContainer" Color="#1d5c38"/>
|
||||
<SolidColorBrush x:Key="Brush.WarningContainer" Color="#b8860b"/>
|
||||
<SolidColorBrush x:Key="Brush.TestContainer" Color="#9c6f1c"/>
|
||||
|
||||
<!-- Colours (for gradients that cannot take a brush) -->
|
||||
<Color x:Key="Color.Background">#0b1326</Color>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user