Skip to content
Merged
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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/theme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### New Features

- Added PostCSS, esbuild, and Vite build plugins that inject fallback values for design system tokens (`--wpds-*`). Available as package exports: `@wordpress/theme/postcss-plugins/postcss-ds-token-fallbacks`, `@wordpress/theme/esbuild-plugins/esbuild-ds-token-fallbacks`, `@wordpress/theme/vite-plugins/vite-ds-token-fallbacks` ([#75589](https://github.com/WordPress/gutenberg/pull/75589)).
- Add `--wpds-cursor-control` design token for interactive non-link elements ([#75697](https://github.com/WordPress/gutenberg/pull/75697)).

## 0.7.0 (2026-02-18)
Expand Down
15 changes: 15 additions & 0 deletions packages/theme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@
"import": "./build-module/prebuilt/js/design-tokens.mjs",
"default": "./build/prebuilt/js/design-tokens.cjs"
},
"./postcss-plugins/postcss-ds-token-fallbacks": {
"types": "./src/postcss-plugins/postcss-ds-token-fallbacks.d.mts",
"import": "./src/postcss-plugins/postcss-ds-token-fallbacks.mjs",
"default": "./src/postcss-plugins/postcss-ds-token-fallbacks.mjs"
},
"./esbuild-plugins/esbuild-ds-token-fallbacks": {
"types": "./src/esbuild-plugins/esbuild-ds-token-fallbacks.d.mts",
"import": "./src/esbuild-plugins/esbuild-ds-token-fallbacks.mjs",
"default": "./src/esbuild-plugins/esbuild-ds-token-fallbacks.mjs"
},
"./vite-plugins/vite-ds-token-fallbacks": {
"types": "./src/vite-plugins/vite-ds-token-fallbacks.d.mts",
"import": "./src/vite-plugins/vite-ds-token-fallbacks.mjs",
"default": "./src/vite-plugins/vite-ds-token-fallbacks.mjs"
},
Comment on lines +46 to +60
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are official exports so anyone could theoretically integrate them into their build tooling. If necessary, we can even export the data source of the fallbacks so people can make their own injection plugins.

"./stylelint-plugins/no-unknown-ds-tokens": {
"types": "./build-types/stylelint-plugins/no-unknown-ds-tokens.d.ts",
"import": "./src/stylelint-plugins/no-unknown-ds-tokens.mjs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare const plugin: import('esbuild').Plugin;
export default plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { readFile } from 'fs/promises';
import { addFallbackToVar } from '../postcss-plugins/ds-token-fallbacks.mjs';
Copy link
Copy Markdown
Contributor

@ciampo ciampo Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking nit: While addFallbackToVar is well tested, the plugin wrappers have no tests at all. Should we add some tests to make that they work as expected? (ie. ESbuild plugin reading files / selecting a loader / filtering paths, PostCSS plugin walking declarations..)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugins themselves are pretty thin so I'd say there's not much meaningful tests we can add at this level that's worth the trouble. I'd lean towards adding targeted tests once we encounter subtle issues we didn't foresee, or if it ends up we edit these plugin files somewhat frequently (hopefully not!).


const LOADER_MAP = {
'.js': 'jsx',
'.jsx': 'jsx',
'.ts': 'tsx',
'.tsx': 'tsx',
'.mjs': 'jsx',
'.mts': 'tsx',
'.cjs': 'jsx',
'.cts': 'tsx',
};

/**
* esbuild plugin that injects design-system token fallbacks into JS/TS files.
*
* Replaces bare `var(--wpds-*)` references in string literals with
* `var(--wpds-*, <fallback>)` so components render correctly without
* a ThemeProvider.
*/
const plugin = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was something caught while reviewing this PR with the aid of an AI agent:

esbuild onLoad may conflict with Emotion Babel plugin

The esbuild plugin is first in the plugins array (build.mjs:1274). When it matches a file (reads it, finds --wpds-, returns transformed contents), esbuild treats that onLoad as resolved — subsequent onLoad hooks from other plugins won't run for that file.

The emotionPlugin (a Babel plugin) also registers an onLoad hook. If any file uses both --wpds- tokens and Emotion's css prop, the token fallback plugin would "win" and the Emotion transform would be skipped.

// packages/wp-build/lib/build.mjs:1274-1280
const plugins = [
    dsTokenFallbacksJs,            // ← wins onLoad for matching files
    needsEmotionPlugin && emotionPlugin, // ← skipped for those files
    wasmInlinePlugin,
    externalizeAllExceptCssPlugin,
    ...createStyleBundlingPlugins( packageDir ),
].filter( Boolean );

Suggestion: Verify whether any files in the codebase use both --wpds- tokens and Emotion CSS-in-JS. If so, consider chaining these differently — e.g., applying the token fallback as a text transform before returning to the default loader, or composing it with the Emotion plugin.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. After considering the technical cost of adding support for token replacement in Emotion files (which is not impossible), my current assessment is that it would be better for us to migrate off of Emotion rather than adding more tooling to continue supporting it.

Maybe we can add a lint to prevent design token usage in Emotion files. In any case, wanting to use design tokens will hopefully be another good motivation for us to migrate off.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added code comment in 5206004

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a lint to prevent design token usage in Emotion files

Added in #75872. This should also address your point about token usage not being checked in Emotion files 😄

name: 'ds-token-fallbacks-js',
setup( build ) {
build.onLoad( { filter: /\.[mc]?[jt]sx?$/ }, async ( args ) => {
// Skip node_modules.
if ( args.path.includes( 'node_modules' ) ) {
return undefined;
}
Comment on lines +27 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance nit: can we move the node_modules check into the filter regex (maybe with a negative lookahead), or add a namespace option (via esbuild's onResolve to tag matching files with a custom namespace) to avoid entering the hook at all for those paths?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into it. esbuild's filter uses Go's regex engine, which doesn't support lookaheads. The onResolve + custom namespace approach would work around that, but it adds complexity and could interfere with other plugins' resolution in a similar way to the Emotion conflict above.

Given the measured overhead is negligible, seems like the in-callback check is the most pragmatic option here.


const source = await readFile( args.path, 'utf8' );

if ( ! source.includes( '--wpds-' ) ) {
return undefined;
}

const ext = args.path.match( /(\.[^.]+)$/ )?.[ 1 ] || '.js';

return {
contents: addFallbackToVar( source ),
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the both the esbuild and vite plugins, we're replacing all instances of the token strings, including in code comments. This should be safe and still understandable. Ideally we skip code comments, but it would increase the complexity of this plugin in a way that's probably not worth the trouble.

loader: LOADER_MAP[ ext ] || 'jsx',
};
} );
},
};

export default plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare const plugin: import('postcss').PluginCreator< never >;
export default plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { addFallbackToVar } from './ds-token-fallbacks.mjs';

const plugin = () => ( {
postcssPlugin: 'postcss-ds-token-fallbacks',
/** @param {import('postcss').Declaration} decl */
Declaration( decl ) {
const updated = addFallbackToVar( decl.value );
if ( updated !== decl.value ) {
decl.value = updated;
}
},
} );

plugin.postcss = true;

export default plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare const plugin: () => import('vite').Plugin;
export default plugin;
28 changes: 28 additions & 0 deletions packages/theme/src/vite-plugins/vite-ds-token-fallbacks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { addFallbackToVar } from '../postcss-plugins/ds-token-fallbacks.mjs';

/**
* Vite plugin that injects design-system token fallbacks into JS/TS files.
*
* Replaces bare `var(--wpds-*)` references in string literals with
* `var(--wpds-*, <fallback>)` so components render correctly without
* a ThemeProvider.
*/
const plugin = () => ( {
name: 'ds-token-fallbacks-js',
transform( code, id ) {
if ( ! /\.[mc]?[jt]sx?$/.test( id ) ) {
return null;
}
if ( id.includes( 'node_modules' ) ) {
return null;
}
if ( ! code.includes( '--wpds-' ) ) {
return null;
}
// Sourcemap omitted: replacements are small, inline substitutions
// that preserve line structure, so the debugging impact is negligible.
return { code: addFallbackToVar( code ), map: null };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking nit: Returning map: null tells Vite there's no source map for this transformation, which may break debugging in dev mode for files containing --wpds-

This is an acceptable trade-off given the simplicity, but a brief comment explaining the choice would help future maintainers.

Alternatively, Opus suggests using `magic-string` and importing `ds-token-fallback.mjs` to generate the sourcemap
import MagicString from 'magic-string';
import { tokenFallbackRegex, getFallback } from '../postcss-plugins/ds-token-fallbacks.mjs';

const plugin = () => ( {
	name: 'ds-token-fallbacks-js',
	transform( code, id ) {
		if ( ! /\.[mc]?[jt]sx?$/.test( id ) ) {
			return null;
		}
		if ( id.includes( 'node_modules' ) ) {
			return null;
		}
		if ( ! code.includes( '--wpds-' ) ) {
			return null;
		}

		const s = new MagicString( code );
		const regex = /var\(\s*(--wpds-[\w-]+)\s*\)/g;
		let match;

		while ( ( match = regex.exec( code ) ) !== null ) {
			const tokenName = match[ 1 ];
			const fallback = tokenFallbacks[ tokenName ];
			if ( fallback !== undefined ) {
				s.overwrite(
					match.index,
					match.index + match[ 0 ].length,
					`var(${ tokenName }, ${ fallback })`
				);
			}
		}

		if ( ! s.hasChanged() ) {
			return null;
		}

		return {
			code: s.toString(),
			map: s.generateMap( { hires: true } ),
		};
	},
} );

Copy link
Copy Markdown
Member Author

@mirka mirka Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comment in 5206004

Also I think the maps wouldn't degrade in a completely unuseful way, because the line numbers will stay the same (or at least adjacent).

},
} );

export default plugin;
15 changes: 14 additions & 1 deletion packages/ui/src/stack/stack.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { useRender, mergeProps } from '@base-ui/react';
import { forwardRef } from '@wordpress/element';
import type { GapSize } from '@wordpress/theme';
import { type StackProps } from './types';
import styles from './style.module.css';

// Static map so that the build-time token fallback plugin can inject fallback
// values into each `var()` call.
const gapTokens: Record< GapSize, string > = {
xs: 'var(--wpds-dimension-gap-xs)',
sm: 'var(--wpds-dimension-gap-sm)',
md: 'var(--wpds-dimension-gap-md)',
lg: 'var(--wpds-dimension-gap-lg)',
xl: 'var(--wpds-dimension-gap-xl)',
'2xl': 'var(--wpds-dimension-gap-2xl)',
'3xl': 'var(--wpds-dimension-gap-3xl)',
};

/**
* A flexible layout component using CSS Flexbox for consistent spacing and alignment.
* Built on design tokens for predictable spacing values.
Expand All @@ -12,7 +25,7 @@ export const Stack = forwardRef< HTMLDivElement, StackProps >( function Stack(
ref
) {
const style: React.CSSProperties = {
gap: gap && `var(--wpds-dimension-gap-${ gap })`,
gap: gap && gapTokens[ gap ],
alignItems: align,
justifyContent: justify,
flexDirection: direction,
Expand Down
39 changes: 33 additions & 6 deletions packages/wp-build/lib/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ import babel from 'esbuild-plugin-babel';
import { camelCase } from 'change-case';
import { NodePackageImporter } from 'sass-embedded';

// Optional dependency: @wordpress/theme provides plugins that inject fallback
// values for design system tokens. Fails gracefully when the package is not
// installed (it is an optional peerDependency).
let dsTokenFallbacks;
let dsTokenFallbacksJs;
try {
const { default: postcssPlugin } = await import(
// eslint-disable-next-line import/no-unresolved
'@wordpress/theme/postcss-plugins/postcss-ds-token-fallbacks'
);
const { default: esbuildPlugin } = await import(
// eslint-disable-next-line import/no-unresolved
'@wordpress/theme/esbuild-plugins/esbuild-ds-token-fallbacks'
);
dsTokenFallbacks = postcssPlugin;
dsTokenFallbacksJs = esbuildPlugin;
} catch {
// @wordpress/theme is optional; skip token fallbacks if not available.
}

/**
* Internal dependencies
*/
Expand Down Expand Up @@ -150,8 +170,9 @@ function compileInlineStyle( { cssModules = false, minify = true } = {} ) {

let moduleExports = null;

// Transform the code: CSS modules and minification.
// Transform the code: token fallbacks, CSS modules and minification.
const plugins = [
dsTokenFallbacks,
cssModules &&
postcssModules( {
generateScopedName: '[contenthash]__[local]',
Expand Down Expand Up @@ -1251,6 +1272,11 @@ async function transpilePackage( packageName ) {
},
};
const plugins = [
// Note: dsTokenFallbacksJs and emotionPlugin both use esbuild's onLoad
// hook, which is non-composable — the first to return contents wins. If a
// file contains --wpds-* tokens, the Emotion transform will be skipped.
// Avoid using design tokens in Emotion styles until Emotion is removed.
dsTokenFallbacksJs,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a plugin that is needed just for Gutenberg or for third-party plugins too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third-party plugins would benefit from it too if they want to start using tokens in plugins that still need to support versions of WordPress that don't have @wordpress/theme.

needsEmotionPlugin && emotionPlugin,
wasmInlinePlugin,
externalizeAllExceptCssPlugin,
Expand Down Expand Up @@ -1408,12 +1434,13 @@ async function compileStyles( packageName ) {
embedded: true,
...getSassOptions( packageDir ),
async transform( source ) {
// Process with autoprefixer for LTR version
const ltrResult = await postcss( [
autoprefixer( { grid: true } ),
] ).process( source, { from: undefined } );
const ltrResult = await postcss(
[
dsTokenFallbacks,
autoprefixer( { grid: true } ),
].filter( Boolean )
).process( source, { from: undefined } );

// Process with rtlcss for RTL version
const rtlResult = await postcss( [
rtlcss(),
] ).process( ltrResult.css, { from: undefined } );
Expand Down
10 changes: 10 additions & 0 deletions storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
} from 'vite';
import react from '@vitejs/plugin-react';
import type { StorybookConfig } from '@storybook/react-vite';
import dsTokenFallbacks from '@wordpress/theme/postcss-plugins/postcss-ds-token-fallbacks';
import dsTokenFallbacksJs from '@wordpress/theme/vite-plugins/vite-ds-token-fallbacks';

const { NODE_ENV = 'development' } = process.env;

Expand Down Expand Up @@ -77,6 +79,7 @@ const config: StorybookConfig = {
viteFinal: async ( viteConfig ) => {
return mergeConfig( viteConfig, {
plugins: [
dsTokenFallbacksJs(),
react( {
jsxImportSource: '@emotion/react',
babel: {
Expand Down Expand Up @@ -174,6 +177,13 @@ const config: StorybookConfig = {
NODE_ENV === 'development'
),
},
css: {
postcss: {
// Vite bundles its own PostCSS, creating a deep
// type incompatibility with the top-level PostCSS.
plugins: [ dsTokenFallbacks as any ],
},
},
optimizeDeps: {
esbuildOptions: {
loader: {
Expand Down
1 change: 1 addition & 0 deletions storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@storybook/addon-docs": "^10.1.11",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.1.11",
"@wordpress/theme": "file:../packages/theme",
"storybook": "^10.1.11",
"storybook-addon-tag-badges": "^3.0.4",
"terser": "^5.37.0"
Expand Down
Loading