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) + }) +})