Skip to content

Commit 3be8172

Browse files
mpstatonclaude
andcommitted
feat(theme, federation): close gate A20 — give every federated token a floor
Answers the open question of what happens when a member is deployed against a token the DEPLOYED shell does not define yet. Until now the answer was: it renders unreadable, in production, and nobody sees it in the member's own repo because locally that member has the latest theme. The asymmetry is what makes A20 dangerous. Token RETIREMENT can be aliased forever, so an old member keeps working. Token INTRODUCTION cannot: an undeclared, unregistered custom property is invalid at computed-value time, so colour inherits and backgrounds go transparent. Measured in a browser: black text on a transparent background, over a dark surface. Invisible. The hybrid this implements: the shell stays the canonical injector (F10), and every federated member ships an @Property baseline registering each Tier-2 token with an initial-value. A registered property with no matching declaration falls back to its initial value instead of to nothing. The shell's declarations still win whenever they exist, so this is free when the stack is healthy and a legible dark surface when it is not. Generated, not hand-maintained. scripts/generate-token-baseline.mjs parses theme.css, flattens every Tier-2 value through its Tier-1 reference to a literal (initial-value forbids var()), and picks <color> or the universal syntax per token — registering a box-shadow or font stack as <color> would invalidate the whole rule and silently drop it. Wired as `pnpm tokens:baseline`, with `pnpm tokens:check` for CI so the generated file cannot drift from theme.css. Zero dependencies. Regenerate when Blake's 88-token Phase 1 lands. Federated bundles now carry the 24-token floor instead of the full theme, which is also 3,018 bytes smaller per remote. Standalone entries (each member's src/index.ts) still import the complete theme.css — they have no shell to inherit from and need all three mode blocks. VERIFIED IN A BROWSER, not by static analysis. Driving the real built stylesheets via Playwright: healthy (shell theme + member bundle) tokens resolve to the shell's real values, and all three modes stay distinct — registration does not interfere with the cascade degraded (member bundle ONLY — the A20 case, which cannot be reproduced against the real stack without deploying a stale shell) text #e8eaf0 on surface #13151b, contrast 15.17:1, neither transparent counterfactual (a token neither declared nor registered) rgb(0,0,0) on transparent — the bug, reproduced, and the proof the floor is what changes the outcome Also verified: 19 packages build, 1,587 files typecheck with zero errors, all six test suites pass. Files changed: - scripts/generate-token-baseline.mjs (new) - packages/theme/token-baseline.css (new, generated) - packages/theme/package.json - packages/federation/src/index.ts - package.json Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019a8tSPbFdvF1pKtADnWyDg
1 parent 42b5267 commit 3be8172

5 files changed

Lines changed: 336 additions & 18 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
"lint": "pnpm -r lint",
1818
"test": "pnpm -r test",
1919
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
20-
"test:all": "bash scripts/test-all.sh"
20+
"test:all": "bash scripts/test-all.sh",
21+
"tokens:baseline": "node scripts/generate-token-baseline.mjs",
22+
"tokens:check": "node scripts/generate-token-baseline.mjs --check"
2123
},
2224
"devDependencies": {
2325
"@module-federation/enhanced": "^2.6.0",

packages/federation/src/index.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,43 @@
33
// target, hand back a destroy(). That was 17 hand-maintained copies of the same
44
// 23 lines, differing only in the exported function name. This is the one copy.
55
//
6-
// WHY THE theme.css IMPORT LIVES HERE, AND WHY ORDER MATTERS
6+
// WHY THIS IMPORTS THE BASELINE AND NOT THE FULL THEME (gate A20)
77
//
8-
// theme.css and the member's ./app.css are imported as side effects so the
9-
// bundler's CSS pipeline injects them — Svelte's append_styles does not fire
10-
// reliably across the federation chunk boundary. theme.css MUST evaluate
11-
// before app.css, so its :root tokens exist before app.css's var() refs
12-
// resolve. That ordering is preserved by the member importing this module
13-
// FIRST and './app.css' SECOND: ES module imports evaluate in declaration
14-
// order, so this module's transitive theme.css lands ahead of the member's
15-
// stylesheet. Reorder those two lines in a member's mount.ts and its tokens
16-
// resolve to nothing.
8+
// The shell is the canonical injector of theme.css (F10). A federated member
9+
// therefore inherits its real token values from the shell's :root at runtime —
10+
// they share one document, and custom properties inherit.
1711
//
18-
// This centralisation is deliberately friendly to Phase 1b / F10 of the
19-
// federated design system, which makes the shell the sole injector of
20-
// theme.css. When that lands, deleting the import below removes it from all
21-
// 17 members at once instead of requiring 14 separate edits.
22-
// See context-v/handoffs/Federated-Design-System-Phases-0-and-1-Shipped-Nothing-Seen.md
12+
// The danger in that arrangement is token INTRODUCTION, not retirement.
13+
// Retirement can be aliased forever. Introduction cannot: a member deployed
14+
// against a token the DEPLOYED shell does not define yet resolves to nothing —
15+
// invalid at computed-value time, so text inherits and backgrounds go
16+
// transparent. It renders unreadable in production and is invisible in the
17+
// member's own repo, because locally that member has the latest theme.
18+
//
19+
// token-baseline.css closes that hole. Every Tier-2 token is registered with
20+
// @property and an initial-value, which gives each one a FLOOR: when no
21+
// declaration matches, the initial value is used instead of nothing. The
22+
// shell's declarations still win whenever they exist, so this costs nothing
23+
// when the stack is healthy and degrades to a legible dark surface when it is
24+
// not. Generated from theme.css by scripts/generate-token-baseline.mjs.
25+
//
26+
// Standalone entries (each member's src/index.ts) still import the FULL
27+
// theme.css — they have no shell to inherit from and need all three mode
28+
// blocks. Only the federated path is thin.
29+
//
30+
// ORDER STILL MATTERS. This module must evaluate before the member's
31+
// './app.css', so the registrations exist before app.css's var() refs resolve.
32+
// ES module imports evaluate in declaration order, so the member importing
33+
// this FIRST and './app.css' SECOND is what preserves it. Reorder those two
34+
// lines in a member's mount.ts and you are back to unstyled.
2335
//
2436
// Each remote still ships its own inlined copy of this code — the federation
2537
// host declares no `shared` block, so a workspace import is bundled per
2638
// remote rather than linked at runtime. One source of truth in the repo,
2739
// seventeen independent artifacts. Autonomy is unaffected.
2840
// See context-v/notes/Sharing-Code-Without-Breaking-Microfrontend-Autonomy.md
2941

30-
import '@augment-it/theme/theme.css';
42+
import '@augment-it/theme/token-baseline.css';
3143
import { mount, unmount, type Component } from 'svelte';
3244

3345
export type MountResult = {

packages/theme/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"type": "module",
66
"exports": {
77
"./theme.css": "./theme.css",
8-
"./mode-switcher": "./mode-switcher.ts"
8+
"./mode-switcher": "./mode-switcher.ts",
9+
"./token-baseline.css": "./token-baseline.css"
910
},
1011
"devDependencies": {
1112
"typescript": "^6.0.3"

packages/theme/token-baseline.css

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/* GENERATED by scripts/generate-token-baseline.mjs — DO NOT EDIT BY HAND.
2+
* Regenerate: node scripts/generate-token-baseline.mjs
3+
*
4+
* The A20 floor. Every Tier-2 token registered with @property so that a
5+
* missing or stale shell declaration degrades to a legible dark value instead
6+
* of resolving to nothing. Shell declarations always win when present; this
7+
* only fills the gap.
8+
*
9+
* Federated members load THIS instead of the full theme.css — the shell is the
10+
* canonical injector (F10). Standalone entries (src/index.ts) still load the
11+
* full theme, because they have no shell to inherit from and need all three
12+
* mode blocks.
13+
*
14+
* 24 tokens registered from theme.css's Tier-2 dark contract.
15+
*/
16+
17+
@property --color-background {
18+
syntax: '<color>';
19+
inherits: true;
20+
initial-value: #0f1115;
21+
}
22+
23+
@property --color-surface {
24+
syntax: '<color>';
25+
inherits: true;
26+
initial-value: #13151b;
27+
}
28+
29+
@property --color-surface-raised {
30+
syntax: '<color>';
31+
inherits: true;
32+
initial-value: #0c0d12;
33+
}
34+
35+
@property --color-field {
36+
syntax: '<color>';
37+
inherits: true;
38+
initial-value: #16181f;
39+
}
40+
41+
@property --color-field-focus {
42+
syntax: '<color>';
43+
inherits: true;
44+
initial-value: #1a1d27;
45+
}
46+
47+
@property --color-border {
48+
syntax: '<color>';
49+
inherits: true;
50+
initial-value: #232634;
51+
}
52+
53+
@property --color-text {
54+
syntax: '<color>';
55+
inherits: true;
56+
initial-value: #e8eaf0;
57+
}
58+
59+
@property --color-text-muted {
60+
syntax: '<color>';
61+
inherits: true;
62+
initial-value: #8a8f9b;
63+
}
64+
65+
@property --color-accent {
66+
syntax: '<color>';
67+
inherits: true;
68+
initial-value: #c75bfb;
69+
}
70+
71+
@property --color-accent-2 {
72+
syntax: '<color>';
73+
inherits: true;
74+
initial-value: #5bbcfb;
75+
}
76+
77+
@property --color-on-accent {
78+
syntax: '<color>';
79+
inherits: true;
80+
initial-value: #0f1115;
81+
}
82+
83+
@property --color-ok-bg {
84+
syntax: '<color>';
85+
inherits: true;
86+
initial-value: #1b3d2f;
87+
}
88+
89+
@property --color-ok-text {
90+
syntax: '<color>';
91+
inherits: true;
92+
initial-value: #a4e3b5;
93+
}
94+
95+
@property --color-error-bg {
96+
syntax: '<color>';
97+
inherits: true;
98+
initial-value: #3d1b1b;
99+
}
100+
101+
@property --color-error-text {
102+
syntax: '<color>';
103+
inherits: true;
104+
initial-value: #f29a9a;
105+
}
106+
107+
@property --color-confidence-low {
108+
syntax: '<color>';
109+
inherits: true;
110+
initial-value: #f29a9a;
111+
}
112+
113+
@property --color-confidence-med {
114+
syntax: '<color>';
115+
inherits: true;
116+
initial-value: #f5c971;
117+
}
118+
119+
@property --color-confidence-high {
120+
syntax: '<color>';
121+
inherits: true;
122+
initial-value: #a4e3b5;
123+
}
124+
125+
@property --color-selected-tint {
126+
syntax: '*';
127+
inherits: true;
128+
initial-value: color-mix(in srgb, #c75bfb 9%, transparent);
129+
}
130+
131+
@property --color-danger-tint {
132+
syntax: '*';
133+
inherits: true;
134+
initial-value: color-mix(in srgb, #f29a9a 7%, transparent);
135+
}
136+
137+
@property --fx-accent-glow {
138+
syntax: '*';
139+
inherits: true;
140+
initial-value: 0 0 0 1px color-mix(in srgb, #c75bfb 30%, transparent);
141+
}
142+
143+
@property --fx-card-shadow {
144+
syntax: '*';
145+
inherits: true;
146+
initial-value: 0 1px 3px rgba(0, 0, 0, 0.35);
147+
}
148+
149+
@property --fx-flash {
150+
syntax: '*';
151+
inherits: true;
152+
initial-value: color-mix(in srgb, #5bbcfb 30%, transparent);
153+
}
154+
155+
@property --font-mono {
156+
syntax: '*';
157+
inherits: true;
158+
initial-value: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
159+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env node
2+
// Generate packages/theme/token-baseline.css from theme.css.
3+
//
4+
// WHAT THIS IS FOR — gate A20, "token introduction across independent deploys."
5+
//
6+
// The federation host injects the canonical theme. A member that is deployed
7+
// against a token the DEPLOYED shell does not define yet gets nothing: an
8+
// unregistered custom property with no declaration is invalid at
9+
// computed-value time, so text inherits and backgrounds go transparent. The
10+
// member renders unreadable, in production, and it is invisible in the
11+
// member's own repo because locally that member has the latest theme.
12+
//
13+
// @property fixes exactly that. Registering a custom property with an
14+
// initial-value gives it a FLOOR: when nothing declares it, the initial value
15+
// is used instead of nothing. The shell's real declarations still win whenever
16+
// they exist, so this costs nothing when the stack is healthy and degrades to
17+
// a legible dark surface when it is not.
18+
//
19+
// The floor is the DARK mode values, because dark is augment-it's native look
20+
// (":root with no data-mode resolves here").
21+
//
22+
// Regenerate whenever theme.css's Tier-2 contract changes:
23+
// node scripts/generate-token-baseline.mjs
24+
// node scripts/generate-token-baseline.mjs --check # CI: fail if stale
25+
//
26+
// Zero dependencies, pure Node — same discipline as scripts/design-drift.mjs.
27+
28+
import { readFile, writeFile } from 'node:fs/promises';
29+
import { fileURLToPath } from 'node:url';
30+
import { dirname, join } from 'node:path';
31+
32+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
33+
const SRC = join(ROOT, 'packages/theme/theme.css');
34+
const OUT = join(ROOT, 'packages/theme/token-baseline.css');
35+
36+
// Tokens whose values are not a plain <color>. Registering these as <color>
37+
// would make the whole @property rule invalid and silently drop it, so they
38+
// use the universal syntax, which skips type-checking but still honours
39+
// initial-value.
40+
const UNIVERSAL = /^--(fx-|font-)|tint$/;
41+
42+
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '');
43+
44+
/** Pull `--name: value;` pairs out of the first block matching `selector`. */
45+
function blockDecls(css, selectorRe) {
46+
const m = css.match(selectorRe);
47+
if (!m) return {};
48+
const body = css.slice(m.index + m[0].length);
49+
const end = body.indexOf('}');
50+
const decls = {};
51+
for (const line of body.slice(0, end).split(';')) {
52+
const mm = line.match(/(--[a-zA-Z0-9_-]+)\s*:\s*([\s\S]+)/);
53+
if (mm) decls[mm[1].trim()] = mm[2].trim();
54+
}
55+
return decls;
56+
}
57+
58+
/**
59+
* Resolve a value to something computationally independent — @property's
60+
* initial-value forbids var(), so every Tier-1 reference must be flattened to
61+
* its literal. Recursive because a Tier-2 token may point at another Tier-2
62+
* token (--color-bg aliases --color-background in the post-Phase-1 theme).
63+
*/
64+
function resolve(value, tier1, tier2, depth = 0) {
65+
if (depth > 10) return null;
66+
const varRef = value.match(/^var\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/);
67+
if (varRef) {
68+
const name = varRef[1];
69+
const next = tier1[name] ?? tier2[name];
70+
return next ? resolve(next, tier1, tier2, depth + 1) : null;
71+
}
72+
// Nested var() inside color-mix()/box-shadow — flatten each one in place.
73+
if (value.includes('var(')) {
74+
let out = value, guard = 0;
75+
while (out.includes('var(') && guard++ < 20) {
76+
out = out.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*\)/g, (whole, name) => {
77+
const next = tier1[name] ?? tier2[name];
78+
return next ?? whole;
79+
});
80+
if (guard > 1 && !out.includes('var(')) break;
81+
}
82+
return out.includes('var(') ? null : out;
83+
}
84+
return value;
85+
}
86+
87+
const raw = await readFile(SRC, 'utf8');
88+
const css = stripComments(raw);
89+
90+
// Tier 1 is the first bare `:root {` block; Tier 2 dark is `:root,\n[data-mode='dark'] {`.
91+
const tier1 = blockDecls(css, /:root\s*\{/);
92+
const tier2 = blockDecls(css, /:root\s*,\s*\[data-mode=['"]dark['"]\]\s*\{/);
93+
94+
const semantic = Object.entries(tier2).filter(([n]) => !n.startsWith('--color__') && !n.startsWith('--font__'));
95+
if (semantic.length === 0) {
96+
console.error('ERROR: no Tier-2 tokens found — has theme.css\'s dark block selector changed?');
97+
process.exit(1);
98+
}
99+
100+
const rules = [];
101+
const skipped = [];
102+
for (const [name, value] of semantic) {
103+
const initial = resolve(value, tier1, tier2);
104+
if (initial === null) { skipped.push(name); continue; }
105+
const universal = UNIVERSAL.test(name);
106+
rules.push(
107+
`@property ${name} {\n` +
108+
` syntax: '${universal ? '*' : '<color>'}';\n` +
109+
` inherits: true;\n` +
110+
` initial-value: ${initial};\n` +
111+
`}`,
112+
);
113+
}
114+
115+
const header = `/* GENERATED by scripts/generate-token-baseline.mjs — DO NOT EDIT BY HAND.
116+
* Regenerate: node scripts/generate-token-baseline.mjs
117+
*
118+
* The A20 floor. Every Tier-2 token registered with @property so that a
119+
* missing or stale shell declaration degrades to a legible dark value instead
120+
* of resolving to nothing. Shell declarations always win when present; this
121+
* only fills the gap.
122+
*
123+
* Federated members load THIS instead of the full theme.css — the shell is the
124+
* canonical injector (F10). Standalone entries (src/index.ts) still load the
125+
* full theme, because they have no shell to inherit from and need all three
126+
* mode blocks.
127+
*
128+
* ${rules.length} tokens registered from theme.css's Tier-2 dark contract.${skipped.length ? `\n * ${skipped.length} skipped (unresolvable): ${skipped.join(', ')}` : ''}
129+
*/\n\n`;
130+
131+
const out = header + rules.join('\n\n') + '\n';
132+
133+
if (process.argv.includes('--check')) {
134+
const existing = await readFile(OUT, 'utf8').catch(() => null);
135+
if (existing !== out) {
136+
console.error('token-baseline.css is STALE — run: node scripts/generate-token-baseline.mjs');
137+
process.exit(1);
138+
}
139+
console.log(`token-baseline.css up to date (${rules.length} tokens)`);
140+
} else {
141+
await writeFile(OUT, out, 'utf8');
142+
console.log(`wrote packages/theme/token-baseline.css — ${rules.length} tokens registered`);
143+
if (skipped.length) console.log(` skipped (unresolvable): ${skipped.join(', ')}`);
144+
}

0 commit comments

Comments
 (0)