-
Notifications
You must be signed in to change notification settings - Fork 3.5k
feat(lint): add connector_motion_detached layout check #2745
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: xuanru/content-overlap-dense-resampling
Are you sure you want to change the base?
Changes from all commits
46d0847
c963ede
3b35be0
70e6026
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,180 @@ | |
| } | ||
| 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 <path> arc (M...A...), not a <circle>. | ||
| // 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) { | ||
| 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 <path> 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 The 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, Consequence: 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 rejectorOr require the fit centre Background: this is the same lens Magi caught on |
||
| 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 const rect = toRect(path.getBoundingClientRect());
// ...
return {
selector: selectorFor(path),
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
ring: ...,
};Downstream Consequence: over-anchoring on partial-arc gauges. The real fix is to keep the fit result and use Cheap intermediate: reject |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 A near-circular const box = ringPathBox(path, rootRect);
if (box) boxes.push(box);
Failure scenario: a near-circular open-arc
Rare shape (most connectors are Fix: track ring-registered paths in a |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 This new call site amplifies a defensive gap in the pre-existing const ends =
line.tagName.toLowerCase() === "line"
? lineScreenEndpoints(svg, line)
: pathScreenEndpoints(svg, line);Look at return {
start: toScreen(path.getPointAtLength(0)),
end: toScreen(path.getPointAtLength(total)),
};In Chrome the pair is usually safe once Failure mode: if either The pre-existing per-sample Fix: wrap the two — 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) }; | ||
| }; | ||
| })(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡
isIndicatorConnector(the name-based prong of gauge exclusion added in the tip commit) has no unit test.The filter walks the connector element's ancestry (id + className) up to the SVG parent and skips the connector before endpoint extraction. It's ONE of the two prongs the PR body advertises as
by name AND geometry, and — per the sibling comment oncheckPipeline.ts:1035— it's the ONLY prong that catches arc-path gauges.Shipped without unit coverage. The pipeline test file operates on
ConnectorLineSampleafter the browser strip, so the regex ancestry walk is exercisable only in an integration/e2e run.Regression pathway: someone refactors
connectorNameFor(which handlesclassName.baseValfor SVG elements) or edits the ancestor-loop termination (node !== svg.parentElement) — the filter silently regresses and CI stays green because the fuzz corpus doesn't run in CI.Also: the regex is a small taxonomy. A connector inside
<g id="chart-ticker-line">matches (ticksubstring) and gets silently skipped even though it's a real dangling connector defect. A legitimate gauge needle authored asclass="reading-arrow"doesn't match and slips through to the geometry prong (which has its own gap per :1035 comment) → false-positive.Fix: extract
isIndicatorConnectorinto a helper that can be exercised without JSDOM — pass in a minimal{ id, className }chain and assert the regex disposition. Or add a corpus-e2e test wired to fuzz080 so a regression on the name filter surfaces in the same PR CI that motivated the tip-commit fix.Stronger fix (couples with the sibling comment on
checkPipeline.ts:1035): add a positivedata-hf-gauge/data-hf-indicatoropt-in data attribute that both checks honor as an explicit author-intent signal — removes reliance on both the fuzzy regex and the arc-path geometry gap.— Review by Rames D Jusso