|
| 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