Merge remote-tracking branch 'origin/master' into reka-color-picker

# Conflicts:
#	src/components/HsvColorArea.vue
This commit is contained in:
Danila Poyarkov 2026-03-05 09:05:06 +03:00
commit 29096fa56b
49 changed files with 786 additions and 176 deletions

View file

@ -39,6 +39,8 @@ jobs:
- name: Unit tests
run: bun test tests/engine/
env:
BUN_HEAVY_TESTS: '1'
- name: Copy-paste detection
run: bun run test:dupes

View file

@ -16,6 +16,8 @@
### Fixes
- Fix hover highlighting nodes from internal component pages — scope hit-test to current page
- Fix hit-testing on transparent frames and groups — empty containers without fills or strokes are now click-through, clipping parents reject hits outside their bounds, matching Figma behavior
- Fix instance overrides on .fig import and clipboard paste — resolve guidPaths by overrideKey, handle component swaps (`overriddenSymbolID`), propagate through nested clone chains. Import and paste now share a single override engine.
- Apply Figma component property assignments on import — boolean visibility toggles and instance swaps via `componentPropRefs`/`componentPropAssignments`
- Apply `derivedSymbolData` sizes on import — containers now shrink correctly when component properties hide children

View file

@ -86,6 +86,26 @@ export function populateAndApplyOverrides(
nodeIdToGuid.set(nodeId, figmaId)
}
// Pre-compute componentId root for every node while all internal page nodes
// are still alive. After overrides, instance swaps delete intermediate clones,
// breaking the chain. DSD resolution uses this to match across clone levels.
const preComputedRoot = new Map<string, string>()
function getPreComputedRoot(nodeId: string, depth = 0): string {
if (preComputedRoot.has(nodeId)) return preComputedRoot.get(nodeId) ?? nodeId
if (depth > 20) return nodeId
const node = graph.getNode(nodeId)
if (node?.componentId && node.componentId !== nodeId) {
const root = getPreComputedRoot(node.componentId, depth + 1)
preComputedRoot.set(nodeId, root)
return root
}
preComputedRoot.set(nodeId, nodeId)
return nodeId
}
for (const node of graph.getAllNodes()) {
if (node.componentId) getPreComputedRoot(node.id)
}
// Component root resolution (walks componentId chain to the ultimate source)
const componentIdRoot = new Map<string, string>()
function getComponentRoot(nodeId: string, depth = 0): string {
@ -123,14 +143,35 @@ export function populateAndApplyOverrides(
}
function findNodeByComponentId(parentId: string, componentId: string): string | null {
const targetRoot = getComponentRoot(componentId)
const parent = graph.getNode(parentId)
if (!parent) return null
// Pass 1: exact componentId match on direct children
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child) continue
if (child.componentId === componentId) return childId
if (child.componentId && getComponentRoot(child.componentId) === targetRoot) return childId
if (child?.componentId === componentId) return childId
}
// Pass 2: root match — but only if exactly one child shares the root
// (multiple siblings with the same root are ambiguous)
const targetRoot = preComputedRoot.get(componentId) ?? getComponentRoot(componentId)
if (targetRoot) {
let rootMatch: string | null = null
let ambiguous = false
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child?.componentId) continue
const childRoot = preComputedRoot.get(child.componentId) ?? getComponentRoot(child.componentId)
if (childRoot === targetRoot) {
if (rootMatch) { ambiguous = true; break }
rootMatch = childId
}
}
if (rootMatch && !ambiguous) return rootMatch
}
// Pass 3: recurse into children
for (const childId of parent.childIds) {
const deep = findNodeByComponentId(childId, componentId)
if (deep) return deep
}
@ -183,6 +224,7 @@ export function populateAndApplyOverrides(
function repopulateInstance(nodeId: string, compId: string) {
const node = graph.getNode(nodeId)
if (!node || node.type !== 'INSTANCE') return
for (const childId of [...node.childIds]) graph.deleteNode(childId)
graph.updateNode(nodeId, { componentId: compId })
const comp = graph.getNode(compId)
@ -210,11 +252,28 @@ export function populateAndApplyOverrides(
}
}
// Apply assignments from cloned instance sources. After population,
// cloned instances have componentId pointing to the original kiwi node.
// If that node had componentPropAssignments, apply them to the clone.
// Apply assignments from the instance's own kiwi data first. The graph
// node for a kiwi INSTANCE has componentPropAssignments that control
// which children are visible, swapped, etc.
for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE' || !node.componentId) continue
if (node.type !== 'INSTANCE') continue
const ownFigmaId = nodeIdToGuid.get(node.id)
if (ownFigmaId) {
const ownAssignments = assignmentSources.get(ownFigmaId)
if (ownAssignments) {
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of ownAssignments) {
if (a.defID) valueByDef.set(guidToString(a.defID), a.value)
}
applyPropAssignments(node.id, valueByDef, propRefsMap)
}
}
// Also apply assignments from cloned instance sources. After
// population, cloned instances have componentId pointing to
// the original kiwi node. If that node had assignments, apply
// them to the clone (defaults for nested instances).
if (!node.componentId) continue
const sourceFigmaId = nodeIdToGuid.get(node.componentId)
if (!sourceFigmaId) continue
const assignments = assignmentSources.get(sourceFigmaId)
@ -307,7 +366,10 @@ export function populateAndApplyOverrides(
const cmd = scaled[o++]
if (cmd === 0) continue
const coords = cmd === 1 || cmd === 2 ? 1 : cmd === 4 ? 3 : -1
if (coords < 0) break
if (coords < 0) {
console.warn(`scaleGeometryBlobs: unknown path command ${cmd} at offset ${o - 1}`)
break
}
for (let i = 0; i < coords; i++) {
dv.setFloat32(o, dv.getFloat32(o, true) * sx, true)
dv.setFloat32(o + 4, dv.getFloat32(o + 4, true) * sy, true)
@ -319,6 +381,9 @@ export function populateAndApplyOverrides(
}
function applyDerivedSymbolData() {
const dsdModified = new Set<string>()
const dsdSizeSet = new Set<string>()
for (const [ncId, nc] of changeMap) {
if (nc.type !== 'INSTANCE') continue
const derived = nc.derivedSymbolData
@ -327,7 +392,8 @@ export function populateAndApplyOverrides(
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (const d of derived) {
for (let i = 0; i < derived.length; i++) {
const d = derived[i]
const guids = d.guidPath?.guids
if (!guids?.length) continue
@ -361,6 +427,61 @@ export function populateAndApplyOverrides(
if (Object.keys(updates).length > 0) {
graph.updateNode(targetId, updates)
dsdModified.add(targetId)
if (d.size) dsdSizeSet.add(targetId)
}
}
}
// Propagate DSD changes through clone chains. Each clone should match
// its source (componentId) for size/position/geometry. Iterate until
// convergence so deeper clone levels receive updates even when
// intermediate clones were also directly DSD-targeted (e.g., a DSD
// entry set only geometry on an intermediate clone — its size must
// still be inherited from the source).
//
// Nodes whose size was explicitly set by DSD (in dsdSizeSet) keep
// their own values; nodes only touched for position/geometry inherit
// size from their source.
if (dsdModified.size > 0) {
const clonesOf = new Map<string, string[]>()
for (const node of graph.getAllNodes()) {
if (!node.componentId) continue
let arr = clonesOf.get(node.componentId)
if (!arr) {
arr = []
clonesOf.set(node.componentId, arr)
}
arr.push(node.id)
}
// BFS from DSD-modified nodes. Unlike the old version, intermediate
// clones that are also in dsdModified are NOT skipped — they act as
// chain links. Nodes in dsdSizeSet keep their explicit size but still
// propagate to their clones.
const queue = [...dsdModified]
const visited = new Set<string>()
for (let sourceId = queue.shift(); sourceId !== undefined; sourceId = queue.shift()) {
const source = graph.getNode(sourceId)
if (!source) continue
const clones = clonesOf.get(sourceId)
if (!clones) continue
for (const cloneId of clones) {
if (visited.has(cloneId)) continue
visited.add(cloneId)
const clone = graph.getNode(cloneId)
if (!clone) continue
if (!dsdSizeSet.has(cloneId)) {
const cu: Partial<SceneNode> = {}
if (source.width !== clone.width) cu.width = source.width
if (source.height !== clone.height) cu.height = source.height
if (source.x !== clone.x) cu.x = source.x
if (source.y !== clone.y) cu.y = source.y
if (source.fillGeometry !== clone.fillGeometry) cu.fillGeometry = structuredClone(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry) cu.strokeGeometry = structuredClone(source.strokeGeometry)
if (Object.keys(cu).length > 0) graph.updateNode(cloneId, cu)
}
queue.push(cloneId)
}
}
}
@ -404,6 +525,36 @@ export function populateAndApplyOverrides(
return overriddenNodes
}
function syncNodeProps(source: SceneNode, target: SceneNode) {
const updates: Partial<SceneNode> = {}
if (source.text !== undefined && source.text !== target.text) updates.text = source.text
if (source.visible !== undefined && source.visible !== target.visible) updates.visible = source.visible
if (source.opacity !== undefined && source.opacity !== target.opacity) updates.opacity = source.opacity
if (source.fills !== undefined && source.fills !== target.fills) updates.fills = structuredClone(source.fills)
if (source.strokes !== undefined && source.strokes !== target.strokes) updates.strokes = structuredClone(source.strokes)
if (source.effects !== undefined && source.effects !== target.effects) updates.effects = structuredClone(source.effects)
if (source.styleRuns !== undefined && source.styleRuns !== target.styleRuns) updates.styleRuns = structuredClone(source.styleRuns)
if (source.layoutGrow !== undefined && source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow
if (source.textAutoResize !== undefined && source.textAutoResize !== target.textAutoResize) updates.textAutoResize = source.textAutoResize
if (source.locked !== undefined && source.locked !== target.locked) updates.locked = source.locked
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
}
function syncChildrenDeep(sourceId: string, targetId: string, skip?: Set<string>) {
const src = graph.getNode(sourceId)
const tgt = graph.getNode(targetId)
if (!src || !tgt) return
const len = Math.min(src.childIds.length, tgt.childIds.length)
for (let i = 0; i < len; i++) {
if (skip?.has(tgt.childIds[i])) continue
const srcNode = graph.getNode(src.childIds[i])
const tgtNode = graph.getNode(tgt.childIds[i])
if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue
syncNodeProps(srcNode, tgtNode)
syncChildrenDeep(src.childIds[i], tgt.childIds[i], skip)
}
}
function propagateOverridesTransitively(seeds: Set<string>) {
if (seeds.size === 0) return
@ -418,8 +569,25 @@ export function populateAndApplyOverrides(
arr.push(node.id)
}
// Also seed parent INSTANCE nodes of overridden children so their
// clones are visited by the BFS — deep overrides (e.g., stroke color
// on a Vector nested inside a check inside an _icon-xs) need the
// instance-level clone to be visited for syncChildrenDeep to propagate.
const expandedSeeds = new Set(seeds)
for (const seedId of seeds) {
let cur = graph.getNode(seedId)
while (cur?.parentId) {
const parent = graph.getNode(cur.parentId)
if (!parent) break
if (parent.type === 'INSTANCE' || parent.type === 'COMPONENT') {
expandedSeeds.add(parent.id)
}
cur = parent
}
}
const needsSync = new Set<string>()
const queue = [...seeds]
const queue = [...expandedSeeds]
for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
const clones = clonesOf.get(id)
if (!clones) continue
@ -431,7 +599,7 @@ export function populateAndApplyOverrides(
}
const visited = new Set<string>()
const syncQueue = [...seeds]
const syncQueue = [...expandedSeeds]
for (let sourceId = syncQueue.shift(); sourceId !== undefined; sourceId = syncQueue.shift()) {
const clones = clonesOf.get(sourceId)
if (!clones) continue
@ -450,24 +618,17 @@ export function populateAndApplyOverrides(
continue
}
if (node.type === 'INSTANCE' && source.type === 'INSTANCE' && node.componentId) {
repopulateInstance(node.id, node.componentId)
} else {
// Only propagate explicitly-set properties — undefined values must
// not overwrite values set by other override phases.
const updates: Partial<SceneNode> = {}
if (source.text !== undefined && source.text !== node.text) updates.text = source.text
if (source.visible !== undefined && source.visible !== node.visible) updates.visible = source.visible
if (source.opacity !== undefined && source.opacity !== node.opacity) updates.opacity = source.opacity
if (source.name !== undefined && source.name !== node.name) updates.name = source.name
if (source.fills !== undefined && source.fills !== node.fills) updates.fills = structuredClone(source.fills)
if (source.strokes !== undefined && source.strokes !== node.strokes) updates.strokes = structuredClone(source.strokes)
if (source.effects !== undefined && source.effects !== node.effects) updates.effects = structuredClone(source.effects)
if (source.styleRuns !== undefined && source.styleRuns !== node.styleRuns) updates.styleRuns = structuredClone(source.styleRuns)
if (source.layoutGrow !== undefined && source.layoutGrow !== node.layoutGrow) updates.layoutGrow = source.layoutGrow
if (source.textAutoResize !== undefined && source.textAutoResize !== node.textAutoResize) updates.textAutoResize = source.textAutoResize
if (source.locked !== undefined && source.locked !== node.locked) updates.locked = source.locked
if (Object.keys(updates).length > 0) graph.updateNode(node.id, updates)
syncNodeProps(source, node)
// For structural changes (instance swaps change child count/type),
// re-clone from the SOURCE (not the component) to preserve the
// componentId chain for DSD propagation.
if (source.childIds.length !== node.childIds.length) {
for (const childId of [...node.childIds]) graph.deleteNode(childId)
if (source.childIds.length > 0) {
graph.populateInstanceChildren(node.id, sourceId)
}
} else if (source.childIds.length > 0 && node.childIds.length > 0) {
syncChildrenDeep(sourceId, cloneId, seeds)
}
syncQueue.push(cloneId)
@ -487,5 +648,8 @@ export function populateAndApplyOverrides(
propagateOverridesTransitively(overriddenNodes)
applyComponentProperties()
// DSD resolution runs AFTER overrides so guidPaths can reach children
// of instance-swapped nodes (repopulateInstance replaces children).
applyDerivedSymbolData()
}

View file

@ -772,6 +772,13 @@ export class SceneGraph {
private static readonly OPAQUE_CONTAINER_TYPES = new Set<NodeType>(['COMPONENT', 'INSTANCE'])
private static hasVisibleFillOrStroke(node: SceneNode): boolean {
return (
node.fills.some((f) => f.visible) ||
node.strokes.some((s) => s.visible)
)
}
private hitTestChildren(
px: number,
py: number,
@ -783,6 +790,12 @@ export class SceneGraph {
const parent = this.nodes.get(parentId)
if (!parent) return null
if (parent.clipsContent) {
if (px < offsetX || px > offsetX + parent.width || py < offsetY || py > offsetY + parent.height) {
return null
}
}
// Reverse order = topmost first
for (let i = parent.childIds.length - 1; i >= 0; i--) {
const childId = parent.childIds[i]
@ -792,18 +805,28 @@ export class SceneGraph {
const ax = offsetX + child.x
const ay = offsetY + child.y
// Components/instances: don't recurse unless in deep mode (double-click)
if (SceneGraph.OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) {
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
return child
}
continue
}
// Check children first (deeper hit)
if (CONTAINER_TYPES.has(child.type)) {
// Components/instances: don't recurse unless in deep mode (double-click).
// Still check fills — empty instances are click-through like frames.
if (SceneGraph.OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) {
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
const childHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
if (childHit) return child
if (SceneGraph.hasVisibleFillOrStroke(child)) return child
}
continue
}
const deepHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
if (deepHit) return deepHit
// Groups are always click-through (only children are hittable).
// Frames/sections without visible fills or strokes are also click-through.
if (child.type === 'GROUP') continue
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
if (SceneGraph.hasVisibleFillOrStroke(child)) return child
}
continue
}
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {

View file

@ -190,19 +190,23 @@ export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path
return paths
}
// No regions — draw all segments as open paths
// No regions — draw all segments as open paths, tracking direction
const path = new ck.Path()
const visited = new Set<number>()
const chains = buildChains(segments, vertices.length)
for (const chain of chains) {
if (chain.length === 0) continue
const firstSeg = segments[chain[0]]
path.moveTo(vertices[firstSeg.start].x, vertices[firstSeg.start].y)
// Determine starting vertex by tracing chain direction
let current = findChainStart(chain, segments)
path.moveTo(vertices[current].x, vertices[current].y)
for (const segIdx of chain) {
visited.add(segIdx)
addSegmentToPath(path, segments[segIdx], vertices)
const seg = segments[segIdx]
const forward = seg.start === current
addSegmentDirected(path, seg, vertices, forward)
current = forward ? seg.end : seg.start
}
}
@ -210,7 +214,7 @@ export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path
if (visited.has(i)) continue
const seg = segments[i]
path.moveTo(vertices[seg.start].x, vertices[seg.start].y)
addSegmentToPath(path, seg, vertices)
addSegmentDirected(path, seg, vertices, true)
}
return [path]
@ -224,11 +228,12 @@ function addLoopToPath(
): void {
if (loop.length === 0) return
// Region loops have pre-oriented segments — always draw forward
const firstSeg = segments[loop[0]]
path.moveTo(vertices[firstSeg.start].x, vertices[firstSeg.start].y)
for (const segIdx of loop) {
addSegmentToPath(path, segments[segIdx], vertices)
addSegmentDirected(path, segments[segIdx], vertices, true)
}
const lastSeg = segments[loop[loop.length - 1]]
@ -237,21 +242,39 @@ function addLoopToPath(
}
}
function addSegmentToPath(path: Path, seg: VectorSegment, vertices: VectorVertex[]): void {
const start = vertices[seg.start]
const end = vertices[seg.end]
function addSegmentDirected(
path: Path,
seg: VectorSegment,
vertices: VectorVertex[],
forward: boolean
): void {
const p0 = forward ? vertices[seg.start] : vertices[seg.end]
const p3 = forward ? vertices[seg.end] : vertices[seg.start]
const ts = seg.tangentStart
const te = seg.tangentEnd
const isLine = ts.x === 0 && ts.y === 0 && te.x === 0 && te.y === 0
if (isLine) {
path.lineTo(end.x, end.y)
path.lineTo(p3.x, p3.y)
} else if (forward) {
path.cubicTo(p0.x + ts.x, p0.y + ts.y, p3.x + te.x, p3.y + te.y, p3.x, p3.y)
} else {
// Cubic bezier: control points are tangent offsets from start/end
path.cubicTo(start.x + ts.x, start.y + ts.y, end.x + te.x, end.y + te.y, end.x, end.y)
// Reversed cubic: swap control points
path.cubicTo(p0.x + te.x, p0.y + te.y, p3.x + ts.x, p3.y + ts.y, p3.x, p3.y)
}
}
function findChainStart(chain: number[], segments: VectorSegment[]): number {
if (chain.length < 2) return segments[chain[0]].start
const first = segments[chain[0]]
const second = segments[chain[1]]
// The shared vertex between first and second is the "end" of the first
// segment in this chain — so the start is the other vertex.
if (first.start === second.start || first.start === second.end) return first.end
return first.start
}
function buildChains(segments: VectorSegment[], _vertexCount: number): number[][] {
if (segments.length === 0) return []
@ -348,6 +371,7 @@ export function geometryBlobToPath(
windingRule: WindingRule
): Path {
const path = new ck.Path()
if (!blob || !(blob.buffer instanceof ArrayBuffer)) return path
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
let o = 0

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

@ -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

@ -561,7 +561,7 @@ export function useCanvasInput(
cursorOverride.value = cursor
const hit =
hitTestSectionTitle(cx, cy) ?? hitTestComponentLabel(cx, cy) ?? store.graph.hitTest(cx, cy)
hitTestSectionTitle(cx, cy) ?? hitTestComponentLabel(cx, cy) ?? store.graph.hitTest(cx, cy, store.state.currentPageId)
store.setHoveredNode(hit && !store.state.selectedIds.has(hit.id) ? hit.id : null)
}

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"

View file

@ -1,12 +1,13 @@
import { describe, expect, test, setDefaultTimeout } from 'bun:test'
setDefaultTimeout(30_000)
import { join } from 'path'
import { tmpdir } from 'os'
import { randomUUID } from 'crypto'
import { heavy } from '../helpers/test-utils'
setDefaultTimeout(30_000)
const CLI = join(import.meta.dir, '../../packages/cli/src/index.ts')
const FIXTURE = join(import.meta.dir, '../fixtures/material3.fig')
const FIXTURE = join(import.meta.dir, '../fixtures/gold-preview.fig')
async function run(args: string[], stdin?: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(['bun', CLI, ...args], {
@ -22,7 +23,7 @@ async function run(args: string[], stdin?: string): Promise<{ stdout: string; st
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}
describe('eval CLI', () => {
heavy('eval CLI', () => {
test('returns page name', async () => {
const { stdout, exitCode } = await run(['eval', FIXTURE, '--code', 'return figma.currentPage.name'])
expect(exitCode).toBe(0)

View file

@ -9,6 +9,7 @@ import { initCodec } from '../../packages/core/src/kiwi/codec'
import { SceneGraph } from '../../packages/core/src/scene-graph'
import type { SceneNode, NodeType, Fill } from '../../packages/core/src/scene-graph'
import { heavy } from '../helpers/test-utils'
setDefaultTimeout(60_000)
@ -60,80 +61,55 @@ function countByType(nodes: SceneNode[]): Map<NodeType, number> {
return counts
}
// Parse fixtures once — they're large
let material3: SceneGraph
let nuxtui: SceneGraph
let material3Nodes: SceneNode[]
let nuxtUiNodes: SceneNode[]
let parsed: SceneGraph
let allNodes: SceneNode[]
beforeAll(async () => {
const m3Buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const nuBuf = readFileSync(resolve(FIXTURES, 'nuxtui.fig'))
material3 = await parseFigFile(m3Buf.buffer as ArrayBuffer)
nuxtui = await parseFigFile(nuBuf.buffer as ArrayBuffer)
material3Nodes = collectAllNodes(material3)
nuxtUiNodes = collectAllNodes(nuxtui)
const buf = readFileSync(resolve(FIXTURES, 'gold-preview.fig'))
parsed = await parseFigFile(buf.buffer as ArrayBuffer)
allNodes = collectAllNodes(parsed)
})
describe('parse real .fig files', () => {
test('material3.fig parses without error', () => {
expect(material3).toBeInstanceOf(SceneGraph)
test('parses without error', () => {
expect(parsed).toBeInstanceOf(SceneGraph)
})
test('nuxtui.fig parses without error', () => {
expect(nuxtui).toBeInstanceOf(SceneGraph)
test('has pages', () => {
expect(parsed.getPages().length).toBeGreaterThan(0)
})
test('material3.fig has pages', () => {
expect(material3.getPages().length).toBeGreaterThan(0)
})
test('material3.fig has nodes', () => {
expect(material3Nodes.length).toBeGreaterThan(0)
})
test('nuxtui.fig has pages', () => {
expect(nuxtui.getPages().length).toBeGreaterThan(0)
})
test('nuxtui.fig has nodes', () => {
expect(nuxtUiNodes.length).toBeGreaterThan(0)
test('has nodes', () => {
expect(allNodes.length).toBeGreaterThan(0)
})
})
describe('node type coverage', () => {
test('material3: contains FRAME nodes', () => {
expect(material3Nodes.some((n) => n.type === 'FRAME')).toBe(true)
test('contains FRAME nodes', () => {
expect(allNodes.some((n) => n.type === 'FRAME')).toBe(true)
})
test('material3: contains TEXT nodes with content', () => {
const textNodes = material3Nodes.filter((n) => n.type === 'TEXT')
test('contains TEXT nodes with content', () => {
const textNodes = allNodes.filter((n) => n.type === 'TEXT')
expect(textNodes.length).toBeGreaterThan(0)
expect(textNodes.some((n) => n.text.length > 0)).toBe(true)
})
test('material3: contains COMPONENT nodes', () => {
expect(material3Nodes.some((n) => n.type === 'COMPONENT')).toBe(true)
test('contains INSTANCE nodes referencing components', () => {
const instances = allNodes.filter((n) => n.type === 'INSTANCE')
expect(instances.length).toBeGreaterThan(0)
expect(instances.some((n) => n.componentId)).toBe(true)
})
test('material3: contains INSTANCE nodes', () => {
expect(material3Nodes.some((n) => n.type === 'INSTANCE')).toBe(true)
})
test('material3: no unmapped node types', () => {
const invalid = material3Nodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
test('nuxtui: no unmapped node types', () => {
const invalid = nuxtUiNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
test('no unmapped node types', () => {
const invalid = allNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
})
describe('property integrity', () => {
test('all nodes have finite dimensions', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
expect(Number.isFinite(n.width)).toBe(true)
expect(Number.isFinite(n.height)).toBe(true)
expect(n.width).toBeGreaterThanOrEqual(0)
@ -142,21 +118,21 @@ describe('property integrity', () => {
})
test('all nodes have finite positions', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
expect(Number.isFinite(n.x)).toBe(true)
expect(Number.isFinite(n.y)).toBe(true)
}
})
test('all nodes have valid opacity', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
expect(n.opacity).toBeGreaterThanOrEqual(0)
expect(n.opacity).toBeLessThanOrEqual(1)
}
})
test('TEXT nodes have fontFamily', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
if (n.type === 'TEXT') {
expect(typeof n.fontFamily).toBe('string')
expect(n.fontFamily.length).toBeGreaterThan(0)
@ -165,7 +141,7 @@ describe('property integrity', () => {
})
test('TEXT nodes have valid fontSize', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
if (n.type === 'TEXT') {
expect(n.fontSize).toBeGreaterThan(0)
}
@ -186,7 +162,7 @@ describe('property integrity', () => {
expect(a).toBeLessThanOrEqual(1)
}
}
for (const n of material3Nodes) {
for (const n of allNodes) {
for (const fill of n.fills) {
checkFill(fill, n.name)
}
@ -194,7 +170,7 @@ describe('property integrity', () => {
})
test('effects have valid radius', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
for (const e of n.effects) {
expect(e.radius).toBeGreaterThanOrEqual(0)
}
@ -202,7 +178,7 @@ describe('property integrity', () => {
})
test('layout nodes have valid spacing', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
if (n.layoutMode !== 'NONE') {
expect(Number.isFinite(n.itemSpacing)).toBe(true)
expect(n.paddingTop).toBeGreaterThanOrEqual(0)
@ -213,6 +189,67 @@ describe('property integrity', () => {
}
})
})
heavy('parse heavy .fig files', () => {
let material3: SceneGraph
let nuxtui: SceneGraph
let material3Nodes: SceneNode[]
let nuxtUiNodes: SceneNode[]
beforeAll(async () => {
const m3Buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const nuBuf = readFileSync(resolve(FIXTURES, 'nuxtui.fig'))
material3 = await parseFigFile(m3Buf.buffer as ArrayBuffer)
nuxtui = await parseFigFile(nuBuf.buffer as ArrayBuffer)
material3Nodes = collectAllNodes(material3)
nuxtUiNodes = collectAllNodes(nuxtui)
})
test('material3.fig parses with pages and nodes', () => {
expect(material3).toBeInstanceOf(SceneGraph)
expect(material3.getPages().length).toBeGreaterThan(0)
expect(material3Nodes.length).toBeGreaterThan(0)
})
test('nuxtui.fig parses with pages and nodes', () => {
expect(nuxtui).toBeInstanceOf(SceneGraph)
expect(nuxtui.getPages().length).toBeGreaterThan(0)
expect(nuxtUiNodes.length).toBeGreaterThan(0)
})
test('material3: contains COMPONENT nodes', () => {
expect(material3Nodes.some((n) => n.type === 'COMPONENT')).toBe(true)
})
test('material3: no unmapped node types', () => {
const invalid = material3Nodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
test('nuxtui: no unmapped node types', () => {
const invalid = nuxtUiNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
test('material3: fills have valid colors', () => {
for (const n of material3Nodes) {
for (const fill of n.fills) {
if (fill.type === 'SOLID') {
const { r, g, b, a } = fill.color
expect(r).toBeGreaterThanOrEqual(0)
expect(r).toBeLessThanOrEqual(1)
expect(g).toBeGreaterThanOrEqual(0)
expect(g).toBeLessThanOrEqual(1)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThanOrEqual(1)
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThanOrEqual(1)
}
}
}
})
test('nuxtui: fills have valid colors', () => {
for (const n of nuxtUiNodes) {
for (const fill of n.fills) {
@ -766,4 +803,48 @@ describe('edge cases', () => {
expect(iconChildren).toHaveLength(2)
expect(iconChildren.map((c) => c.name).sort()).toEqual(['PathB1', 'PathB2'])
})
test('DSD propagates through intermediate clones that are also DSD-targeted', async () => {
const graph = await parseFigFile(readFileSync(resolve(__dirname, '../fixtures/gold-preview.fig')).buffer)
const thumb = [...graph.getAllNodes()].find((n) => n.name === 'Preview Thumbnail')
expect(thumb).toBeDefined()
let overflows = 0
function walk(id: string) {
const node = graph.getNode(id)
if (!node) return
if (node.type === 'VECTOR') {
const parent = graph.getNode(node.parentId)
if (parent?.type === 'INSTANCE' && parent.width > 0 && parent.height > 0) {
// Check visibility
let vis = true
let cur: typeof node | null = node
while (cur) {
if (!cur.visible) {
vis = false
break
}
cur = cur.parentId ? graph.getNode(cur.parentId) ?? null : null
}
// Check clipping
let clipped = false
cur = graph.getNode(parent.parentId)
while (cur) {
if (cur.clipsContent) {
clipped = true
break
}
cur = cur.parentId ? graph.getNode(cur.parentId) ?? null : null
}
if (vis && !clipped && node.width > parent.width * 1.2) {
overflows++
}
}
}
for (const cid of node.childIds) walk(cid)
}
walk(thumb?.id ?? '')
expect(overflows).toBe(0)
})
})

View file

@ -138,7 +138,7 @@ describe('MCP server', () => {
})
test('open_file loads a .fig file', async () => {
const fixturePath = join(import.meta.dir, '..', 'fixtures', 'nuxtui.fig')
const fixturePath = join(import.meta.dir, '..', 'fixtures', 'gold-preview.fig')
const result = await client.callTool({ name: 'open_file', arguments: { path: fixturePath } })
const data = parseResult(result) as { pages: { name: string }[]; currentPage: string }
expect(data.pages.length).toBeGreaterThan(0)

View file

@ -218,13 +218,12 @@ describe('MCP tool execution', () => {
})
test('open and query .fig file', async () => {
const data = await Bun.file('tests/fixtures/nuxtui.fig').arrayBuffer()
const data = await Bun.file('tests/fixtures/gold-preview.fig').arrayBuffer()
const { api } = await setupWithFile(data)
const pages = findTool('list_pages').execute(api, {}) as { pages: { name: string }[] }
expect(pages.pages.length).toBeGreaterThan(1)
expect(pages.pages.length).toBeGreaterThanOrEqual(1)
findTool('switch_page').execute(api, { page: 'FOUNDATIONS' })
const found = findTool('find_nodes').execute(api, { name: 'Button', type: 'COMPONENT' }) as { count: number }
const found = findTool('find_nodes').execute(api, { type: 'INSTANCE' }) as { count: number }
expect(found.count).toBeGreaterThan(0)
})
})

View file

@ -326,3 +326,80 @@ describe('Variables', () => {
expect(graph.resolveColorVariable('v1')).toBeUndefined()
})
})
describe('hitTest', () => {
test('hits a rectangle', () => {
const graph = new SceneGraph()
const id = rect(graph, 'R', 10, 10, 50, 50)
expect(graph.hitTest(35, 35, pageId(graph))?.id).toBe(id)
})
test('misses empty space', () => {
const graph = new SceneGraph()
rect(graph, 'R', 10, 10, 50, 50)
expect(graph.hitTest(200, 200, pageId(graph))).toBeNull()
})
test('frame without fills is click-through', () => {
const graph = new SceneGraph()
graph.createNode('FRAME', pageId(graph), {
name: 'Empty Frame',
x: 0, y: 0, width: 200, height: 200, fills: [],
})
expect(graph.hitTest(100, 100, pageId(graph))).toBeNull()
})
test('frame with visible fill is hittable', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Filled Frame',
x: 0, y: 0, width: 200, height: 200,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
})
expect(graph.hitTest(100, 100, pageId(graph))?.id).toBe(frame.id)
})
test('group is always click-through', () => {
const graph = new SceneGraph()
const groupId = graph.createNode('GROUP', pageId(graph), {
name: 'Group', x: 0, y: 0, width: 200, height: 200,
}).id
const childId = graph.createNode('RECTANGLE', groupId, {
name: 'Child', x: 10, y: 10, width: 30, height: 30,
}).id
// Hit child through group
expect(graph.hitTest(20, 20, pageId(graph))?.id).toBe(childId)
// Miss in group's empty area
expect(graph.hitTest(150, 150, pageId(graph))).toBeNull()
})
test('clipsContent prevents hits outside parent bounds', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Clip Frame', x: 0, y: 0, width: 100, height: 100,
clipsContent: true, fills: [],
})
const childId = graph.createNode('RECTANGLE', frame.id, {
name: 'Overflow Child', x: 50, y: 50, width: 200, height: 200,
}).id
// Inside both frame and child — hit
expect(graph.hitTest(75, 75, pageId(graph))?.id).toBe(childId)
// Inside child but outside clipping frame — miss
expect(graph.hitTest(150, 150, pageId(graph))).toBeNull()
})
test('instance without fills is click-through in empty area', () => {
const graph = new SceneGraph()
const compId = graph.createNode('COMPONENT', pageId(graph), {
name: 'Comp', x: 0, y: 0, width: 200, height: 200, fills: [],
}).id
graph.createNode('RECTANGLE', compId, {
name: 'Inner', x: 10, y: 10, width: 30, height: 30,
})
const instId = graph.createInstance(compId, pageId(graph), { x: 300, y: 0 }).id
// Hit on instance's child area — returns instance (opaque container)
expect(graph.hitTest(320, 20, pageId(graph))?.id).toBe(instId)
// Miss on instance's empty area (no fills)
expect(graph.hitTest(450, 150, pageId(graph))).toBeNull()
})
})

View file

@ -1,10 +1,11 @@
import { describe, expect, test, setDefaultTimeout } from 'bun:test'
import { join } from 'path'
import { heavy } from '../helpers/test-utils'
setDefaultTimeout(30_000)
import { join } from 'path'
const CLI = join(import.meta.dir, '../../packages/cli/src/index.ts')
const FIXTURE = join(import.meta.dir, '../fixtures/material3.fig')
const FIXTURE = join(import.meta.dir, '../fixtures/gold-preview.fig')
async function evalCode(
code: string
@ -25,7 +26,7 @@ function parseJSON(stdout: string): unknown {
return JSON.parse(stdout)
}
describe('CLI tool operations via eval', () => {
heavy('CLI tool operations via eval', () => {
test('create and read back a node', async () => {
const { stdout, exitCode } = await evalCode(`
const r = figma.createRectangle()

3
tests/fixtures/gold-preview.fig vendored Normal file
View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8e54efe5f1e11a1bf3cce669a4de8e3fa42047f4325472da2d8cd3b152c2da33
size 550091

View file

@ -0,0 +1,3 @@
import { describe } from 'bun:test'
export const heavy = describe.if(!!process.env.BUN_HEAVY_TESTS)