openpencil/tests/engine/layout.test.ts

2014 lines
58 KiB
TypeScript
Raw Normal View History

import { describe, test, expect } from 'bun:test'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer, FigmaAPI } from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
function autoFrame(
graph: SceneGraph,
parentId: string,
overrides: Partial<SceneNode> = {}
): SceneNode {
return graph.createNode('FRAME', parentId, {
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 400,
height: 200,
...overrides,
})
}
function rect(
graph: SceneGraph,
parentId: string,
w = 50,
h = 50,
overrides: Partial<SceneNode> = {}
): SceneNode {
return graph.createNode('RECTANGLE', parentId, {
name: 'Rect',
width: w,
height: h,
...overrides,
})
}
describe('Auto Layout', () => {
describe('horizontal basic', () => {
test('positions children left-to-right', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph))
rect(graph, frame.id, 80, 40)
rect(graph, frame.id, 60, 40)
rect(graph, frame.id, 100, 40)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(80)
expect(children[2].x).toBe(140)
})
test('applies item spacing', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), { itemSpacing: 10 })
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(60)
expect(children[2].x).toBe(120)
})
test('applies padding', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
paddingTop: 10,
paddingRight: 20,
paddingBottom: 30,
paddingLeft: 40,
})
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(40)
expect(child.y).toBe(10)
})
test('applies padding and spacing together', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
paddingLeft: 20,
paddingTop: 15,
itemSpacing: 10,
})
rect(graph, frame.id, 50, 30)
rect(graph, frame.id, 50, 30)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(20)
expect(children[0].y).toBe(15)
expect(children[1].x).toBe(80)
expect(children[1].y).toBe(15)
})
})
describe('vertical basic', () => {
test('positions children top-to-bottom', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
})
rect(graph, frame.id, 50, 80)
rect(graph, frame.id, 50, 60)
rect(graph, frame.id, 50, 100)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(0)
expect(children[1].y).toBe(80)
expect(children[2].y).toBe(140)
})
test('applies item spacing vertically', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
itemSpacing: 16,
})
rect(graph, frame.id, 50, 40)
rect(graph, frame.id, 50, 40)
rect(graph, frame.id, 50, 40)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(0)
expect(children[1].y).toBe(56)
expect(children[2].y).toBe(112)
})
})
describe('alignment - primary axis', () => {
test('center alignment (horizontal)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
primaryAxisAlign: 'CENTER',
})
rect(graph, frame.id, 100, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(150)
})
test('max alignment (horizontal)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
primaryAxisAlign: 'MAX',
})
rect(graph, frame.id, 100, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(300)
})
test('space-between alignment (horizontal)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
primaryAxisAlign: 'SPACE_BETWEEN',
})
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[2].x).toBe(350)
expect(children[1].x).toBeCloseTo(175, 0)
})
test('center alignment (vertical)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
primaryAxisAlign: 'CENTER',
})
rect(graph, frame.id, 50, 100)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.y).toBe(150)
})
test('max alignment (vertical)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
primaryAxisAlign: 'MAX',
})
rect(graph, frame.id, 50, 100)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.y).toBe(300)
})
})
describe('alignment - counter axis', () => {
test('center cross-axis alignment (horizontal)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
counterAxisAlign: 'CENTER',
})
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.y).toBe(75)
})
test('max cross-axis alignment (horizontal)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
counterAxisAlign: 'MAX',
})
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.y).toBe(150)
})
test('center cross-axis alignment (vertical)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
counterAxisAlign: 'CENTER',
})
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(75)
})
test('stretch cross-axis alignment (horizontal)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
counterAxisAlign: 'STRETCH',
})
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.height).toBe(200)
})
test('stretch cross-axis alignment (vertical)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
counterAxisAlign: 'STRETCH',
})
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.width).toBe(200)
})
})
describe('sizing modes', () => {
test('hug contents horizontally', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED',
width: 999,
height: 100,
itemSpacing: 10,
})
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 70, 50)
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.width).toBe(130)
})
test('hug contents vertically', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED',
width: 200,
height: 999,
itemSpacing: 10,
})
rect(graph, frame.id, 50, 40)
rect(graph, frame.id, 50, 60)
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.height).toBe(110)
})
test('hug includes padding', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
width: 999,
height: 999,
paddingTop: 10,
paddingRight: 20,
paddingBottom: 30,
paddingLeft: 40,
})
rect(graph, frame.id, 100, 50)
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.width).toBe(160)
expect(f.height).toBe(90)
})
test('child fill in horizontal layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
})
rect(graph, frame.id, 100, 50)
rect(graph, frame.id, 50, 50, { layoutGrow: 1 })
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].width).toBe(100)
expect(children[1].width).toBe(300)
})
test('child fill in vertical layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
})
rect(graph, frame.id, 50, 100)
rect(graph, frame.id, 50, 50, { layoutGrow: 1 })
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].height).toBe(100)
expect(children[1].height).toBe(300)
})
test('multiple fill children share space equally', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 300,
height: 100,
})
rect(graph, frame.id, 50, 50, { layoutGrow: 1 })
rect(graph, frame.id, 50, 50, { layoutGrow: 1 })
rect(graph, frame.id, 50, 50, { layoutGrow: 1 })
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].width).toBe(100)
expect(children[1].width).toBe(100)
expect(children[2].width).toBe(100)
})
test('fill with spacing and padding', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
paddingLeft: 20,
paddingRight: 20,
itemSpacing: 10,
})
rect(graph, frame.id, 100, 50)
rect(graph, frame.id, 50, 50, { layoutGrow: 1 })
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].width).toBe(100)
// 400 - 20 - 20 (padding) - 100 (fixed child) - 10 (spacing) = 250
expect(children[1].width).toBe(250)
})
})
describe('absolute positioning', () => {
test('absolute children are skipped in layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
itemSpacing: 10,
})
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50, {
layoutPositioning: 'ABSOLUTE',
x: 200,
y: 100,
})
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// First auto child at 0
expect(children[0].x).toBe(0)
// Absolute child should keep its position
expect(children[1].x).toBe(200)
expect(children[1].y).toBe(100)
// Third child should be right after first (no gap for absolute)
expect(children[2].x).toBe(60)
})
})
describe('hidden children', () => {
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
test('hidden children preserve original size but collapse in layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 300,
height: 100,
itemSpacing: 10,
})
rect(graph, frame.id, 50, 50, { visible: false })
rect(graph, frame.id, 80, 40)
rect(graph, frame.id, 50, 50, { visible: false })
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
expect(children[0].width).toBe(50)
expect(children[0].height).toBe(50)
expect(children[1].x).toBe(0)
expect(children[1].width).toBe(80)
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
expect(children[2].width).toBe(50)
expect(children[2].height).toBe(50)
})
test('hidden children do not consume spacing', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
itemSpacing: 20,
})
rect(graph, frame.id, 50, 50, { visible: false })
rect(graph, frame.id, 100, 50)
rect(graph, frame.id, 100, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[1].x).toBe(0)
expect(children[2].x).toBe(120)
})
test('hug frame ignores hidden children for sizing', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
})
rect(graph, frame.id, 200, 200, { visible: false })
rect(graph, frame.id, 50, 30)
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.width).toBe(50)
expect(f.height).toBe(30)
})
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
test('hidden nested auto-layout children preserve size but collapse in layout', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 300,
height: 100,
itemSpacing: 16,
})
const inner = autoFrame(graph, outer.id, {
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 50,
height: 50,
visible: false,
})
rect(graph, inner.id, 30, 30)
rect(graph, outer.id, 80, 40)
computeLayout(graph, outer.id)
const children = graph.getChildren(outer.id)
const innerNode = graph.getNode(inner.id)!
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
expect(innerNode.width).toBe(50)
expect(innerNode.height).toBe(50)
expect(children[1].x).toBe(0)
})
})
describe('self-alignment', () => {
test('layoutAlignSelf STRETCH overrides counter axis', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
counterAxisAlign: 'MIN',
})
rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'STRETCH' })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].height).toBe(200)
expect(children[1].height).toBe(50)
})
})
describe('nested auto layout', () => {
test('nested horizontal frames', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 500,
height: 200,
itemSpacing: 10,
})
const inner = autoFrame(graph, outer.id, {
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED',
width: 999,
height: 100,
itemSpacing: 5,
})
rect(graph, inner.id, 40, 40)
rect(graph, inner.id, 60, 40)
rect(graph, outer.id, 80, 80)
computeLayout(graph, outer.id)
const innerNode = graph.getNode(inner.id)!
expect(innerNode.width).toBe(105)
expect(innerNode.x).toBe(0)
const outerChildren = graph.getChildren(outer.id)
expect(outerChildren[1].x).toBe(115)
})
test('nested vertical inside horizontal', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 500,
height: 300,
itemSpacing: 20,
})
const inner = autoFrame(graph, outer.id, {
layoutMode: 'VERTICAL',
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED',
width: 100,
height: 999,
itemSpacing: 10,
})
rect(graph, inner.id, 80, 50)
rect(graph, inner.id, 80, 70)
rect(graph, outer.id, 60, 60)
computeLayout(graph, outer.id)
const innerNode = graph.getNode(inner.id)!
expect(innerNode.height).toBe(130)
const outerChildren = graph.getChildren(outer.id)
expect(outerChildren[0].x).toBe(0)
expect(outerChildren[1].x).toBe(120)
})
test('computeAllLayouts handles deeply nested frames', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const outer = autoFrame(graph, page, {
layoutMode: 'VERTICAL',
width: 300,
height: 500,
itemSpacing: 10,
})
const middle = autoFrame(graph, outer.id, {
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
width: 999,
height: 999,
itemSpacing: 5,
})
rect(graph, middle.id, 40, 30)
rect(graph, middle.id, 60, 30)
rect(graph, outer.id, 100, 50)
computeAllLayouts(graph)
const middleNode = graph.getNode(middle.id)!
expect(middleNode.width).toBe(105)
expect(middleNode.height).toBe(30)
const outerChildren = graph.getChildren(outer.id)
expect(outerChildren[0].y).toBe(0)
expect(outerChildren[1].y).toBe(40)
})
})
describe('wrap layout', () => {
test('wraps children in horizontal layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 200,
height: 300,
layoutWrap: 'WRAP',
})
rect(graph, frame.id, 80, 40)
rect(graph, frame.id, 80, 40)
rect(graph, frame.id, 80, 40)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// First two fit on first row (80 + 80 = 160 <= 200)
expect(children[0].x).toBe(0)
expect(children[0].y).toBe(0)
expect(children[1].x).toBe(80)
expect(children[1].y).toBe(0)
// Third wraps to second row
expect(children[2].x).toBe(0)
expect(children[2].y).toBe(40)
})
test('counterAxisSpacing adds gap between wrapped rows', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 200,
height: 300,
layoutWrap: 'WRAP',
counterAxisSpacing: 10,
})
rect(graph, frame.id, 120, 40)
rect(graph, frame.id, 120, 40)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(0)
expect(children[1].y).toBe(50)
})
test('itemSpacing with wrap', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 200,
height: 300,
layoutWrap: 'WRAP',
itemSpacing: 10,
})
// 90 + 10 + 90 = 190, fits in 200
rect(graph, frame.id, 90, 40)
rect(graph, frame.id, 90, 40)
// 90 wraps to next row
rect(graph, frame.id, 90, 40)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(100)
expect(children[2].x).toBe(0)
expect(children[2].y).toBe(40)
})
})
describe('counter axis spacing (no wrap)', () => {
test('counterAxisSpacing is ignored without wrap in horizontal', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 300,
height: 100,
counterAxisSpacing: 50,
layoutWrap: 'NO_WRAP',
})
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// Without wrap, counterAxisSpacing has no effect
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(50)
})
})
describe('layout mode NONE', () => {
test('computeLayout does nothing for NONE layout', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Plain',
layoutMode: 'NONE',
width: 400,
height: 200,
})
const child = rect(graph, frame.id, 50, 50, { x: 100, y: 100 })
computeLayout(graph, frame.id)
const c = graph.getNode(child.id)!
expect(c.x).toBe(100)
expect(c.y).toBe(100)
})
})
describe('edge cases', () => {
test('empty auto layout frame', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
paddingTop: 10,
paddingRight: 20,
paddingBottom: 30,
paddingLeft: 40,
})
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.width).toBe(60)
expect(f.height).toBe(40)
})
test('single child', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 200,
height: 100,
})
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(0)
expect(child.y).toBe(0)
expect(child.width).toBe(50)
expect(child.height).toBe(50)
})
test('all children absolute → frame hugs to padding only', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
paddingTop: 5,
paddingRight: 5,
paddingBottom: 5,
paddingLeft: 5,
})
rect(graph, frame.id, 100, 100, { layoutPositioning: 'ABSOLUTE' })
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.width).toBe(10)
expect(f.height).toBe(10)
})
test('zero-size children', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
})
rect(graph, frame.id, 0, 0)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.width).toBe(50)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(0)
})
})
describe('mixed sizing', () => {
test('fixed primary, hug counter (horizontal)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 999,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
})
rect(graph, frame.id, 50, 80)
rect(graph, frame.id, 50, 120)
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.width).toBe(400)
expect(f.height).toBe(120)
})
test('hug primary, fixed counter (vertical)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 999,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED',
itemSpacing: 10,
})
rect(graph, frame.id, 50, 40)
rect(graph, frame.id, 80, 60)
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.height).toBe(110)
expect(f.width).toBe(200)
})
})
describe('nested auto layout with fill children', () => {
test('child frame with FILL sizing expands in parent', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
itemSpacing: 10,
})
rect(graph, outer.id, 100, 50)
const inner = autoFrame(graph, outer.id, {
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 50,
height: 50,
layoutGrow: 1,
})
rect(graph, inner.id, 30, 30)
computeLayout(graph, outer.id)
const innerNode = graph.getNode(inner.id)!
// 400 - 100 - 10 = 290
expect(innerNode.width).toBe(290)
})
})
describe('gap mapping correctness', () => {
test('horizontal: itemSpacing is column gap, counterAxisSpacing is row gap', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 200,
height: 300,
layoutWrap: 'WRAP',
itemSpacing: 10,
counterAxisSpacing: 20,
})
// Each row: 90 + 10 + 90 = 190 <= 200
rect(graph, frame.id, 90, 40)
rect(graph, frame.id, 90, 40)
// These wrap
rect(graph, frame.id, 90, 40)
rect(graph, frame.id, 90, 40)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// Row 1
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(100)
// Row 2: y = 40 (row height) + 20 (counterAxisSpacing) = 60
expect(children[2].y).toBe(60)
expect(children[3].x).toBe(100)
})
test('vertical: itemSpacing is row gap, counterAxisSpacing is column gap', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 300,
height: 200,
layoutWrap: 'WRAP',
itemSpacing: 10,
counterAxisSpacing: 20,
})
// Each column: 80 + 10 + 80 = 170 <= 200
rect(graph, frame.id, 40, 80)
rect(graph, frame.id, 40, 80)
// These wrap to second column
rect(graph, frame.id, 40, 80)
rect(graph, frame.id, 40, 80)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// Column 1
expect(children[0].y).toBe(0)
expect(children[1].y).toBe(90)
// Column 2: x = 40 (col width) + 20 (counterAxisSpacing) = 60
expect(children[2].x).toBe(60)
expect(children[2].y).toBe(0)
expect(children[3].x).toBe(60)
expect(children[3].y).toBe(90)
})
})
describe('text measurement', () => {
test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'CENTER',
paddingLeft: 10,
paddingRight: 10,
itemSpacing: 10,
})
const arrow1 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 })
const text = graph.createNode('TEXT', frame.id, {
width: 200,
height: 20,
text: 'Test',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const,
})
const arrow2 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 })
setTextMeasurer((node) => {
if (node.type === 'TEXT' && node.textAutoResize === 'WIDTH_AND_HEIGHT') {
return { width: 60, height: 20 }
}
return null
})
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = graph.getNode(text.id)!
const updatedArrow1 = graph.getNode(arrow1.id)!
const updatedArrow2 = graph.getNode(arrow2.id)!
expect(updatedText.width).toBe(60)
// Total content: 10 + 20 + 10 + 60 + 10 + 20 + 10 = 140
// Free space: 300 - 140 = 160, centered offset = 80
expect(updatedArrow1.x).toBe(90)
expect(updatedText.x).toBe(120)
expect(updatedArrow2.x).toBe(190)
})
test('without measurer, text uses estimated size instead of 100x100 default', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'CENTER',
})
graph.createNode('TEXT', frame.id, {
width: 200,
height: 20,
text: 'Test',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const,
})
setTextMeasurer(null)
computeAllLayouts(graph)
const children = graph.getChildren(frame.id)
const updatedText = children[0]
// Rough estimate: ~0.6 × fontSize × charCount, not the 100×100 default
expect(updatedText.width).toBeLessThan(100)
expect(updatedText.width).toBeGreaterThan(0)
expect(updatedText.height).toBeLessThan(100)
expect(updatedText.height).toBeGreaterThan(0)
})
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
AI chat polishing: skeleton workflow, batched tools, visual feedback, stock photos (#111) * Fix text rendering: always use paragraph wrapping, font fallback, batch tools, severity levels Rendering: - renderText: always use paragraph when fontProvider available, not just when node-specific font loaded — fixes single-line overflow for Georgia/ non-Inter fonts - buildParagraph: add Inter as fallback fontFamily so paragraph wraps text even when requested font not yet loaded - measureTextNode/buildTextPicture: remove isNodeFontLoaded gate — measure with fallback font for consistent layout↔rendering - TEXT_PICTURE_KEYS: add width/height so textPicture invalidates on resize - renderText drawText fallback: clip to node bounds - onAfterExecute: invalidate textPictures after font load Layout: - estimateTextSize: accept maxWidth, compute multi-line height - toJSON: depth parameter with childCount fallback AI tools: - batch_update tool: multiple set_layout/set_layout_child in one call - describe: accept ids array for multi-node inspection - Issue severity (error/warning/info) with pattern classification - checkNestedFlexWithoutFill: cross-axis fill tolerance - CORE_TOOLS trimmed 34→27, rest moved to EXTENDED - System prompt: batch_update, describe ids, severity rules, font/text wrapping guidance, card grid patterns * Add Pexels stock photo integration for AI chat - stock_photo tool: search Pexels by query, download image, apply as IMAGE fill on any node. Picks resolution based on node dimensions. - Pexels API key stored in localStorage, synced to core via setPexelsApiKey on change - ProviderSettings UI: Pexels API key input with link to get free key - System prompt: stock photo usage guidance - Added to CORE_TOOLS so AI can use it in every session * Batch stock_photo + calc, 4-phase workflow, fix wrap false positives - stock_photo: accepts JSON array of requests, all fetched in parallel. Rejects nodes with children (leaf shapes only). One call replaces 14. - calc: accepts JSON array of expressions or single string. One call replaces 3 separate calc invocations. - System prompt: 4-phase workflow (plan → skeleton → content → polish). Batch stock_photo and calc examples. Updated step budget. - describe-layout-issues: skip 'nested flex may collapse' for wrap containers (pill/tag frames are intentionally HUG). Extract childNeedsFill helper to reduce complexity. * Fix nested flex false positive for pills, single-root render rule - describe-layout-issues: skip 'nested flex may collapse' when a sibling already has grow or fill — pill/badge frames next to flex-grow content are intentionally small (e.g. BreakingLabel next to ticker text) - System prompt: render must have exactly one root element. Stock_photo 401 fallback: tell user, don't eval gradients. * Rework workflow: render complete sections, not empty frames Phase 2 now prescribes 5-7 renders, each producing a complete section (nav, hero, stories, opinions, sidebar, footer) with placeholder images, real text, proper auto-layout. No more empty frames to fill later. Removes the fill content phase — skeleton IS the content, just without real photos. Phase 3 is now just polish (batch stock_photo + fixes). Step budget: 12-15 steps instead of 15-30. * Pexels key input: match AI key behavior Same pattern as the AI provider key — intermediate ref, 'Key saved — enter new to replace' placeholder, Clear button, save on change. No longer exposes the raw key in the input field. * No empty frames in skeleton, select nodes after each tool - System prompt: explicit rules against empty frames, no pre-creating wrapper frames for future content. Each render = complete section with visible placeholders. Render 1 = only nav+ticker, not the whole page with empty containers. - tools.ts: set selection to flash node IDs after each mutating tool, so user sees which section AI is working on (selection border persists vs flash which is transient). * AI overlay: pulsing blue while working, green flash on done; skeleton workflow Rendering: - ai-overlays.ts: new module with pulsing blue border (sinusoidal opacity 0.3-0.8 at 1.5s period) for nodes AI is working on, green fade-out flash (800ms) when done - renderer.ts: aiMarkActive/aiMarkDone/aiClearAll methods, drawn after regular flashes - constants.ts: AI_ACTIVE_COLOR, AI_DONE_COLOR, timing constants Integration: - ai-adapter.ts: onBeforeExecute now receives args (for target ID) - tools.ts: marks target node (id or parent_id) active before tool executes, marks done via onFlashNodes after completion - editor.ts: aiMarkActive/aiMarkDone/aiClearAll forwarded to renderer Workflow: - System prompt: 3-phase skeleton workflow. Phase 2 renders gray placeholder blocks (Rectangle bg=#E2E8F0) for every section — page looks like wireframe before any real content. Phase 3 replaces skeletons with real content. Phase 4 batch stock_photo + polish. * render replace_id: atomic skeleton-to-content swap, fix AI overlay lifecycle render tool: - New replace_id parameter — renders new content, places it at the skeleton's position in parent, then deletes the skeleton. No visual gap between skeleton removal and content appearance. AI overlay fixes: - Track lastActiveIds across before/after execute — aiMarkDone clears the exact nodes that were marked active, not just the result IDs - aiFlashDone: direct green flash on result nodes (not gated by _aiActiveNodes membership) - replace_id included in target ID detection for active marking System prompt: - Phase 3 uses render({ replace_id }) instead of delete_node + render * Fix gradient fills, nested flex false positives, render insert_index, h=hug prompt - set_fill: gradient support with color_end + direction (top-bottom, left-right, etc). Uses proper Matrix type. No more eval for gradients. - describe-layout-issues: skip nested flex warning for all non-MIN alignments (SPACE_BETWEEN, CENTER, MAX) — not just SPACE_BETWEEN - render: insert_index parameter for controlling child order - system prompt: page frame h="hug" not fixed height - AI overlay: mark active on any tool with target ID (not just mutating), clear previous active in onBeforeExecute * Add desktop business news site example to system prompt Full workflow example: skeleton with gray placeholders → replace_id content fills → batch stock_photo. Shows 12-col grid pattern, card components, section headers, sidebar widgets, h=hug page frame. Complements the existing mobile app UI example. * Drop blue active pulse, keep only green done flash Blue active marking was invisible — tool execution is 20-80ms (one frame), AI thinking happens between tool calls where no hooks fire. Green done flash on result nodes works and is visible. * Blue pulse on skeleton during AI tool streaming Watch streaming message parts in ChatPanel — when a tool part appears with state 'call'/'partial-call' and has replace_id or parent_id in args, mark that node active (blue pulse). When tool transitions to 'output-available', mark done (green flash). This shows the pulsing blue border on the skeleton wireframe frame while AI generates JSX, and switches to green when the content replaces it. * AI overlay debug log section, watch tool parts by toolCallId - chat-debug.ts: aiOverlayLog array + AI OVERLAY LOG section in debug output showing every part/mark-active/mark-done event with timestamps - ChatPanel.vue: check parts by toolCallId instead of isToolUIPart, log all overlay events to aiOverlayLog for debugging * Fix AI overlay: use 'input' not 'args' for tool part params Vercel AI SDK UIToolInvocation uses 'input' field (not 'args') for tool call parameters. The watch was checking p.args which was always undefined, so no overlay events ever fired. Also use p.type instead of p.toolName for the log. * Move AI overlay to onBeforeExecute/onFlashNodes, reorder render params - onBeforeExecute: mark replace_id/parent_id active (blue pulse) - onFlashNodes: clear active, flash done on new nodes (green) - Reorder render tool params: replace_id first so streaming partial input reveals target ID before JSX body - Remove ChatPanel watch — all overlay logic in tools.ts now - Add aiClearActive() to renderer (clears active without touching done flashes) * Wait 2 rAF frames after marking active before tool execute onBeforeExecute marks the target node active (blue pulse), but execute ran immediately after without yielding to the render loop. The blue frame was never painted because markActive → execute → clearActive all happened in the same JS task. Now await two requestAnimationFrame callbacks so the canvas renders at least one blue-pulse frame before execution starts. * Add comprehensive AI overlay debug logging to chat debug log Both streaming watch (ChatPanel) and tool execution hooks (tools.ts) now log every event to aiOverlayLog with full state: renderer exists, activeNodes count, hasFlashes, node existence. This will show exactly what happens and why the blue pulse is or isn't visible. * Filter partial node IDs in streaming overlay watch During input-streaming, replace_id may be partially received (e.g. '0:' without the number part). Guard with isValidNodeId check. Also refactor watch callback into collectPendingTargets + syncOverlayState to stay under complexity limit. Analysis of last debug log: - Hero: only 235ms active (streaming didn't deliver replace_id early) - Stories: 14.8s active — should be clearly visible - Opinions: 12.5s, LatestNews: 11.5s, Stocks: 9.8s — all long enough - Hero was the only one too fast to notice * Fix undo race, JSX pragma conflict, partial ID overlay, add deletion log - Fix undo snapshot race condition: replace shared `let beforeSnapshot` with a per-tool stack. Vercel AI SDK runs tool calls from one step via Promise.all — concurrent mutating tools overwrote the shared snapshot, causing wrong undo entries and potential content loss. - Fix 'h is not a function' JSX error: rename sucrase pragma from `h` to `__h`. When AI writes `.map((h, i) => ...)`, the loop variable shadowed the pragma, crashing render. - Fix partial node ID `0:1` in overlay: strengthen `isValidNodeId` to require both parts ≥2 chars and verify node exists in graph. During streaming, `replace_id: "0:1811"` arrives as `0:1` → `0:18` → etc. - Add deletion log to SceneGraph: `_deletionLog` records id, name, type, parentId, timestamp, and truncated stack for last 200 deletions. Included in debug log as NODE DELETION LOG section to diagnose the intermittent frame disappearance bug. * Reduce find_nodes spam and describe bloat in system prompt - Instruct AI to reuse IDs from render results and describe output instead of calling find_nodes to rediscover them (saves 8+ calls) - Recommend depth=1 for full-page describe, depth=2 only for sections * Auto-depth describe + validate-after-render workflow Describe tool now auto-picks depth based on subtree size when depth is omitted: ≤15 nodes → depth 4, ≤40 → 3, ≤100 → 2, larger → 1. Max raised to 5 for explicit overrides. Prompt updated: after each render replace_id, call describe on that block (auto depth gives full detail on small sections). Every 2-3 content renders, describe root at depth=1 to catch cross-section layout issues early. * Enforce describe-after-every-render in system prompt Show explicit render → describe → fix pattern with code example. AI was skipping per-block validation and batching all describes at the end, causing compounding layout errors. * Add describe rules: nested text, missing padding, button padding, row centering Four new layout issue detectors in describe-issues.ts: - Nested Text (error): Text node inside Text causes overflow. Suggestion: split into separate Text elements in flex row. - Zero padding container (warning): Frame with fill + text children but no padding — content touches edges. - Button without padding (warning): Button-like frame with children but zero horizontal padding. - Row without vertical centering (warning): Horizontal flex with items=start, tall container, and short children — suggests items=center. All verified with bun -e tests including false positive checks. * Fix gradient alpha loss, add undersize child detection Gradient fix: set_fill with color_end was overwriting alpha to 1 on both gradient stops (`{ ...c, a: 1 }`). Now preserves original alpha from parsed hex — #00000000 → a:0, #000000AA → a:0.667. This caused transparent-to-dark overlays to render as solid black. New describe rule: detect child significantly narrower than parent in a container without auto-layout (e.g. 100px Rectangle inside 896px Frame). Suggests adding flex + w=fill. Caught the HeroImg bug that describe previously missed. * Auto-include issues in render result, no separate describe needed Render tool now scans the created subtree for errors/warnings and includes them in the response. AI gets issues automatically without needing to call describe — eliminates the pattern where AI skips describe on 'simple' sidebar blocks. Example response: { id: '0:42', name: 'Card', issues: [ { node: 'Text', id: '0:45', message: 'no color', severity: 'error' } ] } * Run layout before collecting render issues Issues were checked on raw node sizes (default 100px) before computeAllLayouts ran in onAfterExecute. Now render calls computeAllLayouts inline before collectSubtreeIssues, so issues reflect actual computed dimensions. This fixes hundreds of false 'X 100px overflows parent' warnings that made AI chase nonexistent problems. * Revert auto-issues in render and layout-before-issues Reverts 09a9b89 and 70c116a. Auto-including issues in render result caused false positives (100px default sizes before layout) and made AI chase nonexistent problems, degrading output quality. * Clean up AI chat PR: remove debug scaffolding, add stock photo provider adapter - Delete _deletionLog (stack traces on every node deletion) - Delete aiOverlayLog and all debug push() calls - Delete succesful_prompt_diffucult_ux.md (raw debug dump) and tokens.md (empty) - Remove double-rAF hack from ai-adapter (added 32ms to every tool call) - Restore simple beforeSnapshot for undo (was matching by tool name) - Remove 70-line overlay state tracker from ChatPanel.vue - Remove Math.* regex hack from calc (expr-eval has builtins) - Refactor stock-photo into provider adapter: Pexels + Unsplash - Add Unsplash key management to settings UI and use-chat composable --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-15 12:42:41 +00:00
test('without measurer, HEIGHT text estimates multi-line wrapping', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 269,
height: 400,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
})
graph.createNode('TEXT', frame.id, {
width: 269,
height: 20,
text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected',
fontSize: 15,
lineHeight: 22,
textAutoResize: 'HEIGHT' as const,
})
setTextMeasurer(null)
computeAllLayouts(graph)
const children = graph.getChildren(frame.id)
const updatedText = children[0]
// 74 chars × 15 × 0.6 = 666px single line, in 269px → ~3 lines × 22px = 66px
expect(updatedText.height).toBeGreaterThan(22)
expect(updatedText.width).toBe(269)
})
test('text with w="fill" in flex="col" stretches to parent width', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 400,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 20,
paddingRight: 20,
})
const text = graph.createNode('TEXT', frame.id, {
width: 100,
height: 20,
text: 'This text should fill the parent width',
fontSize: 14,
textAutoResize: 'HEIGHT' as const,
layoutAlignSelf: 'STRETCH' as const,
})
setTextMeasurer((_node, maxWidth) => {
const w = maxWidth ?? 260
return { width: w, height: 20 }
})
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = graph.getNode(text.id)!
// Should stretch to 300 - 20 - 20 = 260, NOT stay at 100
expect(updatedText.width).toBe(260)
})
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
test('HEIGHT auto-resize text wraps via MeasureFunc when filling parent', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 200,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
})
const text = graph.createNode('TEXT', frame.id, {
width: 300,
height: 20,
text: 'Long text that should wrap within the available width',
fontSize: 14,
textAutoResize: 'HEIGHT' as const,
})
setTextMeasurer((_node, maxWidth) => {
const w = maxWidth ?? 1e6
if (w >= 300) return { width: 300, height: 20 }
return { width: w, height: 60 }
})
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = graph.getNode(text.id)!
expect(updatedText.width).toBe(300)
expect(updatedText.height).toBe(20)
})
test('MeasureFunc receives constraint width from flex layout', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 400,
height: 200,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 10,
})
rect(graph, frame.id, 100, 50)
const text = graph.createNode('TEXT', frame.id, {
width: 500,
height: 20,
text: 'Wide text',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const,
layoutGrow: 1,
})
const receivedWidths: number[] = []
setTextMeasurer((_node, maxWidth) => {
if (maxWidth !== undefined) receivedWidths.push(Math.round(maxWidth))
const w = maxWidth ?? 500
return { width: Math.min(200, w), height: w < 200 ? 40 : 20 }
})
computeAllLayouts(graph)
setTextMeasurer(null)
// 400 - 100 - 10 = 290 available for the fill text
expect(receivedWidths.length).toBeGreaterThan(0)
const updatedText = graph.getNode(text.id)!
expect(updatedText.width).toBe(290)
})
test('textAutoResize NONE skips MeasureFunc', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 100,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
})
const text = graph.createNode('TEXT', frame.id, {
width: 150,
height: 40,
text: 'Fixed text',
fontSize: 14,
textAutoResize: 'NONE' as const,
})
let measureCalled = false
setTextMeasurer(() => {
measureCalled = true
return { width: 80, height: 20 }
})
computeAllLayouts(graph)
setTextMeasurer(null)
expect(measureCalled).toBe(false)
const updatedText = graph.getNode(text.id)!
expect(updatedText.width).toBe(150)
expect(updatedText.height).toBe(40)
})
})
describe('min/max constraints', () => {
test('maxWidth clamps child in horizontal layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
})
rect(graph, frame.id, 50, 50, { layoutGrow: 1, maxWidth: 200 })
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.width).toBe(200)
})
test('minWidth prevents shrinking below minimum', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 100,
height: 100,
})
rect(graph, frame.id, 200, 50, { minWidth: 150 })
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.width).toBeGreaterThanOrEqual(150)
})
test('maxHeight clamps child in vertical layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
})
rect(graph, frame.id, 50, 50, { layoutGrow: 1, maxHeight: 150 })
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.height).toBe(150)
})
test('minHeight enforces minimum in vertical layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 200,
height: 400,
})
rect(graph, frame.id, 50, 30, { minHeight: 80 })
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.height).toBe(80)
})
test('min/max on nested auto-layout frame', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 500,
height: 200,
})
const inner = autoFrame(graph, outer.id, {
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 50,
height: 50,
layoutGrow: 1,
maxWidth: 250,
})
rect(graph, inner.id, 30, 30)
computeLayout(graph, outer.id)
const innerNode = graph.getNode(inner.id)!
expect(innerNode.width).toBe(250)
})
})
describe('counterAxisAlignContent', () => {
test('SPACE_BETWEEN distributes wrapped rows evenly', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 200,
height: 300,
layoutWrap: 'WRAP',
counterAxisAlignContent: 'SPACE_BETWEEN' as const,
})
rect(graph, frame.id, 120, 40)
rect(graph, frame.id, 120, 40)
rect(graph, frame.id, 120, 40)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// 3 rows of 40px each = 120px total, 300 - 120 = 180px free space
// SPACE_BETWEEN: first row at 0, last row at 260
expect(children[0].y).toBe(0)
expect(children[2].y).toBe(260)
// Middle row centered: (0 + 260) / 2 = 130
expect(children[1].y).toBe(130)
})
test('AUTO (default) packs wrapped rows at start', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 200,
height: 300,
layoutWrap: 'WRAP',
})
rect(graph, frame.id, 120, 40)
rect(graph, frame.id, 120, 40)
rect(graph, frame.id, 120, 40)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(0)
expect(children[1].y).toBe(40)
expect(children[2].y).toBe(80)
})
})
describe('layoutAlignSelf extended values', () => {
test('layoutAlignSelf CENTER positions child at cross-axis center', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
counterAxisAlign: 'MIN',
})
rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'CENTER' as const })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(75)
expect(children[1].y).toBe(0)
})
test('layoutAlignSelf MAX positions child at cross-axis end', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
counterAxisAlign: 'MIN',
})
rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MAX' as const })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(150)
expect(children[1].y).toBe(0)
})
test('layoutAlignSelf MIN overrides parent STRETCH', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
counterAxisAlign: 'STRETCH',
})
rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MIN' as const })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(0)
expect(children[0].height).toBe(50)
expect(children[1].height).toBe(200)
})
test('layoutAlignSelf in vertical layout', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 300,
height: 400,
counterAxisAlign: 'MIN',
})
rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'CENTER' as const })
rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MAX' as const })
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(125)
expect(children[1].x).toBe(250)
})
})
describe('FILL flexBasis', () => {
test('FILL children with different content sizes get equal width', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
})
const inner1 = autoFrame(graph, frame.id, {
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 100,
height: 50,
layoutGrow: 1,
})
rect(graph, inner1.id, 100, 50)
const inner2 = autoFrame(graph, frame.id, {
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 200,
height: 50,
layoutGrow: 1,
})
rect(graph, inner2.id, 200, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].width).toBe(200)
expect(children[1].width).toBe(200)
})
test('nested FILL children distribute from zero basis', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 300,
height: 100,
itemSpacing: 0,
})
autoFrame(graph, frame.id, {
primaryAxisSizing: 'FILL' as const,
counterAxisSizing: 'FIXED',
width: 50,
height: 100,
})
autoFrame(graph, frame.id, {
primaryAxisSizing: 'FILL' as const,
counterAxisSizing: 'FIXED',
width: 50,
height: 100,
})
autoFrame(graph, frame.id, {
primaryAxisSizing: 'FILL' as const,
counterAxisSizing: 'FIXED',
width: 50,
height: 100,
})
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].width).toBe(100)
expect(children[1].width).toBe(100)
expect(children[2].width).toBe(100)
})
})
describe('hidden children preserve dimensions', () => {
test('re-showing a hidden child restores original size', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 300,
height: 100,
})
const child = rect(graph, frame.id, 80, 60)
computeLayout(graph, frame.id)
expect(graph.getNode(child.id)!.width).toBe(80)
graph.updateNode(child.id, { visible: false })
computeLayout(graph, frame.id)
expect(graph.getNode(child.id)!.width).toBe(80)
expect(graph.getNode(child.id)!.height).toBe(60)
graph.updateNode(child.id, { visible: true })
computeLayout(graph, frame.id)
expect(graph.getNode(child.id)!.width).toBe(80)
expect(graph.getNode(child.id)!.height).toBe(60)
})
})
describe('absolute children in Yoga tree', () => {
test('absolute children do not affect auto-layout flow', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
})
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 100, 100, { layoutPositioning: 'ABSOLUTE', x: 300, y: 150 })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
expect(f.width).toBe(100)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[2].x).toBe(50)
expect(children[1].x).toBe(300)
expect(children[1].y).toBe(150)
})
test('absolute children preserve position when parent resizes', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 200,
itemSpacing: 10,
})
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 80, 40, { layoutPositioning: 'ABSOLUTE', x: 200, y: 100 })
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(200)
expect(children[1].y).toBe(100)
expect(children[1].width).toBe(80)
expect(children[1].height).toBe(40)
})
})
})
CSS Grid layout mode (#73) * Switch to @open-pencil/yoga-layout with CSS Grid support Use our fork (open-pencil/yoga, grid branch) which cherry-picks the upstream CSS Grid PRs (#1893–#1898) onto current main. The JS bindings are ported from the old embind approach to the new wasm_bridge.c API. npm:@open-pencil/yoga-layout alias keeps all imports as 'yoga-layout'. Also handle FinalizationRegistry change (upstream #1908) — node.free() no longer exists, nodes are garbage collected automatically. * Add CSS Grid layout mode Scene graph: - LayoutMode gains 'GRID' option alongside HORIZONTAL/VERTICAL - GridTrack type (sizing: FIXED/FR/AUTO, value) for track definitions - GridPosition type (column, row, columnSpan, rowSpan) for children - New node props: gridTemplateColumns/Rows, gridColumnGap, gridRowGap, gridPosition Layout engine: - buildGridTree() sets Display.Grid and maps GridTrack[] to Yoga's setGridTemplateColumns/Rows API with FR/Points/Auto track types - Grid children use gridPosition for column/row placement with span - Flex layout path unchanged Store: - setLayoutMode('GRID') auto-creates NxM track grid based on child count (sqrt heuristic), defaults to 1fr tracks UI (LayoutSection.vue): - Grid button (grid-2x2 icon) added to flow direction row - Columns/Rows track editors: ScrubInput for value + AppSelect for sizing mode (Fill fr / Fixed px / Auto), add/remove buttons - Separate column gap and row gap ScrubInputs - Wrap button hidden when grid is active - Flex alignment grid hidden when grid is active Kiwi serialization skips GRID mode for now (no .fig codec support). * Add grid layout integration tests 12 new test scenarios covering: - Basic 2x2 grid, fixed columns, mixed fr/fixed, unequal fr weights - Column gap, row gap, both gaps combined - Padding offsets - Explicit gridPosition placement, column span, row span - Absolute children skipped - Hidden children collapsed - Nested grid inside flex parent (computeAllLayouts) Also fix: grid frames as children of flex parents now correctly use Display.Grid via configureChildAsGrid() instead of falling through to configureChildAsAutoLayout() which set FlexDirection. * Deduplicate grid yoga configuration Extract configureAsGrid() and createGridChildNode() shared by buildGridTree (root-level grid) and configureChildAsGrid (grid nested inside flex parent). * Add grid layout to changelog * Add grid support to JSX and Tailwind CSS export OpenPencil format: grid → columns/rows/columnGap/rowGap props, child colStart/rowStart/colSpan/rowSpan. Tailwind format: grid grid-cols-N grid-rows-N gap-x-* gap-y-*, mixed tracks use arbitrary values (grid-cols-[200px_1fr_auto]), child col-start-*/row-start-*/col-span-*/row-span-*. Both formats: padding emitted for grid frames (shared with flex), flex-only props (justify/items/wrap/gap) scoped to isFlex. * Remove duplicate gridTrackToTw, use shared formatTrack Also drop unused GridTrackSizing import from LayoutSection.vue. * Fix grid icon: use layout-grid instead of grid-2x2 unplugin-icons can't resolve icon names with digits after hyphens (grid-2x2 → grid2x2 which doesn't exist). * Polish layout UI and fix auto-layout behavior - Replace text labels with compact icons: ↔/↕ for gap, ☐ for uniform padding, T/R/B/L for per-side padding - Direction-aware gap icon: ↕ for vertical, ↔ for horizontal - Pin +/− padding toggle button right of gap input - Fix alignment grid axes for vertical layout (transpose primary/counter) - Fix grid switch: set FIXED sizing, compute frame size from children - Remove hardcoded white fill from Shift+A wrap - Auto-detect horizontal vs vertical from selection bounds * Add flex-to-grid switch integration test Verify HUG frame expands and children are placed in 2x2 grid when switching from vertical flex to grid layout. * Update changelog for grid layout
2026-03-08 15:23:53 +00:00
function gridFrame(
graph: SceneGraph,
parentId: string,
columns: GridTrack[],
rows: GridTrack[],
overrides: Partial<SceneNode> = {}
): SceneNode {
return graph.createNode('FRAME', parentId, {
layoutMode: 'GRID',
width: 400,
height: 300,
gridTemplateColumns: columns,
gridTemplateRows: rows,
gridColumnGap: 0,
gridRowGap: 0,
...overrides,
})
}
function fr(value = 1): GridTrack {
return { sizing: 'FR', value }
}
function fixed(value: number): GridTrack {
return { sizing: 'FIXED', value }
}
describe('Grid Layout', () => {
describe('basic grid', () => {
test('places children in 2x2 equal columns', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr(), fr()])
for (let i = 0; i < 4; i++) rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// Row 1: (0,0) and (200,0)
expect(children[0].x).toBe(0)
expect(children[0].y).toBe(0)
expect(children[1].x).toBe(200)
expect(children[1].y).toBe(0)
// Row 2: (0,150) and (200,150)
expect(children[2].x).toBe(0)
expect(children[2].y).toBe(150)
expect(children[3].x).toBe(200)
expect(children[3].y).toBe(150)
})
test('fixed column widths', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fixed(100), fixed(300)], [fr()])
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[0].width).toBe(50)
expect(children[1].x).toBe(100)
expect(children[1].width).toBe(50)
})
test('mixed fr and fixed columns', () => {
const graph = new SceneGraph()
// 400px wide: 100px fixed + 300px remaining as 1fr
const frame = gridFrame(graph, pageId(graph), [fixed(100), fr()], [fr()])
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(100)
})
test('unequal fr columns', () => {
const graph = new SceneGraph()
// 1fr + 2fr = 3fr total → 133.33px + 266.67px
const frame = gridFrame(
graph, pageId(graph),
[fr(1), fr(2)],
[fr()],
{ width: 300, height: 100 },
)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBeCloseTo(100, 0)
})
})
describe('gaps', () => {
test('column gap', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr(), fr()],
[fr()],
{ gridColumnGap: 20 },
)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// (400 - 20) / 2 = 190 per column
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(210)
})
test('row gap', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr()],
[fr(), fr()],
{ gridRowGap: 20, height: 200 },
)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(0)
// (200 - 20) / 2 = 90 per row
expect(children[1].y).toBe(110)
})
test('both column and row gaps', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr(), fr()],
[fr(), fr()],
{ gridColumnGap: 10, gridRowGap: 10, width: 210, height: 210 },
)
for (let i = 0; i < 4; i++) rect(graph, frame.id, 30, 30)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// (210 - 10) / 2 = 100 per col, (210 - 10) / 2 = 100 per row
expect(children[0].x).toBe(0)
expect(children[0].y).toBe(0)
expect(children[1].x).toBe(110)
expect(children[1].y).toBe(0)
expect(children[2].x).toBe(0)
expect(children[2].y).toBe(110)
expect(children[3].x).toBe(110)
expect(children[3].y).toBe(110)
})
})
describe('padding', () => {
test('padding offsets grid content', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr()],
[fr()],
{ paddingTop: 10, paddingLeft: 20, paddingRight: 30, paddingBottom: 40 },
)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(20)
expect(child.y).toBe(10)
})
})
describe('explicit placement', () => {
test('gridPosition places child at specific cell', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr(), fr()], [fr(), fr()])
// Place in column 2, row 1 (0-indexed internally, but yoga uses 1-indexed)
rect(graph, frame.id, 50, 50, {
gridPosition: { column: 2, row: 1, columnSpan: 1, rowSpan: 1 },
})
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
// Column 2 starts at 400/3 ≈ 133px
expect(child.x).toBeCloseTo(133, 0)
expect(child.y).toBe(0)
})
test('column span stretches across multiple columns', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr(), fr(), fr()],
[fr()],
{ width: 300, height: 100 },
)
rect(graph, frame.id, 50, 50, {
gridPosition: { column: 1, row: 1, columnSpan: 2, rowSpan: 1 },
})
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(0)
// Child keeps its own 50px width since it's not set to fill
expect(child.width).toBe(50)
})
test('row span stretches across multiple rows', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr()],
[fr(), fr(), fr()],
{ width: 100, height: 300 },
)
rect(graph, frame.id, 50, 50, {
gridPosition: { column: 1, row: 1, columnSpan: 1, rowSpan: 2 },
})
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.y).toBe(0)
expect(child.height).toBe(50)
})
})
describe('absolute children', () => {
test('absolute children are skipped in grid layout', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()])
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50, { layoutPositioning: 'ABSOLUTE', x: 300, y: 200 })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(300)
expect(children[1].y).toBe(200)
expect(children[2].x).toBe(200)
})
})
describe('hidden children', () => {
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
test('hidden children preserve size in grid', () => {
CSS Grid layout mode (#73) * Switch to @open-pencil/yoga-layout with CSS Grid support Use our fork (open-pencil/yoga, grid branch) which cherry-picks the upstream CSS Grid PRs (#1893–#1898) onto current main. The JS bindings are ported from the old embind approach to the new wasm_bridge.c API. npm:@open-pencil/yoga-layout alias keeps all imports as 'yoga-layout'. Also handle FinalizationRegistry change (upstream #1908) — node.free() no longer exists, nodes are garbage collected automatically. * Add CSS Grid layout mode Scene graph: - LayoutMode gains 'GRID' option alongside HORIZONTAL/VERTICAL - GridTrack type (sizing: FIXED/FR/AUTO, value) for track definitions - GridPosition type (column, row, columnSpan, rowSpan) for children - New node props: gridTemplateColumns/Rows, gridColumnGap, gridRowGap, gridPosition Layout engine: - buildGridTree() sets Display.Grid and maps GridTrack[] to Yoga's setGridTemplateColumns/Rows API with FR/Points/Auto track types - Grid children use gridPosition for column/row placement with span - Flex layout path unchanged Store: - setLayoutMode('GRID') auto-creates NxM track grid based on child count (sqrt heuristic), defaults to 1fr tracks UI (LayoutSection.vue): - Grid button (grid-2x2 icon) added to flow direction row - Columns/Rows track editors: ScrubInput for value + AppSelect for sizing mode (Fill fr / Fixed px / Auto), add/remove buttons - Separate column gap and row gap ScrubInputs - Wrap button hidden when grid is active - Flex alignment grid hidden when grid is active Kiwi serialization skips GRID mode for now (no .fig codec support). * Add grid layout integration tests 12 new test scenarios covering: - Basic 2x2 grid, fixed columns, mixed fr/fixed, unequal fr weights - Column gap, row gap, both gaps combined - Padding offsets - Explicit gridPosition placement, column span, row span - Absolute children skipped - Hidden children collapsed - Nested grid inside flex parent (computeAllLayouts) Also fix: grid frames as children of flex parents now correctly use Display.Grid via configureChildAsGrid() instead of falling through to configureChildAsAutoLayout() which set FlexDirection. * Deduplicate grid yoga configuration Extract configureAsGrid() and createGridChildNode() shared by buildGridTree (root-level grid) and configureChildAsGrid (grid nested inside flex parent). * Add grid layout to changelog * Add grid support to JSX and Tailwind CSS export OpenPencil format: grid → columns/rows/columnGap/rowGap props, child colStart/rowStart/colSpan/rowSpan. Tailwind format: grid grid-cols-N grid-rows-N gap-x-* gap-y-*, mixed tracks use arbitrary values (grid-cols-[200px_1fr_auto]), child col-start-*/row-start-*/col-span-*/row-span-*. Both formats: padding emitted for grid frames (shared with flex), flex-only props (justify/items/wrap/gap) scoped to isFlex. * Remove duplicate gridTrackToTw, use shared formatTrack Also drop unused GridTrackSizing import from LayoutSection.vue. * Fix grid icon: use layout-grid instead of grid-2x2 unplugin-icons can't resolve icon names with digits after hyphens (grid-2x2 → grid2x2 which doesn't exist). * Polish layout UI and fix auto-layout behavior - Replace text labels with compact icons: ↔/↕ for gap, ☐ for uniform padding, T/R/B/L for per-side padding - Direction-aware gap icon: ↕ for vertical, ↔ for horizontal - Pin +/− padding toggle button right of gap input - Fix alignment grid axes for vertical layout (transpose primary/counter) - Fix grid switch: set FIXED sizing, compute frame size from children - Remove hardcoded white fill from Shift+A wrap - Auto-detect horizontal vs vertical from selection bounds * Add flex-to-grid switch integration test Verify HUG frame expands and children are placed in 2x2 grid when switching from vertical flex to grid layout. * Update changelog for grid layout
2026-03-08 15:23:53 +00:00
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()])
rect(graph, frame.id, 50, 50, { visible: false })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
expect(children[0].width).toBe(50)
expect(children[0].height).toBe(50)
CSS Grid layout mode (#73) * Switch to @open-pencil/yoga-layout with CSS Grid support Use our fork (open-pencil/yoga, grid branch) which cherry-picks the upstream CSS Grid PRs (#1893–#1898) onto current main. The JS bindings are ported from the old embind approach to the new wasm_bridge.c API. npm:@open-pencil/yoga-layout alias keeps all imports as 'yoga-layout'. Also handle FinalizationRegistry change (upstream #1908) — node.free() no longer exists, nodes are garbage collected automatically. * Add CSS Grid layout mode Scene graph: - LayoutMode gains 'GRID' option alongside HORIZONTAL/VERTICAL - GridTrack type (sizing: FIXED/FR/AUTO, value) for track definitions - GridPosition type (column, row, columnSpan, rowSpan) for children - New node props: gridTemplateColumns/Rows, gridColumnGap, gridRowGap, gridPosition Layout engine: - buildGridTree() sets Display.Grid and maps GridTrack[] to Yoga's setGridTemplateColumns/Rows API with FR/Points/Auto track types - Grid children use gridPosition for column/row placement with span - Flex layout path unchanged Store: - setLayoutMode('GRID') auto-creates NxM track grid based on child count (sqrt heuristic), defaults to 1fr tracks UI (LayoutSection.vue): - Grid button (grid-2x2 icon) added to flow direction row - Columns/Rows track editors: ScrubInput for value + AppSelect for sizing mode (Fill fr / Fixed px / Auto), add/remove buttons - Separate column gap and row gap ScrubInputs - Wrap button hidden when grid is active - Flex alignment grid hidden when grid is active Kiwi serialization skips GRID mode for now (no .fig codec support). * Add grid layout integration tests 12 new test scenarios covering: - Basic 2x2 grid, fixed columns, mixed fr/fixed, unequal fr weights - Column gap, row gap, both gaps combined - Padding offsets - Explicit gridPosition placement, column span, row span - Absolute children skipped - Hidden children collapsed - Nested grid inside flex parent (computeAllLayouts) Also fix: grid frames as children of flex parents now correctly use Display.Grid via configureChildAsGrid() instead of falling through to configureChildAsAutoLayout() which set FlexDirection. * Deduplicate grid yoga configuration Extract configureAsGrid() and createGridChildNode() shared by buildGridTree (root-level grid) and configureChildAsGrid (grid nested inside flex parent). * Add grid layout to changelog * Add grid support to JSX and Tailwind CSS export OpenPencil format: grid → columns/rows/columnGap/rowGap props, child colStart/rowStart/colSpan/rowSpan. Tailwind format: grid grid-cols-N grid-rows-N gap-x-* gap-y-*, mixed tracks use arbitrary values (grid-cols-[200px_1fr_auto]), child col-start-*/row-start-*/col-span-*/row-span-*. Both formats: padding emitted for grid frames (shared with flex), flex-only props (justify/items/wrap/gap) scoped to isFlex. * Remove duplicate gridTrackToTw, use shared formatTrack Also drop unused GridTrackSizing import from LayoutSection.vue. * Fix grid icon: use layout-grid instead of grid-2x2 unplugin-icons can't resolve icon names with digits after hyphens (grid-2x2 → grid2x2 which doesn't exist). * Polish layout UI and fix auto-layout behavior - Replace text labels with compact icons: ↔/↕ for gap, ☐ for uniform padding, T/R/B/L for per-side padding - Direction-aware gap icon: ↕ for vertical, ↔ for horizontal - Pin +/− padding toggle button right of gap input - Fix alignment grid axes for vertical layout (transpose primary/counter) - Fix grid switch: set FIXED sizing, compute frame size from children - Remove hardcoded white fill from Shift+A wrap - Auto-detect horizontal vs vertical from selection bounds * Add flex-to-grid switch integration test Verify HUG frame expands and children are placed in 2x2 grid when switching from vertical flex to grid layout. * Update changelog for grid layout
2026-03-08 15:23:53 +00:00
})
})
describe('flex-to-grid switch', () => {
test('HUG frame expands to fit children in grid', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const frame = autoFrame(graph, page, {
layoutMode: 'VERTICAL',
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
width: 100,
height: 100,
})
rect(graph, frame.id, 80, 80)
rect(graph, frame.id, 80, 80)
rect(graph, frame.id, 80, 80)
rect(graph, frame.id, 80, 80)
computeLayout(graph, frame.id)
const hugNode = graph.getNode(frame.id)!
expect(hugNode.width).toBe(80)
expect(hugNode.height).toBe(320)
const children = graph.getChildren(frame.id)
const cols = Math.max(2, Math.ceil(Math.sqrt(children.length)))
const rows = Math.max(1, Math.ceil(children.length / cols))
const maxChildW = Math.max(...children.map((c) => c.width))
const maxChildH = Math.max(...children.map((c) => c.height))
graph.updateNode(frame.id, {
layoutMode: 'GRID',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: maxChildW * cols,
height: maxChildH * rows,
gridTemplateColumns: Array.from({ length: cols }, () => ({ sizing: 'FR' as const, value: 1 })),
gridTemplateRows: Array.from({ length: rows }, () => ({ sizing: 'FR' as const, value: 1 })),
gridColumnGap: 0,
gridRowGap: 0,
})
computeLayout(graph, frame.id)
const gridNode = graph.getNode(frame.id)!
expect(gridNode.width).toBe(160)
expect(gridNode.height).toBe(160)
const gridChildren = graph.getChildren(frame.id)
expect(gridChildren[0].x).toBe(0)
expect(gridChildren[0].y).toBe(0)
expect(gridChildren[1].x).toBe(80)
expect(gridChildren[1].y).toBe(0)
expect(gridChildren[2].x).toBe(0)
expect(gridChildren[2].y).toBe(80)
expect(gridChildren[3].x).toBe(80)
expect(gridChildren[3].y).toBe(80)
})
})
describe('computeAllLayouts with grid', () => {
test('grid frames are computed in bottom-up pass', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const outer = autoFrame(graph, page, {
layoutMode: 'VERTICAL',
width: 400,
height: 600,
itemSpacing: 10,
})
const inner = gridFrame(graph, outer.id, [fr(), fr()], [fr()], {
width: 380,
height: 100,
})
rect(graph, inner.id, 50, 50)
rect(graph, inner.id, 50, 50)
rect(graph, outer.id, 100, 50)
computeAllLayouts(graph)
const innerChildren = graph.getChildren(inner.id)
expect(innerChildren[0].x).toBe(0)
expect(innerChildren[1].x).toBe(190)
const outerChildren = graph.getChildren(outer.id)
expect(outerChildren[0].y).toBe(0)
expect(outerChildren[1].y).toBe(110)
})
})
Fix auto-layout overflow, grid layout, AI chat UX (#86) * Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap Layout engine (layout.ts): - Use Yoga MeasureFunc for text nodes instead of static pre-measurement, so text wraps correctly when width is determined by flex layout - Add min/max width/height constraint support (minWidth, maxWidth, etc.) - Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts - Extract configureFlexContainer to share between root and nested frames JSX renderer (render/renderer.ts): - Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout - Fix text auto-resize: set HEIGHT when text fills parent in auto-layout Text measurer (renderer/renderer.ts): - Accept optional maxWidth parameter for constraint-based measurement * AI prompt: prefer describe over export_image for post-render verification * Strip TS casts from AI-generated JSX before sucrase parse AI models sometimes emit `as any`, `as const` etc. in JSX props. Sucrase with jsx-only transform can't handle these, causing parse errors. - Strip TypeScript cast expressions before passing to sucrase - Add forbidden patterns to AI prompt: as any, template literals for sizes, Math.random() * Cache text MeasureFunc results to avoid repeated buildParagraph calls Yoga calls MeasureFunc multiple times per node during calculateLayout. Each call was creating a full CanvasKit Paragraph — expensive for layouts with many text nodes. Cache by rounded constraint width. * AI chat: fix provider settings popover, add max output tokens, fix paste in chat input - Fix ProviderSettings popover not visible (remove tooltip/popover trigger conflict, add collision-padding, isolate z-[51]) - Add configurable max output tokens (default 16384) in provider settings - Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls - Add system prompt size limits: split render calls at ~40 elements - Fix paste/copy/cut in chat input (stop event propagation to canvas handler) * Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children Layout engine (layout.ts): - Guard applyYogaLayout to preserve hidden children dimensions - Add setFlexBasis(0) for FILL sizing so children share space from zero - Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly - Replace hardcoded STRETCH checks with mapAlignSelf for full range - Use setWidthStretch/setHeightStretch for grid children instead of flexGrow - Insert absolute children in Yoga tree via configureAbsoluteChild with PositionType.Absolute, keeping manual x/y (no write-back) Types (scene-graph.ts): - Add SPACE_EVENLY to LayoutAlign union - New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE Import (kiwi-convert.ts): - Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN - New mapAlignSelf function for full StackCounterAlign range Tools (modify.ts): - Expand set_layout align enum with SPACE_EVENLY - Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE Tests: 86 pass (+13 new covering all 6 fixes) * Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells Renderer: - Parse grid/columns/rows/columnGap/rowGap/gap props - Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan - Grid prop takes precedence over padding-triggered auto-layout - SPACE_EVENLY added to ALIGN_MAP - Numeric columns shorthand (columns={3} → 3×1fr) - Auto-height: grid without rows sets height=0 for Yoga auto-sizing - fill sizing in grid children → layoutAlignSelf: STRETCH - Component builders accept variadic children: Frame(props, ...children) Layout: - Grid auto-height: skip setHeight when no gridTemplateRows - Write back computed height for auto-height grids - Grid children with layoutMode stretch width to fill cell - recomputeGridChild: re-run flex layout after grid assigns cell size (temporarily sets sizing to FIXED so HUG doesn't override grid width) Export: - Skip height export for auto-height grids (no rows template) Tests: 18 new grid render tests covering all paths * Auto-enable flex for justify/items props, discourage export_image in AI prompt * Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change * Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast * Remove SPACE_EVENLY — not a valid Figma layout value * CI: debug LFS pull from R2 * Apply maxOutputTokens dynamically via prepareCall instead of at agent creation * Fix heavy .fig parse: restore GUID/defID guards, fix optional types Commit f3eac5a removed null guards from fig-import and instance-overrides as 'unnecessary conditions', but Kiwi-decoded data can have NodeChanges without GUIDs and ComponentPropAssignments/Refs without defIDs. Made the types optional to match reality. Also fix CI LFS: add git lfs install --force before pull (R2 endpoint needs the filter registered first). * Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview Closes #91 --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-10 12:43:04 +00:00
describe('grid stretch sizing', () => {
test('STRETCH child fills grid cell', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()], {
width: 300,
height: 100,
})
rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'STRETCH' as const })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].width).toBe(150)
expect(children[0].height).toBe(100)
})
test('layoutGrow child fills grid cell', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()], {
width: 400,
height: 200,
})
rect(graph, frame.id, 50, 50, { layoutGrow: 1 })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].width).toBe(200)
expect(children[0].height).toBe(200)
})
})
describe('layoutSizingVertical with cross-axis children', () => {
test('HORIZONTAL children in VERTICAL parent respect FIXED height after layout', () => {
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const root = api.createFrame()
root.layoutMode = 'VERTICAL'
root.resize(375, 812)
api.currentPage.appendChild(root)
const statusBar = api.createFrame()
statusBar.layoutMode = 'HORIZONTAL'
root.appendChild(statusBar)
statusBar.layoutSizingHorizontal = 'FILL'
statusBar.layoutSizingVertical = 'FIXED'
statusBar.resize(375, 44)
const toolbar = api.createFrame()
toolbar.layoutMode = 'HORIZONTAL'
root.appendChild(toolbar)
toolbar.layoutSizingHorizontal = 'FILL'
toolbar.layoutSizingVertical = 'FIXED'
toolbar.resize(375, 52)
const canvas = api.createFrame()
root.appendChild(canvas)
canvas.layoutSizingHorizontal = 'FILL'
canvas.layoutSizingVertical = 'FILL'
const panel = api.createFrame()
panel.layoutMode = 'VERTICAL'
root.appendChild(panel)
panel.layoutSizingHorizontal = 'FILL'
panel.layoutSizingVertical = 'FIXED'
panel.resize(375, 220)
computeAllLayouts(graph)
const sb = graph.getNode(statusBar.id)!
const tb = graph.getNode(toolbar.id)!
const cv = graph.getNode(canvas.id)!
const pn = graph.getNode(panel.id)!
expect(sb.height).toBe(44)
expect(tb.height).toBe(52)
expect(pn.height).toBe(220)
expect(cv.height).toBe(812 - 44 - 52 - 220)
})
})
CSS Grid layout mode (#73) * Switch to @open-pencil/yoga-layout with CSS Grid support Use our fork (open-pencil/yoga, grid branch) which cherry-picks the upstream CSS Grid PRs (#1893–#1898) onto current main. The JS bindings are ported from the old embind approach to the new wasm_bridge.c API. npm:@open-pencil/yoga-layout alias keeps all imports as 'yoga-layout'. Also handle FinalizationRegistry change (upstream #1908) — node.free() no longer exists, nodes are garbage collected automatically. * Add CSS Grid layout mode Scene graph: - LayoutMode gains 'GRID' option alongside HORIZONTAL/VERTICAL - GridTrack type (sizing: FIXED/FR/AUTO, value) for track definitions - GridPosition type (column, row, columnSpan, rowSpan) for children - New node props: gridTemplateColumns/Rows, gridColumnGap, gridRowGap, gridPosition Layout engine: - buildGridTree() sets Display.Grid and maps GridTrack[] to Yoga's setGridTemplateColumns/Rows API with FR/Points/Auto track types - Grid children use gridPosition for column/row placement with span - Flex layout path unchanged Store: - setLayoutMode('GRID') auto-creates NxM track grid based on child count (sqrt heuristic), defaults to 1fr tracks UI (LayoutSection.vue): - Grid button (grid-2x2 icon) added to flow direction row - Columns/Rows track editors: ScrubInput for value + AppSelect for sizing mode (Fill fr / Fixed px / Auto), add/remove buttons - Separate column gap and row gap ScrubInputs - Wrap button hidden when grid is active - Flex alignment grid hidden when grid is active Kiwi serialization skips GRID mode for now (no .fig codec support). * Add grid layout integration tests 12 new test scenarios covering: - Basic 2x2 grid, fixed columns, mixed fr/fixed, unequal fr weights - Column gap, row gap, both gaps combined - Padding offsets - Explicit gridPosition placement, column span, row span - Absolute children skipped - Hidden children collapsed - Nested grid inside flex parent (computeAllLayouts) Also fix: grid frames as children of flex parents now correctly use Display.Grid via configureChildAsGrid() instead of falling through to configureChildAsAutoLayout() which set FlexDirection. * Deduplicate grid yoga configuration Extract configureAsGrid() and createGridChildNode() shared by buildGridTree (root-level grid) and configureChildAsGrid (grid nested inside flex parent). * Add grid layout to changelog * Add grid support to JSX and Tailwind CSS export OpenPencil format: grid → columns/rows/columnGap/rowGap props, child colStart/rowStart/colSpan/rowSpan. Tailwind format: grid grid-cols-N grid-rows-N gap-x-* gap-y-*, mixed tracks use arbitrary values (grid-cols-[200px_1fr_auto]), child col-start-*/row-start-*/col-span-*/row-span-*. Both formats: padding emitted for grid frames (shared with flex), flex-only props (justify/items/wrap/gap) scoped to isFlex. * Remove duplicate gridTrackToTw, use shared formatTrack Also drop unused GridTrackSizing import from LayoutSection.vue. * Fix grid icon: use layout-grid instead of grid-2x2 unplugin-icons can't resolve icon names with digits after hyphens (grid-2x2 → grid2x2 which doesn't exist). * Polish layout UI and fix auto-layout behavior - Replace text labels with compact icons: ↔/↕ for gap, ☐ for uniform padding, T/R/B/L for per-side padding - Direction-aware gap icon: ↕ for vertical, ↔ for horizontal - Pin +/− padding toggle button right of gap input - Fix alignment grid axes for vertical layout (transpose primary/counter) - Fix grid switch: set FIXED sizing, compute frame size from children - Remove hardcoded white fill from Shift+A wrap - Auto-detect horizontal vs vertical from selection bounds * Add flex-to-grid switch integration test Verify HUG frame expands and children are placed in 2x2 grid when switching from vertical flex to grid layout. * Update changelog for grid layout
2026-03-08 15:23:53 +00:00
})