fix(core): faint SVG grain is not a click target, and a page reset is not an element's CSS (#5628)

* fix(core): a faint SVG grain is not the click target, and a page reset is not an element's CSS

A filter rect under an svg at opacity 0.07 was still picked. The veil check
required that element's own opacity and a background-image. A `*` rule matched
every element, so a rect was given the page reset as its CSS.

fails without the fix: clicking the grain selects the rect, and inspection attaches `*`.

* fix(core): keep picker comments within the source ratchet

* fix(core): preserve selector literals and SVG veil boundaries
This commit is contained in:
Xuanru Li
2026-10-15 04:04:13 +00:00
committed by GitHub
parent 8f8f91c9de
commit c694785df5
4 changed files with 197 additions and 4 deletions
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";
import { authoredCssSelector, installInspection } from "./inspection";
describe("authoredCssSelector", () => {
it.each([
'[data-label="a,b"]',
"[data-label='a b']",
String.raw`[data-label="a\",b"]`,
String.raw`.a\,b`,
String.raw`[data-label=a\,b]`,
':is([data-label="a,b"], .card)',
".card/* , * */",
])("preserves literal content when removing resets from %s", (selector) => {
expect(authoredCssSelector(selector)).toBe(selector);
expect(authoredCssSelector(`*, ${selector}, body *`)).toBe(selector);
});
it("drops page resets and keeps a rule that also names the element", () => {
expect(authoredCssSelector("*")).toBeNull();
expect(authoredCssSelector("html, body")).toBeNull();
expect(authoredCssSelector("html *")).toBeNull();
expect(authoredCssSelector("rect, *")).toBe("rect");
expect(authoredCssSelector(".Card")).toBe(".Card");
});
});
describe("installInspection CSS matching", () => {
afterEach(() => {
document.body.innerHTML = "";
delete (window as { __hfInspection?: unknown }).__hfInspection;
});
it.each(["a,b", "a b"])("retains authored CSS for literal %s", (value) => {
const target = document.createElement("div");
target.id = "target";
target.dataset.label = value;
document.body.append(target);
const selector = `[data-label="${value}"]`;
expect(target.matches(selector)).toBe(true);
const api = installInspection(window, "gen");
try {
const [result] = api.query({
targets: [{ selector: "#target" }],
descendants: false,
cssRules: [
{ id: "plain", selector },
{ id: "mixed", selector: `${selector}, *` },
{ id: "unrelated", selector: '[data-label="other"], *' },
],
}).results;
expect(result?.css).toEqual(["plain", "mixed"]);
} finally {
api.dispose();
}
});
it("does not attach a universal reset to a rect", () => {
document.body.innerHTML = '<svg id="world-grain"><rect id="grain"></rect></svg>';
const api = installInspection(window, "gen");
const [result] = api.query({
targets: [{ selector: "rect" }],
descendants: false,
cssRules: [
{ id: "reset", selector: "*" },
{ id: "page", selector: "html, body" },
{ id: "desc", selector: "body *" },
{ id: "own", selector: "rect" },
{ id: "shared", selector: "rect, *" },
],
}).results;
expect(result?.status).toBe("found");
expect(result?.css).toEqual(["own", "shared"]);
api.dispose();
});
});
+47 -1
View File
@@ -66,6 +66,50 @@ const reserved = new Set([
"defaults",
]);
const PAGE_RESET = new Set(["*", "html", "body", ":root", "html *", "body *", ":root *", "* *"]);
function selectorAlternatives(selector: string): string[] {
const parts: string[] = [];
let depth = 0;
let quote: string | undefined;
let start = 0;
for (let i = 0; i < selector.length; i += 1) {
const ch = selector[i];
if (ch === "\\") {
i += 1;
continue;
}
if (quote) {
if (ch === quote) quote = undefined;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === "/" && selector[i + 1] === "*") {
const end = selector.indexOf("*/", i + 2);
i = end < 0 ? selector.length : end + 1;
continue;
}
if (ch === "(" || ch === "[") depth += 1;
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
else if (ch === "," && depth === 0) {
parts.push(selector.slice(start, i));
start = i + 1;
}
}
parts.push(selector.slice(start));
return parts;
}
export function authoredCssSelector(selector: string): string | null {
const specific = selectorAlternatives(selector)
.map((part) => part.trim())
.filter((part) => part && !PAGE_RESET.has(part.replace(/\s+/g, " ").toLowerCase()));
return specific.length > 0 ? specific.join(", ") : null;
}
export function installInspection(win: InspectorWindow, generation: string, limit = 20_000) {
const records = new WeakMap<Animation, string>();
const attachments = new WeakMap<Animation, string>();
@@ -262,8 +306,10 @@ export function installInspection(win: InspectorWindow, generation: string, limi
});
const css = q.cssRules
.filter((rule) => {
const selector = authoredCssSelector(rule.selector);
if (!selector) return false;
try {
return scope.some((el) => el.matches(rule.selector));
return scope.some((el) => el.matches(selector));
} catch {
return false;
}
+30
View File
@@ -301,6 +301,36 @@ describe("createPickerModule", () => {
expect(candidatesUnder(() => [el("photo"), el("wash"), el("scene")])).toContain("#photo");
});
it("picks the picture under a faint SVG filter grain", () => {
const picker = createPickerModule({ postMessage: createMockPostMessage() });
picker.installPickerApi();
document.head.innerHTML = "<style>#world-grain { opacity: 0.07 }</style>";
document.body.innerHTML =
'<div id="scene"><img id="photo" alt=""><svg id="world-grain"><filter id="world-noise"><feTurbulence /></filter><rect id="grain-rect" filter="url(#world-noise)" /></svg></div>';
const el = (id: string) => document.getElementById(id)!;
expect(
candidatesUnder(() => [el("grain-rect"), el("world-grain"), el("photo"), el("scene")]),
).toEqual(["#photo", "#scene"]);
});
it("keeps faint HTML custom elements whose names begin with fe pickable", () => {
const picker = createPickerModule({ postMessage: createMockPostMessage() });
picker.installPickerApi();
document.body.innerHTML =
'<div id="scene"><feed-card id="card" style="opacity:0.07"></feed-card></div>';
const el = (id: string) => document.getElementById(id)!;
expect(candidatesUnder(() => [el("card"), el("scene")])).toEqual(["#card", "#scene"]);
});
it("still picks a filtered rect in an opaque SVG", () => {
const picker = createPickerModule({ postMessage: createMockPostMessage() });
picker.installPickerApi();
document.body.innerHTML =
'<svg id="chart"><filter id="glow"><feGaussianBlur /></filter><rect id="bar" filter="url(#glow)" /></svg>';
const el = (id: string) => document.getElementById(id)!;
expect(candidatesUnder(() => [el("bar"), el("chart")])).toEqual(["#bar", "#chart"]);
});
it("does not pick a faint texture veil that covers the frame", () => {
const picker = createPickerModule({ postMessage: createMockPostMessage() });
picker.installPickerApi();
+44 -3
View File
@@ -130,10 +130,51 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
function isDecorativeVeil(el: HTMLElement, win: Window): boolean {
const style = win.getComputedStyle(el);
const opacity = Number.parseFloat(style.opacity || "1");
if (!Number.isFinite(opacity) || opacity >= 0.2) return false;
const faint = Number.isFinite(opacity) && opacity < 0.2;
if (faint && !(el.textContent ?? "").trim()) {
const image = style.backgroundImage;
if (image && image !== "none") return true;
}
return faintFilterPaint(el, win);
}
function isFilterDefinition(el: Element): boolean {
if (el.namespaceURI !== "http://www.w3.org/2000/svg") return false;
const tag = el.tagName.toLowerCase();
return tag === "filter" || tag === "defs" || tag.startsWith("fe");
}
function isFilterRect(el: Element): boolean {
if (el.namespaceURI !== "http://www.w3.org/2000/svg") return false;
if (el.tagName.toLowerCase() !== "rect") return false;
const attr = (el.getAttribute("filter") ?? "").trim();
if (attr && attr !== "none") return true;
const painted = el.ownerDocument.defaultView?.getComputedStyle(el).filter ?? "none";
return painted.startsWith("url(");
}
function isOnlyFilterPaint(el: Element): boolean {
if ((el.textContent ?? "").trim()) return false;
const image = style.backgroundImage;
return Boolean(image && image !== "none");
if (isFilterDefinition(el) || isFilterRect(el)) return true;
const children = [...el.children];
return children.length > 0 && children.every((child) => isOnlyFilterPaint(child));
}
function isFaint(el: Element, win: Window): boolean {
const opacity = Number.parseFloat(win.getComputedStyle(el).opacity || "1");
return Number.isFinite(opacity) && opacity < 0.2;
}
function faintFilterPaint(el: Element, win: Window): boolean {
if (!isOnlyFilterPaint(el)) return false;
for (
let current: Element | null = el;
current && current !== document.body && current !== document.documentElement;
current = current.parentElement
) {
if (isFaint(current, win) && isOnlyFilterPaint(current)) return true;
}
return false;
}
function isEffectivelyHidden(el: HTMLElement): boolean {