Add heuristic overlap detection tool and CLI

Implement the analyze overlaps CLI command, RPC endpoint, and ToolDef
to enable heuristic detection of visual overlaps, parent overflows, and
overlay patterns. Geometry computation now uses world-matrix visual
bounds to handle nested ancestor rotations and clipping frames correctly.

Update file-backed CLI commands to use Node fs/promises instead of Bun
runtime file APIs. This is required for the published CLI to function
when installed and executed in a standard Node environment. Add the
package.json subpath export to the core package for metadata consumers.
This commit is contained in:
Joseph Cumines 2026-06-19 09:54:27 +10:00
parent 4bc1698e93
commit f862beb08d
26 changed files with 2334 additions and 9 deletions

View file

@ -7,6 +7,10 @@
- Add JSX authoring support for components, component sets, and instances.
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
- Add `unbind_variable` MCP tool for removing variable bindings.
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.
- Add overlap analysis exports for automation consumers, including `computeOverlaps`, `analyzeOverlaps`, overlap result types, and parameter parsers from core subpath exports.
- Add world-matrix visual bounds to overlap analysis, covering vector/stroke/text geometry, ancestor clipping, rotated clipping frames, and nested ancestor rotations.
- Add the `@open-pencil/core/package.json` subpath export for package metadata consumers.
### Fixes
@ -18,6 +22,7 @@
- Improve Figma boolean imports by preserving XOR operations as editable exclude nodes and falling back to imported fill geometry when boolean path reconstruction cannot produce a path.
- Preserve rotated Figma transform origins for imported vector nodes.
- Render complex text fills through vector glyph outlines so imported Figma text can use the normal fill pipeline for gradients, images, patterns, and other non-solid paints.
- Fix file-backed CLI commands (`convert`, `eval --output`, `export`) to use Node `fs/promises` instead of Bun runtime APIs, so the published CLI works when installed and run under Node.
## 0.13.2 — 2026-05-30

View file

@ -111,6 +111,7 @@ openpencil analyze colors design.fig
openpencil analyze typography design.fig
openpencil analyze spacing design.fig
openpencil analyze clusters design.fig
openpencil analyze overlaps design.fig
openpencil variables design.fig
```

View file

@ -2,6 +2,7 @@ import { defineCommand } from 'citty'
import clusters from './clusters'
import colors from './colors'
import overlaps from './overlaps'
import spacing from './spacing'
import typography from './typography'
@ -11,6 +12,7 @@ export default defineCommand({
colors,
typography,
spacing,
clusters
clusters,
overlaps
}
})

View file

@ -0,0 +1,264 @@
import { defineCommand } from 'citty'
import type { AnalyzeOverlapsResult } from '@open-pencil/core/rpc'
import {
VALID_OVERLAP_CATEGORIES,
VALID_OVERLAP_SCOPES,
VALID_OVERLAP_SEVERITIES,
parseOverlapCategories,
parseOverlapScope,
parseOverlapSeverity
} from '@open-pencil/core/tools'
import { bold, fail, fmtList, fmtSummary, kv } from '#cli/format'
import { loadRpcData } from '#cli/rpc-data'
function validateScope(scope: string): string | undefined {
const normalized = scope.toLowerCase()
return (VALID_OVERLAP_SCOPES as readonly string[]).includes(normalized)
? undefined
: `Invalid scope "${scope}". Must be one of: ${[...VALID_OVERLAP_SCOPES].join(', ')}`
}
function validateSeverity(severity: string): string | undefined {
const normalized = severity.toLowerCase()
return (VALID_OVERLAP_SEVERITIES as readonly string[]).includes(normalized)
? undefined
: `Invalid severity "${severity}". Must be one of: ${[...VALID_OVERLAP_SEVERITIES].join(', ')}`
}
function validateMinRatio(minRatio: number | undefined): string | undefined {
if (minRatio === undefined) return undefined
if (Number.isNaN(minRatio) || minRatio < 0 || minRatio > 1) {
return '--min-ratio must be a number between 0.0 and 1.0'
}
return undefined
}
function validateLimit(limit: string): string | undefined {
const value = Number(limit)
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
return '--limit must be a positive integer'
}
return undefined
}
function validateMinArea(minArea: string): string | undefined {
const value = Number(minArea)
if (!Number.isFinite(value) || value < 0) {
return '--min-area must be a non-negative number'
}
return undefined
}
function validateCategory(category: string): string | undefined {
const values = category
.split(',')
.map((c) => c.trim().toLowerCase())
.filter((c) => c.length > 0)
if (values.length === 0) return undefined
const validCount = (parseOverlapCategories(category) ?? []).length
if (validCount === 0) {
return `Invalid categories: ${values.join(', ')}. Must be one or more of: ${[...VALID_OVERLAP_CATEGORIES].join(', ')}`
}
const invalid = values.filter((c) => !(VALID_OVERLAP_CATEGORIES as readonly string[]).includes(c))
if (invalid.length > 0) {
return `Invalid categor${invalid.length === 1 ? 'y' : 'ies'}: ${invalid.join(', ')}. Must be one or more of: ${[...VALID_OVERLAP_CATEGORIES].join(', ')}`
}
return undefined
}
function collectValidationError(args: {
scope: string
severity: string
category?: string
limit: string
'min-area'?: string
'min-ratio'?: string
}): string | undefined {
return (
validateScope(args.scope) ??
validateSeverity(args.severity) ??
validateLimit(args.limit) ??
(args.category ? validateCategory(args.category) : undefined) ??
(args['min-area'] !== undefined ? validateMinArea(args['min-area']) : undefined) ??
validateMinRatio(args['min-ratio'] ? Number(args['min-ratio']) : undefined)
)
}
function buildRpcArgs(args: {
scope: string
severity: string
category?: string
limit: string
'min-area'?: string
'min-ratio'?: string
'include-hidden'?: boolean
'include-locked'?: boolean
'include-absolute'?: boolean
page?: string
'page-id'?: string
type?: string
}): { rpcArgs: Record<string, unknown>; error?: string } {
const error = collectValidationError(args)
if (error) return { rpcArgs: {}, error }
const minAreaRaw = args['min-area']
const minArea = minAreaRaw ? Number(minAreaRaw) : undefined
const minRatio = args['min-ratio'] ? Number(args['min-ratio']) : undefined
const rpcArgs: Record<string, unknown> = {
scope: parseOverlapScope(args.scope) ?? 'all',
severity: parseOverlapSeverity(args.severity) ?? 'info',
limit: Number(args.limit)
}
if (args.category) rpcArgs.category = args.category
if (minArea !== undefined) rpcArgs.min_area = minArea
if (minRatio !== undefined) rpcArgs.min_ratio = minRatio
if (args['include-hidden']) rpcArgs.include_hidden = true
if (args['include-locked']) rpcArgs.include_locked = true
if (args['include-absolute']) rpcArgs.include_absolute = true
if (args.page) rpcArgs.page = args.page
if (args['page-id']) rpcArgs.page_id = args['page-id']
if (args.type) rpcArgs.type = args.type
return { rpcArgs }
}
export default defineCommand({
meta: { description: 'Detect visual overlaps and layout overflows' },
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
required: false
},
scope: {
type: 'string',
description:
'Which pairs to inspect: all, same-parent, cross-parent, top-level, inside-parent',
default: 'all'
},
category: {
type: 'string',
description: 'Comma-separated categories: sibling-overlap, parent-overflow, overlay'
},
severity: {
type: 'string',
description: 'Minimum severity to include: critical, major, minor, info',
default: 'info'
},
'min-area': {
type: 'string',
description: 'Minimum overlap area in square pixels'
},
'min-ratio': {
type: 'string',
description: 'Minimum overlap ratio relative to the smaller node, 0.0–1.0'
},
'include-hidden': {
type: 'boolean',
description: 'Include hidden nodes in the analysis'
},
'include-locked': {
type: 'boolean',
description: 'Include locked nodes in the analysis'
},
'include-absolute': {
type: 'boolean',
description: 'Include absolutely-positioned nodes in the analysis'
},
page: {
type: 'string',
description: 'Limit analysis to nodes on the named page'
},
'page-id': {
type: 'string',
description: 'Limit analysis to nodes on the page with this stable ID'
},
type: {
type: 'string',
description: 'Comma-separated node types to analyze, e.g. FRAME,TEXT'
},
limit: {
type: 'string',
description: 'Maximum overlap findings to show',
default: '100'
},
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const { rpcArgs, error } = buildRpcArgs(args)
if (error) {
console.error(fail(error))
process.exit(1)
}
const data = await loadRpcData<AnalyzeOverlapsResult>(args.file, 'analyze_overlaps', rpcArgs)
if (args.json) {
console.log(JSON.stringify(data, null, 2))
return
}
if (data.summary.overlapCount === 0) {
console.log(kv('status', 'No overlaps found'))
console.log(fmtSummary({ 'analyzed nodes': data.summary.analyzedNodes }))
return
}
const showing = data.overlaps.length
const total = data.summary.overlapCount
const header =
showing === total
? ` Overlaps — ${total} found`
: ` Overlaps — ${total} found (${showing} shown)`
console.log('')
console.log(bold(header))
console.log('')
console.log(
fmtList(
data.overlaps.map((overlap) => ({
header: `[${overlap.severity}] ${overlap.category}`,
details: {
message: overlap.message,
area: `${overlap.area}px`,
ratio: `${(overlap.ratio * 100).toFixed(1)}%`,
a: `${overlap.nodeA.type} "${overlap.nodeA.name}" (${overlap.nodeA.id})`,
b: `${overlap.nodeB.type} "${overlap.nodeB.name}" (${overlap.nodeB.id})`,
suggestion: overlap.suggestion
}
}))
)
)
console.log('')
console.log(
fmtSummary({
'analyzed nodes': data.summary.analyzedNodes,
'total nodes': data.summary.totalNodes
})
)
console.log(
kv(
'by category',
Object.entries(data.summary.byCategory)
.filter(([, count]) => count > 0)
.map(([k, count]) => `${k}: ${count}`)
.join(', ') || 'none'
)
)
console.log(
kv(
'by severity',
Object.entries(data.summary.bySeverity)
.filter(([, count]) => count > 0)
.map(([k, count]) => `${k}: ${count}`)
.join(', ') || 'none'
)
)
console.log('')
}
})

View file

@ -1,3 +1,4 @@
import { writeFile } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
import { defineCommand } from 'citty'
@ -52,7 +53,7 @@ export default defineCommand({
const graph = await loadDocument(file)
const result = await io.writeDocument(format, graph)
const output = args.output ? resolve(args.output) : defaultOutput(file, format)
await Bun.write(output, result.data as Uint8Array)
await writeFile(output, result.data as Uint8Array)
console.log(ok(`Converted ${file} → ${output}`))
}
})

View file

@ -1,3 +1,5 @@
import { writeFile } from 'node:fs/promises'
import { defineCommand } from 'citty'
import { FigmaAPI } from '@open-pencil/core/figma-api'
@ -96,7 +98,7 @@ export default defineCommand({
const io = new IORegistry(BUILTIN_IO_FORMATS)
const outPath = args.output ? args.output : file
const result = await io.writeDocument('fig', graph)
await Bun.write(outPath, result.data as Uint8Array)
await writeFile(outPath, result.data as Uint8Array)
if (!args.quiet) {
console.error(`Written to ${outPath}`)
}

View file

@ -1,3 +1,4 @@
import { writeFile } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
import { defineCommand } from 'citty'
@ -29,7 +30,7 @@ interface ExportArgs {
}
async function writeAndLog(path: string, content: string | Uint8Array) {
await Bun.write(path, content)
await writeFile(path, content)
const size = typeof content === 'string' ? content.length : content.length
console.log(ok(`Exported ${path} (${(size / 1024).toFixed(1)} KB)`))
}

View file

@ -1,3 +1,5 @@
import { readFile } from 'node:fs/promises'
import { BUILTIN_IO_FORMATS, IORegistry, initCanvasKit } from '@open-pencil/core/io'
import { computeAllLayouts } from '@open-pencil/core/layout'
import type { SceneGraph } from '@open-pencil/core/scene-graph'
@ -7,7 +9,7 @@ export { initCanvasKit }
const io = new IORegistry(BUILTIN_IO_FORMATS)
export async function loadDocument(filePath: string): Promise<SceneGraph> {
const bytes = new Uint8Array(await Bun.file(filePath).arrayBuffer())
const bytes = new Uint8Array(await readFile(filePath))
const { graph } = await io.readDocument({ name: filePath, data: bytes })
computeAllLayouts(graph)
return graph

View file

@ -8,6 +8,7 @@
},
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js",

View file

@ -137,7 +137,7 @@ export function polygonVertices(node: {
})
}
function strokeOverflow(strokes?: Stroke[]): number {
export function strokeOverflow(strokes?: Stroke[]): number {
let overflow = 0
for (const stroke of strokes ?? []) {
if (!stroke.visible) continue
@ -149,7 +149,7 @@ function strokeOverflow(strokes?: Stroke[]): number {
return overflow
}
function effectOverflow(effects?: Effect[]) {
export function effectOverflow(effects?: Effect[]) {
let left = 0
let right = 0
let top = 0
@ -398,3 +398,98 @@ export function computeDescendantVisualBounds(
}
return bounds
}
// ── Rotated-rectangle clipping ──
function crossProduct(a: Vector, b: Vector, p: Vector): number {
return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
}
function lineSegmentIntersect(p1: Vector, p2: Vector, p3: Vector, p4: Vector): Vector {
const dx1 = p2.x - p1.x
const dy1 = p2.y - p1.y
const dx2 = p4.x - p3.x
const dy2 = p4.y - p3.y
const denom = dx1 * dy2 - dy1 * dx2
if (denom === 0) return p1
const t = ((p3.x - p1.x) * dy2 - (p3.y - p1.y) * dx2) / denom
return { x: p1.x + t * dx1, y: p1.y + t * dy1 }
}
function clipHalfPlane(polygon: Vector[], a: Vector, b: Vector, wantPositive: boolean): Vector[] {
const output: Vector[] = []
const isInside = (p: Vector): boolean => {
const cross = crossProduct(a, b, p)
return wantPositive ? cross >= 0 : cross <= 0
}
for (let i = 0; i < polygon.length; i++) {
const curr = polygon[i]
const prev = polygon[i === 0 ? polygon.length - 1 : i - 1]
const currInside = isInside(curr)
const prevInside = isInside(prev)
if (currInside) {
if (!prevInside) output.push(lineSegmentIntersect(prev, curr, a, b))
output.push(curr)
} else if (prevInside) {
output.push(lineSegmentIntersect(prev, curr, a, b))
}
}
return output
}
/**
* Clip an axis-aligned VisualBounds rectangle against a convex polygon
* (e.g. the 4 canvas-space corners of a rotated clipping ancestor).
*
* Uses Sutherland–Hodgman polygon clipping with centroid-based interior
* detection, making it robust to either winding order of the clip polygon.
* Returns the AABB of the intersection, or null if the bounds are fully
* outside the clip polygon.
*
* For a non-rotated clip (axis-aligned corners) the result is identical
* to `intersectVisualBounds`.
*/
export function clipBoundsToPolygon(
bounds: VisualBounds,
clipCorners: Vector[]
): VisualBounds | null {
if (clipCorners.length < 3) return bounds
let cx = 0
let cy = 0
for (const c of clipCorners) {
cx += c.x
cy += c.y
}
cx /= clipCorners.length
cy /= clipCorners.length
let subject: Vector[] = [
{ x: bounds.minX, y: bounds.minY },
{ x: bounds.maxX, y: bounds.minY },
{ x: bounds.maxX, y: bounds.maxY },
{ x: bounds.minX, y: bounds.maxY }
]
for (let i = 0; i < clipCorners.length; i++) {
if (subject.length === 0) return null
const a = clipCorners[i]
const b = clipCorners[(i + 1) % clipCorners.length]
const centroidCross = crossProduct(a, b, { x: cx, y: cy })
subject = clipHalfPlane(subject, a, b, centroidCross >= 0)
}
if (subject.length === 0) return null
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const p of subject) {
if (p.x < minX) minX = p.x
if (p.y < minY) minY = p.y
if (p.x > maxX) maxX = p.x
if (p.y > maxY) maxY = p.y
}
return minX < maxX && minY < maxY ? { minX, minY, maxX, maxY } : null
}

View file

@ -3,10 +3,17 @@ import { orderBy, sortBy } from 'es-toolkit/array'
import { colorToHex, colorDistance as colorDist } from '#core/color'
import type { ColorUsageEntry } from '#core/color/analysis'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import {
computeOverlaps,
type AnalyzeOverlapsArgs,
type AnalyzeOverlapsResult
} from '#core/tools/analyze/overlaps'
import type { Color } from '#core/types'
import type { RpcCommand } from './types'
export type { AnalyzeOverlapsArgs, AnalyzeOverlapsResult } from '#core/tools/analyze/overlaps'
// ── analyze colors ──
export interface AnalyzeColorsArgs {
@ -297,3 +304,10 @@ export const analyzeClustersCommand: RpcCommand<AnalyzeClustersArgs, AnalyzeClus
return { clusters, totalNodes }
}
}
// ── analyze overlaps ──
export const analyzeOverlapsCommand: RpcCommand<AnalyzeOverlapsArgs, AnalyzeOverlapsResult> = {
name: 'analyze_overlaps',
execute: (graph, args) => computeOverlaps(graph, args)
}

View file

@ -3,6 +3,7 @@ import type { SceneGraph } from '#core/scene-graph'
import {
analyzeClustersCommand,
analyzeColorsCommand,
analyzeOverlapsCommand,
analyzeSpacingCommand,
analyzeTypographyCommand
} from './analyze-commands'
@ -33,7 +34,8 @@ export const ALL_RPC_COMMANDS = [
analyzeColorsCommand,
analyzeTypographyCommand,
analyzeSpacingCommand,
analyzeClustersCommand
analyzeClustersCommand,
analyzeOverlapsCommand
] as RpcCommand[]
export function executeRpcCommand(graph: SceneGraph, name: string, args: unknown): unknown {

View file

@ -21,5 +21,7 @@ export type {
SpacingValue,
AnalyzeClustersArgs,
AnalyzeClustersResult,
AnalyzeOverlapsArgs,
AnalyzeOverlapsResult,
TypographyStyle
} from './commands'

View file

@ -3,5 +3,14 @@ export { analyzeColors } from './analyze/colors'
export { diffCreate, diffShow } from './analyze/diff'
export { evalCode } from './analyze/eval'
export { wrapEvalCode } from './analyze/eval/wrap'
export { analyzeOverlaps, computeOverlaps } from './analyze/overlaps'
export type {
AnalyzeOverlapsArgs,
AnalyzeOverlapsResult,
OverlapCategory,
OverlapItem,
OverlapNodeSummary,
OverlapSeverity
} from './analyze/overlaps'
export { analyzeSpacing } from './analyze/spacing'
export { analyzeTypography } from './analyze/typography'

View file

@ -0,0 +1,551 @@
import { getWorldMatrix } from '#core/canvas/coordinate'
import Matrix from '#core/canvas/matrix'
import {
clipBoundsToPolygon,
effectOverflow,
geometryBlobBounds,
intersectVisualBounds,
strokeOverflow,
unionVisualBounds,
type VisualBounds
} from '#core/geometry'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Rect, Vector } from '#core/types'
import type {
AnalyzeOverlapsArgs,
OverlapCategory,
OverlapItem,
OverlapNodeSummary,
OverlapScope,
OverlapSeverity
} from './index'
const SEVERITY_RANK: Record<OverlapSeverity, number> = {
critical: 4,
major: 3,
minor: 2,
info: 1
}
export function parseNodeTypes(raw: string | undefined): Set<string> | undefined {
if (!raw) return undefined
const types = raw
.split(',')
.map((v) => v.trim().toUpperCase())
.filter((v) => v.length > 0)
return types.length > 0 ? new Set(types) : undefined
}
export function visualBoundsArea(bounds: VisualBounds): number {
const width = bounds.maxX - bounds.minX
const height = bounds.maxY - bounds.minY
return width > 0 && height > 0 ? width * height : 0
}
export function boundsToRect(bounds: VisualBounds): Rect {
return {
x: bounds.minX,
y: bounds.minY,
width: bounds.maxX - bounds.minX,
height: bounds.maxY - bounds.minY
}
}
export function toNodeSummary(node: SceneNode): OverlapNodeSummary {
return {
id: node.id,
name: node.name,
type: node.type,
parentId: node.parentId,
x: Math.round(node.x),
y: Math.round(node.y),
width: Math.round(node.width),
height: Math.round(node.height),
rotation: Math.round(node.rotation),
opacity: node.opacity,
visible: node.visible,
locked: node.locked
}
}
export function isEffectivelyHidden(graph: SceneGraph, node: SceneNode): boolean {
let current: SceneNode | undefined = node
while (current) {
if (!current.visible) return true
current = current.parentId ? graph.getNode(current.parentId) : undefined
}
return false
}
export function isEffectivelyLocked(graph: SceneGraph, node: SceneNode): boolean {
let current: SceneNode | undefined = node
while (current) {
if (current.locked) return true
current = current.parentId ? graph.getNode(current.parentId) : undefined
}
return false
}
export function findPageId(graph: SceneGraph, node: SceneNode): string | null {
let current: SceneNode | undefined = node
while (current) {
if (current.type === 'CANVAS') return current.id
if (current.parentId === null) return null
current = graph.getNode(current.parentId)
}
return null
}
export function findPageIdByName(graph: SceneGraph, name: string | undefined): string | undefined {
if (!name) return undefined
const page = graph.getPages().find((p) => p.name === name)
return page?.id
}
export function pairRelationship(
nodeA: SceneNode,
nodeB: SceneNode,
graph: SceneGraph
): {
sameParent: boolean
topLevel: boolean
insideParent: boolean
ancestor: 'neither' | 'a-ancestor' | 'b-ancestor'
} {
const sameParent = nodeA.parentId === nodeB.parentId && nodeA.parentId !== null
const parentA = nodeA.parentId ? graph.getNode(nodeA.parentId) : undefined
const parentB = nodeB.parentId ? graph.getNode(nodeB.parentId) : undefined
const topLevel = parentA?.type === 'CANVAS' && parentB?.type === 'CANVAS'
const insideParent = sameParent && parentA?.type !== 'CANVAS'
let ancestor: 'neither' | 'a-ancestor' | 'b-ancestor' = 'neither'
if (nodeA.id !== nodeB.id) {
if (graph.isDescendant(nodeB.id, nodeA.id)) ancestor = 'a-ancestor'
else if (graph.isDescendant(nodeA.id, nodeB.id)) ancestor = 'b-ancestor'
}
return { sameParent, topLevel, insideParent, ancestor }
}
export function matchesParentOverflowScope(scope: OverlapScope): boolean {
return scope === 'all' || scope === 'inside-parent'
}
export function matchesScope(
rel: ReturnType<typeof pairRelationship>,
scope: OverlapScope
): boolean {
switch (scope) {
case 'all':
return true
case 'same-parent':
return rel.sameParent
case 'cross-parent':
return !rel.sameParent
case 'top-level':
return rel.topLevel
case 'inside-parent':
return rel.insideParent
default:
return true
}
}
export function scoredSeverity(severity: OverlapSeverity): number {
return SEVERITY_RANK[severity]
}
function parentOverflowSeverity(outRatio: number): OverlapSeverity {
if (outRatio > 0.25) return 'critical'
if (outRatio > 0.05) return 'major'
return 'minor'
}
function siblingOverlapSeverity(intersectionArea: number, smallerArea: number): OverlapSeverity {
if (smallerArea <= 0) return 'info'
const ratio = intersectionArea / smallerArea
if (ratio > 0.5) return 'major'
if (ratio > 0.08) return 'minor'
return 'info'
}
function isCandidate(
node: SceneNode,
graph: SceneGraph,
options: {
includeHidden: boolean
includeLocked: boolean
includeAbsolute: boolean
pageId: string | undefined
}
): boolean {
if (node.type === 'CANVAS') return false
if (!options.includeHidden && isEffectivelyHidden(graph, node)) return false
if (!options.includeLocked && isEffectivelyLocked(graph, node)) return false
if (!options.includeAbsolute && node.layoutPositioning === 'ABSOLUTE') return false
if (options.pageId && findPageId(graph, node) !== options.pageId) return false
return true
}
export function filterNodes(
graph: SceneGraph,
args: AnalyzeOverlapsArgs
): { candidates: SceneNode[]; totalNodes: number; analyzedNodes: number } {
const includeHidden = args.include_hidden === true
const includeLocked = args.include_locked === true
const includeAbsolute = args.include_absolute === true
const pageIdFilter = args.page_id?.trim()
const typeFilter = parseNodeTypes(args.type)
const allNodes = [...graph.getAllNodes()]
const candidates: SceneNode[] = []
let totalNodes = 0
for (const node of allNodes) {
if (node.type === 'CANVAS') continue
// Apply page scoping to totalNodes so summary counts reflect the filtered universe.
if (pageIdFilter && findPageId(graph, node) !== pageIdFilter) continue
totalNodes++
if (
!isCandidate(node, graph, {
includeHidden,
includeLocked,
includeAbsolute,
pageId: undefined
})
)
continue
if (typeFilter && !typeFilter.has(node.type)) continue
candidates.push(node)
}
return { candidates, totalNodes, analyzedNodes: candidates.length }
}
const EMPTY_BOUNDS: VisualBounds = { minX: 0, maxX: 0, minY: 0, maxY: 0 }
/**
* Map a node's 4 local corners through the world matrix to canvas space.
* Unlike `getAbsolutePosition` + `rotatedBBox` (which only applies the node's
* own rotation), this correctly accounts for all ancestor transforms
* including nested rotations.
*/
function nodeWorldCorners(node: SceneNode, graph: SceneGraph): Vector[] {
const matrix = getWorldMatrix(node, graph)
const pts = Matrix.mapPoints(matrix, [
0,
0,
node.width,
0,
node.width,
node.height,
0,
node.height
])
return [
{ x: pts[0], y: pts[1] },
{ x: pts[2], y: pts[3] },
{ x: pts[4], y: pts[5] },
{ x: pts[6], y: pts[7] }
]
}
function aabbFromCorners(corners: Vector[]): VisualBounds {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const c of corners) {
if (c.x < minX) minX = c.x
if (c.y < minY) minY = c.y
if (c.x > maxX) maxX = c.x
if (c.y > maxY) maxY = c.y
}
return { minX, minY, maxX, maxY }
}
/**
* Compute a node's visual bounds in canvas space using the world matrix,
* correctly handling nested ancestor rotations. This replaces the previous
* `nodeVisualBounds` call which used `rotatedBBox` with the node's LOCAL
* rotation and the rotated origin — wrong when ancestors are rotated.
*
* Includes stroke overflow (uniform), effect overflow (directional),
* fill/stroke geometry (transformed through the world matrix), and
* text-decoration overflow (approximate: adds to canvas maxY).
*/
function computeNodeVisualBounds(node: SceneNode, graph: SceneGraph): VisualBounds {
const matrix = getWorldMatrix(node, graph)
const baseCorners = Matrix.mapPoints(matrix, [
0,
0,
node.width,
0,
node.width,
node.height,
0,
node.height
])
let bounds = aabbFromCorners([
{ x: baseCorners[0], y: baseCorners[1] },
{ x: baseCorners[2], y: baseCorners[3] },
{ x: baseCorners[4], y: baseCorners[5] },
{ x: baseCorners[6], y: baseCorners[7] }
])
const stroke = strokeOverflow(node.strokes)
if (stroke > 0) {
bounds.minX -= stroke
bounds.minY -= stroke
bounds.maxX += stroke
bounds.maxY += stroke
}
const effects = effectOverflow(node.effects)
bounds.minX -= effects.left
bounds.minY -= effects.top
bounds.maxX += effects.right
bounds.maxY += effects.bottom
const hasNonInsideStroke = node.strokes.some(
(stroke) => stroke.visible && stroke.align !== 'INSIDE'
)
const localGeometry = geometryBlobBounds([
...node.fillGeometry,
...(hasNonInsideStroke ? node.strokeGeometry : [])
])
if (localGeometry) {
const geomCorners = Matrix.mapPoints(matrix, [
localGeometry.x,
localGeometry.y,
localGeometry.x + localGeometry.width,
localGeometry.y,
localGeometry.x + localGeometry.width,
localGeometry.y + localGeometry.height,
localGeometry.x,
localGeometry.y + localGeometry.height
])
const geomBounds = aabbFromCorners([
{ x: geomCorners[0], y: geomCorners[1] },
{ x: geomCorners[2], y: geomCorners[3] },
{ x: geomCorners[4], y: geomCorners[5] },
{ x: geomCorners[6], y: geomCorners[7] }
])
bounds = unionVisualBounds(bounds, geomBounds) ?? bounds
}
if (node.type === 'TEXT' && node.textDecoration !== 'NONE') {
const fontSize = node.fontSize
const underlineOffset = node.textUnderlineOffset ?? fontSize * 0.18
const thickness = node.textDecorationThickness ?? Math.max(1, fontSize / 16)
bounds.maxY += underlineOffset + thickness + fontSize * 0.35
}
return bounds
}
/**
* Collect clipping ancestors as rotated polygons (4 canvas-space corners each).
* Unlike the previous axis-aligned approach, this correctly represents rotated
* clip frames by mapping their local corners through the world matrix.
*/
function collectClipChain(graph: SceneGraph, node: SceneNode): Vector[][] {
const clips: Vector[][] = []
let currentId = node.parentId
while (currentId) {
const current = graph.getNode(currentId)
if (!current) break
if (current.type === 'CANVAS') break
if (
current.clipsContent &&
(current.type === 'FRAME' || current.type === 'COMPONENT' || current.type === 'INSTANCE')
) {
clips.push(nodeWorldCorners(current, graph))
}
currentId = current.parentId
}
return clips
}
export function computeNodeBounds(
node: SceneNode,
graph: SceneGraph
): { bounds: VisualBounds; area: number } {
let bounds = computeNodeVisualBounds(node, graph)
const clips = collectClipChain(graph, node)
for (const clip of clips) {
const clipped = clipBoundsToPolygon(bounds, clip)
if (!clipped) {
return { bounds: EMPTY_BOUNDS, area: 0 }
}
bounds = clipped
}
return { bounds, area: visualBoundsArea(bounds) }
}
function makeOverlapItem(
category: OverlapCategory,
severity: OverlapSeverity,
nodeA: SceneNode,
boundsA: VisualBounds,
nodeB: SceneNode,
boundsB: VisualBounds,
intersection: VisualBounds,
message: string,
suggestion: string,
areaField: 'intersection' | 'overflow' = 'intersection'
): OverlapItem {
const intersectionRect = boundsToRect(intersection)
const area =
areaField === 'intersection'
? visualBoundsArea(intersection)
: visualBoundsArea(boundsA) - visualBoundsArea(intersection)
const ratio =
areaField === 'intersection'
? area / Math.max(1, Math.min(visualBoundsArea(boundsA), visualBoundsArea(boundsB)))
: area / Math.max(1, visualBoundsArea(boundsA))
return {
category,
severity,
message,
suggestion,
area: Math.round(area),
ratio: Math.round(ratio * 1000) / 1000,
nodeA: toNodeSummary(nodeA),
nodeB: toNodeSummary(nodeB),
intersection: {
...intersectionRect,
area: Math.round(visualBoundsArea(intersection))
}
}
}
export function buildParentOverflowResult(
child: SceneNode,
childBounds: VisualBounds,
parent: SceneNode,
parentBounds: VisualBounds
): OverlapItem | null {
if (parent.clipsContent) return null
const childArea = visualBoundsArea(childBounds)
if (childArea <= 0) return null
const intersection = intersectVisualBounds(childBounds, parentBounds)
const outArea = intersection ? childArea - visualBoundsArea(intersection) : childArea
if (outArea <= 0) return null
const outRatio = outArea / childArea
const severity = parentOverflowSeverity(outRatio)
const message = `${child.type === 'TEXT' ? 'Text' : `Node`} "${child.name}" extends ${Math.round(outArea)}px outside parent "${parent.name}"`
const suggestion =
child.type === 'TEXT'
? 'Set the parent to clip content or constrain text sizing (textAutoResize, maxLines).'
: `Reposition inside "${parent.name}" or enable clip content on the parent.`
return makeOverlapItem(
'parent-overflow',
severity,
child,
childBounds,
parent,
parentBounds,
intersection ?? EMPTY_BOUNDS,
message,
suggestion,
'overflow'
)
}
function isNodeAbove(ancestorGraph: SceneGraph, above: SceneNode, below: SceneNode): boolean {
if (above.parentId !== below.parentId || above.parentId === null) return false
const parent = ancestorGraph.getNode(above.parentId)
if (!parent) return false
const aboveIndex = parent.childIds.indexOf(above.id)
const belowIndex = parent.childIds.indexOf(below.id)
return aboveIndex > belowIndex
}
function detectSiblingOverlay(
nodeA: SceneNode,
boundsA: VisualBounds,
nodeB: SceneNode,
boundsB: VisualBounds,
graph: SceneGraph
): OverlapCategory {
const areaA = visualBoundsArea(boundsA)
const areaB = visualBoundsArea(boundsB)
const smallerArea = Math.min(areaA, areaB)
const largerArea = Math.max(areaA, areaB)
const intersection = intersectVisualBounds(boundsA, boundsB)
if (!intersection || smallerArea <= 0) return 'sibling-overlap'
const overlapArea = visualBoundsArea(intersection)
const coversSmall = overlapArea / smallerArea > 0.85
const sizeRatio = largerArea / Math.max(1, smallerArea)
if (sizeRatio < 5 || !coversSmall) return 'sibling-overlap'
const larger = areaA >= areaB ? nodeA : nodeB
const smaller = areaA >= areaB ? nodeB : nodeA
return isNodeAbove(graph, larger, smaller) ? 'overlay' : 'sibling-overlap'
}
export function buildSiblingOverlapResult(
nodeA: SceneNode,
boundsA: VisualBounds,
nodeB: SceneNode,
boundsB: VisualBounds,
graph: SceneGraph
): OverlapItem | null {
const intersection = intersectVisualBounds(boundsA, boundsB)
if (!intersection) return null
const areaA = visualBoundsArea(boundsA)
const areaB = visualBoundsArea(boundsB)
const smallerArea = Math.min(areaA, areaB)
const category = detectSiblingOverlay(nodeA, boundsA, nodeB, boundsB, graph)
const intersectionArea = visualBoundsArea(intersection)
let severity: OverlapSeverity
if (category === 'overlay') {
severity = smallerArea > 0 && intersectionArea / smallerArea > 0.98 ? 'info' : 'minor'
} else {
severity = siblingOverlapSeverity(intersectionArea, smallerArea)
}
const larger = areaA >= areaB ? nodeA : nodeB
const smaller = areaA >= areaB ? nodeB : nodeA
const message =
category === 'overlay'
? `"${larger.name}" appears to be an overlay covering "${smaller.name}"`
: `"${nodeA.name}" overlaps "${nodeB.name}"`
const suggestion =
category === 'overlay'
? 'If intentional (modal/backdrop/dropdown), no action needed. Otherwise reposition or adjust z-order.'
: 'Review stacking and spacing — this overlap is likely unintended.'
return makeOverlapItem(
category,
severity,
nodeA,
boundsA,
nodeB,
boundsB,
intersection,
message,
suggestion
)
}
export function passesThresholds(
item: OverlapItem,
minArea: number,
minRatio: number,
categoryFilter: OverlapCategory[] | undefined,
severityFilter: OverlapSeverity | undefined
): boolean {
if (item.area < minArea) return false
if (item.ratio < minRatio) return false
if (categoryFilter && !categoryFilter.includes(item.category)) return false
if (severityFilter && scoredSeverity(item.severity) < scoredSeverity(severityFilter)) return false
return true
}
export type BoundsEntry = { node: SceneNode; bounds: VisualBounds; area: number }

View file

@ -0,0 +1,394 @@
import { orderBy } from 'es-toolkit/array'
import type { FigmaAPI } from '#core/figma-api'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { defineTool } from '#core/tools/schema'
import {
buildParentOverflowResult,
buildSiblingOverlapResult,
computeNodeBounds,
filterNodes,
findPageId,
findPageIdByName,
matchesParentOverflowScope,
matchesScope,
pairRelationship,
passesThresholds,
scoredSeverity,
type BoundsEntry
} from './helpers'
import {
parseOverlapCategories as parseCategoryFilter,
parseOverlapScope as toScope,
parseOverlapSeverity as parseSeverity
} from './params'
export type OverlapSeverity = 'critical' | 'major' | 'minor' | 'info'
export type OverlapScope = 'all' | 'same-parent' | 'cross-parent' | 'top-level' | 'inside-parent'
export type OverlapCategory = 'sibling-overlap' | 'parent-overflow' | 'overlay'
export { findPageId, findPageIdByName }
export interface OverlapNodeSummary {
id: string
name: string
type: string
parentId: string | null
x: number
y: number
width: number
height: number
rotation: number
opacity: number
visible: boolean
locked: boolean
}
export interface OverlapIntersection {
x: number
y: number
width: number
height: number
area: number
}
export interface OverlapItem {
category: OverlapCategory
severity: OverlapSeverity
message: string
suggestion: string
area: number
ratio: number
nodeA: OverlapNodeSummary
nodeB: OverlapNodeSummary
intersection: OverlapIntersection
}
export interface AnalyzeOverlapsArgs {
scope?: OverlapScope
category?: string
severity?: OverlapSeverity
min_area?: number
min_ratio?: number
include_hidden?: boolean
include_locked?: boolean
include_absolute?: boolean
limit?: number
/** Page name fallback; used only when `page_id` is not supplied. */
page?: string
/** Stable page ID; takes precedence over `page`. */
page_id?: string
type?: string
}
export interface AnalyzeOverlapsSummary {
totalNodes: number
analyzedNodes: number
overlapCount: number
byCategory: Record<OverlapCategory, number>
bySeverity: Record<OverlapSeverity, number>
}
export interface AnalyzeOverlapsResult {
overlaps: OverlapItem[]
summary: AnalyzeOverlapsSummary
}
function buildBoundsCache(
candidates: SceneNode[],
graph: SceneGraph
): { boundsCache: Map<string, BoundsEntry>; entries: BoundsEntry[] } {
const boundsCache = new Map<string, BoundsEntry>()
const entries: BoundsEntry[] = []
for (const node of candidates) {
const cached = boundsCache.get(node.id)
if (cached) {
entries.push(cached)
continue
}
const computed = computeNodeBounds(node, graph)
if (computed.area <= 0) continue
const entry: BoundsEntry = { node, ...computed }
boundsCache.set(node.id, entry)
entries.push(entry)
}
return { boundsCache, entries }
}
function collectParentOverflows(
candidates: SceneNode[],
graph: SceneGraph,
boundsCache: Map<string, BoundsEntry>,
scope: OverlapScope,
minArea: number,
minRatio: number,
categoryFilter: OverlapCategory[] | undefined,
severityFilter: OverlapSeverity | undefined
): OverlapItem[] {
const overlaps: OverlapItem[] = []
for (const child of candidates) {
if (!child.parentId) continue
const parent = graph.getNode(child.parentId)
if (!parent || parent.type === 'CANVAS') continue
const childEntry = boundsCache.get(child.id)
if (!childEntry || childEntry.area <= 0) continue
let parentEntry = boundsCache.get(parent.id)
if (!parentEntry) {
const computed = computeNodeBounds(parent, graph)
if (computed.area <= 0) continue
parentEntry = { node: parent, ...computed }
boundsCache.set(parent.id, parentEntry)
}
const item = buildParentOverflowResult(child, childEntry.bounds, parent, parentEntry.bounds)
if (
item &&
matchesParentOverflowScope(scope) &&
passesThresholds(item, minArea, minRatio, categoryFilter, severityFilter)
) {
overlaps.push(item)
}
}
return overlaps
}
function collectSiblingOverlaps(
entries: BoundsEntry[],
graph: SceneGraph,
scope: OverlapScope,
minArea: number,
minRatio: number,
categoryFilter: OverlapCategory[] | undefined,
severityFilter: OverlapSeverity | undefined
): OverlapItem[] {
const overlaps: OverlapItem[] = []
entries.sort((a, b) => a.bounds.minX - b.bounds.minX)
for (let i = 0; i < entries.length; i++) {
const entryA = entries[i]
if (entryA.area <= 0) continue
const maxX = entryA.bounds.maxX
for (let j = i + 1; j < entries.length; j++) {
const entryB = entries[j]
if (entryB.bounds.minX > maxX) break
if (entryB.bounds.maxY <= entryA.bounds.minY || entryB.bounds.minY >= entryA.bounds.maxY) {
continue
}
const rel = pairRelationship(entryA.node, entryB.node, graph)
if (rel.ancestor !== 'neither') continue
if (!matchesScope(rel, scope)) continue
const item = buildSiblingOverlapResult(
entryA.node,
entryA.bounds,
entryB.node,
entryB.bounds,
graph
)
if (item && passesThresholds(item, minArea, minRatio, categoryFilter, severityFilter)) {
overlaps.push(item)
}
}
}
return overlaps
}
function emptyByCategory(): Record<OverlapCategory, number> {
return {
'sibling-overlap': 0,
'parent-overflow': 0,
overlay: 0
}
}
function emptyBySeverity(): Record<OverlapSeverity, number> {
return {
critical: 0,
major: 0,
minor: 0,
info: 0
}
}
/**
* Compute overlap findings for a scoped subset of the graph.
* Page, scope, type, and visibility filtering is applied via `args`.
*
* Heuristics implemented:
* - `sibling-overlap`: two non-ancestor nodes visually intersect.
* - `parent-overflow`: a child protrudes from a non-clipping parent.
* - `overlay`: a large node covers a much smaller sibling (modal/backdrop pattern).
*
* Filters:
* - hidden and locked nodes are skipped unless explicitly included
* - absolutely-positioned nodes are skipped unless explicitly included
* - ancestor/descendant pairs are never emitted as pair overlaps
*/
export function computeOverlaps(
graph: SceneGraph,
args: AnalyzeOverlapsArgs = {}
): AnalyzeOverlapsResult {
const scope = toScope(args.scope) ?? 'all'
const categoryFilter = parseCategoryFilter(args.category)
const severityFilter = parseSeverity(args.severity)
const minArea = Math.max(0, Number.isFinite(Number(args.min_area)) ? Number(args.min_area) : 0)
const minRatio = Math.max(
0,
Math.min(1, Number.isFinite(Number(args.min_ratio)) ? Number(args.min_ratio) : 0)
)
const explicitPageName = args.page?.trim()
const explicitPageId = args.page_id?.trim()
let resolvedPageId: string | undefined
if (explicitPageId) {
resolvedPageId = explicitPageId
} else if (explicitPageName) {
resolvedPageId = findPageIdByName(graph, explicitPageName)
} else {
resolvedPageId = graph.getPages()[0]?.id
}
if (!resolvedPageId) {
return {
overlaps: [],
summary: {
totalNodes: 0,
analyzedNodes: 0,
overlapCount: 0,
byCategory: emptyByCategory(),
bySeverity: emptyBySeverity()
}
}
}
const resolvedArgs: AnalyzeOverlapsArgs = { ...args, page_id: resolvedPageId }
const { candidates, totalNodes, analyzedNodes } = filterNodes(graph, resolvedArgs)
const { boundsCache, entries } = buildBoundsCache(candidates, graph)
const overlaps = [
...collectParentOverflows(
candidates,
graph,
boundsCache,
scope,
minArea,
minRatio,
categoryFilter,
severityFilter
),
...collectSiblingOverlaps(
entries,
graph,
scope,
minArea,
minRatio,
categoryFilter,
severityFilter
)
]
const sorted = orderBy(
overlaps,
[(o) => scoredSeverity(o.severity), (o) => o.area],
['desc', 'desc']
)
const limit = Math.max(0, Number.isFinite(Number(args.limit)) ? Number(args.limit) : 100)
const trimmed = limit > 0 ? sorted.slice(0, limit) : sorted
const byCategory = emptyByCategory()
const bySeverity = emptyBySeverity()
for (const item of sorted) {
byCategory[item.category]++
bySeverity[item.severity]++
}
return {
overlaps: trimmed,
summary: {
totalNodes,
analyzedNodes,
overlapCount: sorted.length,
byCategory,
bySeverity
}
}
}
export const analyzeOverlaps = defineTool({
name: 'analyze_overlaps',
description:
'Detect visual overlaps and layout overflows across the current page. Useful for finding content that covers footers, text that bleeds outside frames, and accidental sibling overlaps.',
params: {
scope: {
type: 'string',
description:
'Which pairs to inspect: all, same-parent, cross-parent, top-level, inside-parent (default: all)',
enum: ['all', 'same-parent', 'cross-parent', 'top-level', 'inside-parent'],
default: 'all'
},
category: {
type: 'string',
description:
'Comma-separated categories: sibling-overlap, parent-overflow, overlay (default: all)'
},
severity: {
type: 'string',
description: 'Minimum severity to include: critical, major, minor, info (default: info)',
enum: ['critical', 'major', 'minor', 'info'],
default: 'info'
},
min_area: {
type: 'number',
description: 'Minimum overlap area in square pixels (default: 0)'
},
min_ratio: {
type: 'number',
description: 'Minimum overlap ratio relative to the smaller node, 0.0–1.0 (default: 0)'
},
include_hidden: {
type: 'boolean',
description: 'Include hidden nodes in the analysis'
},
include_locked: {
type: 'boolean',
description: 'Include locked nodes in the analysis'
},
include_absolute: {
type: 'boolean',
description: 'Include absolutely-positioned nodes in the analysis'
},
page: {
type: 'string',
description: 'Limit analysis to nodes on the named page'
},
page_id: {
type: 'string',
description:
'Limit analysis to nodes on the page with this stable ID (takes precedence over page)'
},
type: {
type: 'string',
description: 'Comma-separated node types to analyze, e.g. FRAME,TEXT'
},
limit: {
type: 'number',
description: 'Maximum overlap findings to return (default: 100)',
default: 100
}
},
execute: (figma: FigmaAPI, args) => {
const page_id = args.page_id ?? (args.page ? undefined : figma.currentPageId)
return computeOverlaps(figma.graph, { ...(args as AnalyzeOverlapsArgs), page_id })
}
})

View file

@ -0,0 +1,47 @@
import type { OverlapCategory, OverlapScope, OverlapSeverity } from './index'
export const VALID_OVERLAP_SCOPES: readonly OverlapScope[] = [
'all',
'same-parent',
'cross-parent',
'top-level',
'inside-parent'
]
export const VALID_OVERLAP_CATEGORIES: readonly OverlapCategory[] = [
'sibling-overlap',
'parent-overflow',
'overlay'
]
export const VALID_OVERLAP_SEVERITIES: readonly OverlapSeverity[] = [
'critical',
'major',
'minor',
'info'
]
export function parseOverlapScope(raw: string | undefined): OverlapScope | undefined {
if (!raw) return undefined
const normalized = raw.toLowerCase()
return VALID_OVERLAP_SCOPES.find((scope) => scope === normalized)
}
export function parseOverlapCategories(raw: string | undefined): OverlapCategory[] | undefined {
if (!raw) return undefined
const values = raw
.split(',')
.map((v) => v.trim().toLowerCase())
.filter((v) => v.length > 0)
if (values.length === 0) return undefined
const categories = values.filter((v): v is OverlapCategory =>
VALID_OVERLAP_CATEGORIES.includes(v as OverlapCategory)
)
return categories.length > 0 ? categories : undefined
}
export function parseOverlapSeverity(raw: string | undefined): OverlapSeverity | undefined {
if (!raw) return undefined
const normalized = raw.toLowerCase()
return VALID_OVERLAP_SEVERITIES.find((severity) => severity === normalized)
}

View file

@ -6,5 +6,13 @@ export type { ToolDef, ParamDef, ParamType } from './schema'
export { toolsToAI, buildDebugLog } from './ai-adapter'
export type { ToolLogEntry, ToolDebugLog, AIAdapterOptions, StepBudget } from './ai-adapter'
export { calcClusterConfidence, wrapEvalCode } from './analyze'
export {
VALID_OVERLAP_CATEGORIES,
VALID_OVERLAP_SCOPES,
VALID_OVERLAP_SEVERITIES,
parseOverlapCategories,
parseOverlapScope,
parseOverlapSeverity
} from './analyze/overlaps/params'
export { setPexelsApiKey, setUnsplashAccessKey } from './stock-photo'
export { importSvg } from './create'

View file

@ -1,6 +1,7 @@
import {
analyzeClusters,
analyzeColors,
analyzeOverlaps,
analyzeSpacing,
analyzeTypography,
diffCreate,
@ -185,6 +186,7 @@ export const EXTENDED_TOOLS: ToolDef[] = [
analyzeTypography,
analyzeSpacing,
analyzeClusters,
analyzeOverlaps,
diffCreate,
diffShow,
// Codegen

View file

@ -3,9 +3,11 @@ import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { cliSourcePath, repoPath } from '#tests/helpers/paths'
import { cliSourcePath, repoPath, requireBuiltWorkspacePackages } from '#tests/helpers/paths'
import { heavy } from '#tests/helpers/test-utils'
requireBuiltWorkspacePackages()
setDefaultTimeout(30_000)
const CLI = cliSourcePath('index.ts')

View file

@ -0,0 +1,162 @@
import { expect, setDefaultTimeout, test } from 'bun:test'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SceneGraph } from '@open-pencil/core'
import { exportFigFile } from '@open-pencil/core/io/formats/fig'
import { cliSourcePath, repoPath, requireBuiltWorkspacePackages } from '#tests/helpers/paths'
import { heavy } from '#tests/helpers/test-utils'
requireBuiltWorkspacePackages()
setDefaultTimeout(30_000)
const CLI = cliSourcePath('index.ts')
const FIXTURE = repoPath('tests/fixtures/gold-preview.fig')
async function makeTwoPageFixture(): Promise<string> {
const graph = new SceneGraph()
const page1 = graph.getPages()[0].id
const page2 = graph.addPage('Page 2')
graph.createNode('RECTANGLE', page1, { name: 'A', x: 0, y: 0, width: 100, height: 100 })
graph.createNode('RECTANGLE', page1, { name: 'B', x: 50, y: 50, width: 100, height: 100 })
graph.createNode('RECTANGLE', page2.id, { name: 'C', x: 0, y: 0, width: 100, height: 100 })
graph.createNode('RECTANGLE', page2.id, { name: 'D', x: 50, y: 50, width: 100, height: 100 })
const data = await exportFigFile(graph)
const dir = mkdtempSync(join(tmpdir(), 'overlap-cli-'))
const file = join(dir, 'two-pages.fig')
writeFileSync(file, data)
return file
}
async function run(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(['bun', CLI, ...args], {
stdout: 'pipe',
stderr: 'pipe'
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text()
])
const exitCode = await proc.exited
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}
heavy('analyze overlaps CLI', () => {
test('--json returns valid overlap data', async () => {
const { stdout, exitCode } = await run(['analyze', 'overlaps', FIXTURE, '--json'])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.overlaps).toBeArray()
expect(typeof data.summary.totalNodes).toBe('number')
expect(typeof data.summary.analyzedNodes).toBe('number')
expect(typeof data.summary.overlapCount).toBe('number')
})
test('human-readable mode prints a summary', async () => {
const { stdout, exitCode } = await run(['analyze', 'overlaps', FIXTURE])
expect(exitCode).toBe(0)
expect(stdout.length).toBeGreaterThan(0)
expect(stdout).toContain('analyzed nodes')
})
test('invalid scope exits with error', async () => {
const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--scope', 'invalid'])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('Invalid scope')
})
test('invalid severity exits with error', async () => {
const { exitCode, stderr } = await run([
'analyze',
'overlaps',
FIXTURE,
'--severity',
'invalid'
])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('Invalid severity')
})
test('invalid category exits with error', async () => {
const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--category', 'bogus'])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('Invalid categor')
})
test('invalid --min-area exits with error', async () => {
const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--min-area', 'abc'])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('--min-area')
})
test('invalid --limit exits with error', async () => {
const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--limit', '0'])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('--limit')
})
test('--category filters results', async () => {
const { stdout, exitCode } = await run([
'analyze',
'overlaps',
FIXTURE,
'--json',
'--category',
'parent-overflow'
])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.overlaps.every((o: { category: string }) => o.category === 'parent-overflow')).toBe(
true
)
})
test('scope argument is case-insensitive', async () => {
const { stdout, exitCode } = await run([
'analyze',
'overlaps',
FIXTURE,
'--json',
'--scope',
'SAME-PARENT'
])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.overlaps).toBeArray()
expect(typeof data.summary.totalNodes).toBe('number')
})
test('default mode scopes analysis to the first page only', async () => {
const fixture = await makeTwoPageFixture()
const { stdout, exitCode } = await run(['analyze', 'overlaps', fixture, '--json'])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.summary.overlapCount).toBe(1)
expect(data.summary.totalNodes).toBe(2)
expect(data.overlaps[0].nodeA.name).toBe('A')
expect(data.overlaps[0].nodeB.name).toBe('B')
})
test('--page scopes analysis to the named page', async () => {
const fixture = await makeTwoPageFixture()
const { stdout, exitCode } = await run([
'analyze',
'overlaps',
fixture,
'--json',
'--page',
'Page 2'
])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.summary.overlapCount).toBe(1)
expect(data.summary.totalNodes).toBe(2)
expect(data.overlaps[0].nodeA.name).toBe('C')
expect(data.overlaps[0].nodeB.name).toBe('D')
})
})

View file

@ -0,0 +1,181 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph, type Rect } from '@open-pencil/core'
import { computeOverlaps } from '@open-pencil/core/tools/analyze/overlaps'
import { frame, pageId, rect } from './helpers'
describe('analyze overlaps visible bounds', () => {
test('large background below a small badge is not reported as overlay', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'Backdrop', page, 0, 0, 100, 100)
rect(graph, 'Badge', page, 10, 10, 10, 10)
const result = computeOverlaps(graph)
expect(result.overlaps.some((o) => o.category === 'overlay')).toBe(false)
expect(result.overlaps.some((o) => o.category === 'sibling-overlap')).toBe(true)
})
test('text decoration extends visual bounds below raw height', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const t = graph.createNode('TEXT', page, {
name: 'Underlined',
x: 0,
y: 0,
width: 100,
height: 10,
text: 'Underlined',
fontSize: 14,
textDecoration: 'UNDERLINE'
})
rect(graph, 'Below', page, 0, 11, 100, 10)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(result.overlaps.some((o) => o.nodeA.id === t.id || o.nodeB.id === t.id)).toBe(true)
})
test('vector geometry expands bounds beyond width and height', () => {
const graph = new SceneGraph()
const page = pageId(graph)
function rectGeometryBlob(local: Rect) {
const parts = [
{ cmd: 1, x: local.x, y: local.y },
{ cmd: 2, x: local.x + local.width, y: local.y },
{ cmd: 2, x: local.x + local.width, y: local.y + local.height },
{ cmd: 2, x: local.x, y: local.y + local.height },
{ cmd: 0 }
]
let byteLength = 0
for (const part of parts) {
byteLength += 1
if (part.x !== undefined) byteLength += 8
}
const bytes = new Uint8Array(byteLength)
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
let offset = 0
for (const part of parts) {
bytes[offset++] = part.cmd
if (part.x !== undefined) {
dv.setFloat32(offset, part.x, true)
dv.setFloat32(offset + 4, part.y as number, true)
offset += 8
}
}
return bytes
}
const v = graph.createNode('VECTOR', page, {
name: 'WideVector',
x: 0,
y: 0,
width: 10,
height: 10,
fillGeometry: [
{
commandsBlob: rectGeometryBlob({ x: 0, y: 0, width: 200, height: 200 }),
windingRule: 'NONZERO'
}
]
})
rect(graph, 'Far', page, 150, 150, 50, 50)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(result.overlaps.some((o) => o.nodeA.id === v.id || o.nodeB.id === v.id)).toBe(true)
})
test('ancestor clipping prevents false overlaps across the clip boundary', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const clipFrame = frame(graph, 'ClipFrame', page, 0, 0, 50, 50, true)
rect(graph, 'ClippedChild', clipFrame.id, 40, 0, 20, 20)
rect(graph, 'Outside', page, 50, 0, 20, 20)
const clipped = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(clipped.summary.overlapCount).toBe(0)
const noClipFrame = frame(graph, 'NoClipFrame', page, 200, 0, 50, 50, false)
rect(graph, 'UnclippedChild', noClipFrame.id, 40, 0, 20, 20)
rect(graph, 'Outside2', page, 250, 0, 20, 20)
const unclipped = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(unclipped.summary.overlapCount).toBe(1)
})
test('rotated clipping frame prevents false sibling overlap', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// A 90°-rotated clipping frame. Its local space [0,100]x[0,100] maps to
// canvas [0,100]x[0,100] (a square rotated 90° around its center is the
// same square). But the OLD axis-aligned clip used the rotated origin
// (100,0) and produced a wrong clip rectangle [100,200]x[0,100].
const clipFrame = frame(graph, 'RotatedClip', page, 0, 0, 100, 100, true)
graph.updateNode(clipFrame.id, { rotation: 90 })
// Child at local (10,10,20,20). After 90° rotation around the frame
// center (50,50), the child lands at canvas AABB [70,90]x[10,30].
rect(graph, 'Child', clipFrame.id, 10, 10, 20, 20)
// Outside node starting exactly at the clip frame's right boundary.
// With the BUGGY code the child's clipped bounds were [100,110]x[10,30]
// (wrong), producing a false overlap with [100,120]x[10,30].
// With the fix the child's clipped bounds are [70,90]x[10,30] (correct),
// so there is no overlap.
rect(graph, 'Outside', page, 100, 10, 20, 20)
const result = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBe(0)
})
test('child of rotated non-clipping parent is detected at correct position', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// Non-clipping parent rotated 90°. A child at local (10,10,20,20)
// maps to canvas AABB [70,90]x[10,30] after rotation. A second node
// placed at canvas (72,12,10,10) should overlap — proving the bounds
// are computed from the world matrix, not from the rotated origin.
const parent = frame(graph, 'RotatedParent', page, 0, 0, 100, 100, false)
graph.updateNode(parent.id, { rotation: 90 })
const child = rect(graph, 'Child', parent.id, 10, 10, 20, 20)
rect(graph, 'OverlapTarget', page, 72, 12, 10, 10)
const result = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(result.overlaps.some((o) => o.nodeA.id === child.id || o.nodeB.id === child.id)).toBe(
true
)
})
test('rotated clipping frame still clips children that protrude', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// 90°-rotated clipping frame at (0,0,100,100). Its canvas-space clip is
// [0,100]x[0,100] (a square rotated 90° around its center is the same
// square). The OLD axis-aligned clip used the rotated origin (100,0) and
// produced a wrong clip rectangle [100,200]x[0,100].
const clipFrame = frame(graph, 'RotatedClip90', page, 0, 0, 100, 100, true)
graph.updateNode(clipFrame.id, { rotation: 90 })
// Child at local (10,10,80,80) — a large child whose AABB (after 90°
// rotation) is [10,90]x[10,90], entirely inside the [0,100]x[0,100] clip.
rect(graph, 'Center', clipFrame.id, 10, 10, 80, 80)
// Outside node at (110,10,20,20) — its AABB [110,130]x[10,30] does NOT
// overlap the child's clipped AABB [10,90]x[10,90] and does NOT overlap
// the clip frame's AABB [0,100]x[0,100]. With the BUGGY code, the child's
// clipped bounds were [100,170]x[10,90] (wrong), which WOULD overlap
// [110,130]x[10,30] — a false positive.
rect(graph, 'Outside', page, 110, 10, 20, 20)
const result = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBe(0)
})
})

View file

@ -0,0 +1,57 @@
import type { SceneGraph } from '@open-pencil/core'
export function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
export function rect(
graph: SceneGraph,
name: string,
parentId: string,
x = 0,
y = 0,
w = 50,
h = 50
) {
return graph.createNode('RECTANGLE', parentId, { name, x, y, width: w, height: h })
}
export function frame(
graph: SceneGraph,
name: string,
parentId: string,
x = 0,
y = 0,
w = 100,
h = 100,
clipsContent = false
) {
return graph.createNode('FRAME', parentId, {
name,
x,
y,
width: w,
height: h,
clipsContent
})
}
export function text(
graph: SceneGraph,
name: string,
parentId: string,
x = 0,
y = 0,
w = 100,
h = 20
) {
return graph.createNode('TEXT', parentId, {
name,
x,
y,
width: w,
height: h,
text: name,
fontSize: 14
})
}

View file

@ -0,0 +1,420 @@
import { describe, expect, test } from 'bun:test'
import { FigmaAPI, SceneGraph } from '@open-pencil/core'
import {
analyzeOverlaps,
computeOverlaps,
type OverlapScope
} from '@open-pencil/core/tools/analyze/overlaps'
import { frame, pageId, rect, text } from './helpers'
describe('analyze overlaps', () => {
test('detects overlapping sibling rectangles', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Parent', page, 0, 0, 200, 200)
rect(graph, 'A', parent.id, 0, 0, 100, 100)
rect(graph, 'B', parent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'same-parent' })
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(result.overlaps.some((o) => o.category === 'sibling-overlap')).toBe(true)
})
test('ignores separated rectangles', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 50, 50)
rect(graph, 'B', page, 200, 200, 50, 50)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('skips hidden and locked nodes by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
const hidden = rect(graph, 'Hidden', page, 50, 50, 100, 100)
graph.updateNode(hidden.id, { visible: false })
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('includes hidden nodes when include_hidden is true', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
const hidden = rect(graph, 'Hidden', page, 50, 50, 100, 100)
graph.updateNode(hidden.id, { visible: false })
const result = computeOverlaps(graph, { include_hidden: true })
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('reports text that overflows a non-clipping frame', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 80, 80)
text(graph, 'Wide', parent.id, 0, 0, 200, 20)
const result = computeOverlaps(graph)
expect(result.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true)
})
test('reports a child that is fully outside its non-clipping parent', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 100, 100)
rect(graph, 'Stray', parent.id, 200, 0, 50, 50)
const result = computeOverlaps(graph)
expect(result.overlaps).toHaveLength(1)
const overflow = result.overlaps[0]
expect(overflow.category).toBe('parent-overflow')
expect(overflow.area).toBe(2500)
expect(overflow.ratio).toBe(1)
})
test('does not report overflow when parent clips content', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 80, 80, true)
text(graph, 'Wide', parent.id, 0, 0, 200, 20)
const result = computeOverlaps(graph)
expect(result.overlaps).toHaveLength(0)
})
test('detects rotated overlap', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 20)
const rotated = rect(graph, 'B', page, 0, 0, 100, 20)
graph.updateNode(rotated.id, { rotation: 45 })
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('detects a suspicious overlay covering a smaller sibling', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// Create the smaller node first so the later (and therefore visually on-top) larger node
// genuinely covers it.
rect(graph, 'Badge', page, 10, 10, 10, 10)
rect(graph, 'Backdrop', page, 0, 0, 100, 100)
const result = computeOverlaps(graph)
expect(result.overlaps.some((o) => o.category === 'overlay')).toBe(true)
const overlay = result.overlaps.find((o) => o.category === 'overlay')
expect(overlay?.message).toContain('"Backdrop" appears to be an overlay covering "Badge"')
})
test('filters by node type', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
const t = text(graph, 'B', page, 50, 50, 100, 100)
const result = computeOverlaps(graph, { type: 'TEXT' })
expect(result.overlaps.every((o) => o.nodeA.id === t.id || o.nodeB.id === t.id)).toBe(true)
})
test('respects min_area threshold', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
rect(graph, 'B', page, 99, 99, 2, 2)
const result = computeOverlaps(graph, { min_area: 100 })
expect(result.summary.overlapCount).toBe(0)
})
test('does not flag ancestor-descendant overlap', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 200, 200)
rect(graph, 'Child', parent.id, 0, 0, 100, 100)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('scope=same-parent only reports siblings sharing a parent', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parentA = frame(graph, 'ParentA', page, 0, 0, 200, 200)
const parentB = frame(graph, 'ParentB', page, 300, 0, 200, 200)
rect(graph, 'A1', parentA.id, 0, 0, 100, 100)
rect(graph, 'A2', parentA.id, 50, 50, 100, 100)
rect(graph, 'B1', parentB.id, 0, 0, 100, 100)
rect(graph, 'B2', parentB.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'same-parent', category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBe(2)
expect(
result.overlaps.every(
(o) => o.nodeA.parentId === o.nodeB.parentId && o.nodeA.parentId !== page
)
).toBe(true)
})
test('scope=cross-parent only reports nodes from different parents', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parentA = frame(graph, 'ParentA', page, 0, 0, 200, 200)
const parentB = frame(graph, 'ParentB', page, 300, 0, 200, 200)
rect(graph, 'A', parentA.id, 150, 0, 110, 100)
rect(graph, 'B', parentB.id, -60, 0, 110, 100)
const result = computeOverlaps(graph, { scope: 'cross-parent', category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBe(1)
expect(result.overlaps[0].nodeA.parentId).not.toBe(result.overlaps[0].nodeB.parentId)
})
test('scope=top-level only reports direct page children', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'TopA', page, 0, 0, 100, 100)
rect(graph, 'TopB', page, 50, 50, 100, 100)
const parent = frame(graph, 'Parent', page, 300, 300, 200, 200)
rect(graph, 'InnerA', parent.id, 0, 0, 100, 100)
rect(graph, 'InnerB', parent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'top-level' })
expect(result.summary.overlapCount).toBe(1)
expect(result.overlaps[0].nodeA.parentId).toBe(page)
expect(result.overlaps[0].nodeB.parentId).toBe(page)
})
test('scope=inside-parent only reports children inside a non-page parent', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'TopA', page, 0, 0, 100, 100)
rect(graph, 'TopB', page, 50, 50, 100, 100)
const parent = frame(graph, 'Parent', page, 300, 300, 200, 200)
rect(graph, 'InnerA', parent.id, 0, 0, 100, 100)
rect(graph, 'InnerB', parent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'inside-parent' })
expect(result.summary.overlapCount).toBe(1)
expect(result.overlaps[0].nodeA.parentId).toBe(parent.id)
expect(result.overlaps[0].nodeB.parentId).toBe(parent.id)
})
test('respects min_ratio threshold', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Parent', page, 0, 0, 200, 200)
rect(graph, 'A', parent.id, 0, 0, 100, 100)
rect(graph, 'B', parent.id, 50, 50, 100, 100)
const excluded = computeOverlaps(graph, { scope: 'same-parent', min_ratio: 0.5 })
expect(excluded.overlaps).toHaveLength(0)
const included = computeOverlaps(graph, { scope: 'same-parent', min_ratio: 0.1 })
expect(included.overlaps.length).toBeGreaterThan(0)
})
test('excludes locked nodes by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const locked = rect(graph, 'Locked', page, 0, 0, 100, 100)
graph.updateNode(locked.id, { locked: true })
rect(graph, 'A', page, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(
result.overlaps.some((o) => o.nodeA.name === 'Locked' || o.nodeB.name === 'Locked')
).toBe(false)
})
test('includes locked nodes when include_locked is true', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const locked = rect(graph, 'Locked', page, 0, 0, 100, 100)
graph.updateNode(locked.id, { locked: true })
rect(graph, 'A', page, 50, 50, 100, 100)
const result = computeOverlaps(graph, { include_locked: true })
expect(
result.overlaps.some((o) => o.nodeA.name === 'Locked' || o.nodeB.name === 'Locked')
).toBe(true)
})
test('excludes absolutely-positioned nodes by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const absolute = rect(graph, 'Absolute', page, 0, 0, 100, 100)
graph.updateNode(absolute.id, { layoutPositioning: 'ABSOLUTE' })
rect(graph, 'A', page, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(
result.overlaps.some((o) => o.nodeA.name === 'Absolute' || o.nodeB.name === 'Absolute')
).toBe(false)
})
test('includes absolutely-positioned nodes when include_absolute is true', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const absolute = rect(graph, 'Absolute', page, 0, 0, 100, 100)
graph.updateNode(absolute.id, { layoutPositioning: 'ABSOLUTE' })
rect(graph, 'A', page, 50, 50, 100, 100)
const result = computeOverlaps(graph, { include_absolute: true })
expect(
result.overlaps.some((o) => o.nodeA.name === 'Absolute' || o.nodeB.name === 'Absolute')
).toBe(true)
})
test('filters results by page name', () => {
const graph = new SceneGraph()
const page1 = pageId(graph)
const page2 = graph.addPage('Page 2')
const a1 = rect(graph, 'A1', page1, 0, 0, 100, 100)
const a2 = rect(graph, 'A2', page1, 50, 50, 100, 100)
const b1 = rect(graph, 'B1', page2.id, 0, 0, 100, 100)
const b2 = rect(graph, 'B2', page2.id, 50, 50, 100, 100)
const page1Result = computeOverlaps(graph, { page: 'Page 1' })
expect(page1Result.overlaps.length).toBeGreaterThan(0)
expect(
page1Result.overlaps.every(
(o) =>
(o.nodeA.id === a1.id || o.nodeA.id === a2.id) &&
(o.nodeB.id === a1.id || o.nodeB.id === a2.id)
)
).toBe(true)
const page2Result = computeOverlaps(graph, { page: 'Page 2' })
expect(page2Result.overlaps.length).toBeGreaterThan(0)
expect(
page2Result.overlaps.every(
(o) =>
(o.nodeA.id === b1.id || o.nodeA.id === b2.id) &&
(o.nodeB.id === b1.id || o.nodeB.id === b2.id)
)
).toBe(true)
const missing = computeOverlaps(graph, { page: 'Missing' })
expect(missing.overlaps).toHaveLength(0)
})
test('scope values are case-insensitive', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
rect(graph, 'B', page, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'SAME-PARENT' as OverlapScope })
expect(result.overlaps.length).toBeGreaterThan(0)
})
test('ToolDef defaults to current page and does not report overlaps on other pages', () => {
const graph = new SceneGraph()
const page2 = graph.addPage('Page 2')
rect(graph, 'A', page2.id, 0, 0, 100, 100)
rect(graph, 'B', page2.id, 50, 50, 100, 100)
const api = new FigmaAPI(graph)
const result = analyzeOverlaps.execute(api, {})
expect(result.summary.overlapCount).toBe(0)
expect(result.overlaps).toHaveLength(0)
})
test('ToolDef respects explicit page argument', () => {
const graph = new SceneGraph()
const page2 = graph.addPage('Page 2')
rect(graph, 'A', page2.id, 0, 0, 100, 100)
rect(graph, 'B', page2.id, 50, 50, 100, 100)
const api = new FigmaAPI(graph)
const result = analyzeOverlaps.execute(api, { page: 'Page 2' })
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('scope=top-level excludes nested parent-overflow', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 80, 80)
text(graph, 'Wide', parent.id, 0, 0, 200, 20)
const all = computeOverlaps(graph)
expect(all.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true)
const topLevel = computeOverlaps(graph, { scope: 'top-level' })
expect(topLevel.overlaps.some((o) => o.category === 'parent-overflow')).toBe(false)
})
test('scope=inside-parent includes nested parent-overflow', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 80, 80)
text(graph, 'Wide', parent.id, 0, 0, 200, 20)
const result = computeOverlaps(graph, { scope: 'inside-parent' })
expect(result.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true)
})
test('skips nodes inside a hidden ancestor by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const hiddenParent = frame(graph, 'HiddenParent', page, 0, 0, 200, 200)
graph.updateNode(hiddenParent.id, { visible: false })
rect(graph, 'A', hiddenParent.id, 0, 0, 100, 100)
rect(graph, 'B', hiddenParent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('includes nodes inside a hidden ancestor when include_hidden is true', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const hiddenParent = frame(graph, 'HiddenParent', page, 0, 0, 200, 200)
graph.updateNode(hiddenParent.id, { visible: false })
rect(graph, 'A', hiddenParent.id, 0, 0, 100, 100)
rect(graph, 'B', hiddenParent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { include_hidden: true })
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('skips nodes inside a locked ancestor by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const lockedParent = frame(graph, 'LockedParent', page, 0, 0, 200, 200)
graph.updateNode(lockedParent.id, { locked: true })
rect(graph, 'A', lockedParent.id, 0, 0, 100, 100)
rect(graph, 'B', lockedParent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('summary totals reflect full result set when a limit is applied', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 200, 200)
for (let i = 0; i < 4; i++) {
rect(graph, `R${i}`, parent.id, 0, 0, 100, 100)
}
const result = computeOverlaps(graph, { scope: 'inside-parent', limit: 2 })
expect(result.overlaps.length).toBe(2)
expect(result.summary.overlapCount).toBeGreaterThan(2)
const totalByCategory = Object.values(result.summary.byCategory).reduce((a, b) => a + b, 0)
expect(totalByCategory).toBe(result.summary.overlapCount)
const totalBySeverity = Object.values(result.summary.bySeverity).reduce((a, b) => a + b, 0)
expect(totalBySeverity).toBe(result.summary.overlapCount)
})
})

View file

@ -0,0 +1,90 @@
import { describe, expect, test } from 'bun:test'
import { FigmaAPI, SceneGraph } from '@open-pencil/core'
import {
analyzeOverlaps,
computeOverlaps,
findPageId
} from '@open-pencil/core/tools/analyze/overlaps'
import { pageId, rect } from './helpers'
describe('analyze overlaps page scope', () => {
test('default scope limits analysis to a single page', () => {
const graph = new SceneGraph()
const page1 = pageId(graph)
const page2 = graph.addPage('Page 2')
rect(graph, 'A', page1, 0, 0, 100, 100)
rect(graph, 'B', page1, 50, 50, 100, 100)
rect(graph, 'C', page2.id, 0, 0, 100, 100)
rect(graph, 'D', page2.id, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(1)
expect(result.overlaps).toHaveLength(1)
expect(
result.overlaps.every((o) => {
const nodeA = graph.getNode(o.nodeA.id)
const nodeB = graph.getNode(o.nodeB.id)
if (!nodeA || !nodeB) return false
return findPageId(graph, nodeA) === page1 && findPageId(graph, nodeB) === page1
})
).toBe(true)
})
test('missing page name returns an empty result', () => {
const graph = new SceneGraph()
const page1 = pageId(graph)
const page2 = graph.addPage('Page 2')
rect(graph, 'A', page1, 0, 0, 100, 100)
rect(graph, 'B', page1, 50, 50, 100, 100)
rect(graph, 'C', page2.id, 0, 0, 100, 100)
rect(graph, 'D', page2.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { page: 'Missing' })
expect(result.summary.totalNodes).toBe(0)
expect(result.summary.analyzedNodes).toBe(0)
expect(result.summary.overlapCount).toBe(0)
expect(result.overlaps).toHaveLength(0)
})
test('ToolDef currentPage default uses page ID so duplicate page names do not leak', () => {
const graph = new SceneGraph()
const page1 = pageId(graph)
const page2 = graph.addPage('Page 1')
rect(graph, 'A', page2.id, 0, 0, 100, 100)
rect(graph, 'B', page2.id, 50, 50, 100, 100)
const api = new FigmaAPI(graph)
expect(api.currentPageId).toBe(page1)
const defaultResult = analyzeOverlaps.execute(api, {})
expect(defaultResult.summary.overlapCount).toBe(0)
expect(defaultResult.overlaps).toHaveLength(0)
api.currentPage = api.wrapNode(page2.id)
const currentResult = analyzeOverlaps.execute(api, {})
expect(currentResult.summary.overlapCount).toBeGreaterThan(0)
})
test('page_id argument disambiguates duplicate page names', () => {
const graph = new SceneGraph()
const page1 = pageId(graph)
const page2 = graph.addPage('Page 1')
rect(graph, 'A', page2.id, 0, 0, 100, 100)
rect(graph, 'B', page2.id, 50, 50, 100, 100)
const byId = computeOverlaps(graph, { page_id: page2.id })
expect(byId.summary.overlapCount).toBe(1)
const byName = computeOverlaps(graph, { page: 'Page 1' })
expect(byName.summary.overlapCount).toBe(0)
const byFirstPage = computeOverlaps(graph, { page_id: page1 })
expect(byFirstPage.summary.overlapCount).toBe(0)
})
})

View file

@ -1,3 +1,4 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
const repoRoot = join(import.meta.dir, '..', '..')
@ -21,3 +22,12 @@ export function testPath(...segments: string[]): string {
export function publicPath(...segments: string[]): string {
return repoPath('public', ...segments)
}
export function requireBuiltWorkspacePackages(): void {
const coreDist = repoPath('packages/core/dist/index.js')
if (!existsSync(coreDist)) {
throw new Error(
'CLI integration tests require built workspace packages. Run `bun run check` or `bun run --filter @open-pencil/core build` first.'
)
}
}