Merge remote-tracking branch 'origin/master' into reka-color-picker
# Conflicts: # src/components/HsvColorArea.vue
This commit is contained in:
commit
29096fa56b
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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` }"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }}%
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(', ')
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
3
tests/fixtures/gold-preview.fig
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8e54efe5f1e11a1bf3cce669a4de8e3fa42047f4325472da2d8cd3b152c2da33
|
||||
size 550091
|
||||
3
tests/helpers/test-utils.ts
Normal file
3
tests/helpers/test-utils.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { describe } from 'bun:test'
|
||||
|
||||
export const heavy = describe.if(!!process.env.BUN_HEAVY_TESTS)
|
||||
Loading…
Reference in a new issue