Skip to content

Commit d62b8f1

Browse files
xuanruliclaude
andcommitted
fix(lint): sample axis-aligned connectors and gate decorative strokes
Empirical testing of this rule found four blind spots and two false positives. All six are closed here; every real detachment in the fixture set still fires and the five registry examples produce byte-identical findings. Blind spots: - An SVG shape's client rect excludes its stroke, so a single-segment horizontal or vertical `<line>`/`<path>` measures 0 on one axis and `isVisibleElement` dropped it before sampling — the plain A-to-B same-row edge was never checked. The rect gate is now stroke-aware, limited to SVG geometry via `getBBox`. - `<polyline>` elbows were not in the candidate selector at all. - `CONNECTOR_INDICATOR_NAME` was unbounded, so `pointer-events-none` on an ancestor `<svg>` — the standard Tailwind overlay idiom — reclassified every connector beneath it as a gauge indicator and muted the whole subtree. `sticky`, `ticker` and `sticky-header` matched `tick` the same way. Note a `\b`-only fix does not work: `-` is itself a word boundary. False positives: the rule had no connector-naming gate, unlike its static sibling, so any stroke whose end happened to land inside a text card read as a half-attached connector — a decorative accent line and a divider rule both flagged. It now requires `isConnectorPath`, with camelCase tolerated so ids like `edgeBC` still read as connectors. Still open, unchanged by this commit: id-less connectors sharing one selector are dropped as ambiguous, detachments confined to the first 45% are outside the hold window, and only endpoints are measured, so a long curve's bulge can hang in empty space unseen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c1e50ae commit d62b8f1

1 file changed

Lines changed: 18 additions & 3 deletions

File tree

packages/cli/src/commands/layout-audit.browser.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@
198198
return false;
199199
}
200200
const rect = element.getBoundingClientRect();
201-
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
201+
// Stroke is excluded from an SVG shape's client rect, so axis-aligned geometry measures 0 on one axis.
202+
const stroke = typeof element.getBBox === "function" ? parseFloat(style.strokeWidth) || 0 : 0;
203+
if (rect.width + stroke <= 0.5 || rect.height + stroke <= 0.5) return false;
202204
return probeClipPath === false || !isClippedAway(element);
203205
}
204206

@@ -1271,6 +1273,16 @@
12711273
return { compact, painted };
12721274
}
12731275

1276+
/** Connector naming for the motion sampler, tolerant of camelCase ids the word-bounded name test would miss. */
1277+
function isMotionConnectorCandidate(svg, path) {
1278+
if (isConnectorPath(svg, path)) return true;
1279+
const spaced = `${connectorNameFor(svg)} ${connectorNameFor(path)}`.replace(
1280+
/([a-z])([A-Z0-9])/g,
1281+
"$1 $2",
1282+
);
1283+
return CONNECTOR_NAME.test(spaced);
1284+
}
1285+
12741286
function isConnectorPath(svg, path) {
12751287
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
12761288
return (
@@ -1744,7 +1756,8 @@
17441756
const CONNECTOR_MIN_SVG_PX = 100;
17451757
const CONNECTOR_MIN_LEN_PX = 60;
17461758
// Gauge needles/pointers/ticks are one-end-anchored indicators owned by a separate check; skip by id/class of the line or any group ancestor.
1747-
const CONNECTOR_INDICATOR_NAME = /needle|pointer|gauge|tick|indicator/i;
1759+
// Word-bounded so `sticky`/`ticker` do not match, and pointer-events is excluded: a Tailwind utility must not mute a whole subtree.
1760+
const CONNECTOR_INDICATOR_NAME = /\b(needle|pointer(?!-events)|gauge|tick|indicator)\b/i;
17481761
const CONNECTOR_NODE_MIN_AREA = 400;
17491762
// SVG dots/markers are small; keep the floor low but above sub-pixel decoration.
17501763
const CONNECTOR_NODE_MIN_DOT_AREA = 16;
@@ -1884,10 +1897,12 @@
18841897
if (!isVisibleElement(svg, 0.05) || hasAllowOverflowFlag(svg)) continue;
18851898
const svgRect = svg.getBoundingClientRect();
18861899
if (svgRect.width < CONNECTOR_MIN_SVG_PX || svgRect.height < CONNECTOR_MIN_SVG_PX) continue;
1887-
for (const line of Array.from(svg.querySelectorAll("line, path"))) {
1900+
for (const line of Array.from(svg.querySelectorAll("line, path, polyline"))) {
18881901
if (line.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
1902+
// Stroke-inflated: an axis-aligned line has a zero-height/width client rect, so the plain visibility gate drops it.
18891903
if (!isVisibleElement(line, 0.05)) continue;
18901904
if (isIndicatorConnector(line, svg)) continue;
1905+
if (!isMotionConnectorCandidate(svg, line)) continue;
18911906
const ends =
18921907
line.tagName.toLowerCase() === "line"
18931908
? lineScreenEndpoints(svg, line)

0 commit comments

Comments
 (0)