From 9a6e29cf6ff82ca06d2b252d88cb66f84fa67699 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 19:24:20 +0300 Subject: [PATCH] fix(fig): preserve imported layout metadata - Keep Figma stack sizing fields such as RESIZE_TO_FIT_WITH_IMPLICIT_SIZE - Avoid writing default auto-layout fields onto imported instances when Figma omitted them - Cover instance layout metadata roundtrips --- packages/core/src/kiwi/node-change/convert.ts | 22 +++++++ .../core/src/kiwi/node-change/serialize.ts | 45 +++++++++++++++ .../core/src/scene-graph/node-defaults.ts | 1 + packages/core/src/scene-graph/types.ts | 29 ++++++++++ .../auto-layout/transforms.test.ts | 57 +++++++++++++++++++ 5 files changed, 154 insertions(+) diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index 4f6c17241..e1bd63e20 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -355,6 +355,7 @@ function convertLayoutProps( ): Pick< SceneNode, | 'layoutMode' + | 'figmaLayout' | 'itemSpacing' | 'paddingTop' | 'paddingBottom' @@ -387,6 +388,27 @@ function convertLayoutProps( return { layoutMode, + figmaLayout: { + stackMode: nc.stackMode, + stackSpacing: nc.stackSpacing, + stackPadding: nc.stackPadding, + stackPaddingRight: nc.stackPaddingRight, + stackPaddingBottom: nc.stackPaddingBottom, + stackCounterAlign: nc.stackCounterAlign, + stackJustify: nc.stackJustify, + stackCounterAlignItems: nc.stackCounterAlignItems, + stackPrimaryAlignItems: nc.stackPrimaryAlignItems, + stackPrimarySizing: nc.stackPrimarySizing, + stackCounterSizing: nc.stackCounterSizing, + stackVerticalPadding: nc.stackVerticalPadding, + stackHorizontalPadding: nc.stackHorizontalPadding, + stackWrap: nc.stackWrap, + stackPositioning: nc.stackPositioning, + stackChildPrimaryGrow: nc.stackChildPrimaryGrow, + stackChildAlignSelf: nc.stackChildAlignSelf, + stackCounterSpacing: nc.stackCounterSpacing, + bordersTakeSpace: nc.bordersTakeSpace as boolean | undefined + }, itemSpacing: nc.stackSpacing ?? 0, ...convertLayoutPadding(nc), primaryAxisSizing, diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index 41da0e276..ae953e23e 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -322,8 +322,53 @@ function serializeTextProps( } } +function normalizeStackMode( + value: string | undefined +): KiwiNodeChange['stackMode'] { + return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined +} + +function normalizeStackSizing( + value: string | undefined +): KiwiNodeChange['stackPrimarySizing'] { + return value === 'FIXED' || value === 'RESIZE_TO_FIT' || value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE' + ? value + : undefined +} + +function normalizeStackJustify(value: string | undefined): string | undefined { + return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value +} + +function normalizeStackCounterAlign(value: string | undefined): string | undefined { + return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value +} + function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection) + const figmaLayout = node.figmaLayout + if (figmaLayout) { + nc.stackMode = normalizeStackMode(figmaLayout.stackMode) + nc.stackSpacing = figmaLayout.stackSpacing + nc.stackPadding = figmaLayout.stackPadding + nc.stackPaddingRight = figmaLayout.stackPaddingRight + nc.stackPaddingBottom = figmaLayout.stackPaddingBottom + nc.stackCounterAlign = normalizeStackCounterAlign(figmaLayout.stackCounterAlign) + nc.stackJustify = normalizeStackJustify(figmaLayout.stackJustify) + nc.stackCounterAlignItems = normalizeStackCounterAlign(figmaLayout.stackCounterAlignItems) + nc.stackPrimaryAlignItems = normalizeStackJustify(figmaLayout.stackPrimaryAlignItems) + nc.stackPrimarySizing = normalizeStackSizing(figmaLayout.stackPrimarySizing) + nc.stackCounterSizing = normalizeStackSizing(figmaLayout.stackCounterSizing) + nc.stackVerticalPadding = figmaLayout.stackVerticalPadding + nc.stackHorizontalPadding = figmaLayout.stackHorizontalPadding + nc.stackWrap = figmaLayout.stackWrap + nc.stackPositioning = figmaLayout.stackPositioning + nc.stackChildPrimaryGrow = figmaLayout.stackChildPrimaryGrow + nc.stackChildAlignSelf = figmaLayout.stackChildAlignSelf + nc.stackCounterSpacing = figmaLayout.stackCounterSpacing + nc.bordersTakeSpace = figmaLayout.bordersTakeSpace + return + } if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') { nc.stackMode = node.layoutMode nc.stackSpacing = node.itemSpacing diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index ea8a5a480..e3d0597a9 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -46,6 +46,7 @@ export function createDefaultNode( lineHeight: null, letterSpacing: 0, layoutMode: 'NONE', + figmaLayout: null, layoutDirection: 'AUTO', layoutWrap: 'NO_WRAP', primaryAxisAlign: 'MIN', diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 7859f153e..9584d0b51 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -229,6 +229,34 @@ export interface VariantPropSpec { value: string } +export type FigmaLayoutMetadata = Partial< + Record< + | 'stackMode' + | 'stackCounterAlign' + | 'stackJustify' + | 'stackCounterAlignItems' + | 'stackPrimaryAlignItems' + | 'stackPrimarySizing' + | 'stackCounterSizing' + | 'stackWrap' + | 'stackPositioning' + | 'stackChildAlignSelf', + string + > & + Record< + | 'stackSpacing' + | 'stackPadding' + | 'stackPaddingRight' + | 'stackPaddingBottom' + | 'stackVerticalPadding' + | 'stackHorizontalPadding' + | 'stackChildPrimaryGrow' + | 'stackCounterSpacing', + number + > & + Record<'bordersTakeSpace', boolean> +> + export interface SceneNode { id: string type: NodeType @@ -283,6 +311,7 @@ export interface SceneNode { verticalConstraint: ConstraintType layoutMode: LayoutMode + figmaLayout: FigmaLayoutMetadata | null layoutDirection: LayoutDirection layoutWrap: LayoutWrap primaryAxisAlign: LayoutAlign diff --git a/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts b/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts index 2552405f5..031c1f129 100644 --- a/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts +++ b/tests/engine/kiwi/serialize-fixes/auto-layout/transforms.test.ts @@ -130,6 +130,63 @@ describe('Fix 1: auto-layout child transforms', () => { expect(childNc.transform.m12).toBe(45) }) + test('preserves imported Figma layout metadata for instance roundtrips', () => { + const graph = new SceneGraph() + const parent = graph.createNode('FRAME', pageId(graph), { + name: 'Checked List', + width: 878, + height: 24, + layoutMode: 'HORIZONTAL', + counterAxisSizing: 'HUG', + layoutAlignSelf: 'STRETCH', + figmaLayout: { + stackMode: 'HORIZONTAL', + stackPrimarySizing: 'FIXED', + stackCounterSizing: 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE', + stackChildAlignSelf: 'STRETCH' + } + }) + + graph.createNode('INSTANCE', parent.id, { + name: 'Lists', + width: 282.6666564941406, + height: 24, + layoutMode: 'VERTICAL', + layoutGrow: 1, + figmaLayout: { + stackMode: 'VERTICAL', + stackChildPrimaryGrow: 1 + } + }) + + const blobs: Uint8Array[] = [] + const changes = sceneNodeToKiwi( + getNodeOrThrow(graph, parent.id), + ROOT_GUID, + 0, + { value: 100 }, + graph, + blobs + ) as Record[] + + const parentNc = expectDefined( + changes.find((nc) => nc.name === 'Checked List'), + 'parent node change' + ) + expect(parentNc.stackCounterSizing).toBe('RESIZE_TO_FIT_WITH_IMPLICIT_SIZE') + + const instanceNc = expectDefined( + changes.find((nc) => nc.name === 'Lists'), + 'instance node change' + ) + expect(instanceNc.stackMode).toBe('VERTICAL') + expect(instanceNc.stackChildPrimaryGrow).toBe(1) + expect(instanceNc.stackPrimarySizing).toBeUndefined() + expect(instanceNc.stackCounterSizing).toBeUndefined() + expect(instanceNc.stackPrimaryAlignItems).toBeUndefined() + expect(instanceNc.stackCounterAlignItems).toBeUndefined() + }) + test('horizontal auto-layout child also preserves its stored transform offsets', () => { const graph = new SceneGraph() const parent = graph.createNode('FRAME', pageId(graph), {