From 46c678e18fe6899bc635789ec17892291e419d26 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 4 Oct 2026 10:50:46 +0000 Subject: [PATCH] feat: model variables as CSS design tokens (#852) * feat(scene-graph): model variables as CSS tokens A variable now has a CSS custom property name, a unit, raw CSS expressions per mode, and each mode a CSS condition (a selector or @media prelude), so code export can treat variables as design tokens rather than resolved literals. Names are derived when not set: Tailwind v4 theme namespaces from the type, the scopes or the leading name segment, so Gray/50 is --color-gray-50. The first token to claim an explicit name keeps it; Figma files contain duplicates, and later claimants fall back to a derived name. FLOAT tokens infer px except for opacity and font weights. Lengths stay in canvas pixels and only convert when written, so rem does not change what the canvas or Figma sees. In .fig, the name goes to codeSyntax.WEB in the form the snippet already uses, or to plugin data when WEB holds something else such as a Tailwind class. Unit, expressions and conditions are OpenPencil plugin data, validated with Valibot. Conditions and expressions reject braces and semicolons because they are written into stylesheets, and an expression whose mode value was edited elsewhere is dropped so the number stays authoritative. * refactor: move token naming to dom-css and keep fig to persistence CSS naming, namespaces and units are CSS projection, which dom-css owns; scene-graph keeps only the token data and the px/rem storage conversion, and fig only persists plugin data, validated for shape. Drop Variable.cssName: codeSyntax.WEB is the single place a token's name lives, read with postcss-value-parser when it is --x or var(--x), so no second copy has to stay in sync with Figma's field. Derived names use es-toolkit kebabCase and twirlwind's Tailwind namespace table, which excludes opacity since Tailwind v4 has no such namespace. Whether a condition or expression is valid CSS is no longer guessed with a regex in fig; the stylesheet generator will check it with cssom where the string enters a stylesheet. * refactor(fig): parse token plugin data with Valibot's parseJson Invalid JSON becomes a validation issue like any wrong shape instead of a caught exception, and the plugin data lookup reuses getOpenPencilPluginValue rather than repeating it. * refactor: use es-toolkit for token expression keys and name segments mapKeys re-keys expressions by file mode id instead of a manual loop, and compact drops empty name segments. Reading expressions keeps the plain filter: pickBy returns Partial, which would need a cast. * fix: keep token expressions on float32 values and rem precision .fig stores numbers as float32 while plugin data keeps the resolved value as a double, so a value such as 1234.567 differed by more than the 1e-6 tolerance and its expression was dropped as stale on reopen. Compare both at float32 precision. Token numbers were written with four decimals, which turned 0.5px into 0.0313rem; six keep every pixel step down to 1/1024px exact. Also note that derived names can collide, so stylesheets take them from variableCSSNames. --- bun.lock | 1 + .../src/io/formats/fig/variable-export.ts | 12 +- .../formats/fig/roundtrip/variables.test.ts | 84 ++++++++++++- packages/dom-css/AGENTS.md | 1 + packages/dom-css/src/export/index.ts | 1 + packages/dom-css/src/tokens/index.ts | 10 ++ packages/dom-css/src/tokens/names.ts | 116 ++++++++++++++++++ packages/dom-css/src/tokens/values.ts | 34 +++++ packages/dom-css/tests/tokens/names.test.ts | 91 ++++++++++++++ packages/fig/package.json | 3 +- packages/fig/src/document/variables.ts | 17 ++- packages/fig/src/node-change/index.ts | 1 + .../fig/src/node-change/variable/metadata.ts | 28 ++++- .../fig/src/node-change/variable/token.ts | 108 ++++++++++++++++ .../tests/node-change/variable-token.test.ts | 58 +++++++++ packages/scene-graph/src/index.ts | 1 + packages/scene-graph/src/types.ts | 27 +++- packages/scene-graph/src/variables/token.ts | 14 +++ .../scene-graph/tests/variable/token.test.ts | 15 +++ 19 files changed, 607 insertions(+), 15 deletions(-) create mode 100644 packages/dom-css/src/tokens/index.ts create mode 100644 packages/dom-css/src/tokens/names.ts create mode 100644 packages/dom-css/src/tokens/values.ts create mode 100644 packages/dom-css/tests/tokens/names.test.ts create mode 100644 packages/fig/src/node-change/variable/token.ts create mode 100644 packages/fig/tests/node-change/variable-token.test.ts create mode 100644 packages/scene-graph/src/variables/token.ts create mode 100644 packages/scene-graph/tests/variable/token.test.ts diff --git a/bun.lock b/bun.lock index e2d73d711..8ed0cdc5b 100644 --- a/bun.lock +++ b/bun.lock @@ -325,6 +325,7 @@ "es-toolkit": "^1.51.0", "fflate": "^0.8.3", "js-base64": "^3.9.3", + "valibot": "^1.4.2", }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/packages/core/src/io/formats/fig/variable-export.ts b/packages/core/src/io/formats/fig/variable-export.ts index 8361fa279..bc6ff1af4 100644 --- a/packages/core/src/io/formats/fig/variable-export.ts +++ b/packages/core/src/io/formats/fig/variable-export.ts @@ -1,6 +1,9 @@ -import { pluginDataNodeChange, variableMetadataNodeChange } from '@open-pencil/fig/node-change' +import { + collectionPluginDataNodeChange, + variableMetadataNodeChange +} from '@open-pencil/fig/node-change' import type { GUID, NodeChange, VariableDataEntry } from '@open-pencil/kiwi/fig/codec' -import { stringToGuid } from '@open-pencil/kiwi/fig/guid' +import { guidToString, stringToGuid } from '@open-pencil/kiwi/fig/guid' import { modesDefaultFirst, type SceneGraph, @@ -128,6 +131,7 @@ export function appendVariableNodeChanges( modes: Map, nextPosition: () => string = sequentialPositions() ): void { + const modeKey = (modeId: string) => guidToString(modes.get(modeId) ?? stringToGuid(modeId)) for (const collection of graph.variableCollections.values()) { const guid = ids.get(collection.id) ?? stringToGuid(collection.id) changes.push({ @@ -143,7 +147,7 @@ export function appendVariableNodeChanges( name: mode.name, sortPosition: fractionalPosition(i) })), - pluginData: pluginDataNodeChange(collection.pluginData) + pluginData: collectionPluginDataNodeChange(collection, modeKey) }) for (const id of collection.variableIds) { const variable = graph.variables.get(id) @@ -164,7 +168,7 @@ export function appendVariableNodeChanges( variableData: variableValueToKiwi(value, variable.type, ids) })) }, - ...variableMetadataNodeChange(variable), + ...variableMetadataNodeChange(variable, modeKey), key: variable.key, version: variable.version }) diff --git a/packages/core/tests/io/formats/fig/roundtrip/variables.test.ts b/packages/core/tests/io/formats/fig/roundtrip/variables.test.ts index 1323b8524..88c330d44 100644 --- a/packages/core/tests/io/formats/fig/roundtrip/variables.test.ts +++ b/packages/core/tests/io/formats/fig/roundtrip/variables.test.ts @@ -6,7 +6,8 @@ import { initCodec, parseFigFile, SceneGraph, - type Color + type Color, + type Variable } from '@open-pencil/core' import { expectDefined } from '#core-tests/helpers/assert' @@ -263,7 +264,7 @@ describe('variable roundtrip', () => { defaultModeId: '4:3', variableIds: [], pluginData: [ - { pluginId: 'open-pencil', key: 'modes', value: '{"4:3":":root"}' }, + { pluginId: 'open-pencil', key: 'note', value: 'collection' }, { pluginId: 'tokens-studio', key: 'theme', value: 'base' } ] }) @@ -278,7 +279,7 @@ describe('variable roundtrip', () => { scopes: ['CORNER_RADIUS'], codeSyntax: { WEB: '--radius-card', iOS: 'Radius.card' }, pluginData: [ - { pluginId: 'open-pencil', key: 'token', value: '{"unit":"rem"}' }, + { pluginId: 'open-pencil', key: 'note', value: 'variable' }, { pluginId: 'tokens-studio', key: 'path', value: 'radius.card' } ] }) @@ -298,6 +299,83 @@ describe('variable roundtrip', () => { ) }) + test('token units, expressions and mode conditions survive export → re-import', async () => { + await initCodec() + const graph = new SceneGraph() + graph.addCollection({ + id: '4:70', + name: 'Theme', + modes: [ + { modeId: '4:7', name: 'Light' }, + { modeId: '4:8', name: 'Dark', condition: '[data-theme="dark"]' } + ], + defaultModeId: '4:7', + variableIds: [] + }) + const add = (id: string, name: string, extra: Partial) => + graph.addVariable({ + id, + name, + type: 'FLOAT', + collectionId: '4:70', + valuesByMode: { '4:7': 16, '4:8': 16 }, + description: '', + hiddenFromPublishing: false, + ...extra + }) + add('5:10', 'Space/page', { + codeSyntax: { WEB: 'var(--page-gutter)' }, + unit: 'rem', + expressions: { '4:7': { css: 'clamp(1rem, 4vw, 2rem)', resolved: 16 } } + }) + + const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer) + + const page = expectDefined(reimported.variables.get('5:10'), 'page token') + expect(page).toMatchObject({ + unit: 'rem', + codeSyntax: { WEB: 'var(--page-gutter)' }, + expressions: { '4:7': { css: 'clamp(1rem, 4vw, 2rem)', resolved: 16 } } + }) + expect(reimported.variableCollections.get('4:70')?.modes).toEqual([ + { modeId: '4:7', name: 'Light', condition: undefined }, + { modeId: '4:8', name: 'Dark', condition: '[data-theme="dark"]' } + ]) + // Rebuilt on save, never duplicated into pass-through plugin data. + expect(page.pluginData).toBeUndefined() + }) + + test('a token expression on a value .fig rounds to float32 survives export → re-import', async () => { + await initCodec() + const graph = new SceneGraph() + const collection = graph.createCollection('Space') + const gutter = graph.createVariable('Gutter', 'FLOAT', collection.id, 1234.567) + gutter.expressions = { + [collection.defaultModeId]: { css: 'calc(100vw / 3)', resolved: 1234.567 } + } + + const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer) + + const imported = [...reimported.variables.values()].find((v) => v.name === 'Gutter') + expect(imported?.expressions?.[collection.defaultModeId]?.css).toBe('calc(100vw / 3)') + }) + + test('a token expression whose value changed elsewhere is dropped on read', async () => { + await initCodec() + const graph = new SceneGraph() + const collection = graph.createCollection('Space') + const gutter = graph.createVariable('Gutter', 'FLOAT', collection.id, 20) + gutter.expressions = { + [collection.defaultModeId]: { css: 'clamp(1rem, 4vw, 2rem)', resolved: 16 } + } + + const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer) + + const imported = [...reimported.variables.values()].find((v) => v.name === 'Gutter') + expect(imported?.valuesByMode[collection.defaultModeId]).toBe(20) + expect(imported?.expressions).toBeUndefined() + }) + test('a default mode that is not first survives export → re-import', async () => { await initCodec() const graph = new SceneGraph() diff --git a/packages/dom-css/AGENTS.md b/packages/dom-css/AGENTS.md index 8550c9dd1..9659c7747 100644 --- a/packages/dom-css/AGENTS.md +++ b/packages/dom-css/AGENTS.md @@ -5,6 +5,7 @@ DOM, CSS, HTML, JSX, and Tailwind projection between documents and SceneGraph, w - `src/import/` — HTML, CSS, JSX, and Tailwind to SceneGraph, including the `jsx-runtime` entries and CSS value parsing. - `src/export/` — SceneGraph to HTML, Tailwind JSX, and Storybook: projection, CSS formatting, the HTML bundle, and printers. `src/export/index.ts` is the `./export` entry. - `src/runtime/` — browser and headless CSS runtimes. +- `src/tokens/` — variables as design tokens: CSS custom property names, Tailwind namespaces from `twirlwind`, and units. Shared by both directions and exported from `./export`. Rules: diff --git a/packages/dom-css/src/export/index.ts b/packages/dom-css/src/export/index.ts index 5a6613dfb..92f3552f4 100644 --- a/packages/dom-css/src/export/index.ts +++ b/packages/dom-css/src/export/index.ts @@ -8,6 +8,7 @@ export { type TailwindJSXWithLayers } from './tailwind-jsx' export { serializeHTML } from './html' +export * from '../tokens' export type { ExportHTMLBundle, ExportHTMLBundleOptions, diff --git a/packages/dom-css/src/tokens/index.ts b/packages/dom-css/src/tokens/index.ts new file mode 100644 index 000000000..1306c17e3 --- /dev/null +++ b/packages/dom-css/src/tokens/index.ts @@ -0,0 +1,10 @@ +// Design tokens as CSS custom properties: names, namespaces, and units. +export { + cssNameCodeSyntax, + deriveCSSName, + explicitCSSName, + parseCSSName, + variableCSSNames, + variableNamespace +} from './names' +export { tokenNumberToCSS, variableUnit } from './values' diff --git a/packages/dom-css/src/tokens/names.ts b/packages/dom-css/src/tokens/names.ts new file mode 100644 index 000000000..6139b1ae7 --- /dev/null +++ b/packages/dom-css/src/tokens/names.ts @@ -0,0 +1,116 @@ +import { compact } from 'es-toolkit/array' +import { kebabCase } from 'es-toolkit/string' +import valueParser from 'postcss-value-parser' +import { themeNamespaces, type ThemeNamespace } from 'twirlwind' + +import type { Variable, VariableScope } from '@open-pencil/scene-graph' + +const SCOPE_NAMESPACES: Partial> = { + CORNER_RADIUS: 'radius', + GAP: 'spacing', + WIDTH_HEIGHT: 'spacing', + FONT_SIZE: 'text', + LINE_HEIGHT: 'leading', + LETTER_SPACING: 'tracking', + FONT_FAMILY: 'font', + FONT_STYLE: 'font-weight', + EFFECT_FLOAT: 'blur' +} + +/** Leading name segments that already say the namespace, so `Color/primary` is not `--color-color-primary`. */ +const NAMESPACE_WORDS: Record = { + ...Object.fromEntries(themeNamespaces.map((namespace) => [namespace, namespace])), + colors: 'color', + colour: 'color', + colours: 'color', + space: 'spacing', + radii: 'radius', + rounded: 'radius', + corner: 'radius', + corners: 'radius', + 'font-size': 'text', + 'line-height': 'leading', + 'letter-spacing': 'tracking', + fonts: 'font', + 'font-family': 'font', + weight: 'font-weight', + shadows: 'shadow' +} + +/** The Tailwind namespace a token belongs to, from its type, its scopes, then its leading name segment. */ +export function variableNamespace(variable: Variable): ThemeNamespace | undefined { + if (variable.type === 'COLOR') return 'color' + const fromScopes = new Set((variable.scopes ?? []).map((scope) => SCOPE_NAMESPACES[scope])) + const [only] = fromScopes + if (fromScopes.size === 1 && only) return only + return NAMESPACE_WORDS[kebabCase(variable.name.split('/')[0] ?? '')] +} + +/** + * The custom property a code snippet names, without `--`: `var(--brand)`, `var(--brand, red)` + * and `--brand` all name `brand`. A snippet that is anything else, such as a Tailwind class, + * names none. + */ +export function parseCSSName(snippet: string | undefined): string | undefined { + const nodes = valueParser(snippet ?? '').nodes.filter((node) => node.type !== 'space') + const [node] = nodes + if (nodes.length !== 1) return undefined + const word = + node.type === 'function' && node.value === 'var' && 'nodes' in node + ? node.nodes.find((child) => child.type !== 'space') + : node + return word?.type === 'word' && word.value.startsWith('--') && word.value.length > 2 + ? word.value.slice(2) + : undefined +} + +/** The name `codeSyntax.WEB` gives the token, if it gives one. */ +export function explicitCSSName(variable: Variable): string | undefined { + return parseCSSName(variable.codeSyntax?.WEB) +} + +/** + * `Gray/50` as COLOR is `color-gray-50`; `Space/small` is `spacing-small`. Two tokens can derive + * the same name, so stylesheet output takes names from `variableCSSNames`, which makes them unique. + */ +export function deriveCSSName(variable: Variable): string { + const namespace = variableNamespace(variable) + const segments = compact(variable.name.split('/').map((segment) => kebabCase(segment))) + if (segments.length > 1 && namespace && NAMESPACE_WORDS[segments[0] ?? ''] === namespace) { + segments.shift() + } + const body = segments.join('-') || 'token' + if (!namespace || body === namespace || body.startsWith(`${namespace}-`)) return body + return `${namespace}-${body}` +} + +/** + * Document-wide custom property names, without `--`. The first token whose `WEB` snippet claims + * a name keeps it; later claimants, which Figma files do contain, and derived names that collide + * take a derived name with a numeric suffix, in iteration order. + */ +export function variableCSSNames(variables: Iterable): Map { + const list = [...variables] + const names = new Map() + const taken = new Set() + for (const variable of list) { + const name = explicitCSSName(variable) + if (!name || taken.has(name)) continue + names.set(variable.id, name) + taken.add(name) + } + for (const variable of list) { + if (names.has(variable.id)) continue + const base = deriveCSSName(variable) + let name = base + for (let n = 2; taken.has(name); n++) name = `${base}-${n}` + names.set(variable.id, name) + taken.add(name) + } + return names +} + +/** The `WEB` snippet that names a token in Figma's Dev Mode and here. */ +export function cssNameCodeSyntax(name: string): string { + return `var(--${name})` +} diff --git a/packages/dom-css/src/tokens/values.ts b/packages/dom-css/src/tokens/values.ts new file mode 100644 index 000000000..547314bb2 --- /dev/null +++ b/packages/dom-css/src/tokens/values.ts @@ -0,0 +1,34 @@ +import { + tokenNumberInUnit, + type TokenUnit, + type Variable, + type VariableScope +} from '@open-pencil/scene-graph' + +import { variableNamespace } from './names' + +const UNITLESS_SCOPES = new Set(['OPACITY', 'FONT_STYLE', 'FONT_VARIATIONS']) + +/** + * The unit a FLOAT token is written in. An explicit unit wins; opacity and font weights are + * unitless; every other number is a pixel length, which is what the canvas draws. + */ +export function variableUnit(variable: Variable): TokenUnit { + if (variable.type !== 'FLOAT') return 'none' + if (variable.unit) return variable.unit + const scopes = variable.scopes ?? [] + if (scopes.length > 0 && scopes.every((scope) => UNITLESS_SCOPES.has(scope))) return 'none' + return variableNamespace(variable) === 'font-weight' ? 'none' : 'px' +} + +/** Six decimals keep `rem` exact for every pixel step down to 1/1024px (0.5px is 0.03125rem). */ +function trimNumber(value: number): string { + return String(Number(value.toFixed(6))) +} + +/** A stored number written in its unit: 24 as `rem` is `1.5rem`, 150 as `ms` is `150ms`. */ +export function tokenNumberToCSS(value: number, unit: TokenUnit): string { + const number = trimNumber(tokenNumberInUnit(value, unit)) + if (unit === 'none' || number === '0') return number + return `${number}${unit}` +} diff --git a/packages/dom-css/tests/tokens/names.test.ts b/packages/dom-css/tests/tokens/names.test.ts new file mode 100644 index 000000000..7efdabfc5 --- /dev/null +++ b/packages/dom-css/tests/tokens/names.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'bun:test' + +import { + deriveCSSName, + parseCSSName, + tokenNumberToCSS, + variableCSSNames, + variableUnit +} from '@open-pencil/dom-css/export' +import type { Variable } from '@open-pencil/scene-graph' + +function token(name: string, overrides: Partial = {}): Variable { + return { + id: name, + name, + type: 'FLOAT', + collectionId: 'c', + valuesByMode: { m: 8 }, + description: '', + hiddenFromPublishing: false, + ...overrides + } +} + +describe('token CSS names', () => { + test('derive a Tailwind namespace from type, scopes, or the leading segment', () => { + expect(deriveCSSName(token('Gray/50', { type: 'COLOR' }))).toBe('color-gray-50') + expect(deriveCSSName(token('Colors/Brand primary', { type: 'COLOR' }))).toBe( + 'color-brand-primary' + ) + expect(deriveCSSName(token('Card', { scopes: ['CORNER_RADIUS'] }))).toBe('radius-card') + expect(deriveCSSName(token('Space/small'))).toBe('spacing-small') + expect(deriveCSSName(token('Heading', { scopes: ['FONT_SIZE'] }))).toBe('text-heading') + expect(deriveCSSName(token('Bold', { scopes: ['FONT_STYLE'] }))).toBe('font-weight-bold') + expect(deriveCSSName(token('Brand', { type: 'STRING', scopes: ['FONT_FAMILY'] }))).toBe( + 'font-brand' + ) + }) + + test('leave a token outside every namespace unprefixed', () => { + expect(deriveCSSName(token('Elevation/1'))).toBe('elevation-1') + expect(deriveCSSName(token('Fade', { scopes: ['OPACITY'] }))).toBe('fade') + expect(deriveCSSName(token('Mixed', { scopes: ['GAP', 'CORNER_RADIUS'] }))).toBe('mixed') + }) + + test('keep characters outside ASCII, which custom properties allow, and fall back for none', () => { + expect(deriveCSSName(token('Цвет/фон', { type: 'COLOR' }))).toBe('color-цвет-фон') + expect(deriveCSSName(token('🎨', { type: 'COLOR' }))).toBe('color-🎨') + expect(deriveCSSName(token('!!!', { type: 'COLOR' }))).toBe('color-token') + }) + + test('read custom property names from code snippets only', () => { + expect(parseCSSName('var(--color-primary)')).toBe('color-primary') + expect(parseCSSName('var(--ui-bg, #fff)')).toBe('ui-bg') + expect(parseCSSName('--ui-primary')).toBe('ui-primary') + expect(parseCSSName('rounded-xs')).toBeUndefined() + expect(parseCSSName('theme.colors.primary')).toBeUndefined() + expect(parseCSSName('var(--a) var(--b)')).toBeUndefined() + }) + + test('give the first claimant of a WEB name that name and derive the rest', () => { + const names = variableCSSNames([ + token('Info', { type: 'COLOR', codeSyntax: { WEB: '--ui-info' } }), + token('Success', { type: 'COLOR', codeSyntax: { WEB: 'var(--ui-info)' } }), + token('Colors/Info', { type: 'COLOR', codeSyntax: { WEB: 'text-info' } }), + token('Info ', { type: 'COLOR' }) + ]) + expect([...names.values()]).toEqual(['ui-info', 'color-success', 'color-info', 'color-info-2']) + }) +}) + +describe('token units', () => { + test('infer pixels for lengths and no unit for opacity and weights', () => { + expect(variableUnit(token('Space/small'))).toBe('px') + expect(variableUnit(token('Anything'))).toBe('px') + expect(variableUnit(token('Fade', { scopes: ['OPACITY'] }))).toBe('none') + expect(variableUnit(token('Bold', { scopes: ['FONT_STYLE'] }))).toBe('none') + expect(variableUnit(token('Weight/bold'))).toBe('none') + expect(variableUnit(token('Space/small', { unit: 'rem' }))).toBe('rem') + expect(variableUnit(token('Gray', { type: 'COLOR' }))).toBe('none') + }) + + test('write stored numbers in their unit', () => { + expect(tokenNumberToCSS(24, 'rem')).toBe('1.5rem') + expect(tokenNumberToCSS(0.5, 'rem')).toBe('0.03125rem') + expect(tokenNumberToCSS(8, 'px')).toBe('8px') + expect(tokenNumberToCSS(0, 'px')).toBe('0') + expect(tokenNumberToCSS(150, 'ms')).toBe('150ms') + expect(tokenNumberToCSS(0.1 + 0.2, 'none')).toBe('0.3') + }) +}) diff --git a/packages/fig/package.json b/packages/fig/package.json index f5b178537..0660e1aa0 100644 --- a/packages/fig/package.json +++ b/packages/fig/package.json @@ -61,7 +61,8 @@ "dependencies": { "es-toolkit": "^1.51.0", "fflate": "^0.8.3", - "js-base64": "^3.9.3" + "js-base64": "^3.9.3", + "valibot": "^1.4.2" }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/packages/fig/src/document/variables.ts b/packages/fig/src/document/variables.ts index 9690790ee..447ac8adc 100644 --- a/packages/fig/src/document/variables.ts +++ b/packages/fig/src/document/variables.ts @@ -4,6 +4,11 @@ import type { SceneGraph, VariableValue } from '@open-pencil/scene-graph' import { extractPluginData } from '../node-change/plugin-data' import { readVariableMetadata } from '../node-change/variable/metadata' +import { + readModeConditions, + readVariableToken, + withoutTokenPluginData +} from '../node-change/variable/token' import { createResourceResolver } from './resource-reference' function valueOf( @@ -58,11 +63,15 @@ export function materializeVariableResources( report(resource, 'missing collection identity or modes') continue } - const pluginData = extractPluginData(resource) + const pluginData = withoutTokenPluginData(extractPluginData(resource)) + const conditions = readModeConditions(resource) graph.addCollection({ id: guidToString(resource.guid), name: resource.name ?? 'Variables', - modes: modes.map((mode) => ({ modeId: guidToString(mode.id), name: mode.name })), + modes: modes.map((mode) => { + const modeId = guidToString(mode.id) + return { modeId, name: mode.name, condition: conditions[modeId] } + }), defaultModeId: guidToString(modes[0].id), variableIds: [], pluginData: pluginData.length > 0 ? pluginData : undefined @@ -107,13 +116,15 @@ function addVariables( report(resource, error instanceof Error ? error.message : 'Invalid mode value') continue } + const metadata = readVariableMetadata(resource) graph.addVariable({ id: guidToString(resource.guid), name: resource.name ?? 'Variable', type, collectionId, valuesByMode, - ...readVariableMetadata(resource) + ...metadata, + ...readVariableToken(resource, valuesByMode) }) } } diff --git a/packages/fig/src/node-change/index.ts b/packages/fig/src/node-change/index.ts index 734b9754e..c7f571abd 100644 --- a/packages/fig/src/node-change/index.ts +++ b/packages/fig/src/node-change/index.ts @@ -20,5 +20,6 @@ export * from './text/data-export' export * from './text/values' export * from './variable/bindings' export * from './variable/metadata' +export * from './variable/token' export * from './vector/geometry' export * from './vector/network' diff --git a/packages/fig/src/node-change/variable/metadata.ts b/packages/fig/src/node-change/variable/metadata.ts index d5c705fc6..bdcb09171 100644 --- a/packages/fig/src/node-change/variable/metadata.ts +++ b/packages/fig/src/node-change/variable/metadata.ts @@ -5,16 +5,20 @@ import { type CodeSyntaxPlatform, type PluginDataEntry, type Variable, + type VariableCollection, type VariableScope } from '@open-pencil/scene-graph' import { extractPluginData, mergePluginData } from '../plugin-data' +import { modeConditionsPluginData, tokenPluginData, withoutTokenPluginData } from './token' type VariableMetadata = Pick< Variable, 'description' | 'hiddenFromPublishing' | 'scopes' | 'codeSyntax' | 'pluginData' > +type ModeKey = (modeId: string) => string + const isScope = (value: string): value is VariableScope => (VARIABLE_SCOPES as readonly string[]).includes(value) @@ -31,7 +35,7 @@ function readCodeSyntax(nc: NodeChange): Variable['codeSyntax'] { /** Figma keeps the description twice, as `description` and `symbolDescription`. */ export function readVariableMetadata(nc: NodeChange): VariableMetadata { const scopes = nc.variableScopes?.filter(isScope) - const pluginData = extractPluginData(nc) + const pluginData = withoutTokenPluginData(extractPluginData(nc)) return { description: nc.description ?? nc.symbolDescription ?? '', hiddenFromPublishing: nc.isPublishable === false, @@ -41,14 +45,21 @@ export function readVariableMetadata(nc: NodeChange): VariableMetadata { } } -export function variableMetadataNodeChange(variable: Variable): Partial { +export function variableMetadataNodeChange( + variable: Variable, + modeKey: ModeKey +): Partial { const codeSyntax = Object.entries(variable.codeSyntax ?? {}).flatMap(([platform, value]) => value ? [{ platform, value }] : [] ) + const token = tokenPluginData(variable, modeKey) const nc: Partial = { isPublishable: !variable.hiddenFromPublishing, variableScopes: variable.scopes?.length ? variable.scopes : ['ALL_SCOPES'], - pluginData: pluginDataNodeChange(variable.pluginData) + pluginData: pluginDataNodeChange([ + ...withoutTokenPluginData(variable.pluginData ?? []), + ...(token ? [token] : []) + ]) } if (variable.description) { nc.description = variable.description @@ -58,6 +69,17 @@ export function variableMetadataNodeChange(variable: Variable): Partial + +/** Plugin data other than the entries this module owns, which are rebuilt on every save. */ +export function withoutTokenPluginData(pluginData: PluginDataEntry[]): PluginDataEntry[] { + return pluginData.filter( + (entry) => + entry.pluginId !== OPEN_PENCIL_PLUGIN_ID || + (entry.key !== TOKEN_PLUGIN_KEY && entry.key !== MODE_CONDITIONS_PLUGIN_KEY) + ) +} + +function nonEmpty(record: T): T | undefined { + return isEmptyObject(record) ? undefined : record +} + +/** `.fig` stores numbers as float32, so compare at that precision, not the plugin data's double. */ +function sameNumber(a: VariableValue | undefined, b: number): boolean { + return typeof a === 'number' && Math.fround(a) === Math.fround(b) +} + +/** + * The stored number stays authoritative: an expression whose mode value was edited elsewhere, + * Figma included, no longer describes that value and is dropped rather than overriding it. + */ +export function readVariableToken( + nc: NodeChange, + valuesByMode: Record +): TokenFields { + const parsed = v.safeParse(TokenJSON, getOpenPencilPluginValue(nc, TOKEN_PLUGIN_KEY)) + const token = parsed.success ? parsed.output : {} + const expressions = Object.entries(token.expressions ?? {}).filter(([mode, expression]) => + sameNumber(valuesByMode[mode], expression.resolved) + ) + return { unit: token.unit, expressions: nonEmpty(Object.fromEntries(expressions)) } +} + +export function readModeConditions(nc: NodeChange): Record { + const parsed = v.safeParse( + ModeConditionsJSON, + getOpenPencilPluginValue(nc, MODE_CONDITIONS_PLUGIN_KEY) + ) + return parsed.success ? parsed.output : {} +} + +function entry(key: string, value: object): PluginDataEntry { + return { pluginId: OPEN_PENCIL_PLUGIN_ID, key, value: JSON.stringify(value) } +} + +/** Mode ids in the file differ from the model's, so callers map them. */ +export function tokenPluginData( + variable: Variable, + modeKey: (modeId: string) => string +): PluginDataEntry | undefined { + const token = { + unit: variable.unit, + expressions: nonEmpty(mapKeys(variable.expressions ?? {}, (_, mode) => modeKey(mode))) + } + if (!token.unit && !token.expressions) return undefined + return entry(TOKEN_PLUGIN_KEY, token) +} + +export function modeConditionsPluginData( + collection: VariableCollection, + modeKey: (modeId: string) => string +): PluginDataEntry | undefined { + const conditions = collection.modes.flatMap((mode) => + mode.condition ? [[modeKey(mode.modeId), mode.condition] as const] : [] + ) + return conditions.length > 0 + ? entry(MODE_CONDITIONS_PLUGIN_KEY, Object.fromEntries(conditions)) + : undefined +} diff --git a/packages/fig/tests/node-change/variable-token.test.ts b/packages/fig/tests/node-change/variable-token.test.ts new file mode 100644 index 000000000..5b811d9d4 --- /dev/null +++ b/packages/fig/tests/node-change/variable-token.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' + +import { + MODE_CONDITIONS_PLUGIN_KEY, + OPEN_PENCIL_PLUGIN_ID, + readModeConditions, + readVariableToken, + TOKEN_PLUGIN_KEY +} from '#fig/node-change/index' + +import type { NodeChange } from '@open-pencil/kiwi/fig/codec' + +function record(key: string, value: string): NodeChange { + return { pluginData: [{ pluginID: OPEN_PENCIL_PLUGIN_ID, key, value }] } +} + +describe('token plugin data', () => { + test('a malformed or wrongly shaped entry reads as no token data', () => { + expect(readVariableToken(record(TOKEN_PLUGIN_KEY, '{not json'), { m: 8 })).toEqual({ + unit: undefined, + expressions: undefined + }) + expect(readVariableToken(record(TOKEN_PLUGIN_KEY, '{"unit":"furlong"}'), { m: 8 })).toEqual({ + unit: undefined, + expressions: undefined + }) + expect(readModeConditions(record(MODE_CONDITIONS_PLUGIN_KEY, '{"m":42}'))).toEqual({}) + expect(readModeConditions(record(MODE_CONDITIONS_PLUGIN_KEY, '{"m":" "}'))).toEqual({}) + }) + + test('keep expressions only for modes whose value still matches', () => { + const nc = record( + TOKEN_PLUGIN_KEY, + JSON.stringify({ + unit: 'rem', + expressions: { + a: { css: 'clamp(1rem, 4vw, 2rem)', resolved: 16 }, + b: { css: 'clamp(1rem, 4vw, 2rem)', resolved: 16 } + } + }) + ) + expect(readVariableToken(nc, { a: 16, b: 20 })).toEqual({ + unit: 'rem', + expressions: { a: { css: 'clamp(1rem, 4vw, 2rem)', resolved: 16 } } + }) + }) + + test('match a mode value stored at float32 precision', () => { + const nc = record( + TOKEN_PLUGIN_KEY, + JSON.stringify({ expressions: { a: { css: 'calc(100vw / 3)', resolved: 1234.567 } } }) + ) + // What .fig hands back for 1234.567 after storing it as float32. + expect(readVariableToken(nc, { a: Math.fround(1234.567) }).expressions).toEqual({ + a: { css: 'calc(100vw / 3)', resolved: 1234.567 } + }) + }) +}) diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 971042ceb..e69410bc8 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -3,6 +3,7 @@ export * from './mutation-impact' export * from './variables/bindings' export type { VariableModeFallback } from './variables' export { modesDefaultFirst } from './variables' +export * from './variables/token' export { rescaleNodeTree, scaleNodeChanges } from './scaling' import { TRANSFORM_FIELDS, SIZE_FIELDS } from './fields/geometry' export { TRANSFORM_FIELDS, SIZE_FIELDS } from './fields/geometry' diff --git a/packages/scene-graph/src/types.ts b/packages/scene-graph/src/types.ts index 37f990880..741901506 100644 --- a/packages/scene-graph/src/types.ts +++ b/packages/scene-graph/src/types.ts @@ -655,6 +655,19 @@ export type VariableScope = (typeof VARIABLE_SCOPES)[number] export const CODE_SYNTAX_PLATFORMS = ['WEB', 'ANDROID', 'iOS'] as const export type CodeSyntaxPlatform = (typeof CODE_SYNTAX_PLATFORMS)[number] +/** + * The CSS unit a numeric token is written in. Lengths (`px`, `rem`) stay in canvas pixels in + * the document and convert only when written as CSS; the other units store the number as written. + */ +export const TOKEN_UNITS = ['none', 'px', 'rem', '%', 'ms', 's', 'deg'] as const +export type TokenUnit = (typeof TOKEN_UNITS)[number] + +/** A mode value authored as raw CSS. `resolved` is the number the canvas drew for it. */ +export interface TokenExpression { + css: string + resolved: number +} + export interface Variable { id: string name: string @@ -665,8 +678,15 @@ export interface Variable { hiddenFromPublishing: boolean /** Absent means every scope. */ scopes?: VariableScope[] - /** Per-platform code name; `WEB` is the CSS custom property. */ + /** + * Per-platform code snippets, as Figma's Dev Mode shows them. A `WEB` snippet of `--x` or + * `var(--x)` names the token's CSS custom property; otherwise the name is derived. + */ codeSyntax?: Partial> + /** FLOAT only. Absent means inferred when written as CSS. */ + unit?: TokenUnit + /** Raw CSS by mode id, for values a number cannot express (`clamp()`, `calc()`). */ + expressions?: Record pluginData?: PluginDataEntry[] /** Published library key (from NodeChange.key). Used for assetRef resolution in colorVar. */ key?: string @@ -682,6 +702,11 @@ export type NumericNodeProperty = { export interface VariableCollectionMode { modeId: string name: string + /** + * Where the mode applies in CSS: a selector (`[data-theme="dark"]`, `.compact`) or an + * at-rule prelude (`@media (max-width: 640px)`). Absent means the default for its axis. + */ + condition?: string } export interface VariableCollection { diff --git a/packages/scene-graph/src/variables/token.ts b/packages/scene-graph/src/variables/token.ts new file mode 100644 index 000000000..d8ec3560c --- /dev/null +++ b/packages/scene-graph/src/variables/token.ts @@ -0,0 +1,14 @@ +import type { TokenUnit } from '../types' + +/** `rem` tokens are stored in canvas pixels and shown against this root font size. */ +export const ROOT_FONT_SIZE_PX = 16 + +/** The stored number for a value typed in a unit: `1.5` in `rem` is 24 canvas pixels. */ +export function tokenNumberFromUnit(value: number, unit: TokenUnit): number { + return unit === 'rem' ? value * ROOT_FONT_SIZE_PX : value +} + +/** A stored number as it reads in its unit: 24 canvas pixels in `rem` is `1.5`. */ +export function tokenNumberInUnit(value: number, unit: TokenUnit): number { + return unit === 'rem' ? value / ROOT_FONT_SIZE_PX : value +} diff --git a/packages/scene-graph/tests/variable/token.test.ts b/packages/scene-graph/tests/variable/token.test.ts new file mode 100644 index 000000000..b0ba686a7 --- /dev/null +++ b/packages/scene-graph/tests/variable/token.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test' + +import { tokenNumberFromUnit, tokenNumberInUnit } from '@open-pencil/scene-graph' + +describe('token units', () => { + test('store rem as canvas pixels and read it back', () => { + expect(tokenNumberFromUnit(1.5, 'rem')).toBe(24) + expect(tokenNumberInUnit(24, 'rem')).toBe(1.5) + }) + + test('store every other unit as written', () => { + expect(tokenNumberFromUnit(150, 'ms')).toBe(150) + expect(tokenNumberInUnit(12, 'px')).toBe(12) + }) +})