Skip to content
Closed
1 change: 1 addition & 0 deletions packages/cli/src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): 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[]) =>
Expand Down
197 changes: 192 additions & 5 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@
return false;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
// Stroke is excluded from an SVG shape's client rect, so axis-aligned geometry measures 0 on one axis.
const stroke = typeof element.getBBox === "function" ? parseFloat(style.strokeWidth) || 0 : 0;
if (rect.width + stroke <= 0.5 || rect.height + stroke <= 0.5) return false;
return probeClipPath === false || !isClippedAway(element);
}

Expand Down Expand Up @@ -1233,10 +1235,15 @@
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 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)),
end: toScreen(path.getPointAtLength(total)),
};
} catch {
return null;
}
}

function distanceToRect(point, rect) {
Expand Down Expand Up @@ -1266,6 +1273,16 @@
return { compact, painted };
}

/** Connector naming for the motion sampler, tolerant of camelCase ids the word-bounded name test would miss. */
function isMotionConnectorCandidate(svg, path) {
if (isConnectorPath(svg, path)) return true;
const spaced = `${connectorNameFor(svg)} ${connectorNameFor(path)}`.replace(
/([a-z])([A-Z0-9])/g,
"$1 $2",
);
return CONNECTOR_NAME.test(spaced);
}

function isConnectorPath(svg, path) {
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
return (
Expand Down Expand Up @@ -1734,4 +1751,174 @@
}
return samples;
};

// 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 owned by a separate check; skip by id/class of the line or any group ancestor.
// Word-bounded so `sticky`/`ticker` do not match, and pointer-events is excluded: a Tailwind utility must not mute a whole subtree.
const CONNECTOR_INDICATOR_NAME = /\b(needle|pointer(?!-events)|gauge|tick|indicator)\b/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;
// Recognize near-circular stroke <path> 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 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) {
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),
};
}

// Read a near-circular stroke <path> 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;
}
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 The ringPathBox residual gate is scale-relative and can be vacuous for shallow-curvature paths — a decorative flourish then registers as a ring node and silently suppresses real connector findings that end near it.

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;

Kåsa on a shallow-curvature path (say a 100px × 5px slightly-curved decorative stroke) produces a huge phantom-radius fit: as the determinant shrinks toward the degenerate-line case, uc/vc grow — the returned radius can be enormous even though the true curvature is small. Meanwhile the absolute residual stays ~1px because the points do lie on SOMETHING circular. Ratio residual / radius ≈ 0.001 easily passes the 0.15 gate. CONNECTOR_RING_MIN_RADIUS doesn't help because the phantom radius is LARGE.

Consequence: ringPathBox returns a node box with ring: true for that decorative flourish. Downstream pointToNodeGap treats it as a ring perimeter with the flourish's bbox as the outline. Any dangling connector endpoint near the flourish's bbox reads as anchored → the true connector_motion_detached finding is silently suppressed.

Fix: add a bbox-vs-radius sanity gate. A genuine ring's bounding box spans its diameter within a small factor; a shallow-curvature path's bbox is much smaller than the phantom-radius circle it fits to. Something like:

const rect = path.getBoundingClientRect();
const bboxDiag = Math.hypot(rect.width, rect.height);
if (fit.radius > bboxDiag) return null;  // phantom-radius rejector

Or require the fit centre (fit.cx, fit.cy) to sit within the bounding box (a real closed ring's centre is inside its own bbox; a shallow-arc phantom-fit's centre is far away).

Background: this is the same lens Magi caught on #2744's Kåsa fit — different failure mode (n=3 exact-fit there, degenerate-scale phantom-fit here), same underlying issue: residual-normalized-by-radius doesn't degrade gracefully when the fit is bad. Fitting >= CONNECTOR_RING_MIN_LEN-point curves (n = 16 vs 3 params here) makes it overdetermined, but doesn't rule out the shallow-curvature phantom.

Review by Rames D Jusso

const rect = toRect(path.getBoundingClientRect());
// 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;
}
// 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;
return {
selector: selectorFor(path),
left: rect.left,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 ringPathBox returns path.getBoundingClientRect() as the node box but discards the actual fitted (fit.cx, fit.cy, fit.radius) — for a partial-arc dial (e.g. a 60°-of-a-circle gauge arc), the arc's bbox is much smaller than the true ring geometry.

const rect = toRect(path.getBoundingClientRect());
// ...
return {
  selector: selectorFor(path),
  left: rect.left,
  top: rect.top,
  right: rect.right,
  bottom: rect.bottom,
  ring: ...,
};

Downstream pointToNodeGap treats ring: true nodes as "distance to bbox perimeter". So for a 60°-arc gauge whose actual ring would span 200×200px, the arc's bbox might be only 100×60px. An endpoint that sits AT (bbox.right + 10) reads as 10px from the perimeter → anchored, even though it's nowhere near the drawn arc.

Consequence: over-anchoring on partial-arc gauges. The real fix is to keep the fit result and use distance_to_circle_perimeter(x, y, fit.cx, fit.cy, fit.radius) for ring: true path nodes. That would require plumbing the fit result through the NodeBox shape — bigger change — but the current bbox approximation is silently wrong for anything less than a full circle.

Cheap intermediate: reject ringPathBox when the arc covers less than ~270° of the circle (total < 0.75 * 2π * fit.radius). Full-ring dials still detect; partial-arc gauges bail to the name-based isIndicatorConnector path.

Review by Rames D Jusso

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A near-circular <path> can be registered as BOTH a ring node AND sampled as a connector — its endpoints then self-anchor and no finding fires.

const box = ringPathBox(path, rootRect);
if (box) boxes.push(box);

connectorNodeBoxes iterates <path> at :1877 to build node boxes. connectorSample (:1930+) iterates the same <path> set with the same visibility filters to build connectors. There's no cross-check that a path used as a ring node isn't also being sampled as a connector.

Failure scenario: a near-circular open-arc <path> (say a 240° arc) whose endpoint separation ≥ CONNECTOR_MIN_LEN_PX becomes:

  • A ring node via ringPathBox → registered as NodeBox with the arc's bbox
  • A connector via the connector loop → its two endpoints lie on its own bbox perimeter, so pointToNodeGap returns 0 for both (both endpoints are within the arc's own bbox) → both endpoints self-anchor → check never fires

Rare shape (most connectors are <line> or short polyline; most rings are closed shapes), but hides real bugs when it occurs.

Fix: track ring-registered paths in a Set in connectorNodeBoxes and skip them in the connector loop, or vice versa. One-line change.

Review by Rames D Jusso

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, polyline"))) {
if (line.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
// Stroke-inflated: an axis-aligned line has a zero-height/width client rect, so the plain visibility gate drops it.
if (!isVisibleElement(line, 0.05)) continue;
if (isIndicatorConnector(line, svg)) continue;
if (!isMotionConnectorCandidate(svg, line)) continue;
const ends =
line.tagName.toLowerCase() === "line"
? lineScreenEndpoints(svg, line)
: pathScreenEndpoints(svg, line);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 This new call site amplifies a defensive gap in the pre-existing pathScreenEndpoints helper — one bad diagram path can now abort the whole check.

const ends =
  line.tagName.toLowerCase() === "line"
    ? lineScreenEndpoints(svg, line)
    : pathScreenEndpoints(svg, line);

Look at pathScreenEndpoints at :1211-1239: getTotalLength() is defensively try/catch-guarded at :1221-1225 but the two getPointAtLength(0) / getPointAtLength(total) calls at :1237-1238 are NOT:

return {
  start: toScreen(path.getPointAtLength(0)),
  end: toScreen(path.getPointAtLength(total)),
};

In Chrome the pair is usually safe once total > 0, but the API is documented to throw for malformed paths (arcs with degenerate radii, invalid path data that survives getTotalLength numerically).

Failure mode: if either getPointAtLength throws, the exception propagates out of __hyperframesConnectorSamplepage.evaluate rejects → driver.collectConnectorSample(time) rejects → collectGridSamples/runAuditGrid have no per-sample guard → the whole runBrowserCheck rejects. runCheckPipeline catches and replaces the entire browser result with emptyBrowserResult() + a single runtime finding — every layout/contrast/motion finding for the run is dropped over one malformed path.

The pre-existing per-sample connector_detached code path has the same shape (called once via connectorDetachmentIssues at :1287), so this isn't a net-new hazard. But this PR extends the surface — the sampler now runs on EVERY layout sample instead of once — multiplying the throw budget by the sample count.

Fix: wrap the two getPointAtLength calls at :1237-1238 in the same try block that already guards getTotalLength, returning null on throw. Two-line defensive change; matches the surrounding style. Or wrap the pathScreenEndpoints(svg, line) call HERE in a try/catch returning null, which is more localized to your PR.

Review by Rames D Jusso

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) };
};
})();
84 changes: 84 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,44 @@ 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 = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<svg id="dial-svg">
<path id="genuine-ring" fill="none" d="ring" />
<path id="shallow-arc" fill="none" d="arc" />
</svg>
</div>
`;
// 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(
{
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", () => {
Expand Down Expand Up @@ -1979,6 +2017,52 @@ 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 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}`);
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);
}
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import type {
CheckGeometryCandidate,
CheckOptions,
CheckSeverity,
ConnectorFrame,
ConnectorLineSample,
ConnectorNodeBox,
ContrastAuditEntry,
ContrastCapture,
GeometryCandidateRequest,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -568,6 +572,44 @@ function parseOffPivotRotationSample(value: unknown): OffPivotRotationSample[] {
];
}

async function collectConnectorSample(page: Page, time: number): Promise<ConnectorFrame> {
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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading