From e704343f1381867da58889bf53a9c465b6d6b3a0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 21 Jul 2026 00:23:00 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + packages/core/src/canvas/renderer/colors.ts | 4 +- packages/core/src/io/formats/fig/export.ts | 7 +- .../src/kiwi/fig/node-change/serialize.ts | 6 +- packages/fig/src/node-change/convert.ts | 23 +++++- packages/fig/src/node-change/export-node.ts | 21 +++++ packages/fig/src/node-change/plugin-data.ts | 12 +-- packages/fig/src/node-change/serialize.ts | 4 +- packages/scene-graph/src/copy.ts | 2 + packages/scene-graph/src/index.ts | 13 ++++ packages/scene-graph/src/instances.ts | 5 +- packages/scene-graph/src/node-defaults.ts | 1 + packages/scene-graph/src/types.ts | 2 + packages/scene-graph/src/variables.ts | 39 ++++++++++ .../io/fig/import/legacy/variables.test.ts | 76 +++++++++++++++++++ .../engine/io/fig/roundtrip/variables.test.ts | 69 +++++++++++++++++ .../render/canvas/variable-modes.test.ts | 51 +++++++++++++ .../scene-graph/basic/variables.test.ts | 46 +++++++++++ 18 files changed, 367 insertions(+), 15 deletions(-) create mode 100644 tests/engine/render/canvas/variable-modes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f527b4086..20bf87c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/canvas/renderer/colors.ts b/packages/core/src/canvas/renderer/colors.ts index a21b17e54..62521d156 100644 --- a/packages/core/src/canvas/renderer/colors.ts +++ b/packages/core/src/canvas/renderer/colors.ts @@ -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, { diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index 0abd83129..8b0643573 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -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 ) ) } diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index 2789d53f5..6e97ee40b 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -37,7 +37,8 @@ export function sceneNodeToKiwi( glyphBlobMap = new Map(), blobIndexByHex?: Map, assignedGuidValues?: Set, - componentPropertyDefinitionsById?: ReadonlyMap + componentPropertyDefinitionsById?: ReadonlyMap, + modeIdToGuid?: Map ): KiwiNodeChange[] { return sceneNodeToKiwiWithRuntime( node, @@ -53,6 +54,7 @@ export function sceneNodeToKiwi( blobIndexByHex, assignedGuidValues, coreFigExportRuntime, - componentPropertyDefinitionsById + componentPropertyDefinitionsById, + modeIdToGuid ) } diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts index 34a53edf2..6c1323e1e 100644 --- a/packages/fig/src/node-change/convert.ts +++ b/packages/fig/src/node-change/convert.ts @@ -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 = 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 = { 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), diff --git a/packages/fig/src/node-change/export-node.ts b/packages/fig/src/node-change/export-node.ts index 8096fb7e7..f003a98b8 100644 --- a/packages/fig/src/node-change/export-node.ts +++ b/packages/fig/src/node-change/export-node.ts @@ -60,6 +60,7 @@ interface SceneNodeToKiwiContext { fontDigestMap?: Map glyphBlobMap?: Map varIdToGuid?: Map + modeIdToGuid?: Map /** 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 @@ -147,6 +148,20 @@ function parseGuidOrNull(value: string) { return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null } +function serializeVariableModes( + node: SceneNode, + variableIdToGuid?: Map, + modeIdToGuid?: Map +): NonNullable | 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) diff --git a/packages/fig/src/node-change/plugin-data.ts b/packages/fig/src/node-change/plugin-data.ts index 1d4e70a20..74e39b664 100644 --- a/packages/fig/src/node-change/plugin-data.ts +++ b/packages/fig/src/node-change/plugin-data.ts @@ -77,14 +77,14 @@ export function extractBoundVariables(nc: NodeChange): Record { 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 } diff --git a/packages/fig/src/node-change/serialize.ts b/packages/fig/src/node-change/serialize.ts index a165b0d4a..4950dbdcf 100644 --- a/packages/fig/src/node-change/serialize.ts +++ b/packages/fig/src/node-change/serialize.ts @@ -472,7 +472,8 @@ export function sceneNodeToKiwi( blobIndexByHex?: Map, assignedGuidValues?: Set, runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME, - componentPropertyDefinitionsById = buildComponentPropIndex(graph) + componentPropertyDefinitionsById = buildComponentPropIndex(graph), + modeIdToGuid?: Map ): 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, diff --git a/packages/scene-graph/src/copy.ts b/packages/scene-graph/src/copy.ts index 71613c7f1..2109c5173 100644 --- a/packages/scene-graph/src/copy.ts +++ b/packages/scene-graph/src/copy.ts @@ -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))), diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 48aea3c4d..5d94cc23f 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -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) } diff --git a/packages/scene-graph/src/instances.ts b/packages/scene-graph/src/instances.ts index 4bf4bd854..d29665ac8 100644 --- a/packages/scene-graph/src/instances.ts +++ b/packages/scene-graph/src/instances.ts @@ -41,7 +41,8 @@ const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ 'borderRightWeight', 'borderBottomWeight', 'borderLeftWeight', - 'boundVariables' + 'boundVariables', + 'variableModes' ] function setSceneProp( @@ -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) diff --git a/packages/scene-graph/src/node-defaults.ts b/packages/scene-graph/src/node-defaults.ts index 31975e7c8..f5764b967 100644 --- a/packages/scene-graph/src/node-defaults.ts +++ b/packages/scene-graph/src/node-defaults.ts @@ -155,6 +155,7 @@ export function createDefaultNode( symbolLinks: [], variantPropSpecs: [], boundVariables: {}, + variableModes: {}, exportSettings: [], pluginData: [], pluginRelaunchData: [], diff --git a/packages/scene-graph/src/types.ts b/packages/scene-graph/src/types.ts index 80930e466..0fbe33f8e 100644 --- a/packages/scene-graph/src/types.ts +++ b/packages/scene-graph/src/types.ts @@ -499,6 +499,7 @@ export interface SceneNode { variantPropSpecs: VariantPropSpec[] boundVariables: Record + 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 export interface Variable { id: string diff --git a/packages/scene-graph/src/variables.ts b/packages/scene-graph/src/variables.ts index 4d62e9540..6ba4134a9 100644 --- a/packages/scene-graph/src/variables.ts +++ b/packages/scene-graph/src/variables.ts @@ -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 [] diff --git a/tests/engine/io/fig/import/legacy/variables.test.ts b/tests/engine/io/fig/import/legacy/variables.test.ts index 076e439d3..509587af6 100644 --- a/tests/engine/io/fig/import/legacy/variables.test.ts +++ b/tests/engine/io/fig/import/legacy/variables.test.ts @@ -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(), diff --git a/tests/engine/io/fig/roundtrip/variables.test.ts b/tests/engine/io/fig/roundtrip/variables.test.ts index 33fcd9c79..e41f8c003 100644 --- a/tests/engine/io/fig/roundtrip/variables.test.ts +++ b/tests/engine/io/fig/roundtrip/variables.test.ts @@ -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 () => { diff --git a/tests/engine/render/canvas/variable-modes.test.ts b/tests/engine/render/canvas/variable-modes.test.ts new file mode 100644 index 000000000..0583e8d91 --- /dev/null +++ b/tests/engine/render/canvas/variable-modes.test.ts @@ -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) + }) +}) diff --git a/tests/engine/scene-graph/basic/variables.test.ts b/tests/engine/scene-graph/basic/variables.test.ts index d5520b57a..cee455cf1 100644 --- a/tests/engine/scene-graph/basic/variables.test.ts +++ b/tests/engine/scene-graph/basic/variables.test.ts @@ -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({