diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index d66ce2da1a..f48a5bdeb1 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1457,7 +1457,12 @@ * (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. + * non-zero dash would add at the window edges are ignored. The 10% rule applies uniformly: a + * window that spans a whole period without enclosing a whole dash (`2 97` on a 100-long path + * at offset 0.5 paints 3%) is hidden too. A `pathLength` attribute rescales every distance + * along the path, so the window is judged in the author's units: the CSS draw-on idiom + * `pathLength="1"` with `dasharray 1; dashoffset 1` is hidden. A `pathLength` that resolves + * to 0 (`"0"` or an unparseable value) zeroes the dash scale, so the stroke paints solid. */ function shaftDashHidden(path, total) { const style = getComputedStyle(path); @@ -1466,11 +1471,16 @@ 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 authored = path.pathLength?.baseVal; // SVGAnimatedNumber; 0 when unset or unparseable + // A present `pathLength` that resolves to 0 zeroes the dash scale, so the stroke paints solid. + if (authored === 0 && path.hasAttribute("pathLength")) return false; + 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. @@ -1482,7 +1492,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 84b4d2370b..be9f9ffa56 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1084,7 +1084,14 @@ describe("layout-audit.browser coordinate-frame findings", () => { // 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. + // shows 20% and fires. `2 97` at offset 0.5 spans a whole period yet encloses no whole dash + // and paints 3% — hidden; there is no full-period shortcut. A negative offset wraps into the + // period (`100` at -150 shows half a dash; unwrapped it would read as hidden). Percentages + // resolve against the SVG viewport (1740×830 → `10%` ≈ 136), not as bare numbers. + // `pathLength` puts the dashes and the offset in the author's units, so `pathLength="1"` with + // `1 / 1` — the CSS draw-on idiom — is hidden, `1 / 0.9` sits on the 10% boundary (hidden, + // exact in fractional units too) while `1 / 0.5` and a `0.1 0.9` dot on the same path paint. + // A `pathLength` that resolves to 0 zeroes the dash scale and the stroke paints solid. it.each([ { dasharray: "0 4", offset: "0", count: 0 }, { dasharray: "0 4", offset: "0", linecap: "round", count: 1 }, @@ -1097,13 +1104,24 @@ 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 }, ])( - "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: { @@ -3024,6 +3042,17 @@ interface CtmTranslate { } // happy-dom has no SVG geometry APIs; endpoints come from the path's `d`, the CTM is a pure translate. +/** + * `SVGGeometryElement.pathLength.baseVal` as the DOM computes it: a finite SVG number (one + * trailing comma allowed); anything else (unset, `1.`, `Infinity`, hex, garbage) reads as 0. + */ +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; +} + function installConnectorGeometry(translate: CtmTranslate, root: ParentNode = document): void { const matrix = { a: 1, b: 0, c: 0, d: 1, e: translate.e, f: translate.f }; const prop = { configurable: true, writable: true }; @@ -3043,6 +3072,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),