Skip to content

Commit 33d48e0

Browse files
mpstatonclaude
andcommitted
feat(docs-portal): Phase 2a — the swatch page, and the first look at the token system
Closes the gap the Phase 1 handoff called its defining caveat: 35 tokens shipped and verified entirely by static analysis and contrast arithmetic, with not one rendered in a browser. Spec: context-v/specs/Design-System-Portal.md §3. A standalone app at apps/docs-portal on :3020 — deliberately not a federation remote, since it is not in DESIGN.md's member registry and needs the full theme.css for its mode toggle to have anything to switch between. Its own chrome is built only from Tier-2 tokens, so a broken token cannot hide behind a colour the page smuggled in. Two rules keep it honest. It never paints from the manifest's recorded values — it sets data-mode and lets the browser resolve var() through the real cascade, because a page painted from JSON proves the JSON is well-formed and nothing about the stylesheet. And contrast is measured from the painted cell, not computed from the theme source. design-manifest.json is now generated alongside token-baseline.css from the same theme.css parse. DESIGN.md's frontmatter already named it as `full_manifest` and the spec already read from it; it just did not exist. `tokens:check` now guards both, so the portal cannot disagree with the theme about which tokens exist. WHAT LOOKING AT IT FOUND, INCLUDING IN ITSELF Three defects in the page, each invisible to a build and a typecheck: Every contrast cell read as unmeasured. getPropertyValue on a custom property returns the substituted value, which in this theme is a hex string, and a naive rgb parse matched the digits inside the hex. Measuring the painted cell fixes it and makes the code match what its own comment claimed. Every reading was taken mid-transition. theme.css:289 animates colour over 75ms on a mode swap, so a measurement two frames after the switch caught colours still moving — it reported the dark page background as light grey and every vibrant foreground token as failing. Measurement now suppresses transitions and forces a style flush first. Grading the full cross-product produced 75 failures, none real: the grid pairs every token with every surface, and most pairings the product never makes. Only foreground tokens are graded now; structural ones show a measurement without a verdict. A checker that cries wolf is one nobody reads. Two findings that were the page misclassifying, not the theme: --color-thread was graded as text at 4.5:1. DESIGN.md classifies it as a line that tracks --color-border, target 3. Moved to the boundary rule. --color-on-accent was graded against surfaces it never touches, producing 15 phantom failures. DESIGN.md calls it mandatory on accent fills, so it now has its own accent-fill section testing it where it is actually used. It passes comfortably — 5.19 to 14.11 across every fill and mode. WHAT IT FOUND IN THE THEME A22 reproduced visually: --color-border misses 3:1 on all five surfaces in all three modes, 1.0 to 1.29 in dark. A22 is wider than recorded: --color-thread carries the border value, so the chat thread rail sits below the boundary bar too. The gate names only the border. A3's solve does not hold: --color-border-strong was introduced with a new Tier-1 step per ramp "solved for >=3:1 against every surface in its mode". Measured, it fails on surface in dark (2.73), on bg-elevated in vibrant, and on ALL FIVE surfaces in light. Raised separately rather than folded in here. Verified: 20 packages build, portal 74 files typecheck clean, all three modes render distinct and correct backgrounds, toggle stays in sync with data-mode via onModeChange. Files changed: - apps/docs-portal/** (new) - design-manifest.json (new, generated) - scripts/generate-token-baseline.mjs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019a8tSPbFdvF1pKtADnWyDg
1 parent 96ad2f7 commit 33d48e0

10 files changed

Lines changed: 738 additions & 4 deletions

File tree

apps/docs-portal/package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "@augment-it/docs-portal",
3+
"version": "0.0.1",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "rsbuild dev",
8+
"build": "rsbuild build",
9+
"preview": "rsbuild preview",
10+
"check": "svelte-check --tsconfig ./tsconfig.json"
11+
},
12+
"dependencies": {
13+
"@augment-it/theme": "workspace:*",
14+
"svelte": "^5.56.4"
15+
},
16+
"devDependencies": {
17+
"@rsbuild/core": "^2.1.2",
18+
"@rsbuild/plugin-svelte": "^2.0.0",
19+
"svelte-check": "^4.7.1",
20+
"typescript": "^6.0.3"
21+
}
22+
}

apps/docs-portal/rsbuild.config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { defineConfig } from '@rsbuild/core';
2+
import { pluginSvelte } from '@rsbuild/plugin-svelte';
3+
4+
// The design-system portal. Deliberately NOT a federation remote — it is not in
5+
// DESIGN.md's member registry, it is a development surface for looking at the
6+
// token system, and it must load the FULL theme.css (all three mode blocks) so
7+
// the mode toggle has something to switch between.
8+
export default defineConfig({
9+
plugins: [pluginSvelte()],
10+
source: { entry: { index: './src/index.ts' } },
11+
output: {
12+
target: 'web',
13+
overrideBrowserslist: ['last 2 Chrome versions', 'last 2 Firefox versions', 'last 2 Safari versions'],
14+
},
15+
tools: { swc: { jsc: { target: 'es2022' } } },
16+
html: { title: 'augment-it · design system' },
17+
server: { port: 3020 },
18+
dev: { assetPrefix: 'http://localhost:3020' },
19+
});

apps/docs-portal/src/App.svelte

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<script lang="ts">
2+
// Phase 2a — the P0 swatch page.
3+
// Spec: context-v/specs/Design-System-Portal.md §3
4+
//
5+
// The point of this page is that it is the FIRST surface on which the token
6+
// system has ever been looked at. Phases 0 and 1 shipped 35 tokens verified
7+
// entirely by static analysis and contrast arithmetic; not one had been
8+
// rendered in a browser. Everything here is deliberately shallow so that what
9+
// you are judging is the tokens, not the page.
10+
//
11+
// Two rules keep it honest:
12+
//
13+
// 1. It NEVER paints from the manifest's recorded values. It sets data-mode
14+
// and lets the browser resolve `var(--token)` through the real cascade.
15+
// A page that painted from JSON would prove the JSON is well-formed and
16+
// nothing about the stylesheet.
17+
// 2. Contrast is measured from getComputedStyle AFTER paint, not computed
18+
// from the theme source. Same reason.
19+
import { onMount } from 'svelte';
20+
import { MODES, getMode, setMode, onModeChange, type Mode } from '@augment-it/theme/mode-switcher';
21+
import manifest from '../../../design-manifest.json';
22+
23+
const SURFACES: string[] = manifest.surfaces;
24+
const COLOR_TOKENS: string[] = manifest.groups.color;
25+
const EFFECT_TOKENS: string[] = manifest.groups.effect;
26+
const TIER1 = Object.entries(manifest.tier1 as Record<string, string>)
27+
.filter(([n]) => n.startsWith('--color__'));
28+
29+
let mode = $state<Mode>('dark');
30+
let ratios = $state<Record<string, number>>({});
31+
let probe: HTMLDivElement;
32+
33+
function luminance(rgb: number[]): number {
34+
const a = rgb.map((v) => {
35+
const s = v / 255;
36+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
37+
});
38+
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
39+
}
40+
41+
function parseRgb(s: string): number[] | null {
42+
const m = s.match(/\d+(\.\d+)?/g);
43+
return m && m.length >= 3 ? m.slice(0, 3).map(Number) : null;
44+
}
45+
46+
/**
47+
* Measure every (token, surface) pair from the PAINTED cell.
48+
*
49+
* Not from the custom-property values: getPropertyValue('--color-text')
50+
* returns the substituted token value, which in this theme is a hex string
51+
* (`#e8eaf0`), and a naive rgb parse silently matches the digits inside the
52+
* hex and returns nonsense. Reading `color` / `backgroundColor` off the cell
53+
* always yields `rgb(...)`, and — more importantly — it measures what the
54+
* browser actually rendered rather than what the stylesheet says it should.
55+
* That distinction is the whole reason this page exists.
56+
*/
57+
function measure(): void {
58+
if (!probe) return;
59+
// theme.css:289 transitions background-color/color/border-color over 75ms
60+
// on the theme swap. Measuring inside that window reads colours that are
61+
// still animating — it reported the dark page background as a light grey
62+
// and every vibrant foreground token as failing, none of which was real.
63+
// Suppress transitions, force a synchronous style flush, measure the
64+
// settled values, then restore.
65+
document.documentElement.classList.add('measuring');
66+
void document.documentElement.offsetHeight;
67+
const next: Record<string, number> = {};
68+
for (const cell of probe.querySelectorAll<HTMLElement>('.swatch[data-token]')) {
69+
const token = cell.dataset.token;
70+
const surface = cell.dataset.surface;
71+
if (!token || !surface) continue;
72+
const cs = getComputedStyle(cell);
73+
const fg = parseRgb(cs.color);
74+
const bg = parseRgb(cs.backgroundColor);
75+
if (!fg || !bg) continue;
76+
const l1 = luminance(fg);
77+
const l2 = luminance(bg);
78+
next[`${token}|${surface}`] = Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100;
79+
}
80+
const acc: Record<string, number> = {};
81+
for (const cell of probe.querySelectorAll<HTMLElement>('.accent-cell[data-fill]')) {
82+
const cs = getComputedStyle(cell);
83+
const fg = parseRgb(cs.color);
84+
const bg = parseRgb(cs.backgroundColor);
85+
if (!fg || !bg) continue;
86+
const l1 = luminance(fg);
87+
const l2 = luminance(bg);
88+
acc[cell.dataset.fill!] = Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100;
89+
}
90+
document.documentElement.classList.remove('measuring');
91+
ratios = next;
92+
accentRatios = acc;
93+
}
94+
95+
function pick(next: Mode): void {
96+
// setMode dispatches mode-change, which the onModeChange subscription above
97+
// turns into the state update + remeasure. One path, so a click and an
98+
// external change behave identically.
99+
setMode(next);
100+
}
101+
102+
onMount(() => {
103+
mode = getMode();
104+
remeasure();
105+
// The mode can change from outside this component — another tab, or (once
106+
// the portal is federated) the shell, which owns data-mode for every
107+
// remote. Without this the toggle keeps showing whatever was last clicked
108+
// here while the page renders something else entirely.
109+
return onModeChange((next) => {
110+
mode = next;
111+
remeasure();
112+
});
113+
});
114+
115+
/** Two frames: one for the attribute to land, one for the cascade to settle. */
116+
function remeasure(): void {
117+
requestAnimationFrame(() => requestAnimationFrame(measure));
118+
}
119+
120+
// Only FOREGROUND tokens get a pass/fail grade.
121+
//
122+
// The grid is a full cross-product, so most cells are pairings the product
123+
// never makes — `--color-surface` painted as text on `--color-surface-2` is
124+
// not a thing. Grading all 150 produced 75 "failures", none of which were
125+
// real, and a checker that cries wolf is one nobody reads. Structural tokens
126+
// still show their measured ratio (useful for spotting a token that has
127+
// collided with a surface), just without a verdict attached.
128+
// --color-on-accent is deliberately absent: DESIGN.md calls it "mandatory on
129+
// any accent fill — never assume white", so it belongs ON the accent, never
130+
// on a surface. Grading it against surfaces reported 15 failures that were
131+
// purely an artefact of testing it somewhere it is never used. It gets its
132+
// own accent-fill check below instead.
133+
const FOREGROUND = /^--color-(text|link|accent|accent-2|accent-warm|ok-text|error-text|warn-text|confidence-)/;
134+
const isForeground = (token: string) => FOREGROUND.test(token);
135+
136+
// Structural tokens are meant to be seen AS a surface or a line, not read
137+
// through. 3:1 is the WCAG 1.4.11 bar for a UI boundary.
138+
// --color-thread is here on DESIGN.md's own classification: "Connector lines
139+
// — the chat thread rail. A line, so it tracks --color-border", target 3.
140+
const BOUNDARY = /^--color-(border|border-strong|focus|thread)/;
141+
142+
// The one pairing DESIGN.md makes mandatory, tested where it actually occurs.
143+
const ACCENT_FILLS = ['--color-accent', '--color-accent-2', '--color-accent-warm'];
144+
let accentRatios = $state<Record<string, number>>({});
145+
146+
// WCAG AA: 4.5:1 for body text, 3:1 for large text and UI boundaries.
147+
function grade(token: string, r: number | undefined): string {
148+
if (r === undefined) return 'unknown';
149+
if (BOUNDARY.test(token)) return r >= 3 ? 'pass' : 'fail';
150+
if (!isForeground(token)) return 'ungraded';
151+
if (r >= 4.5) return 'pass';
152+
if (r >= 3) return 'large';
153+
return 'fail';
154+
}
155+
</script>
156+
157+
<div class="portal" bind:this={probe}>
158+
<header class="head">
159+
<div>
160+
<h1>augment-it design system</h1>
161+
<p class="sub">
162+
{COLOR_TOKENS.length} colour tokens × {SURFACES.length} surfaces, resolved by the browser in
163+
<strong>{mode}</strong> mode. Contrast measured from what actually painted.
164+
</p>
165+
</div>
166+
<nav class="modes" aria-label="Theme mode">
167+
{#each MODES as m}
168+
<button class:active={mode === m} onclick={() => pick(m)} aria-pressed={mode === m}>{m}</button>
169+
{/each}
170+
</nav>
171+
</header>
172+
173+
<section aria-labelledby="swatches-h">
174+
<h2 id="swatches-h">Semantic tokens on every surface</h2>
175+
<p class="note">
176+
Each cell paints <code>color: var(--token)</code> over <code>background: var(--surface)</code>.
177+
A cell that vanishes is a token that does not resolve on that surface — the P2/P3 failure this
178+
page exists to catch. The number is the measured contrast ratio.
179+
</p>
180+
181+
<div class="grid" style="--cols: {SURFACES.length}">
182+
<div class="cell head-cell">token</div>
183+
{#each SURFACES as surface}
184+
<div class="cell head-cell">{surface.replace('--color-', '')}</div>
185+
{/each}
186+
187+
{#each COLOR_TOKENS as token}
188+
<div class="cell token-name"><code>{token}</code></div>
189+
{#each SURFACES as surface}
190+
{@const r = ratios[`${token}|${surface}`]}
191+
<div
192+
class="cell swatch"
193+
data-token={token}
194+
data-surface={surface}
195+
style="background: var({surface}); color: var({token});"
196+
>
197+
<span class="sample">Aa</span>
198+
<span class="ratio {grade(token, r)}">{r ?? ''}</span>
199+
</div>
200+
{/each}
201+
{/each}
202+
</div>
203+
</section>
204+
205+
<section aria-labelledby="accent-h">
206+
<h2 id="accent-h">Accent fills — <code>--color-on-accent</code></h2>
207+
<p class="note">
208+
DESIGN.md makes this pairing mandatory: <code>--color-on-accent</code> on any accent fill,
209+
never assumed white. Tested where it is actually used rather than against surfaces it never
210+
touches.
211+
</p>
212+
<div class="effects">
213+
{#each ACCENT_FILLS as fill}
214+
{@const r = accentRatios[fill]}
215+
<div class="accent-cell effect-card" data-fill={fill}
216+
style="background: var({fill}); color: var(--color-on-accent);">
217+
<code>on-accent / {fill.replace('--color-', '')}</code>
218+
<span class="ratio {r === undefined ? 'unknown' : r >= 4.5 ? 'pass' : r >= 3 ? 'large' : 'fail'}">{r ?? ''}</span>
219+
</div>
220+
{/each}
221+
</div>
222+
</section>
223+
224+
<section aria-labelledby="effects-h">
225+
<h2 id="effects-h">Effect tokens</h2>
226+
<div class="effects">
227+
{#each EFFECT_TOKENS as token}
228+
<div class="effect-card" style="box-shadow: var({token});">
229+
<code>{token}</code>
230+
</div>
231+
{/each}
232+
</div>
233+
</section>
234+
235+
<section aria-labelledby="palette-h">
236+
<h2 id="palette-h">Tier 1 — the palette</h2>
237+
<p class="note">
238+
Raw named values. Components never read these; Tier-2 tokens point at them. Shown so a
239+
renamed or re-pointed step is visible rather than inferred.
240+
</p>
241+
<div class="palette">
242+
{#each TIER1 as [name, value]}
243+
<div class="chip">
244+
<div class="chip-color" style="background: {value};"></div>
245+
<code>{name.replace('--color__', '')}</code>
246+
<span class="hex">{value}</span>
247+
</div>
248+
{/each}
249+
</div>
250+
</section>
251+
</div>

0 commit comments

Comments
 (0)