diff --git a/Services/AlertRenderer.cs b/Services/AlertRenderer.cs index 95a0279..bafb8de 100644 --- a/Services/AlertRenderer.cs +++ b/Services/AlertRenderer.cs @@ -3,6 +3,7 @@ using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using ytLive.Models; +using ytLive.Services.Compositor; namespace ytLive.Services; @@ -27,9 +28,9 @@ public sealed class AlertRenderer private static readonly Color White = Colors.White; private static readonly Color[] ConfettiPalette = { - Color.FromRgb(0xf4, 0x2f, 0x5f), Color.FromRgb(0xff, 0xd1, 0x66), - Color.FromRgb(0x38, 0xb6, 0xf8), Color.FromRgb(0xa7, 0x8b, 0xfa), - Color.FromRgb(0x39, 0xd3, 0x53), + OutputPalette.Crimson, OutputPalette.Gold, + OutputPalette.Tertiary, OutputPalette.Violet, + OutputPalette.Positive, }; public VideoFrame Render(ChatMessage e, double t01, int width, int height) @@ -70,10 +71,10 @@ public sealed class AlertRenderer var shownAmount = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : $"${e.SuperChatAmount:0.00}"); var fontSize = cardH * 0.24; - DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW); + DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, OutputPalette.Gold, cardW); DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.56, cardH * 0.13, FontWeights.SemiBold, White, cardW); - DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xdd, 0xff, 0xf0), cardW * 0.92); + DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, OutputPalette.MintText, cardW * 0.92); if (t >= EnterEnd && t <= ExitStart) { @@ -122,10 +123,10 @@ public sealed class AlertRenderer var desc = string.IsNullOrWhiteSpace(e.SuperStickerDescription) ? "SUPER STICKER" : e.SuperStickerDescription.ToUpperInvariant(); DrawCentered(ctx, desc, cardCX, h / 2.0 - cardH * 0.12, cardH * 0.17, FontWeights.Bold, White, cardW * 0.9); - DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, Color.FromRgb(0xff, 0xb3, 0xd9), cardW * 0.9); + DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, OutputPalette.Pink, cardW * 0.9); var tier = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : null); if (!string.IsNullOrWhiteSpace(tier)) - DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9); + DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9); ctx.Pop(); ctx.Pop(); @@ -163,10 +164,10 @@ public sealed class AlertRenderer } DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.36, cardH * 0.22, FontWeights.Bold, White, cardW * 0.9); - DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, Color.FromRgb(0xf5, 0xd0, 0xfe), cardW * 0.9); + DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, OutputPalette.Lilac, cardW * 0.9); var level = string.IsNullOrWhiteSpace(e.MembershipLevel) ? "MEMBER" : e.MembershipLevel.ToUpperInvariant(); - DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9); + DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9); } return CreateFrame(visual, w, h, dpi); } @@ -193,7 +194,7 @@ public sealed class AlertRenderer var count = Math.Max(1, e.GiftNewMembersCount); var spread = 1.0 - exit; var hero = count > 1 ? $"×{count}" : "MEMBERSHIP"; - DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.8); + DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, OutputPalette.Gold, cardW * 0.8); DrawCentered(ctx, $"{e.AuthorName} is gifting", cardCX, margin + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9); var chipCount = Math.Min(count, 6); @@ -204,7 +205,7 @@ public sealed class AlertRenderer var chipX = cardCX + (i - slot) * gap; var chipY = margin + cardH * 0.70 + Math.Sin((i + t * 6) * 2.4) * cardH * 0.015; ctx.DrawRoundedRectangle( - new SolidColorBrush(Color.FromRgb(0xff, 0xf3, 0xc4)), + new SolidColorBrush(OutputPalette.GoldPale), null, new Rect(chipX - cardH * 0.05, chipY, cardH * 0.10, cardH * 0.10), cardH * 0.03, cardH * 0.03); @@ -252,10 +253,10 @@ public sealed class AlertRenderer } } - DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9); + DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9); var giver = string.IsNullOrWhiteSpace(e.GifterName) ? e.AuthorName : e.GifterName; DrawCentered(ctx, giver, cardCX, margin + cardH * 0.42, cardH * 0.20, FontWeights.Bold, White, cardW * 0.9); - DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, Color.FromRgb(0xdb, 0xff, 0xf0), cardW * 0.9); + DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, OutputPalette.MintText, cardW * 0.9); ctx.Pop(); ctx.Pop(); @@ -283,17 +284,17 @@ public sealed class AlertRenderer DrawBrand(ctx, margin, cardY, cardW, cardH); var hero = e.MilestoneMonths > 0 ? $"{e.MilestoneMonths}" : "MILESTONE"; - DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, Color.FromRgb(0xff, 0xdd, 0x66), cardW * 0.8); + DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, OutputPalette.GoldBright, cardW * 0.8); var sub = e.MilestoneMonths > 0 ? "MONTHS STRONG" : "SUPPORTING THE STREAM"; DrawCentered(ctx, sub, cardCX, cardY + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9); - DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xff, 0xe8, 0xd0), cardW * 0.9); + DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, OutputPalette.WarmCream, cardW * 0.9); // Tenure bar: grows through the first half, retracts on exit. var barP = Clamp01((t - 0.10) / 0.55) * (1 - exit); var barW = cardW * 0.6 * barP; ctx.DrawRoundedRectangle( - new SolidColorBrush(Color.FromRgb(0xff, 0xd1, 0x66)), + new SolidColorBrush(OutputPalette.Gold), null, new Rect(cardCX - barW / 2, cardY + cardH * 0.84, barW, cardH * 0.045), 4, 4); @@ -313,7 +314,7 @@ public sealed class AlertRenderer { var formatted = new FormattedText( Brand, Invariant, FlowDirection.LeftToRight, - new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), + new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), Math.Max(9, h * 0.055), new SolidColorBrush(Color.FromArgb(0x88, 0xff, 0xff, 0xff)), 96.0); @@ -324,7 +325,7 @@ public sealed class AlertRenderer { var formatted = new FormattedText( text, Invariant, FlowDirection.LeftToRight, - new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal), + new Typeface(OutputPalette.Sans, FontStyles.Normal, weight, FontStretches.Normal), size, new SolidColorBrush(color), 96.0); diff --git a/Services/Compositor/AlertTickerRenderer.cs b/Services/Compositor/AlertTickerRenderer.cs index f8d183d..04df117 100644 --- a/Services/Compositor/AlertTickerRenderer.cs +++ b/Services/Compositor/AlertTickerRenderer.cs @@ -143,7 +143,7 @@ public static class AlertTickerRenderer const double padX = 16; const double gap = 8; - var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); + var font = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); var ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, font, 22, Brushes.White, 1.0); @@ -156,7 +156,7 @@ public static class AlertTickerRenderer { var pill = new Rect(0, top, pillW, pillHeight); dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(0xE6, 0x10, 0x13, 0x24)), null, pill, pillHeight / 2, pillHeight / 2); - dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromRgb(0xe9, 0x45, 0x60)), null, + dc.DrawRoundedRectangle(new SolidColorBrush(OutputPalette.Live), null, new Rect(padX, top + (pillHeight - 12) / 2.0, accentW, 12), 2, 2); dc.DrawText(ft, new Point(padX + accentW + gap, textTop)); } diff --git a/Services/Compositor/BrandFlashPresenter.cs b/Services/Compositor/BrandFlashPresenter.cs index 0df8b6f..af93e52 100644 --- a/Services/Compositor/BrandFlashPresenter.cs +++ b/Services/Compositor/BrandFlashPresenter.cs @@ -53,8 +53,8 @@ public sealed class BrandFlashPresenter : IDisposable /// red and blue channels pushed in OPPOSITE directions — Maxon's "Chromatic Glow" /// notes the green channel is all but invisible behind white text once R and B are /// split, which is exactly the look. - private static readonly Color NeonRed = Color.FromRgb(0xe9, 0x45, 0x60); // brand red, exact - private static readonly Color NeonBlue = Color.FromRgb(0x4d, 0x8b, 0xff); // brand blue #0f3460 lifted for luminance + private static readonly Color NeonRed = OutputPalette.NeonRed; // one red: the standard live red + private static readonly Color NeonBlue = OutputPalette.NeonBlue; // lifted for luminance against video private static readonly Color CoreColor = Colors.White; /// Font size chosen so the credit spans roughly half the master width: @@ -354,7 +354,7 @@ public sealed class BrandFlashPresenter : IDisposable return result; } - var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, + var font = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); var probe = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, font, FontSize, Brushes.White, 1.0); diff --git a/Services/Compositor/OutputPalette.cs b/Services/Compositor/OutputPalette.cs new file mode 100644 index 0000000..7e58943 --- /dev/null +++ b/Services/Compositor/OutputPalette.cs @@ -0,0 +1,80 @@ +using System.Windows.Media; + +namespace ytLive.Services.Compositor; + +/// The colours and typefaces the ON-STREAM graphics paint with. +/// +/// Why this exists rather than reading Themes/Tokens.xaml: the renderers run on the +/// frame-pump thread and must never touch application resources or a dispatcher. They need frozen +/// values they can read from any thread. +/// +/// Two groups live here, deliberately separated: +/// +/// Brand tokens — a mirror of DESIGN.md. OutputPaletteTests parses the design +/// document and fails if any of these drifts from the theme, so the duplication cannot rot. +/// Event accents — the alert box's own six-event colour language (gold for Super +/// Chats, violet for members, …). These are content, not chrome: the design standard has no +/// purple and should not grow one, but they still belong in ONE place instead of 57 scattered +/// literals. +/// +public static class OutputPalette +{ + // ─── Brand tokens (must equal Themes/Tokens.xaml; guarded) ──────────────── + public static readonly Color Live = Color.FromRgb(0xff, 0x54, 0x51); // primary + public static readonly Color LiveDark = Color.FromRgb(0xb9, 0x1a, 0x24); // primary-container + public static readonly Color Amber = Color.FromRgb(0xff, 0xb9, 0x5f); // secondary + public static readonly Color Attention = Color.FromRgb(0xff, 0x8c, 0x3a); + public static readonly Color Positive = Color.FromRgb(0x22, 0xc5, 0x5e); + public static readonly Color Error = Color.FromRgb(0xef, 0x44, 0x44); + public static readonly Color ErrorSurface = Color.FromRgb(0x93, 0x00, 0x0a); + public static readonly Color Tertiary = Color.FromRgb(0x38, 0xbd, 0xf8); + public static readonly Color TextPrimary = Color.FromRgb(0xda, 0xe2, 0xfd); + public static readonly Color TextMuted = Color.FromRgb(0x94, 0xa3, 0xb8); + public static readonly Color Background = Color.FromRgb(0x0b, 0x13, 0x26); + public static readonly Color Surface = Color.FromRgb(0x17, 0x1f, 0x33); + public static readonly Color SurfaceHigh = Color.FromRgb(0x22, 0x2a, 0x3d); + public static readonly Color Outline = Color.FromRgb(0x33, 0x41, 0x55); + + // ─── Alert event accents (content language, not chrome) ─────────────────── + public static readonly Color Gold = Color.FromRgb(0xff, 0xd1, 0x66); + public static readonly Color GoldBright = Color.FromRgb(0xff, 0xdd, 0x66); + public static readonly Color GoldPale = Color.FromRgb(0xff, 0xf3, 0xc4); + public static readonly Color WarmCream = Color.FromRgb(0xff, 0xe8, 0xd0); + public static readonly Color Pink = Color.FromRgb(0xff, 0xb3, 0xd9); + public static readonly Color Lilac = Color.FromRgb(0xf5, 0xd0, 0xfe); + public static readonly Color Crimson = Color.FromRgb(0xf4, 0x2f, 0x5f); + public static readonly Color MintText = Color.FromRgb(0xdb, 0xff, 0xf0); + public static readonly Color Violet = Color.FromRgb(0xa7, 0x8b, 0xfa); + + /// The branding flash's halo channels. Red is the standard live red — one red in the + /// product, including the credit — and the blue is lifted for luminance against a video feed. + public static readonly Color NeonRed = Live; + public static readonly Color NeonBlue = Color.FromRgb(0x4d, 0x8b, 0xff); + + // ─── Typefaces ─────────────────────────────────────────────────────────── + /// Bundled families (OFL). Resolved from the pack folder so on-stream graphics wear + /// the same faces as the UI; a failure falls back to the system face rather than throwing + /// inside a render tick. + private static readonly System.Lazy SansFamily = new(() => Family("Inter")); + private static readonly System.Lazy MonoFamily = new(() => Family("JetBrains Mono")); + + public static FontFamily Sans => SansFamily.Value; + public static FontFamily Mono => MonoFamily.Value; + + /// The pack scheme only exists once WPF has registered it, so a static initializer + /// must not build a pack URI: it throws a TypeInitializationException in any host without an + /// Application (exactly what the first version did in the test process). Fall back to the + /// family name, which resolves from the bundled resource or the system face. + private static FontFamily Family(string name) + { + try { return new FontFamily(new System.Uri("pack://application:,,,/"), "/Assets/Fonts/#" + name); } + catch (System.Exception) { return new FontFamily(name); } + } + + public static SolidColorBrush Brush(Color colour) + { + var brush = new SolidColorBrush(colour); + brush.Freeze(); + return brush; + } +} diff --git a/ytLive.Tests/OutputPaletteTests.cs b/ytLive.Tests/OutputPaletteTests.cs new file mode 100644 index 0000000..0ac124a --- /dev/null +++ b/ytLive.Tests/OutputPaletteTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using System.Windows.Media; +using Xunit; +using ytLive.Services.Compositor; + +namespace ytLive.Tests; + +/// On-stream graphics cannot read application resources (the frame pump is not the UI +/// thread), so their colours live in . That duplication is safe only +/// if it is checked: this test proves every brand token there equals the brush in +/// Themes/Tokens.xaml, and that the renderers carry no colour literals of their own. +/// +/// Without it, the broadcast picture would drift away from the UI one "quick fix" at a +/// time — the exact failure mode DESIGN.md exists to stop. +public class OutputPaletteTests +{ + [Fact] + public void The_on_stream_palette_matches_the_theme_and_the_renderers_carry_no_literals() + { + var brushes = Tokens(); + + var mirrored = new (string Brush, Color Colour)[] + { + ("Brush.Primary", OutputPalette.Live), + ("Brush.PrimaryContainer", OutputPalette.LiveDark), + ("Brush.Secondary", OutputPalette.Amber), + ("Brush.Attention", OutputPalette.Attention), + ("Brush.Positive", OutputPalette.Positive), + ("Brush.Error", OutputPalette.Error), + ("Brush.ErrorContainer", OutputPalette.ErrorSurface), + ("Brush.Tertiary", OutputPalette.Tertiary), + ("Brush.OnSurface", OutputPalette.TextPrimary), + ("Brush.OnSurfaceVariant", OutputPalette.TextMuted), + ("Brush.Background", OutputPalette.Background), + ("Brush.SurfaceContainer", OutputPalette.Surface), + ("Brush.SurfaceContainerHigh", OutputPalette.SurfaceHigh), + ("Brush.Outline", OutputPalette.Outline), + }; + + foreach (var (brushKey, colour) in mirrored) + { + Assert.True(brushes.ContainsKey(brushKey), $"{brushKey} missing from Themes/Tokens.xaml"); + Assert.True(brushes[brushKey] == colour, + $"{brushKey} is {brushes[brushKey]} in the theme but {colour} in OutputPalette — the " + + "broadcast picture and the UI have drifted apart"); + } + + // The renderers must not introduce colours of their own; the alert accents belong to + // OutputPalette too, so one file owns every on-stream value. + foreach (var file in new[] + { + "Services/AlertRenderer.cs", "Services/Compositor/AlertTickerRenderer.cs", + "Services/Compositor/BrandFlashPresenter.cs", + }) + { + var text = SourceAssert.Text(file.Split('/')); + Assert.DoesNotContain("Color.FromRgb(0x", text); + Assert.DoesNotContain("\"Segoe UI\"", text); + } + } + + private static Dictionary Tokens() + { + var text = SourceAssert.Text("Themes", "Tokens.xaml"); + var result = new Dictionary(StringComparer.Ordinal); + foreach (Match m in Regex.Matches(text, "x:Key=\"(Brush\\.[^\"]+)\"\\s+Color=\"#([0-9a-fA-F]{6,8})\"")) + { + var hex = m.Groups[2].Value; + if (hex.Length == 8) hex = hex[2..]; // drop the alpha the palette does not mirror + var r = int.Parse(hex[..2], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var g = int.Parse(hex[2..4], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var b = int.Parse(hex[4..6], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + result[m.Groups[1].Value] = Color.FromRgb((byte)r, (byte)g, (byte)b); + } + return result; + } +}