eigent/scripts/generate-design-tokens.mjs
Douglas Lai 6c49956b2a
Redesign queued tasks and stabilize stop/start transitions (#1934)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-09-16 21:21:55 +08:00

660 lines
20 KiB
JavaScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0.
//
// Deterministic generator for design-system CSS, Tailwind mappings, and types.
// Hand-authored JSON in src/style/tokens is the source of truth.
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(scriptDirectory, '..');
const tokensDirectory = path.join(repositoryRoot, 'src', 'style', 'tokens');
const outputDirectory = path.join(repositoryRoot, 'src', 'style', 'generated');
function readJson(filename) {
return JSON.parse(readFileSync(path.join(tokensDirectory, filename), 'utf8'));
}
function pxToRem(px) {
if (px === 0) return '0';
const rem = px / 16;
return Number.isInteger(rem) ? `${rem}rem` : `${rem}rem`;
}
function roundRole(basePx, ratio, floorPx) {
return Math.max(floorPx, Math.round(basePx * ratio));
}
function assert(condition, message) {
if (!condition) {
throw new Error(`generate-design-tokens: ${message}`);
}
}
const referenceDimension = readJson('reference.dimension.json');
const referenceTypography = readJson('reference.typography.json');
const semanticDimension = readJson('semantic.dimension.json');
const semanticElevation = readJson('semantic.elevation.json');
const componentRecipe = readJson('component.recipe.json');
const toneAssignment = readJson('tone.assignment.json');
const exceptionRegistry = readJson('exception.registry.json');
const colorManifest = readJson('manifest.json');
const fontStacks = JSON.parse(
readFileSync(
path.join(repositoryRoot, 'src', 'style', 'fontStacks.json'),
'utf8'
)
);
/** @type {Map<string, { cssVar: string, value: string, layer: string }>} */
const tokens = new Map();
const cssVarToId = new Map();
function defineToken(id, cssVar, value, layer) {
assert(!tokens.has(id), `Duplicate token id "${id}"`);
assert(!cssVarToId.has(cssVar), `Duplicate CSS variable "${cssVar}"`);
assert(
typeof value === 'string' && value.length > 0,
`Token "${id}" is missing a value`
);
if (layer === 'reference' || layer === 'semantic') {
assert(
value === '0' ||
value === 'none' ||
/^-?\d+(\.\d+)?(px|rem|em)$/.test(value) ||
value.includes(' ') ||
value.startsWith('var(') ||
/^[0-9]+$/.test(value) ||
value.includes(',') ||
value.includes('"'),
`Token "${id}" has an invalid value "${value}"`
);
}
tokens.set(id, { cssVar, value, layer });
cssVarToId.set(cssVar, id);
}
function referenceLookup(id) {
const token = tokens.get(id);
assert(token, `Unknown reference "${id}"`);
return token;
}
function resolveAlias(raw) {
const match = String(raw).match(/^\{([a-z0-9.-]+)\}$/i);
if (!match) return raw;
const target = referenceLookup(match[1]);
return `var(${target.cssVar})`;
}
function walkObject(object, visit, prefix = []) {
for (const [key, value] of Object.entries(object)) {
if (key.startsWith('$')) continue;
const pathParts = [...prefix, key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
walkObject(value, visit, pathParts);
continue;
}
visit(pathParts, value);
}
}
for (const [step, value] of Object.entries(referenceDimension.space)) {
defineToken(
`ref.space.${step}`,
`--ds-ref-space-${step}`,
value,
'reference'
);
}
for (const [step, value] of Object.entries(referenceDimension.radius)) {
defineToken(
`ref.radius.${step}`,
`--ds-ref-radius-${step}`,
value,
'reference'
);
}
for (const [step, value] of Object.entries(referenceDimension.controlHeight)) {
defineToken(
`ref.controlHeight.${step}`,
`--ds-ref-control-height-${step}`,
value,
'reference'
);
}
for (const [name, value] of Object.entries(referenceDimension.touchTarget)) {
defineToken(
`ref.touchTarget.${name}`,
`--ds-ref-touch-target-${name}`,
value,
'reference'
);
}
for (const [step, value] of Object.entries(referenceDimension.layoutRow)) {
defineToken(
`ref.layoutRow.${step}`,
`--ds-ref-layout-row-${step}`,
value,
'reference'
);
}
for (const [step, value] of Object.entries(referenceDimension.iconSize)) {
defineToken(
`ref.iconSize.${step}`,
`--ds-ref-icon-size-${step}`,
value,
'reference'
);
}
for (const [step, value] of Object.entries(referenceDimension.iconStroke)) {
defineToken(
`ref.iconStroke.${step}`,
`--ds-ref-icon-stroke-${step}`,
value,
'reference'
);
}
for (const [step, value] of Object.entries(referenceDimension.border)) {
defineToken(
`ref.border.${step}`,
`--ds-ref-border-${step}`,
value,
'reference'
);
}
for (const [step, value] of Object.entries(referenceDimension.focus)) {
defineToken(
`ref.focus.${step}`,
`--ds-ref-focus-${step}`,
value,
'reference'
);
}
for (const [name, value] of Object.entries(referenceTypography.weights)) {
defineToken(`ref.weight.${name}`, `--ds-weight-${name}`, value, 'reference');
}
defineToken(
'ref.font.text',
'--ds-font-text',
referenceTypography.families.text
.map((family) => (family.includes(' ') ? `"${family}"` : family))
.join(', '),
'reference'
);
defineToken(
'ref.font.code',
'--ds-font-code',
(fontStacks.code ?? referenceTypography.families.code)
.map((family) => (family.includes(' ') ? `"${family}"` : family))
.join(', '),
'reference'
);
const textChannel = referenceTypography.channels.text;
const codeChannel = referenceTypography.channels.code;
function emitRole(channel, role, spec, floorPx) {
const basePx =
channel === 'text' ? textChannel.defaultPx : codeChannel.defaultPx;
const resolvedSize = roundRole(basePx, spec.sizeRatio, floorPx);
const resolvedLine = Math.max(
resolvedSize,
Math.round(basePx * spec.lineRatio)
);
defineToken(
`role.${channel}.${role}.size`,
`--ds-${channel}-${role}-size`,
pxToRem(resolvedSize),
'semantic'
);
defineToken(
`role.${channel}.${role}.line`,
`--ds-${channel}-${role}-line`,
pxToRem(resolvedLine),
'semantic'
);
return { sizePx: resolvedSize, linePx: resolvedLine };
}
const typographyRoles = {};
for (const [role, spec] of Object.entries(referenceTypography.roles.text)) {
const floor =
role === 'meta' ? textChannel.metaFloorPx : textChannel.generalFloorPx;
typographyRoles[`text.${role}`] = emitRole('text', role, spec, floor);
}
for (const [role, spec] of Object.entries(referenceTypography.roles.code)) {
typographyRoles[`code.${role}`] = emitRole(
'code',
role,
spec,
codeChannel.floorPx
);
}
walkObject(semanticDimension, (parts, value) => {
const id = parts.join('.');
const cssVar = `--ds-${parts.join('-')}`;
defineToken(id, cssVar, resolveAlias(value), 'semantic');
});
for (const [role, value] of Object.entries(semanticElevation.roles)) {
defineToken(`elevation.${role}`, `--ds-elevation-${role}`, value, 'semantic');
}
const aliasPattern = /\{([a-z0-9.-]+)\}/gi;
function resolveRecipeValue(value) {
if (typeof value !== 'string') return value;
return value.replace(aliasPattern, (_, id) => {
const token = tokens.get(id);
assert(token, `Recipe references unknown token "${id}"`);
return `var(${token.cssVar})`;
});
}
function recipeCssVar(parts) {
const skipped = parts.filter(
(part, index) => !(index === 1 && part === 'sizes')
);
return `--ds-${skipped
.join('-')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()}`;
}
walkObject(componentRecipe, (parts, value) => {
if (typeof value !== 'string') return;
if (parts[0] === 'button' && parts[1] === 'variants') return;
if (parts[0] === 'button' && parts[1] === 'compat') return;
aliasPattern.lastIndex = 0;
const id = parts.join('.');
if (
aliasPattern.test(value) ||
/rem|px|%$/.test(value) ||
value.startsWith('{')
) {
aliasPattern.lastIndex = 0;
defineToken(id, recipeCssVar(parts), resolveRecipeValue(value), 'recipe');
}
});
const assignedTones = Object.entries(toneAssignment.families)
.filter(([, assignment]) => assignment.kind !== 'retired')
.map(([tone]) => tone)
.sort();
const manifestTones = [...colorManifest.tones].sort();
assert(
assignedTones.length === manifestTones.length &&
assignedTones.every((tone, index) => tone === manifestTones[index]),
`Every shipping manifest tone must have a non-retired assignment. assigned=[${assignedTones.join(',')}] manifest=[${manifestTones.join(',')}]`
);
for (const [tone, assignment] of Object.entries(toneAssignment.families)) {
if (assignment.kind === 'retired') {
assert(
!colorManifest.tones.includes(tone),
`Retired tone "${tone}" must not be published by the manifest`
);
}
}
assert(
Array.isArray(exceptionRegistry.exceptions) &&
exceptionRegistry.exceptions.length > 0,
'Exception registry must list approved exceptions'
);
const cssLines = [
'/* GENERATED FILE. Do not edit. Source: src/style/tokens/*.json */',
'/* Generated by scripts/generate-design-tokens.mjs */',
'',
':root {',
];
const orderedTokens = [...tokens.entries()].sort(([left], [right]) =>
left.localeCompare(right)
);
for (const [, token] of orderedTokens) {
cssLines.push(` ${token.cssVar}: ${token.value};`);
}
cssLines.push(
' --ds-text-base-px: 13;',
' --ds-code-base-px: 13;',
' --font-text: var(--ds-font-text);',
' --font-code: var(--ds-font-code);',
' --spacing-xs: var(--ds-ref-space-4);',
' --spacing-sm: var(--ds-ref-space-8);',
' --spacing-md: var(--ds-ref-space-16);',
' --spacing-lg: var(--ds-ref-space-32);',
' --spacing-xl: var(--ds-ref-space-64);',
' --borderRadius-sm: var(--ds-ref-radius-4);',
' --borderRadius-lg: var(--ds-ref-radius-8);',
' --borderRadius-xl: var(--ds-ref-radius-16);',
' --fontSize-sm: var(--ds-text-base-size);',
' --fontWeight-regular: var(--ds-weight-regular);',
' --fontWeight-medium: var(--ds-weight-medium);',
' --fontWeight-semibold: var(--ds-weight-semibold);',
' --fontWeight-bold: var(--ds-weight-bold);',
' --shadow-ds-elevation-control: var(--ds-elevation-control);',
'}',
''
);
const spacing = {};
const borderRadius = {};
const boxShadow = {};
const fontSize = {};
const fontFamily = {
text: 'var(--ds-font-text)',
code: 'var(--ds-font-code)',
};
const fontWeight = {
regular: 'var(--ds-weight-regular)',
medium: 'var(--ds-weight-medium)',
semibold: 'var(--ds-weight-semibold)',
bold: 'var(--ds-weight-bold)',
};
const height = {};
const width = {};
const size = {};
const minHeight = {};
const borderWidth = {};
const colors = {};
for (const [id, token] of orderedTokens) {
const utilityValue = `var(${token.cssVar})`;
if (id.startsWith('ref.space.')) {
spacing[`ds-${id.slice('ref.space.'.length)}`] = utilityValue;
} else if (id.startsWith('space.')) {
spacing[`ds-${id.slice('space.'.length)}`] = utilityValue;
} else if (id.startsWith('ref.radius.')) {
borderRadius[`ds-${id.slice('ref.radius.'.length)}`] = utilityValue;
} else if (id.startsWith('radius.')) {
borderRadius[`ds-${id.slice('radius.'.length)}`] = utilityValue;
} else if (id.startsWith('elevation.')) {
boxShadow[`ds-${id.replaceAll('.', '-')}`] = utilityValue;
} else if (
id.startsWith('ref.controlHeight.') ||
id.startsWith('controlHeight.')
) {
const step = id.split('.').at(-1);
const key = `ds-control-${step}`;
height[key] = utilityValue;
width[key] = utilityValue;
size[key] = utilityValue;
minHeight[key] = utilityValue;
} else if (id.startsWith('ref.layoutRow.') || id.startsWith('layout.')) {
const step = id.replace('ref.layoutRow.', '').replace('layout.', '');
const key = `ds-layout-${step}`;
height[key] = utilityValue;
minHeight[key] = utilityValue;
} else if (id.startsWith('ref.iconSize.') || id.startsWith('icon.')) {
const step = id.split('.').at(-1);
const key = `ds-icon-${step}`;
height[key] = utilityValue;
width[key] = utilityValue;
size[key] = utilityValue;
} else if (id.startsWith('ref.border.') || id.startsWith('border.')) {
const step = id.split('.').at(-1);
borderWidth[`ds-${step}`] = utilityValue;
} else if (id.startsWith('role.text.') && id.endsWith('.size')) {
const role = id.slice('role.text.'.length, -'.size'.length);
const lineId = `role.text.${role}.line`;
const lineToken = tokens.get(lineId);
fontSize[`ds-text-${role}`] = [
utilityValue,
{ lineHeight: `var(${lineToken.cssVar})` },
];
} else if (id.startsWith('role.code.') && id.endsWith('.size')) {
const role = id.slice('role.code.'.length, -'.size'.length);
const lineToken = tokens.get(`role.code.${role}.line`);
fontSize[`ds-code-${role}`] = [
utilityValue,
{ lineHeight: `var(${lineToken.cssVar})` },
];
}
}
fontSize['label-xs'] = fontSize['ds-text-meta'];
fontSize['body-xs'] = fontSize['ds-text-meta'];
fontSize['label-sm'] = fontSize['ds-text-base'];
fontSize['body-sm'] = fontSize['ds-text-base'];
fontSize['body-base'] = fontSize['ds-text-base'];
fontSize['body-md'] = fontSize['ds-text-body-large'];
fontSize['label-md'] = [
'var(--ds-text-body-large-size)',
{ lineHeight: 'var(--ds-text-body-large-line)' },
];
fontSize['label-lg'] = fontSize['ds-text-title'];
fontSize['heading-xs'] = fontSize['ds-text-meta'];
fontSize['heading-base'] = fontSize['ds-text-page'];
fontSize['heading-2xl'] = fontSize['ds-text-display'];
fontSize['body-lg'] = fontSize['ds-text-section'];
const PUBLIC_GROUPS = ['accent', 'neutral', 'ink', 'hairline'];
const PUBLIC_EMPHASIS = ['subtle', 'muted', 'default', 'strong'];
const PUBLIC_STATES = ['default', 'hover', 'disabled', 'selected'];
const FEEDBACK_TONES = ['success', 'warning', 'error', 'information'];
for (const group of PUBLIC_GROUPS) {
for (const emphasis of PUBLIC_EMPHASIS) {
colors[`ds-${group}-on-${emphasis}`] = `var(--ds-${group}-on-${emphasis})`;
for (const state of PUBLIC_STATES) {
colors[`ds-${group}-${emphasis}-${state}`] =
`var(--ds-${group}-${emphasis}-${state})`;
}
}
}
for (const tone of FEEDBACK_TONES) {
for (const emphasis of PUBLIC_EMPHASIS) {
colors[`ds-${tone}-on-${emphasis}`] = `var(--ds-${tone}-on-${emphasis})`;
}
}
colors['ds-ring-focus'] = 'var(--ds-ring-focus)';
colors['ds-ink-inverse'] = 'var(--ds-ink-inverse)';
colors['ds-icon-inverse'] = 'var(--ds-icon-inverse)';
colors['ds-success-indicator-on-default'] =
'var(--ds-success-indicator-on-default)';
const staticCssVariables = [
...orderedTokens.map(([, token]) => token.cssVar),
...PUBLIC_GROUPS.flatMap((group) => [
...PUBLIC_EMPHASIS.flatMap((emphasis) => [
`--ds-${group}-on-${emphasis}`,
...PUBLIC_STATES.map((state) => `--ds-${group}-${emphasis}-${state}`),
]),
]),
...FEEDBACK_TONES.flatMap((tone) =>
PUBLIC_EMPHASIS.map((emphasis) => `--ds-${tone}-on-${emphasis}`)
),
'--ds-ring-focus',
'--ds-theme-contrast',
'--ds-ink-inverse',
'--ds-icon-inverse',
'--ds-success-indicator-on-default',
].sort();
const colorMatrixCssVariables = [];
for (const element of colorManifest.elements) {
for (const tone of colorManifest.tones) {
for (const emphasis of colorManifest.emphasis) {
for (const state of colorManifest.states) {
colorMatrixCssVariables.push(
`--ds-${element}-${tone}-${emphasis}-${state}`
);
}
}
}
}
for (const color of colorManifest.category.colors) {
for (const [style, state] of colorManifest.category.roles) {
colorMatrixCssVariables.push(`--ds-category-${color}-${style}-${state}`);
}
}
const declaredCssVariables = [
...staticCssVariables,
...colorMatrixCssVariables,
].sort();
const declaredUtilityTokens = new Set();
for (const element of colorManifest.elements) {
for (const tone of colorManifest.tones) {
for (const emphasis of colorManifest.emphasis) {
for (const state of colorManifest.states) {
declaredUtilityTokens.add(`ds-${element}-${tone}-${emphasis}-${state}`);
}
}
}
}
for (const color of colorManifest.category.colors) {
for (const [style, state] of colorManifest.category.roles) {
declaredUtilityTokens.add(`ds-category-${color}-${style}-${state}`);
}
}
for (const status of [
'running',
'splitting',
'pending',
'error',
'reassigning',
'completed',
'blocked',
'paused',
'skipped',
'cancelled',
]) {
declaredUtilityTokens.add(`ds-bg-${status}-subtle-default`);
}
for (const group of PUBLIC_GROUPS) {
for (const emphasis of PUBLIC_EMPHASIS) {
declaredUtilityTokens.add(`ds-${group}-on-${emphasis}`);
for (const state of PUBLIC_STATES) {
declaredUtilityTokens.add(`ds-${group}-${emphasis}-${state}`);
}
}
}
for (const tone of FEEDBACK_TONES) {
for (const emphasis of PUBLIC_EMPHASIS) {
declaredUtilityTokens.add(`ds-${tone}-on-${emphasis}`);
}
}
declaredUtilityTokens.add('ds-ring-focus');
declaredUtilityTokens.add('ds-ink-inverse');
declaredUtilityTokens.add('ds-icon-inverse');
declaredUtilityTokens.add('ds-success-indicator-on-default');
for (const key of Object.keys(fontSize)) {
declaredUtilityTokens.add(key.startsWith('ds-') ? key : `text-${key}`);
}
for (const themeMap of [
colors,
spacing,
borderRadius,
boxShadow,
height,
width,
size,
minHeight,
borderWidth,
]) {
for (const key of Object.keys(themeMap)) {
declaredUtilityTokens.add(key);
}
}
const tailwindModule = `/* GENERATED FILE. Do not edit. Source: src/style/tokens/*.json */
module.exports = ${JSON.stringify(
{
colors,
spacing,
borderRadius,
boxShadow,
fontSize,
fontFamily,
fontWeight,
height,
width,
size,
minHeight,
borderWidth,
},
null,
2
)};
`;
const typeLines = [
'/* GENERATED FILE. Do not edit. Source: src/style/tokens/*.json */',
'',
'export const DS_TOKEN_IDS = [',
...orderedTokens.map(([id]) => ` '${id}',`),
'] as const;',
'',
'export type DsTokenId = (typeof DS_TOKEN_IDS)[number];',
'',
'export const DS_CSS_VARIABLES = [',
...staticCssVariables.map((name) => ` '${name}',`),
'] as const;',
'',
'export type DsCssVariable = (typeof DS_CSS_VARIABLES)[number];',
'',
'export const DS_TYPOGRAPHY_ROLES = {',
...Object.entries(typographyRoles).map(
([role, value]) =>
` '${role}': { sizePx: ${value.sizePx}, linePx: ${value.linePx} },`
),
'} as const;',
'',
];
const declared = {
cssVariables: declaredCssVariables,
utilityTokens: [...declaredUtilityTokens].sort(),
tones: colorManifest.tones,
toneAssignment: toneAssignment.families,
};
mkdirSync(outputDirectory, { recursive: true });
writeFileSync(
path.join(outputDirectory, 'tokens.css'),
`${cssLines.join('\n')}\n`
);
writeFileSync(
path.join(outputDirectory, 'tokens.tailwind.cjs'),
tailwindModule
);
writeFileSync(
path.join(outputDirectory, 'tokens.types.ts'),
typeLines.join('\n')
);
writeFileSync(
path.join(outputDirectory, 'declared-tokens.json'),
`${JSON.stringify(declared, null, 2)}\n`
);
const prettier = path.join(repositoryRoot, 'node_modules', '.bin', 'prettier');
execFileSync(
prettier,
[
'--write',
path.join(outputDirectory, 'tokens.css'),
path.join(outputDirectory, 'tokens.types.ts'),
path.join(outputDirectory, 'declared-tokens.json'),
],
{ cwd: repositoryRoot, stdio: 'inherit' }
);
execFileSync(
process.execPath,
[
path.join(repositoryRoot, 'licenses', 'update_license.js'),
path.join(outputDirectory, 'tokens.types.ts'),
],
{ cwd: repositoryRoot, stdio: 'inherit' }
);
process.stdout.write(
`Generated ${tokens.size} tokens → ${path.relative(repositoryRoot, outputDirectory)}\n`
);