design: Themes/Tokens.xaml — one place a raw value may live, guarded by DESIGN.md
Tokens.xaml carries every colour, type size, spacing step, radius and motion duration from DESIGN.md; App.xaml merges it BEFORE Controls.xaml so styles can resolve tokens. Guard (DesignTokensTests, 4 facts): the test PARSES DESIGN.md — the colour tables under "## Colour" are the contract — and fails if a documented token has no brush, if the hex disagrees, if the meter bands drift, if Tokens ever merges after Controls, or if the retired brand red #e94560 comes back as a token. Adding a colour to the standard now requires implementing it, and changing the theme without the document is caught. Inter/JetBrains Mono are declared as token families with system fallbacks; the bundled font assets are the next unit. No surface consumes tokens yet — this unit is the foundation only.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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 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]);
|
||||
if (!hex.StartsWith("#", StringComparison.Ordinal) || hex.Length != 7) 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..]));
|
||||
}
|
||||
Reference in New Issue
Block a user