fix(fig): honor node-scoped variable modes

- Import and round-trip per-node variable mode overrides

- Resolve fill and stroke aliases through the nearest mode scope

- Preserve mode maps across cloning and instance synchronization
This commit is contained in:
Danila Poyarkov 2026-07-21 00:23:00 +03:00
parent 9db46f919b
commit e704343f13
18 changed files with 367 additions and 15 deletions

View file

@ -27,6 +27,7 @@
### Fixed
- Keep desktop text visible across the scene and overlay canvases, refresh it after local fonts load, and preserve rendering when a requested italic face is unavailable (#395).
- Honor node-scoped variable modes in `.fig` files so light and dark component examples keep their intended colors.
- Improve `.fig` import and rendering fidelity for groups, booleans, instances, rotated vectors, complex text fills, auto-sized text, layout grids, page guides, patterns, noise effects, masks, and canvas backgrounds.
- Preserve pages, components, prototype and library metadata, export settings, unsupported effects, and other unrelated Figma data when editing and resaving `.fig` files.
- Prevent duplicate generated IDs from corrupting `.fig` round trips.

View file

@ -24,7 +24,7 @@ export function resolveFillColorInfo(
): ResolvedRenderColor {
const varId = node.boundVariables[`fills/${fillIndex}/color`]
if (varId) {
const resolved = graph.resolveColorVariable(varId)
const resolved = graph.resolveColorVariableForNode(node.id, varId)
if (resolved) return resolvedVariableColor(resolved, graph)
}
return resolveNodeFillColor(fill, fillIndex, node, {
@ -51,7 +51,7 @@ export function resolveStrokeColorInfo(
): ResolvedRenderColor {
const varId = node.boundVariables[`strokes/${strokeIndex}/color`]
if (varId) {
const resolved = graph.resolveColorVariable(varId)
const resolved = graph.resolveColorVariableForNode(node.id, varId)
if (resolved) return resolvedVariableColor(resolved, graph)
}
return resolveNodeStrokeColor(stroke, strokeIndex, node, {

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- FIG export orchestration keeps shared GUID state in one pipeline */
import type { CanvasKit } from 'canvaskit-wasm'
import { deflateSync, inflateSync } from 'fflate'
@ -334,7 +335,8 @@ function appendInternalResources(context: InternalResourceContext): void {
context.glyphBlobMap,
context.blobIndexByHex,
context.assignedGuidValues,
context.componentPropertyDefinitionsById
context.componentPropertyDefinitionsById,
context.modeIdToGuid
)
)
}
@ -454,7 +456,8 @@ export async function exportFigFile(
glyphBlobMap,
blobIndexByHex,
assignedGuidValues,
componentPropertyDefinitionsById
componentPropertyDefinitionsById,
modeIdToGuid
)
)
}

View file

@ -37,7 +37,8 @@ export function sceneNodeToKiwi(
glyphBlobMap = new Map<string, number>(),
blobIndexByHex?: Map<string, number>,
assignedGuidValues?: Set<string>,
componentPropertyDefinitionsById?: ReadonlyMap<string, ComponentPropertyDefinition>
componentPropertyDefinitionsById?: ReadonlyMap<string, ComponentPropertyDefinition>,
modeIdToGuid?: Map<string, GUID>
): KiwiNodeChange[] {
return sceneNodeToKiwiWithRuntime(
node,
@ -53,6 +54,7 @@ export function sceneNodeToKiwi(
blobIndexByHex,
assignedGuidValues,
coreFigExportRuntime,
componentPropertyDefinitionsById
componentPropertyDefinitionsById,
modeIdToGuid
)
}

View file

@ -54,7 +54,8 @@ import type {
ComponentPropertyReference,
ComponentPropertyType,
SymbolLink,
VariantPropSpec
VariantPropSpec,
VariableModeMap
} from '@open-pencil/scene-graph'
import type { GUID } from '@open-pencil/scene-graph/primitives'
@ -108,6 +109,25 @@ export const VARIABLE_BINDING_FIELDS_INVERSE: Record<string, string> = Object.fr
Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k])
)
interface FigVariableModeMap {
entries?: Array<{
variableSetID?: { guid?: GUID }
variableModeID?: GUID
}>
}
function extractVariableModes(nc: NodeChange): VariableModeMap {
const result: VariableModeMap = {}
const modeMap = nc.variableModeBySetMap as FigVariableModeMap | undefined
for (const entry of modeMap?.entries ?? []) {
const collectionGuid = entry.variableSetID?.guid
const modeGuid = entry.variableModeID
if (!collectionGuid || !modeGuid) continue
result[guidToString(collectionGuid)] = guidToString(modeGuid)
}
return result
}
const NODE_TYPE_MAP: Record<string, NodeType | 'DOCUMENT' | 'VARIABLE'> = {
DOCUMENT: 'DOCUMENT',
VARIABLE: 'VARIABLE',
@ -633,6 +653,7 @@ export function nodeChangeToProps(
expanded: true,
autoRename: (nc.autoRename ?? true) as boolean,
boundVariables: extractBoundVariables(nc),
variableModes: extractVariableModes(nc),
exportSettings: extractExportSettings(nc),
pluginData: extractPluginData(nc),
pluginRelaunchData: extractPluginRelaunchData(nc),

View file

@ -60,6 +60,7 @@ interface SceneNodeToKiwiContext {
fontDigestMap?: Map<string, Uint8Array>
glyphBlobMap?: Map<string, number>
varIdToGuid?: Map<string, GUID>
modeIdToGuid?: Map<string, GUID>
/** Maps "key@version" or "key" (from variable.key/version) variable GUID.
* Used to convert colorVar.assetRef references in raw paints to guid references. */
assetRefToVarGuid?: Map<string, GUID>
@ -147,6 +148,20 @@ function parseGuidOrNull(value: string) {
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
}
function serializeVariableModes(
node: SceneNode,
variableIdToGuid?: Map<string, GUID>,
modeIdToGuid?: Map<string, GUID>
): NonNullable<KiwiNodeChange['variableModeBySetMap']> | undefined {
const entries = Object.entries(node.variableModes).flatMap(([collectionId, modeId]) => {
const collectionGuid = variableIdToGuid?.get(collectionId) ?? parseGuidOrNull(collectionId)
const modeGuid = modeIdToGuid?.get(modeId) ?? parseGuidOrNull(modeId)
if (!collectionGuid || !modeGuid) return []
return [{ variableSetID: { guid: collectionGuid }, variableModeID: modeGuid }]
})
return entries.length > 0 ? { entries } : undefined
}
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set([
'variableConsumptionMap',
'parameterConsumptionMap'
@ -822,6 +837,12 @@ export function sceneNodeToKiwiWithContext(
context.serializeGeometry(nodeForGeometryExport(node), nc, context.blobs)
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
applyRawFigmaNodeFields(context, node, nc)
const variableModeBySetMap = serializeVariableModes(
node,
context.varIdToGuid,
context.modeIdToGuid
)
if (variableModeBySetMap) nc.variableModeBySetMap = variableModeBySetMap
applyExportSettingsPluginData(node)
const pluginData = mergePluginData(node.pluginData)

View file

@ -77,14 +77,14 @@ export function extractBoundVariables(nc: NodeChange): Record<string, string> {
getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY)
)
nc.fillPaints?.forEach((paint, i) => {
if (paint.colorVariableBinding) {
bindings[`fills/${i}/color`] = guidToString(paint.colorVariableBinding.variableID)
}
const variableGuid =
paint.colorVariableBinding?.variableID ?? paint.colorVar?.value?.alias?.guid
if (variableGuid) bindings[`fills/${i}/color`] = guidToString(variableGuid)
})
nc.strokePaints?.forEach((paint, i) => {
if (paint.colorVariableBinding) {
bindings[`strokes/${i}/color`] = guidToString(paint.colorVariableBinding.variableID)
}
const variableGuid =
paint.colorVariableBinding?.variableID ?? paint.colorVar?.value?.alias?.guid
if (variableGuid) bindings[`strokes/${i}/color`] = guidToString(variableGuid)
})
return bindings
}

View file

@ -472,7 +472,8 @@ export function sceneNodeToKiwi(
blobIndexByHex?: Map<string, number>,
assignedGuidValues?: Set<string>,
runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME,
componentPropertyDefinitionsById = buildComponentPropIndex(graph)
componentPropertyDefinitionsById = buildComponentPropIndex(graph),
modeIdToGuid?: Map<string, GUID>
): KiwiNodeChange[] {
// Build assetRef to guid mapping for converting colorVar references in raw paints
const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined
@ -485,6 +486,7 @@ export function sceneNodeToKiwi(
fontDigestMap,
glyphBlobMap,
varIdToGuid,
modeIdToGuid,
assetRefToVarGuid,
componentPropertyDefinitionsById,
fractionalPosition,

View file

@ -174,6 +174,7 @@ export function cloneNodeProps(
...(componentId !== null ? { componentId } : {}),
source: createDefaultSourceMetadata(),
boundVariables: { ...src.boundVariables },
variableModes: { ...src.variableModes },
overrides: Object.keys(src.overrides).length > 0 ? structuredClone(src.overrides) : {},
componentPropertyAssignments: { ...src.componentPropertyAssignments },
componentPropertyValues: { ...src.componentPropertyValues }
@ -183,6 +184,7 @@ export function cloneNodeProps(
...rest,
...(componentId !== null ? { componentId } : {}),
boundVariables: { ...src.boundVariables },
variableModes: { ...src.variableModes },
overrides: Object.keys(src.overrides).length > 0 ? structuredClone(src.overrides) : {},
fills: copyOpt(src.fills, (value) => markCopySource(value, copyFills(value))),
strokes: copyOpt(src.strokes, (value) => markCopySource(value, copyStrokes(value))),

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- SceneGraph exposes a stable facade over domain modules */
export * from './images'
export * from './copy'
export * from './snap'
@ -150,6 +151,10 @@ export class SceneGraph {
return Variables.getActiveModeId(this, collectionId)
}
getNodeVariableModeId(nodeId: string, collectionId: string): string {
return Variables.getNodeVariableModeId(this, nodeId, collectionId)
}
setActiveMode(collectionId: string, modeId: string): void {
Variables.setActiveMode(this, collectionId, modeId)
}
@ -186,6 +191,14 @@ export class SceneGraph {
return Variables.resolveNumberVariable(this, variableId)
}
resolveColorVariableForNode(nodeId: string, variableId: string): Color | undefined {
return Variables.resolveColorVariableForNode(this, nodeId, variableId)
}
resolveNumberVariableForNode(nodeId: string, variableId: string): number | undefined {
return Variables.resolveNumberVariableForNode(this, nodeId, variableId)
}
getVariablesForCollection(collectionId: string): Variable[] {
return Variables.getVariablesForCollection(this, collectionId)
}

View file

@ -41,7 +41,8 @@ const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [
'borderRightWeight',
'borderBottomWeight',
'borderLeftWeight',
'boundVariables'
'boundVariables',
'variableModes'
]
function setSceneProp<K extends keyof SceneNode>(
@ -68,6 +69,8 @@ function copyProp(
} else if (key === 'boundVariables') {
// Shallow copy the binding map — values are variable IDs (strings), not objects
setSceneProp(target, key, { ...source.boundVariables })
} else if (key === 'variableModes') {
setSceneProp(target, key, { ...source.variableModes })
} else if (key === 'gridPosition') {
// Shallow copy the grid position object — all fields are primitives
setSceneProp(target, key, source.gridPosition ? { ...source.gridPosition } : null)

View file

@ -155,6 +155,7 @@ export function createDefaultNode(
symbolLinks: [],
variantPropSpecs: [],
boundVariables: {},
variableModes: {},
exportSettings: [],
pluginData: [],
pluginRelaunchData: [],

View file

@ -499,6 +499,7 @@ export interface SceneNode {
variantPropSpecs: VariantPropSpec[]
boundVariables: Record<string, string>
variableModes: VariableModeMap
exportSettings: ExportSetting[]
pluginData: PluginDataEntry[]
@ -533,6 +534,7 @@ export interface ComponentPropertyDefinition {
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
export type VariableValue = Color | number | string | boolean | { aliasId: string }
export type VariableModeMap = Record<string, string>
export interface Variable {
id: string

View file

@ -113,6 +113,20 @@ export function getActiveModeId(graph: SceneGraph, collectionId: string): string
return collection?.defaultModeId ?? ''
}
export function getNodeVariableModeId(
graph: SceneGraph,
nodeId: string,
collectionId: string
): string {
let node = graph.nodes.get(nodeId)
while (node) {
const modeId = node.variableModes[collectionId]
if (modeId) return modeId
node = node.parentId ? graph.nodes.get(node.parentId) : undefined
}
return getActiveModeId(graph, collectionId)
}
export function setActiveMode(graph: SceneGraph, collectionId: string, modeId: string): void {
graph.activeMode.set(collectionId, modeId)
}
@ -214,6 +228,31 @@ export function resolveNumberVariable(graph: SceneGraph, variableId: string): nu
return typeof value === 'number' ? value : undefined
}
export function resolveColorVariableForNode(
graph: SceneGraph,
nodeId: string,
variableId: string
): Color | undefined {
const variable = graph.variables.get(variableId)
if (!variable) return undefined
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId)
const value = resolveVariable(graph, variableId, modeId)
if (value && typeof value === 'object' && 'r' in value) return value
return undefined
}
export function resolveNumberVariableForNode(
graph: SceneGraph,
nodeId: string,
variableId: string
): number | undefined {
const variable = graph.variables.get(variableId)
if (!variable) return undefined
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId)
const value = resolveVariable(graph, variableId, modeId)
return typeof value === 'number' ? value : undefined
}
export function getVariablesForCollection(graph: SceneGraph, collectionId: string): Variable[] {
const collection = graph.variableCollections.get(collectionId)
if (!collection) return []

View file

@ -7,6 +7,82 @@ import { expectDefined } from '#tests/helpers/assert'
import { canvas, doc, node } from './helpers'
describe('fig-import: variable asset refs', () => {
test('imports node-scoped modes and native paint aliases', () => {
const graph = importNodeChanges([
doc(),
canvas(),
{
...node('VARIABLE_SET', 20, 1),
name: 'Theme',
variableSetModes: [
{ id: { sessionID: 10, localID: 1 }, name: 'Light' },
{ id: { sessionID: 10, localID: 2 }, name: 'Dark' }
]
} as NodeChange,
{
...node('VARIABLE', 21, 1),
name: 'Background',
variableSetID: { guid: { sessionID: 1, localID: 20 } },
variableResolvedType: 'COLOR',
variableDataValues: {
entries: [
{
modeID: { sessionID: 10, localID: 1 },
variableData: {
dataType: 'COLOR',
resolvedDataType: 'COLOR',
value: { colorValue: { r: 1, g: 1, b: 1, a: 1 } }
}
},
{
modeID: { sessionID: 10, localID: 2 },
variableData: {
dataType: 'COLOR',
resolvedDataType: 'COLOR',
value: { colorValue: { r: 0, g: 0, b: 0, a: 1 } }
}
}
]
}
} as NodeChange,
node('FRAME', 30, 1, {
name: 'Dark scope',
variableModeBySetMap: {
entries: [
{
variableSetID: { guid: { sessionID: 1, localID: 20 } },
variableModeID: { sessionID: 10, localID: 2 }
}
]
},
fillPaints: [
{
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
colorVar: {
value: { alias: { guid: { sessionID: 1, localID: 21 } } },
dataType: 'ALIAS',
resolvedDataType: 'COLOR'
}
}
] as NodeChange['fillPaints']
})
])
const frame = expectDefined(
[...graph.getAllNodes()].find((candidate) => candidate.name === 'Dark scope'),
'dark scope'
)
expect(frame.variableModes).toEqual({ '1:20': '10:2' })
expect(frame.boundVariables['fills/0/color']).toBe('1:21')
expect(graph.resolveColorVariableForNode(frame.id, '1:21')).toEqual({
r: 0,
g: 0,
b: 0,
a: 1
})
})
test('resolves color variables and aliases by assetRef', () => {
const graph = importNodeChanges([
doc(),

View file

@ -117,6 +117,75 @@ describe('variable roundtrip', () => {
expect(Object.keys(reimportedRect.boundVariables)).toContain('strokes/0/color')
})
test('node-scoped variable modes survive export → re-import', async () => {
await initCodec()
const graph = new SceneGraph()
graph.addCollection({
id: '4:55',
name: 'Theme',
modes: [
{ modeId: '4:1', name: 'Light' },
{ modeId: '4:2', name: 'Dark' }
],
defaultModeId: '4:1',
variableIds: []
})
graph.addVariable({
id: '5:1',
name: 'Background',
type: 'COLOR',
collectionId: '4:55',
valuesByMode: {
'4:1': { r: 1, g: 1, b: 1, a: 1 },
'4:2': { r: 0, g: 0, b: 0, a: 1 }
},
description: '',
hiddenFromPublishing: false
})
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Dark scope',
variableModes: { '4:55': '4:2' }
})
graph.createNode('RECTANGLE', frame.id, { name: 'Scoped child' })
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const importedFrame = expectDefined(
[...reimported.getAllNodes()].find((node) => node.name === 'Dark scope'),
'dark scope'
)
const importedChild = expectDefined(
[...reimported.getAllNodes()].find((node) => node.name === 'Scoped child'),
'scoped child'
)
const importedBackground = expectDefined(
[...reimported.variables.values()].find((variable) => variable.name === 'Background'),
'background variable'
)
const importedCollection = expectDefined(
reimported.variableCollections.get(importedBackground.collectionId),
'theme collection'
)
const importedDarkMode = expectDefined(
importedCollection.modes.find((mode) => mode.name === 'Dark'),
'dark mode'
)
expect(importedFrame.variableModes).toEqual({
[importedCollection.id]: importedDarkMode.modeId
})
expect(reimported.resolveColorVariableForNode(importedChild.id, importedBackground.id)).toEqual(
{
r: 0,
g: 0,
b: 0,
a: 1
}
)
})
test.if(runsHeavyTests)(
'material3.fig variables survive round-trip',
async () => {

View file

@ -0,0 +1,51 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph, type Fill } from '@open-pencil/scene-graph'
import { resolveFillColor } from '#core/canvas/renderer/colors'
const LIGHT = { r: 1, g: 1, b: 1, a: 1 }
const DARK = { r: 0.04, g: 0.04, b: 0.05, a: 1 }
const BOUND_FILL: Fill = {
type: 'SOLID',
color: LIGHT,
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
describe('node-scoped variable modes', () => {
test('renderer resolves paint bindings through the nearest mode scope', () => {
const graph = new SceneGraph()
graph.addCollection({
id: 'theme',
name: 'Theme',
modes: [
{ modeId: 'light', name: 'Light' },
{ modeId: 'dark', name: 'Dark' }
],
defaultModeId: 'light',
variableIds: []
})
graph.addVariable({
id: 'background',
name: 'Background',
type: 'COLOR',
collectionId: 'theme',
valuesByMode: { light: LIGHT, dark: DARK },
description: '',
hiddenFromPublishing: false
})
const page = graph.addPage('Page')
const darkFrame = graph.createNode('FRAME', page.id, {
variableModes: { theme: 'dark' }
})
const child = graph.createNode('RECTANGLE', darkFrame.id, {
fills: [BOUND_FILL],
boundVariables: { 'fills/0/color': 'background' }
})
expect(resolveFillColor(child.fills[0], 0, child, graph)).toEqual(DARK)
})
})

View file

@ -113,6 +113,52 @@ describe('Variables', () => {
expect(graph.resolveColorVariable('v1')).toEqual({ r: 0, g: 0, b: 0, a: 1 })
})
test('node variable modes inherit from the nearest ancestor', () => {
const graph = new SceneGraph()
graph.addCollection({
id: 'col1',
name: 'Theme',
modes: [
{ modeId: 'light', name: 'Light' },
{ modeId: 'dark', name: 'Dark' }
],
defaultModeId: 'light',
variableIds: []
})
graph.addVariable({
id: 'v1',
name: 'bg',
type: 'COLOR',
collectionId: 'col1',
valuesByMode: {
light: { r: 1, g: 1, b: 1, a: 1 },
dark: { r: 0, g: 0, b: 0, a: 1 }
},
description: '',
hiddenFromPublishing: false
})
const page = graph.addPage('Page')
const frame = graph.createNode('FRAME', page.id, { variableModes: { col1: 'dark' } })
const child = graph.createNode('RECTANGLE', frame.id)
const nestedOverride = graph.createNode('RECTANGLE', frame.id, {
variableModes: { col1: 'light' }
})
expect(graph.resolveColorVariableForNode(child.id, 'v1')).toEqual({
r: 0,
g: 0,
b: 0,
a: 1
})
expect(graph.resolveColorVariableForNode(nestedOverride.id, 'v1')).toEqual({
r: 1,
g: 1,
b: 1,
a: 1
})
})
test('missing active mode falls back to default value', () => {
const graph = new SceneGraph()
graph.addCollection({