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<T>, 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.
This commit is contained in:
Danila Poyarkov 2026-10-04 10:50:46 +00:00 committed by GitHub
parent 64c9461407
commit 46c678e18f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 607 additions and 15 deletions

View file

@ -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",

View file

@ -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<string, GUID>,
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
})

View file

@ -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<Variable>) =>
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()

View file

@ -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:

View file

@ -8,6 +8,7 @@ export {
type TailwindJSXWithLayers
} from './tailwind-jsx'
export { serializeHTML } from './html'
export * from '../tokens'
export type {
ExportHTMLBundle,
ExportHTMLBundleOptions,

View file

@ -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'

View file

@ -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<Record<VariableScope, ThemeNamespace>> = {
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<string, ThemeNamespace> = {
...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<Variable>): Map<string, string> {
const list = [...variables]
const names = new Map<string, string>()
const taken = new Set<string>()
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})`
}

View file

@ -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<VariableScope>(['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}`
}

View file

@ -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> = {}): 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')
})
})

View file

@ -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",

View file

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

View file

@ -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'

View file

@ -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<NodeChange> {
export function variableMetadataNodeChange(
variable: Variable,
modeKey: ModeKey
): Partial<NodeChange> {
const codeSyntax = Object.entries(variable.codeSyntax ?? {}).flatMap(([platform, value]) =>
value ? [{ platform, value }] : []
)
const token = tokenPluginData(variable, modeKey)
const nc: Partial<NodeChange> = {
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<NodeChan
return nc
}
export function collectionPluginDataNodeChange(
collection: VariableCollection,
modeKey: ModeKey
): NodeChange['pluginData'] {
const conditions = modeConditionsPluginData(collection, modeKey)
return pluginDataNodeChange([
...withoutTokenPluginData(collection.pluginData ?? []),
...(conditions ? [conditions] : [])
])
}
export function pluginDataNodeChange(
pluginData: PluginDataEntry[] | undefined
): NodeChange['pluginData'] {

View file

@ -0,0 +1,108 @@
import { mapKeys } from 'es-toolkit/object'
import { isEmptyObject } from 'es-toolkit/predicate'
import * as v from 'valibot'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import {
TOKEN_UNITS,
type PluginDataEntry,
type Variable,
type VariableCollection,
type VariableValue
} from '@open-pencil/scene-graph'
import { getOpenPencilPluginValue, OPEN_PENCIL_PLUGIN_ID } from '../plugin-data'
/** Token fields Figma has no slot for, on each VARIABLE. */
export const TOKEN_PLUGIN_KEY = 'token'
/** Mode conditions by mode id, on each VARIABLE_SET. */
export const MODE_CONDITIONS_PLUGIN_KEY = 'modeConditions'
// Shape only. Whether a string is valid CSS is checked where it is written into a stylesheet.
const cssText = v.pipe(v.string(), v.trim(), v.nonEmpty(), v.maxLength(1000))
// Plugin data is a JSON string; invalid JSON is an issue like any wrong shape, never a throw.
const TokenJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
unit: v.optional(v.picklist(TOKEN_UNITS)),
expressions: v.optional(
v.record(v.string(), v.object({ css: cssText, resolved: v.pipe(v.number(), v.finite()) }))
)
})
)
const ModeConditionsJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), cssText))
type TokenFields = Pick<Variable, 'unit' | 'expressions'>
/** 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<T extends object>(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<string, VariableValue>
): 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<string, string> {
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
}

View file

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

View file

@ -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'

View file

@ -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<Record<CodeSyntaxPlatform, string>>
/** 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<string, TokenExpression>
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 {

View file

@ -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
}

View file

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