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:
parent
64c9461407
commit
46c678e18f
1
bun.lock
1
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export {
|
|||
type TailwindJSXWithLayers
|
||||
} from './tailwind-jsx'
|
||||
export { serializeHTML } from './html'
|
||||
export * from '../tokens'
|
||||
export type {
|
||||
ExportHTMLBundle,
|
||||
ExportHTMLBundleOptions,
|
||||
|
|
|
|||
10
packages/dom-css/src/tokens/index.ts
Normal file
10
packages/dom-css/src/tokens/index.ts
Normal 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'
|
||||
116
packages/dom-css/src/tokens/names.ts
Normal file
116
packages/dom-css/src/tokens/names.ts
Normal 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})`
|
||||
}
|
||||
34
packages/dom-css/src/tokens/values.ts
Normal file
34
packages/dom-css/src/tokens/values.ts
Normal 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}`
|
||||
}
|
||||
91
packages/dom-css/tests/tokens/names.test.ts
Normal file
91
packages/dom-css/tests/tokens/names.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'] {
|
||||
|
|
|
|||
108
packages/fig/src/node-change/variable/token.ts
Normal file
108
packages/fig/src/node-change/variable/token.ts
Normal 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
|
||||
}
|
||||
58
packages/fig/tests/node-change/variable-token.test.ts
Normal file
58
packages/fig/tests/node-change/variable-token.test.ts
Normal 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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
14
packages/scene-graph/src/variables/token.ts
Normal file
14
packages/scene-graph/src/variables/token.ts
Normal 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
|
||||
}
|
||||
15
packages/scene-graph/tests/variable/token.test.ts
Normal file
15
packages/scene-graph/tests/variable/token.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue