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);