Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/font-assets.md
Original file line number Diff line number Diff line change
@@ -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:`.
31 changes: 20 additions & 11 deletions apps/docs/src/content/docs/ui-library/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<component>`** 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).
Expand All @@ -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';
```

Expand Down
27 changes: 26 additions & 1 deletion packages/sdk/vite.config.mts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/// <reference types="vitest/config" />
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';

Expand Down Expand Up @@ -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
Expand Down
133 changes: 126 additions & 7 deletions packages/ui/combine-css-bundle.mts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
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
Expand All @@ -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<string, string>();
const unicodeRanges = new Map<string, Record<string, string>>();
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<string, string>;
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();
}
Expand Down Expand Up @@ -62,24 +181,24 @@ 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.
const styles = cssFilesIn(assetsDirectory)
.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) {
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
46 changes: 43 additions & 3 deletions packages/ui/scripts/check-built-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand Down
1 change: 0 additions & 1 deletion packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import './styles/fonts.css';
import './styles/globals.css';
import './styles/layers.css';
import './styles/typography.css';
Expand Down
17 changes: 0 additions & 17 deletions packages/ui/src/styles/fonts.css

This file was deleted.

Loading