From c8921a8f149befbbfc1b671245069d5e8c31d0ee Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Sat, 12 Sep 2026 23:03:26 +0000 Subject: [PATCH 1/2] fix(cli): connector_detached now skips dash-hidden connector shafts connector_detached fired on SVG connector paths that were 100% hidden via stroke-dashoffset/stroke-dasharray draw-on entrances, evaluating rendered/user-space geometry unconditionally. The sibling connector_orphan check already gates on shaftDashHidden(path); add the same gate here, in the same position. Co-Authored-By: Miguel Angel --- .../cli/src/commands/layout-audit.browser.js | 1 + .../src/commands/layout-audit.browser.test.ts | 42 ++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 8693f8a678..e9e0ee7fec 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1337,6 +1337,7 @@ for (const path of Array.from(svg.querySelectorAll("path"))) { if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue; if (!isConnectorPath(svg, path)) continue; + if (shaftDashHidden(path)) continue; const user = pathUserEndpoints(path); const rendered = pathScreenEndpoints(svg, path, user); if (!user || !rendered) continue; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index d65dabba00..65564b6823 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1027,8 +1027,7 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(issues.some((issue) => issue.code === "canvas_overflow")).toBe(true); }); - it("flags connector paths drawn in a foreign frame and passes anchored ones", () => { - document.body.innerHTML = ` + const foreignFrameDom = `
@@ -1039,18 +1038,20 @@ describe("layout-audit.browser coordinate-frame findings", () => {
`; - installGeometry( - { - root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), - n1: rect({ left: 900, top: 500, width: 160, height: 160 }), - n2: rect({ left: 300, top: 200, width: 160, height: 160 }), - "connector-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }), - }, - { - n1: { backgroundColor: "rgb(30, 40, 50)" }, - n2: { backgroundColor: "rgb(30, 40, 50)" }, - }, - ); + const foreignFrameRects = { + root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + n1: rect({ left: 900, top: 500, width: 160, height: 160 }), + n2: rect({ left: 300, top: 200, width: 160, height: 160 }), + "connector-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }), + }; + const foreignFrameStyles = { + n1: { backgroundColor: "rgb(30, 40, 50)" }, + n2: { backgroundColor: "rgb(30, 40, 50)" }, + }; + + it("flags connector paths drawn in a foreign frame and passes anchored ones", () => { + document.body.innerHTML = foreignFrameDom; + installGeometry(foreignFrameRects, foreignFrameStyles); // Screen CTM translates svg user space by the svg's offset (80, 227): the detached path's // start (980, 580) renders at (1060, 807) — 147px below #n1's box — while the anchored // path's start (900, 353) renders at (980, 580), inside #n1. @@ -1065,6 +1066,19 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(issues[0]?.fixHint).toContain("invert getScreenCTM"); }); + it("does not flag a paste-bug connector still hidden behind its dash offset", () => { + document.body.innerHTML = foreignFrameDom; + // The fixture above, with #detached fully dash-hidden — draw-on entrance not yet advanced. + installGeometry(foreignFrameRects, { + ...foreignFrameStyles, + detached: { strokeDasharray: "100", strokeDashoffset: "100" }, + }); + installConnectorGeometry({ e: 80, f: 227 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]); + }); + it("skips svgs and paths without connector intent", () => { document.body.innerHTML = `
From 58aefbba341dfe974646e1012c33b513ee95ee58 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Tue, 15 Sep 2026 01:00:23 +0000 Subject: [PATCH 2/2] fix(cli): connector dash gate reads the whole stroke-dasharray; one owner for connector enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shaftDashHidden` read only the first `stroke-dasharray` entry, so any pattern starting with 0 (`0 4` dotted) or a long first dash counted as hidden and suppressed both connector findings on a visible stroke. It now resolves the full computed list (comma/space separated, units, percentages against the SVG viewport diagonal, odd lists repeated; `none`, all-zero and negative lists render solid) and treats the shaft as hidden only when the window `[dashoffset, dashoffset + length]` sits in a single gap, with up to 10% of the length overlapping a neighbouring dash. Zero-length dashes paint only under `stroke-linecap: round | square`; under the default `butt` they render nothing, which also covers the `0px, 999999px` state a finished draw-off tween leaves behind. `connector_detached` and `connector_orphan` each walked SVG paths with their own copy of the candidate/gate logic, which had already drifted. A single `connectorShafts(root)` generator now owns enumeration, the dash gate and endpoint resolution, and yields the `painted` verdict; both findings consume it. The path length is queried once per shaft (`pathUserEndpoints` returns it) instead of twice, and the attach threshold formula has one owner. Tests pin `0 4` (butt and round), `0, 4`, `4 0`, `0`, `none`, px units, negative offsets, a draw-off end state, and both sides of the 10% boundary. Co-Authored-By: Miguel Ángel --- .../cli/src/commands/layout-audit.browser.js | 324 +++++++++++------- .../src/commands/layout-audit.browser.test.ts | 40 +++ 2 files changed, 233 insertions(+), 131 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index e9e0ee7fec..d66ce2da1a 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1259,7 +1259,10 @@ ); } - /** Raw `d`-space endpoints (no CTM) — the mapping authors use when they paste screen coords into `d`. */ + /** + * Raw `d`-space endpoints (no CTM) — the mapping authors use when they paste screen coords into + * `d` — plus the path length they were sampled from, so callers never re-query the geometry. + */ function pathUserEndpoints(path) { if (typeof path.getTotalLength !== "function" || typeof path.getPointAtLength !== "function") { return null; @@ -1273,7 +1276,7 @@ if (!Number.isFinite(total) || total <= 0) return null; const start = path.getPointAtLength(0); const end = path.getPointAtLength(total); - return { start: { x: start.x, y: start.y }, end: { x: end.x, y: end.y } }; + return { start: { x: start.x, y: start.y }, end: { x: end.x, y: end.y }, total }; } // Screen endpoints via getScreenCTM (viewBox, preserveAspectRatio, group transforms). @@ -1325,87 +1328,110 @@ return { compact, painted }; } - // Flag only the documented bug: rendered endpoints miss, but user-space-as-screen would attach. - function connectorDetachmentIssues(root, rootRect, time) { - const issues = []; - let anchors = null; - // Attach near-miss tolerance (screen px). Separate from the closed-glyph chord floor. - const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02); - const MIN_CONNECTOR_CHORD_PX = 8; + // Attach near-miss tolerance (screen px), shared by both connector findings. Separate from + // the closed-glyph chord floor. + function connectorAttachThreshold(rootRect) { + return Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02); + } + + /** + * The one owner of connector enumeration. Both connector findings (`connector_detached`, + * `connector_orphan`) consume this so a change to what counts as a connector lands in both. + * + * A candidate is a `` inside a visible `` with connector intent (marker or name), + * outside decoration-only containers, whose endpoints resolve in both user and screen space. + * Dash-hidden shafts (see `shaftDashHidden`) are filtered out: nothing renders, so no finding + * about where it renders can apply. `painted` (display/visibility/opacity, see + * `shaftIsPainted`) is reported rather than filtered — `connector_orphan` is defined on a + * visible shaft and skips unpainted ones, `connector_detached` does not yet gate on it. + * `chord` is the rendered span in screen px: closed/glyph paths collapse to one point, so + * callers threshold it in px, not user units. + */ + function* connectorShafts(root) { for (const svg of Array.from(root.querySelectorAll("svg"))) { if (!isVisibleElement(svg)) continue; for (const path of Array.from(svg.querySelectorAll("path"))) { if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue; if (!isConnectorPath(svg, path)) continue; - if (shaftDashHidden(path)) continue; const user = pathUserEndpoints(path); + if (!user || shaftDashHidden(path, user.total)) continue; const rendered = pathScreenEndpoints(svg, path, user); - if (!user || !rendered) continue; - // Closed/glyph paths collapse to one point — compare in screen px (not user units). - const renderedChord = Math.hypot( + if (!rendered) continue; + const chord = Math.hypot( rendered.end.x - rendered.start.x, rendered.end.y - rendered.start.y, ); - if (renderedChord < MIN_CONNECTOR_CHORD_PX) continue; - if (anchors === null) anchors = connectorAnchorRects(root, rootRect); - if (anchors.compact.length < 2) return issues; - // Stable DOM identity across painted (inside) and compact (near-miss) tiers. - const attachmentKey = (point) => { - for (const anchor of anchors.painted) { - if (!anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0) { - return anchor.element; - } - } - for (const anchor of anchors.compact) { - if (distanceToRect(point, anchor.rect) <= threshold) return anchor.element; - } - return null; - }; - const attached = (point) => attachmentKey(point) !== null; - // Half-attached as drawn is allowed; only full render-miss proceeds. - if (attached(rendered.start) || attached(rendered.end)) continue; - // Paste-into-`d` bug: both raw endpoints land on distinct anchors as screen pixels. - const userStartKey = attachmentKey(user.start); - const userEndKey = attachmentKey(user.end); - const pasteBug = Boolean(userStartKey && userEndKey && userStartKey !== userEndKey); - // Guessed marked shaft: both frames miss. Same-anchor grazes attach in user-space - // and must stay skipped. Name-only decorative flow/arrow paths stay skipped. - // 80px keeps short marker glyphs (chevrons, tips) out. - const markedMiss = - renderedChord >= 80 && - !userStartKey && - !userEndKey && - (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")); - if (!pasteBug && !markedMiss) continue; - const gap = Math.round( - Math.min( - Math.min(...anchors.compact.map((a) => distanceToRect(rendered.start, a.rect))), - Math.min(...anchors.compact.map((a) => distanceToRect(rendered.end, a.rect))), - ), - ); - issues.push({ - code: "connector_detached", - severity: "warning", - time, - selector: selectorFor(path), - containerSelector: selectorFor(svg), - message: pasteBug - ? `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.` - : `Connector path endpoints render ${gap}px from the nearest anchorable element — a marked shaft that meets no node.`, - rect: toRect({ - left: Math.min(rendered.start.x, rendered.end.x), - top: Math.min(rendered.start.y, rendered.end.y), - right: Math.max(rendered.start.x, rendered.end.x), - bottom: Math.max(rendered.start.y, rendered.end.y), - width: Math.abs(rendered.end.x - rendered.start.x), - height: Math.abs(rendered.end.y - rendered.start.y), - }), - fixHint: pasteBug - ? "Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage." - : "Measure the settled node boxes and write `d` in the SVG's user space (invert getScreenCTM), or grow a layout-owned shaft from the source node.", - }); + yield { svg, path, user, rendered, chord, painted: shaftIsPainted(path) }; } } + } + + // Flag only the documented bug: rendered endpoints miss, but user-space-as-screen would attach. + function connectorDetachmentIssues(root, rootRect, time) { + const issues = []; + let anchors = null; + const threshold = connectorAttachThreshold(rootRect); + const MIN_CONNECTOR_CHORD_PX = 8; + for (const { svg, path, user, rendered, chord } of connectorShafts(root)) { + if (chord < MIN_CONNECTOR_CHORD_PX) continue; + if (anchors === null) anchors = connectorAnchorRects(root, rootRect); + if (anchors.compact.length < 2) return issues; + // Stable DOM identity across painted (inside) and compact (near-miss) tiers. + const attachmentKey = (point) => { + for (const anchor of anchors.painted) { + if (!anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0) { + return anchor.element; + } + } + for (const anchor of anchors.compact) { + if (distanceToRect(point, anchor.rect) <= threshold) return anchor.element; + } + return null; + }; + const attached = (point) => attachmentKey(point) !== null; + // Half-attached as drawn is allowed; only full render-miss proceeds. + if (attached(rendered.start) || attached(rendered.end)) continue; + // Paste-into-`d` bug: both raw endpoints land on distinct anchors as screen pixels. + const userStartKey = attachmentKey(user.start); + const userEndKey = attachmentKey(user.end); + const pasteBug = Boolean(userStartKey && userEndKey && userStartKey !== userEndKey); + // Guessed marked shaft: both frames miss. Same-anchor grazes attach in user-space + // and must stay skipped. Name-only decorative flow/arrow paths stay skipped. + // 80px keeps short marker glyphs (chevrons, tips) out. + const markedMiss = + chord >= 80 && + !userStartKey && + !userEndKey && + (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")); + if (!pasteBug && !markedMiss) continue; + const gap = Math.round( + Math.min( + Math.min(...anchors.compact.map((a) => distanceToRect(rendered.start, a.rect))), + Math.min(...anchors.compact.map((a) => distanceToRect(rendered.end, a.rect))), + ), + ); + issues.push({ + code: "connector_detached", + severity: "warning", + time, + selector: selectorFor(path), + containerSelector: selectorFor(svg), + message: pasteBug + ? `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.` + : `Connector path endpoints render ${gap}px from the nearest anchorable element — a marked shaft that meets no node.`, + rect: toRect({ + left: Math.min(rendered.start.x, rendered.end.x), + top: Math.min(rendered.start.y, rendered.end.y), + right: Math.max(rendered.start.x, rendered.end.x), + bottom: Math.max(rendered.start.y, rendered.end.y), + width: Math.abs(rendered.end.x - rendered.start.x), + height: Math.abs(rendered.end.y - rendered.start.y), + }), + fixHint: pasteBug + ? "Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage." + : "Measure the settled node boxes and write `d` in the SVG's user space (invert getScreenCTM), or grow a layout-owned shaft from the source node.", + }); + } return issues; } @@ -1422,19 +1448,67 @@ return opacityChain(path) >= 0.2; } - function shaftDashHidden(path) { - if (typeof path.getTotalLength !== "function") return false; - let total; - try { - total = path.getTotalLength(); - } catch { - return false; - } - if (!Number.isFinite(total) || total <= 0) return false; + /** + * True when the stroke's dash pattern currently paints nothing: the window of the pattern the + * shaft shows, `[dashoffset, dashoffset + length]`, sits inside a single gap — a draw-on + * entrance (`dasharray >= length; dashoffset >= length`) not yet advanced. Up to 10% of the + * length may still poke into a neighbouring dash (the tail of a nearly finished tween). Any + * whole dash inside the window means the stroke paints, so dashed patterns, `4 0`, a bare `0` + * (renders solid) and `none` are painted. A zero-length dash paints only as a line cap: `0 4` + * is a dotted line under `stroke-linecap: round | square` and invisible under the default + * `butt` (the state a finished draw-off tween leaves behind: `0px, 999999px`). Caps that a + * non-zero dash would add at the window edges are ignored. + */ + function shaftDashHidden(path, total) { const style = getComputedStyle(path); - const offset = Number.parseFloat(style.strokeDashoffset || "0"); - const dash = Number.parseFloat(String(style.strokeDasharray || "").split(/[\s,]+/)[0] || "0"); - return offset >= total * 0.9 && dash >= total * 0.9; + const dashes = dashArrayLengths(style.strokeDasharray, path); + if (dashes === null) return false; + const dotsPaint = (style.strokeLinecap || "butt") !== "butt"; + const dashPaints = (index) => index % 2 === 0 && (dashes[index] > 0 || dotsPaint); + if (!dashes.some((_, index) => dashPaints(index))) return true; // only butt-capped dots + const period = dashes.reduce((sum, length) => sum + length, 0); + if (total > period) return false; // a full period of dash paints — call it visible + const offset = dashLength(style.strokeDashoffset, path); // unparseable reads as 0 + const start = Number.isFinite(offset) ? ((offset % period) + period) % period : 0; + const end = start + total; + let painted = 0; + let segmentStart = 0; + // Two periods cover any window that starts inside the first. + for (let i = 0; i < dashes.length * 2; i++) { + const segmentEnd = segmentStart + dashes[i % dashes.length]; + if (dashPaints(i % dashes.length)) { + if (segmentStart >= start && segmentEnd <= end) return false; + painted += Math.max(0, Math.min(segmentEnd, end) - Math.max(segmentStart, start)); + } + segmentStart = segmentEnd; + } + return painted <= total * 0.1; + } + + // Computed `stroke-dasharray` as an even-length list of user-unit lengths, or null when the + // stroke is solid: `none`, an all-zero list, or any negative/unparseable entry (which the spec + // renders as `none`). Odd lists repeat, per spec. + function dashArrayLengths(value, path) { + const text = String(value || "none").trim(); + if (text === "none") return null; + const lengths = text.split(/[\s,]+/).map((token) => dashLength(token, path)); + if (lengths.some((length) => !Number.isFinite(length) || length < 0)) return null; + if (lengths.every((length) => length === 0)) return null; + return lengths.length % 2 === 0 ? lengths : lengths.concat(lengths); + } + + // One dash length in user units. Computed lengths are already px; a percentage is relative to + // the normalised diagonal of the owning SVG viewport (viewBox when set, else the layout box). + function dashLength(token, path) { + const text = String(token).trim(); + const value = Number.parseFloat(text); + if (!Number.isFinite(value) || !text.endsWith("%")) return value; + const svg = path.ownerSVGElement; + if (!svg) return NaN; + const box = svg.viewBox && svg.viewBox.baseVal; + const { width, height } = + box && box.width > 0 && box.height > 0 ? box : svg.getBoundingClientRect(); + return (value / 100) * (Math.hypot(width, height) / Math.SQRT2); } function connectorEndpointCandidates(root, rootRect) { @@ -1457,57 +1531,45 @@ function connectorOrphanIssues(root, rootRect, time) { const issues = []; let candidates = null; - const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02); - for (const svg of Array.from(root.querySelectorAll("svg"))) { - if (!isVisibleElement(svg)) continue; - for (const path of Array.from(svg.querySelectorAll("path"))) { - if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue; - if (!isConnectorPath(svg, path)) continue; - if (!shaftIsPainted(path) || shaftDashHidden(path)) continue; - const user = pathUserEndpoints(path); - const rendered = pathScreenEndpoints(svg, path, user); - if (!user || !rendered) continue; - const renderedChord = Math.hypot( - rendered.end.x - rendered.start.x, - rendered.end.y - rendered.start.y, - ); - if (renderedChord < 80) continue; - if (candidates === null) candidates = connectorEndpointCandidates(root, rootRect); - const dark = []; - for (const point of [rendered.start, rendered.end]) { - let best = null; - let attached = false; - for (const candidate of candidates) { - const gap = distanceToRect(point, candidate.rect); - if (gap > threshold) continue; - if (isVisibleElement(candidate.element)) { - attached = true; - break; - } - if (best === null || gap < best.gap) best = { gap, candidate }; + const threshold = connectorAttachThreshold(rootRect); + for (const { svg, path, rendered, chord, painted } of connectorShafts(root)) { + if (!painted) continue; + if (chord < 80) continue; + if (candidates === null) candidates = connectorEndpointCandidates(root, rootRect); + const dark = []; + for (const point of [rendered.start, rendered.end]) { + let best = null; + let attached = false; + for (const candidate of candidates) { + const gap = distanceToRect(point, candidate.rect); + if (gap > threshold) continue; + if (isVisibleElement(candidate.element)) { + attached = true; + break; } - if (!attached && best !== null) dark.push(best.candidate); + if (best === null || gap < best.gap) best = { gap, candidate }; } - if (dark.length === 0) continue; - issues.push({ - code: "connector_orphan", - severity: "warning", - time, - selector: selectorFor(path), - containerSelector: selectorFor(svg), - message: `Connector shaft is visible while ${dark.length === 2 ? "both endpoints are" : `its endpoint ${selectorFor(dark[0].element)} is`} not on stage.`, - rect: toRect({ - left: Math.min(rendered.start.x, rendered.end.x), - top: Math.min(rendered.start.y, rendered.end.y), - right: Math.max(rendered.start.x, rendered.end.x), - bottom: Math.max(rendered.start.y, rendered.end.y), - width: Math.abs(rendered.end.x - rendered.start.x), - height: Math.abs(rendered.end.y - rendered.start.y), - }), - fixHint: - "Show the shaft only after both ends are on, and hide it with the earlier exit. Do not give the line its own clock.", - }); + if (!attached && best !== null) dark.push(best.candidate); } + if (dark.length === 0) continue; + issues.push({ + code: "connector_orphan", + severity: "warning", + time, + selector: selectorFor(path), + containerSelector: selectorFor(svg), + message: `Connector shaft is visible while ${dark.length === 2 ? "both endpoints are" : `its endpoint ${selectorFor(dark[0].element)} is`} not on stage.`, + rect: toRect({ + left: Math.min(rendered.start.x, rendered.end.x), + top: Math.min(rendered.start.y, rendered.end.y), + right: Math.max(rendered.start.x, rendered.end.x), + bottom: Math.max(rendered.start.y, rendered.end.y), + width: Math.abs(rendered.end.x - rendered.start.x), + height: Math.abs(rendered.end.y - rendered.start.y), + }), + fixHint: + "Show the shaft only after both ends are on, and hide it with the earlier exit. Do not give the line its own clock.", + }); } return issues; } diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 65564b6823..84b4d2370b 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1079,6 +1079,46 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]); }); + // The dash gate reads the whole pattern: only a stroke whose visible window sits inside one + // gap is hidden. Dashed patterns, a bare `0` (renders solid) and `none` all paint; a + // zero-length dash paints only as a round/square cap (`0 4` is dotted with round caps and + // invisible with the default butt cap). Path length is 100 (installConnectorGeometry); + // `50 100` at offset 40 leaves exactly 10% painted — the tolerance boundary — while offset 30 + // shows 20% and fires. + it.each([ + { dasharray: "0 4", offset: "0", count: 0 }, + { dasharray: "0 4", offset: "0", linecap: "round", count: 1 }, + { dasharray: "0, 4", offset: "0", linecap: "square", count: 1 }, + { dasharray: "0px, 999999px", offset: "-99.999px", count: 0 }, + { dasharray: "0 400", offset: "0", linecap: "round", count: 1 }, + { dasharray: "0 400", offset: "1", linecap: "round", count: 0 }, + { dasharray: "4 0", offset: "0", count: 1 }, + { dasharray: "0", offset: "0", count: 1 }, + { dasharray: "none", offset: "0", count: 1 }, + { dasharray: "100px", offset: "100px", count: 0 }, + { dasharray: "100", offset: "-100", count: 0 }, + { dasharray: "50 100", offset: "50", count: 0 }, + { dasharray: "50 100", offset: "40", count: 0 }, + { dasharray: "50 100", offset: "30", count: 1 }, + ])( + "stroke-dasharray $dasharray, dashoffset $offset, linecap $linecap → $count connector_detached", + ({ dasharray, offset, linecap, count }) => { + document.body.innerHTML = foreignFrameDom; + installGeometry(foreignFrameRects, { + ...foreignFrameStyles, + detached: { + strokeDasharray: dasharray, + strokeDashoffset: offset, + ...(linecap ? { strokeLinecap: linecap } : {}), + }, + }); + installConnectorGeometry({ e: 80, f: 227 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_detached")).toHaveLength(count); + }, + ); + it("skips svgs and paths without connector intent", () => { document.body.innerHTML = `