#!/usr/bin/env node // Generates docs/design/tokens.css from docs/design/tokens.json. // Usage: node docs/design/tools/build-tokens.mjs [--check] // --check exits 1 when tokens.css is out of date instead of writing it. // // Selector contract for consumers: // no attributes default palette, light or dark from prefers-color-scheme // data-mode="light|dim|dark" explicit mode (packages/webui already sets data-mode) // data-palette="" any non-default palette // Property names match packages/webui/src/public/shared/app.css and brand.js // (--onAction stays camelCase there), so this file can replace the inline // setProperty theming without renaming any var() use. import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const src = join(here, '..', 'tokens.json'); const out = join(here, '..', 'tokens.css'); const t = JSON.parse(readFileSync(src, 'utf8')); const strong = (mode) => mode === 'light' ? 'color-mix(in srgb, var(--text) 72%, #000)' : 'color-mix(in srgb, var(--text) 70%, #fff)'; const colors = (modes, mode) => Object.entries(modes[mode]).map(([k, v]) => ` --${k}: ${v};`) .concat(` --strong: ${strong(mode)};`, ` color-scheme: ${mode === 'light' ? 'light' : 'dark'};`).join('\n'); const block = (sel, body) => `${sel} {\n${body}\n}`; const media = (sel, body) => `@media (prefers-color-scheme: dark) {\n ${sel} {\n${body.replace(/^/gm, ' ')}\n }\n}`; const def = t.palettes.find((p) => p.id === t.default.palette); if (!def) { console.error(`default palette ${t.default.palette} is not in tokens.json`); process.exit(2); } const shared = [ ` --font: ${t.font.sans.stack};`, ` --mono: ${t.font.mono.stack};`, ` --r: ${t.shape.radius.control}px;`, ` --r-lg: ${t.shape.radius.panel}px;`, ` --r-table: ${t.shape.radius.table}px;`, ` --pad: ${t.space.gutter}px;`, ` --gap: ${t.space.gap}px;`, ].join('\n'); const parts = [ `/* Generated from tokens.json by tools/build-tokens.mjs. Do not edit by hand. */`, `/* Fonts: ${t.font.sans.family} and ${t.font.mono.family}. ${t.font.source} */`, block(':root', shared), `/* ${def.name}: the default palette */`, block(':root', colors(def.modes, 'light')), media(':root:not([data-mode="light"]):not([data-mode="dim"])', colors(def.modes, 'dark')), block(':root[data-mode="dark"]', colors(def.modes, 'dark')), block(':root[data-mode="dim"]', colors(def.modes, 'dim')), ]; for (const p of t.palettes) { if (p.id === def.id) continue; const s = `:root[data-palette="${p.id}"]`; parts.push(`/* ${p.name} */`, block(s, colors(p.modes, 'light')), media(`${s}:not([data-mode="light"]):not([data-mode="dim"])`, colors(p.modes, 'dark')), block(`${s}[data-mode="dark"]`, colors(p.modes, 'dark')), block(`${s}[data-mode="dim"]`, colors(p.modes, 'dim'))); } const css = parts.join('\n\n') + '\n'; if (process.argv.includes('--check')) { let cur = ''; try { cur = readFileSync(out, 'utf8'); } catch {} if (cur !== css) { console.error('tokens.css is out of date; run node docs/design/tools/build-tokens.mjs'); process.exit(1); } console.log('tokens.css is current'); } else { writeFileSync(out, css); console.log(`wrote ${out} (${t.palettes.length} palettes x ${t.modes.length} modes)`); }