openpencil/tests/engine/layout.test.ts
Danila Poyarkov 6545f20c53
Refactor architecture boundaries across core, app, and packages (#234)
* refactor(core): decompose editor factory and action modules

Split the monolithic editor factory and large action modules into focused
domain helpers:

- create.ts assembles context through bridge modules (clipboard,
  components, structure, undo) and delegates to graph-reads, graph-events,
  layout-runner, component-sync, and state factory
- structure.ts delegates to group, container-wrap, auto-layout-wrap,
  reorder, and state-toggle helpers
- selection.ts delegates to hit-test, overlays, container navigation,
  and read helpers
- clipboard.ts delegates to subtree-history, images, export, copy,
  fonts, and placement helpers
- shapes.ts delegates to pen actions and section-adopt
- components.ts delegates to focus and instances helpers
- alignment.ts delegates to flip-rotate helper
- text.ts uses explicit TextEditSession for snapshot comparison

New focused modules: nudge, variable-bindings, layout-mode,
page-viewports, tool-registry, color-space

Undo: history/position and history/snapshot helpers, hardened
batch/rollback with nested batch support and configurable limit

* refactor(core): split tool definitions by domain

Split the monolithic tool registry into domain-specific modules:

- read/ — selection, find, pages, fonts, components, nodes, query, jsx
- create/ — basic shapes, components, vector, JSX render
- modify/ — paint, effects, geometry, layout, state, text, update
- structure/ — basic, arrange, batch, hierarchy, replace, tree
- variables/ — bindings, collections, read, values
- vector/ — boolean, path, export, viewport
- analyze/ — colors, typography, spacing, clusters, diff, eval
- describe/ — summaries, tree, roles, layout-issues
- stock-photo/ — providers, requests, apply
- codegen/ — component-map, tokens

Split registry into core/extended tiers; refine schema and AI adapter

* refactor(core): restructure kiwi codec and instance overrides

Reorganize the Kiwi .fig codec into domain subdirectories:

- binary/ — codec, schema, protocol
- fig/ — file, import, parse (core, worker, transfer)
- node-change/ — convert, export-node, serialize, plugin-data
- instance-overrides/ — constraints, dsd, populate, props, resolve,
  symbol-overrides, symbol-props, sync, types

Vendored kiwi-schema/ left isolated

* refactor(core): split profiler, icons, IO, and add subpath exports

Profiler: speedscope-export, capture-session, hud-controller
Icons: api, svg, types, render, create-icons tool
IO: format registry and subpath exports
Canvas/color/text/vector: targeted cleanup

Add deliberate subpath exports: random, xpath, vector, color, canvas,
scene-graph, kiwi, design-jsx, io, tools, editor, layout, canvaskit,
profiler, text, lint, rpc, figma-api, constants

* refactor(vue): decompose canvas input, surface lifecycle, and controls

Canvas surface: gl-surface, kit-loader, render-loop, resize-observer

Canvas input handlers:
- move: drop-target, move-snap, duplicate-drag
- select: select-move, select-hover, select-hit
- resize: resize-rect, resize-vector, resize-start
- transform: rotation, marquee, pan, text-selection
- text-edit: navigation, clipboard, textarea lifecycle
- Shared: click-count, space-key, pan, pan-zoom, draw, raf-scheduler

Editor composition:
- commands split: actions, context, metadata, edit, selection, view
- menu-model split: command-groups, builders, types
- Gradient stop composable reuse in primitive root

Controls: fill, layout, typography, appearance, effects, stroke,
okhcl, prop-scrub, node-props, undo-batch, color-variable-binding
Variables/i18n/document/export helpers

Organize canvas, primitives, controls, editor, and variables into
cohesive module directories with package-local import aliases

Expose MenuActionNode/MenuSeparatorNode from public API

* refactor(app): split document IO, editor session, and automation bridge

Document IO: source-state, naming, writer, reload-source, reload-state,
imported-document, watch-targets, save-targets

Editor session: create, modules, types, accessors, computed, refs
Editor canvas: loader-overlay, collaboration-awareness,
context-selection, menu-actions, menu-model

Automation bridge: eval, tools, exports, files, selection, RPC fallback

AI/ACP: transport, map-update, permission, debug, chat effects/storage
Collab: awareness, graph-bindings, yjs-sync, follow, session, types
Shell keyboard: actions, bindings, clipboard, focus, nudging,
raw-events, registry, reserved, shortcuts, space-tool
Shell menu: app-menu, document-name, entry, files

Demo: colors, effects, helpers, section builders (components,
app-preview, effects, standalone, variables) — document.ts reduced
from 981 to 32 lines as pure orchestrator

Move app modules under src/app/ with organized domain structure:
editor, document, ai, collab, shell, automation, demo, tabs

* refactor(app): decompose UI components with provide/inject context

Split monolithic components using Reka UI-inspired namespace folders
with scoped provide/inject context — no prop drilling:

- CollabPanel/ — context, avatars, share, connected, join
- ColorPickerPanel/ — context, area, format, field groups, sliders
- MobileHud/ — context, action toast, tool badge, file menu, presence
- ProviderSettings/ — context, API key/type, endpoint, tokens, photos
- Toolbar/ — actions, types, desktop, mobile, tool button, flyout
- LayoutSection/ — types, auto-layout, flex, grid, padding, size, clip

Properties helpers: fill-okhcl adapter, fill-label, color-style-row
Menu: entry helpers, document-name rename, stale type removal

* refactor(mcp): split server into focused modules

- browser-rpc — WebSocket client management
- mcp-sessions — session lifecycle
- tool-output — response formatting
- tool-schema — Zod schema generation from ToolDefs
- jsx-preprocess — JSX source transformation
- result — result helpers
- tool-registration — MCP tool wiring
- auth — API key validation
- http-options — CORS/request handling
- stdio-bridge — stdio transport adapter

* refactor(cli): split analyze subcommands and shared helpers

- Analyze subcommands: clusters, colors, spacing, typography
- RPC data loading helper
- Migrate imports to targeted core subpath exports

* refactor(docs): split VitePress config and shared table component

Config helpers: sdk-sidebar, seo, labels, sidebars, locale-theme,
root-theme, locales

Shared SdkDataTable component replaces duplicated table markup in
SdkPropsTable, SdkEventsTable, and SdkSlotsTable

Update contributing and testing docs

* refactor(tauri): decompose desktop entrypoint

Split lib.rs into focused service modules:

- fig_container.rs — .fig archive/compression commands
- fonts.rs — font cache and system font enumeration
- menu.rs — native menu construction
- menu_events.rs — menu event dispatch and devtools toggle
- window.rs — main window show/focus lifecycle

* test: share domain test factories and migrate fixtures

New shared helpers:
- tests/helpers/scene.ts — makeSceneGraph factory
- tests/helpers/vector-network.ts — vertex/segment/network builders
- tests/helpers/fig-traversal.ts — all-node collection, type counts
- tests/helpers/undo.ts — undo test utilities
- tests/helpers/editor-history.ts — editor history test helpers

Migrate render, vector, fig-roundtrip, and undo tests to use shared
factories instead of inline fixture construction

* build: add structural lint rules, split vite config, update docs

Structural lint (oxlint.structure.json + lint/plugin.js):
- 20+ custom rules enforcing package boundaries, lifecycle patterns,
  naming conventions, and import discipline

Vite config split: raw-markdown, canvaskit-assets, pwa, server,
aliases, automation plugins

Remove legacy shims and utils superseded by SDK/core modules
Update AGENTS.md, CONTRIBUTING.md, eval-command docs, tsconfig

* fix(vue): normalize canvas directory casing and remove duplicate export

- Rename Canvas/ to canvas/ in git index to match #vue/canvas/* imports
  (PascalCase was correct for component primitives but canvas/ is a
  non-component domain directory)
- Remove duplicate ./random subpath export in core package.json

* fix: add #vue and #core Vite resolve aliases for dev server

* refactor(core): reduce remaining large modules

Split the remaining large core hotspots into cohesive domain modules while preserving public facades and behavior.

- Extract scene graph types, variables, node defaults, and vector-network helpers
- Decompose canvas renderer orchestration, state, paints, colors, lifecycle, labels, and delegated domain methods into renderer/ and labels/ subfolders
- Split Kiwi node-change, binary variable binding, layout, RPC, vector, JSX export, clipboard, design JSX, and Figma proxy helpers
- Replace collision-driven *Fn import aliases with namespace imports and enforce the pattern in lint

Validation:
- bun run check
- bun --filter @open-pencil/vue build
- bun run test:dupes

* fix(app): forward color input attrs

* fix(app): cover section drawing errors

* fix(editor): undo option-drag duplicates

* docs: document domain subfolder convention

* fix(app): handle undo redo on keydown

* refactor(app): dispatch shortcuts from keydown

* refactor: group prefixed domain modules

* refactor(app): use tinykeys for shortcuts

* refactor(core): group symbol override modules

* refactor(core): group fig kiwi container helper

* refactor(canvas): split overlay rendering modules

* refactor(vue): remove unused internal barrels

* fix(app): lay out demo components before instancing

* fix(app): restore demo badge spacing

* perf(canvas): split scene and overlay rendering

* refactor(vue): wrap wheel gesture lifecycle

* fix(canvas): wait for fonts before hiding loader

* docs: update unreleased changelog
2026-04-30 15:14:19 +03:00

2244 lines
65 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, createEditor, setTextMeasurer, FigmaAPI, readFigFile } from '@open-pencil/core'
import { createEditorStore } from '@/app/editor/session'
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,
})
}
async function loadFixtureGraph(name: string) {
const path = new URL(`../fixtures/${name}`, import.meta.url)
const buffer = await Bun.file(path).arrayBuffer()
const file = new File([buffer], name, { type: 'application/octet-stream' })
return readFigFile(file)
}
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)
})
test('positions children right-to-left when layoutDirection is RTL', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
width: 300,
height: 80,
layoutDirection: 'RTL',
paddingLeft: 20,
paddingRight: 30,
itemSpacing: 10
})
rect(graph, frame.id, 50, 30)
rect(graph, frame.id, 60, 30)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(220)
expect(children[1].x).toBe(150)
})
test('inherits RTL flow when layoutDirection is AUTO inside an RTL parent', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 320,
height: 120,
layoutDirection: 'RTL'
})
const inner = autoFrame(graph, outer.id, {
width: 240,
height: 80,
layoutDirection: 'AUTO'
})
rect(graph, inner.id, 50, 30)
rect(graph, inner.id, 60, 30)
computeAllLayouts(graph, outer.id)
const children = graph.getChildren(inner.id)
expect(children[0].x).toBe(190)
expect(children[1].x).toBe(130)
})
test('allows nested frame to override parent flow direction', () => {
const graph = new SceneGraph()
const outer = autoFrame(graph, pageId(graph), {
width: 320,
height: 120,
layoutDirection: 'LTR'
})
const inner = autoFrame(graph, outer.id, {
width: 240,
height: 80,
layoutDirection: 'RTL'
})
rect(graph, inner.id, 50, 30)
rect(graph, inner.id, 60, 30)
computeAllLayouts(graph, outer.id)
const children = graph.getChildren(inner.id)
expect(children[0].x).toBe(190)
expect(children[1].x).toBe(130)
})
test('recomputes deep auto descendants when parent flow changes', () => {
const graph = new SceneGraph()
const outer = graph.createNode('FRAME', pageId(graph), {
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: 320,
height: 200,
layoutDirection: 'LTR'
})
const wrapper = graph.createNode('FRAME', outer.id, {
layoutMode: 'NONE',
width: 280,
height: 120
})
const inner = autoFrame(graph, wrapper.id, {
width: 240,
height: 80,
layoutDirection: 'AUTO'
})
rect(graph, inner.id, 50, 30)
rect(graph, inner.id, 60, 30)
const editor = createEditor({ graph })
editor.updateNodeWithUndo(outer.id, { layoutDirection: 'RTL' }, 'Change layout direction')
const children = graph.getChildren(inner.id)
expect(children[0].x).toBe(190)
expect(children[1].x).toBe(130)
})
})
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', () => {
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)
expect(children[0].width).toBe(50)
expect(children[0].height).toBe(50)
expect(children[1].x).toBe(0)
expect(children[1].width).toBe(80)
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)
})
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)!
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('opening imported fig keeps stored text bounds before CanvasKit measurement', async () => {
const graph = await loadFixtureGraph('gold-preview.fig')
const store = createEditorStore(graph)
const title = [...store.graph.getAllNodes()].find(
(node) => node.type === 'TEXT' && node.text === "World's largest"
)
const subtitle = [...store.graph.getAllNodes()].find(
(node) =>
node.type === 'TEXT' && node.text === 'Preline UI Figma - crafted with Tailwind CSS styles'
)
const description = [...store.graph.getAllNodes()].find(
(node) =>
node.type === 'TEXT' &&
node.text.startsWith('Preline UI Figma is the largest free design system for Figma')
)
if (!title || !subtitle || !description) {
throw new Error('Expected imported text nodes in gold-preview.fig')
}
expect(title.width).toBe(444)
expect(title.height).toBe(73)
expect(subtitle.width).toBe(439)
expect(subtitle.height).toBe(22)
expect(description.width).toBe(878)
expect(description.height).toBe(60)
await store.switchPage(store.graph.getPages()[0].id)
expect(store.graph.getNode(title.id)?.width).toBe(444)
expect(store.graph.getNode(title.id)?.height).toBe(73)
expect(store.graph.getNode(subtitle.id)?.width).toBe(439)
expect(store.graph.getNode(subtitle.id)?.height).toBe(22)
expect(store.graph.getNode(description.id)?.width).toBe(878)
expect(store.graph.getNode(description.id)?.height).toBe(60)
})
test('imported nested instance layout keeps hidden sibling offsets stable', async () => {
const graph = await loadFixtureGraph('gold-preview.fig')
const previewRoot = graph.getChildren(graph.getPages()[0].id)[0]
const wysiwygEditor = graph.getChildren(previewRoot.id).find((node) => node.name === '_WYSIWYG-editor')
const toolbarVariant = wysiwygEditor
? graph.getChildren(wysiwygEditor.id).find((node) => node.name === '_on-text-WYSIWYG-toolbar')
: undefined
const toolbarRow = toolbarVariant
? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar')
: undefined
const hiddenInput = toolbarRow
? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input')
: undefined
const visibleToolbar = toolbarRow
? graph.getChildren(toolbarRow.id).find(
(node) => node.name === 'Toolbar' && node.id !== toolbarRow.id
)
: undefined
if (!toolbarRow || !hiddenInput || !visibleToolbar) {
throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig')
}
expect(hiddenInput.visible).toBe(false)
expect(visibleToolbar.x).toBe(8)
computeAllLayouts(graph, graph.getPages()[0].id)
expect(graph.getNode(visibleToolbar.id)?.x).toBe(8)
expect(graph.getNode(visibleToolbar.id)?.y).toBe(8)
})
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 preserves stored dimensions from .fig import', () => {
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]
expect(updatedText.width).toBe(200)
expect(updatedText.height).toBe(20)
})
test('without measurer, text with 100x100 default falls back to estimate', () => {
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: 100,
height: 100,
text: 'Test',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const,
})
setTextMeasurer(null)
computeAllLayouts(graph)
const children = graph.getChildren(frame.id)
const updatedText = children[0]
expect(updatedText.width).toBeLessThan(100)
expect(updatedText.width).toBeGreaterThan(0)
expect(updatedText.height).toBeLessThan(100)
expect(updatedText.height).toBeGreaterThan(0)
})
test('without measurer, HEIGHT text preserves stored height from .fig import', () => {
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: 66,
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]
expect(updatedText.height).toBe(66)
expect(updatedText.width).toBe(269)
})
test('without measurer, HEIGHT text with 100x100 default falls back to estimate', () => {
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: 100,
height: 100,
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]
expect(updatedText.height).toBeGreaterThan(22)
})
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)
})
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)
})
})
})
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', () => {
test('hidden children preserve size in grid', () => {
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)
expect(children[0].width).toBe(50)
expect(children[0].height).toBe(50)
})
})
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)
})
})
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)
})
})
})