diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 0294359c18..0bd993f674 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 6786d78da2..a17fa7fe5a 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) { @@ -1738,4 +1746,197 @@ } 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; + // 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; + // 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; + // 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) { + 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; + } + 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 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()); + // 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; + 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); + 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, + 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", + }); + } + 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; + } + + 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; + if (isIndicatorConnector(line, svg)) 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/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); } diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index c47cced9ca..a2539fc712 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, ring: value.ring === true }]; +} + 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..ddc7f9598d --- /dev/null +++ b/packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts @@ -0,0 +1,198 @@ +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, + ring: false, +}; +const SATELLITE: ConnectorNodeBox = { + selector: "#sat", + left: 780, + top: 480, + right: 860, + bottom: 520, + ring: false, +}; + +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("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", + 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("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) => + 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, + 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 7d45065d4d..c2c6a6bb3b 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( @@ -1009,6 +1016,255 @@ 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; +// 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; + 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 { + attachedFraction: number; + detachedFraction: number; + 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. */ +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 attached = heldGaps.filter((gap) => gap <= CONNECTOR_ATTACH_PX); + const detached = heldGaps.filter((gap) => gap > detachThreshold); + return { + 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.attachedFraction >= CONNECTOR_HELD_ATTACH_FRAC; +} + +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. */ @@ -1049,6 +1305,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, @@ -1061,6 +1321,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 2007ea5b19..ab2d7a65f4 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -161,6 +161,35 @@ 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. `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 + * `__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 } @@ -194,6 +223,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 d9b3531609..64b1ba2e64 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"