openpencil/packages/fig/tests/node-change/variable-token.test.ts
Danila Poyarkov 46c678e18f
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.
2026-10-04 10:50:46 +00:00

59 lines
1.9 KiB
TypeScript

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