Files

214 lines
11 KiB
JavaScript

// gen-tokens.mjs — how @luxfi/design LAYERS on @hanzo/design instead of forking it.
//
// There is one hand-authored token file in this package: tokens/brand.css. Every
// other token — the neutral ladder, the opacity rungs, surfaces, type, spacing,
// elevation, motion — is INHERITED from the substrate at build time and written
// out beneath the brand layer. Bump the substrate, run `pnpm build`, and Lux
// picks up the fix. Nothing to sync by hand, so nothing to drift.
//
// Three things happen to the inherited CSS on the way through, and each one is
// load-bearing:
//
// 1. COMMENTS ARE STRIPPED. The substrate's prose is excellent and it is also
// Hanzo's — it says "Hanzo is monochrome", cites hanzo.ai, names
// @hanzo/brand. That prose belongs in the package that owns it, and a Lux
// developer reading a Lux stylesheet should find Lux. The rationale is one
// `node_modules` hop away for anyone who wants it, and the banner says so.
//
// 2. `hanzo-` BECOMES `lux-`. The substrate ships two Hanzo-named identifiers
// (--hanzo-black, --hanzo-white) and six Hanzo-named keyframes
// (hanzo-fade-up, hanzo-glow, …). A custom property is vocabulary, not a
// dependency reference: a Lux surface must not have --hanzo-black in its
// DevTools. The rename is textual and total, so the keyframes and every
// --animate-* that references them stay consistent. --hanzo-black is
// #0A0A0B, which is exactly what this package's press kit already called
// --lux-black, so the rename lands on the value that was always meant.
// check-tokens.mjs then fails the build if a single `hanzo` survives.
//
// 3. @font-face BLOCKS ARE DROPPED, and with them every url() in the sheet.
// ZEN IS AUTHORED IN @hanzo/font AND SELF-HOSTED BY @hanzo/design. Lux uses
// the same two faces, and the estate should hold exactly one copy of a font
// binary, not one per brand. Shipping the rules without the files would
// point every consumer's bundler at a woff2 this package does not contain
// and fail their build on a missing module — the substrate hit that exact
// bug and wrote the rebase logic to fix it. So the honest move is to ship
// neither. --font-sans keeps its full literal fallback stack, so a surface
// that loads only this sheet renders in the system UI face rather than in
// nothing; a surface that wants Zen installs @hanzo/font — an optional peer
// here — and imports `@hanzo/font/css` ahead of this sheet. It must be the
// CONSUMER's own dependency: under pnpm's strict layout @hanzo/design is
// transitive and not resolvable by name from a consumer, so pointing them
// at the substrate's sheet does not work however well it reads. This is
// documented in the README, and check-tokens.mjs asserts no url() at all.
//
// The output shape mirrors the substrate exactly — same file names, same token
// names, same `:root` dark / `.light` contract — so a consumer swaps one import
// and gets Lux.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const require = createRequire(import.meta.url)
const substrate = dirname(require.resolve('@hanzo/design/styles.css'))
const substrateVersion = JSON.parse(readFileSync(join(substrate, 'package.json'), 'utf8')).version
// The substrate's token groups, in the order its own entry point carries them.
const INHERITED = ['colors', 'typography', 'spacing', 'grid', 'radius', 'elevation', 'motion', 'z', 'fonts', 'base']
const GROUP = { z: 'zIndex' }
/** Strip the substrate's prose, drop the faces, and speak Lux. See the header. */
const inherit = (css) =>
css
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/@font-face\s*\{[^}]*\}/g, '')
.replace(/hanzo-/g, 'lux-')
.replace(/\n{3,}/g, '\n\n')
.trim()
const readInherited = (f) => inherit(readFileSync(join(substrate, 'tokens', `${f}.css`), 'utf8'))
const brandCss = readFileSync(join(root, 'tokens', 'brand.css'), 'utf8')
// ── the entry point ──────────────────────────────────────────────────────
// Flattened, exactly as the substrate flattens: no @import to resolve, no
// nested url() to rebase, no ordering for a consumer to get wrong. The brand
// layer is written LAST so it wins — `:root` and `.light` have identical
// specificity, so source order is the only thing deciding, and every token
// tokens/brand.css declares must therefore appear after the substrate's.
const banner = `/* Lux Design System — the entry point. Import THIS one file.
*
* AUTO-GENERATED by scripts/gen-tokens.mjs — DO NOT EDIT.
* Edit tokens/brand.css and re-run "pnpm gen".
*
* The neutral system below is inherited from @hanzo/design@${substrateVersion} at build
* time and is not maintained here; the Lux brand layer is the last block in the
* file. Typeface: Zen — this sheet declares no @font-face and no url(), so
* install @hanzo/font and import "@hanzo/font/css" ahead of this one if you want
* the faces rather than the system stack. See scripts/gen-tokens.mjs for why.
*/
`
const bundle =
banner +
INHERITED.map((f) => `\n/* ── inherited: ${f} ───────────────────────────────────── */\n${readInherited(f)}\n`).join('') +
`\n/* ── Lux: tokens/brand.css ────────────────────────────── */\n${brandCss}`
writeFileSync(join(root, 'styles.css'), bundle)
console.log(`gen-tokens: wrote styles.css — ${INHERITED.length} inherited groups + the Lux brand layer`)
// ── the typed layer ──────────────────────────────────────────────────────
// The typed map is the authored DARK default — dark is the default theme, and
// live theme switching happens through the cascade via cssVar()/var(--x), not
// through this map. So it is built from `:root` blocks ONLY, in two passes.
//
// Reading every declaration regardless of block is what a first cut did, and it
// was wrong in a way that only showed up from a consumer's side: brand.css
// declares --brand-foreground exclusively in `.light` (the dark value it
// inherits is already correct), so the light value — #ffffff — became the
// package's reported default while the stylesheet correctly painted #09090b.
// Code and CSS disagreed, which is the one thing generating this file is
// supposed to make impossible.
//
// Pass 1 takes `:root`, brand first so Lux wins the first-wins map, exactly as
// source order makes it win in the cascade. Pass 2 takes `.light` and only ever
// ADDS names that appear in no `:root` at all, so the map stays complete
// without a light value ever standing in for a default it does not have.
const ROOT_RE = /:root(?::root)?\s*\{([^{}]*)\}/g
const LIGHT_RE = /\.light(?:\.light)?\s*\{([^{}]*)\}/g
const blocks = (css, re) => [...css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(re)].map((m) => m[1]).join('\n')
const decls = (s) => [...s.matchAll(/--([A-Za-z0-9-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1].trim(), m[2].trim()])
const SOURCES = [['brand', brandCss], ...INHERITED.map((f) => [GROUP[f] ?? f, readInherited(f)])]
const groupMaps = {}
const flatMap = new Map()
for (const re of [ROOT_RE, LIGHT_RE])
for (const [g, css] of SOURCES) {
const gm = (groupMaps[g] ??= new Map())
for (const [n, v] of decls(blocks(css, re))) {
if (!gm.has(n)) gm.set(n, v)
if (!flatMap.has(`--${n}`)) flatMap.set(`--${n}`, v)
}
}
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
const objLit = (pairs) => '{\n' + pairs.map(([n, v]) => ` '${esc(n)}': '${esc(v)}',`).join('\n') + '\n} as const'
let ts = `// AUTO-GENERATED by scripts/gen-tokens.mjs — DO NOT EDIT.
// Brand tokens come from tokens/brand.css; everything else is inherited from
// @hanzo/design@${substrateVersion}. Re-run \`pnpm gen\`.
/* eslint-disable */
`
for (const [g, m] of Object.entries(groupMaps)) {
const pairs = [...m.keys()].map((n) => [n, flatMap.get(`--${n}`)])
ts += `/** ${g} tokens. Values are raw CSS, resolved through the Lux brand layer. */\n`
ts += `export const ${g} = ${objLit(pairs)}\n\n`
}
ts += `/** Every token, keyed by its literal CSS custom-property name ('--brand', …). */\n`
ts += `export const cssVars = ${objLit([...flatMap])}\n\n`
ts += `export type CssVarName = keyof typeof cssVars\n`
mkdirSync(join(root, 'src'), { recursive: true })
writeFileSync(join(root, 'src', 'tokens.gen.ts'), ts)
console.log(`gen-tokens: wrote src/tokens.gen.ts — ${flatMap.size} tokens across ${Object.keys(groupMaps).length} groups`)
// ── the Tailwind bridge ──────────────────────────────────────────────────
// One import after tailwindcss and an app's utilities resolve to Lux values,
// with nothing to wire by hand. `inline` is deliberate: it compiles `bg-brand`
// to var(--brand) rather than to that variable's build-time value, so toggling
// .light moves the UI and not merely the variables.
const SLOTS = [
'background', 'foreground',
'card', 'card-foreground',
'popover', 'popover-foreground',
'primary', 'primary-foreground',
'secondary', 'secondary-foreground',
'muted', 'muted-foreground',
'accent', 'accent-foreground',
'destructive', 'destructive-foreground',
'border', 'input', 'ring',
// The accent Lux actually spends. `bg-brand`, `text-brand`, `border-brand`.
'brand', 'brand-foreground', 'brand-hover', 'brand-bg', 'brand-border',
]
const STATE = { error: 'state-error', success: 'state-success', online: 'state-online' }
const missing = SLOTS.filter((s) => !flatMap.has(`--${s}`))
if (missing.length) {
console.error(`gen-tokens: nothing declares --${missing.join(', --')}`)
process.exit(1)
}
writeFileSync(
join(root, 'tailwind.css'),
`/* Lux Design System — Tailwind v4 bridge. The ONE import a Tailwind app adds.
*
* AUTO-GENERATED by scripts/gen-tokens.mjs — DO NOT EDIT.
*
* @import "tailwindcss";
* @import "@luxfi/design/tailwind.css";
*
* Import it AFTER tailwindcss — @theme has to follow the framework it extends.
* The tokens are inlined rather than @imported because an @import must precede
* every other rule; placed after tailwindcss it would be invalid and the browser
* would drop the whole token layer without a word.
*/
${bundle}
@theme inline {
${SLOTS.map((s) => ` --color-${s}: var(--${s});`).join('\n')}
${Object.entries(STATE).map(([k, v]) => ` --color-${k}: var(--${v});`).join('\n')}
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
}
`
)
console.log(`gen-tokens: wrote tailwind.css — ${SLOTS.length} colour slots + ${Object.keys(STATE).length} states`)