mirror of
https://github.com/luxfi/design.git
synced 2026-10-30 16:58:57 +00:00
214 lines
11 KiB
JavaScript
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`)
|