From fe237569158ec37c7d3183b500a76b60444e1a95 Mon Sep 17 00:00:00 2001 From: xuanru Date: Sun, 6 Sep 2026 10:10:57 +0000 Subject: [PATCH 01/14] feat(lint): catch leftover marker heads and guessed marked shafts Dash-draw on a marked path shows the arrowhead before the shaft exists. A long marked path that misses every node in both user and screen space is the same detach as the CTM-paste bug, without the counterfactual. --- .../cli/src/commands/layout-audit.browser.js | 20 +- .../src/commands/layout-audit.browser.test.ts | 32 +++ packages/lint/src/hyperframeLinter.ts | 2 + packages/lint/src/rules/connectors.test.ts | 141 +++++++++++++ packages/lint/src/rules/connectors.ts | 190 ++++++++++++++++++ 5 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 packages/lint/src/rules/connectors.test.ts create mode 100644 packages/lint/src/rules/connectors.ts diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 919b7ed59e..724a62b0bf 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1326,7 +1326,16 @@ // Paste-into-`d` bug: both raw endpoints land on distinct anchors as screen pixels. const userStartKey = attachmentKey(user.start); const userEndKey = attachmentKey(user.end); - if (!userStartKey || !userEndKey || userStartKey === userEndKey) continue; + 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))), @@ -1339,7 +1348,9 @@ time, selector: selectorFor(path), containerSelector: selectorFor(svg), - message: `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.`, + 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), @@ -1348,8 +1359,9 @@ width: Math.abs(rendered.end.x - rendered.start.x), height: Math.abs(rendered.end.y - rendered.start.y), }), - fixHint: - "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.", + 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.", }); } } diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 783b5e877a..9007e78f50 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1025,6 +1025,38 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]); }); + it("flags a long marked shaft whose rendered ends miss every node", () => { + document.body.innerHTML = ` +
+
+
+ + + + +
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + n1: rect({ left: 900, top: 400, width: 160, height: 160 }), + n2: rect({ left: 1400, top: 400, width: 160, height: 160 }), + "schematic-svg": rect({ left: 0, top: 0, width: 1920, height: 1080 }), + }, + { + n1: { backgroundColor: "rgb(30, 40, 50)" }, + n2: { backgroundColor: "rgb(30, 40, 50)" }, + }, + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + const issues = runAudit().filter((issue) => issue.code === "connector_detached"); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ selector: "#path-input" }); + expect(issues[0]?.message).toContain("marked shaft that meets no node"); + }); + // Same DOM node via painted-inside + compact-near-miss must share one identity (not p0 vs c0). it("skips same-anchor cross-tier arrows that only graze one node", () => { document.body.innerHTML = ` diff --git a/packages/lint/src/hyperframeLinter.ts b/packages/lint/src/hyperframeLinter.ts index ae6aade898..beb22ae7f4 100644 --- a/packages/lint/src/hyperframeLinter.ts +++ b/packages/lint/src/hyperframeLinter.ts @@ -17,6 +17,7 @@ import { adapterRules } from "./rules/adapters"; import { textureRules } from "./rules/textures"; import { fontRules } from "./rules/fonts"; import { slideshowRules } from "./rules/slideshow"; +import { connectorRules } from "./rules/connectors"; // Rules are grouped by source module so a timing can be attributed to // something a human can act on. Individual rules stay anonymous: an @@ -43,6 +44,7 @@ const RULE_GROUPS: ReadonlyArray<{ { group: "textures", rules: textureRules }, { group: "fonts", rules: fontRules }, { group: "slideshow", rules: slideshowRules }, + { group: "connectors", rules: connectorRules }, ]; /** diff --git a/packages/lint/src/rules/connectors.test.ts b/packages/lint/src/rules/connectors.test.ts new file mode 100644 index 0000000000..bb7e2166ee --- /dev/null +++ b/packages/lint/src/rules/connectors.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { lintHyperframeHtml } from "../hyperframeLinter.js"; + +async function codes(html: string): Promise { + const result = await lintHyperframeHtml(html); + return result.findings.map((f) => f.code); +} + +const SHELL = `
`; + +describe("connector rules", () => { + it("errors on orientation= on a marker", async () => { + const html = ` + ${SHELL} + + + + + + `; + expect(await codes(html)).toContain("marker_orient_typo"); + }); + + it("does not treat data-orientation as the typo", async () => { + const html = ` + ${SHELL} + + + + + + `; + expect(await codes(html)).not.toContain("marker_orient_typo"); + }); + + it("errors when a quoted selector draws a marked path with strokeDashoffset", async () => { + const html = ` + ${SHELL} + + + + + + `; + expect(await codes(html)).toContain("marker_dash_draw_on"); + }); + + it("errors when a getElementById alias is the dash target", async () => { + const html = ` + ${SHELL} + + + + + + `; + expect(await codes(html)).toContain("marker_dash_draw_on"); + }); + + it("errors when the alias sits in a GSAP array target", async () => { + const html = ` + ${SHELL} + + + + + + + `; + const result = await lintHyperframeHtml(html); + const dash = result.findings.filter((f) => f.code === "marker_dash_draw_on"); + expect(dash.map((f) => f.elementId).sort()).toEqual(["path-input", "path-primary"]); + }); + + it("does not treat a tag selector as an id hit", async () => { + const html = ` + ${SHELL} + + + + + + `; + expect(await codes(html)).not.toContain("marker_dash_draw_on"); + }); + + it("does not flag a marked path when dash is on a different element", async () => { + const html = ` + ${SHELL} + + + + + + + `; + expect(await codes(html)).not.toContain("marker_dash_draw_on"); + }); + + it("does not flag a marked path revealed by opacity only", async () => { + const html = ` + ${SHELL} + + + + + + `; + expect(await codes(html)).not.toContain("marker_dash_draw_on"); + }); +}); diff --git a/packages/lint/src/rules/connectors.ts b/packages/lint/src/rules/connectors.ts new file mode 100644 index 0000000000..9e7379d7a0 --- /dev/null +++ b/packages/lint/src/rules/connectors.ts @@ -0,0 +1,190 @@ +import type { LintContext, HyperframeLintFinding } from "../context"; +import { readAttr, truncateSnippet } from "../utils"; +import type { LintRule } from "../types"; + +const MARKER_TAGS = new Set(["path", "line", "polyline", "polygon"]); +const GSAP_CALL_RE = /\.\s*(?:fromTo|from|to|set)\s*\(/gi; +const EL_ALIAS_RE = + /\b([A-Za-z_$][\w$]*)\s*=\s*(?:document\.)?(?:getElementById\s*\(\s*['"]([^'"]+)['"]\s*\)|querySelector\s*\(\s*['"]#([^'"]+)['"]\s*\))/g; + +// Quote-aware: a naive depth scan closes inside strings / ${}. +// fallow-ignore-next-line complexity +function matchingParen(source: string, openIdx: number): number { + if (source[openIdx] !== "(") return -1; + let depth = 0; + let inStr: string | null = null; + let tmpl = 0; + for (let i = openIdx; i < source.length; i++) { + const c = source[i]; + if (inStr === "`") { + if (c === "\\" && i + 1 < source.length) { + i += 1; + continue; + } + if (c === "`" && tmpl === 0) { + inStr = null; + continue; + } + if (c === "$" && source[i + 1] === "{") { + tmpl += 1; + i += 1; + continue; + } + if (c === "}" && tmpl > 0) { + tmpl -= 1; + continue; + } + if (tmpl > 0) { + if (c === "(") depth += 1; + else if (c === ")") { + depth -= 1; + if (depth === 0) return i; + } + } + continue; + } + if (inStr === "'" || inStr === '"') { + if (c === "\\" && i + 1 < source.length) { + i += 1; + continue; + } + if (c === inStr) inStr = null; + continue; + } + if (c === "'" || c === '"' || c === "`") { + inStr = c; + continue; + } + if (c === "(") depth += 1; + else if (c === ")") { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +function selectorHitsId(sel: string, elementId: string): boolean { + const trimmed = sel.trim(); + if (trimmed === `#${elementId}`) return true; + return new RegExp( + `(?:^|[\\s,>|+~])#${elementId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:$|[\\s,>|+~.:\\[])`, + ).test(trimmed); +} + +function aliasesIn(script: string): Map { + const out = new Map(); + for (const m of script.matchAll(EL_ALIAS_RE)) { + const elementId = m[2] || m[3]; + if (m[1] && elementId) out.set(m[1], elementId); + } + return out; +} + +type DashTarget = { kind: "sel" | "var"; value: string; index: number }; + +function tokenToDashTarget(token: string, callIndex: number): DashTarget | null { + const quoted = token.match(/^(['"])([^'"]+)\1$/); + if (quoted?.[2]) return { kind: "sel", value: quoted[2], index: callIndex }; + if (/^[A-Za-z_$][\w$]*$/.test(token)) return { kind: "var", value: token, index: callIndex }; + return null; +} + +function firstArgDashTargets(args: string, callIndex: number): DashTarget[] { + const trimmed = args.trimStart(); + const quote = trimmed.match(/^(['"])([^'"]+)\1\s*,/); + if (quote?.[2]) return [{ kind: "sel", value: quote[2], index: callIndex }]; + const array = trimmed.match(/^\[\s*([^[\]]+)\]\s*,/); + if (array?.[1]) { + return array[1] + .split(",") + .map((part) => tokenToDashTarget(part.trim(), callIndex)) + .filter((hit): hit is DashTarget => hit !== null); + } + const ident = trimmed.match(/^([A-Za-z_$][\w$]*)\s*,/); + if (ident?.[1]) return [{ kind: "var", value: ident[1], index: callIndex }]; + return []; +} + +function dashHitsId(target: DashTarget, elementId: string, aliases: Map): boolean { + return target.kind === "sel" + ? selectorHitsId(target.value, elementId) + : aliases.get(target.value) === elementId; +} + +function dashTargets(script: string): DashTarget[] { + const hits: DashTarget[] = []; + for (const m of script.matchAll(GSAP_CALL_RE)) { + const open = script.indexOf("(", m.index ?? 0); + if (open < 0) continue; + const close = matchingParen(script, open); + if (close < 0) continue; + const args = script.slice(open + 1, close); + if (!/strokeDashoffset/i.test(args)) continue; + hits.push(...firstArgDashTargets(args, m.index ?? 0)); + } + return hits; +} + +function markerOrientFindings(ctx: LintContext): HyperframeLintFinding[] { + const findings: HyperframeLintFinding[] = []; + for (const tag of ctx.tags) { + if (tag.name !== "marker") continue; + if (!/(?:^|[\s"'])orientation\s*=/i.test(tag.attrs)) continue; + findings.push({ + code: "marker_orient_typo", + severity: "error", + message: + "SVG uses invalid attribute orientation= — browsers ignore it and default to orient=0 " + + "(arrowhead pinned to +x / screen-right).", + elementId: readAttr(tag.raw, "id") ?? undefined, + snippet: truncateSnippet(tag.raw), + fixHint: + 'Use orient="auto" (or orient="auto-start-reverse" for bidirectional ends). Never orientation=.', + }); + } + return findings; +} + +function idsWithHtmlMarkers(ctx: LintContext): Set { + const ids = new Set(); + for (const tag of ctx.tags) { + if (!MARKER_TAGS.has(tag.name)) continue; + if (!/\bmarker-(?:end|start)\s*=/i.test(tag.attrs)) continue; + const id = readAttr(tag.raw, "id"); + if (id) ids.add(id); + } + return ids; +} + +function markerDashFindings(ctx: LintContext): HyperframeLintFinding[] { + const ids = idsWithHtmlMarkers(ctx); + if (ids.size === 0) return []; + const findings: HyperframeLintFinding[] = []; + const seen = new Set(); + for (const script of ctx.scripts) { + const aliases = aliasesIn(script.content); + for (const target of dashTargets(script.content)) { + for (const elementId of ids) { + if (seen.has(elementId)) continue; + if (!dashHitsId(target, elementId, aliases)) continue; + seen.add(elementId); + findings.push({ + code: "marker_dash_draw_on", + severity: "error", + elementId, + message: + `#${elementId} has marker-end/marker-start and is animated with strokeDashoffset — ` + + "the marker still shows while the shaft is hidden, so a bare arrowhead pops in first.", + snippet: truncateSnippet(script.content.slice(target.index, target.index + 110)), + fixHint: + "Do not combine marker-* with strokeDashoffset draw-on. Finish the draw, then attach the " + + "marker / fade a separate head; or use a layout-owned scaleX/scaleY connector.", + }); + } + } + } + return findings; +} + +export const connectorRules: LintRule[] = [markerOrientFindings, markerDashFindings]; From 2bc64b8fad059250e431e89258cba02c274e3b2b Mon Sep 17 00:00:00 2001 From: xuanru Date: Mon, 7 Sep 2026 04:58:27 +0000 Subject: [PATCH 02/14] feat(check): flag orphan connectors and unbalanced style tags Catch a visible shaft while fewer than two nodes are on stage (enter-early / exit-late), and extra that dumps CSS onto the frame. Co-authored-by: Cursor --- .../cli/src/commands/layout-audit.browser.js | 77 ++++++++++++++++++ .../src/commands/layout-audit.browser.test.ts | 79 +++++++++++++++++++ packages/cli/src/utils/checkBrowser.ts | 1 + packages/cli/src/utils/layoutAudit.test.ts | 1 + packages/cli/src/utils/layoutAudit.ts | 2 + packages/lint/src/rules/core.test.ts | 25 ++++++ packages/lint/src/rules/core.ts | 20 +++++ 7 files changed, 205 insertions(+) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 724a62b0bf..e6b7a56bf7 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1368,6 +1368,82 @@ return issues; } + function svgConnectorLayer(svg) { + const tokens = connectorNameFor(svg) + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + return tokens.some( + (token) => + token === "connector" || + token === "connectors" || + token === "schematic" || + token === "schematics", + ); + } + + 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; + 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; + } + + // Shaft painted while fewer than two nodes are visible — enter-early or exit-late. + function connectorOrphanIssues(root, rootRect, time) { + const issues = []; + let anchors = null; + for (const svg of Array.from(root.querySelectorAll("svg"))) { + if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue; + for (const path of Array.from(svg.querySelectorAll("path"))) { + if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue; + const marked = path.hasAttribute("marker-start") || path.hasAttribute("marker-end"); + if (!marked && !svgConnectorLayer(svg)) continue; + if (!isVisibleElement(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 (anchors === null) anchors = connectorAnchorRects(root, rootRect); + if (anchors.compact.length >= 2) continue; + issues.push({ + code: "connector_orphan", + severity: "warning", + time, + selector: selectorFor(path), + containerSelector: selectorFor(svg), + message: + anchors.compact.length === 0 + ? "Connector shaft is visible while no node boxes are on stage." + : "Connector shaft is visible while only one node box is 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; + } + function candidateAnchor(element) { const dataAttributes = {}; for (const attribute of Array.from(element.attributes)) { @@ -1472,6 +1548,7 @@ issues.push(...escaped.issues); issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged)); issues.push(...connectorDetachmentIssues(root, rootRect, time)); + issues.push(...connectorOrphanIssues(root, rootRect, time)); 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 9007e78f50..25f599f62a 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1251,6 +1251,85 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]); }); + + it("flags a marked shaft when node boxes are hidden", () => { + document.body.innerHTML = ` +
+
+
+ + + + +
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + n1: rect({ left: 900, top: 400, width: 160, height: 160 }), + n2: rect({ left: 1400, top: 400, width: 160, height: 160 }), + connectors: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + }, + { + n1: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" }, + n2: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" }, + }, + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + const issues = runAudit().filter((issue) => issue.code === "connector_orphan"); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ selector: "#path-input" }); + expect(issues[0]?.message).toContain("no node boxes"); + }); + + it("does not orphan a shaft when two nodes are visible", () => { + document.body.innerHTML = ` +
+
+
+ + + +
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + n1: rect({ left: 900, top: 400, width: 160, height: 160 }), + n2: rect({ left: 1400, top: 400, width: 160, height: 160 }), + connectors: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + }, + { + n1: { backgroundColor: "rgb(30, 40, 50)" }, + n2: { backgroundColor: "rgb(30, 40, 50)" }, + }, + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toEqual([]); + }); + + it("does not orphan an unnamed decorative path on an empty frame", () => { + document.body.innerHTML = ` +
+ +
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + decor: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + }, + {}, + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toEqual([]); + }); }); describe("layout-audit.browser content overlap", () => { diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 21a12db9dc..006172e738 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -1221,6 +1221,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ "escaped_container", "panel_out_of_canvas", "connector_detached", + "connector_orphan", "rotation_pivot_drift", "off_pivot_rotation", "motion_appears_late", diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index 13ea482e32..53026e18b2 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -309,6 +309,7 @@ describe("persistence-tiered severity (#U10)", () => { "escaped_container", "panel_out_of_canvas", "connector_detached", + "connector_orphan", ] as const) { const collapsed = collapseStaticLayoutIssues([{ ...issue(code, "warning"), time: 3 }], 9); expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 }); diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 5f68cb7007..ba9ced3683 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -23,6 +23,7 @@ export type LayoutIssueCode = | "escaped_container" | "panel_out_of_canvas" | "connector_detached" + | "connector_orphan" // Cross-sample rotation finding — a spinning element whose bbox center drifts // because it pivots about the wrong point (bad transformOrigin/svgOrigin). | "rotation_pivot_drift" @@ -202,6 +203,7 @@ const PERSISTENCE_TIERED_CODES: ReadonlySet = new Set([ "escaped_container", "panel_out_of_canvas", "connector_detached", + "connector_orphan", ]); export function collapseStaticLayoutIssues( diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index d11d9a4b67..6608b95638 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -294,6 +294,31 @@ describe("core rules", () => { expect(finding).toBeUndefined(); }); + it("reports error when an extra style closer dumps CSS as text", async () => { + const html = compositionWithBodyPrefix( + "", + ` + + + .leftover { color: red; } +
Hello
+`, + ); + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "unbalanced_style_tags"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("extra "); + }); + + it("does not report paired style blocks", async () => { + const html = compositionWithBodyPrefix("", `
Hello
`); + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "unbalanced_style_tags")).toBeUndefined(); + }); + it("reports error when CSS block comment syntax leaks into visible markup", async () => { const html = compositionWithBodyPrefix( "", diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index d387a42d53..62486a6718 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -231,6 +231,26 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ return findings; }, + // unbalanced_style_tags — extra dumps the rest of the stylesheet as on-screen text. + ({ source }) => { + const withoutScripts = source.replace(//gi, ""); + const opens = withoutScripts.match(//gi)?.length ?? 0; + if (opens === closes) return []; + return [ + { + code: "unbalanced_style_tags", + severity: "error", + message: + opens > closes + ? "A closes the stylesheet early, so trailing CSS renders as visible on-screen text.", + fixHint: "Keep paired. One extra closer dumps CSS into the body.", + snippet: truncateSnippet(withoutScripts.match(/<\/?style\b[^>]*>/i)?.[0] || ""` string literal inside it counted toward the tag balance, reporting an error on a composition whose tags are paired. Both the strip and the closer count now tolerate whitespace before the `>`, which is also what CodeQL flagged on this branch. --- .../cli/src/commands/layout-audit.browser.js | 15 ++++++++- .../src/commands/layout-audit.browser.test.ts | 19 +++++++++++ packages/lint/src/rules/core.test.ts | 33 +++++++++++++++++++ packages/lint/src/rules/core.ts | 4 +-- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 69c9e30ce1..526bd489ea 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1368,6 +1368,19 @@ return issues; } + function shaftIsPainted(path) { + if (IGNORE_TAGS.has(path.tagName) || hasIgnoreFlag(path)) return false; + const style = getComputedStyle(path); + if ( + style.display === "none" || + style.visibility === "hidden" || + style.visibility === "collapse" + ) { + return false; + } + return opacityChain(path) >= 0.2; + } + function shaftDashHidden(path) { if (typeof path.getTotalLength !== "function") return false; let total; @@ -1409,7 +1422,7 @@ for (const path of Array.from(svg.querySelectorAll("path"))) { if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue; if (!isConnectorPath(svg, path)) continue; - if (!isVisibleElement(path) || shaftDashHidden(path)) continue; + if (!shaftIsPainted(path) || 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 4d41189790..49a305a373 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1326,6 +1326,25 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(issues[0]?.message).toContain("#n2"); }); + it("flags a horizontal shaft, whose bounding box has no height", () => { + document.body.innerHTML = orphanDom; + installGeometry( + orphanRects, + orphanStyles({ n2: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" } }), + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + const flat = document.getElementById("path-input"); + if (flat) { + const box = { left: 360, top: 480, right: 1400, bottom: 480, width: 1040, height: 0 }; + flat.getBoundingClientRect = () => ({ ...box, x: box.left, y: box.top, toJSON: () => box }); + } + + const issues = runAudit().filter((issue) => issue.code === "connector_orphan"); + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toContain("#n2"); + }); + it("does not orphan a shaft whose endpoints are both on stage", () => { document.body.innerHTML = orphanDom; installGeometry(orphanRects, orphanStyles({})); diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index 6608b95638..cec6eb468a 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -313,6 +313,39 @@ describe("core rules", () => { expect(finding?.message).toContain("extra "); }); + it("does not count style text inside a script closed with a spaced end tag", async () => { + const html = compositionWithBodyPrefix( + "", + ` + + +
Hello
+`, + ); + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "unbalanced_style_tags")).toBeUndefined(); + }); + + it("reports an extra closer written as ", async () => { + const html = compositionWithBodyPrefix( + "", + ` + + + .leftover { color: red; } +
Hello
+`, + ); + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "unbalanced_style_tags")?.severity).toBe("error"); + }); + it("does not report paired style blocks", async () => { const html = compositionWithBodyPrefix("", `
Hello
`); const result = await lintHyperframeHtml(html); diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index 62486a6718..479c253ab5 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -233,9 +233,9 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // unbalanced_style_tags — extra dumps the rest of the stylesheet as on-screen text. ({ source }) => { - const withoutScripts = source.replace(//gi, ""); + const withoutScripts = source.replace(/]*>/gi, ""); const opens = withoutScripts.match(//gi)?.length ?? 0; + const closes = withoutScripts.match(/<\/style\s*>/gi)?.length ?? 0; if (opens === closes) return []; return [ { From 428a8fe3c57d41d653e73061500f397f19dd5712 Mon Sep 17 00:00:00 2001 From: xuanru Date: Tue, 8 Sep 2026 00:06:22 +0000 Subject: [PATCH 08/14] fix(lint): count style tags in one pass instead of stripping scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL reads the `source.replace(/ HyperframeLintFinding[]> = [ return findings; }, - // unbalanced_style_tags — extra dumps the rest of the stylesheet as on-screen text. + // unbalanced_style_tags ({ source }) => { - const withoutScripts = source.replace(/]*>/gi, ""); - const opens = withoutScripts.match(//gi)?.length ?? 0; + let opens = 0; + let closes = 0; + let firstTag = ""; + for (const match of source.matchAll( + /]*>|/gi, + )) { + const token = match[0].toLowerCase(); + if (token.startsWith(" HyperframeLintFinding[]> = [ ? "A closes the stylesheet early, so trailing CSS renders as visible on-screen text.", fixHint: "Keep paired. One extra closer dumps CSS into the body.", - snippet: truncateSnippet(withoutScripts.match(/<\/?style\b[^>]*>/i)?.[0] || " + +
Hello
+`, + ); + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "unbalanced_style_tags")).toBeUndefined(); + }); + it("does not report paired style blocks", async () => { const html = compositionWithBodyPrefix("", `
Hello
`); const result = await lintHyperframeHtml(html); From a30c2c48c2f4554f000c9611308c31e0365eb292 Mon Sep 17 00:00:00 2001 From: xuanru Date: Tue, 8 Sep 2026 00:31:53 +0000 Subject: [PATCH 11/14] fix(check): type the hidden-shaft fixture table as a style record --- packages/cli/src/commands/layout-audit.browser.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index fca6be4c9e..fcb1b45785 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1429,7 +1429,7 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(issues[0]).toMatchObject({ selector: "#path-input" }); }); - const hiddenShaftStyles = [ + const hiddenShaftStyles: Array> = [ { opacity: "0" }, { display: "none" }, { visibility: "hidden" }, From 8c973cea4434884fa7696fb9cdb956a996f39e7b Mon Sep 17 00:00:00 2001 From: xuanru Date: Tue, 8 Sep 2026 00:51:36 +0000 Subject: [PATCH 12/14] fix(check): key connector_orphan by geometry and let a live node win the endpoint Two defects found in review. connector_orphan was persistence-tiered but absent from the geometry key, so several id-less shafts orphaning at one sample each collapsed into a single finding that then read as held rather than transient. On the 47 corpus compositions this recovers 9 findings across 5 compositions where 3 across 2 were reported; one composition fades its nodes and its connectors on a shared stagger, so four shafts each outlive a different node and only one of the four survived the collapse. The endpoint scan took the nearest candidate and asked whether that one was hidden, without asking whether a visible box was also in range. A staged halo sitting on the node it belongs to produced a finding naming the halo. A visible candidate within threshold now settles the endpoint. Fixtures added for both, and for the two guards a mutation sweep found unpinned: the connector-shape test and the dash-offset skip. --- .../cli/src/commands/layout-audit.browser.js | 7 ++- .../src/commands/layout-audit.browser.test.ts | 62 +++++++++++++++---- packages/cli/src/utils/layoutAudit.test.ts | 19 ++++++ packages/cli/src/utils/layoutAudit.ts | 6 +- 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 526bd489ea..22d6be3fdd 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1435,12 +1435,17 @@ 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 }; } - if (best !== null && !isVisibleElement(best.candidate.element)) dark.push(best.candidate); + if (!attached && best !== null) dark.push(best.candidate); } if (dark.length === 0) continue; issues.push({ diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index fcb1b45785..181fd1657d 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1393,18 +1393,58 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(issues[0]).toMatchObject({ selector: "#path-to-commitment" }); }); - it("does not orphan an unnamed decorative path on an empty frame", () => { - document.body.innerHTML = ` -
- -
- `; + it("does not orphan an unnamed decorative path that runs between real nodes", () => { + document.body.innerHTML = orphanDom + .replace( + '', + '', + ) + .replace('', ''); installGeometry( - { - root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), - decor: rect({ left: 0, top: 0, width: 1920, height: 1080 }), - }, - {}, + { ...orphanRects, decor: orphanRects.connectors }, + orphanStyles({ n2: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" } }), + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toEqual([]); + }); + + it("orphans that same path once it carries an arrowhead", () => { + document.body.innerHTML = orphanDom.replace('', ''); + installGeometry( + { ...orphanRects, decor: orphanRects.connectors }, + orphanStyles({ n2: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" } }), + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toHaveLength(1); + }); + + it("does not orphan a shaft still hidden behind its dash offset", () => { + document.body.innerHTML = orphanDom; + installGeometry( + orphanRects, + orphanStyles({ + n2: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" }, + "path-input": { strokeDasharray: "100", strokeDashoffset: "100" }, + }), + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toEqual([]); + }); + + it("does not blame a staged halo that sits on a live node", () => { + document.body.innerHTML = orphanDom.replace( + '
', + '
\n
', + ); + installGeometry( + { ...orphanRects, "n2-halo": rect({ left: 1390, top: 390, width: 180, height: 180 }) }, + orphanStyles({ "n2-halo": { backgroundColor: "rgb(80, 90, 100)", opacity: "0" } }), ); installConnectorGeometry({ e: 0, f: 0 }); installAuditScript(); diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index 53026e18b2..6e1ecdb9ec 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -316,6 +316,25 @@ describe("persistence-tiered severity (#U10)", () => { } }); + it("keeps id-less connector findings apart by geometry, so each stays a single sample", () => { + for (const code of ["connector_detached", "connector_orphan"] as const) { + const shaft = { ...issue(code, "warning"), selector: "svg path" }; + const collapsed = collapseStaticLayoutIssues( + [ + { ...shaft, time: 1, rect: { ...shaft.rect, left: 100, top: 100 } }, + { ...shaft, time: 3, rect: { ...shaft.rect, left: 600, top: 300 } }, + { ...shaft, time: 5, rect: { ...shaft.rect, left: 1200, top: 700 } }, + ], + 9, + ); + + expect(collapsed).toHaveLength(3); + for (const finding of collapsed) { + expect(finding).toMatchObject({ severity: "info", occurrences: 1 }); + } + } + }); + it("keeps a held but small canvas_overflow at info", () => { const breach = { ...issue("canvas_overflow", "info"), diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index ba9ced3683..c1eb6f20ba 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -351,8 +351,10 @@ function staticIssueKey(issue: LayoutIssue): string { } function framePositionKey(issue: LayoutIssue): string { - // connector_detached shares it: id-less paths collapse to one selector, so distinct lines need geometry in the key. - return issue.code === "frame_out_of_frame" || issue.code === "connector_detached" + // connector_detached and connector_orphan share it: id-less paths collapse to one selector, so distinct lines need geometry in the key. + return issue.code === "frame_out_of_frame" || + issue.code === "connector_detached" || + issue.code === "connector_orphan" ? `${Math.round(issue.rect.left)},${Math.round(issue.rect.top)}` : ""; } From f0857bbc7c1724be271c9df7e52d811ff89e55de Mon Sep 17 00:00:00 2001 From: xuanru Date: Tue, 8 Sep 2026 01:01:34 +0000 Subject: [PATCH 13/14] test(check): kill every surviving mutant in the connector_orphan path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven guards still passed the suite when deleted: the four candidate filters, the connector layer's own visibility gate, the defs/marker skip and the chord floor. Each now has a fixture where that guard alone decides the outcome. The earlier sweep that missed them was mutating the wrong copy of a shared line — connectorAnchorRects and connectorEndpointCandidates carry the same filter text, and a first-match replace edited the detached one. Mutations are scoped by enclosing function now. --- .../src/commands/layout-audit.browser.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 181fd1657d..9670255a92 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1437,6 +1437,110 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toEqual([]); }); + const guardDom = (edge: string) => ` +
+
+ ${edge === "in-svg" ? "" : '
'} + + + ${edge === "in-svg" ? '' : ""} + + +
+ `; + const guardBase = { + root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + n1: rect({ left: 200, top: 400, width: 160, height: 160 }), + connectors: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + }; + const opaqueHidden = { backgroundColor: "rgb(30, 40, 50)", opacity: "0" }; + const endpointGuardCases = [ + { + name: "an element inside the connector svg is never an endpoint", + dom: "in-svg", + edgeRect: rect({ left: 1400, top: 400, width: 160, height: 160 }), + edgeStyle: opaqueHidden, + }, + { + name: "a box with neither paint nor text is never an endpoint", + dom: "outside", + edgeRect: rect({ left: 1400, top: 400, width: 160, height: 160 }), + edgeStyle: { opacity: "0" }, + }, + { + name: "a box below the area floor is never an endpoint", + dom: "outside", + edgeRect: rect({ left: 1400, top: 470, width: 16, height: 16 }), + edgeStyle: opaqueHidden, + }, + { + name: "a box larger than a stage fraction is never an endpoint", + dom: "outside", + edgeRect: rect({ left: 1000, top: 200, width: 1200, height: 600 }), + edgeStyle: opaqueHidden, + }, + ]; + for (const guard of endpointGuardCases) { + it(guard.name, () => { + document.body.innerHTML = guardDom(guard.dom); + installGeometry( + { ...guardBase, edge: guard.edgeRect }, + { n1: { backgroundColor: "rgb(30, 40, 50)" }, edge: guard.edgeStyle }, + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toEqual([]); + }); + } + + it("skips a connector layer the composition has taken off screen", () => { + document.body.innerHTML = orphanDom; + installGeometry( + orphanRects, + orphanStyles({ + n2: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" }, + connectors: { display: "none" }, + }), + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toEqual([]); + }); + + it("skips the arrowhead glyph living in defs", () => { + document.body.innerHTML = orphanDom.replace( + '', + '', + ); + installGeometry( + orphanRects, + orphanStyles({ n2: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" } }), + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + const issues = runAudit().filter((issue) => issue.code === "connector_orphan"); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ selector: "#path-input" }); + }); + + it("skips a marked stub too short to read as a link", () => { + document.body.innerHTML = orphanDom.replace( + 'd="M 360 480 L 1400 480"', + 'd="M 360 480 L 400 480"', + ); + installGeometry( + { ...orphanRects, n2: rect({ left: 400, top: 400, width: 160, height: 160 }) }, + orphanStyles({ n2: { backgroundColor: "rgb(30, 40, 50)", opacity: "0" } }), + ); + installConnectorGeometry({ e: 0, f: 0 }); + installAuditScript(); + + expect(runAudit().filter((issue) => issue.code === "connector_orphan")).toEqual([]); + }); + it("does not blame a staged halo that sits on a live node", () => { document.body.innerHTML = orphanDom.replace( '
', From 383e6e14734c2eac7a2043adadc32c4ae12a9570 Mon Sep 17 00:00:00 2001 From: xuanru Date: Tue, 8 Sep 2026 01:08:08 +0000 Subject: [PATCH 14/14] test(check): order the halo before the node it hides under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The halo fixture proved nothing. Both boxes contain the endpoint, so both score gap 0, and the strict tie-break keeps whichever comes first in document order — which was the visible node. The old code picked the same winner and stayed silent too. Putting the halo first makes the tie-break hand it the slot, so only the visible-candidate check keeps the endpoint attached. --- packages/cli/src/commands/layout-audit.browser.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 9670255a92..221620b173 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1544,7 +1544,7 @@ describe("layout-audit.browser coordinate-frame findings", () => { it("does not blame a staged halo that sits on a live node", () => { document.body.innerHTML = orphanDom.replace( '
', - '
\n
', + '
\n
', ); installGeometry( { ...orphanRects, "n2-halo": rect({ left: 1390, top: 390, width: 180, height: 180 }) },