Fix property panel visibility toggles, independent corners/stroke sides, extract shared ColorStyleRow

- Fix fill/stroke/effect/appearance visibility eye toggle (stale clone reads)
- Fix independent corner radii toggle not showing 4 inputs
- Fix stroke sides toggle: replace dropdown with direct toggle like corners
- Copy uniform weight to all sides when expanding stroke sides
- Batch multi-selection visibility toggles into single undo entry
- Extract ColorStyleRow shared by FillSection and StrokeSection
- Replace useFillVariableBinding/useStrokeVariableBinding with useColorVariableBinding(kind)
- AppearanceSection uses useAppearance() directly instead of slot wrapper
- Add sceneVersion dependency to useNodeProps and PropertyListRoot computeds
- Add E2E tests for visibility toggles and corner/stroke side toggles
This commit is contained in:
Danila Poyarkov 2026-03-27 17:06:33 +03:00
parent ae13bb1cbd
commit c1d7fc80b0
20 changed files with 738 additions and 431 deletions

View file

@ -15,7 +15,7 @@ These APIs are public, but they are more specialized than the main component and
## Picker and editor internals
- [useFillVariableBinding](./use-fill-variable-binding)
- [useColorVariableBinding](./use-color-variable-binding)
- [useFillPicker](./use-fill-picker)
- [useGradientStops](./use-gradient-stops)
- [useFontPicker](./use-font-picker)

View file

@ -0,0 +1,25 @@
---
title: useColorVariableBinding
description: Variable-binding helper for fill and stroke color editors.
---
# useColorVariableBinding
`useColorVariableBinding(kind)` exposes search, binding, and unbinding helpers for color variables used by fill and stroke editors.
Use it when building color UIs that need to connect fills or strokes to design variables.
## Usage
```ts
import { useColorVariableBinding } from '@open-pencil/vue'
const fillBinding = useColorVariableBinding('fills')
const strokeBinding = useColorVariableBinding('strokes')
```
## Related APIs
- [useFillControls](../composables/use-fill-controls)
- [useStrokeControls](../composables/use-stroke-controls)
- [FillPickerRoot](../components/fill-picker-root)

View file

@ -1,15 +0,0 @@
---
title: useFillVariableBinding
description: Variable-binding helper for fill color editors.
---
# useFillVariableBinding
`useFillVariableBinding()` exposes search, binding, and unbinding helpers for color variables used by fill editors.
Use it when building fill UIs that need to connect solid fills to design variables.
## Related APIs
- [useFillControls](../composables/use-fill-controls)
- [FillPickerRoot](../components/fill-picker-root)

View file

@ -1,13 +1,13 @@
---
title: useFillControls
description: Fill-panel composable with default fill behavior and variable binding support.
description: Fill-panel composable with default fill behavior.
---
# useFillControls
`useFillControls()` is the fill-property composable used by fill editing UIs.
It builds on variable-binding support and adds a reusable default fill value.
It adds a reusable default fill value.
## Usage
@ -19,11 +19,9 @@ const fills = useFillControls()
## What it gives you
Along with fill binding behavior, it exposes:
It exposes:
- `defaultFill`
- fill variable-binding helpers
- selection-aware fill editing state
## Practical examples

View file

@ -140,7 +140,7 @@ These exports are intentionally public, but they are lower-level or more special
- `useNodeProps()`
- `useSceneComputed()`
- `useFillVariableBinding()`
- `useColorVariableBinding()`
- `useFillPicker()`
- `useGradientStops()`
- `useFontPicker()`

View file

@ -3,6 +3,7 @@ import { computed } from 'vue'
import { useEditor } from '@open-pencil/vue/context/editorContext'
import { useNodeProps } from '@open-pencil/vue/controls/useNodeProps'
import { useSceneComputed } from '@open-pencil/vue/internal/useSceneComputed'
import { providePropertyList } from './context'
import type { Fill, Stroke, Effect, SceneNode } from '@open-pencil/core'
@ -26,16 +27,21 @@ const emit = defineEmits<{
const editor = useEditor()
const { isArrayMixed } = useNodeProps()
const selectedNodes = computed(() => editor.getSelectedNodes())
const activeNode = computed<SceneNode | null>(
() => editor.getSelectedNode() ?? selectedNodes.value[0] ?? null
)
const selectedNodes = useSceneComputed(() => {
void editor.state.sceneVersion
return editor.getSelectedNodes()
})
const activeNode = useSceneComputed<SceneNode | null>(() => {
void editor.state.sceneVersion
return editor.getSelectedNode() ?? selectedNodes.value[0] ?? null
})
const isMulti = computed(() => selectedNodes.value.length > 1)
const active = computed(() => selectedNodes.value.length > 0)
const isMixed = computed(() => isArrayMixed(propKey))
const items = computed(() => {
const items = useSceneComputed(() => {
void editor.state.sceneVersion
if (isMixed.value) return []
return (activeNode.value?.[propKey] ?? []) as ArrayItemType[]
})
@ -90,10 +96,17 @@ function patch(index: number, changes: Record<string, unknown>) {
function toggleVisibility(index: number) {
emit('toggleVisibility', index)
for (const n of targetNodes()) {
const arr = n[propKey] as Array<{ visible: boolean }>
const nodes = targetNodes()
if (nodes.length === 0) return
if (nodes.length > 1) {
editor.undo.beginBatch(`Toggle ${propKey} visibility`)
}
for (const n of nodes) {
const liveNode = editor.getNode(n.id)
if (!liveNode) continue
const arr = liveNode[propKey] as Array<{ visible: boolean }>
if (!arr[index]) continue
const newArr = [...n[propKey]] as Array<{ visible: boolean }>
const newArr = [...liveNode[propKey]] as Array<{ visible: boolean }>
newArr[index] = { ...newArr[index], visible: !arr[index].visible }
editor.updateNodeWithUndo(
n.id,
@ -101,6 +114,9 @@ function toggleVisibility(index: number) {
`Toggle ${propKey} visibility`
)
}
if (nodes.length > 1) {
editor.undo.commitBatch()
}
}
providePropertyList({

View file

@ -51,15 +51,24 @@ export function useAppearance() {
function toggleVisibility() {
if (isMulti.value) {
const allVisible = nodes.value.every((n) => n.visible)
for (const n of nodes.value) {
const liveNodes = nodes.value
.map((n) => editor.getNode(n.id))
.filter((n): n is SceneNode => n != null)
if (liveNodes.length === 0) return
const allVisible = liveNodes.every((n) => n.visible)
editor.undo.beginBatch('Toggle visibility')
for (const n of liveNodes) {
editor.updateNodeWithUndo(n.id, { visible: !allVisible }, 'Toggle visibility')
}
} else {
const n = node.value
if (!n) return
editor.updateNodeWithUndo(n.id, { visible: !n.visible }, 'Toggle visibility')
editor.undo.commitBatch()
return
}
const selected = node.value
if (!selected) return
const liveNode = editor.getNode(selected.id)
if (!liveNode) return
editor.updateNodeWithUndo(liveNode.id, { visible: !liveNode.visible }, 'Toggle visibility')
}
function toggleIndependentCorners() {

View file

@ -5,13 +5,9 @@ import { useEditor } from '@open-pencil/vue/context/editorContext'
import type { Variable } from '@open-pencil/core'
/**
* Returns helpers for binding fill colors to color variables.
*
* This composable is used by fill editing UIs that need variable search,
* binding, and unbinding behavior.
*/
export function useFillVariableBinding() {
type ColorBindingKind = 'fills' | 'strokes'
export function useColorVariableBinding(kind: ColorBindingKind) {
const store = useEditor()
const colorVariables = computed(() => store.getVariablesByType('COLOR'))
const searchTerm = ref('')
@ -21,19 +17,23 @@ export function useFillVariableBinding() {
return colorVariables.value.filter((v) => contains(v.name, searchTerm.value))
})
function bindingPath(index: number) {
return `${kind}/${index}/color`
}
function getBoundVariable(nodeId: string, index: number): Variable | undefined {
const n = store.getNode(nodeId)
if (!n) return undefined
const varId = n.boundVariables[`fills/${index}/color`]
const varId = n.boundVariables[bindingPath(index)]
return varId ? store.getVariable(varId) : undefined
}
function bindFillVariable(nodeId: string, index: number, variableId: string) {
store.bindVariable(nodeId, `fills/${index}/color`, variableId)
function bindVariable(nodeId: string, index: number, variableId: string) {
store.bindVariable(nodeId, bindingPath(index), variableId)
}
function unbindFillVariable(nodeId: string, index: number) {
store.unbindVariable(nodeId, `fills/${index}/color`)
function unbindVariable(nodeId: string, index: number) {
store.unbindVariable(nodeId, bindingPath(index))
}
return {
@ -42,7 +42,7 @@ export function useFillVariableBinding() {
searchTerm,
filteredVariables,
getBoundVariable,
bindFillVariable,
unbindFillVariable
bindVariable,
unbindVariable
}
}

View file

@ -1,6 +1,6 @@
import { DEFAULT_SHAPE_FILL } from '@open-pencil/core'
import { useFillVariableBinding } from './useFillVariableBinding'
import { useColorVariableBinding } from './useColorVariableBinding'
/**
* Returns fill-related panel helpers and a reusable default fill value.
@ -9,7 +9,7 @@ import { useFillVariableBinding } from './useFillVariableBinding'
* fill editing UIs.
*/
export function useFillControls() {
const ctx = useFillVariableBinding()
const ctx = useColorVariableBinding('fills')
return {
...ctx,

View file

@ -21,8 +21,14 @@ type ArrayItem = Fill | Stroke | Effect | Record<string, unknown>
*/
export function useNodeProps() {
const store = useEditor()
const node = useSceneComputed(() => store.getSelectedNode() ?? null)
const nodes = useSceneComputed(() => store.getSelectedNodes())
const node = useSceneComputed(() => {
void store.state.sceneVersion
return store.getSelectedNode() ?? null
})
const nodes = useSceneComputed(() => {
void store.state.sceneVersion
return store.getSelectedNodes()
})
const isMulti = computed(() => nodes.value.length > 1)
const active = computed(() => node.value || isMulti.value)
const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null)

View file

@ -52,7 +52,7 @@ export { useTypography } from './controls/useTypography'
export type { UseTypographyOptions } from './controls/useTypography'
export { useExport } from './controls/useExport'
export { useFillControls } from './controls/useFillControls'
export { useFillVariableBinding } from './controls/useFillVariableBinding'
export { useColorVariableBinding } from './controls/useColorVariableBinding'
export { useEffectsControls } from './controls/useEffectsControls'
export { useStrokeControls } from './controls/useStrokeControls'

View file

@ -34,6 +34,13 @@ const { panels } = useI18n()
swatch-class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
@update="emit('update', $event)"
>
<template #trigger="{ style }">
<button
data-test-id="fill-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
:style="style"
/>
</template>
<template #default="{ fill: currentFill, category, toSolid, toGradient, toImage, update }">
<div class="mb-2 flex items-center gap-0.5">
<Tip :label="panels.solid">

View file

@ -1,179 +1,172 @@
<script setup lang="ts">
import { AppearanceControlsRoot, useI18n } from '@open-pencil/vue'
import { computed, ref } from 'vue'
import { useAppearance, useI18n } from '@open-pencil/vue'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { sectionWrapper } from '@/components/ui/section'
const { panels } = useI18n()
const {
node,
isMulti,
active,
hasCornerRadius,
independentCorners,
cornerRadiusValue,
opacityPercent,
visibilityState,
updateProp,
commitProp,
toggleVisibility,
toggleIndependentCorners,
updateCornerProp,
commitCornerProp
} = useAppearance()
const manualExpanded = ref<boolean | null>(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()
}
</script>
<template>
<AppearanceControlsRoot
v-slot="{
node,
isMulti,
active,
hasCornerRadius,
independentCorners,
cornerRadiusValue,
opacityPercent,
visibilityState,
updateProp,
commitProp,
toggleVisibility,
toggleIndependentCorners,
updateCornerProp,
commitCornerProp
}"
>
<div v-if="active" data-test-id="appearance-section" :class="sectionWrapper()">
<div class="mb-1.5 flex items-center justify-between">
<label class="text-[11px] text-muted">{{ panels.appearance }}</label>
<Tip :label="panels.toggleVisibility">
<div v-if="active" data-test-id="appearance-section" :class="sectionWrapper()">
<div class="mb-1.5 flex items-center justify-between">
<label class="text-[11px] text-muted">{{ panels.appearance }}</label>
<Tip :label="panels.toggleVisibility">
<button
data-test-id="appearance-visibility"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-muted hover:bg-hover hover:text-surface"
:class="{ 'text-accent': visibilityState === 'hidden' }"
@click="toggleVisibility"
>
<icon-lucide-eye v-if="visibilityState === 'visible'" class="size-3.5" />
<icon-lucide-eye-off v-else-if="visibilityState === 'hidden'" class="size-3.5" />
<icon-lucide-eye v-else class="size-3.5 opacity-50" />
</button>
</Tip>
</div>
<div class="flex gap-1.5">
<ScrubInput
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</ScrubInput>
<template v-if="hasCornerRadius">
<ScrubInput
v-if="!showIndependentCorners"
:model-value="cornerRadiusValue"
:min="0"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-radius class="size-3" />
</template>
</ScrubInput>
<Tip :label="panels.independentCornerRadii">
<button
data-test-id="appearance-visibility"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-muted hover:bg-hover hover:text-surface"
:class="{ 'text-accent': visibilityState === 'hidden' }"
@click="toggleVisibility"
data-test-id="independent-corners-toggle"
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': showIndependentCorners }"
@click="onToggleCorners"
>
<icon-lucide-eye v-if="visibilityState === 'visible'" class="size-3.5" />
<icon-lucide-eye-off v-else-if="visibilityState === 'hidden'" class="size-3.5" />
<icon-lucide-eye v-else class="size-3.5 opacity-50" />
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M1 4V2.5A1.5 1.5 0 0 1 2.5 1H4" />
<path d="M8 1h1.5A2.5 2.5 0 0 1 11 3.5V5" />
<path d="M11 8v1a2 2 0 0 1-2 2H8" />
<path d="M4 11H3a2 2 0 0 1-2-2V8" />
</svg>
</button>
</Tip>
</div>
<div class="flex gap-1.5">
<ScrubInput
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</ScrubInput>
<template v-if="hasCornerRadius">
<ScrubInput
v-if="independentCorners !== true"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-radius class="size-3" />
</template>
</ScrubInput>
<Tip :label="panels.independentCornerRadii">
<button
data-test-id="independent-corners-toggle"
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': independentCorners === true }"
@click="toggleIndependentCorners"
>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M1 4V2.5A1.5 1.5 0 0 1 2.5 1H4" />
<path d="M8 1h1.5A2.5 2.5 0 0 1 11 3.5V5" />
<path d="M11 8v1a2 2 0 0 1-2 2H8" />
<path d="M4 11H3a2 2 0 0 1-2-2V8" />
</svg>
</button>
</Tip>
</template>
</div>
<div
v-if="hasCornerRadius && independentCorners === true && !isMulti"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<ScrubInput
data-test-id="corner-tl-input"
:model-value="node!.topLeftRadius"
:min="0"
@update:model-value="updateCornerProp('topLeftRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)"
>
<template #icon>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M1 11V4a3 3 0 0 1 3-3h7" />
</svg>
</template>
</ScrubInput>
<ScrubInput
data-test-id="corner-tr-input"
:model-value="node!.topRightRadius"
:min="0"
@update:model-value="updateCornerProp('topRightRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
>
<template #icon>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M11 11V4a3 3 0 0 0-3-3H1" />
</svg>
</template>
</ScrubInput>
<ScrubInput
data-test-id="corner-bl-input"
:model-value="node!.bottomLeftRadius"
:min="0"
@update:model-value="updateCornerProp('bottomLeftRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
>
<template #icon>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M1 1v7a3 3 0 0 0 3 3h7" />
</svg>
</template>
</ScrubInput>
<ScrubInput
data-test-id="corner-br-input"
:model-value="node!.bottomRightRadius"
:min="0"
@update:model-value="updateCornerProp('bottomRightRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
>
<template #icon>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M11 1v7a3 3 0 0 1-3 3H1" />
</svg>
</template>
</ScrubInput>
</div>
</template>
</div>
</AppearanceControlsRoot>
<div
v-if="hasCornerRadius && showIndependentCorners && !isMulti && node"
data-test-id="independent-corners-grid"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<ScrubInput
:model-value="node.topLeftRadius"
:min="0"
@update:model-value="updateCornerProp('topLeftRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('topLeftRadius', v, p)"
>
<template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1 11V4a3 3 0 0 1 3-3h7" />
</svg>
</template>
</ScrubInput>
<ScrubInput
:model-value="node.topRightRadius"
:min="0"
@update:model-value="updateCornerProp('topRightRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('topRightRadius', v, p)"
>
<template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 11V4a3 3 0 0 0-3-3H1" />
</svg>
</template>
</ScrubInput>
<ScrubInput
:model-value="node.bottomLeftRadius"
:min="0"
@update:model-value="updateCornerProp('bottomLeftRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('bottomLeftRadius', v, p)"
>
<template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1 1v7a3 3 0 0 0 3 3h7" />
</svg>
</template>
</ScrubInput>
<ScrubInput
:model-value="node.bottomRightRadius"
:min="0"
@update:model-value="updateCornerProp('bottomRightRadius', $event)"
@commit="(v: number, p: number) => commitCornerProp('bottomRightRadius', v, p)"
>
<template #icon>
<svg class="size-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 1v7a3 3 0 0 1-3 3H1" />
</svg>
</template>
</ScrubInput>
</div>
</div>
</template>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
import {
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { iconButton } from '@/components/ui/icon-button'
import { colorToCSS } from '@open-pencil/core'
import { useI18n } from '@open-pencil/vue'
import type { Variable } from '@open-pencil/core'
type BindingApi = {
store: {
resolveColorVariable: (id: string) => unknown
}
colorVariables: { value: Variable[] }
filteredVariables: { value: Variable[] }
searchTerm: { value: string }
getBoundVariable: (nodeId: string, index: number) => Variable | undefined
bindVariable: (nodeId: string, index: number, variableId: string) => void
unbindVariable: (nodeId: string, index: number) => void
}
const props = defineProps<{
item: { opacity: number; visible: boolean }
index: number
activeNodeId?: string | null
bindingApi: BindingApi
visibilityTestId: string
unbindTestId?: string
}>()
const emit = defineEmits<{
patch: [changes: Record<string, unknown>]
toggleVisibility: []
remove: []
}>()
const { panels, dialogs } = useI18n()
</script>
<template>
<div class="group flex items-center gap-1.5 py-0.5">
<div class="min-w-0 flex flex-1 items-center gap-1.5">
<slot />
</div>
<ScrubInput
class="w-12 shrink-0"
suffix="%"
:model-value="Math.round(item.opacity * 100)"
:min="0"
:max="100"
@update:model-value="emit('patch', { opacity: Math.max(0, Math.min(1, $event / 100)) })"
/>
<PopoverRoot
v-if="
activeNodeId &&
bindingApi.colorVariables.value.length > 0 &&
!bindingApi.getBoundVariable(activeNodeId, index)
"
>
<Tip :label="panels.applyVariable">
<PopoverTrigger
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
>
<icon-lucide-link class="size-3.5" />
</PopoverTrigger>
</Tip>
<PopoverPortal>
<PopoverContent
side="left"
:side-offset="8"
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
>
<ComboboxRoot
@update:model-value="
activeNodeId && bindingApi.bindVariable(activeNodeId, index, ($event as Variable).id)
"
>
<ComboboxInput
:model-value="bindingApi.searchTerm.value"
:placeholder="dialogs.search"
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
@update:model-value="bindingApi.searchTerm.value = String($event)"
/>
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted"
>{{ panels.noVariablesFound }}</ComboboxEmpty
>
<ComboboxItem
v-for="v in bindingApi.filteredVariables.value"
:key="v.id"
:value="v"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
>
<div
class="size-3 shrink-0 rounded-sm border border-border"
:style="{
background: bindingApi.store.resolveColorVariable(v.id)
? colorToCSS(bindingApi.store.resolveColorVariable(v.id) as never)
: '#000'
}"
/>
<span class="min-w-0 flex-1 truncate">{{ v.name }}</span>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<Tip
v-else-if="activeNodeId && bindingApi.getBoundVariable(activeNodeId, index)"
:label="panels.detachVariable"
>
<button
:data-test-id="unbindTestId"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
@click="bindingApi.unbindVariable(activeNodeId, index)"
>
<icon-lucide-unlink class="size-3" />
</button>
</Tip>
<button
:data-test-id="visibilityTestId"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="emit('toggleVisibility')"
>
<icon-lucide-eye v-if="item.visible" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</button>
<button :class="iconButton({ ui: { base: 'shrink-0' } })" @click="emit('remove')">−</button>
</div>
</template>

View file

@ -36,8 +36,8 @@ const { panels } = useI18n()
<div
v-for="(effect, i) in items as Effect[]"
:key="i"
data-test-id="effects-item"
:key="`${i}:${effect.visible ? 'visible' : 'hidden'}`"
data-test-id="effect-item"
:data-test-index="i"
>
<div class="group flex items-center gap-1.5 py-0.5">
@ -64,6 +64,7 @@ const { panels } = useI18n()
/>
<button
:data-test-id="`effect-visibility-${i}`"
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="toggleVisibility(i)"
>

View file

@ -1,29 +1,16 @@
<script setup lang="ts">
import {
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { colorToCSS, colorToHexRaw } from '@open-pencil/core'
import { colorToHexRaw } from '@open-pencil/core'
import { PropertyListRoot, useFillControls, useI18n } from '@open-pencil/vue'
import FillPicker from '@/components/FillPicker.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
import { iconButton } from '@/components/ui/icon-button'
import { sectionLabel, sectionWrapper } from '@/components/ui/section'
import type { Fill, Variable } from '@open-pencil/core'
import type { Fill } from '@open-pencil/core'
const fillCtx = useFillControls()
const { panels, dialogs } = useI18n()
const { panels } = useI18n()
</script>
<template>
@ -44,12 +31,20 @@ const { panels, dialogs } = useI18n()
</button>
</div>
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
<div
<ColorStyleRow
v-for="(fill, i) in items as Fill[]"
:key="i"
:key="`${i}:${fill.visible ? 'visible' : 'hidden'}`"
:item="fill"
:index="i"
:active-node-id="activeNode?.id ?? null"
:binding-api="fillCtx"
:visibility-test-id="`fill-visibility-${i}`"
unbind-test-id="fill-unbind-variable"
data-test-id="fill-item"
:data-test-index="i"
class="group flex items-center gap-1.5 py-0.5"
@patch="patch(i, $event)"
@toggle-visibility="toggleVisibility(i)"
@remove="remove(i)"
>
<FillPicker :fill="fill" @update="update(i, $event)" />
@ -59,15 +54,6 @@ const { panels, dialogs } = useI18n()
>
{{ fillCtx.getBoundVariable(activeNode.id, i)!.name }}
</span>
<Tip :label="panels.detachVariable">
<button
data-test-id="fill-unbind-variable"
class="cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
@click="fillCtx.unbindFillVariable(activeNode.id, i)"
>
<icon-lucide-unlink class="size-3" />
</button>
</Tip>
</template>
<template v-else>
<span class="min-w-0 flex-1 font-mono text-xs text-surface">
@ -78,83 +64,7 @@ const { panels, dialogs } = useI18n()
<template v-else>{{ fill.type }}</template>
</span>
</template>
<ScrubInput
class="w-12"
suffix="%"
:model-value="Math.round(fill.opacity * 100)"
:min="0"
:max="100"
@update:model-value="patch(i, { opacity: Math.max(0, Math.min(1, $event / 100)) })"
/>
<PopoverRoot
v-if="
fillCtx.colorVariables.value.length > 0 &&
fill.type === 'SOLID' &&
activeNode &&
!fillCtx.getBoundVariable(activeNode.id, i)
"
>
<Tip :label="panels.applyVariable">
<PopoverTrigger
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
>
<icon-lucide-link class="size-3.5" />
</PopoverTrigger>
</Tip>
<PopoverPortal>
<PopoverContent
side="left"
:side-offset="8"
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
>
<ComboboxRoot
@update:model-value="
activeNode && fillCtx.bindFillVariable(activeNode.id, i, ($event as Variable).id)
"
>
<ComboboxInput
:model-value="fillCtx.searchTerm.value"
:placeholder="dialogs.search"
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
@update:model-value="fillCtx.searchTerm.value = String($event)"
/>
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted"
>{{ panels.noVariablesFound }}</ComboboxEmpty
>
<ComboboxItem
v-for="v in fillCtx.filteredVariables.value"
:key="v.id"
:value="v"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
>
<div
class="size-3 shrink-0 rounded-sm border border-border"
:style="{
background: fillCtx.store.resolveColorVariable(v.id)
? colorToCSS(fillCtx.store.resolveColorVariable(v.id)!)
: '#000'
}"
/>
<span class="min-w-0 flex-1 truncate">{{ v.name }}</span>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<button
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="toggleVisibility(i)"
>
<icon-lucide-eye v-if="fill.visible" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</button>
<button :class="iconButton()" @click="remove(i)">−</button>
</div>
</ColorStyleRow>
</div>
</PropertyListRoot>
</template>

View file

@ -1,30 +1,34 @@
<script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { PropertyListRoot, useStrokeControls, useI18n } from '@open-pencil/vue'
import { PropertyListRoot, useColorVariableBinding, useStrokeControls, useI18n } from '@open-pencil/vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import ColorInput from '@/components/ColorInput.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { iconButton } from '@/components/ui/icon-button'
import { menu, useMenuUI } from '@/components/ui/menu'
import { sectionLabel, sectionWrapper } from '@/components/ui/section'
import type { SceneNode, Stroke } from '@open-pencil/core'
const strokeCtx = useStrokeControls()
const strokeVarCtx = useColorVariableBinding('strokes')
const { panels } = useI18n()
const sideMenuCls = useMenuUI({
content: 'min-w-[140px] rounded-md p-0.5',
item: 'relative px-2'
})
const expandedSides = ref(false)
function onToggleSides(activeNode: SceneNode) {
const next = !expandedSides.value
expandedSides.value = next
if (next && !activeNode.independentStrokeWeights) {
const weight = activeNode.strokes[0]?.weight ?? 1
strokeCtx.selectSide('CUSTOM', { ...activeNode, borderTopWeight: weight, borderRightWeight: weight, borderBottomWeight: weight, borderLeftWeight: weight } as SceneNode)
} else if (!next && activeNode.independentStrokeWeights) {
strokeCtx.selectSide('ALL', activeNode)
}
}
</script>
<template>
@ -47,31 +51,26 @@ const sideMenuCls = useMenuUI({
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
<div
<ColorStyleRow
v-for="(stroke, i) in items as Stroke[]"
:key="i"
:key="`${i}:${stroke.visible ? 'visible' : 'hidden'}`"
:item="stroke"
:index="i"
:active-node-id="activeNode?.id ?? null"
:binding-api="strokeVarCtx"
:visibility-test-id="`stroke-visibility-${i}`"
unbind-test-id="stroke-unbind-variable"
data-test-id="stroke-item"
:data-test-index="i"
class="group flex items-center gap-1.5 py-0.5"
@patch="patch(i, $event)"
@toggle-visibility="toggleVisibility(i)"
@remove="remove(i)"
>
<ColorInput
class="min-w-0 flex-1"
:color="stroke.color"
editable
@update="patch(i, { color: $event })"
/>
<button
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="toggleVisibility(i)"
>
<icon-lucide-eye v-if="stroke.visible" class="size-3.5" />
<icon-lucide-eye-off v-else class="size-3.5" />
</button>
<button :class="iconButton({ ui: { base: 'shrink-0' } })" @click="remove(i)">−</button>
</div>
<ColorInput class="min-w-0 flex-1" :color="stroke.color" editable @update="patch(i, { color: $event })" />
</ColorStyleRow>
<div
v-if="!isMixed && activeNode && activeNode.strokes.length > 0"
v-if="!isMixed && (items as unknown[]).length > 0"
class="mt-1 flex items-center gap-1.5"
>
<AppSelect
@ -81,7 +80,7 @@ const sideMenuCls = useMenuUI({
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode)"
/>
<ScrubInput
v-if="!activeNode.independentStrokeWeights"
v-if="!expandedSides"
class="flex-1"
:model-value="activeNode.strokes[0]?.weight ?? 1"
:min="0"
@ -101,80 +100,25 @@ const sideMenuCls = useMenuUI({
</svg>
</template>
</ScrubInput>
<DropdownMenuRoot v-model:open="strokeCtx.sideMenuOpen.value">
<Tip :label="panels.strokeSides">
<DropdownMenuTrigger as-child>
<button
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': activeNode.independentStrokeWeights }"
>
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="1" width="5" height="5" rx="1" />
<rect x="8" y="1" width="5" height="5" rx="1" />
<rect x="1" y="8" width="5" height="5" rx="1" />
<rect x="8" y="8" width="5" height="5" rx="1" />
</svg>
</button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuPortal>
<DropdownMenuContent :side-offset="4" align="end" :class="sideMenuCls.content">
<DropdownMenuItem
v-for="opt in strokeCtx.sideOptions"
:key="opt.value"
:class="menu({ justify: 'start' }).item({ class: sideMenuCls.item })"
@click="strokeCtx.selectSide(opt.value, activeNode)"
>
<icon-lucide-check
v-if="strokeCtx.currentSides(activeNode) === opt.value"
class="absolute left-2 size-3 text-accent"
/>
<span class="flex items-center gap-2 pl-5">
<svg
class="size-3.5"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<template v-if="opt.value === 'ALL'">
<rect x="1" y="1" width="12" height="12" rx="1" />
</template>
<template v-else-if="opt.value === 'CUSTOM'">
<line x1="4" y1="7" x2="10" y2="7" />
<line x1="7" y1="4" x2="7" y2="10" />
</template>
<template v-else>
<rect
x="1"
y="1"
width="12"
height="12"
rx="1"
stroke-opacity="0.3"
stroke-dasharray="2 2"
/>
<line v-if="opt.value === 'TOP'" x1="1" y1="1" x2="13" y2="1" />
<line v-else-if="opt.value === 'BOTTOM'" x1="1" y1="13" x2="13" y2="13" />
<line v-else-if="opt.value === 'LEFT'" x1="1" y1="1" x2="1" y2="13" />
<line v-else-if="opt.value === 'RIGHT'" x1="13" y1="1" x2="13" y2="13" />
</template>
</svg>
<span>{{ opt.label }}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<Tip :label="panels.strokeSides">
<button
data-test-id="stroke-sides-toggle"
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': expandedSides }"
@click="onToggleSides(activeNode)"
>
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="1" width="5" height="5" rx="1" />
<rect x="8" y="1" width="5" height="5" rx="1" />
<rect x="1" y="8" width="5" height="5" rx="1" />
<rect x="8" y="8" width="5" height="5" rx="1" />
</svg>
</button>
</Tip>
</div>
<div
v-if="
!isMixed &&
activeNode &&
activeNode.strokes.length > 0 &&
activeNode.independentStrokeWeights
"
v-if="!isMixed && (items as unknown[]).length > 0 && expandedSides"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<ScrubInput

View file

@ -0,0 +1,92 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '../helpers/canvas'
let page: Page
let canvas: CanvasHelper
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
})
test.afterAll(async () => {
await page.close()
})
async function getSelectedNodeFlags() {
return page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const id = [...store.state.selectedIds][0]
if (!id) return null
const n = store.graph.getNode(id)
if (!n) return null
return {
type: n.type,
independentCorners: n.independentCorners,
independentStrokeWeights: n.independentStrokeWeights
}
})
}
async function drawFrame(x: number, y: number, w: number, h: number) {
await canvas.pressKey('f')
await canvas.drag(x, y, x + w, y + h)
await canvas.waitForRender()
}
test('independent corners toggle shows per-corner inputs', async () => {
await drawFrame(120, 120, 120, 80)
await canvas.waitForRender()
const flags = await getSelectedNodeFlags()
expect(flags!.type).toBe('FRAME')
expect(flags!.independentCorners).toBe(false)
const toggle = page.locator('[data-test-id="independent-corners-toggle"]')
await expect(toggle).toBeVisible()
await toggle.click()
await canvas.waitForRender()
expect((await getSelectedNodeFlags())!.independentCorners).toBe(true)
const grid = page.locator('[data-test-id="independent-corners-grid"]')
await expect(grid).toBeVisible()
const cornerInputs = grid.locator('[data-test-id="scrub-input"]')
expect(await cornerInputs.count()).toBe(4)
await toggle.click()
await canvas.waitForRender()
await expect(grid).not.toBeVisible()
})
test('stroke sides toggle shows per-side weight inputs', async () => {
await drawFrame(300, 50, 120, 80)
await canvas.waitForRender()
const addStroke = page.locator('[data-test-id="stroke-section-add"]')
await expect(addStroke).toBeVisible()
await addStroke.click()
await canvas.waitForRender()
const toggle = page.locator('[data-test-id="stroke-sides-toggle"]')
await expect(toggle).toBeVisible({ timeout: 5000 })
const sectionInputsBefore = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count()
await toggle.click()
await canvas.waitForRender()
const sectionInputsAfter = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count()
expect(sectionInputsAfter).toBeGreaterThan(sectionInputsBefore)
await toggle.click()
await canvas.waitForRender()
const sectionInputsFinal = await page.locator('[data-test-id="stroke-section"] [data-test-id="scrub-input"]').count()
expect(sectionInputsFinal).toBe(sectionInputsBefore)
})

View file

@ -130,7 +130,7 @@ test('adding an effect creates effect item', async () => {
await addBtn.click()
await canvas.waitForRender()
const effectItems = effectsSection().locator('[data-test-id="effects-item"]')
const effectItems = effectsSection().locator('[data-test-id="effect-item"]')
await expect(effectItems.first()).toBeVisible()
const id = await getSelectedId()
@ -172,6 +172,78 @@ test('visibility toggle in appearance section works', async () => {
expect(restored!.visible).toBe(true)
})
test('fill stroke and effect visibility toggles update on repeated clicks and support undo redo', async () => {
const id = await getSelectedId()
expect(id).toBeTruthy()
const fillButton = page.locator('[data-test-id="fill-visibility-0"]')
await expect(fillButton).toBeVisible()
const initial = await getNode(id!)
expect(initial!.fills[0]?.visible).toBe(true)
await fillButton.click()
await canvas.waitForRender()
await expect(fillButton.locator('icon-lucide-eye-off')).toBeVisible()
expect((await getNode(id!))!.fills[0]?.visible).toBe(false)
await fillButton.click()
await canvas.waitForRender()
await expect(fillButton.locator('icon-lucide-eye')).toBeVisible()
expect((await getNode(id!))!.fills[0]?.visible).toBe(true)
await canvas.undo()
expect((await getNode(id!))!.fills[0]?.visible).toBe(false)
await canvas.redo()
expect((await getNode(id!))!.fills[0]?.visible).toBe(true)
const strokeAddButton = strokeSection().locator('[data-test-id="stroke-section-add"]')
await strokeAddButton.click()
await canvas.waitForRender()
const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]')
await expect(strokeButton).toBeVisible()
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
await strokeButton.click()
await canvas.waitForRender()
await expect(strokeButton.locator('icon-lucide-eye-off')).toBeVisible()
expect((await getNode(id!))!.strokes[0]?.visible).toBe(false)
await strokeButton.click()
await canvas.waitForRender()
await expect(strokeButton.locator('icon-lucide-eye')).toBeVisible()
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
await canvas.undo()
expect((await getNode(id!))!.strokes[0]?.visible).toBe(false)
await canvas.redo()
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
const effectAddButton = effectsSection().locator('[data-test-id="effects-section-add"]')
await effectAddButton.click()
await canvas.waitForRender()
const effectButton = page.locator('[data-test-id="effect-visibility-0"]')
await expect(effectButton).toBeVisible()
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
await effectButton.click()
await canvas.waitForRender()
await expect(effectButton.locator('icon-lucide-eye-off')).toBeVisible()
expect((await getNode(id!))!.effects[0]?.visible).toBe(false)
await effectButton.click()
await canvas.waitForRender()
await expect(effectButton.locator('icon-lucide-eye')).toBeVisible()
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
await canvas.undo()
expect((await getNode(id!))!.effects[0]?.visible).toBe(false)
await canvas.redo()
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
})
test('deselecting shows empty design panel', async () => {
await page.keyboard.press('Escape')
await canvas.waitForRender()

View file

@ -0,0 +1,100 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '../helpers/canvas'
let page: Page
let canvas: CanvasHelper
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
})
test.afterAll(async () => {
await page.close()
})
function getSelectedNode() {
return page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const id = [...store.state.selectedIds][0]
if (!id) return null
const n = store.graph.getNode(id)
if (!n) return null
return {
fills: n.fills,
strokes: n.strokes,
visible: n.visible
}
})
}
test('fill visibility supports repeat click and undo redo', async () => {
await canvas.drawRect(120, 120, 120, 80)
await canvas.waitForRender()
const fillButton = page.locator('[data-test-id="fill-visibility-0"]')
await expect(fillButton).toBeVisible()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
await fillButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(false)
await fillButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
await canvas.undo()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(false)
await canvas.redo()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true)
})
test('stroke visibility supports repeat click and undo redo', async () => {
await page.locator('[data-test-id="stroke-section-add"]').click()
await canvas.waitForRender()
const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]')
await expect(strokeButton).toBeVisible()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
await strokeButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false)
await strokeButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
await canvas.undo()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false)
await canvas.redo()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true)
})
test('appearance visibility supports repeat click and undo redo in one step', async () => {
const visibilityButton = page.locator('[data-test-id="appearance-visibility"]')
await expect(visibilityButton).toBeVisible()
expect((await getSelectedNode())!.visible).toBe(true)
await visibilityButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.visible).toBe(false)
await visibilityButton.click()
await canvas.waitForRender()
expect((await getSelectedNode())!.visible).toBe(true)
await canvas.undo()
expect((await getSelectedNode())!.visible).toBe(false)
await canvas.undo()
expect((await getSelectedNode())!.visible).toBe(true)
})