Merge origin/master (PR #41 data-test-ids)

This commit is contained in:
Danila Poyarkov 2026-03-05 09:03:26 +03:00
commit d060e32784
36 changed files with 301 additions and 59 deletions

View file

@ -174,10 +174,11 @@ const topMenus = [
<template>
<div class="shrink-0 border-b border-border">
<div class="flex items-center gap-2 px-2 py-1.5">
<img src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<img data-test-id="app-logo" src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<input
v-if="editingName"
data-doc-name-edit
data-test-id="app-document-name-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0.5 text-xs text-surface outline-none"
:value="store.state.documentName"
@blur="commitRename($event.target as HTMLInputElement)"
@ -186,11 +187,13 @@ const topMenus = [
/>
<span
v-else
data-test-id="app-document-name"
class="min-w-0 flex-1 cursor-default truncate rounded px-1 py-0.5 text-xs text-surface hover:bg-hover"
@dblclick="startRename"
>{{ store.state.documentName }}</span
>
<button
data-test-id="app-toggle-ui"
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
title="Toggle UI (⌘\)"
@click="store.state.showUI = !store.state.showUI"
@ -202,6 +205,7 @@ const topMenus = [
<MenubarRoot class="flex items-center gap-0.5 overflow-x-auto scrollbar-none">
<MenubarMenu v-for="menu in topMenus" :key="menu.label">
<MenubarTrigger
:data-test-id="`menubar-${menu.label.toLowerCase()}`"
class="flex cursor-pointer items-center rounded px-2 py-1 text-xs text-muted transition-colors select-none hover:bg-hover hover:text-surface data-[state=open]:bg-hover data-[state=open]:text-surface"
>
{{ menu.label }}

View file

@ -22,6 +22,7 @@ const modelValue = defineModel<T>({ required: true })
<template>
<SelectRoot v-model="modelValue">
<SelectTrigger
data-test-id="app-select-trigger"
class="flex min-w-0 flex-1 cursor-pointer items-center justify-between rounded border border-border bg-input px-1.5 py-1 text-xs text-surface outline-none hover:bg-hover"
>
<SelectValue :placeholder="placeholder" />

View file

@ -13,6 +13,7 @@ const { copy, copied } = useClipboard({ copiedDuring: 1500 })
<ToastRoot
v-for="t in toast.toasts.value"
:key="t.id"
data-test-id="toast-item"
:duration="t.variant === 'error' ? 0 : toast.TOAST_DURATION"
class="flex max-w-sm items-start gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
:class="t.variant === 'error' ? 'bg-red-600' : 'bg-blue-600'"
@ -27,6 +28,7 @@ const { copy, copied } = useClipboard({ copiedDuring: 1500 })
<ToastDescription class="min-w-0 flex-1 select-text">{{ t.message }}</ToastDescription>
<button
v-if="t.variant === 'error'"
data-test-id="toast-copy-error"
class="mt-0.5 shrink-0 cursor-pointer rounded p-0.5 opacity-70 hover:opacity-100"
:title="copied ? 'Copied!' : 'Copy error'"
@click="copy(t.message)"
@ -36,6 +38,7 @@ const { copy, copied } = useClipboard({ copiedDuring: 1500 })
</button>
<ToastClose
v-if="t.variant === 'error'"
data-test-id="toast-close"
class="mt-0.5 shrink-0 cursor-pointer rounded p-0.5 opacity-70 hover:opacity-100"
>
<icon-lucide-x class="size-3" />

View file

@ -40,7 +40,7 @@ function handleStop() {
</script>
<template>
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div data-test-id="chat-panel" class="flex min-w-0 flex-1 flex-col overflow-hidden">
<APIKeySetup v-if="!isConfigured" />
<template v-else>
@ -49,6 +49,7 @@ function handleStop() {
<!-- Empty state -->
<div
v-if="messages.length === 0"
data-test-id="chat-empty-state"
class="flex h-full flex-col items-center justify-center gap-3 text-muted"
>
<icon-lucide-message-circle class="size-8 opacity-50" />
@ -56,11 +57,15 @@ function handleStop() {
</div>
<!-- Messages -->
<div v-else class="flex flex-col gap-3">
<div v-else data-test-id="chat-messages" class="flex flex-col gap-3">
<ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
<!-- Typing indicator -->
<div v-if="status === 'submitted'" class="flex gap-2">
<div
v-if="status === 'submitted'"
data-test-id="chat-typing-indicator"
class="flex gap-2"
>
<div
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
>

View file

@ -38,14 +38,22 @@ watch(jsxCode, () => {
</script>
<template>
<div v-if="!jsxCode" class="flex flex-1 items-center justify-center px-4 text-center">
<div
v-if="!jsxCode"
data-test-id="code-panel-empty"
class="flex flex-1 items-center justify-center px-4 text-center"
>
<span class="text-xs text-muted">Select a layer to see its JSX code</span>
</div>
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div v-else data-test-id="code-panel" class="flex min-h-0 flex-1 flex-col">
<div
data-test-id="code-panel-header"
class="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5"
>
<span class="text-[11px] text-muted">JSX</span>
<button
data-test-id="code-panel-copy"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
@click="copyCode"
>

View file

@ -92,6 +92,7 @@ function initials(name: string): string {
<TooltipRoot>
<TooltipTrigger as-child>
<div
data-test-id="collab-local-avatar"
class="flex size-6 items-center justify-center rounded-full border-2 border-panel text-[10px] font-semibold text-white"
:style="{ background: colorToCSS(state.localColor) }"
>
@ -111,6 +112,7 @@ function initials(name: string): string {
<TooltipRoot v-for="peer in peers" :key="peer.clientId">
<TooltipTrigger as-child>
<div
data-test-id="collab-peer-avatar"
class="flex size-6 cursor-pointer items-center justify-center rounded-full border-2 text-[10px] font-semibold text-white transition-all"
:class="
followingPeer === peer.clientId
@ -145,6 +147,7 @@ function initials(name: string): string {
<PopoverRoot v-model:open="popoverOpen">
<PopoverTrigger as-child>
<button
data-test-id="collab-share-button"
class="flex h-7 cursor-pointer items-center gap-1.5 rounded-md border-none px-3 text-xs font-medium transition-colors"
:class="
state.connected
@ -161,6 +164,7 @@ function initials(name: string): string {
<PopoverPortal>
<PopoverContent
data-test-id="collab-popover"
class="z-50 w-72 rounded-lg border border-border bg-panel p-3 shadow-xl"
:side-offset="8"
side="bottom"
@ -173,10 +177,12 @@ function initials(name: string): string {
<input
:value="shareUrl"
readonly
data-test-id="collab-room-link"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
@focus="($event.target as HTMLInputElement).select()"
/>
<button
data-test-id="collab-copy-link"
class="flex h-7 cursor-pointer items-center gap-1 rounded border-none bg-accent px-2 text-xs text-white hover:bg-accent/90"
@click="copyLink"
>
@ -191,6 +197,7 @@ function initials(name: string): string {
</div>
<button
data-test-id="collab-disconnect"
class="flex h-7 w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent text-xs text-muted hover:bg-hover hover:text-surface"
@click="emit('disconnect')"
>
@ -209,6 +216,7 @@ function initials(name: string): string {
<label class="mb-1 block text-xs text-muted">Your name</label>
<input
v-model="nameDraft"
data-test-id="collab-name-input"
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Enter your name"
autofocus
@ -217,6 +225,7 @@ function initials(name: string): string {
</div>
<button
data-test-id="collab-join-button"
class="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border-none bg-accent text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
:disabled="!nameDraft.trim()"
@click="onJoin"
@ -232,6 +241,7 @@ function initials(name: string): string {
<label class="mb-1 block text-xs text-muted">Your name</label>
<input
v-model="nameDraft"
data-test-id="collab-name-input"
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Enter your name"
@keydown.enter="onShare"
@ -239,6 +249,7 @@ function initials(name: string): string {
</div>
<button
data-test-id="collab-share-file"
class="mb-3 flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border-none bg-accent text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
:disabled="!nameDraft.trim()"
@click="onShare"
@ -256,11 +267,13 @@ function initials(name: string): string {
<div class="flex items-center gap-1.5">
<input
v-model="joinInput"
data-test-id="collab-join-input"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Paste room link or ID"
@keydown.enter="onJoin"
/>
<button
data-test-id="collab-join-room-button"
class="flex h-7 cursor-pointer items-center rounded border-none bg-accent px-3 text-xs text-white hover:bg-accent/90 disabled:opacity-50"
:disabled="!joinInput.trim() || !nameDraft.trim()"
@click="onJoin"

View file

@ -28,6 +28,7 @@ function onHexChange(e: Event) {
<ColorPicker :color="color" @update="emit('update', $event)" />
<input
v-if="editable"
data-test-id="color-hex-input"
class="min-w-0 flex-1 border-none bg-transparent font-mono text-xs text-surface outline-none"
:value="colorToHexRaw(color)"
maxlength="6"

View file

@ -24,6 +24,7 @@ const swatchColor = computed(() => {
<PopoverRoot>
<PopoverTrigger as-child>
<button
data-test-id="color-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ background: swatchColor }"
/>
@ -31,6 +32,7 @@ const swatchColor = computed(() => {
<PopoverPortal>
<PopoverContent
data-test-id="color-picker-popover"
class="z-[100] w-56 rounded-lg border border-border bg-panel p-2 shadow-xl"
:side-offset="4"
side="left"

View file

@ -28,8 +28,15 @@ const isComponentType = computed(() => {
<template>
<!-- Multi-select summary -->
<div v-if="multiCount > 1" class="flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pb-4">
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<div
v-if="multiCount > 1"
data-test-id="design-panel-multi"
class="flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pb-4"
>
<div
data-test-id="design-multi-header"
class="flex items-center gap-1.5 border-b border-border px-3 py-2"
>
<span class="text-[11px] text-muted">Mixed</span>
<span class="text-xs font-semibold">{{ multiCount }} layers</span>
</div>
@ -41,9 +48,15 @@ const isComponentType = computed(() => {
</div>
<!-- Single selection -->
<div v-else-if="node" class="flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pb-4">
<!-- Node header -->
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<div
v-else-if="node"
data-test-id="design-panel-single"
class="flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pb-4"
>
<div
data-test-id="design-node-header"
class="flex items-center gap-1.5 border-b border-border px-3 py-2"
>
<span class="text-[11px]" :class="isComponentType ? 'text-[#9747ff]' : 'text-muted'">{{
node.type
}}</span>
@ -56,12 +69,14 @@ const isComponentType = computed(() => {
class="flex flex-col gap-1 border-b border-border px-3 py-2"
>
<button
data-test-id="design-go-to-component"
class="rounded bg-[#9747ff]/10 px-2 py-1 text-left text-[11px] text-[#9747ff] hover:bg-[#9747ff]/20"
@click="store.goToMainComponent()"
>
Go to Main Component
</button>
<button
data-test-id="design-detach-instance"
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="store.detachInstance()"
>
@ -80,7 +95,11 @@ const isComponentType = computed(() => {
<ExportSection />
</div>
<div v-else class="flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pb-4">
<div
v-else
data-test-id="design-panel-empty"
class="flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pb-4"
>
<PageSection />
<VariablesSection @open-dialog="variablesOpen = true" />
</div>

View file

@ -40,11 +40,20 @@ const cursor = computed(() => {
<template>
<CanvasContextMenu>
<div class="canvas-area relative flex-1 min-w-0 min-h-0 overflow-hidden">
<canvas ref="canvasRef" :style="{ cursor }" class="block size-full touch-none" />
<div
data-test-id="canvas-area"
class="canvas-area relative flex-1 min-w-0 min-h-0 overflow-hidden"
>
<canvas
ref="canvasRef"
data-test-id="canvas-element"
:style="{ cursor }"
class="block size-full touch-none"
/>
<Transition leave-active-class="transition-opacity duration-300" leave-to-class="opacity-0">
<div
v-if="store.state.loading"
data-test-id="canvas-loading"
class="absolute inset-0 z-50 flex items-center justify-center bg-canvas"
>
<svg

View file

@ -231,6 +231,7 @@ function stopSwatchColor(stop: GradientStop) {
<PopoverRoot>
<PopoverTrigger as-child>
<button
data-test-id="fill-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ background: swatchBackground }"
/>
@ -247,6 +248,7 @@ function stopSwatchColor(stop: GradientStop) {
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'SOLID' }"
data-test-id="fill-picker-tab-solid"
title="Solid"
@click="setCategory('SOLID')"
>
@ -257,6 +259,7 @@ function stopSwatchColor(stop: GradientStop) {
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'GRADIENT' }"
data-test-id="fill-picker-tab-gradient"
title="Gradient"
@click="setCategory('GRADIENT')"
>
@ -273,6 +276,7 @@ function stopSwatchColor(stop: GradientStop) {
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'IMAGE' }"
data-test-id="fill-picker-tab-image"
title="Image"
@click="setCategory('IMAGE')"
>
@ -319,6 +323,7 @@ function stopSwatchColor(stop: GradientStop) {
<div
v-if="isGradient && fill.gradientStops?.length"
ref="gradientStopBarRef"
data-test-id="fill-picker-gradient-bar"
class="relative mb-2 h-6 rounded"
:style="{ background: gradientBarBackground }"
@pointermove="onStopBarPointerMove"
@ -343,6 +348,7 @@ function stopSwatchColor(stop: GradientStop) {
<span class="text-[11px] text-muted">Stops</span>
<button
class="flex size-4 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted hover:text-surface"
data-test-id="fill-picker-add-stop"
title="Add stop"
@click="addStop"
>
@ -399,6 +405,7 @@ function stopSwatchColor(stop: GradientStop) {
<!-- Image placeholder -->
<div
v-if="fill.type === 'IMAGE'"
data-test-id="fill-picker-image-placeholder"
class="flex h-24 items-center justify-center rounded border border-dashed border-border text-xs text-muted"
>
Image fill (coming soon)

View file

@ -50,6 +50,7 @@ function onSelect(val: string) {
<PopoverRoot v-model:open="open">
<PopoverAnchor>
<button
data-test-id="font-picker-trigger"
class="flex w-full cursor-pointer items-center justify-between rounded border border-border bg-input px-2 py-1 text-xs text-surface hover:bg-hover"
@click="open = !open"
>
@ -69,6 +70,7 @@ function onSelect(val: string) {
<ListboxFilter
ref="filterRef"
v-model="searchTerm"
data-test-id="font-picker-search"
class="min-w-0 flex-1 border-none bg-transparent text-xs text-surface outline-none placeholder:text-muted"
placeholder="Search fonts…"
autocomplete="off"
@ -86,6 +88,7 @@ function onSelect(val: string) {
>
<ListboxItem
:value="option"
data-test-id="font-picker-item"
class="flex w-full cursor-pointer items-center gap-2 px-2 py-2 text-sm text-surface outline-none data-[highlighted]:bg-hover"
:style="{ fontFamily: `'${option}', sans-serif` }"
>

View file

@ -131,6 +131,7 @@ const hueColor = computed(() => {
<!-- SV area -->
<div
ref="svAreaRef"
data-test-id="hsv-sv-area"
class="relative h-[140px] w-full cursor-crosshair overflow-hidden rounded"
:style="{ background: hueColor }"
@pointerdown="onSvPointerDown"
@ -146,7 +147,15 @@ const hueColor = computed(() => {
<!-- Hue slider -->
<div class="mt-2">
<input type="range" class="hue-slider" :value="hue" min="0" max="360" @input="onHueInput" />
<input
type="range"
data-test-id="hsv-hue-slider"
class="hue-slider"
:value="hue"
min="0"
max="360"
@input="onHueInput"
/>
</div>
<!-- Alpha slider -->
@ -157,6 +166,7 @@ const hueColor = computed(() => {
/>
<input
type="range"
data-test-id="hsv-alpha-slider"
class="alpha-slider"
:value="alpha * 100"
min="0"
@ -170,6 +180,7 @@ const hueColor = computed(() => {
<span class="text-[11px] text-muted">#</span>
<input
type="text"
data-test-id="hsv-hex-input"
class="min-w-0 flex-1 rounded border border-border bg-input px-1.5 py-0.5 font-mono text-xs text-surface"
:value="hexValue"
maxlength="6"
@ -177,6 +188,7 @@ const hueColor = computed(() => {
/>
<input
type="number"
data-test-id="hsv-alpha-input"
class="w-10 rounded border border-border bg-input px-1 py-0.5 text-right text-xs text-surface"
:value="Math.round(alpha * 100)"
min="0"

View file

@ -260,6 +260,7 @@ function updateDropTarget(ev: PointerEvent) {
<template>
<aside
data-test-id="layers-panel"
class="flex min-w-0 flex-1 flex-col overflow-hidden border-r border-border bg-panel"
style="contain: paint layout style"
>
@ -279,12 +280,19 @@ function updateDropTarget(ev: PointerEvent) {
/>
</SplitterResizeHandle>
<SplitterPanel :default-size="70" :min-size="20" class="flex flex-col overflow-hidden">
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">
<header
data-test-id="layers-header"
class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted"
>
Layers
</header>
<ContextMenuRoot :modal="false">
<ContextMenuTrigger as-child @contextmenu="onLayerRightClick">
<div ref="listRef" class="relative flex-1 overflow-y-auto scrollbar-thin px-1">
<div
ref="listRef"
data-test-id="layers-tree"
class="relative flex-1 overflow-y-auto scrollbar-thin px-1"
>
<TreeRoot
:key="treeKey"
v-slot="{ flattenItems }"
@ -301,6 +309,7 @@ function updateDropTarget(ev: PointerEvent) {
>
<TreeItem v-slot="{ isExpanded }" v-bind="item.bind" as-child @select="onSelect">
<button
data-test-id="layers-item"
class="group/row flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 text-left text-xs"
:class="[
store.state.selectedIds.has(item.value.id)

View file

@ -72,6 +72,7 @@ const menuClass =
<template>
<ContextMenuContent :class="menuClass" :side-offset="2" align="start">
<ContextMenuItem
data-test-id="context-copy"
:class="itemClass"
:disabled="!hasSelection"
@select="document.execCommand('copy')"
@ -80,6 +81,7 @@ const menuClass =
<span class="text-[11px] text-muted">C</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-cut"
:class="itemClass"
:disabled="!hasSelection"
@select="document.execCommand('cut')"
@ -87,11 +89,16 @@ const menuClass =
<span>Cut</span>
<span class="text-[11px] text-muted">X</span>
</ContextMenuItem>
<ContextMenuItem :class="itemClass" @select="document.execCommand('paste')">
<ContextMenuItem
data-test-id="context-paste"
:class="itemClass"
@select="document.execCommand('paste')"
>
<span>Paste here</span>
<span class="text-[11px] text-muted">V</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-duplicate"
:class="itemClass"
:disabled="!hasSelection"
@select="store.duplicateSelected()"
@ -99,7 +106,12 @@ const menuClass =
<span>Duplicate</span>
<span class="text-[11px] text-muted">D</span>
</ContextMenuItem>
<ContextMenuItem :class="itemClass" :disabled="!hasSelection" @select="store.deleteSelected()">
<ContextMenuItem
data-test-id="context-delete"
:class="itemClass"
:disabled="!hasSelection"
@select="store.deleteSelected()"
>
<span>Delete</span>
<span class="text-[11px] text-muted"></span>
</ContextMenuItem>
@ -107,7 +119,7 @@ const menuClass =
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSub v-if="otherPages.length > 0 && hasSelection">
<ContextMenuSubTrigger :class="itemClass">
<ContextMenuSubTrigger data-test-id="context-move-to-page" :class="itemClass">
<span>Move to page</span>
<span class="text-sm text-muted"></span>
</ContextMenuSubTrigger>
@ -125,26 +137,51 @@ const menuClass =
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuItem :class="itemClass" :disabled="!hasSelection" @select="store.bringToFront()">
<ContextMenuItem
data-test-id="context-bring-to-front"
:class="itemClass"
:disabled="!hasSelection"
@select="store.bringToFront()"
>
<span>Bring to front</span>
<span class="text-[11px] text-muted">]</span>
</ContextMenuItem>
<ContextMenuItem :class="itemClass" :disabled="!hasSelection" @select="store.sendToBack()">
<ContextMenuItem
data-test-id="context-send-to-back"
:class="itemClass"
:disabled="!hasSelection"
@select="store.sendToBack()"
>
<span>Send to back</span>
<span class="text-[11px] text-muted">[</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuItem :class="itemClass" :disabled="multiCount < 2" @select="store.groupSelected()">
<ContextMenuItem
data-test-id="context-group"
:class="itemClass"
:disabled="multiCount < 2"
@select="store.groupSelected()"
>
<span>Group</span>
<span class="text-[11px] text-muted">G</span>
</ContextMenuItem>
<ContextMenuItem v-if="isGroup" :class="itemClass" @select="store.ungroupSelected()">
<ContextMenuItem
v-if="isGroup"
data-test-id="context-ungroup"
:class="itemClass"
@select="store.ungroupSelected()"
>
<span>Ungroup</span>
<span class="text-[11px] text-muted">G</span>
</ContextMenuItem>
<ContextMenuItem v-if="hasSelection" :class="itemClass" @select="store.wrapInAutoLayout()">
<ContextMenuItem
v-if="hasSelection"
data-test-id="context-auto-layout"
:class="itemClass"
@select="store.wrapInAutoLayout()"
>
<span>Add auto layout</span>
<span class="text-[11px] text-muted">A</span>
</ContextMenuItem>
@ -152,6 +189,7 @@ const menuClass =
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuItem
data-test-id="context-create-component"
:class="componentItemClass"
:disabled="!hasSelection"
@select="store.createComponentFromSelection()"
@ -161,6 +199,7 @@ const menuClass =
</ContextMenuItem>
<ContextMenuItem
v-if="canCreateComponentSet"
data-test-id="context-create-component-set"
:class="componentItemClass"
@select="store.createComponentSetFromComponents()"
>
@ -169,6 +208,7 @@ const menuClass =
</ContextMenuItem>
<ContextMenuItem
v-if="isComponent"
data-test-id="context-create-instance"
:class="componentItemClass"
@select="store.createInstanceFromComponent(singleNode!.id)"
>
@ -176,12 +216,18 @@ const menuClass =
</ContextMenuItem>
<ContextMenuItem
v-if="isInstance"
data-test-id="context-go-to-component"
:class="componentItemClass"
@select="store.goToMainComponent()"
>
<span>Go to main component</span>
</ContextMenuItem>
<ContextMenuItem v-if="isInstance" :class="itemClass" @select="store.detachInstance()">
<ContextMenuItem
v-if="isInstance"
data-test-id="context-detach-instance"
:class="itemClass"
@select="store.detachInstance()"
>
<span>Detach instance</span>
<span class="text-[11px] text-muted">B</span>
</ContextMenuItem>
@ -189,18 +235,30 @@ const menuClass =
<template v-if="hasSelection">
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuItem :class="itemClass" @select="store.toggleVisibility()">
<ContextMenuItem
data-test-id="context-toggle-visibility"
:class="itemClass"
@select="store.toggleVisibility()"
>
<span>{{ isVisible ? 'Hide' : 'Show' }}</span>
<span class="text-[11px] text-muted">H</span>
</ContextMenuItem>
<ContextMenuItem :class="itemClass" @select="store.toggleLock()">
<ContextMenuItem
data-test-id="context-toggle-lock"
:class="itemClass"
@select="store.toggleLock()"
>
<span>{{ isLocked ? 'Unlock' : 'Lock' }}</span>
<span class="text-[11px] text-muted">L</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuItem :class="itemClass" @select="store.exportSelection(1, 'PNG')">
<ContextMenuItem
data-test-id="context-export-png"
:class="itemClass"
@select="store.exportSelection(1, 'PNG')"
>
<span>Export as PNG</span>
<span class="text-[11px] text-muted">E</span>
</ContextMenuItem>

View file

@ -44,10 +44,13 @@ function onKeydown(e: KeyboardEvent, pageId: string) {
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col">
<div data-test-id="pages-panel" class="flex min-h-0 flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between px-3 py-1.5">
<span class="text-[11px] uppercase tracking-wider text-muted">Pages</span>
<span data-test-id="pages-header" class="text-[11px] uppercase tracking-wider text-muted"
>Pages</span
>
<button
data-test-id="pages-add"
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
title="Add page"
@click="store.addPage()"
@ -60,6 +63,7 @@ function onKeydown(e: KeyboardEvent, pageId: string) {
<input
v-if="editingPageId === pg.id"
data-page-edit
data-test-id="pages-item-input"
class="w-full rounded border border-accent bg-input px-2 py-1 text-xs text-surface outline-none"
:value="pg.name"
@blur="commitRename(pg.id, $event.target as HTMLInputElement)"
@ -74,6 +78,7 @@ function onKeydown(e: KeyboardEvent, pageId: string) {
</div>
<button
v-else
data-test-id="pages-item"
class="flex w-full cursor-pointer items-center gap-1.5 rounded border-none px-2 py-1 text-left text-xs"
:class="
pg.id === store.state.currentPageId

View file

@ -14,6 +14,7 @@ const { activeTab } = useAIChat()
<template>
<aside
data-test-id="properties-panel"
class="flex min-w-0 flex-1 flex-col overflow-hidden border-l border-border bg-panel"
style="contain: paint layout style"
>
@ -21,12 +22,14 @@ const { activeTab } = useAIChat()
<TabsList class="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
<TabsTrigger
value="design"
data-test-id="properties-tab-design"
class="rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
Design
</TabsTrigger>
<TabsTrigger
value="code"
data-test-id="properties-tab-code"
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
<icon-lucide-code class="size-3" />
@ -34,6 +37,7 @@ const { activeTab } = useAIChat()
</TabsTrigger>
<TabsTrigger
value="ai"
data-test-id="properties-tab-ai"
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
<icon-lucide-sparkles class="size-3" />
@ -41,6 +45,7 @@ const { activeTab } = useAIChat()
</TabsTrigger>
<span
v-if="activeTab === 'design'"
data-test-id="properties-zoom"
class="ml-auto cursor-pointer rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover"
>
{{ Math.round(store.state.zoom * 100) }}%

View file

@ -10,6 +10,7 @@ const show = !IS_TAURI && typeof window !== 'undefined' && !window.showSaveFileP
<template>
<div
v-if="show && !dismissed"
data-test-id="safari-banner"
class="flex items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200"
>
<span class="flex-1">
@ -19,6 +20,7 @@ const show = !IS_TAURI && typeof window !== 'undefined' && !window.showSaveFileP
Edge for full support.
</span>
<button
data-test-id="safari-banner-dismiss"
class="shrink-0 rounded px-1.5 py-0.5 text-amber-300 transition-colors hover:bg-amber-500/20"
@click="dismissed = true"
>

View file

@ -111,6 +111,7 @@ function onKeydown(e: KeyboardEvent) {
<template>
<div
data-test-id="scrub-input"
class="flex min-w-0 flex-1 items-center rounded border border-border bg-input h-[26px] focus-within:border-accent"
:style="{ cursor: editing ? 'auto' : 'ew-resize' }"
@pointerdown="!editing && startScrub($event)"
@ -127,6 +128,7 @@ function onKeydown(e: KeyboardEvent) {
v-if="editing"
ref="inputRef"
type="number"
data-test-id="scrub-input-field"
class="min-w-0 flex-1 cursor-text border-none bg-transparent pr-1.5 font-[inherit] text-xs text-surface outline-none"
:value="isMixed ? '' : displayValue"
:placeholder="placeholder"

View file

@ -36,12 +36,14 @@ function onClose(e: MouseEvent, tabId: string) {
v-for="tab in tabs"
:key="tab.id"
:value="tab.id"
data-test-id="tabbar-tab"
class="group/tab flex h-full max-w-48 min-w-0 cursor-pointer items-center gap-1.5 border-r border-border px-3 text-xs transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-accent data-[state=active]:bg-panel data-[state=active]:text-surface data-[state=inactive]:text-muted data-[state=inactive]:hover:text-surface"
@mousedown="onMiddleClick($event, tab.id)"
>
<icon-lucide-file class="size-3 shrink-0 opacity-50" />
<span class="min-w-0 flex-1 truncate">{{ tab.name }}</span>
<button
data-test-id="tabbar-close"
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded opacity-0 transition-opacity hover:bg-hover group-hover/tab:opacity-100 data-[state=active]:opacity-100"
:class="tab.isActive ? 'opacity-100' : ''"
:title="`Close ${tab.name}`"
@ -54,6 +56,7 @@ function onClose(e: MouseEvent, tabId: string) {
</TabsTrigger>
</TabsList>
<button
data-test-id="tabbar-new"
class="flex size-9 shrink-0 cursor-pointer items-center justify-center text-muted transition-colors hover:text-surface"
title="New tab"
aria-label="New tab"

View file

@ -81,11 +81,15 @@ function activeKeyForTool(tool: (typeof TOOLS)[number]): Tool {
<template>
<div class="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center">
<div class="flex gap-0.5 rounded-xl border border-border bg-panel p-1 shadow-lg">
<div
data-test-id="toolbar"
class="flex gap-0.5 rounded-xl border border-border bg-panel p-1 shadow-lg"
>
<template v-for="tool in TOOLS" :key="tool.key">
<!-- Tool with flyout: split button + chevron -->
<div v-if="tool.flyout && tool.flyout.length > 1" class="flex items-center">
<button
:data-test-id="`toolbar-tool-${activeKeyForTool(tool).toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-lg border-none transition-colors"
:class="
isActive(tool)
@ -101,6 +105,7 @@ function activeKeyForTool(tool: (typeof TOOLS)[number]): Tool {
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
:data-test-id="`toolbar-flyout-${tool.key.toLowerCase()}`"
class="flex h-8 w-3 cursor-pointer items-center justify-center rounded-lg border-none transition-colors"
:class="
isActive(tool)
@ -122,6 +127,7 @@ function activeKeyForTool(tool: (typeof TOOLS)[number]): Tool {
<DropdownMenuItem
v-for="sub in tool.flyout"
:key="sub"
:data-test-id="`toolbar-flyout-item-${sub.toLowerCase()}`"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none transition-colors"
:class="
store.state.activeTool === sub
@ -144,6 +150,7 @@ function activeKeyForTool(tool: (typeof TOOLS)[number]): Tool {
<!-- Simple tool button -->
<button
v-else
:data-test-id="`toolbar-tool-${tool.key.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-lg border-none transition-colors"
:class="
isActive(tool)

View file

@ -378,6 +378,7 @@ const table = useVueTable({
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-40 bg-black/50" />
<DialogContent
data-test-id="variables-dialog"
class="fixed left-1/2 top-1/2 z-50 flex h-[75vh] w-[800px] max-w-[90vw] -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border border-border bg-panel shadow-2xl outline-none"
>
<div v-if="collections.length === 0" class="flex flex-1 flex-col">
@ -393,6 +394,7 @@ const table = useVueTable({
<div class="text-center">
<p class="text-sm text-muted">No variable collections</p>
<button
data-test-id="variables-create-collection"
class="mt-2 cursor-pointer rounded bg-hover px-3 py-1.5 text-xs text-surface hover:bg-border"
@click="addCollection"
>
@ -420,6 +422,7 @@ const table = useVueTable({
<TabsTrigger
v-else
:value="col.id"
data-test-id="variables-collection-tab"
class="cursor-pointer whitespace-nowrap rounded border-none px-2.5 py-1 text-xs text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
@dblclick="startRenameCollection(col.id)"
>
@ -438,6 +441,7 @@ const table = useVueTable({
/>
</div>
<button
data-test-id="variables-add-collection"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
title="Add collection"
@click="addCollection"
@ -521,6 +525,7 @@ const table = useVueTable({
<!-- Footer -->
<button
data-test-id="variables-add-variable"
class="flex w-full shrink-0 cursor-pointer items-center gap-1.5 border-t border-border bg-transparent px-4 py-2 text-xs text-muted hover:bg-hover hover:text-surface"
@click="addVariable"
>

View file

@ -14,18 +14,23 @@ function save() {
</script>
<template>
<div class="flex flex-1 flex-col items-center justify-center gap-4 px-4">
<div
data-test-id="api-key-setup"
class="flex flex-1 flex-col items-center justify-center gap-4 px-4"
>
<icon-lucide-key-round class="size-8 text-muted" />
<p class="text-center text-xs text-muted">Enter your OpenRouter API key to start chatting.</p>
<form class="flex w-full gap-1.5" @submit.prevent="save">
<input
v-model="input"
type="password"
data-test-id="api-key-input"
placeholder="sk-or-…"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface outline-none focus:border-accent"
/>
<button
type="submit"
data-test-id="api-key-save"
class="shrink-0 rounded bg-accent px-2.5 py-1 text-xs font-medium text-white hover:bg-accent/90"
:disabled="!input.trim()"
>
@ -35,6 +40,7 @@ function save() {
<a
href="https://openrouter.ai/keys"
target="_blank"
data-test-id="api-key-get-link"
class="text-[10px] text-muted underline hover:text-surface"
>
Get an API key

View file

@ -51,6 +51,7 @@ function handleSubmit(e: Event) {
<div class="mb-1.5 flex items-center">
<SelectRoot v-model="modelId">
<SelectTrigger
data-test-id="chat-model-selector"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-bot class="size-3" />
@ -90,6 +91,7 @@ function handleSubmit(e: Event) {
<input
v-model="input"
type="text"
data-test-id="chat-input"
placeholder="Describe a change…"
class="min-w-0 flex-1 rounded border border-border bg-input px-2.5 py-1.5 text-xs text-surface outline-none placeholder:text-muted focus:border-accent"
:disabled="status === 'submitted'"
@ -98,6 +100,7 @@ function handleSubmit(e: Event) {
<TooltipTrigger as-child>
<button
type="button"
data-test-id="chat-stop-button"
class="shrink-0 rounded border border-border px-2 py-1.5 text-xs text-muted hover:bg-hover"
@click="emit('stop')"
>
@ -118,6 +121,7 @@ function handleSubmit(e: Event) {
<TooltipTrigger as-child>
<button
type="submit"
data-test-id="chat-send-button"
class="shrink-0 rounded bg-accent px-2.5 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
:disabled="!input.trim()"
>

View file

@ -55,11 +55,15 @@ function toolState(part: ToolPart): 'pending' | 'done' | 'error' {
</script>
<template>
<div :class="message.role === 'user' ? 'flex justify-end' : ''">
<div
:data-test-id="`chat-message-${message.role}`"
:class="message.role === 'user' ? 'flex justify-end' : ''"
>
<div class="min-w-0 space-y-1.5" :class="message.role === 'user' ? 'max-w-[85%]' : ''">
<!-- Tool timeline -->
<div
v-if="message.role === 'assistant' && getToolParts(message).length > 0"
data-test-id="chat-tool-timeline"
class="space-y-0.5 rounded-lg border border-border bg-canvas p-2"
>
<CollapsibleRoot v-for="tool in getToolParts(message)" :key="tool.toolCallId">
@ -114,6 +118,7 @@ function toolState(part: ToolPart): 'pending' | 'done' | 'error' {
<!-- Text bubble -->
<div
v-if="getTextContent(message)"
data-test-id="chat-text-bubble"
class="rounded-xl px-3 py-2 text-xs leading-relaxed"
:class="
message.role === 'user'

View file

@ -147,10 +147,11 @@ const cornerRadiusValue = computed(() => {
</script>
<template>
<div v-if="active" class="border-b border-border px-3 py-2">
<div v-if="active" data-test-id="appearance-section" class="border-b border-border px-3 py-2">
<div class="mb-1.5 flex items-center justify-between">
<label class="text-[11px] text-muted">Appearance</label>
<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' }"
title="Toggle visibility"

View file

@ -143,10 +143,11 @@ function toggleExpand(index: number) {
</script>
<template>
<div v-if="active" class="border-b border-border px-3 py-2">
<div v-if="active" data-test-id="effects-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Effects</label>
<button
data-test-id="effects-section-add"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="add"
>
@ -156,7 +157,12 @@ function toggleExpand(index: number) {
<p v-if="effectsAreMixed" class="text-[11px] text-muted">Click + to replace mixed effects</p>
<div v-for="(effect, i) in effectsAreMixed ? [] : ((node ?? nodes[0])?.effects ?? [])" :key="i">
<div
v-for="(effect, i) in effectsAreMixed ? [] : ((node ?? nodes[0])?.effects ?? [])"
:key="i"
data-test-id="effects-item"
:data-test-index="i"
>
<!-- Collapsed row: color swatch | type dropdown | eye | minus -->
<div class="group flex items-center gap-1.5 py-0.5">
<button

View file

@ -95,10 +95,11 @@ onUnmounted(() => {
</script>
<template>
<div class="border-b border-border px-3 py-2">
<div data-test-id="export-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Export</label>
<button
data-test-id="export-section-add"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="addSetting"
>
@ -106,7 +107,13 @@ onUnmounted(() => {
</button>
</div>
<div v-for="(setting, i) in settings" :key="i" class="flex items-center gap-1.5 py-0.5">
<div
v-for="(setting, i) in settings"
:key="i"
data-test-id="export-item"
:data-test-index="i"
class="flex items-center gap-1.5 py-0.5"
>
<AppSelect
:model-value="setting.scale"
:options="SCALE_OPTIONS"
@ -128,6 +135,7 @@ onUnmounted(() => {
<button
v-if="settings.length > 0"
data-test-id="export-button"
class="mt-1.5 w-full cursor-pointer truncate rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:cursor-default disabled:opacity-50"
:disabled="exporting"
@click="doExport"
@ -137,6 +145,7 @@ onUnmounted(() => {
<button
v-if="settings.length > 0"
data-test-id="export-preview-toggle"
class="mt-1 flex w-full cursor-pointer items-center gap-1 rounded border-none bg-transparent px-0 py-1 text-[11px] text-muted hover:text-surface"
@click="showPreview = !showPreview"
>

View file

@ -116,10 +116,11 @@ const filteredVariables = computed(() => {
</script>
<template>
<div v-if="active" class="border-b border-border px-3 py-2">
<div v-if="active" data-test-id="fill-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Fill</label>
<button
data-test-id="fill-section-add"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="add"
>
@ -130,6 +131,8 @@ const filteredVariables = computed(() => {
<div
v-for="(fill, i) in fillsAreMixed ? [] : (activeNode?.fills ?? [])"
:key="i"
data-test-id="fill-item"
:data-test-index="i"
class="group flex items-center gap-1.5 py-0.5"
>
<FillPicker :fill="fill" @update="updateFill(i, $event)" />
@ -142,6 +145,7 @@ const filteredVariables = computed(() => {
{{ getBoundVariable(i)!.name }}
</span>
<button
data-test-id="fill-unbind-variable"
class="cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
title="Detach variable"
@click="unbindVariable(i)"

View file

@ -126,7 +126,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
</script>
<template>
<div v-if="node" class="border-b border-border px-3 py-2">
<div v-if="node" data-test-id="layout-section" class="border-b border-border px-3 py-2">
<label class="mb-1.5 block text-[11px] text-muted">Layout</label>
<div class="flex gap-1.5">
<!-- Width -->
@ -147,6 +147,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
</button>
<div
v-if="widthSizingOpen"
data-test-id="layout-width-sizing-dropdown"
class="absolute top-full left-0 right-0 z-10 min-w-40 rounded-md border border-border bg-panel p-1 shadow-lg"
>
<button
@ -194,6 +195,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
</button>
<div
v-if="heightSizingOpen"
data-test-id="layout-height-sizing-dropdown"
class="absolute top-full left-0 right-0 z-10 min-w-40 rounded-md border border-border bg-panel p-1 shadow-lg"
>
<button
@ -233,6 +235,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
<button
v-if="node.layoutMode === 'NONE'"
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
data-test-id="layout-add-auto"
title="Add auto layout (Shift+A)"
@click="store.setLayoutMode(node.id, 'VERTICAL')"
>
@ -241,6 +244,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
<button
v-else
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
data-test-id="layout-remove-auto"
title="Remove auto layout"
@click="store.setLayoutMode(node.id, 'NONE')"
>
@ -258,6 +262,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
? 'border-accent bg-accent text-white'
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'
"
data-test-id="layout-direction-vertical"
title="Vertical layout"
@click="store.setLayoutMode(node.id, 'VERTICAL')"
>
@ -274,6 +279,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
? 'border-accent bg-accent text-white'
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'
"
data-test-id="layout-direction-horizontal"
title="Horizontal layout"
@click="store.setLayoutMode(node.id, 'HORIZONTAL')"
>
@ -290,6 +296,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
? 'border-accent bg-accent text-white'
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'
"
data-test-id="layout-direction-wrap"
title="Wrap"
@click="updateProp('layoutWrap', node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
>
@ -303,7 +310,10 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
<!-- Alignment grid + Gap -->
<div class="mt-1.5 flex items-center gap-2">
<div class="grid grid-cols-3 gap-0.5 rounded border border-border bg-input p-1">
<div
data-test-id="layout-alignment-grid"
class="grid grid-cols-3 gap-0.5 rounded border border-border bg-input p-1"
>
<button
v-for="(a, i) in ALIGN_GRID"
:key="i"

View file

@ -13,7 +13,7 @@ function updateColor(color: Color) {
</script>
<template>
<div class="border-b border-border px-3 py-2">
<div data-test-id="page-section" class="border-b border-border px-3 py-2">
<label class="mb-1.5 block text-[11px] text-muted">Page</label>
<ColorInput :color="store.state.pageColor" editable @update="updateColor" />
</div>

View file

@ -124,7 +124,7 @@ function rotate90() {
</script>
<template>
<div v-if="active" class="border-b border-border px-3 py-2">
<div v-if="active" data-test-id="position-section" class="border-b border-border px-3 py-2">
<label class="mb-1.5 block text-[11px] text-muted">Position</label>
<!-- Alignment buttons -->
@ -132,6 +132,7 @@ function rotate90() {
<div class="flex gap-0.5">
<button
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-left"
title="Align left"
@click="alignHorizontal('left')"
>
@ -139,6 +140,7 @@ function rotate90() {
</button>
<button
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-center-h"
title="Align center horizontally"
@click="alignHorizontal('center')"
>
@ -146,6 +148,7 @@ function rotate90() {
</button>
<button
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-right"
title="Align right"
@click="alignHorizontal('right')"
>
@ -155,6 +158,7 @@ function rotate90() {
<div class="flex gap-0.5">
<button
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-top"
title="Align top"
@click="alignVertical('top')"
>
@ -162,6 +166,7 @@ function rotate90() {
</button>
<button
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-center-v"
title="Align center vertically"
@click="alignVertical('center')"
>
@ -169,6 +174,7 @@ function rotate90() {
</button>
<button
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-bottom"
title="Align bottom"
@click="alignVertical('bottom')"
>
@ -228,6 +234,7 @@ function rotate90() {
</ScrubInput>
<button
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-flip-horizontal"
title="Flip horizontal"
@click="flipHorizontal"
>
@ -235,6 +242,7 @@ function rotate90() {
</button>
<button
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-flip-vertical"
title="Flip vertical"
@click="flipVertical"
>
@ -242,6 +250,7 @@ function rotate90() {
</button>
<button
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-rotate-90"
title="Rotate 90°"
@click="rotate90"
>

View file

@ -231,18 +231,16 @@ const borderWeights = computed(() => {
</script>
<template>
<div v-if="active" class="border-b border-border px-3 py-2">
<!-- Header: label + add button -->
<div v-if="active" data-test-id="stroke-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Stroke</label>
<div class="flex items-center gap-0.5">
<button
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="add"
>
+
</button>
</div>
<button
data-test-id="stroke-section-add"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="add"
>
+
</button>
</div>
<p v-if="strokesAreMixed" class="text-[11px] text-muted">Click + to replace mixed strokes</p>
@ -251,6 +249,8 @@ const borderWeights = computed(() => {
<div
v-for="(stroke, i) in strokesAreMixed ? [] : (activeNode?.strokes ?? [])"
:key="i"
data-test-id="stroke-item"
:data-test-index="i"
class="group flex items-center gap-1.5 py-0.5"
>
<ColorInput

View file

@ -73,13 +73,14 @@ onMounted(async () => {
</script>
<template>
<div v-if="node" class="border-b border-border px-3 py-2">
<div v-if="node" data-test-id="typography-section" class="border-b border-border px-3 py-2">
<label class="mb-1.5 block text-[11px] text-muted">Typography</label>
<div class="mb-1.5 flex items-center gap-1.5">
<FontPicker class="min-w-0 flex-1" :model-value="node.fontFamily" @select="selectFamily" />
<icon-lucide-alert-triangle
v-if="hasMissingFonts"
data-test-id="typography-missing-font"
class="size-3.5 shrink-0 text-amber-400"
:title="
'Missing font' + (missingFonts.length > 1 ? 's' : '') + ': ' + missingFonts.join(', ')

View file

@ -18,10 +18,11 @@ const variableCount = computed(() => {
</script>
<template>
<div class="border-b border-border px-3 py-2">
<div data-test-id="variables-section" class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="text-[11px] font-medium text-surface">Variables</label>
<button
data-test-id="variables-section-open"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
title="Open variables"
@click="emit('openDialog')"

View file

@ -66,7 +66,7 @@ function onDisconnect() {
</script>
<template>
<div class="flex h-screen w-screen flex-col">
<div data-test-id="editor-root" class="flex h-screen w-screen flex-col">
<SafariBanner />
<TabBar />
<SplitterGroup
@ -122,8 +122,11 @@ function onDisconnect() {
class="absolute left-7 top-7 z-10 flex items-center gap-2 rounded-lg border border-border bg-panel px-2 py-1 shadow-sm"
>
<img src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<span class="text-xs text-surface">{{ store.state.documentName }}</span>
<span data-test-id="editor-document-name" class="text-xs text-surface">{{
store.state.documentName
}}</span>
<button
data-test-id="editor-show-ui"
class="ml-1 flex size-6 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
title="Show UI (⌘\)"
@click="store.state.showUI = true"