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
This commit is contained in:
Danila Poyarkov 2026-05-20 19:24:20 +03:00
parent 68ebef5f9b
commit 9a6e29cf6f
5 changed files with 154 additions and 0 deletions

View file

@ -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,

View file

@ -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

View file

@ -46,6 +46,7 @@ export function createDefaultNode(
lineHeight: null,
letterSpacing: 0,
layoutMode: 'NONE',
figmaLayout: null,
layoutDirection: 'AUTO',
layoutWrap: 'NO_WRAP',
primaryAxisAlign: 'MIN',

View file

@ -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

View file

@ -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<string, unknown>[]
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), {