Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/per-component-css-token-slices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tiny-design/react": minor
"@tiny-design/tokens": minor
---

Emit per-component CSS token slices to dramatically shrink per-component bundles. The tokens package now emits `dist/css/foundation.css` (primitives), `dist/css/semantic.css` (semantics), and `dist/css/components/<name>.css` (per-component) alongside the existing `base.css`. Each compiled component entry imports only the slices it transitively needs, reducing per-component CSS by ~60% raw and ~80% gzipped (Button: 261 KB → 103 KB raw, 36 KB → 7.5 KB gzipped). Full-library bundle size is unchanged; `base.css` is still emitted for backward compatibility.
30 changes: 26 additions & 4 deletions packages/react/scripts/build-styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,37 @@ async function processWithPostcss(css) {
return result.css;
}

// 1. Base CSS: copy the runtime theme bundle from @tiny-design/tokens
// 1. Base CSS: copy the runtime theme bundle from @tiny-design/tokens.
// Also copies the per-tier (foundation/semantic) and per-component slice files
// plus the slice manifest so inject-style-imports.js can wire components to slices.
function copyBaseCss() {
const src = require.resolve('@tiny-design/tokens/dist/css/base.css');
const baseSrc = require.resolve('@tiny-design/tokens/dist/css/base.css');
const tokensCssDir = path.dirname(baseSrc);
const foundationSrc = path.join(tokensCssDir, 'foundation.css');
const semanticSrc = path.join(tokensCssDir, 'semantic.css');
const componentsSrcDir = path.join(tokensCssDir, 'components');
const manifestSrc = path.join(tokensCssDir, 'component-deps.json');

for (const dir of [ES_DIR, LIB_DIR]) {
const outDir = path.join(dir, 'style');
const componentsOutDir = path.join(outDir, 'components');
mkdirp(outDir);
fs.copyFileSync(src, path.join(outDir, 'base.css'));
mkdirp(componentsOutDir);
fs.copyFileSync(baseSrc, path.join(outDir, 'base.css'));
fs.copyFileSync(foundationSrc, path.join(outDir, 'foundation.css'));
fs.copyFileSync(semanticSrc, path.join(outDir, 'semantic.css'));
fs.copyFileSync(manifestSrc, path.join(outDir, 'component-deps.json'));

for (const file of fs.readdirSync(componentsSrcDir)) {
if (!file.endsWith('.css')) continue;
fs.copyFileSync(path.join(componentsSrcDir, file), path.join(componentsOutDir, file));
}
}
console.log(' es/style/base.css + lib/style/base.css (copied from @tiny-design/tokens base theme CSS)');
const sliceCount = fs.readdirSync(componentsSrcDir).filter((f) => f.endsWith('.css')).length;
console.log(
` es/style/{base,foundation,semantic}.css + lib/style/{base,foundation,semantic}.css copied`
);
console.log(` es/style/components/*.css + lib/style/components/*.css (${sliceCount} slices)`);
}

// 2. Per-component CSS: compile each component's style/index.scss entry
Expand Down
76 changes: 44 additions & 32 deletions packages/react/scripts/inject-style-imports.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,41 +69,48 @@ function transformPath(cssPath) {
return cssPath;
}

function loadSliceManifest(baseDir) {
const manifestPath = path.join(baseDir, 'style', 'component-deps.json');
if (!fs.existsSync(manifestPath)) return {};
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
}

function formatImport(p, format) {
return format === 'esm' ? `import '${p}';` : `require('${p}');`;
}

/**
* Build the CSS import lines to prepend to a component's index.js.
*
* Component CSS now resolves tokens through three layers:
* 1. foundation.css — primitive token tier (always loaded)
* 2. semantic.css — semantic token tier (always loaded)
* 3. components/<slice>.css — only the component-tier slices this component needs
* (own + transitive style/var() deps), per the slice manifest
* After tokens come the component's own selectors and any composed selector deps.
*/
function buildImportLines(componentDir, format) {
function buildImportLines(componentDir, format, manifest) {
const componentName = path.basename(componentDir);
const styleJsPath = path.join(componentDir, 'style', 'index.js');
const ownCssPath = path.join(componentDir, 'style', 'index.css');
const deps = parseCssDeps(styleJsPath);

const imports = [];

// Always import base styles first (theme, normalize, animations)
const basePath = '../style/base.css';
if (format === 'esm') {
imports.push(`import '${basePath}';`);
} else {
imports.push(`require('${basePath}');`);
imports.push(formatImport('../style/foundation.css', format));
imports.push(formatImport('../style/semantic.css', format));

const sliceNames = manifest[componentName] || [];
for (const sliceName of sliceNames) {
imports.push(formatImport(`../style/components/${sliceName}.css`, format));
}

if (deps.length > 0) {
// Use parsed dependencies from style/index.js (includes own CSS + deps)
for (const dep of deps) {
const transformed = transformPath(dep);
if (format === 'esm') {
imports.push(`import '${transformed}';`);
} else {
imports.push(`require('${transformed}');`);
}
imports.push(formatImport(transformPath(dep), format));
}
} else if (fs.existsSync(ownCssPath)) {
// Fallback: no style/index.js but has compiled CSS
if (format === 'esm') {
imports.push("import './style/index.css';");
} else {
imports.push("require('./style/index.css');");
}
imports.push(formatImport('./style/index.css', format));
}

return imports;
Expand All @@ -112,17 +119,17 @@ function buildImportLines(componentDir, format) {
/**
* Inject CSS imports into a component's index.js file.
*/
function injectComponent(componentDir, format) {
function injectComponent(componentDir, format, manifest) {
const indexPath = path.join(componentDir, 'index.js');
if (!fs.existsSync(indexPath)) return false;

const imports = buildImportLines(componentDir, format);
if (imports.length <= 1) return false; // Only base import, no component CSS
const imports = buildImportLines(componentDir, format, manifest);
if (imports.length <= 2) return false; // Only foundation + semantic, no component CSS

const content = fs.readFileSync(indexPath, 'utf-8');

// Don't inject twice
if (content.includes("style/index.css") || content.includes("style/base.css")) {
if (content.includes('style/foundation.css') || content.includes('style/index.css')) {
return false;
}

Expand All @@ -132,24 +139,29 @@ function injectComponent(componentDir, format) {
}

/**
* Inject base CSS import into the barrel index.js (es/index.js or lib/index.js).
* Inject foundation + semantic token imports into the barrel index.js.
* Per-component slices are NOT injected here — each component entry pulls its own slices.
*/
function injectBarrel(dir, format) {
const indexPath = path.join(dir, 'index.js');
if (!fs.existsSync(indexPath)) return;

const content = fs.readFileSync(indexPath, 'utf-8');
if (content.includes("style/base.css")) return;
if (content.includes('style/foundation.css')) return;

const line = format === 'esm'
? "import './style/base.css';\n"
: "require('./style/base.css');\n";
const lines = [
formatImport('./style/foundation.css', format),
formatImport('./style/semantic.css', format),
].join('\n') + '\n';

fs.writeFileSync(indexPath, line + content);
console.log(` injected base CSS into ${path.relative(path.resolve(__dirname, '..'), indexPath)}`);
fs.writeFileSync(indexPath, lines + content);
console.log(
` injected foundation + semantic CSS into ${path.relative(path.resolve(__dirname, '..'), indexPath)}`
);
}

function processDir(baseDir, format) {
const manifest = loadSliceManifest(baseDir);
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
let count = 0;

Expand All @@ -159,7 +171,7 @@ function processDir(baseDir, format) {
if (entry.name.startsWith('_') || entry.name === 'style' || entry.name === 'locale') continue;

const componentDir = path.join(baseDir, entry.name);
if (injectComponent(componentDir, format)) {
if (injectComponent(componentDir, format, manifest)) {
count++;
}
}
Expand Down
144 changes: 144 additions & 0 deletions packages/tokens/scripts/build-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,127 @@ function buildBaseThemeCss(tokens, cssValues, tokenMap, lightTheme, darkTheme) {
return parts.join('\n');
}

function buildSliceCss(tokens, cssValues, tokenMap, lightTheme, darkTheme) {
if (tokens.length === 0) return '';
return buildBaseThemeCss(tokens, cssValues, tokenMap, lightTheme, darkTheme);
}

const SLICE_SCAN_SKIP_DIRS = new Set(['__tests__', '__snapshots__', 'demo']);

function listSliceScanFiles(dirPath) {
if (!fs.existsSync(dirPath)) return [];
const result = [];
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
if (SLICE_SCAN_SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
result.push(...listSliceScanFiles(full));
} else if (CONSUMER_FILE_EXTENSIONS.has(path.extname(entry.name))) {
result.push(full);
}
}
return result;
}

function listReactComponentDirs() {
if (!fs.existsSync(REACT_SRC_DIR)) return [];
return listDirectories(REACT_SRC_DIR).filter(
(name) => !name.startsWith('_') && name !== 'style' && name !== 'locale'
);
}

function extractStyleEntryDeps(dirName, namespacesWithStyles) {
const styleEntryPath = path.join(REACT_SRC_DIR, dirName, 'style', 'index.tsx');
if (!fs.existsSync(styleEntryPath)) return new Set();

const sourceText = readText(styleEntryPath);
const importRegex = /from\s+['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g;
const deps = new Set();
let match;

while ((match = importRegex.exec(sourceText)) !== null) {
const importPath = match[1] || match[2];
const styleDepMatch = importPath.match(/^\.\.\/\.\.\/([^/]+)\/style/);
if (styleDepMatch && namespacesWithStyles.has(styleDepMatch[1])) {
deps.add(styleDepMatch[1]);
}
}

return deps;
}

function computeSliceManifest(allTokens) {
const componentTokens = allTokens.filter((token) => token.category === 'component');
const tokensByNamespace = new Map();
for (const token of componentTokens) {
if (!token.component) continue;
if (!tokensByNamespace.has(token.component)) {
tokensByNamespace.set(token.component, []);
}
tokensByNamespace.get(token.component).push(token);
}

const varToNamespace = new Map();
for (const token of componentTokens) {
if (token.component) {
varToNamespace.set(tokenKeyToCssVar(token.key), token.component);
}
}

const reactDirs = listReactComponentDirs();
const namespacesWithStyles = new Set(reactDirs);

const directDeps = new Map();
for (const dirName of reactDirs) {
const dirPath = path.join(REACT_SRC_DIR, dirName);
const deps = new Set();

for (const filePath of listSliceScanFiles(dirPath)) {
const sourceText = readText(filePath);
for (const reference of extractTyVarReferences(sourceText)) {
const namespace = varToNamespace.get(reference.name);
if (namespace) deps.add(namespace);
}
}

for (const styleDep of extractStyleEntryDeps(dirName, namespacesWithStyles)) {
deps.add(styleDep);
}

directDeps.set(dirName, deps);
}

const closure = new Map();
for (const dirName of directDeps.keys()) {
const visited = new Set();
const queue = [...(directDeps.get(dirName) || [])];
while (queue.length > 0) {
const namespace = queue.shift();
if (visited.has(namespace)) continue;
visited.add(namespace);
const nsDeps = directDeps.get(namespace);
if (nsDeps) {
for (const next of nsDeps) {
if (!visited.has(next)) queue.push(next);
}
}
}
closure.set(dirName, visited);
}

const manifest = {};
const tokenSliceNames = new Set(tokensByNamespace.keys());
for (const [dirName, namespaceSet] of closure.entries()) {
const sliceNames = Array.from(namespaceSet)
.filter((namespace) => tokenSliceNames.has(namespace))
.sort();
if (sliceNames.length === 0) continue;
manifest[dirName] = sliceNames;
}

return { tokensByNamespace, manifest };
}

function buildRegistryDts() {
return `export type TokenCategory = 'primitive' | 'semantic' | 'component';
export type TokenType =
Expand Down Expand Up @@ -970,13 +1091,32 @@ function buildRuntimeTokens(options = {}) {
const darkCss = buildThemeCss(allTokens, cssValues, tokenMap, darkThemeOverrides, "[data-tiny-theme='dark']");
const baseCss = buildBaseThemeCss(allTokens, cssValues, tokenMap, lightTheme, darkTheme);

const primitiveTier = allTokens.filter((token) => token.category === 'primitive');
const semanticTier = allTokens.filter((token) => token.category === 'semantic');
const foundationCss = buildSliceCss(primitiveTier, cssValues, tokenMap, lightTheme, darkTheme);
const semanticCss = buildSliceCss(semanticTier, cssValues, tokenMap, lightTheme, darkTheme);

const { tokensByNamespace, manifest } = computeSliceManifest(allTokens);

mkdirp(DIST_CSS_DIR);
mkdirp(SCHEMA_DIST_DIR);
const componentsDir = path.join(DIST_CSS_DIR, 'components');
mkdirp(componentsDir);
writeJson(path.join(DIST_DIR, 'registry.json'), registry);
writeJson(path.join(DIST_DIR, 'presets.json'), presets);
fs.writeFileSync(path.join(DIST_CSS_DIR, 'light.css'), lightCss);
fs.writeFileSync(path.join(DIST_CSS_DIR, 'dark.css'), darkCss);
fs.writeFileSync(path.join(DIST_CSS_DIR, 'base.css'), baseCss);
fs.writeFileSync(path.join(DIST_CSS_DIR, 'foundation.css'), foundationCss);
fs.writeFileSync(path.join(DIST_CSS_DIR, 'semantic.css'), semanticCss);

for (const [namespace, namespaceTokens] of tokensByNamespace.entries()) {
const sliceCss = buildSliceCss(namespaceTokens, cssValues, tokenMap, lightTheme, darkTheme);
fs.writeFileSync(path.join(componentsDir, `${namespace}.css`), sliceCss);
}

writeJson(path.join(DIST_CSS_DIR, 'component-deps.json'), manifest);

fs.writeFileSync(REGISTRY_DTS_PATH, buildRegistryDts());
fs.writeFileSync(PRESETS_DTS_PATH, buildPresetsDts(presets));
fs.copyFileSync(THEME_SCHEMA_PATH, path.join(SCHEMA_DIST_DIR, 'theme.schema.json'));
Expand All @@ -989,6 +1129,10 @@ function buildRuntimeTokens(options = {}) {
console.log(' dist/css/light.css');
console.log(' dist/css/dark.css');
console.log(' dist/css/base.css');
console.log(' dist/css/foundation.css');
console.log(' dist/css/semantic.css');
console.log(` dist/css/components/*.css (${tokensByNamespace.size} slices)`);
console.log(` dist/css/component-deps.json (${Object.keys(manifest).length} dirs)`);
console.log('\nRuntime tokens done.');

return { registry, presets };
Expand Down
Loading