diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0c006ae..f64472c60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes +- Serialize variables, collections, and bindings to `.fig` files — previously lost on save (#65) - Double-click on layer tree no longer toggles expand/collapse — use the chevron instead - Page rename input matches layer rename styling diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index f61112724..6b505c256 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -3,11 +3,12 @@ import { zipSync, deflateSync } from 'fflate' import { CANVAS_BG_COLOR, IS_TAURI } from './constants' import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi } from './kiwi-serialize' import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec' +import { stringToGuid } from './kiwi/kiwi-convert' import { renderThumbnail } from './render-image' import type { NodeChange } from './kiwi/codec' import type { SkiaRenderer } from './renderer' -import type { SceneGraph } from './scene-graph' +import type { SceneGraph, VariableValue } from './scene-graph' import type { CanvasKit } from 'canvaskit-wasm' const THUMBNAIL_1X1 = Uint8Array.from( @@ -19,6 +20,33 @@ const THUMBNAIL_1X1 = Uint8Array.from( type KiwiNodeChange = NodeChange & Record +function variableValueToKiwi( + value: VariableValue, + type: string +): { value: Record; dataType: string; resolvedDataType: string } { + if (typeof value === 'object' && value !== null && 'aliasId' in value) { + return { + value: { alias: { guid: stringToGuid(value.aliasId) } }, + dataType: 'ALIAS', + resolvedDataType: type === 'COLOR' ? 'COLOR' : type === 'BOOLEAN' ? 'BOOLEAN' : type === 'STRING' ? 'STRING' : 'FLOAT' + } + } + if (type === 'COLOR' && typeof value === 'object' && value !== null && 'r' in value) { + return { + value: { colorValue: { r: value.r, g: value.g, b: value.b, a: value.a } }, + dataType: 'COLOR', + resolvedDataType: 'COLOR' + } + } + if (type === 'BOOLEAN') { + return { value: { boolValue: !!value }, dataType: 'BOOLEAN', resolvedDataType: 'BOOLEAN' } + } + if (type === 'STRING') { + return { value: { textValue: String(value) }, dataType: 'STRING', resolvedDataType: 'STRING' } + } + return { value: { floatValue: Number(value) }, dataType: 'FLOAT', resolvedDataType: 'FLOAT' } +} + const THUMBNAIL_WIDTH = 400 const THUMBNAIL_HEIGHT = 225 @@ -53,12 +81,16 @@ export async function exportFigFile( const blobs: Uint8Array[] = [] const pages = graph.getPages(true) + const nodeIdToGuid = new Map() + let internalCanvasGuid: { sessionID: number; localID: number } | null = null for (let p = 0; p < pages.length; p++) { const page = pages[p] const canvasLocalID = localIdCounter.value++ const canvasGuid = { sessionID: 0, localID: canvasLocalID } + if (page.internalOnly) internalCanvasGuid = canvasGuid + const canvasNc: KiwiNodeChange = { guid: canvasGuid, parentIndex: { guid: docGuid, position: fractionalPosition(p) }, @@ -80,7 +112,86 @@ export async function exportFigFile( const children = graph.getChildren(page.id) for (let i = 0; i < children.length; i++) { - nodeChanges.push(...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs)) + nodeChanges.push( + ...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs, nodeIdToGuid) + ) + } + } + + if (graph.variableCollections.size > 0) { + if (!internalCanvasGuid) { + const internalLocalID = localIdCounter.value++ + internalCanvasGuid = { sessionID: 0, localID: internalLocalID } + nodeChanges.push({ + guid: internalCanvasGuid, + parentIndex: { guid: docGuid, position: fractionalPosition(pages.length) }, + type: 'CANVAS', + name: 'Internal Only Canvas', + visible: true, + opacity: 1, + phase: 'CREATED', + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + strokeWeight: 1, + strokeAlign: 'CENTER', + strokeJoin: 'MITER', + internalOnly: true + }) + } + + let collIdx = 0 + for (const [colId, col] of graph.variableCollections) { + const colGuid = stringToGuid(colId) + const colNc: KiwiNodeChange = { + guid: colGuid, + parentIndex: { guid: internalCanvasGuid, position: fractionalPosition(collIdx++) }, + type: 'VARIABLE_SET', + name: col.name, + phase: 'CREATED', + strokeAlign: 'CENTER', + strokeJoin: 'BEVEL', + variableSetModes: col.modes.map((m, i) => ({ + id: stringToGuid(m.modeId), + name: m.name, + sortPosition: fractionalPosition(i) + })) + } + nodeChanges.push(colNc) + + let varIdx = 0 + for (const varId of col.variableIds) { + const variable = graph.variables.get(varId) + if (!variable) continue + + const varGuid = stringToGuid(varId) + const resolvedType = + variable.type === 'COLOR' + ? 'COLOR' + : variable.type === 'BOOLEAN' + ? 'BOOLEAN' + : variable.type === 'STRING' + ? 'STRING' + : 'FLOAT' + + const entries = Object.entries(variable.valuesByMode).map(([modeId, value]) => ({ + modeID: stringToGuid(modeId), + variableData: variableValueToKiwi(value, variable.type) + })) + + const varNc: KiwiNodeChange = { + guid: varGuid, + parentIndex: { guid: internalCanvasGuid, position: fractionalPosition(varIdx++) }, + type: 'VARIABLE', + name: variable.name, + phase: 'CREATED', + strokeAlign: 'CENTER', + strokeJoin: 'BEVEL', + variableSetID: { guid: colGuid }, + variableResolvedType: resolvedType, + variableDataValues: { entries }, + variableScopes: ['ALL_SCOPES'] + } + nodeChanges.push(varNc) + } } } diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index a1a6ed733..73ca0bec3 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -4,8 +4,9 @@ import { deflateSync, inflateSync } from 'fflate' import { weightToStyle } from './fonts' import { encodeVectorNetworkBlob } from './vector' +import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert' -import type { NodeChange, Paint } from './kiwi/codec' +import type { NodeChange, Paint, VariableConsumptionEntry } from './kiwi/codec' import type { SceneGraph, SceneNode, CharacterStyleOverride } from './scene-graph' type KiwiNodeChange = NodeChange & Record @@ -166,10 +167,12 @@ export function sceneNodeToKiwi( childIndex: number, localIdCounter: { value: number }, graph: SceneGraph, - blobs: Uint8Array[] + blobs: Uint8Array[], + nodeIdToGuid?: Map ): KiwiNodeChange[] { const localID = localIdCounter.value++ const guid = { sessionID: 1, localID } + nodeIdToGuid?.set(node.id, guid) const sx = node.flipX ? -1 : 1 const cos = Math.cos((node.rotation * Math.PI) / 180) const sin = Math.sin((node.rotation * Math.PI) / 180) @@ -321,10 +324,31 @@ export function sceneNodeToKiwi( }) } + if (Object.keys(node.boundVariables).length > 0) { + const entries: VariableConsumptionEntry[] = [] + for (const [field, varId] of Object.entries(node.boundVariables)) { + const kiwiField = VARIABLE_BINDING_FIELDS[field] + if (!kiwiField) continue + const variable = graph.variables.get(varId) + if (!variable) continue + const varGuid = stringToGuid(varId) + const resolvedType = variable.type === 'COLOR' ? 'COLOR' : variable.type === 'BOOLEAN' ? 'BOOLEAN' : variable.type === 'STRING' ? 'STRING' : 'FLOAT' + entries.push({ + variableData: { + value: { alias: { guid: varGuid } }, + dataType: 'ALIAS', + resolvedDataType: resolvedType + }, + variableField: kiwiField + }) + } + if (entries.length > 0) nc.variableConsumptionMap = { entries } + } + const result: KiwiNodeChange[] = [nc] const children = graph.getChildren(node.id) for (let i = 0; i < children.length; i++) { - result.push(...sceneNodeToKiwi(children[i], guid, i, localIdCounter, graph, blobs)) + result.push(...sceneNodeToKiwi(children[i], guid, i, localIdCounter, graph, blobs, nodeIdToGuid)) } return result diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index 6d3d241bb..55a9c8c90 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -209,6 +209,31 @@ export interface Effect { blendMode?: string } +export interface VariableAnyValue { + boolValue?: boolean + textValue?: string + floatValue?: number + colorValue?: Color + alias?: { guid: GUID } +} + +export interface VariableDataEntry { + value?: VariableAnyValue + dataType?: string + resolvedDataType?: string +} + +export interface VariableConsumptionEntry { + nodeField?: number + variableData?: VariableDataEntry + variableField?: string +} + +export interface VariableDataValuesEntry { + modeID: GUID + variableData: VariableDataEntry +} + export interface NodeChange { [key: string]: unknown guid: GUID @@ -289,6 +314,14 @@ export interface NodeChange { // Constraints horizontalConstraint?: string verticalConstraint?: string + // Variables + variableData?: VariableDataEntry + variableConsumptionMap?: { entries?: VariableConsumptionEntry[] } + variableSetModes?: Array<{ id: GUID; name: string; sortPosition?: string }> + variableSetID?: { guid: GUID } + variableResolvedType?: string + variableDataValues?: { entries?: VariableDataValuesEntry[] } + variableScopes?: string[] } export interface FigmaMessage { diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index 0bb7e34ab..5055f4891 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -1,7 +1,12 @@ import type { VariableType, VariableValue } from '../scene-graph' import { SceneGraph } from '../scene-graph' -import { guidToString, nodeChangeToProps, sortChildren } from './kiwi-convert' +import { + guidToString, + nodeChangeToProps, + sortChildren, + VARIABLE_BINDING_FIELDS_INVERSE +} from './kiwi-convert' import { populateAndApplyOverrides } from './instance-overrides' import type { InstanceNodeChange } from './instance-overrides' @@ -67,7 +72,7 @@ export function importNodeChanges( if (!nc) return const { nodeType, ...props } = nodeChangeToProps(nc, blobs) - if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') return + if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return const node = graph.createNode(nodeType, graphParentId, props) guidToNodeId.set(ncId, node.id) @@ -79,26 +84,33 @@ export function importNodeChanges( function importVariables() { for (const [id, nc] of changeMap) { - if (nc.type !== 'VARIABLE') continue - const varData = ( - nc as unknown as { - variableData?: { - value?: { boolValue?: boolean; textValue?: string; floatValue?: number } - dataType?: string - } - } - ).variableData - if (!varData) continue + if (nc.type !== 'VARIABLE_SET') continue - const parentId = parentMap.get(id) ?? '' - const parentNc = changeMap.get(parentId) - const collectionName = parentNc?.name ?? 'Variables' - const collectionId = parentId + const modes = (nc.variableSetModes ?? []).map((m) => { + const modeId = m.id ? guidToString(m.id) : 'default' + return { modeId, name: m.name ?? 'Mode' } + }) + if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' }) + + graph.addCollection({ + id, + name: nc.name ?? 'Variables', + modes, + defaultModeId: modes[0].modeId, + variableIds: [] + }) + } + + for (const [id, nc] of changeMap) { + if (nc.type !== 'VARIABLE') continue + + const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '') if (!graph.variableCollections.has(collectionId)) { + const parentNc = changeMap.get(collectionId) graph.addCollection({ id: collectionId, - name: collectionName, + name: parentNc?.name ?? 'Variables', modes: [{ modeId: 'default', name: 'Default' }], defaultModeId: 'default', variableIds: [] @@ -106,19 +118,39 @@ export function importNodeChanges( } let type: VariableType = 'FLOAT' - let value: VariableValue = 0 - const dt = varData.dataType - const v = varData.value + const resolvedType = nc.variableResolvedType + if (resolvedType === 'COLOR') type = 'COLOR' + else if (resolvedType === 'BOOLEAN') type = 'BOOLEAN' + else if (resolvedType === 'STRING') type = 'STRING' - if (dt === 'BOOLEAN' || dt === '0') { - type = 'BOOLEAN' - value = v?.boolValue ?? false - } else if (dt === 'STRING' || dt === '2') { - type = 'STRING' - value = v?.textValue ?? '' - } else { - type = 'FLOAT' - value = v?.floatValue ?? 0 + const valuesByMode: Record = {} + + if (nc.variableDataValues?.entries) { + for (const entry of nc.variableDataValues.entries) { + const modeId = guidToString(entry.modeID) + const vd = entry.variableData + if (!vd.value) continue + + const dt = vd.dataType ?? vd.resolvedDataType + if (dt === 'COLOR' && vd.value.colorValue) { + const c = vd.value.colorValue + valuesByMode[modeId] = { r: c.r, g: c.g, b: c.b, a: c.a } + } else if (dt === 'BOOLEAN') { + valuesByMode[modeId] = vd.value.boolValue ?? false + } else if (dt === 'STRING') { + valuesByMode[modeId] = vd.value.textValue ?? '' + } else if (dt === 'ALIAS' && vd.value.alias?.guid) { + valuesByMode[modeId] = { aliasId: guidToString(vd.value.alias.guid) } + } else { + valuesByMode[modeId] = vd.value.floatValue ?? 0 + } + } + } + + if (Object.keys(valuesByMode).length === 0) { + const col = graph.variableCollections.get(collectionId) + const defaultMode = col?.defaultModeId ?? 'default' + valuesByMode[defaultMode] = type === 'BOOLEAN' ? false : type === 'STRING' ? '' : type === 'COLOR' ? { r: 0, g: 0, b: 0, a: 1 } : 0 } graph.addVariable({ @@ -126,13 +158,29 @@ export function importNodeChanges( name: nc.name ?? 'Variable', type, collectionId, - valuesByMode: { default: value }, + valuesByMode, description: '', hiddenFromPublishing: false }) } } + function importVariableBindings() { + for (const [ncId, nc] of changeMap) { + if (!nc.variableConsumptionMap?.entries?.length) continue + + const nodeId = guidToNodeId.get(ncId) + if (!nodeId) continue + + for (const entry of nc.variableConsumptionMap.entries) { + const varGuid = entry.variableData?.value?.alias?.guid + if (!varGuid) continue + const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? ''] + if (field) graph.bindVariable(nodeId, field, guidToString(varGuid)) + } + } + } + // Find the document node (type=DOCUMENT or guid 0:0) let docId: string | null = null for (const [id, nc] of changeMap) { @@ -172,6 +220,7 @@ export function importNodeChanges( } importVariables() + importVariableBindings() // Remap componentId from original Figma GUIDs to imported node IDs for (const node of graph.getAllNodes()) { diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 519463920..bb312c9c8 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -39,6 +39,37 @@ export function guidToString(guid: GUID): string { return `${guid.sessionID}:${guid.localID}` } +export function stringToGuid(str: string): GUID { + const [session, local] = str.split(':') + return { sessionID: parseInt(session, 10), localID: parseInt(local, 10) } +} + +export const VARIABLE_BINDING_FIELDS: Record = { + cornerRadius: 'CORNER_RADIUS', + topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS', + topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS', + bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS', + bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS', + strokeWeight: 'STROKE_WEIGHT', + itemSpacing: 'STACK_SPACING', + paddingLeft: 'STACK_PADDING_LEFT', + paddingTop: 'STACK_PADDING_TOP', + paddingRight: 'STACK_PADDING_RIGHT', + paddingBottom: 'STACK_PADDING_BOTTOM', + counterAxisSpacing: 'STACK_COUNTER_SPACING', + visible: 'VISIBLE', + opacity: 'OPACITY', + width: 'WIDTH', + height: 'HEIGHT', + fontSize: 'FONT_SIZE', + letterSpacing: 'LETTER_SPACING', + lineHeight: 'LINE_HEIGHT' +} + +export const VARIABLE_BINDING_FIELDS_INVERSE: Record = Object.fromEntries( + Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k]) +) + const convertColor = normalizeColor function imageHashToString(hash: Record): string { diff --git a/packages/core/src/kiwi/schema.ts b/packages/core/src/kiwi/schema.ts index 84e807347..d1b9558c1 100644 --- a/packages/core/src/kiwi/schema.ts +++ b/packages/core/src/kiwi/schema.ts @@ -71,6 +71,7 @@ enum NodeType { SECTION_OVERLAY = 26; WASHI_TAPE = 27; VARIABLE = 28; + VARIABLE_SET = 29; } enum ShapeWithTextType { @@ -1480,6 +1481,12 @@ message NodeChange { string accessibleLabel = 304; bool propsAreBubbled = 305; VariableData variableData = 306; + VariableDataMap variableConsumptionMap = 307; + VariableSetMode[] variableSetModes = 312; + VariableSetID variableSetID = 313; + VariableResolvedDataType variableResolvedType = 314; + VariableDataValues variableDataValues = 315; + VariableScope[] variableScopes = 353; uint gridRowCount = 435; uint gridColumnCount = 436; float gridRowGap = 437; @@ -1997,17 +2004,106 @@ enum VariableDataType { BOOLEAN = 0; FLOAT = 1; STRING = 2; + ALIAS = 3; + COLOR = 4; +} + +enum VariableResolvedDataType { + BOOLEAN = 0; + FLOAT = 1; + STRING = 2; + COLOR = 4; +} + +message VariableID { + GUID guid = 1; +} + +message VariableSetID { + GUID guid = 1; } message VariableAnyValue { bool boolValue = 1; string textValue = 2; float floatValue = 3; + VariableID alias = 4; + Color colorValue = 5; } message VariableData { VariableAnyValue value = 1; VariableDataType dataType = 2; + VariableResolvedDataType resolvedDataType = 3; +} + +message VariableSetMode { + GUID id = 1; + string name = 2; + string sortPosition = 3; +} + +message VariableDataValuesEntry { + GUID modeID = 1; + VariableData variableData = 2; +} + +message VariableDataValues { + VariableDataValuesEntry[] entries = 1; +} + +enum VariableScope { + ALL_SCOPES = 0; + TEXT_CONTENT = 1; + CORNER_RADIUS = 2; + WIDTH_HEIGHT = 3; + GAP = 4; + ALL_FILLS = 5; + FRAME_FILL = 6; + SHAPE_FILL = 7; + TEXT_FILL = 8; + STROKE = 9; + STROKE_FLOAT = 10; + EFFECT_FLOAT = 11; + EFFECT_COLOR = 12; + OPACITY = 13; + FONT_FAMILY = 15; + FONT_SIZE = 16; + LINE_HEIGHT = 17; + LETTER_SPACING = 18; +} + +enum VariableField { + MISSING = 0; + CORNER_RADIUS = 1; + STROKE_WEIGHT = 4; + STACK_SPACING = 5; + STACK_PADDING_LEFT = 6; + STACK_PADDING_TOP = 7; + STACK_PADDING_RIGHT = 8; + STACK_PADDING_BOTTOM = 9; + VISIBLE = 10; + WIDTH = 12; + HEIGHT = 13; + RECTANGLE_TOP_LEFT_CORNER_RADIUS = 14; + RECTANGLE_TOP_RIGHT_CORNER_RADIUS = 15; + RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS = 16; + RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS = 17; + OPACITY = 31; + FONT_SIZE = 32; + LETTER_SPACING = 34; + LINE_HEIGHT = 36; + STACK_COUNTER_SPACING = 23; +} + +message VariableDataMapEntry { + uint nodeField = 1; + VariableData variableData = 2; + VariableField variableField = 3; +} + +message VariableDataMap { + VariableDataMapEntry[] entries = 1; } enum HTMLTag { diff --git a/tests/engine/fig-roundtrip.test.ts b/tests/engine/fig-roundtrip.test.ts index 02a11eccf..2e7c152c2 100644 --- a/tests/engine/fig-roundtrip.test.ts +++ b/tests/engine/fig-roundtrip.test.ts @@ -851,3 +851,81 @@ describe('edge cases', () => { expect(overflows).toBe(0) }) }) + +describe('variable roundtrip', () => { + test('variables and collections survive export → re-import', async () => { + await initCodec() + + const graph = new SceneGraph() + const col = graph.createCollection('Design Tokens') + graph.createVariable('color/primary', 'COLOR', col.id, { r: 0.23, g: 0.51, b: 0.96, a: 1 }) + graph.createVariable('spacing/base', 'FLOAT', col.id, 8) + graph.createVariable('visible', 'BOOLEAN', col.id, true) + graph.createVariable('label', 'STRING', col.id, 'Hello') + + const exported = await exportFigFile(graph) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + expect(reimported.variables.size).toBe(4) + expect(reimported.variableCollections.size).toBe(1) + + const reimportedCol = [...reimported.variableCollections.values()][0] + expect(reimportedCol.name).toBe('Design Tokens') + expect(reimportedCol.variableIds).toHaveLength(4) + + const vars = [...reimported.variables.values()] + const colorVar = vars.find((v) => v.name === 'color/primary')! + expect(colorVar.type).toBe('COLOR') + const colorVal = Object.values(colorVar.valuesByMode)[0] as { r: number; g: number; b: number; a: number } + expect(colorVal.r).toBeCloseTo(0.23, 1) + + const floatVar = vars.find((v) => v.name === 'spacing/base')! + expect(floatVar.type).toBe('FLOAT') + expect(Object.values(floatVar.valuesByMode)[0]).toBe(8) + + const boolVar = vars.find((v) => v.name === 'visible')! + expect(boolVar.type).toBe('BOOLEAN') + expect(Object.values(boolVar.valuesByMode)[0]).toBe(true) + + const strVar = vars.find((v) => v.name === 'label')! + expect(strVar.type).toBe('STRING') + expect(Object.values(strVar.valuesByMode)[0]).toBe('Hello') + }) + + test('variable bindings survive export → re-import', async () => { + await initCodec() + + const graph = new SceneGraph() + const col = graph.createCollection('Tokens') + const floatVar = graph.createVariable('radius', 'FLOAT', col.id, 12) + + const page = graph.getPages()[0] + const rect = graph.createNode('RECTANGLE', page.id, { + name: 'Bound Rect', + width: 100, + height: 100, + cornerRadius: 12, + }) + graph.bindVariable(rect.id, 'cornerRadius', floatVar.id) + + const exported = await exportFigFile(graph) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + const reimportedRect = [...reimported.getAllNodes()].find((n) => n.name === 'Bound Rect')! + expect(reimportedRect).toBeDefined() + expect(Object.keys(reimportedRect.boundVariables)).toContain('cornerRadius') + }) + + test('material3.fig variables survive round-trip', async () => { + const buf = readFileSync(resolve(FIXTURES, 'material3.fig')) + const original = await parseFigFile(buf.buffer as ArrayBuffer) + + const exported = await exportFigFile(original) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + expect(reimported.variables.size).toBe(original.variables.size) + expect(reimported.variableCollections.size).toBeGreaterThanOrEqual( + [...original.variableCollections.values()].filter((c) => c.variableIds.length > 0).length + ) + }) +})