From bc6f5c5de8ef0c3673b36ddaae1d2448ad8dbddb Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Fri, 21 Aug 2026 15:21:01 +0200 Subject: [PATCH] perf(ui)!: ship the fonts as assets, inline only the two used weights The library inlined twelve font faces as base64 because Vite's library mode inlines every asset unconditionally, so the built stylesheet carried 382 KB of fonts: index.css was 509 KB and the SDK stylesheet, which bundles it, 591 KB. The faces are generated after Vite finishes, from the fontsource metadata, and copied into dist/assets. Poppins 400 and 600 latin stay inlined - the only weights the typography classes declare - so the common text needs no extra request. Everything else is fetched on demand, and each face now carries the unicode-range the per-subset fontsource files omit, so a document without extended latin skips those files entirely. The legacy woff source is gone. index.css is 150 KB, the SDK stylesheet 230 KB, and dist gains ten woff2 files. A new gate fails the build when a stylesheet references an asset that is not in dist - the failure mode this arrangement invites, and the one a previous font change hit only on clean CI. Consumers keep their imports; a Content-Security-Policy naming font-src needs 'self' rather than 'data:', and the dist layout has to survive copying. --- .changeset/font-assets.md | 7 + .../src/content/docs/ui-library/overview.mdx | 31 ++-- packages/sdk/vite.config.mts | 27 +++- packages/ui/combine-css-bundle.mts | 133 +++++++++++++++++- packages/ui/package.json | 1 + packages/ui/scripts/check-built-css.ts | 46 +++++- packages/ui/src/index.ts | 1 - packages/ui/src/styles/fonts.css | 17 --- 8 files changed, 223 insertions(+), 40 deletions(-) create mode 100644 .changeset/font-assets.md delete mode 100644 packages/ui/src/styles/fonts.css diff --git a/.changeset/font-assets.md b/.changeset/font-assets.md new file mode 100644 index 000000000..9379ace31 --- /dev/null +++ b/.changeset/font-assets.md @@ -0,0 +1,7 @@ +--- +'@workflowbuilder/ui': minor +'@workflowbuilder/sdk': minor +--- + +Fonts now ship as `.woff2` assets next to the stylesheets, with only the two dominant faces inlined. +A Content-Security-Policy that lists `font-src` now needs `'self'` or the serving origin instead of `data:`. diff --git a/apps/docs/src/content/docs/ui-library/overview.mdx b/apps/docs/src/content/docs/ui-library/overview.mdx index 3015683b6..0e55f6a7a 100644 --- a/apps/docs/src/content/docs/ui-library/overview.mdx +++ b/apps/docs/src/content/docs/ui-library/overview.mdx @@ -25,10 +25,24 @@ your own. Everything else the components need, including `@base-ui/react` ## Styles -Importing a component from the package root injects that component's CSS -automatically, including the layer order (`@layer ui.base, ui.component`) -and typography classes - so the only thing left to add is the design -tokens: +The package has five style surfaces: + +- **`@workflowbuilder/ui`** is the root barrel. It provides every component and + all component and global CSS, but not the design token values. +- **`@workflowbuilder/ui/`** provides one component and only that + component's CSS. Add `styles.css` for the global reset, typography and fonts, + and `tokens.css` for the design tokens. +- **`@workflowbuilder/ui/index.css`** provides all component and global CSS, + including typography and fonts, but not the design token values. +- **`@workflowbuilder/ui/styles.css`** provides the global reset, typography and + fonts, but no component CSS or design token values. +- **`@workflowbuilder/ui/tokens.css`** provides the design token values, but no + component, typography or font rules. + +The stylesheets reference `./assets/*.woff2`, so preserve the package's `dist` +layout when copying or serving them. + +With the root barrel, add only the design tokens: ```ts // Design tokens (the `--wb-*` custom properties). @@ -39,16 +53,11 @@ import '@workflowbuilder/ui/tokens.css'; import { Button } from '@workflowbuilder/ui'; ``` -Need only one component's styles without the others? Import the per-component -subpath instead. That only injects the component's own CSS. Every built -stylesheet carries the cascade-layer order, so import order doesn't matter; -add the global stylesheet once if you also want the typography classes, plus -the tokens: +Every built stylesheet carries the cascade-layer order, so import order does +not matter. With a per-component subpath, add the global stylesheet and tokens: ```ts -// Optional: global typography classes. import '@workflowbuilder/ui/styles.css'; -// Design tokens (the `--wb-*` custom properties). import '@workflowbuilder/ui/tokens.css'; ``` diff --git a/packages/sdk/vite.config.mts b/packages/sdk/vite.config.mts index 73b5ad415..00b7b089b 100644 --- a/packages/sdk/vite.config.mts +++ b/packages/sdk/vite.config.mts @@ -1,7 +1,8 @@ /// import react from '@vitejs/plugin-react'; +import fs from 'node:fs'; import path from 'node:path'; -import { defineConfig } from 'vite'; +import { type Plugin, defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; import svgr from 'vite-plugin-svgr'; @@ -51,10 +52,34 @@ const EXTERNAL_PACKAGES = [ const isExternalPackage = (id: string) => EXTERNAL_PACKAGES.some((packageName) => id === packageName || id.startsWith(`${packageName}/`)); +function emitUiFontAssets(): Plugin { + const distributionDirectory = path.resolve(import.meta.dirname, 'dist'); + + return { + name: 'wb-sdk:emit-ui-font-assets', + apply: 'build', + closeBundle() { + const uiDistribution = path.resolve(import.meta.dirname, '../ui/dist'); + const stylesheetPath = path.resolve(distributionDirectory, 'style.css'); + const fontStyles = fs.readFileSync(path.resolve(uiDistribution, 'fonts.css'), 'utf8'); + const assetsDirectory = path.resolve(distributionDirectory, 'assets'); + + fs.mkdirSync(assetsDirectory, { recursive: true }); + for (const file of fs.readdirSync(path.resolve(uiDistribution, 'assets'))) { + if (!file.endsWith('.woff2')) continue; + fs.copyFileSync(path.resolve(uiDistribution, 'assets', file), path.resolve(assetsDirectory, file)); + } + + fs.appendFileSync(stylesheetPath, `\n${fontStyles}`); + }, + }; +} + export default defineConfig(({ command }) => ({ plugins: [ svgr(), react(), + emitUiFontAssets(), dts({ // Bundle all type declarations into a single dist/index.d.ts file // via rollup-plugin-dts (matches the meeting decision to stop diff --git a/packages/ui/combine-css-bundle.mts b/packages/ui/combine-css-bundle.mts index 351944def..d77034442 100644 --- a/packages/ui/combine-css-bundle.mts +++ b/packages/ui/combine-css-bundle.mts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; import type { Plugin } from 'vite'; @@ -6,7 +7,7 @@ import type { Plugin } from 'vite'; * Post-build CSS steps for the multi-entry library bundle. See css-layers.md. * * Emits `index.css` (all component styles, prefixed with the @layer order) - * and `styles.css` (the global layer order, reset and typography), then + * and `styles.css` (the global layer order, reset, typography and fonts), then * stamps the @layer order statement into every per-component stylesheet in * `dist/assets/`. Duplicate statements are no-ops, so whichever stylesheet * loads first establishes the correct order. Do not rely on import order @@ -22,13 +23,131 @@ export function combineCssBundle(rootDirectory: string): Plugin { name: 'wb-ui:combine-css-bundle', apply: 'build', closeBundle() { - writeCombinedStylesheet(distributionDirectory, stylesDirectory); - writeGlobalStylesheet(distributionDirectory, stylesDirectory); + const fontStyles = emitFontAssets(distributionDirectory); + const layerOrder = readLayerOrder(stylesDirectory); + fs.writeFileSync(path.resolve(distributionDirectory, 'fonts.css'), `${layerOrder}\n${fontStyles}\n`); + writeCombinedStylesheet(distributionDirectory, stylesDirectory, fontStyles); + writeGlobalStylesheet(distributionDirectory, stylesDirectory, fontStyles); prependLayerOrderToAssets(distributionDirectory, stylesDirectory); }, }; } +type FontFaceDefinition = { + family: 'Inter' | 'Poppins'; + packageName: '@fontsource/inter' | '@fontsource/poppins'; + subset: 'latin' | 'latin-ext'; + weight: 300 | 400 | 500 | 600 | 700; + inline: boolean; +}; + +const FONT_FACES: FontFaceDefinition[] = [ + { family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 300, inline: false }, + { family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 400, inline: true }, + { family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 500, inline: false }, + { family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 600, inline: true }, + { family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 700, inline: false }, + { + family: 'Poppins', + packageName: '@fontsource/poppins', + subset: 'latin-ext', + weight: 300, + inline: false, + }, + { + family: 'Poppins', + packageName: '@fontsource/poppins', + subset: 'latin-ext', + weight: 400, + inline: false, + }, + { + family: 'Poppins', + packageName: '@fontsource/poppins', + subset: 'latin-ext', + weight: 500, + inline: false, + }, + { + family: 'Poppins', + packageName: '@fontsource/poppins', + subset: 'latin-ext', + weight: 600, + inline: false, + }, + { + family: 'Poppins', + packageName: '@fontsource/poppins', + subset: 'latin-ext', + weight: 700, + inline: false, + }, + { family: 'Inter', packageName: '@fontsource/inter', subset: 'latin', weight: 400, inline: false }, + { + family: 'Inter', + packageName: '@fontsource/inter', + subset: 'latin-ext', + weight: 400, + inline: false, + }, +]; + +const require = createRequire(import.meta.url); + +export function emitFontAssets(distributionDirectory: string): string { + const assetsDirectory = path.resolve(distributionDirectory, 'assets'); + fs.mkdirSync(assetsDirectory, { recursive: true }); + + const packageDirectories = new Map(); + const unicodeRanges = new Map>(); + const rules = FONT_FACES.map((face) => { + let packageDirectory = packageDirectories.get(face.packageName); + if (!packageDirectory) { + packageDirectory = path.dirname(require.resolve(`${face.packageName}/package.json`)); + packageDirectories.set(face.packageName, packageDirectory); + } + + let packageUnicodeRanges = unicodeRanges.get(face.packageName); + if (!packageUnicodeRanges) { + packageUnicodeRanges = JSON.parse( + fs.readFileSync(path.resolve(packageDirectory, 'unicode.json'), 'utf8'), + ) as Record; + unicodeRanges.set(face.packageName, packageUnicodeRanges); + } + + const unicodeRange = packageUnicodeRanges[face.subset]; + if (!unicodeRange) { + throw new Error(`wb-ui:combine-css-bundle: ${face.packageName} has no ${face.subset} unicode range`); + } + + const familySlug = face.family.toLowerCase(); + const fileName = `${familySlug}-${face.subset}-${face.weight}-normal.woff2`; + const sourcePath = path.resolve(packageDirectory, 'files', fileName); + if (!fs.existsSync(sourcePath)) { + throw new Error(`wb-ui:combine-css-bundle: ${sourcePath} is missing`); + } + + const source = face.inline + ? `url(data:font/woff2;base64,${fs.readFileSync(sourcePath).toString('base64')}) format('woff2')` + : `url(./assets/${fileName}) format('woff2')`; + + if (!face.inline) fs.copyFileSync(sourcePath, path.resolve(assetsDirectory, fileName)); + + return [ + ' @font-face {', + ` font-family: '${face.family}';`, + ' font-style: normal;', + ' font-display: swap;', + ` font-weight: ${face.weight};`, + ` src: ${source};`, + ` unicode-range: ${unicodeRange};`, + ' }', + ].join('\n'); + }); + + return `@layer ui.base {\n${rules.join('\n\n')}\n}`; +} + function readLayerOrder(stylesDirectory: string): string { return fs.readFileSync(path.resolve(stylesDirectory, 'layers.css'), 'utf8').trim(); } @@ -62,7 +181,7 @@ function cssFilesIn(assetsDirectory: string): string[] { return files; } -function writeCombinedStylesheet(distributionDirectory: string, stylesDirectory: string) { +function writeCombinedStylesheet(distributionDirectory: string, stylesDirectory: string, fontStyles: string) { const assetsDirectory = assetsDirectoryOf(distributionDirectory); // Within a layer, file order only breaks ties between equal-specificity rules. @@ -70,16 +189,16 @@ function writeCombinedStylesheet(distributionDirectory: string, stylesDirectory: .map((file) => fs.readFileSync(path.resolve(assetsDirectory, file), 'utf8')) .join('\n'); - const combined = `${readLayerOrder(stylesDirectory)}\n${styles}`; + const combined = `${readLayerOrder(stylesDirectory)}\n${styles}\n${fontStyles}`; fs.writeFileSync(path.resolve(distributionDirectory, 'index.css'), combined); } -function writeGlobalStylesheet(distributionDirectory: string, stylesDirectory: string) { +function writeGlobalStylesheet(distributionDirectory: string, stylesDirectory: string, fontStyles: string) { const globals = ['layers.css', 'globals.css', 'typography.css'] .map((file) => fs.readFileSync(path.resolve(stylesDirectory, file), 'utf8')) .join('\n'); - fs.writeFileSync(path.resolve(distributionDirectory, 'styles.css'), globals); + fs.writeFileSync(path.resolve(distributionDirectory, 'styles.css'), `${globals}\n${fontStyles}`); } function prependLayerOrderToAssets(distributionDirectory: string, stylesDirectory: string) { diff --git a/packages/ui/package.json b/packages/ui/package.json index 5e9a3ea7a..727c02df7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -57,6 +57,7 @@ }, "./index.css": "./dist/index.css", "./styles.css": "./dist/styles.css", + "./fonts.css": "./dist/fonts.css", "./tokens.css": "./dist/tokens.css" }, "dependencies": { diff --git a/packages/ui/scripts/check-built-css.ts b/packages/ui/scripts/check-built-css.ts index 79710c167..f1eb1bcc7 100644 --- a/packages/ui/scripts/check-built-css.ts +++ b/packages/ui/scripts/check-built-css.ts @@ -22,9 +22,9 @@ * 4. Only the layer names declared in `src/styles/layers.css` may appear. An * unknown name (a typo) lands AFTER the declared order and silently wins * the cascade. - * 5. Every `*.css` entry in package.json `exports` must exist in dist, and no - * dist stylesheet may use `@import` - a relative import breaks silently when - * a file is copied out alone, and constructed stylesheets ignore imports. + * 5. Every `*.css` entry in package.json `exports` must exist in dist, no dist + * stylesheet may use `@import`, and every non-data `url()` must resolve to a + * file in dist. * * These are the only lines of defense for these bug classes today; source-level lint * rules would catch some of them earlier but none is configured yet. @@ -238,6 +238,41 @@ function checkPublishedSurface(files: string[]): FailureReport[] { return failures; } +function checkUrlTargets(files: string[]): FailureReport[] { + const failures: FailureReport[] = []; + const urlPattern = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi; + + for (const file of files) { + const content = readFileSync(path.resolve(distributionDirectory, file), 'utf8'); + const hits: Hit[] = []; + + postcss.parse(content).walkDecls((declaration) => { + for (const match of declaration.value.matchAll(urlPattern)) { + const reference = (match[1] ?? match[2] ?? match[3]).trim(); + if (reference.toLowerCase().startsWith('data:')) continue; + + let targetPath = ''; + try { + const fileReference = decodeURIComponent(reference.split(/[?#]/, 1)[0]); + targetPath = path.resolve(distributionDirectory, path.dirname(file), fileReference); + } catch { + hits.push(hitFor(declaration)); + continue; + } + + const relativeTarget = path.relative(distributionDirectory, targetPath); + if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget) || !existsSync(targetPath)) { + hits.push(hitFor(declaration)); + } + } + }); + + if (hits.length > 0) failures.push({ file, hits }); + } + + return failures; +} + // --- Run all checks --------------------------------------------------------- function report(title: string, failures: FailureReport[], hint: string): boolean { @@ -292,6 +327,11 @@ const results = [ 'package.json exports must point at real files, and dist CSS must be self-contained - ' + 'a relative @import breaks silently when a file is copied out of the package.', ), + report( + 'Built CSS: every non-data url() resolves inside dist', + checkUrlTargets(files), + 'Copy every referenced asset into dist and keep its path relative to the stylesheet.', + ), ]; if (results.includes(false)) { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 5e40367af..a3a2cdb93 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,3 @@ -import './styles/fonts.css'; import './styles/globals.css'; import './styles/layers.css'; import './styles/typography.css'; diff --git a/packages/ui/src/styles/fonts.css b/packages/ui/src/styles/fonts.css deleted file mode 100644 index 076b36ed3..000000000 --- a/packages/ui/src/styles/fonts.css +++ /dev/null @@ -1,17 +0,0 @@ -/* Bundled instead of CDN-fetched: strict CSPs, GDPR (pre-consent request), - and air-gapped deployments all rule a CDN out. Loaded via the root barrel - import — consumers importing only subpaths load the families themselves. */ -@import '@fontsource/poppins/latin-300.css' layer(ui.base); -@import '@fontsource/poppins/latin-400.css' layer(ui.base); -@import '@fontsource/poppins/latin-500.css' layer(ui.base); -@import '@fontsource/poppins/latin-600.css' layer(ui.base); -@import '@fontsource/poppins/latin-700.css' layer(ui.base); -@import '@fontsource/poppins/latin-ext-300.css' layer(ui.base); -@import '@fontsource/poppins/latin-ext-400.css' layer(ui.base); -@import '@fontsource/poppins/latin-ext-500.css' layer(ui.base); -@import '@fontsource/poppins/latin-ext-600.css' layer(ui.base); -@import '@fontsource/poppins/latin-ext-700.css' layer(ui.base); - -/* Inter serves the UI/Code type role (wb-text-code), Regular only. */ -@import '@fontsource/inter/latin-400.css' layer(ui.base); -@import '@fontsource/inter/latin-ext-400.css' layer(ui.base);