From 12031b76c9e0416a1c0d505a0af65780c011346e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 14:47:48 +0300 Subject: [PATCH] feat(app): migrate position and appearance panels - Move independent-corner presentation and batched mutations into AppearanceControls - Rebuild Position and Appearance with aligned panel grids and semantic locators - Replace type text headers with compact node icons and add visual coverage --- CHANGELOG.md | 1 + .../appearance-controls-root.data.ts | 5 + .../components/appearance-controls-root.md | 22 +- .../sdk/api/composables/use-appearance.md | 8 +- packages/vue/README.md | 6 +- .../vue/src/controls/appearance/helpers.ts | 99 +++-- packages/vue/src/controls/appearance/types.ts | 5 + .../vue/src/controls/node-props/helpers.ts | 2 +- packages/vue/src/index.ts | 6 + .../AppearanceControlsRoot.vue | 5 + .../primitives/AppearanceControls/index.ts | 5 + .../primitives/AppearanceControls/types.ts | 35 ++ src/components/DesignPanel.vue | 53 ++- .../properties/AppearanceSection.vue | 405 +++++++++--------- src/components/properties/PositionSection.vue | 72 ++-- .../ui/panel/PanelFoundation.stories.ts | 17 +- src/components/ui/panel/PanelHeader.vue | 47 ++ src/components/ui/panel/index.ts | 1 + src/theme/panel/header.ts | 24 ++ tests/e2e/color-picker/demo-card.spec.ts | 5 +- tests/e2e/components.spec.ts | 8 +- tests/e2e/design/panel.spec.ts | 21 +- ...-position-appearance-openpencil-darwin.png | Bin 0 -> 20893 bytes .../properties/corner-stroke-toggles.spec.ts | 35 +- tests/e2e/properties/number-field.spec.ts | 4 +- tests/e2e/properties/panel.spec.ts | 22 +- tests/e2e/properties/visibility.spec.ts | 5 +- tests/e2e/tools/section.spec.ts | 2 +- tests/engine/vue/controls/appearance.test.ts | 64 +++ 29 files changed, 631 insertions(+), 353 deletions(-) create mode 100644 packages/docs/programmable/sdk/api/components/appearance-controls-root.data.ts create mode 100644 packages/vue/src/controls/appearance/types.ts create mode 100644 packages/vue/src/primitives/AppearanceControls/types.ts create mode 100644 src/components/ui/panel/PanelHeader.vue create mode 100644 src/theme/panel/header.ts create mode 100644 tests/e2e/design/panel.spec.ts-snapshots/design-panel-position-appearance-openpencil-darwin.png create mode 100644 tests/engine/vue/controls/appearance.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c3db85e18..953ff17e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions. - Add headless PropertySection, SegmentedControl, and typed PropertyList anatomy, with controlled list events and an undo-aware OpenPencil adapter. - Refine variable-bound number fields with a quiet identity pill, one picker affordance, an accessible variable combobox, and non-destructive focus behavior. +- Redesign Position and Appearance controls with aligned panel grids, SDK-owned independent-corner state, and compact type-icon selection headers. - Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress. - Add desktop image drag-and-drop into the Tauri app window. - Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page. diff --git a/packages/docs/programmable/sdk/api/components/appearance-controls-root.data.ts b/packages/docs/programmable/sdk/api/components/appearance-controls-root.data.ts new file mode 100644 index 000000000..69fc4cd0b --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/appearance-controls-root.data.ts @@ -0,0 +1,5 @@ +import { defineComponentMetaLoader } from '#docs/sdk/component-meta' + +export default defineComponentMetaLoader([ + 'packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue' +]) diff --git a/packages/docs/programmable/sdk/api/components/appearance-controls-root.md b/packages/docs/programmable/sdk/api/components/appearance-controls-root.md index 205807c1c..b6e676f0c 100644 --- a/packages/docs/programmable/sdk/api/components/appearance-controls-root.md +++ b/packages/docs/programmable/sdk/api/components/appearance-controls-root.md @@ -1,13 +1,29 @@ --- title: AppearanceControlsRoot -description: Headless root primitive for opacity, visibility, and corner-radius controls. +description: Headless root primitive for opacity, visibility, blend mode, and corner-radius controls. --- + + # AppearanceControlsRoot -`AppearanceControlsRoot` exposes the slot contract returned by `useAppearance()` as a structural primitive. +`AppearanceControlsRoot` exposes the slot contract returned by `useAppearance()` as a structural +primitive. Use it when you want reusable appearance controls with custom presentation. -Use it when you want reusable appearance controls with custom presentation. +The root owns selection-derived presentation decisions, including `showIndependentCorners`. +That state becomes active when the selected node explicitly uses independent corners or when an +imported node contains unequal corner values with a stale uniform flag. Consumers should render +from this state rather than maintaining a parallel local expansion ref. + +Multi-node independent-corner toggles and per-corner commits are grouped into one undo entry. + +## Generated API reference + +The following tables are extracted from the Vue source and JSDoc during the documentation build. + + ## Related APIs diff --git a/packages/docs/programmable/sdk/api/composables/use-appearance.md b/packages/docs/programmable/sdk/api/composables/use-appearance.md index 974d20f8d..18287fb25 100644 --- a/packages/docs/programmable/sdk/api/composables/use-appearance.md +++ b/packages/docs/programmable/sdk/api/composables/use-appearance.md @@ -12,7 +12,8 @@ It exposes selection-derived UI state for: - visibility - opacity - corner radius -- independent corner radii +- independent corner radii, including imported unequal-corner state +- blend mode ## Usage @@ -29,6 +30,7 @@ const { visibilityState, opacityPercent, cornerRadiusValue, + showIndependentCorners, toggleVisibility, toggleIndependentCorners, } = useAppearance() @@ -49,6 +51,10 @@ appearance.updateCornerProp('topLeftRadius', 12) appearance.commitCornerProp('topLeftRadius', 12, 8) ``` +Render the per-corner editor from `showIndependentCorners`. It accounts for both the explicit +scene-node flag and imported nodes whose corner values differ. Multi-selection toggles and commits +are grouped into one undo entry. + ## Related APIs - [SDK API Overview](../) diff --git a/packages/vue/README.md b/packages/vue/README.md index b6eae7278..3f14923b8 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -96,8 +96,10 @@ adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and sa expressions such as `+10`, `*2`, `50%`, and `12*8+4`. `BindableValue` composes fields with a generic `BindingProvider` and supports detach-on-edit, read-only, and edit-variable policies. Focusing a bound NumberField is non-destructive; the configured policy begins only on the first -value mutation. `PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and -undo through `useEditorPropertyList()`. +value mutation. `AppearanceControlsRoot` exposes selection-derived independent-corner presentation +state so consumers do not need parallel expansion heuristics. `PropertyListRoot` is controlled and +editor-agnostic; OpenPencil panels connect it to selection and undo through +`useEditorPropertyList()`. ## Public API tiers diff --git a/packages/vue/src/controls/appearance/helpers.ts b/packages/vue/src/controls/appearance/helpers.ts index 6959c0de3..7ce77ddde 100644 --- a/packages/vue/src/controls/appearance/helpers.ts +++ b/packages/vue/src/controls/appearance/helpers.ts @@ -4,6 +4,7 @@ import type { ComputedRef } from 'vue' import type { Editor } from '@open-pencil/core/editor' import type { BlendMode, SceneNode } from '@open-pencil/scene-graph' +import type { CornerRadiusKey } from '#vue/controls/appearance/types' import { MIXED, type MixedValue } from '#vue/controls/node-props/use' const CORNER_RADIUS_TYPES = new Set([ @@ -25,6 +26,14 @@ type AppearanceActionOptions = AppearanceStateOptions & { editor: Editor } +function hasUnequalCorners(node: SceneNode) { + return !( + node.topLeftRadius === node.topRightRadius && + node.topLeftRadius === node.bottomRightRadius && + node.topLeftRadius === node.bottomLeftRadius + ) +} + export function createAppearanceState({ node, nodes, isMulti, merged }: AppearanceStateOptions) { const hasCornerRadius = computed(() => { if (isMulti.value) return nodes.value.every((n) => CORNER_RADIUS_TYPES.has(n.type)) @@ -36,6 +45,12 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan return node.value?.independentCorners ?? false }) + const showIndependentCorners = computed(() => { + if (isMulti.value) return false + const selected = node.value + return selected ? selected.independentCorners || hasUnequalCorners(selected) : false + }) + const cornerRadiusValue = computed(() => { if (isMulti.value) return merged('cornerRadius') return node.value?.cornerRadius ?? 0 @@ -60,6 +75,7 @@ export function createAppearanceState({ node, nodes, isMulti, merged }: Appearan return { hasCornerRadius, independentCorners, + showIndependentCorners, cornerRadiusValue, opacityPercent, blendModeValue, @@ -106,40 +122,51 @@ export function createAppearanceActions({ editor, node, nodes, isMulti }: Appear function toggleIndependentCorners() { const selected = node.value - const singleTarget = selected ? [selected] : [] - const targets = isMulti.value ? nodes.value : singleTarget - for (const n of targets) { - if (n.independentCorners) { - const uniform = n.topLeftRadius - editor.updateNodeWithUndo( - n.id, - { - independentCorners: false, - cornerRadius: uniform, - topLeftRadius: uniform, - topRightRadius: uniform, - bottomRightRadius: uniform, - bottomLeftRadius: uniform - } as Partial, - 'Uniform corner radius' - ) - } else { - editor.updateNodeWithUndo( - n.id, - { - independentCorners: true, - topLeftRadius: n.cornerRadius, - topRightRadius: n.cornerRadius, - bottomRightRadius: n.cornerRadius, - bottomLeftRadius: n.cornerRadius - } as Partial, - 'Independent corner radii' - ) + const targets = isMulti.value ? [...nodes.value] : [] + if (!isMulti.value && selected) targets.push(selected) + if (targets.length === 0) return + const makeIndependent = !targets.every( + (target) => target.independentCorners || hasUnequalCorners(target) + ) + + editor.undo.runBatch( + makeIndependent ? 'Independent corner radii' : 'Uniform corner radius', + () => { + for (const target of targets) { + if (makeIndependent) { + if (target.independentCorners) continue + editor.updateNodeWithUndo( + target.id, + { + independentCorners: true, + topLeftRadius: target.cornerRadius, + topRightRadius: target.cornerRadius, + bottomRightRadius: target.cornerRadius, + bottomLeftRadius: target.cornerRadius + } as Partial, + 'Independent corner radii' + ) + } else { + const uniform = target.topLeftRadius + editor.updateNodeWithUndo( + target.id, + { + independentCorners: false, + cornerRadius: uniform, + topLeftRadius: uniform, + topRightRadius: uniform, + bottomRightRadius: uniform, + bottomLeftRadius: uniform + } as Partial, + 'Uniform corner radius' + ) + } + } } - } + ) } - function updateCornerProp(key: string, value: number) { + function updateCornerProp(key: CornerRadiusKey, value: number) { if (isMulti.value) { for (const n of nodes.value) editor.updateNode(n.id, { [key]: value }) } else { @@ -148,11 +175,13 @@ export function createAppearanceActions({ editor, node, nodes, isMulti }: Appear } } - function commitCornerProp(key: string, _value: number, previous: number) { + function commitCornerProp(key: CornerRadiusKey, _value: number, previous: number) { if (isMulti.value) { - for (const n of nodes.value) { - editor.commitNodeUpdate(n.id, { [key]: previous } as Partial, `Change ${key}`) - } + editor.undo.runBatch(`Change ${key}`, () => { + for (const n of nodes.value) { + editor.commitNodeUpdate(n.id, { [key]: previous } as Partial, `Change ${key}`) + } + }) } else { const n = node.value if (n) { diff --git a/packages/vue/src/controls/appearance/types.ts b/packages/vue/src/controls/appearance/types.ts new file mode 100644 index 000000000..29ee745d5 --- /dev/null +++ b/packages/vue/src/controls/appearance/types.ts @@ -0,0 +1,5 @@ +export type CornerRadiusKey = + | 'topLeftRadius' + | 'topRightRadius' + | 'bottomRightRadius' + | 'bottomLeftRadius' diff --git a/packages/vue/src/controls/node-props/helpers.ts b/packages/vue/src/controls/node-props/helpers.ts index 29e1b18e1..415bebf0f 100644 --- a/packages/vue/src/controls/node-props/helpers.ts +++ b/packages/vue/src/controls/node-props/helpers.ts @@ -63,7 +63,7 @@ export function createNodePropSelectionState(store: Editor) { return store.getSelectedNodes() }) const isMulti = computed(() => nodes.value.length > 1) - const active = computed(() => node.value || isMulti.value) + const active = computed(() => node.value !== null || isMulti.value) const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null) function merged(key: K): MixedValue { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index be7177032..7df4233f5 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -139,6 +139,12 @@ export type { LayerDragInstruction, LayerTreeContext, LayerNode } from '#vue/pri export { LayoutControlsRoot, useLayoutControlsContext } from '#vue/primitives/LayoutControls' export type { LayoutControlsContext } from '#vue/primitives/LayoutControls' export { AppearanceControlsRoot } from '#vue/primitives/AppearanceControls' +export type { + AppearanceControlsActions, + AppearanceControlsRootSlotProps, + AppearanceControlsRootSlots +} from '#vue/primitives/AppearanceControls' +export type { CornerRadiusKey } from '#vue/controls/appearance/types' export { PageListRoot } from '#vue/primitives/PageList' export { PositionControlsRoot } from '#vue/primitives/PositionControls' export { useEditorPropertyList } from '#vue/controls/property-list' diff --git a/packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue b/packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue index 4fe8bff7d..2122502d5 100644 --- a/packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue +++ b/packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue @@ -1,10 +1,13 @@ @@ -38,16 +43,17 @@ const { panels } = useI18n() data-test-id="design-panel-multi" class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4" > -
- {{ panels.mixed }} - {{ - panels.layersCount({ count: String(multiCount) }) - }} - -
+ + + + {{ panels.layersCount({ count: String(multiCount) }) }} + + + @@ -62,16 +68,19 @@ const { panels } = useI18n() data-test-id="design-panel-single" class="scrollbar-thin flex-1 overflow-x-hidden overflow-y-auto pb-4" > -
- {{ - node.type - }} - {{ node.name }} - -
+ + + {{ node.name }} + +
-import { computed, ref } from 'vue' +import { computed } from 'vue' -import { MIXED, useAppearance, useI18n } from '@open-pencil/vue' +import { AppearanceControlsRoot, MIXED, useI18n } from '@open-pencil/vue' import NumberField from '@/components/inputs/NumberField.vue' import VariableNumberField from '@/components/properties/VariableNumberField.vue' import AppSelect from '@/components/ui/AppSelect.vue' import IconButton from '@/components/ui/IconButton.vue' +import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue' +import PanelGrid from '@/components/ui/panel/PanelGrid.vue' +import PanelRail from '@/components/ui/panel/PanelRail.vue' import PanelSection from '@/components/ui/panel/PanelSection.vue' -import Tip from '@/components/ui/Tip.vue' import type { BlendMode } from '@open-pencil/scene-graph' const { panels } = useI18n() - type BlendModeSelectValue = BlendMode | 'MIXED' -const blendModeOptions = computed>(() => { - const options: Array<{ value: BlendModeSelectValue; label: string }> = [ - { value: 'PASS_THROUGH', label: panels.value.blendModePassThrough }, - { value: 'NORMAL', label: panels.value.blendModeNormal }, - { value: 'DARKEN', label: panels.value.blendModeDarken }, - { value: 'MULTIPLY', label: panels.value.blendModeMultiply }, - { value: 'COLOR_BURN', label: panels.value.blendModeColorBurn }, - { value: 'LIGHTEN', label: panels.value.blendModeLighten }, - { value: 'SCREEN', label: panels.value.blendModeScreen }, - { value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge }, - { value: 'OVERLAY', label: panels.value.blendModeOverlay }, - { value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight }, - { value: 'HARD_LIGHT', label: panels.value.blendModeHardLight }, - { value: 'DIFFERENCE', label: panels.value.blendModeDifference }, - { value: 'EXCLUSION', label: panels.value.blendModeExclusion }, - { value: 'HUE', label: panels.value.blendModeHue }, - { value: 'SATURATION', label: panels.value.blendModeSaturation }, - { value: 'COLOR', label: panels.value.blendModeColor }, - { value: 'LUMINOSITY', label: panels.value.blendModeLuminosity } - ] - return blendModeValue.value === MIXED - ? [{ value: 'MIXED', label: panels.value.mixed }, ...options] - : options -}) -const { - node, - isMulti, - active, - hasCornerRadius, - independentCorners, - cornerRadiusValue, - opacityPercent, - blendModeValue, - visibilityState, - setBlendMode, - updateProp, - commitProp, - toggleVisibility, - toggleIndependentCorners, - updateCornerProp, - commitCornerProp -} = useAppearance() +const baseBlendModeOptions = computed>(() => [ + { value: 'PASS_THROUGH', label: panels.value.blendModePassThrough }, + { value: 'NORMAL', label: panels.value.blendModeNormal }, + { value: 'DARKEN', label: panels.value.blendModeDarken }, + { value: 'MULTIPLY', label: panels.value.blendModeMultiply }, + { value: 'COLOR_BURN', label: panels.value.blendModeColorBurn }, + { value: 'LIGHTEN', label: panels.value.blendModeLighten }, + { value: 'SCREEN', label: panels.value.blendModeScreen }, + { value: 'COLOR_DODGE', label: panels.value.blendModeColorDodge }, + { value: 'OVERLAY', label: panels.value.blendModeOverlay }, + { value: 'SOFT_LIGHT', label: panels.value.blendModeSoftLight }, + { value: 'HARD_LIGHT', label: panels.value.blendModeHardLight }, + { value: 'DIFFERENCE', label: panels.value.blendModeDifference }, + { value: 'EXCLUSION', label: panels.value.blendModeExclusion }, + { value: 'HUE', label: panels.value.blendModeHue }, + { value: 'SATURATION', label: panels.value.blendModeSaturation }, + { value: 'COLOR', label: panels.value.blendModeColor }, + { value: 'LUMINOSITY', label: panels.value.blendModeLuminosity } +]) -const manualExpanded = ref(null) - -const showIndependentCorners = computed(() => { - if (manualExpanded.value !== null) return manualExpanded.value - if (independentCorners.value === true) return true - const n = node.value - if (!n) return false - return !( - n.topLeftRadius === n.topRightRadius && - n.topLeftRadius === n.bottomRightRadius && - n.topLeftRadius === n.bottomLeftRadius - ) -}) - -function onToggleCorners() { - manualExpanded.value = !showIndependentCorners.value - toggleIndependentCorners() +function blendModeOptions(value: BlendMode | typeof MIXED) { + return value === MIXED + ? [{ value: 'MIXED' as const, label: panels.value.mixed }, ...baseBlendModeOptions.value] + : baseBlendModeOptions.value } - -const blendModeSelectValue = computed({ - get: () => (blendModeValue.value === MIXED ? 'MIXED' : blendModeValue.value), - set: (value) => { - if (value !== 'MIXED') setBlendMode(value) - } -}) diff --git a/src/components/properties/PositionSection.vue b/src/components/properties/PositionSection.vue index c8b9bcea5..dbea41fe6 100644 --- a/src/components/properties/PositionSection.vue +++ b/src/components/properties/PositionSection.vue @@ -1,11 +1,12 @@ + + + + diff --git a/src/components/ui/panel/index.ts b/src/components/ui/panel/index.ts index e9b7fafcd..16988854e 100644 --- a/src/components/ui/panel/index.ts +++ b/src/components/ui/panel/index.ts @@ -1,5 +1,6 @@ export { default as PanelFieldGroup } from './PanelFieldGroup.vue' export { default as PanelGrid } from './PanelGrid.vue' +export { default as PanelHeader } from './PanelHeader.vue' export { default as PanelRail } from './PanelRail.vue' export { default as PanelRow } from './PanelRow.vue' export { default as PanelSection } from './PanelSection.vue' diff --git a/src/theme/panel/header.ts b/src/theme/panel/header.ts new file mode 100644 index 000000000..ffdc63e5c --- /dev/null +++ b/src/theme/panel/header.ts @@ -0,0 +1,24 @@ +const panelHeaderTheme = { + slots: { + root: 'grid min-w-0 grid-cols-[var(--spacing-panel-icon)_minmax(0,1fr)_auto] items-center gap-panel border-b border-border px-panel-x py-panel-y text-surface', + icon: 'flex size-panel-icon items-center justify-center text-muted', + title: 'min-w-0 truncate text-xs font-semibold text-surface', + actions: + 'flex min-w-0 items-center justify-end gap-0.5 [&_[data-slot=icon-button]]:size-control [&_[data-slot=icon-button]]:rounded-panel' + }, + variants: { + component: { + true: { + icon: 'text-component', + title: 'text-component' + }, + false: {} + } + }, + defaultVariants: { + component: false + } +} + +export type PanelHeaderTheme = typeof panelHeaderTheme +export default panelHeaderTheme diff --git a/tests/e2e/color-picker/demo-card.spec.ts b/tests/e2e/color-picker/demo-card.spec.ts index af759f849..d0ac8fc2a 100644 --- a/tests/e2e/color-picker/demo-card.spec.ts +++ b/tests/e2e/color-picker/demo-card.spec.ts @@ -43,8 +43,9 @@ async function selectDemoCard(page: Parameters[0]['page'], canvas: }) await canvas.waitForRender() - await expect(page.getByTestId('design-panel-single')).toBeVisible() - await expect(page.getByTestId('design-node-header')).toContainText('Card') + const designPanel = page.getByTestId('design-panel-single') + await expect(designPanel).toBeVisible() + await expect(designPanel.getByRole('heading', { name: 'Card' })).toBeVisible() } async function getSelectedFill(page: Parameters[0]['page']) { diff --git a/tests/e2e/components.spec.ts b/tests/e2e/components.spec.ts index 06fe8ac27..99fa7c56d 100644 --- a/tests/e2e/components.spec.ts +++ b/tests/e2e/components.spec.ts @@ -52,9 +52,8 @@ test('create component from selection (⌘⌥K)', async () => { componentId = selectedId }) -test('component shows purple label in design panel', async () => { - const header = editor.page.getByTestId('design-node-header') - await expect(header).toContainText('COMPONENT') +test('component shows its type icon in the design panel', async () => { + await expect(editor.page.getByRole('img', { name: 'COMPONENT' })).toBeVisible() }) test('component visible in layers panel', async () => { @@ -103,8 +102,7 @@ test('instance shows INSTANCE type in design panel', async () => { }, instance.id) await editor.canvas.waitForRender() - const header = editor.page.getByTestId('design-node-header') - await expect(header).toContainText('INSTANCE') + await expect(editor.page.getByRole('img', { name: 'INSTANCE' })).toBeVisible() }) test('instance has "Go to Main Component" button', async () => { diff --git a/tests/e2e/design/panel.spec.ts b/tests/e2e/design/panel.spec.ts index 93ae2b39c..02ffe7f46 100644 --- a/tests/e2e/design/panel.spec.ts +++ b/tests/e2e/design/panel.spec.ts @@ -8,10 +8,6 @@ function designPanel() { return editor.page.getByTestId('design-panel-single') } -function nodeHeader() { - return editor.page.getByTestId('design-node-header') -} - function fillSection() { return editor.page.getByTestId('fill-section') } @@ -21,7 +17,7 @@ function strokeSection() { } function positionSection() { - return editor.page.getByTestId('position-section') + return propertySection(editor.page, 'Position') } function effectsSection() { @@ -73,8 +69,9 @@ test('selecting a rectangle shows design panel with type and name', async () => await editor.canvas.waitForRender() await expect(designPanel()).toBeVisible() - await expect(nodeHeader()).toContainText('RECTANGLE') - await expect(nodeHeader()).toContainText('Rectangle') + await expect(designPanel().getByRole('img', { name: 'RECTANGLE' })).toBeVisible() + await expect(designPanel().getByRole('heading', { name: 'Rectangle' })).toBeVisible() + await expect(designPanel()).toHaveScreenshot('design-panel-position-appearance.png') }) test('position section shows X, Y, rotation inputs', async () => { @@ -246,7 +243,9 @@ test('mask action toggles mask section and mask type control', async () => { }) test('visibility toggle in appearance section works', async () => { - const visBtn = editor.page.getByTestId('appearance-visibility') + const visBtn = propertySection(editor.page, 'Appearance').getByRole('button', { + name: 'Toggle visibility' + }) await expect(visBtn).toBeVisible() const id = await getSelectedId() @@ -393,10 +392,10 @@ test('multi-select shows mixed header and boolean operations', async () => { await editor.canvas.selectAll() await editor.canvas.waitForRender() - const multiHeader = editor.page.getByTestId('design-multi-header') + const multiHeader = editor.page + .getByTestId('design-panel-multi') + .getByRole('heading', { name: /layers/ }) await expect(multiHeader).toBeVisible() - await expect(multiHeader).toContainText('Mixed') - await expect(multiHeader).toContainText('layers') const booleanOperations = editor.page.getByTestId('boolean-operations-trigger') await booleanOperations.hover() diff --git a/tests/e2e/design/panel.spec.ts-snapshots/design-panel-position-appearance-openpencil-darwin.png b/tests/e2e/design/panel.spec.ts-snapshots/design-panel-position-appearance-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..0c0cf5d0863b6ae72ff416517646efe664861feb GIT binary patch literal 20893 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

XwR={pv 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 { expect(flags?.type).toBe('FRAME') expect(flags?.independentCorners).toBe(false) - const toggle = page.getByTestId('independent-corners-toggle') + const toggle = propertySection(page, 'Appearance').getByRole('button', { + name: 'Independent corner radii' + }) await expect(toggle).toBeVisible() await toggle.click() await canvas.waitForRender() expect((await getSelectedNodeFlags())?.independentCorners).toBe(true) - const grid = page.getByTestId('independent-corners-grid') + const grid = page.locator('[data-corner-grid]') await expect(grid).toBeVisible() const cornerInputs = grid.getByRole('spinbutton') expect(await cornerInputs.count()).toBe(4) @@ -65,6 +68,34 @@ test('independent corners toggle shows per-corner inputs', async () => { await expect(grid).not.toBeVisible() }) +test('multi-selection independent corners toggle is one undo step', async () => { + await canvas.clearCanvas() + await drawFrame(80, 80, 100, 70) + await drawFrame(240, 80, 100, 70) + await canvas.pressKey('Meta+a') + await canvas.waitForRender() + + const independentStates = () => + page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + return [...store.state.selectedIds].map( + (id) => store.graph.getNode(id)?.independentCorners ?? null + ) + }) + + const toggle = propertySection(page, 'Appearance').getByRole('button', { + name: 'Independent corner radii' + }) + await toggle.click() + await canvas.waitForRender() + expect(await independentStates()).toEqual([true, true]) + + await canvas.pressKey('Meta+z') + await canvas.waitForRender() + expect(await independentStates()).toEqual([false, false]) +}) + test('stroke sides toggle shows per-side weight inputs', async () => { await drawFrame(300, 50, 120, 80) await canvas.waitForRender() diff --git a/tests/e2e/properties/number-field.spec.ts b/tests/e2e/properties/number-field.spec.ts index a70448e19..2f0caa28f 100644 --- a/tests/e2e/properties/number-field.spec.ts +++ b/tests/e2e/properties/number-field.spec.ts @@ -11,7 +11,7 @@ function xField() { async function editField(field: ReturnType) { await field.click() - return field.getByRole('spinbutton', { name: 'X' }) + return field.getByRole('spinbutton', { name: 'X Axis' }) } async function numericFieldValue(field: ReturnType): Promise { @@ -30,7 +30,7 @@ test('NumberField commits arithmetic and relative expressions', async () => { let input = await editField(field) await expect(field).not.toHaveAttribute('role') await expect(input).toHaveAttribute('role', 'spinbutton') - await expect(input).toHaveAttribute('aria-label', 'X') + await expect(input).toHaveAttribute('aria-label', 'X Axis') await expect(field.getByRole('spinbutton')).toHaveCount(1) await input.fill('*2') await input.press('Enter') diff --git a/tests/e2e/properties/panel.spec.ts b/tests/e2e/properties/panel.spec.ts index 80fae4816..3e915bacf 100644 --- a/tests/e2e/properties/panel.spec.ts +++ b/tests/e2e/properties/panel.spec.ts @@ -41,7 +41,7 @@ test('corner radius uniform sets cornerRadius', async () => { const scrubContainer = propertyField(editor.page, 'cornerRadius') await scrubContainer.click() await editor.canvas.waitForRender() - const input = scrubContainer.getByRole('spinbutton', { name: 'cornerRadius' }) + const input = scrubContainer.getByRole('spinbutton', { name: 'Radius' }) await input.fill('12') await input.press('Enter') await editor.canvas.waitForRender() @@ -52,13 +52,15 @@ test('corner radius uniform sets cornerRadius', async () => { }) test('independent corners toggle shows four corner inputs', async () => { - await editor.page.getByTestId('independent-corners-toggle').click() + await propertySection(editor.page, 'Appearance') + .getByRole('button', { name: 'Independent corner radii' }) + .click() await editor.canvas.waitForRender() - await expect(editor.page.getByTestId('corner-tl-input')).toBeVisible() - await expect(editor.page.getByTestId('corner-tr-input')).toBeVisible() - await expect(editor.page.getByTestId('corner-br-input')).toBeVisible() - await expect(editor.page.getByTestId('corner-bl-input')).toBeVisible() + 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() }) @@ -246,7 +248,7 @@ test('bound NumberField detach edit is one undo step', async () => { }) await field.click({ position: { x: 40, y: 13 } }) - const input = field.getByRole('spinbutton', { name: 'cornerRadius' }) + 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') @@ -285,7 +287,7 @@ test('alignment buttons align nodes to same X', async () => { await editor.canvas.pressKey('Meta+a') await editor.canvas.waitForRender() - await editor.page.getByTestId('position-align-left').click() + await propertySection(editor.page, 'Position').getByRole('button', { name: 'Align left' }).click() await editor.canvas.waitForRender() const children = await getPageChildren(editor.page) @@ -298,7 +300,9 @@ test('flip horizontal sets flipX', async () => { await editor.canvas.clearCanvas() await editor.canvas.drawRect(200, 200, 80, 80) - await editor.page.getByTestId('position-flip-horizontal').click() + await propertySection(editor.page, 'Position') + .getByRole('button', { name: 'Flip horizontal' }) + .click() await editor.canvas.waitForRender() const node = await getSelectedNode(editor.page) diff --git a/tests/e2e/properties/visibility.spec.ts b/tests/e2e/properties/visibility.spec.ts index a7bd98fb1..2a2a2b8eb 100644 --- a/tests/e2e/properties/visibility.spec.ts +++ b/tests/e2e/properties/visibility.spec.ts @@ -1,5 +1,6 @@ import { expect, test, useEditorSetup } from '#tests/e2e/fixtures' import { expectDefined } from '#tests/helpers/assert' +import { propertySection } from '#tests/helpers/properties' import { getSelectedNode } from '#tests/helpers/store' const editor = useEditorSetup() @@ -92,7 +93,9 @@ test('multi-selection list add is one undo step', async () => { }) test('appearance visibility supports repeat click and undo redo in one step', async () => { - const visibilityButton = editor.page.getByTestId('appearance-visibility') + const visibilityButton = propertySection(editor.page, 'Appearance').getByRole('button', { + name: 'Toggle visibility' + }) await expect(visibilityButton).toBeVisible() expect(expectDefined(await getSelectedNode(editor.page), 'selected node').visible).toBe(true) diff --git a/tests/e2e/tools/section.spec.ts b/tests/e2e/tools/section.spec.ts index c80ef6ed6..2dfad9a85 100644 --- a/tests/e2e/tools/section.spec.ts +++ b/tests/e2e/tools/section.spec.ts @@ -5,7 +5,7 @@ const editor = useEditorSetupWithClear('/?test') test('draw section in full editor without browser errors', async () => { await editor.canvas.drawSection(100, 100, 240, 160) - await expect(editor.page.getByTestId('design-node-header')).toContainText('SECTION') + await expect(editor.page.getByRole('img', { name: 'SECTION' })).toBeVisible() await expect .poll(async () => { return editor.page.evaluate(() => { diff --git a/tests/engine/vue/controls/appearance.test.ts b/tests/engine/vue/controls/appearance.test.ts new file mode 100644 index 000000000..c7f89bb85 --- /dev/null +++ b/tests/engine/vue/controls/appearance.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' + +import { computed, ref } from 'vue' + +import type { SceneNode } from '@open-pencil/scene-graph' +import type { MixedValue } from '@open-pencil/vue' + +import { createAppearanceState } from '#vue/controls/appearance/helpers' + +import { createRect, firstPageId, makeSceneGraph } from '#tests/helpers/scene' + +function appearanceState(node: SceneNode, multi = false) { + const selected = ref(node) + const nodes = ref([node]) + const isMulti = ref(multi) + + function merged(key: K): MixedValue { + const current = selected.value + if (!current) throw new Error('Expected selected node') + return current[key] + } + + return createAppearanceState({ + node: computed(() => selected.value), + nodes: computed(() => nodes.value), + isMulti: computed(() => isMulti.value), + merged + }) +} + +function rectangle() { + const graph = makeSceneGraph() + return createRect(graph, firstPageId(graph)) +} + +describe('appearance control state', () => { + test('keeps equal uniform corners collapsed', () => { + const state = appearanceState(rectangle()) + expect(state.showIndependentCorners.value).toBe(false) + }) + + test('expands corners when the explicit independent flag is set', () => { + const node = rectangle() + node.independentCorners = true + const state = appearanceState(node) + expect(state.showIndependentCorners.value).toBe(true) + }) + + test('expands imported unequal corners when the explicit flag is stale', () => { + const node = rectangle() + node.independentCorners = false + node.topLeftRadius = 4 + node.topRightRadius = 12 + const state = appearanceState(node) + expect(state.showIndependentCorners.value).toBe(true) + }) + + test('leaves the per-corner editor collapsed for multi-selection', () => { + const node = rectangle() + node.independentCorners = true + const state = appearanceState(node, true) + expect(state.showIndependentCorners.value).toBe(false) + }) +})