Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ gutenberg.zip
.content-entry.js
coverage
/.vitest-attachments/
/packages/theme/figma/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Am I understanding correctly that @mattmiklic is going to test these artifacts from this branch before merging?

In that case, what's the ultimate home for these files? I feel like based on current conventions, it would be committed under packages/theme/prebuilt/figma/ or something.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, that's what I'm thinking too

/test-results/
**/__screenshots__/*.browser.test.*/
.phpunit.result.cache
Expand Down
1 change: 1 addition & 0 deletions packages/theme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

### Internal

- Generate complete border radius and width token samples for Figma mode imports. ([#84035](https://github.com/WordPress/gutenberg/pull/84035))
- Update the Vite development dependency to 8.3.2 ([#83992](https://github.com/WordPress/gutenberg/pull/83992)).
- Update the Vite development dependency to 8.3.1. ([#83569](https://github.com/WordPress/gutenberg/pull/83569))
- Restore color-scale Storybook accessibility checks with labeled color samples, readable seed labels, and keyboard scrolling ([#83358](https://github.com/WordPress/gutenberg/pull/83358)).
Expand Down
60 changes: 60 additions & 0 deletions packages/theme/bin/build-figma-tokens/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Resolver } from '@terrazzo/parser';

/**
* Generate complete DTCG files for independent Figma border collections.
*
* @param resolver The parsed design token resolver.
* @return One import file per collection mode.
*/
export function generateFigmaFiles( resolver: Resolver ) {
const files: { filename: string; contents: string }[] = [];

for ( const [ group, modifier ] of [
[ 'radius', 'corner-radius' ],
[ 'width', 'pixel-density' ],
] ) {
const contexts = resolver.source.modifiers?.[ modifier ]?.contexts;
if ( ! contexts ) {
throw new Error(
`Missing Figma collection modifier: ${ modifier }`
);
}

for ( const mode of Object.keys( contexts ) ) {
const tokens = resolver.apply(
{ [ modifier ]: mode },
{ modifiers: [ modifier ], resolveAliases: false }
);
const contents: Record< string, unknown > = {};

for ( const [ id, token ] of Object.entries( tokens ) ) {
if ( ! id.startsWith( `wpds-border.${ group }.` ) ) {
continue;
}

// Keep the original hierarchy so Figma variable names still match.
const path = id.split( '.' );
const name = path.pop()!;
let parent = contents;
for ( const segment of path ) {
parent[ segment ] ??= {};
parent = parent[ segment ] as Record< string, unknown >;
}
parent[ name ] = {
$type: token.$type,
$value: token.$value,
$description: token.$description,
$extensions: token.$extensions,
$deprecated: token.$deprecated,
};
}

files.push( {
filename: `${ group }/${ mode }.json`,
contents: JSON.stringify( contents, null, '\t' ) + '\n',
} );
}
}

return files;
}
23 changes: 23 additions & 0 deletions packages/theme/bin/build-figma-tokens/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from '@terrazzo/parser';
import config from '../../terrazzo.config.ts';
import { generateFigmaFiles } from './generate.ts';

const sources = await Promise.all(
config.tokens.map( async ( filename: URL ) => ( {
filename,
src: await readFile( filename, 'utf8' ),
} ) )
);
const { resolver } = await parse( sources, { config } );

for ( const file of generateFigmaFiles( resolver ) ) {
const filename = new URL(
`../../figma/${ file.filename }`,
import.meta.url
);
await mkdir( dirname( fileURLToPath( filename ) ), { recursive: true } );
await writeFile( filename, file.contents );
}
161 changes: 161 additions & 0 deletions packages/theme/bin/build-figma-tokens/test/generate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { readFile } from 'node:fs/promises';
import { beforeAll, describe, expect, it } from 'vitest';
import { defineConfig, parse } from '@terrazzo/parser';
import config from '../../../terrazzo.config.ts';
import { generateFigmaFiles } from '../generate.ts';

describe( 'Figma border collection files', () => {
let files: ReturnType< typeof generateFigmaFiles >;
let border: Record<
string,
Record<
string,
{
$description?: string;
$extensions?: Record< string, unknown >;
}
>
>;

beforeAll( async () => {
const sources = await Promise.all(
config.tokens.map( async ( filename: URL ) => ( {
filename,
src: await readFile( filename, 'utf8' ),
} ) )
);
const { resolver } = await parse( sources, { config } );
files = generateFigmaFiles( resolver );
border = JSON.parse(
await readFile(
new URL( '../../../tokens/border.json', import.meta.url ),
'utf8'
)
)[ 'wpds-border' ];
} );

it( 'creates complete mode files with the original names, types, descriptions, and scopes', () => {
expect( files.map( ( file ) => file.filename ) ).toEqual( [
'radius/none.json',
'radius/subtle.json',
'radius/moderate.json',
'radius/pronounced.json',
'width/standard.json',
'width/high-dpi.json',
] );

for ( const file of files ) {
const group = file.filename.split( '/' )[ 0 ];
const document = JSON.parse( file.contents );
expect( Object.keys( document ) ).toEqual( [ 'wpds-border' ] );
expect( Object.keys( document[ 'wpds-border' ] ) ).toEqual( [
group,
] );
const tokens = document[ 'wpds-border' ][ group ];
const names = Object.keys( border[ group ] ).filter(
( name ) => ! name.startsWith( '$' )
);
expect( Object.keys( tokens ) ).toEqual( names );
for ( const name of names ) {
expect( tokens[ name ] ).toEqual( {
$type: 'dimension',
$value: { value: expect.any( Number ), unit: 'px' },
$description: border[ group ][ name ].$description,
$extensions: border[ group ][ name ].$extensions,
} );
}
}
} );

it( 'keeps radius and width modes independent', () => {
const values = Object.fromEntries(
files.map( ( file ) => {
const group = file.filename.split( '/' )[ 0 ];
const tokens: Record< string, { $value: { value: number } } > =
JSON.parse( file.contents )[ 'wpds-border' ][ group ];
return [
file.filename,
Object.values( tokens ).map(
( token ) => token.$value.value
),
];
} )
);
expect( values ).toEqual( {
'radius/none.json': [ 0, 0, 0, 0, 0 ],
'radius/subtle.json': [ 1, 2, 4, 8, 12 ],
'radius/moderate.json': [ 6, 8, 12, 16, 20 ],
'radius/pronounced.json': [ 18, 20, 22, 24, 26 ],
'width/standard.json': [ 1, 2, 4, 8, 2 ],
'width/high-dpi.json': [ 1, 2, 4, 8, 1.5 ],
} );
} );

it( 'preserves token aliases within a collection', async () => {
const { resolver } = await parse(
[
{
filename: new URL(
'aliases.resolver.json',
import.meta.url
),
src: {
version: '2025.10',
sets: {
base: {
sources: [
{
'wpds-border': {
$type: 'dimension',
radius: {
sm: {
$value: {
value: 2,
unit: 'px',
},
},
control: {
$value: '{wpds-border.radius.sm}',
},
},
},
},
],
},
},
modifiers: {
'corner-radius': {
default: 'subtle',
contexts: { subtle: [] },
},
'pixel-density': {
default: 'standard',
contexts: { standard: [] },
},
},
resolutionOrder: [
{ $ref: '#/sets/base' },
{ $ref: '#/modifiers/corner-radius' },
{ $ref: '#/modifiers/pixel-density' },
],
},
},
],
{
config: defineConfig(
{},
{ cwd: new URL( './', import.meta.url ) }
),
}
);
const file = generateFigmaFiles( resolver ).find(
( candidate ) => candidate.filename === 'radius/subtle.json'
)!;
expect(
JSON.parse( file.contents )[ 'wpds-border' ].radius.control
).toEqual( {
$type: 'dimension',
$value: '{wpds-border.radius.sm}',
} );
} );
} );
1 change: 1 addition & 0 deletions packages/theme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@
"scripts": {
"build:default-ramps": "node bin/generate-default-ramps/index.ts",
"build:tokens": "node bin/generate-primitive-tokens/index.ts && node bin/build-design-tokens/index.ts",
"build:figma": "node bin/build-figma-tokens/index.ts",
"build": "npm run build:default-ramps && npm run build:tokens",
"lint:package-contents": "wp-validate-package-contents . --disallow-path src",
"postbuild": "prettier --write tokens/color.json tokens/modes prebuilt src/prebuilt src/color-ramps/lib/default-ramps.ts docs ../base-styles/internal/_wpds-token-fallbacks.scss"
Expand Down
48 changes: 45 additions & 3 deletions packages/theme/tokens/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,50 @@ The design tokens use [the `$extensions` feature](https://www.designtokens.org/t

### Figma Support

The tokens are implemented so that they can be imported directly into Figma variables, using [Figma's built-in support for importing design tokens](https://help.figma.com/hc/en-us/articles/15343816063383-Modes-for-variables#h_01KAGYPSFC984XDB4YWBCNRZJ7).
Figma can import supported DTCG token types as variables through its [built-in design token importer](https://help.figma.com/hc/en-us/articles/15343816063383-Modes-for-variables#h_01KAGYPSFC984XDB4YWBCNRZJ7). The resolver itself is not an import file. The files in `modes/` are partial resolver inputs, so they do not contain every variable needed to create a collection.

This also includes support for [variable modes](https://help.figma.com/hc/en-us/articles/15343816063383-Modes-for-variables). The contextual token values are stored under [the `modes/` directory](https://github.com/WordPress/gutenberg/blob/trunk/packages/theme/tokens/modes/) and composed by `wpds.resolver.json`.
#### Generate border collection samples

Token definitions will also include relevant Figma scopes, which are useful to ensure that token values are only shown in relevant fields in the Figma interface (e.g. border radius tokens only shown in the radius selection fields). These are implemented through the `$extensions['com.figma.scopes']` extension, and a full list of supported scopes is available in [Figma's `VariableScope` developer documentation](https://developers.figma.com/docs/plugins/api/VariableScope/).
From the repository root, run:

```sh
npm run --workspace @wordpress/theme build:figma
```

This command applies `wpds.resolver.json` to generate six complete DTCG files under `packages/theme/figma/`. Radius and width use separate collections so designers can choose corner radius and pixel density independently.

| Collection | Files relative to `packages/theme/figma/` | Default mode | Variables |
| ------------- | ------------------------------------------------------------------------------------------ | ------------ | --------------------------------------- |
| Border radius | `radius/none.json`, `radius/subtle.json`, `radius/moderate.json`, `radius/pronounced.json` | `subtle` | `wpds-border/radius/{xs,sm,md,lg,xl}` |
| Border width | `width/standard.json`, `width/high-dpi.json` | `standard` | `wpds-border/width/{xs,sm,md,lg,focus}` |

Each file includes every token in its collection, including unchanged values, descriptions, types, and Figma scopes. The original hierarchy preserves variable names. Token aliases remain references instead of becoming literal values. The current border tokens contain no aliases; aliases within a collection have automated coverage, but Figma alias behavior still needs testing.

These files are local, ignored samples, generated on demand rather than during the package build. Do not edit them as sources. `wpds.resolver.json`, its base sources, and the tracked files in `modes/` remain canonical. Keep the tracked mode files until the Figma tests below establish a compatible replacement and the artifact policy is agreed in [#82561](https://github.com/WordPress/gutenberg/issues/82561).

#### Test in a Figma library branch

This is a proposed workflow awaiting Figma validation. Record the results in #82561, including the library branch, tested revision, collection and mode names, and any importer differences.

Expected pixel values:

| Mode | `xs` | `sm` | `md` | `lg` | `xl` or `focus` |
| ------------------- | ---- | ---- | ---- | ---- | --------------- |
| Radius `none` | 0 | 0 | 0 | 0 | 0 |
| Radius `subtle` | 1 | 2 | 4 | 8 | 12 |
| Radius `moderate` | 6 | 8 | 12 | 16 | 20 |
| Radius `pronounced` | 18 | 20 | 22 | 24 | 26 |
| Width `standard` | 1 | 2 | 4 | 8 | 2 |
| Width `high-dpi` | 1 | 2 | 4 | 8 | 1.5 |

1. Create a branch of the theme library. In the Variables view, create the Border radius collection and drag in all four `radius/` files together. Repeat with the two `width/` files in Border width. Check mode names against the filenames, rename them if needed, and set `subtle` and `standard` as the defaults.
2. Check that each collection has five Number variables with the names above. Radius variables must have the `CORNER_RADIUS` scope and width variables the `STROKE_FLOAT` scope. Check descriptions and the pixel values in the table.
3. Bind a sample component's corner radius and stroke width to the new variables. Switch both collections' modes independently on an object or page. High-DPI is an explicit preview choice in Figma; the generated CSS selects it automatically through a resolution media query.
4. Change one existing value in a local source, regenerate the samples, and right-click the corresponding Figma mode to select **Import mode**. Check that the matching variable updates and retains its bindings. Restore the source and import the original value again.
5. Add a temporary token to a local border source and regenerate. Confirm that importing into an existing mode does not create the new variable. Use the Figma API/plugin workflow to create it with the matching name, Number type, description, scope, and values for every mode, then verify later imports update it. Remove the temporary token after testing. This importer limitation was [reported by the theme library maintainer](https://github.com/WordPress/gutenberg/issues/82561#issuecomment-5799294192).
6. Add a temporary alias to another token within the same collection, regenerate, and test both creating a collection and updating a mode. Confirm that Figma preserves the alias relationship, then remove the temporary alias. Cross-collection aliases are outside these samples; they need Figma's `com.figma.aliasData` extension and a separate validation.
7. Inspect components bound to the existing combined Border collection. Record their variable IDs and bindings before testing the split. Verify a binding migration in the library branch, including component instances and existing mode selections. Matching names alone do not establish that bindings survive. Keep the existing collection until this migration is verified.

Figma creates new variables only for supported tokens present with the same type in every imported file. Importing an existing mode updates variables with matching names and types. See [Figma's import documentation](https://help.figma.com/hc/en-us/articles/15343816063383-Modes-for-variables#h_01KAGYPSFC984XDB4YWBCNRZJ7) for those constraints and [the separate typography issue](https://github.com/WordPress/gutenberg/issues/74620) for unsupported typography types. The generated samples cover only border radius and width.

Token definitions include Figma scopes to show variables in the relevant fields, such as corner radius. These use the `$extensions['com.figma.scopes']` extension. See [Figma's `VariableScope` developer documentation](https://developers.figma.com/docs/plugins/api/VariableScope/) for the supported scopes.
Loading