Skip to content

Commit 24fddf9

Browse files
committed
fix(ui,sdk)!: carry the font faces in the entry chunk and harden the font pipeline
1 parent d1c1b06 commit 24fddf9

10 files changed

Lines changed: 187 additions & 40 deletions

File tree

.changeset/font-assets.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
---
2-
'@workflowbuilder/ui': minor
3-
'@workflowbuilder/sdk': minor
2+
'@workflowbuilder/ui': major
3+
'@workflowbuilder/sdk': major
44
---
55

66
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:`.
7+
8+
Breaking changes:
9+
10+
- Preserve the published `dist/assets` directory next to copied stylesheets so their relative font URLs keep resolving.
11+
- If a Content Security Policy defines `font-src`, add `'self'` or the serving origin while retaining `data:` for the inlined faces.
12+
13+
Only Poppins latin 400 and 600 are inline. Other weights, Inter, and non-ASCII glyphs use `font-display: swap` assets and may briefly render in the fallback font; preload the relevant files when that flash of unstyled text (FOUT) is unacceptable.

apps/docs/src/components/ui-examples/component-preview.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import componentCss from '@workflowbuilder/ui/index.css?raw';
2-
import globalCss from '@workflowbuilder/ui/styles.css?raw';
31
import { type ReactNode, useEffect, useRef, useState } from 'react';
42
import { createPortal } from 'react-dom';
53

64
import styles from './component-preview.module.css';
75

6+
import previewCss from '../../../../../packages/ui/dist/docs-preview.css?raw';
7+
88
// Examples render in a shadow root so Starlight's rules cannot reach them and
99
// the library's cannot leak out. Inherited and custom properties still cross
1010
// the boundary - that is how the docs theme reaches the examples. Inside a
1111
// shadow root `:root` matches nothing, hence the retarget to `:host`.
12-
const shadowCss = `${`${globalCss}\n${componentCss}`.replaceAll(':root', ':host')}
12+
const shadowCss = `${previewCss.replaceAll(':root', ':host')}
1313
:host > :not(style) { max-width: 100%; }`;
1414

1515
export function ComponentPreview({ children }: { children: ReactNode }) {

apps/docs/src/content/docs/get-started/theming.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ The aggregated `style.css` ships with the SDK's default visual layer. Override C
99

1010
## Typography
1111

12-
Poppins is bundled into `style.css` as inline base64 woff2 (latin + latin-ext, weights 300–700). No external font CDN is contacted at runtime — works under strict CSP, behind GDPR-controlled consent flows, and in air-gapped deployments.
12+
`style.css` inlines Poppins latin 400 and 600 and references the remaining Poppins and Inter faces in the adjacent `assets` directory. Preserve that `dist` layout when copying the stylesheet. A Content Security Policy (CSP) that defines `font-src` must allow both `data:` and `'self'` or the origin serving those assets. No external font CDN is contacted at runtime, so the SDK still works behind consent controls and in air-gapped deployments.
13+
14+
Other weights, Inter, and non-ASCII glyphs use `font-display: swap` assets. They can briefly appear in the fallback font while the matching file loads; preload the relevant `.woff2` files when that flash of unstyled text (FOUT) is unacceptable.
1315

1416
Override `--wb-font-family` to use a different face:
1517

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ The package has five style surfaces:
4242
The stylesheets reference `./assets/*.woff2`, so preserve the package's `dist`
4343
layout when copying or serving them.
4444

45+
Only Poppins latin 400 and 600 are inline. Other weights, Inter, and non-ASCII
46+
glyphs use `font-display: swap` assets, so they can briefly appear in the
47+
fallback font while loading. Preload the relevant `.woff2` files when that
48+
flash of unstyled text (FOUT) is unacceptable:
49+
50+
```html
51+
<link rel="preload" href="/assets/poppins-latin-ext-400-normal.woff2" as="font" type="font/woff2" crossorigin />
52+
```
53+
4554
With the root barrel, add only the design tokens:
4655

4756
```ts

knip.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default {
5555
},
5656
'packages/ui': {
5757
entry: ['src/index.ts', 'vite.config.mts', 'scripts/check-built-css.ts'],
58-
project: ['src/**/*.{ts,tsx,mts}', '*.mts', 'scripts/**/*.ts'],
58+
project: ['src/**/*.{ts,tsx}', '*.mts', 'scripts/**/*.ts'],
5959
// Built tokens are copied by relative path (../tokens/dist) in vite.config,
6060
// and the fontsource families feed the post-build font generator - real
6161
// dependencies knip's JS/TS walk cannot see.

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"lint": "eslint",
6161
"test": "vitest run",
6262
"test:watch": "vitest",
63-
"build:lib": "vite build",
63+
"build:lib": "vite build && pnpm exec tsx ../ui/scripts/check-built-css.ts dist",
6464
"prepublishOnly": "pnpm build:lib"
6565
},
6666
"peerDependencies": {

packages/sdk/src/index.css

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
See packages/ui/css-layers.md. */
77
@layer ui.base, ui.component;
88

9-
/* Poppins and Inter ship with @workflowbuilder/ui (next to the wb-text-*
10-
classes that require them) and arrive through the JS module graph. */
119
@import '@workflowbuilder/ui/tokens.css';
1210

1311
/* @workflowbuilder/ui styles arrive through the JS module graph - importing

packages/sdk/vite.config.mts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,38 @@ function emitUiFontAssets(): Plugin {
6161
closeBundle() {
6262
const uiDistribution = path.resolve(import.meta.dirname, '../ui/dist');
6363
const stylesheetPath = path.resolve(distributionDirectory, 'style.css');
64-
const fontStyles = fs.readFileSync(path.resolve(uiDistribution, 'fonts.css'), 'utf8');
64+
const fontStylesPath = path.resolve(uiDistribution, 'fonts.css');
6565
const assetsDirectory = path.resolve(distributionDirectory, 'assets');
6666

67+
if (!fs.existsSync(fontStylesPath)) {
68+
throw new Error(
69+
'@workflowbuilder/ui dist is missing fonts.css - build the UI first: `pnpm build:ui`',
70+
);
71+
}
72+
if (!fs.existsSync(stylesheetPath)) {
73+
throw new Error(
74+
`wb-sdk:emit-ui-font-assets: ${stylesheetPath} is missing - ` +
75+
'the Vite build emitted no SDK stylesheet to receive the font faces',
76+
);
77+
}
78+
79+
const fontStyles = fs
80+
.readFileSync(fontStylesPath, 'utf8')
81+
.replace(/^@layer ui\.base, ui\.component;\s*/, '');
82+
const stylesheet = fs
83+
.readFileSync(stylesheetPath, 'utf8')
84+
.replaceAll(
85+
/@font-face\s*{(?=[^{}]*font-family:\s*["']?(?:Poppins|Inter)["']?\s*;)[^{}]*}/g,
86+
'',
87+
);
88+
6789
fs.mkdirSync(assetsDirectory, { recursive: true });
6890
for (const file of fs.readdirSync(path.resolve(uiDistribution, 'assets'))) {
6991
if (!file.endsWith('.woff2')) continue;
7092
fs.copyFileSync(path.resolve(uiDistribution, 'assets', file), path.resolve(assetsDirectory, file));
7193
}
7294

95+
fs.writeFileSync(stylesheetPath, stylesheet);
7396
fs.appendFileSync(stylesheetPath, `\n${fontStyles}`);
7497
},
7598
};

packages/ui/combine-css-bundle.mts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ import type { Plugin } from 'vite';
66
/**
77
* Post-build CSS steps for the multi-entry library bundle. See css-layers.md.
88
*
9-
* Emits `index.css` (all component styles, prefixed with the @layer order)
10-
* and `styles.css` (the global layer order, reset, typography and fonts), then
11-
* stamps the @layer order statement into every per-component stylesheet in
12-
* `dist/assets/`. Duplicate statements are no-ops, so whichever stylesheet
13-
* loads first establishes the correct order. Do not rely on import order
14-
* instead: in the built barrel the injected per-chunk CSS evaluates before
15-
* the entry CSS, so "the declaration loads first" is true in src/ but false
16-
* in dist/ (the inverted-cascade bug class).
9+
* Emits the standalone stylesheets, font assets, and a font-free stylesheet
10+
* for docs previews, then stamps the @layer order statement into every
11+
* per-component stylesheet in `dist/assets/`. Duplicate statements are
12+
* no-ops, so whichever stylesheet loads first establishes the correct order.
13+
* Do not rely on import order instead: in the built barrel the injected
14+
* per-chunk CSS evaluates before the entry CSS, so "the declaration loads
15+
* first" is true in src/ but false in dist/ (the inverted-cascade bug class).
1716
*/
1817
export function combineCssBundle(rootDirectory: string): Plugin {
1918
const distributionDirectory = path.resolve(rootDirectory, 'dist');
@@ -23,11 +22,13 @@ export function combineCssBundle(rootDirectory: string): Plugin {
2322
name: 'wb-ui:combine-css-bundle',
2423
apply: 'build',
2524
closeBundle() {
25+
assetsDirectoryOf(distributionDirectory);
2626
const fontStyles = emitFontAssets(distributionDirectory);
2727
const layerOrder = readLayerOrder(stylesDirectory);
2828
fs.writeFileSync(path.resolve(distributionDirectory, 'fonts.css'), `${layerOrder}\n${fontStyles}\n`);
2929
writeCombinedStylesheet(distributionDirectory, stylesDirectory, fontStyles);
3030
writeGlobalStylesheet(distributionDirectory, stylesDirectory, fontStyles);
31+
appendFontStylesToEntryChunk(distributionDirectory, fontStyles);
3132
prependLayerOrderToAssets(distributionDirectory, stylesDirectory);
3233
},
3334
};
@@ -94,7 +95,7 @@ const FONT_FACES: FontFaceDefinition[] = [
9495

9596
const require = createRequire(import.meta.url);
9697

97-
export function emitFontAssets(distributionDirectory: string): string {
98+
function emitFontAssets(distributionDirectory: string): string {
9899
const assetsDirectory = path.resolve(distributionDirectory, 'assets');
99100
fs.mkdirSync(assetsDirectory, { recursive: true });
100101

@@ -189,8 +190,9 @@ function writeCombinedStylesheet(distributionDirectory: string, stylesDirectory:
189190
.map((file) => fs.readFileSync(path.resolve(assetsDirectory, file), 'utf8'))
190191
.join('\n');
191192

192-
const combined = `${readLayerOrder(stylesDirectory)}\n${styles}\n${fontStyles}`;
193-
fs.writeFileSync(path.resolve(distributionDirectory, 'index.css'), combined);
193+
const combined = `${readLayerOrder(stylesDirectory)}\n${styles}`;
194+
fs.writeFileSync(path.resolve(distributionDirectory, 'index.css'), `${combined}\n${fontStyles}`);
195+
fs.writeFileSync(path.resolve(distributionDirectory, 'docs-preview.css'), combined);
194196
}
195197

196198
function writeGlobalStylesheet(distributionDirectory: string, stylesDirectory: string, fontStyles: string) {
@@ -201,6 +203,19 @@ function writeGlobalStylesheet(distributionDirectory: string, stylesDirectory: s
201203
fs.writeFileSync(path.resolve(distributionDirectory, 'styles.css'), `${globals}\n${fontStyles}`);
202204
}
203205

206+
function appendFontStylesToEntryChunk(distributionDirectory: string, fontStyles: string) {
207+
const stylesheetPath = path.resolve(assetsDirectoryOf(distributionDirectory), 'index.css');
208+
if (!fs.existsSync(stylesheetPath)) {
209+
throw new Error(
210+
`wb-ui:combine-css-bundle: ${stylesheetPath} is missing - ` +
211+
'the root JS barrel has no entry-chunk stylesheet to carry the font faces',
212+
);
213+
}
214+
215+
const entryChunkFontStyles = fontStyles.replaceAll('url(./assets/', 'url(./');
216+
fs.appendFileSync(stylesheetPath, `\n${entryChunkFontStyles}`);
217+
}
218+
204219
function prependLayerOrderToAssets(distributionDirectory: string, stylesDirectory: string) {
205220
const assetsDirectory = assetsDirectoryOf(distributionDirectory);
206221
const layerOrder = readLayerOrder(stylesDirectory);

0 commit comments

Comments
 (0)