fix(app): align property item subcontrols

- Keep Fill and Effects settings inside their shared content rails\n- Use panel spacing tokens instead of ad-hoc offsets\n- Preserve selection when Escape dismisses overlays\n- Label the Font settings trigger for assistive technology
This commit is contained in:
Danila Poyarkov 2026-07-18 05:44:52 +03:00
parent 429a7e5ae2
commit 33d6a1efd1
9 changed files with 288 additions and 231 deletions

View file

@ -48,6 +48,7 @@
### Fixes
- Align Fill and Effects sub-controls to their shared property-row content rails, and improve font-settings accessibility without clearing selection when dismissing overlays.
- Restore the Variables dialog empty state, full-width table layout, unclipped type menu, and expected Escape handling in dialogs and popup menus.
- Pin the patched `websocket-driver` release used through Trystero/Firebase collaboration to resolve a critical protocol-length advisory.
- Preserve unrelated Figma prototype, library, export, and raw metadata when editing modeled `.fig` fields, while still overriding stale field-specific payloads.

View file

@ -50,12 +50,23 @@ function opacityBindings(): ShortcutDefinition[] {
}))
}
const EDITOR_SHORTCUT_OVERLAY_SELECTOR =
'[data-picker-content], [role="dialog"], [role="listbox"], [role="menu"]'
function originatedInOverlay(event: KeyboardEvent) {
return event
.composedPath()
.some((target) => target instanceof Element && target.matches(EDITOR_SHORTCUT_OVERLAY_SELECTOR))
}
function hasOpenDismissableLayer() {
return document.querySelector('[data-dismissable-layer]') !== null
}
function shouldIgnoreShortcut(event: KeyboardEvent, options: KeyboardShortcutOptions) {
return (
(event.target instanceof Element &&
event.target.closest(
'[data-picker-content], [role="dialog"], [role="listbox"], [role="menu"]'
) !== null) ||
hasOpenDismissableLayer() ||
originatedInOverlay(event) ||
isEditing(event) ||
options.inputFocused.value ||
!!options.store.state.editingTextId ||
@ -177,7 +188,8 @@ export function registerKeyboardShortcuts(options: KeyboardShortcutOptions) {
handler(event)
}
])
)
),
{ capture: true }
)
onScopeDispose(unsubscribe)

View file

@ -76,7 +76,11 @@ onMounted(() => {
<template>
<PopoverRoot v-model:open="popoverOpen" @update:open="setPopoverOpen">
<Tip :label="dialogs.fontSettings" :disabled="popoverOpen">
<PopoverTrigger data-test-id="font-settings-trigger" :class="trigger.base">
<PopoverTrigger
data-test-id="font-settings-trigger"
:aria-label="dialogs.fontSettings"
:class="trigger.base"
>
<icon-lucide-settings class="size-3.5" />
</PopoverTrigger>
</Tip>

View file

@ -64,171 +64,176 @@ function effectPreview(effect: Effect): Fill {
:index="index"
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeEffect"
class="items-start"
@remove="effectsCtx.adjustExpandedAfterRemove(index)"
>
<Tip
:label="
effectsCtx.expandedIndex.value === index
? panels.collapseEffectSettings
: panels.expandEffectSettings
"
>
<button
type="button"
:aria-expanded="effectsCtx.expandedIndex.value === index"
:aria-label="
effectsCtx.expandedIndex.value === index
? panels.collapseEffectSettings
: panels.expandEffectSettings
"
data-property="effect-expand"
class="flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded border border-border bg-input p-0"
@click="effectsCtx.toggleExpand(index)"
>
<FillSwatch
v-if="effectsCtx.isShadow(effect.type)"
:fill="effectPreview(effect)"
class="size-full border-0"
/>
<icon-lucide-blend v-else class="size-3 text-muted" />
</button>
</Tip>
<AppSelect
class="min-w-0 flex-1"
:model-value="effect.type"
:options="effectsCtx.effectOptions"
:label="panels.effects"
data-property="effect-type"
@update:model-value="
effectsCtx.updateType(actions.patch, activeNode, index, $event as Effect['type'])
"
/>
</PropertyItemRow>
<div
v-if="effectsCtx.expandedIndex.value === index"
class="ml-[26px] flex flex-col gap-1.5 py-1.5"
data-slot="effect-settings"
>
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="effect.blendMode ?? 'NORMAL'"
:options="blendModeOptions"
:label="panels.blendMode"
data-property="effect-blend-mode"
@update:model-value="
commitDiscretePropertyListChange(flush, () =>
actions.patch(index, { blendMode: $event as Effect['blendMode'] })
)
"
/>
</PanelFieldGroup>
<template v-if="effectsCtx.isShadow(effect.type)">
<div class="flex items-center gap-1.5">
<Tip :label="panels.xAxis">
<NumberField
icon="X"
:model-value="effect.offset.x"
data-property="effect-offset-x"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
offset: { ...effect.offset, x: $event }
})
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-panel">
<Tip
:label="
effectsCtx.expandedIndex.value === index
? panels.collapseEffectSettings
: panels.expandEffectSettings
"
>
<button
type="button"
:aria-expanded="effectsCtx.expandedIndex.value === index"
:aria-label="
effectsCtx.expandedIndex.value === index
? panels.collapseEffectSettings
: panels.expandEffectSettings
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
offset: { ...effect.offset, x: $event }
})
"
/>
data-property="effect-expand"
class="flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded border border-border bg-input p-0"
@click="effectsCtx.toggleExpand(index)"
>
<FillSwatch
v-if="effectsCtx.isShadow(effect.type)"
:fill="effectPreview(effect)"
class="size-full border-0"
/>
<icon-lucide-blend v-else class="size-3 text-muted" />
</button>
</Tip>
<Tip :label="panels.yAxis">
<NumberField
icon="Y"
:model-value="effect.offset.y"
data-property="effect-offset-y"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
offset: { ...effect.offset, y: $event }
})
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
offset: { ...effect.offset, y: $event }
})
"
/>
</Tip>
</div>
<div class="flex items-center gap-1.5">
<Tip :label="panels.radius">
<NumberField
icon="B"
:model-value="effect.radius"
:min="0"
data-property="effect-radius"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, { radius: $event })
"
@commit="effectsCtx.commitEffect(activeNode, index, { radius: $event })"
/>
</Tip>
<Tip :label="panels.spread">
<NumberField
icon="S"
:model-value="effect.spread"
data-property="effect-spread"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, { spread: $event })
"
@commit="effectsCtx.commitEffect(activeNode, index, { spread: $event })"
/>
</Tip>
</div>
<div class="flex items-center gap-1.5">
<ColorInput
<AppSelect
class="min-w-0 flex-1"
:color="effect.color"
editable
@update="effectsCtx.updateColor(actions.patch, index, $event)"
:model-value="effect.type"
:options="effectsCtx.effectOptions"
:label="panels.effects"
data-property="effect-type"
@update:model-value="
effectsCtx.updateType(actions.patch, activeNode, index, $event as Effect['type'])
"
/>
<Tip :label="panels.opacity">
<NumberField
class="w-14"
suffix="%"
:model-value="Math.round(effect.color.a * 100)"
:min="0"
:max="100"
data-property="effect-opacity"
</div>
<div
v-if="effectsCtx.expandedIndex.value === index"
class="flex flex-col gap-panel py-panel"
data-slot="effect-settings"
>
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="effect.blendMode ?? 'NORMAL'"
:options="blendModeOptions"
:label="panels.blendMode"
data-property="effect-blend-mode"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
commitDiscretePropertyListChange(flush, () =>
actions.patch(index, { blendMode: $event as Effect['blendMode'] })
)
"
/>
</Tip>
</div>
</template>
</PanelFieldGroup>
<template v-if="effectsCtx.isShadow(effect.type)">
<div class="flex items-center gap-1.5">
<Tip :label="panels.xAxis">
<NumberField
icon="X"
:model-value="effect.offset.x"
data-property="effect-offset-x"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
offset: { ...effect.offset, x: $event }
})
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
offset: { ...effect.offset, x: $event }
})
"
/>
</Tip>
<Tip :label="panels.yAxis">
<NumberField
icon="Y"
:model-value="effect.offset.y"
data-property="effect-offset-y"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
offset: { ...effect.offset, y: $event }
})
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
offset: { ...effect.offset, y: $event }
})
"
/>
</Tip>
</div>
<NumberField
v-else
class="w-24 flex-none"
icon="B"
:model-value="effect.radius"
:min="0"
data-property="effect-radius"
@update:model-value="effectsCtx.scrubEffect(activeNode, index, { radius: $event })"
@commit="effectsCtx.commitEffect(activeNode, index, { radius: $event })"
/>
</div>
<div class="flex items-center gap-1.5">
<Tip :label="panels.radius">
<NumberField
icon="B"
:model-value="effect.radius"
:min="0"
data-property="effect-radius"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, { radius: $event })
"
@commit="effectsCtx.commitEffect(activeNode, index, { radius: $event })"
/>
</Tip>
<Tip :label="panels.spread">
<NumberField
icon="S"
:model-value="effect.spread"
data-property="effect-spread"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, { spread: $event })
"
@commit="effectsCtx.commitEffect(activeNode, index, { spread: $event })"
/>
</Tip>
</div>
<div class="flex items-center gap-1.5">
<ColorInput
class="min-w-0 flex-1"
:color="effect.color"
editable
@update="effectsCtx.updateColor(actions.patch, index, $event)"
/>
<Tip :label="panels.opacity">
<NumberField
class="w-14"
suffix="%"
:model-value="Math.round(effect.color.a * 100)"
:min="0"
:max="100"
data-property="effect-opacity"
@update:model-value="
effectsCtx.scrubEffect(activeNode, index, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
@commit="
effectsCtx.commitEffect(activeNode, index, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
/>
</Tip>
</div>
</template>
<NumberField
v-else
class="w-24 flex-none"
icon="B"
:model-value="effect.radius"
:min="0"
data-property="effect-radius"
@update:model-value="effectsCtx.scrubEffect(activeNode, index, { radius: $event })"
@commit="effectsCtx.commitEffect(activeNode, index, { radius: $event })"
/>
</div>
</div>
</PropertyItemRow>
</div>
</PanelSection>
</PropertyListRoot>

View file

@ -87,84 +87,88 @@ function updateSolidColor(
<div v-for="(fill, index) in items" :key="`${index}:${fill.visible ? 'visible' : 'hidden'}`">
<PropertyItemRow
class="items-start"
prop-key="fills"
:index="index"
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeFill"
>
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
>
<PaintField
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
<div class="flex min-w-0 flex-1 flex-col gap-panel">
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<PaintField
class="w-full flex-none"
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="fill.blendMode ?? 'NORMAL'"
:options="blendModeOptions"
:label="panels.blendMode"
data-property="fill-blend-mode"
@update:model-value="
commitDiscretePropertyListChange(flush, () =>
actions.patch(index, { blendMode: $event as Fill['blendMode'] })
)
"
/>
</PanelFieldGroup>
</div>
</PropertyItemRow>
<PanelFieldGroup :label="panels.blendMode" class="ml-[26px] mt-1.5">
<AppSelect
:model-value="fill.blendMode ?? 'NORMAL'"
:options="blendModeOptions"
:label="panels.blendMode"
data-property="fill-blend-mode"
@update:model-value="
commitDiscretePropertyListChange(flush, () =>
actions.patch(index, { blendMode: $event as Fill['blendMode'] })
)
"
/>
</PanelFieldGroup>
</div>
</PanelSection>
</PropertyListRoot>

View file

@ -154,7 +154,19 @@ test('effect settings expand semantically and row remove reveals on hover', asyn
await expect(expand).toHaveAttribute('aria-expanded', 'false')
await expand.click()
await expect(expand).toHaveAttribute('aria-expanded', 'true')
await expect(editor.page.locator('[data-slot="effect-settings"]')).toBeVisible()
const effectSettings = editor.page.locator('[data-slot="effect-settings"]')
await expect(effectSettings).toBeVisible()
const effectTypeBox = expectDefined(
await effectItem.locator('[data-property="effect-type"]').boundingBox(),
'effect type bounds'
)
const effectBlendBox = expectDefined(
await effectSettings.locator('[data-property="effect-blend-mode"]').boundingBox(),
'effect blend mode bounds'
)
expect(
Math.abs(effectBlendBox.x + effectBlendBox.width - (effectTypeBox.x + effectTypeBox.width))
).toBeLessThan(1)
const remove = effectItem.getByRole('button', { name: 'Remove effect' })
await expect(remove).toHaveCSS('opacity', '1')
@ -177,6 +189,20 @@ test('paint effect and export rows share compact visual anatomy', async () => {
}
await editor.page.mouse.move(0, 0)
const fillItem = propertyItems(editor.page, 'fills').first()
const paintFieldBox = expectDefined(
await fillItem.locator('[data-slot="paint-field"]').boundingBox(),
'fill paint field bounds'
)
const fillBlendBox = expectDefined(
await editor.page.locator('[data-property="fill-blend-mode"]').boundingBox(),
'fill blend mode bounds'
)
expect(Math.abs(fillBlendBox.x - paintFieldBox.x)).toBeLessThan(1)
expect(
Math.abs(fillBlendBox.x + fillBlendBox.width - (paintFieldBox.x + paintFieldBox.width))
).toBeLessThan(1)
await expect(designPanel()).toHaveScreenshot('design-panel-paint-effects-export.png')
for (const sectionName of ['Position', 'Layout', 'Appearance']) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -25,7 +25,8 @@ test('font settings popover exposes web font access without desktop-only cache a
await expect(
typography.getByRole('img', { name: /Missing font: Missing Test Sans/ })
).toBeVisible()
const fontSettings = page.getByTestId('font-settings-trigger')
const fontSettings = page.getByRole('button', { name: 'Font settings' })
await expect(fontSettings).toHaveAttribute('data-test-id', 'font-settings-trigger')
await fontSettings.hover()
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Font settings' })).toBeVisible()
await fontSettings.click()
@ -47,4 +48,8 @@ test('font settings popover exposes web font access without desktop-only cache a
await expect(page.getByTestId('font-settings-refresh-cache')).toHaveCount(0)
await expect(page.getByTestId('font-settings-clear-cache')).toHaveCount(0)
await expect(page.getByText('Download CJK and Arabic fallbacks')).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(page.getByTestId('font-settings-panel')).toBeHidden()
await expect(typography).toBeVisible()
})