From aec72371f7fe5b6d00196713b3e64c3c5780b0a2 Mon Sep 17 00:00:00 2001 From: xuanru Date: Thu, 23 Jul 2026 09:43:16 +0000 Subject: [PATCH 1/6] feat(lint): add connector_motion_detached layout check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects a connector (SVG /) that stays anchored to a node at one endpoint while its other endpoint sits in empty space across the held frames — the half-attached dangle the per-frame connector_detached check deliberately ignores. Catches spokes/edges frozen at build or rotated about a wrong pivot while their target kept moving. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/check.test.ts | 1 + .../cli/src/commands/layout-audit.browser.js | 89 ++++++ packages/cli/src/utils/checkBrowser.ts | 43 +++ ...ckPipeline.connectorMotionDetached.test.ts | 89 ++++++ packages/cli/src/utils/checkPipeline.ts | 254 ++++++++++++++++++ packages/cli/src/utils/checkTypes.ts | 30 +++ packages/cli/src/utils/layoutAudit.ts | 3 + 7 files changed, 509 insertions(+) create mode 100644 packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 8d9a07f97e..31a1058068 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -153,6 +153,7 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), collectRotationSample: vi.fn(async (_time: number) => []), collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })), + collectConnectorSample: vi.fn(async (time: number) => ({ time, connectors: [], nodes: [] })), collectGeometryCandidates: vi.fn(async () => []), collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })), anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) => diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 382184309a..7ca6670244 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1734,4 +1734,93 @@ } return samples; }; + + // connector_motion_detached sampling. Per seeked frame, report every diagram + // connector's two screen-space endpoints AND every plausible node/box bbox. + // Node accumulates these across the grid and flags an endpoint that stays + // anchored to a node while the other endpoint sits in empty space on the held + // frames — a connector whose coordinates were frozen (wrong rotation pivot, or + // measured once at build) while its target kept moving. Icon-sized SVGs and + // short strokes are filtered out so only real diagram connectors count. + const CONNECTOR_MIN_SVG_PX = 100; + const CONNECTOR_MIN_LEN_PX = 60; + const CONNECTOR_NODE_MIN_AREA = 400; + const CONNECTOR_NODE_CAP = 300; + + function lineScreenEndpoints(svg, line) { + if (typeof line.getScreenCTM !== "function" || typeof svg.createSVGPoint !== "function") { + return null; + } + const matrix = line.getScreenCTM(); + if (!matrix) return null; + const map = (x, y) => { + const point = svg.createSVGPoint(); + point.x = x; + point.y = y; + const mapped = point.matrixTransform(matrix); + return { x: mapped.x, y: mapped.y }; + }; + return { + start: map(line.x1.baseVal.value, line.y1.baseVal.value), + end: map(line.x2.baseVal.value, line.y2.baseVal.value), + }; + } + + // Node/box candidates a connector could anchor to: sized, opaque or text- + // bearing, non-SVG elements that are not a full-canvas layer. + function connectorNodeBoxes(root, rootRect) { + const boxes = []; + const rootArea = rectArea(rootRect); + for (const element of Array.from(root.querySelectorAll("*"))) { + if (boxes.length >= CONNECTOR_NODE_CAP) break; + if (element.closest("svg") || !isVisibleElement(element, 0.05)) continue; + const opaque = + RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(getComputedStyle(element)); + if (!opaque && !textContentFor(element)) continue; + const rect = toRect(element.getBoundingClientRect()); + const area = rectArea(rect); + if (area < CONNECTOR_NODE_MIN_AREA || area >= rootArea * 0.5) continue; + boxes.push({ + selector: selectorFor(element), + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }); + } + return boxes; + } + + window.__hyperframesConnectorSample = function collectConnectorSample() { + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const rootRect = rootRectFor(root); + const connectors = []; + for (const svg of Array.from(root.querySelectorAll("svg"))) { + if (!isVisibleElement(svg, 0.05) || hasAllowOverflowFlag(svg)) continue; + const svgRect = svg.getBoundingClientRect(); + if (svgRect.width < CONNECTOR_MIN_SVG_PX || svgRect.height < CONNECTOR_MIN_SVG_PX) continue; + for (const line of Array.from(svg.querySelectorAll("line, path"))) { + if (line.closest(CONNECTOR_SKIP_CONTAINERS)) continue; + if (!isVisibleElement(line, 0.05)) continue; + const ends = + line.tagName.toLowerCase() === "line" + ? lineScreenEndpoints(svg, line) + : pathScreenEndpoints(svg, line); + if (!ends) continue; + const len = Math.hypot(ends.end.x - ends.start.x, ends.end.y - ends.start.y); + if (len < CONNECTOR_MIN_LEN_PX) continue; + connectors.push({ + selector: selectorFor(line), + ax: round(ends.start.x), + ay: round(ends.start.y), + bx: round(ends.end.x), + by: round(ends.end.y), + }); + } + } + return { connectors, nodes: connectorNodeBoxes(root, rootRect) }; + }; })(); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index c47cced9ca..e3d8599713 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -39,6 +39,9 @@ import type { CheckGeometryCandidate, CheckOptions, CheckSeverity, + ConnectorFrame, + ConnectorLineSample, + ConnectorNodeBox, ContrastAuditEntry, ContrastCapture, GeometryCandidateRequest, @@ -358,6 +361,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud collectLayoutGeometry: () => collectLayoutGeometry(page), collectRotationSample: (time) => collectRotationSample(page, time), collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time), + collectConnectorSample: (time) => collectConnectorSample(page, time), collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request), collectMotionFrame: (time, selectors, scopes) => collectMotionFrame(page, time, selectors, scopes), @@ -568,6 +572,44 @@ function parseOffPivotRotationSample(value: unknown): OffPivotRotationSample[] { ]; } +async function collectConnectorSample(page: Page, time: number): Promise { + const raw = await page.evaluate(() => { + const sample = Reflect.get(window, "__hyperframesConnectorSample"); + if (typeof sample !== "function") return null; + return Reflect.apply(sample, window, []); + }); + if (!isRecord(raw)) return { time, connectors: [], nodes: [] }; + const connectors = Array.isArray(raw.connectors) ? raw.connectors : []; + const nodes = Array.isArray(raw.nodes) ? raw.nodes : []; + return { + time, + connectors: connectors.flatMap(parseConnectorLine), + nodes: nodes.flatMap(parseConnectorNode), + }; +} + +function parseConnectorLine(value: unknown): ConnectorLineSample[] { + if (!isRecord(value)) return []; + const selector = stringValue(value, "selector"); + const ax = numberValue(value, "ax"); + const ay = numberValue(value, "ay"); + const bx = numberValue(value, "bx"); + const by = numberValue(value, "by"); + if (!selector || ax === null || ay === null || bx === null || by === null) return []; + return [{ selector, ax, ay, bx, by }]; +} + +function parseConnectorNode(value: unknown): ConnectorNodeBox[] { + if (!isRecord(value)) return []; + const selector = stringValue(value, "selector"); + const left = numberValue(value, "left"); + const top = numberValue(value, "top"); + const right = numberValue(value, "right"); + const bottom = numberValue(value, "bottom"); + if (!selector || left === null || top === null || right === null || bottom === null) return []; + return [{ selector, left, top, right, bottom }]; +} + async function collectGeometryCandidates( page: Page, time: number, @@ -1151,6 +1193,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ "escaped_container", "panel_out_of_canvas", "connector_detached", + "connector_motion_detached", "rotation_pivot_drift", "off_pivot_rotation", "motion_appears_late", diff --git a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts new file mode 100644 index 0000000000..950cd61425 --- /dev/null +++ b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { detectConnectorMotionDetached } from "./checkPipeline.js"; +import type { ConnectorFrame, ConnectorLineSample, ConnectorNodeBox } from "./checkTypes.js"; + +const CANVAS = { width: 1000, height: 1000 }; +// Two nodes: a hub near the centre and a satellite the connector should reach. +const HUB: ConnectorNodeBox = { selector: "#hub", left: 480, top: 480, right: 520, bottom: 520 }; +const SATELLITE: ConnectorNodeBox = { + selector: "#sat", + left: 780, + top: 480, + right: 860, + bottom: 520, +}; + +function frame( + time: number, + connector: ConnectorLineSample, + nodes: ConnectorNodeBox[], +): ConnectorFrame { + return { time, connectors: [connector], nodes }; +} + +/** A dangling connector: end A stays on the hub, end B sits ~180px from every + * node across all held frames — the half-attached signature (fuzz011). */ +function danglingFrames(): ConnectorFrame[] { + return [0, 2, 4, 6, 8].map((time) => + frame(time, { selector: "#spoke", ax: 500, ay: 500, bx: 640, by: 500 }, [HUB, SATELLITE]), + ); +} + +describe("detectConnectorMotionDetached", () => { + it("fires when one endpoint is anchored and the other dangles in empty space", () => { + const findings = detectConnectorMotionDetached(danglingFrames(), CANVAS); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f?.code).toBe("connector_motion_detached"); + expect(f?.severity).toBe("warning"); + expect(f?.selector).toBe("#spoke"); + expect(f?.message).toContain("empty space"); + }); + + it("stays quiet when both endpoints reach a node", () => { + const frames = [0, 2, 4, 6, 8].map((time) => + frame(time, { selector: "#spoke", ax: 500, ay: 500, bx: 820, by: 500 }, [HUB, SATELLITE]), + ); + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0); + }); + + it("stays quiet when the loose end is only mildly short of a node", () => { + // End B sits 40px from the satellite — under the 80px detach floor. + const frames = [0, 2, 4, 6, 8].map((time) => + frame(time, { selector: "#spoke", ax: 500, ay: 500, bx: 740, by: 500 }, [HUB, SATELLITE]), + ); + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0); + }); + + it("stays quiet when BOTH endpoints float free (no anchor)", () => { + const frames = [0, 2, 4, 6, 8].map((time) => + frame(time, { selector: "#spoke", ax: 100, ay: 100, bx: 300, by: 100 }, [HUB, SATELLITE]), + ); + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0); + }); + + it("only considers held frames — an entrance-only detachment does not fire", () => { + // Detached early (t<0.45*dur=3.6) then anchored on every held frame. + const frames: ConnectorFrame[] = [ + frame(0, { selector: "#spoke", ax: 500, ay: 500, bx: 640, by: 500 }, [HUB, SATELLITE]), + frame(2, { selector: "#spoke", ax: 500, ay: 500, bx: 640, by: 500 }, [HUB, SATELLITE]), + frame(4, { selector: "#spoke", ax: 500, ay: 500, bx: 820, by: 500 }, [HUB, SATELLITE]), + frame(6, { selector: "#spoke", ax: 500, ay: 500, bx: 820, by: 500 }, [HUB, SATELLITE]), + frame(8, { selector: "#spoke", ax: 500, ay: 500, bx: 820, by: 500 }, [HUB, SATELLITE]), + ]; + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0); + }); + + it("does not fire with fewer than the minimum frames", () => { + expect(detectConnectorMotionDetached(danglingFrames().slice(0, 3), CANVAS)).toHaveLength(0); + }); + + it("drops a selector that aliases multiple connectors in one frame", () => { + const frames: ConnectorFrame[] = danglingFrames().map((f) => ({ + ...f, + connectors: [...f.connectors, { selector: "#spoke", ax: 500, ay: 500, bx: 820, by: 500 }], + })); + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index a02fb2fa5b..96f0eb464c 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -45,6 +45,8 @@ import type { CheckScreenshot, CheckSection, CheckSeverity, + ConnectorFrame, + ConnectorNodeBox, ContrastAuditEntry, GeometryCandidateRequest, MotionSpecResolution, @@ -199,6 +201,9 @@ interface GridSamples { * material-point geometry + dial hub per layout sample; flattened by selector * to detect off_pivot_rotation. */ indicatorFrames: OffPivotFrame[]; + /** Connector endpoints + node boxes at each layout sample; correlated after + * the run to detect connector_motion_detached. */ + connectorFrames: ConnectorFrame[]; } interface GeometrySeen { @@ -372,6 +377,7 @@ async function collectGridSamples( geometrySignatures: [], rotationSamples: [], indicatorFrames: [], + connectorFrames: [], }; for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) { await driver.seek(time); @@ -386,6 +392,7 @@ async function collectGridSamples( collected.geometrySignatures.push(await driver.collectLayoutGeometry()); collected.rotationSamples.push(...(await driver.collectRotationSample(time))); collected.indicatorFrames.push(await driver.collectOffPivotRotationSample(time)); + collected.connectorFrames.push(await driver.collectConnectorSample(time)); } if (canvas) { const geometryIssues = await collectGeometryAt( @@ -978,6 +985,248 @@ export function detectOffPivotRotation(frames: OffPivotFrame[]): AnchoredLayoutI return selectHubFindings(candidates); } +// connector_motion_detached thresholds. Corpus timings: entrances settle by +// ~2s of a 7-8s composition, so held/steady state starts well before half. +const CONNECTOR_MIN_FRAMES = 4; +// An endpoint within this of a node counts as anchored (well under a node's size). +const CONNECTOR_ATTACH_PX = 24; +// The dangling end must clear this to flag. Set high, and measured against a +// dense node-candidate set, so only an endpoint sitting in genuinely empty space +// fires — a slow drift that still lands near a box, or a snug residual gap, does +// not. Corpus: fuzz011's detached spokes dangle ~185-190px from every node; +// legitimate lead-lines to a nearby label stay well under this. +const CONNECTOR_DETACH_FLOOR_PX = 80; +const CONNECTOR_DETACH_VIEWPORT_FRACTION = 0.06; +// Held/steady frames begin at this fraction of the timeline (past entrances). +const CONNECTOR_HELD_START_FRAC = 0.45; +const CONNECTOR_MIN_HELD_FRAMES = 2; +// Fraction of held frames on which the loose end must be detached to flag. +const CONNECTOR_HELD_DETACH_FRAC = 0.8; + +interface ConnectorObservation { + time: number; + ax: number; + ay: number; + bx: number; + by: number; + nodes: ConnectorNodeBox[]; +} + +function pointToNodeGap(x: number, y: number, node: ConnectorNodeBox): number { + const dx = Math.max(node.left - x, 0, x - node.right); + const dy = Math.max(node.top - y, 0, y - node.bottom); + if (dx > 0 || dy > 0) return Math.hypot(dx, dy); + // Inside the bbox: a solid node anchors anywhere; a hollow ring only near its + // stroke, so measure distance to the bbox perimeter (its hole is not attached). + if (!node.ring) return 0; + return Math.min(x - node.left, node.right - x, y - node.top, node.bottom - y); +} + +function nearestNodeGap(x: number, y: number, nodes: ConnectorNodeBox[]): number { + let min = Number.POSITIVE_INFINITY; + for (const node of nodes) min = Math.min(min, pointToNodeGap(x, y, node)); + return min; +} + +/** Group observations by connector selector, dropping any selector that maps to + * more than one connector in a single frame — those endpoints can't be tracked + * across seeks without aliasing (the false-association risk). */ +function groupConnectorsBySelector(frames: ConnectorFrame[]): Map { + const bySelector = new Map(); + const ambiguous = new Set(); + for (const frame of frames) { + const seenThisFrame = new Set(); + for (const connector of frame.connectors) { + if (seenThisFrame.has(connector.selector)) ambiguous.add(connector.selector); + seenThisFrame.add(connector.selector); + const observation: ConnectorObservation = { + time: frame.time, + ax: connector.ax, + ay: connector.ay, + bx: connector.bx, + by: connector.by, + nodes: frame.nodes, + }; + const group = bySelector.get(connector.selector); + if (group) group.push(observation); + else bySelector.set(connector.selector, [observation]); + } + } + for (const selector of ambiguous) bySelector.delete(selector); + return bySelector; +} + +interface EndpointHeldGaps { + minGap: number; + detachedFraction: number; + danglingGap: number; +} + +/** Per-endpoint held-frame gap summary: closest it ever gets to a node, the + * fraction of held frames it sits beyond the detach threshold, and the gap it + * settles at when detached. */ +function endpointHeldGaps( + group: ConnectorObservation[], + pick: (o: ConnectorObservation) => { x: number; y: number }, + holdStart: number, + detachThreshold: number, +): EndpointHeldGaps | null { + const heldGaps: number[] = []; + for (const observation of group) { + if (observation.nodes.length === 0 || observation.time < holdStart) continue; + const point = pick(observation); + heldGaps.push(nearestNodeGap(point.x, point.y, observation.nodes)); + } + if (heldGaps.length < CONNECTOR_MIN_HELD_FRAMES) return null; + const detached = heldGaps.filter((gap) => gap > detachThreshold); + return { + minGap: Math.min(...heldGaps), + detachedFraction: detached.length / heldGaps.length, + danglingGap: detached.length > 0 ? Math.min(...detached) : 0, + }; +} + +function isAnchored(gaps: EndpointHeldGaps): boolean { + return gaps.minGap <= CONNECTOR_ATTACH_PX; +} + +function isDangling(gaps: EndpointHeldGaps): boolean { + return gaps.detachedFraction >= CONNECTOR_HELD_DETACH_FRAC; +} + +// Gauge-indicator geometry: the anchored end pivots near a ring/arc centre and +// the loose end extends radially outward. That is a needle/pointer (owned by a +// separate gauge check), not a broken node connector. A defective radial +// connector points the other way — anchored on a peripheral node, loose end +// drifting toward the centre — so this only excludes true centre-pivot pointers. +const GAUGE_HUB_CENTRE_FRACTION = 0.25; + +function isGaugeIndicator( + anchored: { x: number; y: number }, + dangling: { x: number; y: number }, + nodes: ConnectorNodeBox[], +): boolean { + for (const node of nodes) { + if (!node.ring) continue; + const cx = (node.left + node.right) / 2; + const cy = (node.top + node.bottom) / 2; + const size = Math.max(node.right - node.left, node.bottom - node.top); + const dAnchored = Math.hypot(anchored.x - cx, anchored.y - cy); + const dDangling = Math.hypot(dangling.x - cx, dangling.y - cy); + if (dAnchored <= GAUGE_HUB_CENTRE_FRACTION * size && dDangling > dAnchored) return true; + } + return false; +} + +function connectorDetachFinding( + selector: string, + point: { x: number; y: number }, + gap: number, + time: number, +): AnchoredLayoutIssue { + const rect: LayoutRect = { + left: point.x - 4, + top: point.y - 4, + right: point.x + 4, + bottom: point.y + 4, + width: 8, + height: 8, + }; + return { + code: "connector_motion_detached", + severity: "warning", + time, + selector, + dataAttributes: {}, + sourceFile: "index.html", + bbox: rectToBbox(rect), + rect, + message: `Connector has one endpoint on a node but the other sits ${Math.round(gap)}px from every node across the held frames — it points into empty space (a fixed endpoint while its target moved: wrong rotation pivot, or a path measured once at build).`, + fixHint: + "Bind BOTH endpoints to their nodes for the whole timeline: rotate the connector inside the same group as its nodes (about the shared centre), or recompute its path when node positions change instead of measuring once at build.", + }; +} + +/** + * connector_motion_detached: a connector (SVG /) that stays anchored + * to a node at ONE endpoint while its OTHER endpoint sits in empty space — far + * from every node — across the held (steady) frames. This is the sibling of the + * per-frame `connector_detached` check, which requires BOTH endpoints far from + * anchors on a single frame and so deliberately ignores the half-attached case. + * But that half-attached case is the dominant real failure under motion: a + * spoke/edge whose one end stays pinned (e.g. at a hub) while the other drifts + * off a node that kept moving — rotated about a wrong pivot, or with a path + * measured once at build then never updated as the target animated. + * + * FP-guarded, deliberately strict: one endpoint must be genuinely anchored + * (structural, not a name match); the loose end must clear a high detach + * threshold on ~all held frames (measured against a dense node-candidate set, so + * only an endpoint in truly empty space fires — snug residual gaps and lead + * lines to a nearby label do not); and a selector that aliases multiple + * connectors in one frame is dropped. + */ +interface DanglePick { + anchored: { x: number; y: number }; + dangling: { x: number; y: number }; + gap: number; +} + +/** If exactly one endpoint is anchored and the other persistently dangles, name + * the anchored/dangling points and the dangling gap; else null. */ +function danglingEndpoint( + last: ConnectorObservation, + gapsA: EndpointHeldGaps, + gapsB: EndpointHeldGaps, +): DanglePick | null { + const a = { x: last.ax, y: last.ay }; + const b = { x: last.bx, y: last.by }; + if (isAnchored(gapsA) && isDangling(gapsB) && !isAnchored(gapsB)) { + return { anchored: a, dangling: b, gap: gapsB.danglingGap }; + } + if (isAnchored(gapsB) && isDangling(gapsA) && !isAnchored(gapsA)) { + return { anchored: b, dangling: a, gap: gapsA.danglingGap }; + } + return null; +} + +/** One connector's held trajectory → a finding, or null. A finding needs one + * endpoint anchored to a node and the other persistently in empty space, and is + * suppressed for gauge-indicator geometry (see isGaugeIndicator). */ +function connectorGroupFinding( + selector: string, + group: ConnectorObservation[], + holdStart: number, + detachThreshold: number, +): AnchoredLayoutIssue | null { + const last = group.length >= CONNECTOR_MIN_FRAMES ? group[group.length - 1] : undefined; + if (!last) return null; + const gapsA = endpointHeldGaps(group, (o) => ({ x: o.ax, y: o.ay }), holdStart, detachThreshold); + const gapsB = endpointHeldGaps(group, (o) => ({ x: o.bx, y: o.by }), holdStart, detachThreshold); + if (!gapsA || !gapsB) return null; + const pick = danglingEndpoint(last, gapsA, gapsB); + if (!pick || isGaugeIndicator(pick.anchored, pick.dangling, last.nodes)) return null; + return connectorDetachFinding(selector, pick.dangling, pick.gap, last.time); +} + +export function detectConnectorMotionDetached( + frames: ConnectorFrame[], + canvas: Canvas, +): AnchoredLayoutIssue[] { + const findings: AnchoredLayoutIssue[] = []; + const duration = frames.reduce((max, frame) => Math.max(max, frame.time), 0); + if (duration <= 0) return findings; + const holdStart = CONNECTOR_HELD_START_FRAC * duration; + const detachThreshold = Math.max( + CONNECTOR_DETACH_FLOOR_PX, + CONNECTOR_DETACH_VIEWPORT_FRACTION * Math.min(canvas.width, canvas.height), + ); + for (const [selector, group] of groupConnectorsBySelector(frames)) { + const finding = connectorGroupFinding(selector, group, holdStart, detachThreshold); + if (finding) findings.push(finding); + } + return findings; +} + /** Error-severity findings with real geometry become labeled overview boxes. * Contrast failures are annotated separately by the driver itself, since * they're only known once contrast measurement for this sample completes. */ @@ -1018,6 +1267,10 @@ export async function runAuditGrid( await driver.getCanvas(), ); const offPivotFindings = detectOffPivotRotation(collected.indicatorFrames); + const connectorFindings = detectConnectorMotionDetached( + collected.connectorFrames, + await driver.getCanvas(), + ); const contrast = buildContrastResults(collected.contrastEntries); return { duration: grid.duration, @@ -1030,6 +1283,7 @@ export async function runAuditGrid( ...sweepFindings, ...rotationFindings, ...offPivotFindings, + ...connectorFindings, ], motionIssues, motionSampleCount: collected.motionFrames.length, diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index c334787e37..3b6dab9d0b 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -161,6 +161,33 @@ export interface OffPivotFrame { samples: OffPivotRotationSample[]; } +/** One diagram connector's two screen endpoints at a single seeked sample. */ +export interface ConnectorLineSample { + selector: string; + ax: number; + ay: number; + bx: number; + by: number; +} + +/** Screen bbox of one plausible node/box a connector could anchor to. */ +export interface ConnectorNodeBox { + selector: string; + left: number; + top: number; + right: number; + bottom: number; +} + +/** All connectors + node boxes at one seeked sample, produced by + * `__hyperframesConnectorSample` and accumulated across the grid to detect + * `connector_motion_detached`. */ +export interface ConnectorFrame { + time: number; + connectors: ConnectorLineSample[]; + nodes: ConnectorNodeBox[]; +} + export type MotionSpecResolution = | { kind: "none" } | { kind: "valid"; path: string; spec: MotionSpec } @@ -189,6 +216,9 @@ export interface CheckAuditDriver { * geometry + resolved dial hub at the current seeked state, as a time-hoisted * frame envelope. */ collectOffPivotRotationSample(time: number): Promise; + /** connector_motion_detached: every connector's endpoints + every node bbox + * at the current seeked state. Accumulated across the grid — see checkPipeline. */ + collectConnectorSample(time: number): Promise; collectGeometryCandidates( time: number, request: GeometryCandidateRequest, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 5f68cb7007..24e2e8287f 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -23,6 +23,9 @@ export type LayoutIssueCode = | "escaped_container" | "panel_out_of_canvas" | "connector_detached" + // Cross-sample connector finding — one endpoint anchored to a node while the + // other dangles in empty space on held frames (frozen coords while target moved). + | "connector_motion_detached" // Cross-sample rotation finding — a spinning element whose bbox center drifts // because it pivots about the wrong point (bad transformOrigin/svgOrigin). | "rotation_pivot_drift" From fe66d5071c8385e4a86a2011fe698f4aaacdb21f Mon Sep 17 00:00:00 2001 From: xuanru Date: Thu, 23 Jul 2026 10:13:50 +0000 Subject: [PATCH 2/6] fix(lint): connector_motion_detached anchors to SVG hub/ring shapes Adds SVG circle/ellipse/rect to the node-candidate set so a connector terminating on an SVG-drawn hub, ring, or dot marker counts as attached. Stroke-only shapes (fill:none rings) match by bbox perimeter, not hollow interior. Clears the leader-line-to-ring false positives (fuzz033/038/074) while the drifted-ring detachment (fuzz011) still fires. Co-Authored-By: Claude Opus 4.8 --- .../cli/src/commands/layout-audit.browser.js | 23 +++++++++++- packages/cli/src/utils/checkBrowser.ts | 2 +- ...ckPipeline.connectorMotionDetached.test.ts | 36 ++++++++++++++++++- packages/cli/src/utils/checkTypes.ts | 4 ++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 7ca6670244..9962121e54 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1745,6 +1745,8 @@ const CONNECTOR_MIN_SVG_PX = 100; const CONNECTOR_MIN_LEN_PX = 60; const CONNECTOR_NODE_MIN_AREA = 400; + // SVG dots/markers are small; keep the floor low but above sub-pixel decoration. + const CONNECTOR_NODE_MIN_DOT_AREA = 16; const CONNECTOR_NODE_CAP = 300; function lineScreenEndpoints(svg, line) { @@ -1767,7 +1769,9 @@ } // Node/box candidates a connector could anchor to: sized, opaque or text- - // bearing, non-SVG elements that are not a full-canvas layer. + // bearing HTML elements plus SVG hub/ring/dot shapes. A shape drawn stroke-only + // (fill:none) is a ring — the connector attaches to its stroke, so it is marked + // `ring` and matched by perimeter, not hollow interior (see pointToNodeGap). function connectorNodeBoxes(root, rootRect) { const boxes = []; const rootArea = rectArea(rootRect); @@ -1786,6 +1790,23 @@ top: rect.top, right: rect.right, bottom: rect.bottom, + ring: false, + }); + } + for (const shape of Array.from(root.querySelectorAll("circle, ellipse, rect"))) { + if (boxes.length >= CONNECTOR_NODE_CAP) break; + if (!isVisibleElement(shape, 0.05)) continue; + const rect = toRect(shape.getBoundingClientRect()); + const area = rectArea(rect); + if (area < CONNECTOR_NODE_MIN_DOT_AREA || area >= rootArea * 0.5) continue; + const fill = getComputedStyle(shape).fill; + boxes.push({ + selector: selectorFor(shape), + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + ring: fill === "none" || fill === "transparent" || shape.getAttribute("fill") === "none", }); } return boxes; diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index e3d8599713..a2539fc712 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -607,7 +607,7 @@ function parseConnectorNode(value: unknown): ConnectorNodeBox[] { const right = numberValue(value, "right"); const bottom = numberValue(value, "bottom"); if (!selector || left === null || top === null || right === null || bottom === null) return []; - return [{ selector, left, top, right, bottom }]; + return [{ selector, left, top, right, bottom, ring: value.ring === true }]; } async function collectGeometryCandidates( diff --git a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts index 950cd61425..796e0fdf6d 100644 --- a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts +++ b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts @@ -5,13 +5,21 @@ import type { ConnectorFrame, ConnectorLineSample, ConnectorNodeBox } from "./ch const CANVAS = { width: 1000, height: 1000 }; // Two nodes: a hub near the centre and a satellite the connector should reach. -const HUB: ConnectorNodeBox = { selector: "#hub", left: 480, top: 480, right: 520, bottom: 520 }; +const HUB: ConnectorNodeBox = { + selector: "#hub", + left: 480, + top: 480, + right: 520, + bottom: 520, + ring: false, +}; const SATELLITE: ConnectorNodeBox = { selector: "#sat", left: 780, top: 480, right: 860, bottom: 520, + ring: false, }; function frame( @@ -79,6 +87,32 @@ describe("detectConnectorMotionDetached", () => { expect(detectConnectorMotionDetached(danglingFrames().slice(0, 3), CANVAS)).toHaveLength(0); }); + // Hollow ring centred at 900,500; bbox left stroke is at x=700. + const RING: ConnectorNodeBox = { + selector: "#ring", + left: 700, + top: 300, + right: 1100, + bottom: 700, + ring: true, + }; + + it("treats a loose end landing on a ring's stroke as anchored (no fire)", () => { + // End B at 708,500 sits ~8px inside the ring's left stroke → anchored. + const frames = [0, 2, 4, 6, 8].map((time) => + frame(time, { selector: "#spoke", ax: 500, ay: 500, bx: 708, by: 500 }, [HUB, RING]), + ); + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0); + }); + + it("still fires when the loose end sits in a ring's hollow centre", () => { + // End B at the ring centre 900,500 is ~200px from its perimeter → dangling. + const frames = [0, 2, 4, 6, 8].map((time) => + frame(time, { selector: "#spoke", ax: 500, ay: 500, bx: 900, by: 500 }, [HUB, RING]), + ); + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(1); + }); + it("drops a selector that aliases multiple connectors in one frame", () => { const frames: ConnectorFrame[] = danglingFrames().map((f) => ({ ...f, diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 3b6dab9d0b..2efd1b98e3 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -170,13 +170,15 @@ export interface ConnectorLineSample { by: number; } -/** Screen bbox of one plausible node/box a connector could anchor to. */ +/** Screen bbox of one plausible node/box a connector could anchor to. `ring` + * marks a stroke-only SVG shape matched by perimeter, not hollow interior. */ export interface ConnectorNodeBox { selector: string; left: number; top: number; right: number; bottom: number; + ring: boolean; } /** All connectors + node boxes at one seeked sample, produced by From a3beeaf657c90410ccd55b815ff9e38fcaa9c4e6 Mon Sep 17 00:00:00 2001 From: xuanru Date: Thu, 23 Jul 2026 10:37:10 +0000 Subject: [PATCH 3/6] fix(lint): exclude gauge needles/pointers from connector_motion_detached A gauge needle/pointer/tick is a one-end-anchored indicator, not a node-to-node connector; a separate gauge check owns it. Skip a connector whose element or group ancestor id/class matches needle|pointer|gauge|tick| indicator, and skip gauge-indicator geometry (endpoint pivots near an SVG arc/hub centre with the loose end radially outward). A defective radial connector points the other way so it still fires. Clears the fuzz080 gauge false positive; fuzz011 detach still fires (0 FP across the 81-sample corpus). Co-Authored-By: Claude Opus 4.8 --- .../cli/src/commands/layout-audit.browser.js | 12 +++++ ...ckPipeline.connectorMotionDetached.test.ts | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 9962121e54..339c480c1b 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1744,11 +1744,22 @@ // short strokes are filtered out so only real diagram connectors count. const CONNECTOR_MIN_SVG_PX = 100; const CONNECTOR_MIN_LEN_PX = 60; + // Gauge needles/pointers/ticks are one-end-anchored indicators, not node-to-node + // connectors — a separate (gauge) check owns them. Skip by id/class of the line + // or any group ancestor up to the SVG. + const CONNECTOR_INDICATOR_NAME = /needle|pointer|gauge|tick|indicator/i; const CONNECTOR_NODE_MIN_AREA = 400; // SVG dots/markers are small; keep the floor low but above sub-pixel decoration. const CONNECTOR_NODE_MIN_DOT_AREA = 16; const CONNECTOR_NODE_CAP = 300; + function isIndicatorConnector(line, svg) { + for (let node = line; node && node !== svg.parentElement; node = node.parentElement) { + if (CONNECTOR_INDICATOR_NAME.test(connectorNameFor(node))) return true; + } + return false; + } + function lineScreenEndpoints(svg, line) { if (typeof line.getScreenCTM !== "function" || typeof svg.createSVGPoint !== "function") { return null; @@ -1826,6 +1837,7 @@ for (const line of Array.from(svg.querySelectorAll("line, path"))) { if (line.closest(CONNECTOR_SKIP_CONTAINERS)) continue; if (!isVisibleElement(line, 0.05)) continue; + if (isIndicatorConnector(line, svg)) continue; const ends = line.tagName.toLowerCase() === "line" ? lineScreenEndpoints(svg, line) diff --git a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts index 796e0fdf6d..f398465804 100644 --- a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts +++ b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts @@ -105,6 +105,56 @@ describe("detectConnectorMotionDetached", () => { expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0); }); + it("does not fire on a gauge needle: anchored at the arc centre, loose end radially outward", () => { + // Arc ring centred at 900,500; needle base on the hub near centre, tip out + // past the arc — a pointer, not a broken connector. + const hub: ConnectorNodeBox = { + selector: "#hub", + left: 890, + top: 490, + right: 910, + bottom: 510, + ring: false, + }; + const arc: ConnectorNodeBox = { + selector: "#arc", + left: 700, + top: 300, + right: 1100, + bottom: 700, + ring: true, + }; + const frames = [0, 2, 4, 6, 8].map((time) => + frame(time, { selector: "#needle", ax: 900, ay: 500, bx: 900, by: 180 }, [hub, arc]), + ); + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0); + }); + + it("still fires on a radial connector drifting toward the centre (not outward)", () => { + // Anchored on a peripheral node; loose end drifts inward to empty space near + // a ring centre — the fuzz011 shape, opposite of a gauge pointer. + const peripheral: ConnectorNodeBox = { + selector: "#panel", + left: 120, + top: 120, + right: 260, + bottom: 200, + ring: false, + }; + const arc: ConnectorNodeBox = { + selector: "#arc", + left: 700, + top: 300, + right: 1100, + bottom: 700, + ring: true, + }; + const frames = [0, 2, 4, 6, 8].map((time) => + frame(time, { selector: "#spoke", ax: 180, ay: 160, bx: 900, by: 500 }, [peripheral, arc]), + ); + expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(1); + }); + it("still fires when the loose end sits in a ring's hollow centre", () => { // End B at the ring centre 900,500 is ~200px from its perimeter → dangling. const frames = [0, 2, 4, 6, 8].map((time) => From cdbc8d2871d5d905f3f03f89758602db7a5d99ea Mon Sep 17 00:00:00 2001 From: xuanru Date: Fri, 24 Jul 2026 10:21:53 +0000 Subject: [PATCH 4/6] fix(lint): sustained attachment + path/ring hub nodes for connector_motion_detached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 blocker: "anchored" was defined as minGap (the single closest frame), so an endpoint that grazed a node once and then detached for the rest of the held window still read as anchored — letting a genuinely dangling partner escape the finding. Anchoring is now SUSTAINED: an endpoint counts as anchored only if it stays within attach tolerance on >= 80% of held frames (CONNECTOR_HELD_ATTACH_FRAC). A connector that touches once then detaches is flagged; a connector that stays attached but for a single graze off-node is not. Blocker-3 part 2 + round-2 gauge-exclusion gap: the node sampler only registered circle/ellipse/rect, so arc-drawn dials () never became ring nodes — a connector attached to a path ring false-fired, and the gauge-indicator geometry exclusion (which keys on ring nodes) couldn't see them. connectorNodeBoxes now recognizes near-circular stroke elements as ring nodes (Kåsa fit, residual-gated), flowing into both anchoring and isGaugeIndicator. Round-2: pathScreenEndpoints' getPointAtLength pair was unguarded; since this sampler now runs once per seeked frame, one malformed path could abort the whole check. Wrapped in try/catch so a bad path degrades to "skip this path". Tests: touch-once-then-detached now fires (verified red before the fix); the mirror single-graze-off-node case stays quiet. Existing ring-node/gauge pipeline coverage unchanged and still green. Co-Authored-By: Claude Opus 4.8 --- .../cli/src/commands/layout-audit.browser.js | 70 +++++++++++++++++-- ...ckPipeline.connectorMotionDetached.test.ts | 25 +++++++ packages/cli/src/utils/checkPipeline.ts | 19 +++-- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 339c480c1b..ead746581a 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1233,10 +1233,18 @@ const mapped = point.matrixTransform(matrix); return { x: mapped.x, y: mapped.y }; }; - return { - start: toScreen(path.getPointAtLength(0)), - end: toScreen(path.getPointAtLength(total)), - }; + // getPointAtLength can still throw on a degenerate/malformed path even after + // getTotalLength succeeded. This sampler runs once PER seeked frame, so one + // bad path must degrade to "no endpoints" (skip this path), not abort the + // whole check for every remaining path and frame. + try { + return { + start: toScreen(path.getPointAtLength(0)), + end: toScreen(path.getPointAtLength(total)), + }; + } catch { + return null; + } } function distanceToRect(point, rect) { @@ -1752,6 +1760,13 @@ // SVG dots/markers are small; keep the floor low but above sub-pixel decoration. const CONNECTOR_NODE_MIN_DOT_AREA = 16; const CONNECTOR_NODE_CAP = 300; + // A stroke-drawn dial/hub ring may be an arc (M...A...), not a . + // Recognize near-circular stroke paths as ring nodes so connectors attaching + // to them aren't false-flagged as detached, and so the gauge-indicator geometry + // exclusion (which keys on ring nodes) can see arc-drawn dials too. + const CONNECTOR_RING_MIN_LEN = 120; + const CONNECTOR_RING_MIN_RADIUS = 20; + const CONNECTOR_RING_MAX_RESIDUAL_FRAC = 0.15; function isIndicatorConnector(line, svg) { for (let node = line; node && node !== svg.parentElement; node = node.parentElement) { @@ -1783,6 +1798,46 @@ // bearing HTML elements plus SVG hub/ring/dot shapes. A shape drawn stroke-only // (fill:none) is a ring — the connector attaches to its stroke, so it is marked // `ring` and matched by perimeter, not hollow interior (see pointToNodeGap). + // A near-circular stroke read as a ring/hub node. Returns a node box + // (ring flag from fill) or null when the path is not a resolvable circular hub. + // getPointAtLength is guarded — one malformed path must not abort the sampler. + function ringPathBox(path, rootRect) { + if (typeof path.getTotalLength !== "function" || typeof path.getPointAtLength !== "function") { + return null; + } + let total; + try { + total = path.getTotalLength(); + } catch { + return null; + } + if (!Number.isFinite(total) || total < CONNECTOR_RING_MIN_LEN) return null; + const points = []; + try { + for (let i = 0; i < 16; i++) { + const local = path.getPointAtLength((total * i) / 16); + points.push({ x: local.x, y: local.y }); + } + } catch { + return null; + } + const fit = fitCirclePoints(points); + if (!fit || fit.radius < CONNECTOR_RING_MIN_RADIUS) return null; + if (fit.residual > CONNECTOR_RING_MAX_RESIDUAL_FRAC * fit.radius) return null; + const rect = toRect(path.getBoundingClientRect()); + const area = rectArea(rect); + if (area < CONNECTOR_NODE_MIN_DOT_AREA || area >= rectArea(rootRect) * 0.5) return null; + const fill = getComputedStyle(path).fill; + return { + selector: selectorFor(path), + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + ring: fill === "none" || fill === "transparent" || path.getAttribute("fill") === "none", + }; + } + function connectorNodeBoxes(root, rootRect) { const boxes = []; const rootArea = rectArea(rootRect); @@ -1820,6 +1875,13 @@ ring: fill === "none" || fill === "transparent" || shape.getAttribute("fill") === "none", }); } + for (const path of Array.from(root.querySelectorAll("path"))) { + if (boxes.length >= CONNECTOR_NODE_CAP) break; + if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue; + if (!isVisibleElement(path, 0.05)) continue; + const box = ringPathBox(path, rootRect); + if (box) boxes.push(box); + } return boxes; } diff --git a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts index f398465804..ddc7f9598d 100644 --- a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts +++ b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts @@ -87,6 +87,31 @@ describe("detectConnectorMotionDetached", () => { expect(detectConnectorMotionDetached(danglingFrames().slice(0, 3), CANVAS)).toHaveLength(0); }); + it("flags a loose end that only touches a node in ONE held frame then detaches the rest", () => { + // End A stays pinned to the hub; end B lands on the satellite for a single + // held frame (t=4) then dangles ~120px away for the rest of the held window. + // A lone graze is NOT sustained attachment, so B must read as dangling and + // the connector must be flagged — not cleared by the single touch. + const held: ConnectorFrame[] = [0, 2, 4, 5, 6, 7, 8].map((time) => { + const bx = time === 4 ? 820 : 640; // on satellite once, dangling otherwise + return frame(time, { selector: "#spoke", ax: 500, ay: 500, bx, by: 500 }, [HUB, SATELLITE]); + }); + const findings = detectConnectorMotionDetached(held, CANVAS); + expect(findings).toHaveLength(1); + expect(findings[0]?.selector).toBe("#spoke"); + }); + + it("keeps an endpoint anchored when it stays within tolerance across the held window", () => { + // The mirror case: B sits on the satellite for every held frame but one — a + // single-frame graze OFF a node does not turn a sustained anchor into a + // dangle, so nothing fires. + const held: ConnectorFrame[] = [0, 2, 4, 5, 6, 7, 8].map((time) => { + const bx = time === 6 ? 640 : 820; // off-node once, on the satellite otherwise + return frame(time, { selector: "#spoke", ax: 500, ay: 500, bx, by: 500 }, [HUB, SATELLITE]); + }); + expect(detectConnectorMotionDetached(held, CANVAS)).toHaveLength(0); + }); + // Hollow ring centred at 900,500; bbox left stroke is at x=700. const RING: ConnectorNodeBox = { selector: "#ring", diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 96f0eb464c..39e5984992 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -1002,6 +1002,11 @@ const CONNECTOR_HELD_START_FRAC = 0.45; const CONNECTOR_MIN_HELD_FRAMES = 2; // Fraction of held frames on which the loose end must be detached to flag. const CONNECTOR_HELD_DETACH_FRAC = 0.8; +// Fraction of held frames an endpoint must stay within attach tolerance to +// count as ANCHORED. A single-frame graze is not attachment: a genuinely +// detached endpoint that merely touches a node once must not read as anchored +// (else its dangling partner escapes the finding). Sustained, not instantaneous. +const CONNECTOR_HELD_ATTACH_FRAC = 0.8; interface ConnectorObservation { time: number; @@ -1057,14 +1062,15 @@ function groupConnectorsBySelector(frames: ConnectorFrame[]): Map { x: number; y: number }, @@ -1078,16 +1084,17 @@ function endpointHeldGaps( heldGaps.push(nearestNodeGap(point.x, point.y, observation.nodes)); } if (heldGaps.length < CONNECTOR_MIN_HELD_FRAMES) return null; + const attached = heldGaps.filter((gap) => gap <= CONNECTOR_ATTACH_PX); const detached = heldGaps.filter((gap) => gap > detachThreshold); return { - minGap: Math.min(...heldGaps), + attachedFraction: attached.length / heldGaps.length, detachedFraction: detached.length / heldGaps.length, danglingGap: detached.length > 0 ? Math.min(...detached) : 0, }; } function isAnchored(gaps: EndpointHeldGaps): boolean { - return gaps.minGap <= CONNECTOR_ATTACH_PX; + return gaps.attachedFraction >= CONNECTOR_HELD_ATTACH_FRAC; } function isDangling(gaps: EndpointHeldGaps): boolean { From 63d508fb986761777adf4ba6fe12911ec443825b Mon Sep 17 00:00:00 2001 From: xuanru Date: Fri, 24 Jul 2026 22:27:28 +0000 Subject: [PATCH 5/6] fix(lint): reject phantom shallow-curvature ring nodes in connector audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ringPathBox gated only on Kåsa residual normalized by radius, which a shallow-curvature arc passes by fitting an enormous phantom circle. Add a bbox-vs-radius sanity gate: the fitted diameter must stay within 3x the path's drawn bounding box, so a nearly-straight arc no longer registers as a false ring/hub node (and no longer creates phantom gauge-exclusions). Co-Authored-By: Claude Opus 4.8 --- .../cli/src/commands/layout-audit.browser.js | 17 ++++ .../src/commands/layout-audit.browser.test.ts | 87 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index ead746581a..139545ca05 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1767,6 +1767,11 @@ const CONNECTOR_RING_MIN_LEN = 120; const CONNECTOR_RING_MIN_RADIUS = 20; const CONNECTOR_RING_MAX_RESIDUAL_FRAC = 0.15; + // Phantom-radius guard: a real ring/hub's fitted diameter tracks its bounding + // box (full circle 1x, quarter arc ~2x). A shallow-curvature arc fits an + // enormous circle with a tiny normalized residual, so the diameter runs many× + // the box — reject beyond this factor. + const CONNECTOR_RING_MAX_DIAMETER_BBOX_FRAC = 3; function isIndicatorConnector(line, svg) { for (let node = line; node && node !== svg.parentElement; node = node.parentElement) { @@ -1825,6 +1830,18 @@ if (!fit || fit.radius < CONNECTOR_RING_MIN_RADIUS) return null; if (fit.residual > CONNECTOR_RING_MAX_RESIDUAL_FRAC * fit.radius) return null; const rect = toRect(path.getBoundingClientRect()); + // Reject the shallow-curvature phantom fit: normalized residual is small at + // any radius, so a nearly-straight arc masquerades as a huge ring. The + // fitted diameter must stay within a sane factor of the drawn bounding box. + const bboxSpan = Math.max(rect.width, rect.height); + if (bboxSpan <= 0 || fit.radius * 2 > CONNECTOR_RING_MAX_DIAMETER_BBOX_FRAC * bboxSpan) { + return null; + } + // Scoped-known seams (left as-is — narrow and not phantom-radius): a short + // genuine partial arc under-samples its parent circle's box so a real hub + // drawn as a sliver can still miss the span gate; and a curved connector + // that is itself near-circular can be read as its own ring node. Both are + // rare vs. the shallow-curvature false ring this gate closes. const area = rectArea(rect); if (area < CONNECTOR_NODE_MIN_DOT_AREA || area >= rectArea(rootRect) * 0.5) return null; const fill = getComputedStyle(path).fill; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index eb551164ba..5de6d30933 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -877,6 +877,46 @@ describe("layout-audit.browser coordinate-frame findings", () => { // "knowledge-overflow" contains conn-family substrings only across word boundaries — no match. expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]); }); + + it("rejects a shallow-curvature arc as a phantom ring node but keeps a genuine near-circular arc", () => { + document.body.innerHTML = ` +
+ + + + +
+ `; + // A near-full circle (diameter tracks its bbox) vs a shallow arc on a huge + // circle: the arc's Kåsa residual normalized by its enormous radius is tiny + // (passes the residual gate) yet its fitted diameter is ~10x its drawn box. + const genuine = { cx: 500, cy: 500, radius: 100, startDeg: 0, endDeg: 360 }; + const shallow = { cx: 500, cy: 2500, radius: 2000, startDeg: 264, endDeg: 276 }; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + "dial-svg": rect({ left: 200, top: 200, width: 700, height: 700 }), + "genuine-ring": arcBBox(genuine), + "shallow-arc": arcBBox(shallow), + }, + { + "genuine-ring": { fill: "none" }, + "shallow-arc": { fill: "none" }, + }, + ); + installArcSampling("genuine-ring", genuine); + installArcSampling("shallow-arc", shallow); + installAuditScript(); + + const sample = ( + window as unknown as { + __hyperframesConnectorSample: () => { nodes: Array<{ selector: string }> }; + } + ).__hyperframesConnectorSample(); + const nodeSelectors = sample.nodes.map((node) => node.selector); + expect(nodeSelectors).toContain("#genuine-ring"); + expect(nodeSelectors).not.toContain("#shallow-arc"); + }); }); describe("layout-audit.browser content overlap", () => { @@ -1979,6 +2019,53 @@ function installConnectorGeometry(translate: CtmTranslate): void { } } +interface ArcSpec { + cx: number; + cy: number; + radius: number; + startDeg: number; + endDeg: number; +} + +// The point ringPathBox reads at arc-length `length` (local SVG user units). +function arcPointAt(spec: ArcSpec, length: number, total: number): { x: number; y: number } { + const frac = total === 0 ? 0 : length / total; + const rad = ((spec.startDeg + (spec.endDeg - spec.startDeg) * frac) * Math.PI) / 180; + return { x: spec.cx + spec.radius * Math.cos(rad), y: spec.cy + spec.radius * Math.sin(rad) }; +} + +function arcTotalLength(spec: ArcSpec): number { + return (spec.radius * Math.abs(spec.endDeg - spec.startDeg) * Math.PI) / 180; +} + +// The bounding box of the 16 samples ringPathBox takes (i/16 of total, i=0..15). +function arcBBox(spec: ArcSpec): DOMRect { + const total = arcTotalLength(spec); + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i < 16; i++) { + const point = arcPointAt(spec, (total * i) / 16, total); + xs.push(point.x); + ys.push(point.y); + } + const left = Math.min(...xs); + const top = Math.min(...ys); + return rect({ left, top, width: Math.max(...xs) - left, height: Math.max(...ys) - top }); +} + +// happy-dom has no SVG path geometry: mock getTotalLength/getPointAtLength so +// ringPathBox samples the given circular arc in local user units. +function installArcSampling(pathId: string, spec: ArcSpec): void { + const path = document.getElementById(pathId); + if (!path) throw new Error(`no path #${pathId}`); + const total = arcTotalLength(spec); + Object.defineProperty(path, "getTotalLength", { value: () => total, configurable: true }); + Object.defineProperty(path, "getPointAtLength", { + value: (length: number) => arcPointAt(spec, length, total), + configurable: true, + }); +} + function installAuditScript(): void { window.eval(script); } From d114f477d9aa51470af36444181d0b8aa8f2dc1a Mon Sep 17 00:00:00 2001 From: xuanru Date: Sat, 25 Jul 2026 08:22:11 +0000 Subject: [PATCH 6/6] style: collapse multi-line comments to single lines --- .../cli/src/commands/layout-audit.browser.js | 45 +++----------- .../src/commands/layout-audit.browser.test.ts | 7 +-- ...ckPipeline.connectorMotionDetached.test.ts | 18 ++---- packages/cli/src/utils/checkPipeline.ts | 61 ++++--------------- packages/cli/src/utils/checkTypes.ts | 10 +-- packages/cli/src/utils/layoutAudit.ts | 3 +- 6 files changed, 30 insertions(+), 114 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 139545ca05..d8b0872dab 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1233,10 +1233,7 @@ const mapped = point.matrixTransform(matrix); return { x: mapped.x, y: mapped.y }; }; - // getPointAtLength can still throw on a degenerate/malformed path even after - // getTotalLength succeeded. This sampler runs once PER seeked frame, so one - // bad path must degrade to "no endpoints" (skip this path), not abort the - // whole check for every remaining path and frame. + // getPointAtLength can throw on a degenerate path even after getTotalLength; skip that path instead of aborting the whole per-frame check. try { return { start: toScreen(path.getPointAtLength(0)), @@ -1743,34 +1740,20 @@ return samples; }; - // connector_motion_detached sampling. Per seeked frame, report every diagram - // connector's two screen-space endpoints AND every plausible node/box bbox. - // Node accumulates these across the grid and flags an endpoint that stays - // anchored to a node while the other endpoint sits in empty space on the held - // frames — a connector whose coordinates were frozen (wrong rotation pivot, or - // measured once at build) while its target kept moving. Icon-sized SVGs and - // short strokes are filtered out so only real diagram connectors count. + // connector_motion_detached sampling: per frame, report each connector's screen endpoints and every node bbox; icon-sized SVGs and short strokes are filtered out. const CONNECTOR_MIN_SVG_PX = 100; const CONNECTOR_MIN_LEN_PX = 60; - // Gauge needles/pointers/ticks are one-end-anchored indicators, not node-to-node - // connectors — a separate (gauge) check owns them. Skip by id/class of the line - // or any group ancestor up to the SVG. + // Gauge needles/pointers/ticks are one-end-anchored indicators owned by a separate check; skip by id/class of the line or any group ancestor. const CONNECTOR_INDICATOR_NAME = /needle|pointer|gauge|tick|indicator/i; const CONNECTOR_NODE_MIN_AREA = 400; // SVG dots/markers are small; keep the floor low but above sub-pixel decoration. const CONNECTOR_NODE_MIN_DOT_AREA = 16; const CONNECTOR_NODE_CAP = 300; - // A stroke-drawn dial/hub ring may be an arc (M...A...), not a . - // Recognize near-circular stroke paths as ring nodes so connectors attaching - // to them aren't false-flagged as detached, and so the gauge-indicator geometry - // exclusion (which keys on ring nodes) can see arc-drawn dials too. + // Recognize near-circular stroke arcs as ring nodes so connectors attaching to them aren't false-flagged and the gauge exclusion sees arc-drawn dials. const CONNECTOR_RING_MIN_LEN = 120; const CONNECTOR_RING_MIN_RADIUS = 20; const CONNECTOR_RING_MAX_RESIDUAL_FRAC = 0.15; - // Phantom-radius guard: a real ring/hub's fitted diameter tracks its bounding - // box (full circle 1x, quarter arc ~2x). A shallow-curvature arc fits an - // enormous circle with a tiny normalized residual, so the diameter runs many× - // the box — reject beyond this factor. + // Phantom-radius guard: a shallow arc fits an enormous circle with a tiny residual, so reject when the fitted diameter runs past this factor of the bbox. const CONNECTOR_RING_MAX_DIAMETER_BBOX_FRAC = 3; function isIndicatorConnector(line, svg) { @@ -1799,13 +1782,7 @@ }; } - // Node/box candidates a connector could anchor to: sized, opaque or text- - // bearing HTML elements plus SVG hub/ring/dot shapes. A shape drawn stroke-only - // (fill:none) is a ring — the connector attaches to its stroke, so it is marked - // `ring` and matched by perimeter, not hollow interior (see pointToNodeGap). - // A near-circular stroke read as a ring/hub node. Returns a node box - // (ring flag from fill) or null when the path is not a resolvable circular hub. - // getPointAtLength is guarded — one malformed path must not abort the sampler. + // Read a near-circular stroke as a ring/hub node box (ring flag from fill), or null if not a resolvable hub; getPointAtLength is guarded so one bad path can't abort the sampler. function ringPathBox(path, rootRect) { if (typeof path.getTotalLength !== "function" || typeof path.getPointAtLength !== "function") { return null; @@ -1830,18 +1807,12 @@ if (!fit || fit.radius < CONNECTOR_RING_MIN_RADIUS) return null; if (fit.residual > CONNECTOR_RING_MAX_RESIDUAL_FRAC * fit.radius) return null; const rect = toRect(path.getBoundingClientRect()); - // Reject the shallow-curvature phantom fit: normalized residual is small at - // any radius, so a nearly-straight arc masquerades as a huge ring. The - // fitted diameter must stay within a sane factor of the drawn bounding box. + // Reject the shallow-curvature phantom fit: a nearly-straight arc masquerades as a huge ring, so the fitted diameter must stay within a sane factor of the bbox. const bboxSpan = Math.max(rect.width, rect.height); if (bboxSpan <= 0 || fit.radius * 2 > CONNECTOR_RING_MAX_DIAMETER_BBOX_FRAC * bboxSpan) { return null; } - // Scoped-known seams (left as-is — narrow and not phantom-radius): a short - // genuine partial arc under-samples its parent circle's box so a real hub - // drawn as a sliver can still miss the span gate; and a curved connector - // that is itself near-circular can be read as its own ring node. Both are - // rare vs. the shallow-curvature false ring this gate closes. + // Known narrow seams left as-is: a sliver partial arc can miss the span gate, and a near-circular connector can read as its own ring node — both rare vs. the false ring this gate closes. const area = rectArea(rect); if (area < CONNECTOR_NODE_MIN_DOT_AREA || area >= rectArea(rootRect) * 0.5) return null; const fill = getComputedStyle(path).fill; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 5de6d30933..cb7e7c2743 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -887,9 +887,7 @@ describe("layout-audit.browser coordinate-frame findings", () => { `; - // A near-full circle (diameter tracks its bbox) vs a shallow arc on a huge - // circle: the arc's Kåsa residual normalized by its enormous radius is tiny - // (passes the residual gate) yet its fitted diameter is ~10x its drawn box. + // Shallow arc on a huge circle passes the residual gate yet its fitted diameter is ~10x its drawn box — the phantom-radius case, vs a genuine near-full circle. const genuine = { cx: 500, cy: 500, radius: 100, startDeg: 0, endDeg: 360 }; const shallow = { cx: 500, cy: 2500, radius: 2000, startDeg: 264, endDeg: 276 }; installGeometry( @@ -2053,8 +2051,7 @@ function arcBBox(spec: ArcSpec): DOMRect { return rect({ left, top, width: Math.max(...xs) - left, height: Math.max(...ys) - top }); } -// happy-dom has no SVG path geometry: mock getTotalLength/getPointAtLength so -// ringPathBox samples the given circular arc in local user units. +// happy-dom has no SVG path geometry: mock getTotalLength/getPointAtLength so ringPathBox can sample the given arc. function installArcSampling(pathId: string, spec: ArcSpec): void { const path = document.getElementById(pathId); if (!path) throw new Error(`no path #${pathId}`); diff --git a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts index ddc7f9598d..4dabeb3f7f 100644 --- a/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts +++ b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts @@ -30,8 +30,7 @@ function frame( return { time, connectors: [connector], nodes }; } -/** A dangling connector: end A stays on the hub, end B sits ~180px from every - * node across all held frames — the half-attached signature (fuzz011). */ +/** A dangling connector: end A stays on the hub, end B sits ~180px from every node across held frames — the half-attached signature (fuzz011). */ function danglingFrames(): ConnectorFrame[] { return [0, 2, 4, 6, 8].map((time) => frame(time, { selector: "#spoke", ax: 500, ay: 500, bx: 640, by: 500 }, [HUB, SATELLITE]), @@ -88,10 +87,7 @@ describe("detectConnectorMotionDetached", () => { }); it("flags a loose end that only touches a node in ONE held frame then detaches the rest", () => { - // End A stays pinned to the hub; end B lands on the satellite for a single - // held frame (t=4) then dangles ~120px away for the rest of the held window. - // A lone graze is NOT sustained attachment, so B must read as dangling and - // the connector must be flagged — not cleared by the single touch. + // A lone graze (B touches the satellite once at t=4, dangles the rest) is not sustained attachment, so B must still read as dangling and the connector be flagged. const held: ConnectorFrame[] = [0, 2, 4, 5, 6, 7, 8].map((time) => { const bx = time === 4 ? 820 : 640; // on satellite once, dangling otherwise return frame(time, { selector: "#spoke", ax: 500, ay: 500, bx, by: 500 }, [HUB, SATELLITE]); @@ -102,9 +98,7 @@ describe("detectConnectorMotionDetached", () => { }); it("keeps an endpoint anchored when it stays within tolerance across the held window", () => { - // The mirror case: B sits on the satellite for every held frame but one — a - // single-frame graze OFF a node does not turn a sustained anchor into a - // dangle, so nothing fires. + // Mirror case: a single-frame graze OFF a node doesn't turn a sustained anchor into a dangle, so nothing fires. const held: ConnectorFrame[] = [0, 2, 4, 5, 6, 7, 8].map((time) => { const bx = time === 6 ? 640 : 820; // off-node once, on the satellite otherwise return frame(time, { selector: "#spoke", ax: 500, ay: 500, bx, by: 500 }, [HUB, SATELLITE]); @@ -131,8 +125,7 @@ describe("detectConnectorMotionDetached", () => { }); it("does not fire on a gauge needle: anchored at the arc centre, loose end radially outward", () => { - // Arc ring centred at 900,500; needle base on the hub near centre, tip out - // past the arc — a pointer, not a broken connector. + // Arc ring at 900,500 with the needle base near centre and tip past the arc — a pointer, not a broken connector. const hub: ConnectorNodeBox = { selector: "#hub", left: 890, @@ -156,8 +149,7 @@ describe("detectConnectorMotionDetached", () => { }); it("still fires on a radial connector drifting toward the centre (not outward)", () => { - // Anchored on a peripheral node; loose end drifts inward to empty space near - // a ring centre — the fuzz011 shape, opposite of a gauge pointer. + // Anchored on a peripheral node with the loose end drifting inward to empty space near a ring centre — the fuzz011 shape, opposite of a gauge pointer. const peripheral: ConnectorNodeBox = { selector: "#panel", left: 120, diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 39e5984992..2a86760f8d 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -201,8 +201,7 @@ interface GridSamples { * material-point geometry + dial hub per layout sample; flattened by selector * to detect off_pivot_rotation. */ indicatorFrames: OffPivotFrame[]; - /** Connector endpoints + node boxes at each layout sample; correlated after - * the run to detect connector_motion_detached. */ + /** Connector endpoints + node boxes per layout sample; correlated after the run to detect connector_motion_detached. */ connectorFrames: ConnectorFrame[]; } @@ -985,16 +984,11 @@ export function detectOffPivotRotation(frames: OffPivotFrame[]): AnchoredLayoutI return selectHubFindings(candidates); } -// connector_motion_detached thresholds. Corpus timings: entrances settle by -// ~2s of a 7-8s composition, so held/steady state starts well before half. +// connector_motion_detached thresholds. const CONNECTOR_MIN_FRAMES = 4; // An endpoint within this of a node counts as anchored (well under a node's size). const CONNECTOR_ATTACH_PX = 24; -// The dangling end must clear this to flag. Set high, and measured against a -// dense node-candidate set, so only an endpoint sitting in genuinely empty space -// fires — a slow drift that still lands near a box, or a snug residual gap, does -// not. Corpus: fuzz011's detached spokes dangle ~185-190px from every node; -// legitimate lead-lines to a nearby label stay well under this. +// Detach floor: set high and measured against a dense node set, so only an endpoint in genuinely empty space fires (not a snug gap or lead-line). const CONNECTOR_DETACH_FLOOR_PX = 80; const CONNECTOR_DETACH_VIEWPORT_FRACTION = 0.06; // Held/steady frames begin at this fraction of the timeline (past entrances). @@ -1002,10 +996,7 @@ const CONNECTOR_HELD_START_FRAC = 0.45; const CONNECTOR_MIN_HELD_FRAMES = 2; // Fraction of held frames on which the loose end must be detached to flag. const CONNECTOR_HELD_DETACH_FRAC = 0.8; -// Fraction of held frames an endpoint must stay within attach tolerance to -// count as ANCHORED. A single-frame graze is not attachment: a genuinely -// detached endpoint that merely touches a node once must not read as anchored -// (else its dangling partner escapes the finding). Sustained, not instantaneous. +// Fraction of held frames within attach tolerance to count as anchored — sustained, so a single-frame graze doesn't let a dangling partner escape. const CONNECTOR_HELD_ATTACH_FRAC = 0.8; interface ConnectorObservation { @@ -1021,8 +1012,7 @@ function pointToNodeGap(x: number, y: number, node: ConnectorNodeBox): number { const dx = Math.max(node.left - x, 0, x - node.right); const dy = Math.max(node.top - y, 0, y - node.bottom); if (dx > 0 || dy > 0) return Math.hypot(dx, dy); - // Inside the bbox: a solid node anchors anywhere; a hollow ring only near its - // stroke, so measure distance to the bbox perimeter (its hole is not attached). + // Inside the bbox: a solid node anchors anywhere; a hollow ring only near its stroke, so measure distance to the perimeter. if (!node.ring) return 0; return Math.min(x - node.left, node.right - x, y - node.top, node.bottom - y); } @@ -1033,9 +1023,7 @@ function nearestNodeGap(x: number, y: number, nodes: ConnectorNodeBox[]): number return min; } -/** Group observations by connector selector, dropping any selector that maps to - * more than one connector in a single frame — those endpoints can't be tracked - * across seeks without aliasing (the false-association risk). */ +/** Group observations by connector selector, dropping any selector that aliases multiple connectors in one frame (untrackable across seeks). */ function groupConnectorsBySelector(frames: ConnectorFrame[]): Map { const bySelector = new Map(); const ambiguous = new Set(); @@ -1067,10 +1055,7 @@ interface EndpointHeldGaps { danglingGap: number; } -/** Per-endpoint held-frame gap summary: the fraction of held frames it sits - * within attach tolerance of a node (sustained attachment, not a lone graze), - * the fraction it sits beyond the detach threshold, and the gap it settles at - * when detached. */ +/** Per-endpoint held-frame gap summary: fractions within attach tolerance and beyond the detach threshold, plus the settled dangling gap. */ function endpointHeldGaps( group: ConnectorObservation[], pick: (o: ConnectorObservation) => { x: number; y: number }, @@ -1101,11 +1086,7 @@ function isDangling(gaps: EndpointHeldGaps): boolean { return gaps.detachedFraction >= CONNECTOR_HELD_DETACH_FRAC; } -// Gauge-indicator geometry: the anchored end pivots near a ring/arc centre and -// the loose end extends radially outward. That is a needle/pointer (owned by a -// separate gauge check), not a broken node connector. A defective radial -// connector points the other way — anchored on a peripheral node, loose end -// drifting toward the centre — so this only excludes true centre-pivot pointers. +// Gauge indicator: anchored end pivots near a ring centre, loose end extends outward — a needle (owned by a separate gauge check), so exclude only true centre-pivot pointers. const GAUGE_HUB_CENTRE_FRACTION = 0.25; function isGaugeIndicator( @@ -1154,32 +1135,14 @@ function connectorDetachFinding( }; } -/** - * connector_motion_detached: a connector (SVG /) that stays anchored - * to a node at ONE endpoint while its OTHER endpoint sits in empty space — far - * from every node — across the held (steady) frames. This is the sibling of the - * per-frame `connector_detached` check, which requires BOTH endpoints far from - * anchors on a single frame and so deliberately ignores the half-attached case. - * But that half-attached case is the dominant real failure under motion: a - * spoke/edge whose one end stays pinned (e.g. at a hub) while the other drifts - * off a node that kept moving — rotated about a wrong pivot, or with a path - * measured once at build then never updated as the target animated. - * - * FP-guarded, deliberately strict: one endpoint must be genuinely anchored - * (structural, not a name match); the loose end must clear a high detach - * threshold on ~all held frames (measured against a dense node-candidate set, so - * only an endpoint in truly empty space fires — snug residual gaps and lead - * lines to a nearby label do not); and a selector that aliases multiple - * connectors in one frame is dropped. - */ +/** connector_motion_detached: the half-attached case connector_detached ignores — one endpoint pinned while the other drifts into empty space across held frames (wrong pivot, or coords measured once at build). Strict + FP-guarded. */ interface DanglePick { anchored: { x: number; y: number }; dangling: { x: number; y: number }; gap: number; } -/** If exactly one endpoint is anchored and the other persistently dangles, name - * the anchored/dangling points and the dangling gap; else null. */ +/** If exactly one endpoint is anchored and the other persistently dangles, name the anchored/dangling points and the gap; else null. */ function danglingEndpoint( last: ConnectorObservation, gapsA: EndpointHeldGaps, @@ -1196,9 +1159,7 @@ function danglingEndpoint( return null; } -/** One connector's held trajectory → a finding, or null. A finding needs one - * endpoint anchored to a node and the other persistently in empty space, and is - * suppressed for gauge-indicator geometry (see isGaugeIndicator). */ +/** One connector's held trajectory → a finding, or null; needs one anchored endpoint and one persistently in empty space, suppressed for gauge-indicator geometry. */ function connectorGroupFinding( selector: string, group: ConnectorObservation[], diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 2efd1b98e3..af65fbc51b 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -170,8 +170,7 @@ export interface ConnectorLineSample { by: number; } -/** Screen bbox of one plausible node/box a connector could anchor to. `ring` - * marks a stroke-only SVG shape matched by perimeter, not hollow interior. */ +/** Screen bbox of a node a connector could anchor to; `ring` marks a stroke-only SVG shape matched by perimeter, not hollow interior. */ export interface ConnectorNodeBox { selector: string; left: number; @@ -181,9 +180,7 @@ export interface ConnectorNodeBox { ring: boolean; } -/** All connectors + node boxes at one seeked sample, produced by - * `__hyperframesConnectorSample` and accumulated across the grid to detect - * `connector_motion_detached`. */ +/** All connectors + node boxes at one seeked sample, accumulated across the grid to detect connector_motion_detached. */ export interface ConnectorFrame { time: number; connectors: ConnectorLineSample[]; @@ -218,8 +215,7 @@ export interface CheckAuditDriver { * geometry + resolved dial hub at the current seeked state, as a time-hoisted * frame envelope. */ collectOffPivotRotationSample(time: number): Promise; - /** connector_motion_detached: every connector's endpoints + every node bbox - * at the current seeked state. Accumulated across the grid — see checkPipeline. */ + /** connector_motion_detached: every connector's endpoints + every node bbox at the current seeked state, accumulated across the grid. */ collectConnectorSample(time: number): Promise; collectGeometryCandidates( time: number, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 24e2e8287f..d5aea0d05c 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -23,8 +23,7 @@ export type LayoutIssueCode = | "escaped_container" | "panel_out_of_canvas" | "connector_detached" - // Cross-sample connector finding — one endpoint anchored to a node while the - // other dangles in empty space on held frames (frozen coords while target moved). + // Cross-sample finding — one endpoint anchored while the other dangles in empty space on held frames (frozen coords while target moved). | "connector_motion_detached" // Cross-sample rotation finding — a spinning element whose bbox center drifts // because it pivots about the wrong point (bad transformOrigin/svgOrigin).