fix(fig): write bound fields resolved when exporting .fig (#859)

Figma draws the value a bound paint or number stores until something makes
it resolve the variable again. OpenPencil resolves bound colours only when
drawing, so exports wrote the colour from before the binding, and a graph
bound without the editor kept stale numbers too: a file opened in Figma
showed bound fills in their old colour.

The exporter now writes each node with its bindings resolved, without
changing the document: colours as the variable's RGB at full alpha with
its alpha as the paint's opacity, which is how Figma stores them in every
fixture, and numbers in scene units. Nodes without an explicit mode take
the collection's default, because the mode the editor shows is not saved.

The resolution moves into scene-graph as resolvedPaintBindings and
resolvedNumericBindings, which the reader and reconcileNumericVariableBindings
now share, and the node resolvers take a fallback of 'active' or 'default'.
This commit is contained in:
Danila Poyarkov 2026-10-04 12:59:42 +04:00 committed by GitHub
parent 5146e7f7e5
commit 4939cd4982
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 189 additions and 66 deletions

View file

@ -46,6 +46,7 @@
### Fixed
- Show variable-bound colours and numbers correctly when a `.fig` exported from OpenPencil opens in Figma. Figma draws the value a bound field stores until something makes it resolve the variable again, and exports stored the colour from before the binding, so a bound fill appeared in its old colour. Each bound field is now written as it resolves in its layer's mode, or in the collection's default mode when the layer sets none.
- Open Figma files that use slots with each instance's own slot content instead of its component's default, keep slot properties, their settings, and instance content when saving back to `.fig`, and keep an instance's slot content when you switch its variant.
- Stop showing a “signal is aborted without reason” error when you switch pages again before the previous page has finished loading.
- Export layers with two shadows as one `effects` prop instead of repeating the `shadow` attribute, background blurs as `backgroundBlur` instead of a layer blur, hidden children with `visible={false}` instead of leaving them out, and per-corner radii even when the uniform radius is 0.

View file

@ -5,11 +5,13 @@ import { inflateSync, unzipSync } from 'fflate'
import { exportFigFile, initCodec, SceneGraph } from '@open-pencil/core'
import { parseFigKiwiContainer } from '@open-pencil/kiwi/fig/parse'
import { ByteBuffer, compileSchema, decodeBinarySchema } from '@open-pencil/kiwi/schema-runtime'
import type { GUID } from '@open-pencil/scene-graph/primitives'
import type { Color, GUID } from '@open-pencil/scene-graph/primitives'
import { expectDefined } from '#core-tests/helpers/assert'
interface DecodedPaint {
color?: Color
opacity?: number
colorVar?: {
value?: { alias?: { guid?: GUID } }
dataType?: string
@ -23,6 +25,7 @@ interface DecodedNodeChange {
type?: string
fillPaints?: DecodedPaint[]
strokePaints?: DecodedPaint[]
cornerRadius?: number
}
function decodeNodeChanges(fig: Uint8Array): DecodedNodeChange[] {
@ -92,4 +95,58 @@ describe('Figma export colour variable bindings', () => {
})
}
})
test('writes bound fields resolved for each node\'s mode, as Figma draws the stored value', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const theme = graph.createCollection('Theme')
const light = theme.defaultModeId
graph.addMode(theme.id, 'dark', 'Dark')
const surface = graph.createVariable('Surface', 'COLOR', theme.id, { r: 1, g: 1, b: 1, a: 1 })
surface.valuesByMode.dark = { r: 0.07, g: 0.09, b: 0.15, a: 0.8 }
const radius = graph.createVariable('Radius', 'FLOAT', theme.id, 12)
radius.valuesByMode.dark = 16
graph.setDefaultMode(theme.id, 'dark')
// Stale literals, as a graph bound without the editor keeps them.
const stale = { r: 1, g: 0, b: 0, a: 1 }
const card = (name: string, variableModes: Record<string, string> = {}) => {
const node = graph.createNode('FRAME', page.id, {
name,
width: 40,
height: 40,
cornerRadius: 0,
variableModes,
fills: [{ type: 'SOLID', color: stale, opacity: 1, visible: true }]
})
graph.bindVariable(node.id, 'fills/0/color', surface.id)
graph.bindVariable(node.id, 'cornerRadius', radius.id)
return node
}
const dark = card('Default mode')
card('Light mode', { [theme.id]: light })
const nodeChanges = decodeNodeChanges(new Uint8Array(await exportFigFile(graph)))
const exported = (name: string) =>
expectDefined(
nodeChanges.find((change) => change.name === name),
name
)
const defaultCard = exported('Default mode')
expect(defaultCard.cornerRadius).toBe(16)
expect(defaultCard.fillPaints?.[0]?.color).toEqual({
r: expect.closeTo(0.07, 5),
g: expect.closeTo(0.09, 5),
b: expect.closeTo(0.15, 5),
a: 1
})
expect(defaultCard.fillPaints?.[0]?.opacity).toBeCloseTo(0.8, 5)
const lightCard = exported('Light mode')
expect(lightCard.cornerRadius).toBe(12)
expect(lightCard.fillPaints?.[0]?.color).toEqual({ r: 1, g: 1, b: 1, a: 1 })
expect(lightCard.fillPaints?.[0]?.opacity).toBe(1)
// Export reads the document; it does not resolve bindings into it.
expect(graph.getNode(dark.id)?.fills[0]?.color).toEqual(stale)
expect(graph.getNode(dark.id)?.cornerRadius).toBe(0)
})
})

View file

@ -1,21 +1,4 @@
import {
copyFills,
copyStrokes,
type Color,
type Fill,
type SceneGraph,
type SceneNode
} from '@open-pencil/scene-graph'
/**
* A bound paint takes its whole color from the variable, alpha included. Drawing reads
* that alpha from the paint's opacity rather than the color, so the variable owns both
* and any opacity the record carried is a stale literal Figma would not apply either.
*/
function applyBoundColor(paint: Pick<Fill, 'color' | 'opacity'>, color: Color): void {
paint.color = { ...color, a: 1 }
paint.opacity = color.a
}
import { resolvedPaintBindings, type SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
/** Resolve bound paint colors only after occurrence hierarchy and modes are available. */
export function applyDocumentPaintBindings(
@ -23,27 +6,7 @@ export function applyDocumentPaintBindings(
materialized: readonly SceneNode[]
): void {
for (const node of materialized) {
// Most nodes bind nothing, so neither the entry array nor the change object is built
// until a paint binding is actually found.
let changes: Partial<SceneNode> | undefined
for (const field in node.boundVariables) {
if (!field.endsWith('/color')) continue
const match = /^(fills|strokes)\/(\d+)\/color$/.exec(field)
if (!match) continue
const id = node.boundVariables[field]
const kind = match[1] === 'fills' ? 'fills' : 'strokes'
const index = Number(match[2])
const color = graph.resolveColorVariableForNode(node.id, id)
if (!color || !node[kind][index]) continue
changes ??= {}
if (kind === 'fills') {
changes.fills ??= copyFills(node.fills)
applyBoundColor(changes.fills[index], color)
} else {
changes.strokes ??= copyStrokes(node.strokes)
applyBoundColor(changes.strokes[index], color)
}
}
if (changes) graph.updateNode(node.id, changes)
const changes = resolvedPaintBindings(graph, node)
if (changes.fills || changes.strokes) graph.updateNode(node.id, changes)
}
}

View file

@ -40,6 +40,7 @@ import {
type SceneNodeToKiwiContext
} from './context'
import { mergeOverrides, serializeRuntimePropertyOverrides } from './override-claims'
import { nodeWithResolvedBindings } from './resolved-bindings'
import { slotContentAssignment, slotDefinitionFields } from './slots'
export type { KiwiNodeChange, SceneNodeToKiwiContext } from './context'
@ -883,12 +884,13 @@ function exportKiwiNodeType(node: SceneNode, context: SceneNodeToKiwiContext): s
}
export function sceneNodeToKiwiWithContext(
node: SceneNode,
source: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
): KiwiNodeChange[] {
const node = nodeWithResolvedBindings(context.graph, source)
const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? {
sessionID: 1,
localID: localIdCounter.value++

View file

@ -17,6 +17,7 @@ import {
type SceneNodeToKiwiContext,
type StyleReference
} from './context'
import { nodeWithResolvedBindings } from './resolved-bindings'
function exportedTextStyleReference(context: SceneNodeToKiwiContext, id: string): StyleReference {
context.styleReferences ??= buildStyleReferences(context.graph)
@ -138,11 +139,15 @@ function bindingClaim(
}
function overrideClaim(
input: ClaimInput,
claimed: ClaimInput,
field: string,
path: GUID[],
counter: { value: number }
): KiwiSymbolOverridePayload | undefined {
const input = {
...claimed,
target: nodeWithResolvedBindings(claimed.context.graph, claimed.target)
}
if (field === 'componentId')
return exportedSwapOverride(input.context, input.target, path, counter)
const claim = field.startsWith('boundVariables/')

View file

@ -0,0 +1,21 @@
import {
resolvedNumericBindings,
resolvedPaintBindings,
type SceneGraph,
type SceneNode
} from '@open-pencil/scene-graph'
/**
* Figma draws the value a bound field stores until something makes it resolve the binding
* again, so each node is written with its bindings resolved for its mode. The editor resolves
* colors only when drawing, and a graph bound without the editor keeps stale numbers too.
* A node without an explicit mode takes its collection's default: the mode the editor happens
* to show is not saved, and Figma opens the file in the default.
*/
export function nodeWithResolvedBindings(graph: SceneGraph, node: SceneNode): SceneNode {
const changes = {
...resolvedNumericBindings(graph, node, 'default'),
...resolvedPaintBindings(graph, node, 'default')
}
return Object.keys(changes).length > 0 ? { ...node, ...changes } : node
}

View file

@ -1,6 +1,7 @@
/* eslint-disable max-lines -- SceneGraph exposes a stable facade over domain modules */
export * from './mutation-impact'
export * from './variables/bindings'
export type { VariableModeFallback } from './variables'
export { modesDefaultFirst } from './variables'
export { rescaleNodeTree, scaleNodeChanges } from './scaling'
import { TRANSFORM_FIELDS, SIZE_FIELDS } from './fields/geometry'
@ -58,6 +59,7 @@ import { styleDetachmentChanges } from './shared-styles'
import { markSourceFieldsEdited } from './source-metadata'
import { GLYPH_AFFECTING_KEYS, invalidateTextCaches, TEXT_PICTURE_KEYS } from './text-picture'
import * as Variables from './variables'
import type { VariableModeFallback } from './variables'
import { normalizeVectorNetwork } from './vector-network'
export type { GUID, Color, Size, Vector } from './primitives'
@ -252,12 +254,20 @@ export class SceneGraph {
return Variables.resolveNumberVariable(this, variableId)
}
resolveColorVariableForNode(nodeId: string, variableId: string): Color | undefined {
return Variables.resolveColorVariableForNode(this, nodeId, variableId)
resolveColorVariableForNode(
nodeId: string,
variableId: string,
fallback?: VariableModeFallback
): Color | undefined {
return Variables.resolveColorVariableForNode(this, nodeId, variableId, fallback)
}
resolveNumberVariableForNode(nodeId: string, variableId: string): number | undefined {
return Variables.resolveNumberVariableForNode(this, nodeId, variableId)
resolveNumberVariableForNode(
nodeId: string,
variableId: string,
fallback?: VariableModeFallback
): number | undefined {
return Variables.resolveNumberVariableForNode(this, nodeId, variableId, fallback)
}
resolveStringVariableForNode(nodeId: string, variableId: string): string | undefined {

View file

@ -135,10 +135,17 @@ export function getActiveModeId(graph: SceneGraph, collectionId: string): string
return collection?.defaultModeId ?? ''
}
/**
* What a node without an explicit mode falls back to: the mode the editor shows (`active`), or
* the collection's default (`default`), which is what a saved document means to other tools.
*/
export type VariableModeFallback = 'active' | 'default'
export function getNodeVariableModeId(
graph: SceneGraph,
nodeId: string,
collectionId: string
collectionId: string,
fallback: VariableModeFallback = 'active'
): string {
let node = graph.nodes.get(nodeId)
while (node) {
@ -146,6 +153,8 @@ export function getNodeVariableModeId(
if (modeId) return modeId
node = node.parentId ? graph.nodes.get(node.parentId) : undefined
}
if (fallback === 'default')
return graph.variableCollections.get(collectionId)?.defaultModeId ?? ''
return getActiveModeId(graph, collectionId)
}
@ -260,11 +269,12 @@ export function resolveNumberVariable(graph: SceneGraph, variableId: string): nu
export function resolveColorVariableForNode(
graph: SceneGraph,
nodeId: string,
variableId: string
variableId: string,
fallback: VariableModeFallback = 'active'
): Color | undefined {
const variable = graph.variables.get(variableId)
if (!variable) return undefined
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId)
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId, fallback)
const value = resolveVariable(graph, variableId, modeId)
if (value && typeof value === 'object' && 'r' in value) return value
return undefined
@ -273,11 +283,12 @@ export function resolveColorVariableForNode(
export function resolveNumberVariableForNode(
graph: SceneGraph,
nodeId: string,
variableId: string
variableId: string,
fallback: VariableModeFallback = 'active'
): number | undefined {
const variable = graph.variables.get(variableId)
if (!variable) return undefined
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId)
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId, fallback)
const value = resolveVariable(graph, variableId, modeId)
return typeof value === 'number' ? value : undefined
}

View file

@ -1,6 +1,9 @@
import { copyFills, copyStrokes } from '../copy'
import type { SceneGraph } from '../index'
import { hasInstanceOverride } from '../instances'
import type { SceneNode } from '../types'
import type { Color } from '../primitives'
import type { Fill, SceneNode, Stroke } from '../types'
import type { VariableModeFallback } from '../variables'
import { NUMERIC_FIELDS, isNumericVariableBindingField } from './fields'
export { isNumericVariableBindingField } from './fields'
@ -29,23 +32,73 @@ export function assignVariableBindingUnits(
}
}
/**
* The values a node's numeric bindings resolve to in its mode, in scene units, for the fields
* whose stored value differs. A width or height an instance overrides keeps its own value.
*/
export function resolvedNumericBindings(
graph: SceneGraph,
node: SceneNode,
fallback: VariableModeFallback = 'active'
): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
for (const [field, variableId] of Object.entries(node.boundVariables)) {
if (!NUMERIC_FIELDS.has(field)) continue
if ((field === 'width' || field === 'height') && hasInstanceOverride(graph, node.id, field))
continue
const value = graph.resolveNumberVariableForNode(node.id, variableId, fallback)
if (value === undefined || !Number.isFinite(value)) continue
const scale = node.variableBindingScales[field] ?? 1
const effective = value * scale
if (!Number.isFinite(effective)) continue
const next = field === 'opacity' ? Math.max(0, Math.min(1, effective)) : effective
if (node[field as keyof SceneNode] !== next) Object.assign(updates, { [field]: next })
}
return updates
}
/**
* A bound paint takes its whole color from the variable, alpha included, the way Figma stores
* it: the variable's RGB at full alpha, and its alpha as the paint's opacity.
*/
function applyBoundColor(paint: Fill | Stroke, color: Color): void {
paint.color = { ...color, a: 1 }
paint.opacity = color.a
}
/** The fills and strokes a node's color bindings resolve to in its mode, when it has any. */
export function resolvedPaintBindings(
graph: SceneGraph,
node: SceneNode,
fallback: VariableModeFallback = 'active'
): Pick<Partial<SceneNode>, 'fills' | 'strokes'> {
// Most nodes bind nothing, so no copy is made until a paint binding is actually found.
const changes: Pick<Partial<SceneNode>, 'fills' | 'strokes'> = {}
for (const field in node.boundVariables) {
if (!field.endsWith('/color')) continue
const match = /^(fills|strokes)\/(\d+)\/color$/.exec(field)
if (!match) continue
const color = graph.resolveColorVariableForNode(node.id, node.boundVariables[field], fallback)
const index = Number(match[2])
if (!color) continue
if (match[1] === 'fills') {
if (!node.fills[index]) continue
changes.fills ??= copyFills(node.fills)
applyBoundColor(changes.fills[index], color)
} else {
if (!node.strokes[index]) continue
changes.strokes ??= copyStrokes(node.strokes)
applyBoundColor(changes.strokes[index], color)
}
}
return changes
}
/** Resolve live numeric bindings in scene units, without authoring instance overrides. */
export function reconcileNumericVariableBindings(graph: SceneGraph): string[] {
const changed: string[] = []
for (const node of graph.getAllNodes()) {
const updates: Partial<SceneNode> = {}
for (const [field, variableId] of Object.entries(node.boundVariables)) {
if (!NUMERIC_FIELDS.has(field)) continue
if ((field === 'width' || field === 'height') && hasInstanceOverride(graph, node.id, field))
continue
const value = graph.resolveNumberVariableForNode(node.id, variableId)
if (value === undefined || !Number.isFinite(value)) continue
const scale = node.variableBindingScales[field] ?? 1
const effective = value * scale
if (!Number.isFinite(effective)) continue
const next = field === 'opacity' ? Math.max(0, Math.min(1, effective)) : effective
if (node[field as keyof SceneNode] !== next) Object.assign(updates, { [field]: next })
}
const updates = resolvedNumericBindings(graph, node)
if (Object.keys(updates).length === 0) continue
graph.updateNode(node.id, updates)
changed.push(node.id)