Fix hit-testing: scope hover to current page, click-through empty containers

- Scope hover hit-test to current page (was searching all pages including
  internal component page, causing ghost hover outlines)
- Frames/sections without visible fills or strokes are click-through
- Groups are always click-through (only children are hittable)
- Clipping parents reject hits outside their bounds
- Instances/components check children before falling back to fill check
- Switch test fixtures from material3.fig (55MB) / nuxtui.fig (82MB) to
  gold-preview.fig (537KB) for fast dev runs, gate heavy fixtures behind
  BUN_HEAVY_TESTS env var (enabled in CI)
This commit is contained in:
Danila Poyarkov 2026-03-05 08:56:35 +03:00
parent 69c8096c44
commit 38e7fdd4ac
11 changed files with 221 additions and 76 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import { initCodec } from '../../packages/core/src/kiwi/codec'
import { SceneGraph } from '../../packages/core/src/scene-graph'
import type { SceneNode, NodeType, Fill } from '../../packages/core/src/scene-graph'
import { heavy } from '../helpers/test-utils'
setDefaultTimeout(60_000)
@ -60,80 +61,55 @@ function countByType(nodes: SceneNode[]): Map<NodeType, number> {
return counts
}
// Parse fixtures once — they're large
let material3: SceneGraph
let nuxtui: SceneGraph
let material3Nodes: SceneNode[]
let nuxtUiNodes: SceneNode[]
let parsed: SceneGraph
let allNodes: SceneNode[]
beforeAll(async () => {
const m3Buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const nuBuf = readFileSync(resolve(FIXTURES, 'nuxtui.fig'))
material3 = await parseFigFile(m3Buf.buffer as ArrayBuffer)
nuxtui = await parseFigFile(nuBuf.buffer as ArrayBuffer)
material3Nodes = collectAllNodes(material3)
nuxtUiNodes = collectAllNodes(nuxtui)
const buf = readFileSync(resolve(FIXTURES, 'gold-preview.fig'))
parsed = await parseFigFile(buf.buffer as ArrayBuffer)
allNodes = collectAllNodes(parsed)
})
describe('parse real .fig files', () => {
test('material3.fig parses without error', () => {
expect(material3).toBeInstanceOf(SceneGraph)
test('parses without error', () => {
expect(parsed).toBeInstanceOf(SceneGraph)
})
test('nuxtui.fig parses without error', () => {
expect(nuxtui).toBeInstanceOf(SceneGraph)
test('has pages', () => {
expect(parsed.getPages().length).toBeGreaterThan(0)
})
test('material3.fig has pages', () => {
expect(material3.getPages().length).toBeGreaterThan(0)
})
test('material3.fig has nodes', () => {
expect(material3Nodes.length).toBeGreaterThan(0)
})
test('nuxtui.fig has pages', () => {
expect(nuxtui.getPages().length).toBeGreaterThan(0)
})
test('nuxtui.fig has nodes', () => {
expect(nuxtUiNodes.length).toBeGreaterThan(0)
test('has nodes', () => {
expect(allNodes.length).toBeGreaterThan(0)
})
})
describe('node type coverage', () => {
test('material3: contains FRAME nodes', () => {
expect(material3Nodes.some((n) => n.type === 'FRAME')).toBe(true)
test('contains FRAME nodes', () => {
expect(allNodes.some((n) => n.type === 'FRAME')).toBe(true)
})
test('material3: contains TEXT nodes with content', () => {
const textNodes = material3Nodes.filter((n) => n.type === 'TEXT')
test('contains TEXT nodes with content', () => {
const textNodes = allNodes.filter((n) => n.type === 'TEXT')
expect(textNodes.length).toBeGreaterThan(0)
expect(textNodes.some((n) => n.text.length > 0)).toBe(true)
})
test('material3: contains COMPONENT nodes', () => {
expect(material3Nodes.some((n) => n.type === 'COMPONENT')).toBe(true)
test('contains INSTANCE nodes referencing components', () => {
const instances = allNodes.filter((n) => n.type === 'INSTANCE')
expect(instances.length).toBeGreaterThan(0)
expect(instances.some((n) => n.componentId)).toBe(true)
})
test('material3: contains INSTANCE nodes', () => {
expect(material3Nodes.some((n) => n.type === 'INSTANCE')).toBe(true)
})
test('material3: no unmapped node types', () => {
const invalid = material3Nodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
test('nuxtui: no unmapped node types', () => {
const invalid = nuxtUiNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
test('no unmapped node types', () => {
const invalid = allNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
})
describe('property integrity', () => {
test('all nodes have finite dimensions', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
expect(Number.isFinite(n.width)).toBe(true)
expect(Number.isFinite(n.height)).toBe(true)
expect(n.width).toBeGreaterThanOrEqual(0)
@ -142,21 +118,21 @@ describe('property integrity', () => {
})
test('all nodes have finite positions', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
expect(Number.isFinite(n.x)).toBe(true)
expect(Number.isFinite(n.y)).toBe(true)
}
})
test('all nodes have valid opacity', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
expect(n.opacity).toBeGreaterThanOrEqual(0)
expect(n.opacity).toBeLessThanOrEqual(1)
}
})
test('TEXT nodes have fontFamily', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
if (n.type === 'TEXT') {
expect(typeof n.fontFamily).toBe('string')
expect(n.fontFamily.length).toBeGreaterThan(0)
@ -165,7 +141,7 @@ describe('property integrity', () => {
})
test('TEXT nodes have valid fontSize', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
if (n.type === 'TEXT') {
expect(n.fontSize).toBeGreaterThan(0)
}
@ -186,7 +162,7 @@ describe('property integrity', () => {
expect(a).toBeLessThanOrEqual(1)
}
}
for (const n of material3Nodes) {
for (const n of allNodes) {
for (const fill of n.fills) {
checkFill(fill, n.name)
}
@ -194,7 +170,7 @@ describe('property integrity', () => {
})
test('effects have valid radius', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
for (const e of n.effects) {
expect(e.radius).toBeGreaterThanOrEqual(0)
}
@ -202,7 +178,7 @@ describe('property integrity', () => {
})
test('layout nodes have valid spacing', () => {
for (const n of material3Nodes) {
for (const n of allNodes) {
if (n.layoutMode !== 'NONE') {
expect(Number.isFinite(n.itemSpacing)).toBe(true)
expect(n.paddingTop).toBeGreaterThanOrEqual(0)
@ -213,6 +189,67 @@ describe('property integrity', () => {
}
})
})
heavy('parse heavy .fig files', () => {
let material3: SceneGraph
let nuxtui: SceneGraph
let material3Nodes: SceneNode[]
let nuxtUiNodes: SceneNode[]
beforeAll(async () => {
const m3Buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const nuBuf = readFileSync(resolve(FIXTURES, 'nuxtui.fig'))
material3 = await parseFigFile(m3Buf.buffer as ArrayBuffer)
nuxtui = await parseFigFile(nuBuf.buffer as ArrayBuffer)
material3Nodes = collectAllNodes(material3)
nuxtUiNodes = collectAllNodes(nuxtui)
})
test('material3.fig parses with pages and nodes', () => {
expect(material3).toBeInstanceOf(SceneGraph)
expect(material3.getPages().length).toBeGreaterThan(0)
expect(material3Nodes.length).toBeGreaterThan(0)
})
test('nuxtui.fig parses with pages and nodes', () => {
expect(nuxtui).toBeInstanceOf(SceneGraph)
expect(nuxtui.getPages().length).toBeGreaterThan(0)
expect(nuxtUiNodes.length).toBeGreaterThan(0)
})
test('material3: contains COMPONENT nodes', () => {
expect(material3Nodes.some((n) => n.type === 'COMPONENT')).toBe(true)
})
test('material3: no unmapped node types', () => {
const invalid = material3Nodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
test('nuxtui: no unmapped node types', () => {
const invalid = nuxtUiNodes.filter((n) => !VALID_NODE_TYPES.has(n.type))
expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([])
})
test('material3: fills have valid colors', () => {
for (const n of material3Nodes) {
for (const fill of n.fills) {
if (fill.type === 'SOLID') {
const { r, g, b, a } = fill.color
expect(r).toBeGreaterThanOrEqual(0)
expect(r).toBeLessThanOrEqual(1)
expect(g).toBeGreaterThanOrEqual(0)
expect(g).toBeLessThanOrEqual(1)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThanOrEqual(1)
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThanOrEqual(1)
}
}
}
})
test('nuxtui: fills have valid colors', () => {
for (const n of nuxtUiNodes) {
for (const fill of n.fills) {

View file

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

View file

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

View file

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

View file

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

View file

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