diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 091c6c432b2..8a9c90485ad 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1612,18 +1612,23 @@ // True when the visible dash window [offset, offset + length] sits in one gap (an unstarted // draw-on), allowing 10% overlap. Zero-length dashes paint only with a round or square linecap. + // Distances are in `pathLength` units when set; a `pathLength` resolving to 0 paints solid. function shaftDashHidden(path, total) { const style = getComputedStyle(path); const dashes = dashArrayLengths(style.strokeDasharray, path); if (dashes === null) return false; + const authored = path.pathLength?.baseVal; // SVGAnimatedNumber; 0 when unset or unparseable + if (authored === 0 && path.hasAttribute("pathLength")) 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 period = dashes.reduce((sum, dash) => sum + dash, 0); + const length = authored > 0 ? authored : total; const offset = dashLength(style.strokeDashoffset, path); // unparseable reads as 0 - const start = Number.isFinite(offset) ? ((offset % period) + period) % period : 0; - const end = start + total; + // Wrap into [0, period); adding the period only to negatives keeps `0.9 % 2` exact. + const wrapped = Number.isFinite(offset) ? offset % period : 0; + const start = wrapped < 0 ? wrapped + period : wrapped; + const end = start + length; let painted = 0; let segmentStart = 0; // Two periods cover any window that starts inside the first. @@ -1635,7 +1640,7 @@ } segmentStart = segmentEnd; } - return painted <= total * 0.1; + return painted <= length * 0.1; } // Computed `stroke-dasharray` as an even-length list of user-unit lengths, or null when the diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 7978057d74f..82c2be313a7 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1528,13 +1528,25 @@ describe("layout-audit.browser coordinate-frame findings", () => { { dasharray: "none", offset: "0", count: 1 }, { dasharray: "100px", offset: "100px", count: 0 }, { dasharray: "100", offset: "-100", count: 0 }, + { dasharray: "100", offset: "-150", count: 1 }, + { dasharray: "10%", offset: "10%", count: 0 }, { dasharray: "50 100", offset: "50", count: 0 }, { dasharray: "50 100", offset: "40", count: 0 }, { dasharray: "50 100", offset: "30", count: 1 }, + { dasharray: "2 97", offset: "0.5", count: 0 }, + { dasharray: "1", offset: "1", pathLength: 1, count: 0 }, + { dasharray: "1", offset: "0.9", pathLength: 1, count: 0 }, + { dasharray: "1", offset: "0.5", pathLength: 1, count: 1 }, + { dasharray: "0.1 0.9", offset: "0", pathLength: 1, linecap: "round", count: 1 }, + { dasharray: "100", offset: "100", pathLength: 0, count: 1 }, + { dasharray: "0 4", offset: "0", pathLength: 0, count: 1 }, ])( - "stroke-dasharray $dasharray, dashoffset $offset, linecap $linecap → $count connector_detached", - ({ dasharray, offset, linecap, count }) => { + "stroke-dasharray $dasharray, dashoffset $offset, linecap $linecap, pathLength $pathLength → $count connector_detached", + ({ dasharray, offset, linecap, pathLength, count }) => { document.body.innerHTML = foreignFrameDom; + if (pathLength !== undefined) { + document.getElementById("detached")?.setAttribute("pathLength", String(pathLength)); + } installGeometry(foreignFrameRects, { ...foreignFrameStyles, detached: { @@ -3473,6 +3485,14 @@ interface CtmTranslate { f: number; } +// `pathLength.baseVal` as the DOM computes it: a finite SVG number, else 0 (unset, `1.`, `Infinity`, hex). +function domPathLength(attr: string | null): number { + if (attr === null) return 0; + const svgNumber = /^\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?(?:\s*,)?\s*$/.test(attr); + const parsed = svgNumber ? Number(attr.replace(",", "")) : 0; + return Number.isFinite(parsed) ? parsed : 0; +} + // happy-dom has no SVG geometry APIs; endpoints come from the path's `d`, the CTM is a pure translate. function installConnectorGeometry(translate: CtmTranslate, root: ParentNode = document): void { const matrix = { a: 1, b: 0, c: 0, d: 1, e: translate.e, f: translate.f }; @@ -3493,6 +3513,11 @@ function installConnectorGeometry(translate: CtmTranslate, root: ParentNode = do const start = { x: numbers[0] ?? 0, y: numbers[1] ?? 0 }; const end = { x: numbers[numbers.length - 2] ?? 0, y: numbers[numbers.length - 1] ?? 0 }; Object.defineProperty(path, "getTotalLength", { ...prop, value: () => 100 }); + // happy-dom has no SVGGeometryElement; mirror the DOM's `pathLength`. + Object.defineProperty(path, "pathLength", { + ...prop, + value: { baseVal: domPathLength(path.getAttribute("pathLength")) }, + }); Object.defineProperty(path, "getPointAtLength", { ...prop, value: (length: number) => (length === 0 ? start : end),