feat(app): finish Design panel migration

- Rebuild Typography, Variant, Mask, and Page sections with shared labeled fields
- Expose semantic empty states across list sections
- Replace section test hooks with accessible roles and add focused visual coverage
This commit is contained in:
Danila Poyarkov 2026-07-14 17:40:56 +03:00
parent 5371ed11b8
commit 27fdef2ea4
31 changed files with 355 additions and 146 deletions

View file

@ -23,6 +23,7 @@
- Replace the fragmented Vue SDK color-picker model helpers with `useColorModel()`, providing precise Reka bridges, extensible formats, and shared RGB, HSL, HSB, and OkHCL channel behavior.
- Add accessible color-channel sliders, binding-aware fill primitives, and keyboard-operable gradient stops while separating fill state from popover composition.
- Rebuild Fill, Stroke, Effects, and Export controls with shared compact item rows, semantic actions, binding-aware paint fields, and reversible color-picker edits.
- Finish the Design panel migration with compact labeled Typography, Variant, Mask, and Page controls plus semantic empty-section states.
- 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.

View file

@ -16,6 +16,14 @@
"effects": "Effekte",
"export": "Export",
"typography": "Typografie",
"fontFamily": "Schriftfamilie",
"fontWeight": "Schriftstärke",
"fontSize": "Schriftgröße",
"lineHeight": "Zeilenhöhe",
"letterSpacing": "Zeichenabstand",
"textAlignment": "Textausrichtung",
"textFormatting": "Textformatierung",
"pageBackground": "Seitenhintergrund",
"variables": "Variablen",
"constraints": "Einschränkungen",
"addFill": "Füllung hinzufügen",

View file

@ -26,6 +26,14 @@
"effects": "Efectos",
"export": "Exportar",
"typography": "Tipografía",
"fontFamily": "Familia tipográfica",
"fontWeight": "Peso de fuente",
"fontSize": "Tamaño de fuente",
"lineHeight": "Altura de línea",
"letterSpacing": "Espaciado entre letras",
"textAlignment": "Alineación del texto",
"textFormatting": "Formato de texto",
"pageBackground": "Fondo de página",
"variables": "Variables",
"constraints": "Restricciones",
"addFill": "Añadir relleno",

View file

@ -16,6 +16,14 @@
"effects": "Effets",
"export": "Export",
"typography": "Typographie",
"fontFamily": "Famille de police",
"fontWeight": "Graisse",
"fontSize": "Taille de police",
"lineHeight": "Hauteur de ligne",
"letterSpacing": "Espacement des lettres",
"textAlignment": "Alignement du texte",
"textFormatting": "Mise en forme du texte",
"pageBackground": "Arrière-plan de la page",
"variables": "Variables",
"constraints": "Contraintes",
"addFill": "Ajouter un remplissage",

View file

@ -16,6 +16,14 @@
"effects": "Effetti",
"export": "Esporta",
"typography": "Tipografia",
"fontFamily": "Famiglia di caratteri",
"fontWeight": "Peso del carattere",
"fontSize": "Dimensione del carattere",
"lineHeight": "Altezza riga",
"letterSpacing": "Spaziatura lettere",
"textAlignment": "Allineamento testo",
"textFormatting": "Formattazione testo",
"pageBackground": "Sfondo pagina",
"variables": "Variabili",
"constraints": "Vincoli",
"addFill": "Aggiungi riempimento",

View file

@ -26,6 +26,14 @@
"effects": "エフェクト",
"export": "エクスポート",
"typography": "タイポグラフィ",
"fontFamily": "フォントファミリー",
"fontWeight": "フォントの太さ",
"fontSize": "フォントサイズ",
"lineHeight": "行の高さ",
"letterSpacing": "文字間隔",
"textAlignment": "テキストの配置",
"textFormatting": "テキストの書式",
"pageBackground": "ページの背景",
"variables": "変数",
"constraints": "制約",
"addFill": "塗りを追加",

View file

@ -16,6 +16,14 @@
"effects": "Efekty",
"export": "Eksport",
"typography": "Typografia",
"fontFamily": "Rodzina czcionek",
"fontWeight": "Grubość czcionki",
"fontSize": "Rozmiar czcionki",
"lineHeight": "Wysokość wiersza",
"letterSpacing": "Odstępy między literami",
"textAlignment": "Wyrównanie tekstu",
"textFormatting": "Formatowanie tekstu",
"pageBackground": "Tło strony",
"variables": "Zmienne",
"constraints": "Ograniczenia",
"addFill": "Dodaj wypełnienie",

View file

@ -16,6 +16,14 @@
"effects": "Эффекты",
"export": "Экспорт",
"typography": "Типографика",
"fontFamily": "Гарнитура шрифта",
"fontWeight": "Насыщенность шрифта",
"fontSize": "Размер шрифта",
"lineHeight": "Высота строки",
"letterSpacing": "Межбуквенный интервал",
"textAlignment": "Выравнивание текста",
"textFormatting": "Форматирование текста",
"pageBackground": "Фон страницы",
"variables": "Переменные",
"constraints": "Ограничения",
"addFill": "Добавить заливку",

View file

@ -16,6 +16,14 @@
"effects": "效果",
"export": "导出",
"typography": "排版",
"fontFamily": "字体",
"fontWeight": "字重",
"fontSize": "字号",
"lineHeight": "行高",
"letterSpacing": "字间距",
"textAlignment": "文本对齐",
"textFormatting": "文本格式",
"pageBackground": "页面背景",
"variables": "变量",
"constraints": "约束",
"addFill": "添加填充",

View file

@ -47,6 +47,14 @@ export const panelMessageDefaults = {
mask: 'Mask',
export: 'Export',
typography: 'Typography',
fontFamily: 'Font family',
fontWeight: 'Font weight',
fontSize: 'Font size',
lineHeight: 'Line height',
letterSpacing: 'Letter spacing',
textAlignment: 'Text alignment',
textFormatting: 'Text formatting',
pageBackground: 'Page background',
variables: 'Variables',
variants: 'Variants',
constraints: 'Constraints',

View file

@ -88,14 +88,14 @@ const { panels } = useI18n()
class="flex flex-col gap-1 border-b border-border px-3 py-2"
>
<button
data-test-id="design-go-to-component"
type="button"
class="rounded bg-component/10 px-2 py-1 text-left text-[11px] text-component hover:bg-component/20"
@click="goToMainComponent.run()"
>
{{ panels.goToMainComponent }}
</button>
<button
data-test-id="design-detach-instance"
type="button"
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="detachInstance.run()"
>

View file

@ -15,6 +15,7 @@ import { WEB_FONT_PROVIDER_IDS } from '@open-pencil/core/text'
import type { FontPickerUI } from '@open-pencil/vue'
const { label = 'Font family' } = defineProps<{ label?: string }>()
const modelValue = defineModel<string>({ required: true })
const emit = defineEmits<{ select: [family: string] }>()
@ -62,7 +63,12 @@ function loadPreviewFont(family: string, source: string) {
@select="emit('select', $event)"
>
<template #trigger>
<button data-test-id="font-picker-trigger" :class="selectCls.trigger">
<button
type="button"
data-test-id="font-picker-trigger"
:aria-label="label"
:class="selectCls.trigger"
>
<span class="truncate">{{ modelValue }}</span>
<icon-lucide-chevron-down class="size-3 shrink-0 text-muted" />
</button>

View file

@ -32,7 +32,7 @@ function effectPreview(effect: Effect): Fill {
prop-key="effects"
:label="panels.effects"
>
<PanelSection :label="panels.effects">
<PanelSection :label="panels.effects" :empty="!isMixed && items.length === 0">
<template #actions>
<IconButton
:label="panels.addEffect"

View file

@ -108,7 +108,7 @@ watch(previewKey, updatePreview, { flush: 'post' })
</script>
<template>
<PanelSection :label="panels.export">
<PanelSection :label="panels.export" :empty="activeSettings.length === 0">
<template #actions>
<IconButton :label="panels.addExport" @click="addSetting">
<icon-lucide-plus class="size-3.5" />

View file

@ -66,7 +66,7 @@ function updateSolidColor(
prop-key="fills"
:label="panels.fill"
>
<PanelSection :label="panels.fill">
<PanelSection :label="panels.fill" :empty="!isMixed && items.length === 0">
<template #actions>
<IconButton :label="panels.addFill" @click="actions.add({ ...fillCtx.defaultFill })">
<icon-lucide-plus class="size-3.5" />

View file

@ -4,8 +4,8 @@ import { computed } from 'vue'
import { useI18n, useMask } from '@open-pencil/vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import type { MaskType } from '@open-pencil/scene-graph'
@ -25,15 +25,14 @@ const selectedMaskType = computed<MaskType>({
</script>
<template>
<PanelSection v-if="active" :label="panels.mask" data-test-id="mask-section">
<Tip :label="panels.maskType">
<PanelSection v-if="active" :label="panels.mask">
<PanelFieldGroup :label="panels.maskType">
<AppSelect
class="w-full"
:label="panels.maskType"
v-model="selectedMaskType"
:label="panels.maskType"
:options="maskTypeOptions"
data-test-id="mask-type-select"
data-property="mask-type"
/>
</Tip>
</PanelFieldGroup>
</PanelSection>
</template>

View file

@ -2,18 +2,58 @@
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import ColorPicker from '@/components/ColorPicker/ColorPicker.vue'
import PaintField from '@/components/properties/paint/PaintField.vue'
import PaintValue from '@/components/properties/paint/PaintValue.vue'
import FillSwatch from '@/components/ui/FillSwatch.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import { useEditorStore } from '@/app/editor/active-store'
import type { Color, Fill } from '@open-pencil/scene-graph'
const editor = useEditorStore()
const pageColor = computed(() => editor.state.pageColor)
const pageFill = computed<Fill>(() => ({
type: 'SOLID',
color: pageColor.value,
opacity: 1,
visible: true
}))
const { panels } = useI18n()
function updatePageAlpha(alpha: number) {
editor.setPageColor({ ...pageColor.value, a: alpha })
}
function updatePageColor(color: Color) {
editor.setPageColor(color)
}
</script>
<template>
<PanelSection :label="panels.page ?? 'Page'" data-test-id="page-section">
<ColorInput :color="pageColor" editable @update="editor.setPageColor($event)" />
<PanelSection :label="panels.page">
<PaintField
:opacity="pageColor.a"
:opacity-label="panels.opacity"
@update:opacity="updatePageAlpha"
>
<template #preview>
<ColorPicker :color="pageColor" @update="updatePageColor">
<template #trigger>
<button
type="button"
:aria-label="panels.pageBackground"
class="size-5 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
>
<FillSwatch :fill="pageFill" class="size-full" />
</button>
</template>
</ColorPicker>
</template>
<template #value>
<PaintValue :color="pageColor" :label="panels.pageBackground" @update="updatePageColor" />
</template>
</PaintField>
</PanelSection>
</template>

View file

@ -85,7 +85,7 @@ function onToggleSides(activeNode: SceneNode | null) {
prop-key="strokes"
:label="panels.stroke"
>
<PanelSection :label="panels.stroke">
<PanelSection :label="panels.stroke" :empty="!isMixed && items.length === 0">
<template #actions>
<IconButton :label="panels.addStroke" @click="actions.add(strokeCtx.defaultStroke)">
<icon-lucide-plus class="size-3.5" />

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { TypographyControlsRoot, useI18n } from '@open-pencil/vue'
import FontPicker from '@/components/font-picker/FontPicker.vue'
@ -6,27 +8,31 @@ import FontSettingsPopover from '@/components/FontSettings/FontSettingsPopover.v
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelRow from '@/components/ui/panel/PanelRow.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import Tip from '@/components/ui/Tip.vue'
import { loadFont } from '@/app/editor/fonts'
import { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
const { panels, menu } = useI18n()
const fontLoader = { load: loadFont }
const alignmentOptions = computed(() => [
{ value: 'LEFT', label: panels.value.alignLeft },
{ value: 'CENTER', label: panels.value.alignCenterHorizontally },
{ value: 'RIGHT', label: panels.value.alignRight }
])
</script>
<template>
<TypographyControlsRoot v-slot="ctx" :font-loader="fontLoader">
<PanelSection
v-if="ctx.node.value"
:label="panels.typography"
data-test-id="typography-section"
>
<PanelRow class="mb-1.5">
<PanelSection v-if="ctx.node.value" :label="panels.typography">
<div class="mb-panel flex min-w-0 items-center gap-panel">
<FontPicker
class="min-w-0 flex-1"
:model-value="ctx.node.value.fontFamily"
:label="panels.fontFamily"
@select="ctx.actions.setFamily"
/>
<FontSettingsPopover />
@ -40,21 +46,31 @@ const fontLoader = { load: loadFont }
"
>
<icon-lucide-alert-triangle
data-test-id="typography-missing-font"
role="img"
:aria-label="
'Missing font' +
(ctx.missingFonts.value.length > 1 ? 's' : '') +
': ' +
ctx.missingFonts.value.join(', ')
"
class="size-3.5 shrink-0 text-[var(--color-warning-action)]"
/>
</Tip>
</PanelRow>
</div>
<PanelRow cols="two" class="mb-1.5">
<PanelGrid columns="two" class="mb-panel">
<PanelFieldGroup :label="panels.fontWeight">
<AppSelect
:label="panels.fontWeight"
:model-value="ctx.node.value.fontWeight"
:options="ctx.weights"
@update:model-value="ctx.actions.setWeight(+$event)"
/>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.fontSize">
<VariableNumberField
class="flex-1"
:model-value="ctx.node.value.fontSize"
:aria-label="panels.fontSize"
:min="1"
:max="1000"
:node-id="ctx.node.value.id"
@ -62,14 +78,16 @@ const fontLoader = { load: loadFont }
@update:model-value="ctx.actions.updateProp('fontSize', $event)"
@commit="(v: number, p: number) => ctx.actions.commitProp('fontSize', v, p)"
/>
</PanelRow>
</PanelFieldGroup>
</PanelGrid>
<PanelRow cols="two" class="mb-1.5">
<PanelGrid columns="two" class="mb-panel">
<PanelFieldGroup :label="panels.lineHeight">
<VariableNumberField
class="flex-1"
:model-value="
ctx.node.value.lineHeight ?? Math.round((ctx.node.value.fontSize || 14) * 1.2)
"
:aria-label="panels.lineHeight"
:min="0"
:node-id="ctx.node.value.id"
binding-path="lineHeight"
@ -80,10 +98,12 @@ const fontLoader = { load: loadFont }
<icon-lucide-baseline class="size-3" />
</template>
</VariableNumberField>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.letterSpacing">
<VariableNumberField
class="flex-1"
suffix="%"
:model-value="ctx.node.value.letterSpacing"
:aria-label="panels.letterSpacing"
:node-id="ctx.node.value.id"
binding-path="letterSpacing"
@update:model-value="ctx.actions.updateProp('letterSpacing', $event)"
@ -93,11 +113,12 @@ const fontLoader = { load: loadFont }
<icon-lucide-a-large-small class="size-3" />
</template>
</VariableNumberField>
</PanelRow>
</PanelFieldGroup>
</PanelGrid>
<div class="mb-1.5">
<label class="mb-1 block text-[11px] text-muted">{{ panels.direction }}</label>
<PanelFieldGroup :label="panels.direction" class="mb-panel">
<AppSelect
:label="panels.direction"
:model-value="ctx.node.value.textDirection"
:options="[
{ value: 'AUTO', label: panels.auto },
@ -106,41 +127,29 @@ const fontLoader = { load: loadFont }
]"
@update:model-value="ctx.actions.setDirection($event as 'AUTO' | 'LTR' | 'RTL')"
/>
</div>
</PanelFieldGroup>
<PanelRow class="gap-3">
<PanelRow gap="sm">
<IconButton
:label="panels.alignLeft"
size="md"
:active="ctx.node.value.textAlignHorizontal === 'LEFT'"
@click="ctx.actions.align('LEFT')"
<PanelFieldGroup :label="panels.textAlignment" class="mb-panel">
<SegmentedControl
:model-value="ctx.node.value.textAlignHorizontal"
:options="alignmentOptions"
:label="panels.textAlignment"
@change="ctx.actions.align($event as 'LEFT' | 'CENTER' | 'RIGHT')"
>
<icon-lucide-align-left class="size-3.5" />
</IconButton>
<IconButton
:label="panels.alignCenterHorizontally"
size="md"
:active="ctx.node.value.textAlignHorizontal === 'CENTER'"
@click="ctx.actions.align('CENTER')"
>
<icon-lucide-align-center class="size-3.5" />
</IconButton>
<IconButton
:label="panels.alignRight"
size="md"
:active="ctx.node.value.textAlignHorizontal === 'RIGHT'"
@click="ctx.actions.align('RIGHT')"
>
<icon-lucide-align-right class="size-3.5" />
</IconButton>
</PanelRow>
<PanelRow gap="sm">
<template #option="{ option }">
<icon-lucide-align-left v-if="option.value === 'LEFT'" class="size-3.5" />
<icon-lucide-align-center v-else-if="option.value === 'CENTER'" class="size-3.5" />
<icon-lucide-align-right v-else class="size-3.5" />
</template>
</SegmentedControl>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.textFormatting" :ui="{ container: 'flex-row gap-panel' }">
<div class="flex items-center gap-panel" role="toolbar" :aria-label="panels.textFormatting">
<IconButton
:label="`${menu.bold} (${appMenuShortcutLabel('text.bold')})`"
size="md"
:active="ctx.activeFormatting.value.includes('bold')"
data-test-id="typography-bold-button"
@click="ctx.actions.toggleBold"
>
<icon-lucide-bold class="size-3.5" />
@ -169,8 +178,8 @@ const fontLoader = { load: loadFont }
>
<icon-lucide-strikethrough class="size-3.5" />
</IconButton>
</PanelRow>
</PanelRow>
</div>
</PanelFieldGroup>
</PanelSection>
</TypographyControlsRoot>
</template>

View file

@ -23,13 +23,9 @@ const { panels } = useI18n()
</script>
<template>
<PanelSection :label="panels.variables" data-test-id="variables-section">
<PanelSection :label="panels.variables" :empty="!hasVariables">
<template #actions>
<IconButton
:label="panels.openVariables"
data-test-id="variables-section-open"
@click="emit('openDialog')"
>
<IconButton :label="panels.openVariables" @click="emit('openDialog')">
<icon-lucide-settings-2 class="size-3.5" />
</IconButton>
</template>

View file

@ -4,6 +4,7 @@ import { computed } from 'vue'
import { useI18n, useSelectionState } from '@open-pencil/vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import { useEditorStore } from '@/app/editor/active-store'
@ -42,25 +43,21 @@ function switchVariant(propertyName: string, newValue: string) {
</script>
<template>
<PanelSection
v-if="hasVariants"
:label="panels.variants"
data-test-id="variant-section"
:ui="{ title: 'text-component' }"
>
<div class="flex flex-col gap-1.5">
<div
<PanelSection v-if="hasVariants" :label="panels.variants" :ui="{ title: 'text-component' }">
<div class="flex flex-col gap-panel">
<PanelFieldGroup
v-for="[propName, options] in variantOptions"
:key="propName"
class="flex flex-col gap-0.5"
:label="propName"
>
<label class="text-[10px] text-muted">{{ propName }}</label>
<AppSelect
:label="propName"
:model-value="currentValues[propName] ?? ''"
:options="[...options].map((v) => ({ value: v, label: v }))"
:options="[...options].map((value) => ({ value, label: value }))"
:data-property="propName"
@update:model-value="switchVariant(propName, $event)"
/>
</div>
</PanelFieldGroup>
</div>
</PanelSection>
</template>

View file

@ -106,13 +106,11 @@ test('instance shows INSTANCE type in design panel', async () => {
})
test('instance has "Go to Main Component" button', async () => {
const goToBtn = editor.page.getByTestId('design-go-to-component')
await expect(goToBtn).toBeVisible()
await expect(editor.page.getByRole('button', { name: 'Go to Main Component' })).toBeVisible()
})
test('instance has "Detach" button', async () => {
const detachBtn = editor.page.getByTestId('design-detach-instance')
await expect(detachBtn).toBeVisible()
await expect(editor.page.getByRole('button', { name: 'Detach Instance' })).toBeVisible()
})
test('modifying component propagates to instance', async () => {

View file

@ -173,9 +173,10 @@ test('assets panel groups component sets and inserts the default variant', async
expect(inserted?.width).toBe(132)
expect(inserted?.childTexts).toEqual(['Secondary'])
await expect(page.getByTestId('variant-section')).toBeVisible()
const variantSection = page.getByRole('region', { name: 'Variants' })
await expect(variantSection).toBeVisible()
await page.getByTestId('variant-section').getByTestId('app-select-trigger').click()
await variantSection.getByRole('combobox', { name: 'Type' }).click()
await page.getByRole('option', { name: 'Primary' }).click()
expectDefined(inserted?.id, 'inserted instance id')

View file

@ -257,15 +257,16 @@ test('mask action toggles mask section and mask type control', async () => {
const maskAction = editor.page.getByTestId('selection-toggle-mask')
await expect(maskAction).toBeVisible()
await expect(editor.page.getByTestId('mask-section')).toHaveCount(0)
await expect(propertySection(editor.page, 'Mask')).toHaveCount(0)
await maskAction.click()
await editor.canvas.waitForRender()
let node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node').isMask).toBe(true)
await expect(editor.page.getByTestId('mask-section')).toBeVisible()
const maskSection = propertySection(editor.page, 'Mask')
await expect(maskSection).toBeVisible()
const maskTypeSelect = editor.page.getByTestId('mask-type-select')
const maskTypeSelect = maskSection.getByRole('combobox', { name: 'Mask type' })
await maskTypeSelect.click()
await editor.page.getByRole('option', { name: 'Luminance' }).click()
await editor.canvas.waitForRender()
@ -277,7 +278,7 @@ test('mask action toggles mask section and mask type control', async () => {
await editor.canvas.waitForRender()
node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node').isMask).toBe(false)
await expect(editor.page.getByTestId('mask-section')).toHaveCount(0)
await expect(propertySection(editor.page, 'Mask')).toHaveCount(0)
})
test('visibility toggle in appearance section works', async () => {

View file

@ -13,11 +13,18 @@ test('font settings popover exposes web font access without desktop-only cache a
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = store.createShape('TEXT', 120, 120, 240, 40)
store.updateNode(id, { characters: 'Font settings smoke' })
store.updateNode(id, {
characters: 'Font settings smoke',
fontFamily: 'Missing Test Sans'
})
store.select([id])
})
await expect(page.getByTestId('typography-section')).toBeVisible()
const typography = page.getByRole('region', { name: 'Typography' })
await expect(typography).toBeVisible()
await expect(
typography.getByRole('img', { name: /Missing font: Missing Test Sans/ })
).toBeVisible()
const fontSettings = page.getByTestId('font-settings-trigger')
await fontSettings.hover()
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Font settings' })).toBeVisible()

View file

@ -0,0 +1,37 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { propertySection } from '#tests/helpers/properties'
const editor = useEditorSetup()
function pageColor() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.pageColor
})
}
test('page background uses the shared paint field for hex and alpha', async () => {
const pageSection = propertySection(editor.page, 'Page')
await expect(pageSection).toBeVisible()
const hex = pageSection.getByRole('textbox', { name: 'Page background' })
await hex.fill('336699')
await hex.press('Enter')
await editor.canvas.waitForRender()
let color = await pageColor()
expect(color.r).toBeCloseTo(0.2, 2)
expect(color.g).toBeCloseTo(0.4, 2)
expect(color.b).toBeCloseTo(0.6, 2)
const opacity = pageSection.getByRole('spinbutton', { name: 'Opacity' })
await opacity.click()
await opacity.fill('50')
await opacity.press('Enter')
await editor.canvas.waitForRender()
color = await pageColor()
expect(color.a).toBeCloseTo(0.5, 2)
editor.canvas.assertNoErrors()
})

View file

@ -40,8 +40,17 @@ test('fill visibility supports repeat click and undo redo', async () => {
)
})
test('empty list sections expose semantic empty state', async () => {
await expect(propertySection(editor.page, 'Fill')).not.toHaveAttribute('data-empty')
await expect(propertySection(editor.page, 'Stroke')).toHaveAttribute('data-empty', '')
await expect(propertySection(editor.page, 'Effects')).toHaveAttribute('data-empty', '')
await expect(propertySection(editor.page, 'Export')).toHaveAttribute('data-empty', '')
})
test('stroke visibility supports repeat click and undo redo', async () => {
await propertySection(editor.page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
const strokeSection = propertySection(editor.page, 'Stroke')
await strokeSection.getByRole('button', { name: 'Add stroke' }).click()
await expect(strokeSection).not.toHaveAttribute('data-empty')
await editor.canvas.waitForRender()
const strokeButton = propertyItems(editor.page, 'strokes')

View file

@ -53,7 +53,7 @@ test('typography section appears for text node', async () => {
await editor.canvas.click(200, 200)
await editor.canvas.waitForRender()
const typoSection = editor.page.getByTestId('typography-section')
const typoSection = editor.page.getByRole('region', { name: 'Typography' })
await expect(typoSection).toBeVisible()
})

View file

@ -44,6 +44,34 @@ test('double-click enters text edit mode', async () => {
canvas.assertNoErrors()
})
test('typography controls use compact labeled anatomy', async () => {
await canvas.pressKey('Escape')
await canvas.waitForRender()
await canvas.click(275, 215)
await canvas.waitForRender()
const typography = page.getByRole('region', { name: 'Typography' })
await expect(typography.getByRole('button', { name: 'Font family' })).toBeVisible()
await expect(typography.getByRole('combobox', { name: 'Font weight' })).toBeVisible()
await expect(typography.getByRole('spinbutton', { name: 'Font size' })).toBeVisible()
await expect(typography.getByRole('spinbutton', { name: 'Line height' })).toBeVisible()
await expect(typography.getByRole('spinbutton', { name: 'Letter spacing' })).toBeVisible()
await expect(typography.getByRole('combobox', { name: 'Direction' })).toBeVisible()
const alignment = typography.getByRole('group', { name: 'Text alignment' })
await expect(alignment.getByRole('button', { name: 'Align left' })).toHaveAttribute(
'aria-pressed',
'true'
)
await alignment.getByRole('button', { name: 'Align center horizontally' }).click()
await canvas.waitForRender()
await expect(
alignment.getByRole('button', { name: 'Align center horizontally' })
).toHaveAttribute('aria-pressed', 'true')
await expect(typography).toHaveScreenshot('typography-panel.png')
})
test('bold button toggles fontWeight to 700 then back to 400', async () => {
await canvas.pressKey('Escape')
await canvas.waitForRender()
@ -62,7 +90,9 @@ test('bold button toggles fontWeight to 700 then back to 400', async () => {
}, nodeId)
await canvas.waitForRender()
const boldBtn = page.getByTestId('typography-bold-button')
const boldBtn = page.getByRole('toolbar', { name: 'Text formatting' }).getByRole('button', {
name: /^Bold/
})
await expect(boldBtn).toBeVisible({ timeout: 3000 })
await page.waitForTimeout(200)
await boldBtn.click()
@ -72,7 +102,7 @@ test('bold button toggles fontWeight to 700 then back to 400', async () => {
const bold = await getNodeById(page, nodeId)
expect(bold?.fontWeight).toBe(700)
await page.getByTestId('typography-bold-button').click()
await boldBtn.click()
await page.waitForTimeout(500)
await canvas.waitForRender()

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -19,10 +19,16 @@ function variableRows() {
return editor.page.getByTestId('variable-row')
}
function openVariables() {
return editor.page
.getByRole('region', { name: 'Variables' })
.getByRole('button', { name: 'Open variables' })
}
test('variables dialog opens', async () => {
await createColorVariable('primary-color')
await editor.page.getByTestId('variables-section-open').click()
await openVariables().click()
await expect(editor.page.getByTestId('variables-dialog')).toBeVisible()
editor.canvas.assertNoErrors()
})
@ -87,7 +93,7 @@ test('color swatch opens color picker', async () => {
// close dialog if open from previous test
await editor.page.keyboard.press('Escape')
await editor.page.waitForTimeout(200)
await editor.page.getByTestId('variables-section-open').click()
await openVariables().click()
await expect(editor.page.getByTestId('variables-dialog')).toBeVisible({ timeout: 3000 })
const swatch = editor.page