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:
@@ -6,6 +6,9 @@
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<!-- Design tokens first: every style below resolves its colours, sizes and
|
||||
radii from them (DESIGN.md is the source of truth). -->
|
||||
<ResourceDictionary Source="Themes/Tokens.xaml"/>
|
||||
<ResourceDictionary Source="Themes/Controls.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
|
||||
|
||||
<!-- ============================================================================
|
||||
DESIGN TOKENS — the only place a raw value may live.
|
||||
Source of truth: DESIGN.md (normative). Guard: ytLive.Tests/DesignTokensTests.cs,
|
||||
which fails if a documented token has no brush here, or the values disagree.
|
||||
|
||||
Merge order: App.xaml merges this BEFORE Themes/Controls.xaml, so every style can
|
||||
reference a token instead of a literal.
|
||||
============================================================================ -->
|
||||
|
||||
<!-- Surfaces (dark navy scale) -->
|
||||
<SolidColorBrush x:Key="Brush.Background" Color="#0b1326"/>
|
||||
<SolidColorBrush x:Key="Brush.SurfaceContainerLowest" Color="#060e20"/>
|
||||
<SolidColorBrush x:Key="Brush.SurfaceContainerLow" Color="#131b2e"/>
|
||||
<SolidColorBrush x:Key="Brush.SurfaceContainer" Color="#171f33"/>
|
||||
<SolidColorBrush x:Key="Brush.SurfaceContainerHigh" Color="#222a3d"/>
|
||||
<SolidColorBrush x:Key="Brush.SurfaceContainerHighest" Color="#2d3449"/>
|
||||
<SolidColorBrush x:Key="Brush.SurfaceBright" Color="#31394d"/>
|
||||
<SolidColorBrush x:Key="Brush.Outline" Color="#334155"/>
|
||||
<SolidColorBrush x:Key="Brush.OutlineVariant" Color="#1e293b"/>
|
||||
|
||||
<!-- Text -->
|
||||
<SolidColorBrush x:Key="Brush.OnSurface" Color="#dae2fd"/>
|
||||
<SolidColorBrush x:Key="Brush.OnSurfaceVariant" Color="#94a3b8"/>
|
||||
<SolidColorBrush x:Key="Brush.OnSurfaceDisabled" Color="#5b6478"/>
|
||||
|
||||
<!-- Accent roles: one per meaning, never mixed -->
|
||||
<SolidColorBrush x:Key="Brush.Tertiary" Color="#38bdf8"/>
|
||||
<SolidColorBrush x:Key="Brush.OnTertiary" Color="#0b1326"/>
|
||||
<SolidColorBrush x:Key="Brush.Primary" Color="#ff5451"/>
|
||||
<SolidColorBrush x:Key="Brush.PrimaryContainer" Color="#b91a24"/>
|
||||
<SolidColorBrush x:Key="Brush.Secondary" Color="#ffb95f"/>
|
||||
<SolidColorBrush x:Key="Brush.SecondaryContainer" Color="#ee9800"/>
|
||||
<SolidColorBrush x:Key="Brush.Attention" Color="#ff8c3a"/>
|
||||
<SolidColorBrush x:Key="Brush.Error" Color="#ef4444"/>
|
||||
<SolidColorBrush x:Key="Brush.ErrorContainer" Color="#93000a"/>
|
||||
<SolidColorBrush x:Key="Brush.OnError" Color="#ffdad6"/>
|
||||
|
||||
<!-- Meter bands (measured levels, never gain settings) -->
|
||||
<SolidColorBrush x:Key="Brush.Meter.Nominal" Color="#38bdf8"/>
|
||||
<SolidColorBrush x:Key="Brush.Meter.Caution" Color="#ffb95f"/>
|
||||
<SolidColorBrush x:Key="Brush.Meter.Peak" Color="#ff5451"/>
|
||||
|
||||
<!-- Typography. Inter/JetBrains Mono are bundled; the fallbacks keep the app correct
|
||||
on a machine where the asset failed to load. -->
|
||||
<FontFamily x:Key="FontFamily.Sans">Inter, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="FontFamily.Mono">JetBrains Mono, Cascadia Mono, Consolas</FontFamily>
|
||||
|
||||
<sys:Double x:Key="FontSize.HeadlineLg">32</sys:Double>
|
||||
<sys:Double x:Key="FontSize.HeadlineMd">24</sys:Double>
|
||||
<sys:Double x:Key="FontSize.HeadlineSm">18</sys:Double>
|
||||
<sys:Double x:Key="FontSize.BodyLg">15</sys:Double>
|
||||
<sys:Double x:Key="FontSize.BodyMd">13</sys:Double>
|
||||
<sys:Double x:Key="FontSize.BodySm">12</sys:Double>
|
||||
<sys:Double x:Key="FontSize.LabelLg">13</sys:Double>
|
||||
<sys:Double x:Key="FontSize.LabelMd">11</sys:Double>
|
||||
<sys:Double x:Key="FontSize.LabelSm">10</sys:Double>
|
||||
|
||||
<!-- Spacing -->
|
||||
<sys:Double x:Key="Space.Xs">4</sys:Double>
|
||||
<sys:Double x:Key="Space.Sm">8</sys:Double>
|
||||
<sys:Double x:Key="Space.Md">12</sys:Double>
|
||||
<sys:Double x:Key="Space.Lg">16</sys:Double>
|
||||
<sys:Double x:Key="Space.Xl">24</sys:Double>
|
||||
<sys:Double x:Key="Space.Gutter">12</sys:Double>
|
||||
<sys:Double x:Key="Space.Margin">16</sys:Double>
|
||||
<sys:Double x:Key="Space.MarginDocked">8</sys:Double>
|
||||
|
||||
<!-- Shape -->
|
||||
<CornerRadius x:Key="Radius.Sm">4</CornerRadius>
|
||||
<CornerRadius x:Key="Radius.Md">8</CornerRadius>
|
||||
<CornerRadius x:Key="Radius.Lg">16</CornerRadius>
|
||||
|
||||
<!-- Motion -->
|
||||
<Duration x:Key="Motion.Fast">0:0:0.09</Duration>
|
||||
<Duration x:Key="Motion.Normal">0:0:0.15</Duration>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -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