openpencil/tests/e2e/properties/panel.spec.ts
Danila Poyarkov 7ad6475e2b
feat: create, fill, and edit slots (#862)
* feat(app): add slot property controls and a shared picker

AppPicker is a searchable, grouped list that opens beside the properties
panel, built on Reka's popover and listbox so search keeps arrow-key
navigation. It has comfortable rows with a thumbnail and description and
compact rows for plain names, plus an optional footer action.

The slot property row shows whether an instance's slot is Default or
Modified, its item count, its limits with a checklist popover, Add
instances on AppPicker, and Reset slot and Delete contents. These are
presentational; wiring them to the editor follows. Strings are English
only until the locale files catch up.

* feat(app): open variable, style, and instance-swap choices in the shared picker

The variable binding picker, the shared style fields, and instance-swap
properties now open AppPicker: a titled panel beside the properties panel
with search that keeps arrow-key navigation, a check on the current
choice, and footer actions. Variable binding keeps its detach and
create-variable actions. Instance-swap choices list the property's
preferred components first; instanceSwapOptions keeps the preferred flag
it already computed. AppPickerField gives select-shaped fields the same
picker with a combobox trigger.

* feat(core): edit instance slot content

Only an instance's slots take layers now. slotScope classifies a parent as
free, a slot of an instance, or the locked rest of an instance. Moves,
reorders, layer-panel drops, paste, duplicate, and instance creation
claim an untouched slot first, as Figma does on the first edit, and
refuse the locked part; drops over it land in the instance's parent.
Claiming keeps the layers but unlinks them from the component and moves
the instance's overrides on nested instances onto those instances.

Reset slot, Delete contents, and Add instance are editor actions, each
one undo step that restores the instance's subtree. A canvas drop or
reorder that claims a slot undoes together with the move.

* feat(app): show and edit slot properties of the selected instance

The component properties section lists each slot of the selected instance
with its state, item count and limits, and adds instances, resets or clears
its content through the editor's slot actions. The slot model lives in the
Vue SDK as useSlotProperties.

* feat(app): outline slots on the canvas and mark them in the layers panel

Hovering or selecting a component, an instance, or a layer inside a slot
draws its slots with a dashed pink outline and tints empty ones. Slot
frames are selected and hovered in pink and show a dashed-square icon in
the layers panel.

* test(app): cover slot outlines with canvas snapshots

* feat(app): translate slot and picker strings

* docs: note slot editing and the shared picker

* refactor: share slot test and story setup

* refactor(app): name the picker's header prop heading

* feat(core): create, configure, and remove slots on main components

A frame of a main component becomes a slot through a SLOT property
named after it; other sibling layers are first wrapped in an auto
layout frame. Slot settings and removal are single undo steps.
Instances now follow the component's property bindings on sync, so a
slot created or removed on the component reaches existing instances.
Slot helpers move to a slots domain folder in Scene Graph and Core.

* feat(app): create and configure slots from the menu and properties panel

Create slot joins the canvas context menu for layers of a main
component. A Slots section on main components and their frames
renames slots, sets their description, layer limits, and preferred
components, and removes them.

* fix: keep slot claims in the same undo step and refuse wraps inside instances

Creating an instance and pasting HTML now batch the slot claim with the
edit. Undoing an added slot instance restores the previous selection.
Grouping or wrapping layers in the locked part of an instance is
refused, and a section that cannot move no longer claims a slot. The
picker clears its search however it closes, and its close and slot
actions labels are translated.

* feat(core): create and inspect slots through the plugin API

component.createSlot() adds a 100x100 frame named Slot, Slot 2, and so
on, bound to a new SLOT property, as live Figma does. Slot frames read
type SLOT, resetSlot() restores an instance slot's component content,
and limitViolations reports BELOW_MIN, ABOVE_MAX, and
HAS_NON_PREFERRED for instance slots. addComponentProperty and
editComponentProperty take a description and slotSettings, a cloned
slot is a plain frame, and componentPropertyReferences uses property
keys in both directions. The limit checks move to Scene Graph so the
Vue SDK and the plugin API share them.

* fix: keep nested slot content across swaps and read nested instance properties

A nested instance points at the instance it was cloned from, so its
component properties resolved to nothing. Properties now resolve through
those links to the main component, and a swap or variant switch parks
the slot content nested instances own and restores it into nested
instances of the same names, as live Figma does. Plugin appendChild and
insertChild claim the slot they add to and refuse the locked part of an
instance with Figma's error.

* test(core): record resetSlot on a main component slot; fix the Slots roadmap row

* fix(core): refuse deleting layers outside an instance's slots

As in Figma, delete and plugin remove() leave an instance's own layers
and its slot frames alone, while removing slot content claims the slot
in the same undo step as the delete.

* test(app): wait for the bulk rename dialog to close before the next shortcut

* feat(app): unify add, remove, and settings controls in the properties panel

A section's + adds an item and a row's - removes it everywhere: grid
tracks and variant properties now follow the fill and effect lists, and
the header + of a component set adds Property 1 ready to rename instead
of an inline form. Removing a variant is Delete, as for any layer.
Row settings share the sliders icon, the variables button opens with
its own icon, every icon button requires a label, and the instance
header buttons use sentence case. Create Slot joins the app menu.

* docs: note the unified properties panel controls

* feat(app): animate floating panels alike and share the severity icon

Popovers, menus, selects, comboboxes, and pickers now fade and grow in
from the side they open on and fade out on close, from one motion
preset that respects reduced motion; tooltips keep the tooltip preset.
SeverityIcon and its colours move from the design check to shared
feedback UI, and slot limits use them, so a broken limit reads like a
design-check warning.

* docs: note consistent popover and menu animation

* feat(app): give every floating panel one surface and close it at once

Popovers, menus, selects, comboboxes, pickers, presence cards, chat
history, and the issue tooltip share one rounded surface with a 1px
ring that outlines it in both themes; one-line tooltips keep a compact
shape with the same edge. The select theme's radius and elevation
options and local shadow overrides are gone. Panels still grow in from
their side but now close immediately: a fading modal menu kept blocking
the canvas and shortcuts until it unmounted.

* fix(app): keep a reopened context menu open and name option-drag undo Duplicate

Closing the canvas menu hands focus back to the canvas; when that
landed just after a quick reopen, the new menu closed as focus moved
outside it. Shortcuts no longer wait for a panel that is already
closing, and an option-drag duplicate that claims a slot is undone as
Duplicate rather than Move.

* test(app): wait for menus and popovers to close before the next key
2026-10-04 17:02:28 +00:00

510 lines
20 KiB
TypeScript

import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { propertyField, propertyItems, propertySection } from '#tests/helpers/properties'
import { getPageChildren, getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup()
test('property sections are always expanded (no collapse on title click)', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const section = propertySection(editor.page, 'Appearance')
const blendMode = section.getByRole('combobox', { name: 'Blend mode' })
await expect(blendMode).toBeVisible()
// Section titles are static (Figma-like): clicking must not collapse.
await section.getByText('Appearance', { exact: true }).first().click()
await expect(blendMode).toBeVisible()
})
test('appearance fields share control height and show variable actions', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const section = propertySection(editor.page, 'Appearance')
const controls = [
section.getByRole('combobox', { name: 'Blend mode' }),
section.getByRole('spinbutton', { name: 'Opacity' }),
section.getByRole('spinbutton', { name: 'Radius' }),
section.getByRole('spinbutton', { name: 'Corner smoothing' })
]
for (const control of controls) {
await expect(control).toHaveCSS('height', '24px')
}
const applyVariable = section.getByRole('button', { name: 'Apply variable' }).first()
await expect(applyVariable).toBeVisible()
await expect(applyVariable).toHaveCSS('opacity', '1')
await expect(applyVariable).toHaveCSS('width', '20px')
await expect(applyVariable).toHaveCSS('height', '20px')
await applyVariable.hover()
await expect(applyVariable).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
// The shared picker opens beside the panel, to the left of its trigger, like Figma's.
await applyVariable.click()
const picker = editor.page.getByRole('dialog', { name: 'Apply variable' })
await expect(picker).toBeVisible()
await expect(picker).toHaveAttribute('data-side', 'left')
const triggerBox = expectDefined(await applyVariable.boundingBox(), 'variable trigger bounds')
const pickerBox = expectDefined(await picker.boundingBox(), 'variable picker bounds')
expect(pickerBox.x + pickerBox.width).toBeLessThanOrEqual(triggerBox.x)
await editor.page.keyboard.press('Escape')
await expect(picker).toBeHidden()
// Near the bottom of the window it shifts up to stay fully visible.
const viewport = editor.page.viewportSize()
if (!viewport) throw new Error('Viewport unavailable')
const height = Math.ceil(triggerBox.y + triggerBox.height + 24)
await editor.page.setViewportSize({ width: viewport.width, height })
try {
await applyVariable.click()
await expect(picker).toBeVisible()
const shifted = expectDefined(await picker.boundingBox(), 'shifted picker bounds')
expect(shifted.y).toBeGreaterThanOrEqual(0)
expect(shifted.y + shifted.height).toBeLessThanOrEqual(height)
await editor.page.keyboard.press('Escape')
} finally {
await editor.page.setViewportSize(viewport)
}
})
test('paint fields retain editable color and opacity widths', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const fillItem = propertyItems(editor.page, 'fills').first()
const paintField = fillItem.locator('[data-slot="paint-field"]')
await expect(paintField).toHaveCSS('height', '24px')
const colorBox = expectDefined(
await fillItem.getByRole('textbox', { name: 'Fill' }).boundingBox(),
'fill color bounds'
)
const opacityBox = expectDefined(
await fillItem.getByRole('spinbutton', { name: 'Opacity' }).boundingBox(),
'fill opacity bounds'
)
expect(colorBox.width).toBeGreaterThanOrEqual(42)
expect(opacityBox.width).toBeGreaterThanOrEqual(40)
})
test('property fields show only the most specific tooltip', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const widthField = propertyField(editor.page, 'width')
const variableTrigger = widthField.getByRole('button', { name: 'Apply variable' })
const tooltips = editor.page.getByRole('tooltip')
await widthField.hover({ position: { x: 40, y: 12 } })
await expect(tooltips).toHaveText('Width')
await expect(tooltips).toHaveCount(1)
await variableTrigger.hover()
await expect(tooltips).toHaveText('Apply variable')
await expect(tooltips).toHaveCount(1)
await editor.page.mouse.move(500, 400)
await variableTrigger.focus()
await expect(tooltips).toHaveText('Apply variable')
await expect(tooltips).toHaveCount(1)
await propertyField(editor.page, 'height').hover({ position: { x: 40, y: 12 } })
await expect(tooltips).toHaveText('Height')
await expect(tooltips).toHaveCount(1)
})
test('NumberField drag changes X position', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 80, 80)
const before = await getSelectedNode(editor.page)
const initialX = expectDefined(before, 'selected rectangle before drag').x
const xField = propertyField(editor.page, 'x')
await editor.canvas.dragNumberField(xField, 50)
const after = await getSelectedNode(editor.page)
expect(after?.x).not.toBe(initialX)
editor.canvas.assertNoErrors()
})
test('corner radius uniform sets cornerRadius', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const scrubContainer = propertyField(editor.page, 'cornerRadius')
await scrubContainer.click()
await editor.canvas.waitForRender()
const input = scrubContainer.getByRole('spinbutton', { name: 'Radius' })
await input.fill('12')
await input.press('Enter')
await editor.canvas.waitForRender()
const node = await getSelectedNode(editor.page)
expect(node?.cornerRadius).toBe(12)
editor.canvas.assertNoErrors()
})
test('independent corners toggle shows four corner inputs', async () => {
await propertySection(editor.page, 'Appearance')
.getByRole('button', { name: 'Independent corner radii' })
.click()
await editor.canvas.waitForRender()
await expect(propertyField(editor.page, 'topLeftRadius')).toBeVisible()
await expect(propertyField(editor.page, 'topRightRadius')).toBeVisible()
await expect(propertyField(editor.page, 'bottomRightRadius')).toBeVisible()
await expect(propertyField(editor.page, 'bottomLeftRadius')).toBeVisible()
editor.canvas.assertNoErrors()
})
test('fill gradient switch changes fill type', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.pressKey('Escape')
await editor.canvas.waitForRender()
// fresh rect with default solid fill
await editor.canvas.drawRect(300, 300, 80, 80)
await editor.canvas.waitForRender()
await expect(propertySection(editor.page, 'Fill')).toBeVisible({ timeout: 5000 })
const fillItem = propertyItems(editor.page, 'fills').first()
await expect(fillItem).toBeVisible({ timeout: 5000 })
const fillSwatch = fillItem.getByTestId('fill-picker-swatch')
await expect(fillSwatch).toBeVisible({ timeout: 5000 })
await fillSwatch.click()
await editor.canvas.waitForRender()
await editor.page.getByTestId('fill-picker-tab-gradient').click()
await editor.canvas.waitForRender()
const node = expectDefined(await getSelectedNode(editor.page), 'gradient-filled node')
expect(node.fills[0]?.type).toBe('GRADIENT_LINEAR')
await fillSwatch.click()
editor.canvas.assertNoErrors()
})
test('variable bind badge appears on fill', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const col = store.graph.createCollection('Colors')
const v = store.graph.createVariable('brand-red', 'COLOR', col.id, { r: 1, g: 0, b: 0, a: 1 })
const id = [...store.state.selectedIds][0]
if (!id) return
store.graph.bindVariable(id, 'fills/0/color', v.id)
store.state.sceneVersion++
})
await editor.canvas.waitForRender()
await expect(propertyItems(editor.page, 'fills').first().getByText('brand-red')).toBeVisible()
editor.canvas.assertNoErrors()
})
test('fill color can bind an existing variable', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const variableId = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const col = store.graph.createCollection('Colors')
const variable = store.graph.createVariable('test-brand-red', 'COLOR', col.id, {
r: 1,
g: 0,
b: 0,
a: 1
})
store.state.sceneVersion++
return variable.id
})
await editor.canvas.waitForRender()
const fillItem = propertyItems(editor.page, 'fills').first()
await fillItem.getByLabel('Apply variable').click()
await editor.page.getByText('test-brand-red', { exact: true }).click()
await editor.canvas.waitForRender()
await expect(fillItem.getByText('test-brand-red')).toBeVisible()
const fillSwatch = fillItem.getByTestId('fill-picker-swatch')
await expect(fillSwatch.locator('[data-slot="swatch"] > span')).toHaveCSS(
'background-color',
'rgb(255, 0, 0)'
)
await fillSwatch.click()
const redInput = editor.page.getByRole('spinbutton', { name: 'Red' })
await expect(redInput).toHaveValue('255')
await redInput.fill('0')
await redInput.press('Enter')
await fillSwatch.click()
await expect(editor.page.locator('[data-picker-content]')).toHaveCount(0)
await editor.canvas.waitForRender()
await expect(fillItem.getByText('test-brand-red')).toHaveCount(0)
const undoLabel = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.undo.undoLabel
})
expect(undoLabel).toBe('Change fill color')
const boundVariableId = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
return id ? (store.getNode(id)?.boundVariables['fills/0/color'] ?? null) : null
})
expect(boundVariableId).toBeNull()
await editor.canvas.undo()
await editor.canvas.waitForRender()
const undoLabelAfter = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.undo.undoLabel
})
expect(undoLabelAfter).toBe('Bind variable')
const restoredBinding = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
return id ? store.getNode(id)?.boundVariables['fills/0/color'] : undefined
})
expect(restoredBinding).toBe(variableId)
await expect(fillItem.getByText('test-brand-red')).toBeVisible()
editor.canvas.assertNoErrors()
})
test('bound fill picker opens non-destructively and Escape rolls back color edits', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const before = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const collection = store.graph.createCollection('Colors')
const variable = store.graph.createVariable('rollback-brand', 'COLOR', collection.id, {
r: 1,
g: 0,
b: 0,
a: 1
})
const id = [...store.state.selectedIds][0]
if (!id) throw new Error('Expected selected node')
store.graph.bindVariable(id, 'fills/0/color', variable.id)
store.state.sceneVersion++
const node = store.getNode(id)
return { color: node?.fills[0]?.color, binding: node?.boundVariables['fills/0/color'] }
})
await editor.canvas.waitForRender()
const fillItem = propertyItems(editor.page, 'fills').first()
await fillItem.getByTestId('fill-picker-swatch').click()
await expect(fillItem.getByText('rollback-brand')).toBeVisible()
const opened = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
return { color: node?.fills[0]?.color, binding: node?.boundVariables['fills/0/color'] }
})
expect(opened).toEqual(before)
const area = editor.page.locator('.cursor-crosshair').first()
const box = expectDefined(await area.boundingBox(), 'color area bounds')
await editor.page.mouse.click(box.x + box.width - 8, box.y + 8)
await editor.page.keyboard.press('Escape')
await editor.canvas.waitForRender()
const after = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
return { color: node?.fills[0]?.color, binding: node?.boundVariables['fills/0/color'] }
})
expect(after).toEqual(before)
await expect(fillItem.getByText('rollback-brand')).toBeVisible()
editor.canvas.assertNoErrors()
})
test('fill color can create and bind a variable', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const fillItem = propertyItems(editor.page, 'fills').first()
await fillItem.getByLabel('Apply variable').click()
await expect(editor.page.getByText(/Create color variable from #?[0-9A-F]{6}/)).toBeVisible()
await editor.page.getByText(/Create color variable from #?[0-9A-F]{6}/).click()
await editor.page.getByPlaceholder('Variable name').fill('Surface/default')
await editor.page.getByRole('button', { name: 'Create', exact: true }).click()
await editor.canvas.waitForRender()
await expect(fillItem.getByText('Surface/default')).toBeVisible()
const boundVariable = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
if (!id) return null
const node = store.getNode(id)
const variableId = node?.boundVariables['fills/0/color']
return variableId ? store.getVariable(variableId)?.name : null
})
expect(boundVariable).toBe('Surface/default')
editor.canvas.assertNoErrors()
})
test('width can create, bind, and detach a number variable', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const widthField = propertyField(editor.page, 'width')
await widthField.getByLabel('Apply variable').click()
await editor.page.getByText('Create number variable from 80').click()
await editor.page.getByPlaceholder('Variable name').fill('Card/width')
await editor.page.getByRole('button', { name: 'Create', exact: true }).click()
await editor.canvas.waitForRender()
await expect(widthField.getByText('Card/width')).toBeVisible()
const boundVariable = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
if (!id) return null
const node = store.getNode(id)
const variableId = node?.boundVariables.width
return variableId ? store.getVariable(variableId)?.name : null
})
expect(boundVariable).toBe('Card/width')
await widthField.focus()
const widthInput = widthField.getByRole('spinbutton')
await widthInput.fill('120')
await widthInput.press('Enter')
await editor.canvas.waitForRender()
await expect(widthField.getByText('Card/width')).toHaveCount(0)
const directWidth = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
return node ? { width: node.width, binding: node.boundVariables.width ?? null } : null
})
expect(directWidth).toEqual({ width: 120, binding: null })
editor.canvas.assertNoErrors()
})
test('bound NumberField detach edit is one undo step', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
const field = propertyField(editor.page, 'cornerRadius')
await field.getByLabel('Apply variable').click()
await editor.page.getByText('Create number variable from 0').click()
await editor.page.getByPlaceholder('Variable name').fill('Radius/default')
await editor.page.getByRole('button', { name: 'Create', exact: true }).click()
await editor.canvas.waitForRender()
await expect(field.getByText('Radius/default')).toBeVisible()
const readState = () =>
editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
const node = id ? store.getNode(id) : null
const variableId = node?.boundVariables.cornerRadius
return node
? {
radius: node.cornerRadius,
binding: variableId ? store.getVariable(variableId)?.name : null
}
: null
})
await field.click({ position: { x: 40, y: 13 } })
const input = field.getByRole('spinbutton', { name: 'Radius' })
await input.press('Tab')
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
await editor.canvas.pressKey('Meta+z')
await editor.canvas.waitForRender()
expect(await readState()).toEqual({ radius: 0, binding: null })
await editor.canvas.pressKey('Meta+Shift+z')
await editor.canvas.waitForRender()
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
await field.getByLabel('Apply variable').click()
const variableSearch = editor.page.getByRole('combobox', { name: 'Search…', exact: true })
await expect(variableSearch).toBeVisible()
await variableSearch.press('Escape')
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
await field.click({ position: { x: 40, y: 13 } })
await input.fill('12')
await input.press('Escape')
await editor.canvas.waitForRender()
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
await field.click({ position: { x: 40, y: 13 } })
await input.fill('24')
await input.press('Enter')
await editor.canvas.waitForRender()
expect(await readState()).toEqual({ radius: 24, binding: null })
await editor.canvas.pressKey('Meta+z')
await editor.canvas.waitForRender()
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
editor.canvas.assertNoErrors()
})
test('alignment buttons align nodes to same X', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(50, 200, 60, 60)
await editor.canvas.drawRect(250, 200, 60, 60)
await editor.canvas.pressKey('Meta+a')
await editor.canvas.waitForRender()
await propertySection(editor.page, 'Position').getByRole('button', { name: 'Align left' }).click()
await editor.canvas.waitForRender()
const children = await getPageChildren(editor.page)
expect(children.length).toBe(2)
expect(children[0].x).toBe(children[1].x)
editor.canvas.assertNoErrors()
})
test('flip horizontal sets flipX', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80)
await propertySection(editor.page, 'Position')
.getByRole('button', { name: 'Flip horizontal' })
.click()
await editor.canvas.waitForRender()
const node = await getSelectedNode(editor.page)
expect(node?.flipX).toBe(true)
editor.canvas.assertNoErrors()
})
test('clip content checkbox toggles clipsContent', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.pressKey('f')
await editor.canvas.drag(100, 100, 300, 300)
await editor.canvas.waitForRender()
// Enable auto-layout so the clip-content checkbox is visible
await editor.canvas.pressKey('Shift+a')
await editor.canvas.waitForRender()
const before = expectDefined(await getSelectedNode(editor.page), 'selected frame before clipping')
const initialValue = before.clipsContent
await editor.page.getByTestId('clip-content-checkbox').click()
await editor.canvas.waitForRender()
const after = await getSelectedNode(editor.page)
expect(after?.clipsContent).toBe(!initialValue)
editor.canvas.assertNoErrors()
})