From f2bf577f34488fd58a146efb004fab2b5f2833cd Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 07:43:17 -0700 Subject: [PATCH] =?UTF-8?q?design:=20Themes/Tokens.xaml=20=E2=80=94=20one?= =?UTF-8?q?=20place=20a=20raw=20value=20may=20live,=20guarded=20by=20DESIG?= =?UTF-8?q?N.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App.xaml | 29 ++++--- DESIGN.md | 12 ++- Themes/Tokens.xaml | 81 ++++++++++++++++++ ytLive.Tests/DesignTokensTests.cs | 133 ++++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 Themes/Tokens.xaml create mode 100644 ytLive.Tests/DesignTokensTests.cs diff --git a/App.xaml b/App.xaml index 0c688a0..396eaf0 100644 --- a/App.xaml +++ b/App.xaml @@ -1,13 +1,16 @@ - - - - - - - - - + + + + + + + + + + + diff --git a/DESIGN.md b/DESIGN.md index f5028ab..1f3bbc3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -171,7 +171,11 @@ rejected in favour of the values above. ## Implementation status -- **Not yet wired.** `Themes/Controls.xaml` still carries the legacy palette and the dozens of - hardcoded hexes; `Themes/Tokens.xaml` lands next, then surfaces are converted one at a time. -- Target end state: every colour, radius and spacing value resolves from `Tokens.xaml`; a guard - test fails on any raw `#hex` outside it. +- **`Themes/Tokens.xaml` exists and is merged first in `App.xaml`** (2026-10-30). Guard: + `ytLive.Tests/DesignTokensTests.cs` — it parses this document and fails if a documented token has + no brush, if a value disagrees, or if the retired `#e94560` returns as a token. +- **Still to sweep:** `Themes/Controls.xaml` and the dozens of hardcoded hexes in XAML/C# still use + the legacy palette. Surfaces convert one at a time; the end state is that every colour, radius and + spacing value resolves from `Tokens.xaml` and a guard fails on any raw `#hex` outside it. +- Fonts (Inter, JetBrains Mono) are declared as token families with system fallbacks; the bundled + font assets land next. diff --git a/Themes/Tokens.xaml b/Themes/Tokens.xaml new file mode 100644 index 0000000..11d28ad --- /dev/null +++ b/Themes/Tokens.xaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inter, Segoe UI + JetBrains Mono, Cascadia Mono, Consolas + + 32 + 24 + 18 + 15 + 13 + 12 + 13 + 11 + 10 + + + 4 + 8 + 12 + 16 + 24 + 12 + 16 + 8 + + + 4 + 8 + 16 + + + 0:0:0.09 + 0:0:0.15 + + diff --git a/ytLive.Tests/DesignTokensTests.cs b/ytLive.Tests/DesignTokensTests.cs new file mode 100644 index 0000000..268ca7a --- /dev/null +++ b/ytLive.Tests/DesignTokensTests.cs @@ -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; + +/// 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. +/// +/// 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. +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); + } + + /// The `| Token | Hex |` tables under "## Colour", stopping at "## Typography". + /// The "Rejected from the export" table lives further down and must not count. + private static Dictionary DocumentedColours() + { + var result = new Dictionary(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 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); + } + + /// The first `backticked` run in a markdown cell, or the trimmed cell itself. + 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(); + } + + /// `surface-container-lowest` → `SurfaceContainerLowest`. + private static string Pascal(string token) => + string.Concat(token.Split('-', StringSplitOptions.RemoveEmptyEntries) + .Select(part => char.ToUpperInvariant(part[0]) + part[1..])); +}