From daafb79c43a303f248dbaad2a96345f570d7ba82 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Tue, 15 Sep 2026 02:31:29 +0000 Subject: [PATCH 1/2] fix(cli): connector dash gate honours pathLength MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shaftDashHidden` compared the stroke's dash window against `getTotalLength()`, but a `pathLength` attribute rescales every distance along the path, so the CSS draw-on idiom (`` with `stroke-dasharray: 1; stroke-dashoffset: 1`) — which renders zero ink — still fired `connector_detached`. The window is now judged in the author's units: `pathLength` when set (`SVGAnimatedNumber.baseVal`, 0 when unset), the real length otherwise. `dashLength`/`dashArrayLengths` stay the only owners of parsing and units. Two adjacent changes to the gate's arithmetic: - The `total > period` shortcut is removed so 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, like any other stroke painting under 10%; any pattern that does enclose a whole dash is still caught by the in-loop check. - The dash-offset wrap adds the period only to negative remainders. The previous `((offset % period) + period) % period` is off by one ulp for fractional offsets (`0.9 % 2` → 0.8999…), which flipped the exact 10% boundary once dashes are expressed in `pathLength` units. Tests: rows for `pathLength="1"` (`1/1` hidden, `1/0.9` on the boundary hidden, `1/0.5` painted, `0.1 0.9` round dot painted), a negative offset that only the wrap gets right (`100` at -150), a percentage row (`10%` resolves against the viewport, not as a bare number), and the `2 97` full-period row. `installConnectorGeometry` mirrors the DOM's `pathLength` for happy-dom, which has no SVGGeometryElement. Co-Authored-By: Miguel Ángel --- .../cli/src/commands/layout-audit.browser.js | 19 ++++++++----- .../src/commands/layout-audit.browser.test.ts | 27 ++++++++++++++++--- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index d66ce2da1a..28054bcbae 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1457,7 +1457,11 @@ * (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. */ function shaftDashHidden(path, total) { const style = getComputedStyle(path); @@ -1466,11 +1470,14 @@ 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, negatives invalid + 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 +1489,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..bc6cae5abc 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1084,7 +1084,13 @@ 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. it.each([ { dasharray: "0 4", offset: "0", count: 0 }, { dasharray: "0 4", offset: "0", linecap: "round", count: 1 }, @@ -1097,13 +1103,23 @@ 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 }, ])( - "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: { @@ -3043,6 +3059,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` (0 when unset). + Object.defineProperty(path, "pathLength", { + ...prop, + value: { baseVal: Number(path.getAttribute("pathLength")) || 0 }, + }); Object.defineProperty(path, "getPointAtLength", { ...prop, value: (length: number) => (length === 0 ? start : end), From e61ec8d3669b119486f78ac62da42b55be794f28 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Tue, 15 Sep 2026 06:11:39 +0000 Subject: [PATCH 2/2] fix(cli): treat a zero pathLength as a solid stroke in the dash gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A present `pathLength` that resolves to 0 (`"0"` or an unparseable value) zeroes the browser's dash scale factor, so the stroke paints solid. The gate saw `baseVal` 0, fell back to the real length, judged the window inside a gap and dropped the shaft, suppressing a real `connector_detached` finding. Return painted for that case. Add a `pathLength=0` row to the dash table and make the happy-dom `pathLength` mirror parse like the DOM (finite SVG number only; `Infinity`, hex, `1.` read as 0). Co-Authored-By: Miguel Ángel --- .../cli/src/commands/layout-audit.browser.js | 7 +++++-- .../src/commands/layout-audit.browser.test.ts | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 28054bcbae..f48a5bdeb1 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1461,7 +1461,8 @@ * 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. + * `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); @@ -1471,7 +1472,9 @@ 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, dash) => sum + dash, 0); - const authored = path.pathLength?.baseVal; // SVGAnimatedNumber; 0 when unset, negatives invalid + 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 // Wrap into [0, period); adding the period only to negatives keeps `0.9 % 2` exact. diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index bc6cae5abc..be9f9ffa56 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1091,6 +1091,7 @@ describe("layout-audit.browser coordinate-frame findings", () => { // `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 }, @@ -1113,6 +1114,7 @@ describe("layout-audit.browser coordinate-frame findings", () => { { 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, pathLength $pathLength → $count connector_detached", ({ dasharray, offset, linecap, pathLength, count }) => { @@ -3040,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 }; @@ -3059,10 +3072,10 @@ 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` (0 when unset). + // happy-dom has no SVGGeometryElement; mirror the DOM's `pathLength`. Object.defineProperty(path, "pathLength", { ...prop, - value: { baseVal: Number(path.getAttribute("pathLength")) || 0 }, + value: { baseVal: domPathLength(path.getAttribute("pathLength")) }, }); Object.defineProperty(path, "getPointAtLength", { ...prop,