Skip to content

Commit bc6f5c5

Browse files
committed
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.
1 parent 7948a91 commit bc6f5c5

8 files changed

Lines changed: 223 additions & 40 deletions

File tree

.changeset/font-assets.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@workflowbuilder/ui': minor
3+
'@workflowbuilder/sdk': minor
4+
---
5+
6+
Fonts now ship as `.woff2` assets next to the stylesheets, with only the two dominant faces inlined.
7+
A Content-Security-Policy that lists `font-src` now needs `'self'` or the serving origin instead of `data:`.

apps/docs/src/content/docs/ui-library/overview.mdx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,24 @@ your own. Everything else the components need, including `@base-ui/react`
2525

2626
## Styles
2727

28-
Importing a component from the package root injects that component's CSS
29-
automatically, including the layer order (`@layer ui.base, ui.component`)
30-
and typography classes - so the only thing left to add is the design
31-
tokens:
28+
The package has five style surfaces:
29+
30+
- **`@workflowbuilder/ui`** is the root barrel. It provides every component and
31+
all component and global CSS, but not the design token values.
32+
- **`@workflowbuilder/ui/<component>`** provides one component and only that
33+
component's CSS. Add `styles.css` for the global reset, typography and fonts,
34+
and `tokens.css` for the design tokens.
35+
- **`@workflowbuilder/ui/index.css`** provides all component and global CSS,
36+
including typography and fonts, but not the design token values.
37+
- **`@workflowbuilder/ui/styles.css`** provides the global reset, typography and
38+
fonts, but no component CSS or design token values.
39+
- **`@workflowbuilder/ui/tokens.css`** provides the design token values, but no
40+
component, typography or font rules.
41+
42+
The stylesheets reference `./assets/*.woff2`, so preserve the package's `dist`
43+
layout when copying or serving them.
44+
45+
With the root barrel, add only the design tokens:
3246

3347
```ts
3448
// Design tokens (the `--wb-*` custom properties).
@@ -39,16 +53,11 @@ import '@workflowbuilder/ui/tokens.css';
3953
import { Button } from '@workflowbuilder/ui';
4054
```
4155

42-
Need only one component's styles without the others? Import the per-component
43-
subpath instead. That only injects the component's own CSS. Every built
44-
stylesheet carries the cascade-layer order, so import order doesn't matter;
45-
add the global stylesheet once if you also want the typography classes, plus
46-
the tokens:
56+
Every built stylesheet carries the cascade-layer order, so import order does
57+
not matter. With a per-component subpath, add the global stylesheet and tokens:
4758

4859
```ts
49-
// Optional: global typography classes.
5060
import '@workflowbuilder/ui/styles.css';
51-
// Design tokens (the `--wb-*` custom properties).
5261
import '@workflowbuilder/ui/tokens.css';
5362
```
5463

packages/sdk/vite.config.mts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/// <reference types="vitest/config" />
22
import react from '@vitejs/plugin-react';
3+
import fs from 'node:fs';
34
import path from 'node:path';
4-
import { defineConfig } from 'vite';
5+
import { type Plugin, defineConfig } from 'vite';
56
import dts from 'vite-plugin-dts';
67
import svgr from 'vite-plugin-svgr';
78

@@ -51,10 +52,34 @@ const EXTERNAL_PACKAGES = [
5152
const isExternalPackage = (id: string) =>
5253
EXTERNAL_PACKAGES.some((packageName) => id === packageName || id.startsWith(`${packageName}/`));
5354

55+
function emitUiFontAssets(): Plugin {
56+
const distributionDirectory = path.resolve(import.meta.dirname, 'dist');
57+
58+
return {
59+
name: 'wb-sdk:emit-ui-font-assets',
60+
apply: 'build',
61+
closeBundle() {
62+
const uiDistribution = path.resolve(import.meta.dirname, '../ui/dist');
63+
const stylesheetPath = path.resolve(distributionDirectory, 'style.css');
64+
const fontStyles = fs.readFileSync(path.resolve(uiDistribution, 'fonts.css'), 'utf8');
65+
const assetsDirectory = path.resolve(distributionDirectory, 'assets');
66+
67+
fs.mkdirSync(assetsDirectory, { recursive: true });
68+
for (const file of fs.readdirSync(path.resolve(uiDistribution, 'assets'))) {
69+
if (!file.endsWith('.woff2')) continue;
70+
fs.copyFileSync(path.resolve(uiDistribution, 'assets', file), path.resolve(assetsDirectory, file));
71+
}
72+
73+
fs.appendFileSync(stylesheetPath, `\n${fontStyles}`);
74+
},
75+
};
76+
}
77+
5478
export default defineConfig(({ command }) => ({
5579
plugins: [
5680
svgr(),
5781
react(),
82+
emitUiFontAssets(),
5883
dts({
5984
// Bundle all type declarations into a single dist/index.d.ts file
6085
// via rollup-plugin-dts (matches the meeting decision to stop

packages/ui/combine-css-bundle.mts

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import fs from 'node:fs';
2+
import { createRequire } from 'node:module';
23
import path from 'node:path';
34
import type { Plugin } from 'vite';
45

56
/**
67
* Post-build CSS steps for the multi-entry library bundle. See css-layers.md.
78
*
89
* Emits `index.css` (all component styles, prefixed with the @layer order)
9-
* and `styles.css` (the global layer order, reset and typography), then
10+
* and `styles.css` (the global layer order, reset, typography and fonts), then
1011
* stamps the @layer order statement into every per-component stylesheet in
1112
* `dist/assets/`. Duplicate statements are no-ops, so whichever stylesheet
1213
* loads first establishes the correct order. Do not rely on import order
@@ -22,13 +23,131 @@ export function combineCssBundle(rootDirectory: string): Plugin {
2223
name: 'wb-ui:combine-css-bundle',
2324
apply: 'build',
2425
closeBundle() {
25-
writeCombinedStylesheet(distributionDirectory, stylesDirectory);
26-
writeGlobalStylesheet(distributionDirectory, stylesDirectory);
26+
const fontStyles = emitFontAssets(distributionDirectory);
27+
const layerOrder = readLayerOrder(stylesDirectory);
28+
fs.writeFileSync(path.resolve(distributionDirectory, 'fonts.css'), `${layerOrder}\n${fontStyles}\n`);
29+
writeCombinedStylesheet(distributionDirectory, stylesDirectory, fontStyles);
30+
writeGlobalStylesheet(distributionDirectory, stylesDirectory, fontStyles);
2731
prependLayerOrderToAssets(distributionDirectory, stylesDirectory);
2832
},
2933
};
3034
}
3135

36+
type FontFaceDefinition = {
37+
family: 'Inter' | 'Poppins';
38+
packageName: '@fontsource/inter' | '@fontsource/poppins';
39+
subset: 'latin' | 'latin-ext';
40+
weight: 300 | 400 | 500 | 600 | 700;
41+
inline: boolean;
42+
};
43+
44+
const FONT_FACES: FontFaceDefinition[] = [
45+
{ family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 300, inline: false },
46+
{ family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 400, inline: true },
47+
{ family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 500, inline: false },
48+
{ family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 600, inline: true },
49+
{ family: 'Poppins', packageName: '@fontsource/poppins', subset: 'latin', weight: 700, inline: false },
50+
{
51+
family: 'Poppins',
52+
packageName: '@fontsource/poppins',
53+
subset: 'latin-ext',
54+
weight: 300,
55+
inline: false,
56+
},
57+
{
58+
family: 'Poppins',
59+
packageName: '@fontsource/poppins',
60+
subset: 'latin-ext',
61+
weight: 400,
62+
inline: false,
63+
},
64+
{
65+
family: 'Poppins',
66+
packageName: '@fontsource/poppins',
67+
subset: 'latin-ext',
68+
weight: 500,
69+
inline: false,
70+
},
71+
{
72+
family: 'Poppins',
73+
packageName: '@fontsource/poppins',
74+
subset: 'latin-ext',
75+
weight: 600,
76+
inline: false,
77+
},
78+
{
79+
family: 'Poppins',
80+
packageName: '@fontsource/poppins',
81+
subset: 'latin-ext',
82+
weight: 700,
83+
inline: false,
84+
},
85+
{ family: 'Inter', packageName: '@fontsource/inter', subset: 'latin', weight: 400, inline: false },
86+
{
87+
family: 'Inter',
88+
packageName: '@fontsource/inter',
89+
subset: 'latin-ext',
90+
weight: 400,
91+
inline: false,
92+
},
93+
];
94+
95+
const require = createRequire(import.meta.url);
96+
97+
export function emitFontAssets(distributionDirectory: string): string {
98+
const assetsDirectory = path.resolve(distributionDirectory, 'assets');
99+
fs.mkdirSync(assetsDirectory, { recursive: true });
100+
101+
const packageDirectories = new Map<string, string>();
102+
const unicodeRanges = new Map<string, Record<string, string>>();
103+
const rules = FONT_FACES.map((face) => {
104+
let packageDirectory = packageDirectories.get(face.packageName);
105+
if (!packageDirectory) {
106+
packageDirectory = path.dirname(require.resolve(`${face.packageName}/package.json`));
107+
packageDirectories.set(face.packageName, packageDirectory);
108+
}
109+
110+
let packageUnicodeRanges = unicodeRanges.get(face.packageName);
111+
if (!packageUnicodeRanges) {
112+
packageUnicodeRanges = JSON.parse(
113+
fs.readFileSync(path.resolve(packageDirectory, 'unicode.json'), 'utf8'),
114+
) as Record<string, string>;
115+
unicodeRanges.set(face.packageName, packageUnicodeRanges);
116+
}
117+
118+
const unicodeRange = packageUnicodeRanges[face.subset];
119+
if (!unicodeRange) {
120+
throw new Error(`wb-ui:combine-css-bundle: ${face.packageName} has no ${face.subset} unicode range`);
121+
}
122+
123+
const familySlug = face.family.toLowerCase();
124+
const fileName = `${familySlug}-${face.subset}-${face.weight}-normal.woff2`;
125+
const sourcePath = path.resolve(packageDirectory, 'files', fileName);
126+
if (!fs.existsSync(sourcePath)) {
127+
throw new Error(`wb-ui:combine-css-bundle: ${sourcePath} is missing`);
128+
}
129+
130+
const source = face.inline
131+
? `url(data:font/woff2;base64,${fs.readFileSync(sourcePath).toString('base64')}) format('woff2')`
132+
: `url(./assets/${fileName}) format('woff2')`;
133+
134+
if (!face.inline) fs.copyFileSync(sourcePath, path.resolve(assetsDirectory, fileName));
135+
136+
return [
137+
' @font-face {',
138+
` font-family: '${face.family}';`,
139+
' font-style: normal;',
140+
' font-display: swap;',
141+
` font-weight: ${face.weight};`,
142+
` src: ${source};`,
143+
` unicode-range: ${unicodeRange};`,
144+
' }',
145+
].join('\n');
146+
});
147+
148+
return `@layer ui.base {\n${rules.join('\n\n')}\n}`;
149+
}
150+
32151
function readLayerOrder(stylesDirectory: string): string {
33152
return fs.readFileSync(path.resolve(stylesDirectory, 'layers.css'), 'utf8').trim();
34153
}
@@ -62,24 +181,24 @@ function cssFilesIn(assetsDirectory: string): string[] {
62181
return files;
63182
}
64183

65-
function writeCombinedStylesheet(distributionDirectory: string, stylesDirectory: string) {
184+
function writeCombinedStylesheet(distributionDirectory: string, stylesDirectory: string, fontStyles: string) {
66185
const assetsDirectory = assetsDirectoryOf(distributionDirectory);
67186

68187
// Within a layer, file order only breaks ties between equal-specificity rules.
69188
const styles = cssFilesIn(assetsDirectory)
70189
.map((file) => fs.readFileSync(path.resolve(assetsDirectory, file), 'utf8'))
71190
.join('\n');
72191

73-
const combined = `${readLayerOrder(stylesDirectory)}\n${styles}`;
192+
const combined = `${readLayerOrder(stylesDirectory)}\n${styles}\n${fontStyles}`;
74193
fs.writeFileSync(path.resolve(distributionDirectory, 'index.css'), combined);
75194
}
76195

77-
function writeGlobalStylesheet(distributionDirectory: string, stylesDirectory: string) {
196+
function writeGlobalStylesheet(distributionDirectory: string, stylesDirectory: string, fontStyles: string) {
78197
const globals = ['layers.css', 'globals.css', 'typography.css']
79198
.map((file) => fs.readFileSync(path.resolve(stylesDirectory, file), 'utf8'))
80199
.join('\n');
81200

82-
fs.writeFileSync(path.resolve(distributionDirectory, 'styles.css'), globals);
201+
fs.writeFileSync(path.resolve(distributionDirectory, 'styles.css'), `${globals}\n${fontStyles}`);
83202
}
84203

85204
function prependLayerOrderToAssets(distributionDirectory: string, stylesDirectory: string) {

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
},
5858
"./index.css": "./dist/index.css",
5959
"./styles.css": "./dist/styles.css",
60+
"./fonts.css": "./dist/fonts.css",
6061
"./tokens.css": "./dist/tokens.css"
6162
},
6263
"dependencies": {

packages/ui/scripts/check-built-css.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
* 4. Only the layer names declared in `src/styles/layers.css` may appear. An
2323
* unknown name (a typo) lands AFTER the declared order and silently wins
2424
* the cascade.
25-
* 5. Every `*.css` entry in package.json `exports` must exist in dist, and no
26-
* dist stylesheet may use `@import` - a relative import breaks silently when
27-
* a file is copied out alone, and constructed stylesheets ignore imports.
25+
* 5. Every `*.css` entry in package.json `exports` must exist in dist, no dist
26+
* stylesheet may use `@import`, and every non-data `url()` must resolve to a
27+
* file in dist.
2828
*
2929
* These are the only lines of defense for these bug classes today; source-level lint
3030
* rules would catch some of them earlier but none is configured yet.
@@ -238,6 +238,41 @@ function checkPublishedSurface(files: string[]): FailureReport[] {
238238
return failures;
239239
}
240240

241+
function checkUrlTargets(files: string[]): FailureReport[] {
242+
const failures: FailureReport[] = [];
243+
const urlPattern = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
244+
245+
for (const file of files) {
246+
const content = readFileSync(path.resolve(distributionDirectory, file), 'utf8');
247+
const hits: Hit[] = [];
248+
249+
postcss.parse(content).walkDecls((declaration) => {
250+
for (const match of declaration.value.matchAll(urlPattern)) {
251+
const reference = (match[1] ?? match[2] ?? match[3]).trim();
252+
if (reference.toLowerCase().startsWith('data:')) continue;
253+
254+
let targetPath = '';
255+
try {
256+
const fileReference = decodeURIComponent(reference.split(/[?#]/, 1)[0]);
257+
targetPath = path.resolve(distributionDirectory, path.dirname(file), fileReference);
258+
} catch {
259+
hits.push(hitFor(declaration));
260+
continue;
261+
}
262+
263+
const relativeTarget = path.relative(distributionDirectory, targetPath);
264+
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget) || !existsSync(targetPath)) {
265+
hits.push(hitFor(declaration));
266+
}
267+
}
268+
});
269+
270+
if (hits.length > 0) failures.push({ file, hits });
271+
}
272+
273+
return failures;
274+
}
275+
241276
// --- Run all checks ---------------------------------------------------------
242277

243278
function report(title: string, failures: FailureReport[], hint: string): boolean {
@@ -292,6 +327,11 @@ const results = [
292327
'package.json exports must point at real files, and dist CSS must be self-contained - ' +
293328
'a relative @import breaks silently when a file is copied out of the package.',
294329
),
330+
report(
331+
'Built CSS: every non-data url() resolves inside dist',
332+
checkUrlTargets(files),
333+
'Copy every referenced asset into dist and keep its path relative to the stylesheet.',
334+
),
295335
];
296336

297337
if (results.includes(false)) {

packages/ui/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import './styles/fonts.css';
21
import './styles/globals.css';
32
import './styles/layers.css';
43
import './styles/typography.css';

packages/ui/src/styles/fonts.css

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)