110 hardcoded colours gone from the preview — the biggest surface in the app. Nicknames: - the webcam selection ring is the interactive accent (was the retired brand red) - the mute chips are `attention` (a muted mic is a caution, not a live state) with a white play glyph; the live badge is `primary` - YPP progress bars/track, TRAX level zones, the error line, the dim strips and the poster veil all resolve from tokens (`positive`, `secondary-container`, `error-container`, `scrim-dark`, `scrim-dark-strong`) Plus the duplication this sweep exposed: the webcam's right-click "Border Color" submenu carried **seven hand-written colours**, a second source of truth beside the properties drop-down that TASK 63 had already unified onto `Models/BorderColorPalette.cs` — and the two lists disagreed (7 vs 14). The submenu is now built from the palette when it opens, so every offered colour is the same colour everywhere, with the palette's name beside it. The click handler is unchanged; generated rows inherit the menu's SceneElement DataContext. Guard list extended: Controls/PreviewPane.xaml.
161 lines
7.1 KiB
C#
161 lines
7.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using System.Xml.Linq;
|
|
using Xunit;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>DESIGN.md became the visual standard on 2026-10-30. This is its guard: the document
|
|
/// is the contract, `Themes/Tokens.xaml` is the implementation, and a token documented in one but
|
|
/// missing (or different) in the other fails here — so the palette cannot drift into two truths
|
|
/// the way the 2026-10-26 and 2026-10-29 Stitch exports did.
|
|
///
|
|
/// <para>Deliberately parses the markdown rather than duplicating a list: adding a colour to the
|
|
/// standard requires implementing it, and changing a value in the theme without the document is a
|
|
/// lie the gate catches.</para></summary>
|
|
public class DesignTokensTests
|
|
{
|
|
private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
|
|
|
|
[Fact]
|
|
public void Every_colour_in_the_standard_exists_as_a_token_brush_with_the_documented_value()
|
|
{
|
|
var documented = DocumentedColours();
|
|
Assert.True(documented.Count >= 20,
|
|
$"parsed only {documented.Count} colour tokens out of DESIGN.md — the table format changed");
|
|
|
|
var brushes = Brushes();
|
|
|
|
foreach (var (token, hex) in documented)
|
|
{
|
|
var key = "Brush." + Pascal(token);
|
|
Assert.True(brushes.ContainsKey(key),
|
|
$"DESIGN.md documents `{token}` ({hex}) but Themes/Tokens.xaml has no `{key}` brush");
|
|
Assert.Equal(hex, brushes[key], ignoreCase: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void The_meter_bands_are_the_standard_three()
|
|
{
|
|
var brushes = Brushes();
|
|
|
|
// DESIGN.md -> "Meter thresholds": nominal to -12 dB, caution to -3 dB, red above.
|
|
Assert.Equal("#38bdf8", brushes["Brush.Meter.Nominal"], ignoreCase: true);
|
|
Assert.Equal("#ffb95f", brushes["Brush.Meter.Caution"], ignoreCase: true);
|
|
Assert.Equal("#ff5451", brushes["Brush.Meter.Peak"], ignoreCase: true);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tokens_merge_before_the_theme_so_styles_can_resolve_them()
|
|
{
|
|
var app = XDocument.Load(SourceAssert.RepoPath("App.xaml"));
|
|
var sources = app.Descendants()
|
|
.Where(e => e.Name.LocalName == "ResourceDictionary")
|
|
.Select(e => (string?)e.Attribute("Source"))
|
|
.Where(s => s != null)
|
|
.ToList();
|
|
|
|
var tokens = sources.FindIndex(s => s!.EndsWith("Themes/Tokens.xaml", StringComparison.Ordinal));
|
|
var controls = sources.FindIndex(s => s!.EndsWith("Themes/Controls.xaml", StringComparison.Ordinal));
|
|
|
|
Assert.True(tokens >= 0, "App.xaml does not merge Themes/Tokens.xaml");
|
|
Assert.True(controls >= 0, "App.xaml no longer merges Themes/Controls.xaml");
|
|
Assert.True(tokens < controls,
|
|
"Themes/Tokens.xaml must merge BEFORE Themes/Controls.xaml, or styles cannot resolve a token");
|
|
}
|
|
|
|
[Fact]
|
|
public void Converted_surfaces_carry_no_raw_colour()
|
|
{
|
|
// The sweep converts one surface per commit; each adds its file here, so every step is
|
|
// guarded on its own instead of waiting for a global grep at the end. Pure white and black
|
|
// are allowed (gloss highlights, knockout masks) — everything else must be a token.
|
|
string[] converted = { "Themes/Controls.xaml", "MainWindow.xaml", "Controls/TopBar.xaml", "Controls/SceneThumbnailStrip.xaml",
|
|
"Controls/LeftPanel.xaml",
|
|
"Controls/PreviewPane.xaml" };
|
|
|
|
foreach (var file in converted)
|
|
{
|
|
var text = SourceAssert.Text(file.Split('/'));
|
|
var raw = Regex.Matches(text, @"#[0-9a-fA-F]{6,8}")
|
|
.Select(m => m.Value)
|
|
.Where(v => !v.Equals("#ffffff", StringComparison.OrdinalIgnoreCase)
|
|
&& !v.Equals("#000000", StringComparison.OrdinalIgnoreCase))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
Assert.True(raw.Count == 0,
|
|
$"{file} still hardcodes {string.Join(", ", raw)} — resolve a Brush.* token instead");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void The_retired_brand_red_is_not_a_token()
|
|
{
|
|
// #e94560 was the general accent in 15 files. It is retired (DESIGN.md, 2026-10-30):
|
|
// blue is interactive, red means live/record. Re-introducing it as a token would undo
|
|
// the rule, so it must not appear anywhere in the token sheet.
|
|
Assert.DoesNotContain("#e94560", File.ReadAllText(SourceAssert.RepoPath("Themes", "Tokens.xaml")),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>The `| Token | Hex |` tables under "## Colour", stopping at "## Typography".
|
|
/// The "Rejected from the export" table lives further down and must not count.</summary>
|
|
private static Dictionary<string, string> DocumentedColours()
|
|
{
|
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
var inColour = false;
|
|
|
|
foreach (var raw in File.ReadAllLines(SourceAssert.RepoPath("DESIGN.md")))
|
|
{
|
|
var line = raw.Trim();
|
|
if (line.StartsWith("## ", StringComparison.Ordinal))
|
|
{
|
|
inColour = line.StartsWith("## Colour", StringComparison.Ordinal);
|
|
continue;
|
|
}
|
|
if (!inColour || !line.StartsWith("| `", StringComparison.Ordinal)) continue;
|
|
|
|
var cells = line.Split('|').Select(c => c.Trim()).Where(c => c.Length > 0).ToArray();
|
|
if (cells.Length < 2) continue;
|
|
|
|
// The token cell may carry a note after the backticked name("`tertiary` (electric blue)").
|
|
var token = Backticked(cells[0]);
|
|
var hex = Backticked(cells[1]);
|
|
// 6-digit RGB or 8-digit ARGB (the scrim washes carry alpha).
|
|
if (!hex.StartsWith("#", StringComparison.Ordinal) || hex.Length is not (7 or 9)) continue;
|
|
result[token] = hex;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static Dictionary<string, string> Brushes()
|
|
{
|
|
var dict = XDocument.Load(SourceAssert.RepoPath("Themes", "Tokens.xaml"));
|
|
return dict.Descendants()
|
|
.Where(e => e.Name.LocalName == "SolidColorBrush")
|
|
.Select(e => ((string?)e.Attribute(Xaml + "Key"), (string?)e.Attribute("Color")))
|
|
.Where(p => p.Item1 != null && p.Item2 != null)
|
|
.ToDictionary(p => p.Item1!, p => p.Item2!, StringComparer.Ordinal);
|
|
}
|
|
|
|
/// <summary>The first `backticked` run in a markdown cell, or the trimmed cell itself.</summary>
|
|
private static string Backticked(string cell)
|
|
{
|
|
var start = cell.IndexOf('`');
|
|
if (start < 0) return cell.Trim();
|
|
var end = cell.IndexOf('`', start + 1);
|
|
return end < 0 ? cell.Trim() : cell[(start + 1)..end].Trim();
|
|
}
|
|
|
|
/// <summary>`surface-container-lowest` → `SurfaceContainerLowest`.</summary>
|
|
private static string Pascal(string token) =>
|
|
string.Concat(token.Split('-', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
|
|
}
|