mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(extension): recording hover
This commit is contained in:
@@ -156,4 +156,353 @@ describe("record-capture semantic", () => {
|
||||
target: { name: "下一步", role: "button" },
|
||||
});
|
||||
});
|
||||
|
||||
it("records a hover trigger before clicking its revealed menu item", () => {
|
||||
document.body.innerHTML = `
|
||||
<button type="button" aria-label="Open user navigation menu">avatar</button>
|
||||
<ul class="user-menu dropdown-menu">
|
||||
<li><a href="/u/me">My profile</a></li>
|
||||
</ul>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-menu", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const item = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "Open user navigation menu" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "click",
|
||||
target: { role: "link", name: "My profile" },
|
||||
});
|
||||
});
|
||||
|
||||
it("records hover for compact topbar image buttons before menu item clicks", () => {
|
||||
document.body.innerHTML = `
|
||||
<button type="button" aria-label="image" style="position: absolute; top: 8px; left: 900px; width: 32px; height: 32px;">
|
||||
<img alt="image" />
|
||||
</button>
|
||||
<ul class="user-menu dropdown-menu">
|
||||
<li><a href="/u/me">My profile</a></li>
|
||||
</ul>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-topbar", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const item = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "image" },
|
||||
});
|
||||
});
|
||||
|
||||
it("records hover when the revealed menu item is inside the hover root", () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="user dropdown" role="button" aria-label="image">
|
||||
<img alt="image" />
|
||||
<ul>
|
||||
<li><a href="/u/me">My profile</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-contained-menu", (step) => steps.push(step));
|
||||
const trigger = document.querySelector('[role="button"]')!;
|
||||
const item = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "image" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "click",
|
||||
target: { role: "link", name: "My profile" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a revealed topbar menu link replace the pending hover trigger", () => {
|
||||
document.body.innerHTML = `
|
||||
<a href="/u/me" aria-label="image" style="position: absolute; top: 8px; left: 900px; width: 32px; height: 32px;">
|
||||
<img alt="image" />
|
||||
</a>
|
||||
<ul class="user-menu dropdown-menu">
|
||||
<li><a class="dropdown-item profile-link" href="/u/me">My profile</a></li>
|
||||
</ul>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-topbar-link", (step) => steps.push(step));
|
||||
const trigger = document.querySelector('a[aria-label="image"]')!;
|
||||
const item = document.querySelector("ul a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(item, "getBoundingClientRect").mockReturnValue({
|
||||
x: 820,
|
||||
y: 72,
|
||||
top: 72,
|
||||
left: 820,
|
||||
right: 916,
|
||||
bottom: 104,
|
||||
width: 96,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((el) => {
|
||||
const style = {
|
||||
cursor: el === item ? "pointer" : "",
|
||||
pointerEvents: "auto",
|
||||
} as CSSStyleDeclaration;
|
||||
return style;
|
||||
});
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "link", name: "image" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the first hover trigger over a later lower-score accepted hover", () => {
|
||||
document.body.innerHTML = `
|
||||
<button type="button" aria-label="Open user navigation menu" aria-haspopup="menu" aria-expanded="false">
|
||||
<img alt="image" />
|
||||
</button>
|
||||
<a class="account-menu" role="button" href="/u/me">My profile</a>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-latch", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const laterHover = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(laterHover, "getBoundingClientRect").mockReturnValue({
|
||||
x: 820,
|
||||
y: 72,
|
||||
top: 72,
|
||||
left: 820,
|
||||
right: 916,
|
||||
bottom: 104,
|
||||
width: 96,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((el) => {
|
||||
const style = {
|
||||
cursor: el === laterHover ? "pointer" : "",
|
||||
pointerEvents: "auto",
|
||||
} as CSSStyleDeclaration;
|
||||
return style;
|
||||
});
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
laterHover.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
laterHover.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "Open user navigation menu" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "click",
|
||||
target: { role: "button", name: "My profile" },
|
||||
});
|
||||
});
|
||||
|
||||
it("records avatar div hover with a semantic image target", () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="tg-avatar tg-avatar--img tg-avatar--shape-hexagon">
|
||||
<img class="tg-avatar__image" />
|
||||
</div>
|
||||
<ul class="user-menu dropdown-menu">
|
||||
<li><a href="/u/me">My profile</a></li>
|
||||
</ul>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-avatar-div", (step) => steps.push(step));
|
||||
const trigger = document.querySelector(".tg-avatar")!;
|
||||
const image = document.querySelector("img")!;
|
||||
const item = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(image, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
vi.spyOn(window, "getComputedStyle").mockReturnValue({
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
} as CSSStyleDeclaration);
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
image.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { tag: "img", role: "img", name: "image" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "click",
|
||||
target: { role: "link", name: "My profile" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record hover before an unrelated page click", () => {
|
||||
document.body.innerHTML = `
|
||||
<button type="button" aria-label="Open user navigation menu">avatar</button>
|
||||
<main>
|
||||
<a href="/projects">Projects</a>
|
||||
</main>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-unrelated-click", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const link = document.querySelector("main a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
link.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "click",
|
||||
target: { role: "link", name: "Projects" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record hover before clicking the same trigger", () => {
|
||||
document.body.innerHTML = `
|
||||
<button type="button" aria-label="Open user navigation menu">avatar</button>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-same-trigger", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["click"]);
|
||||
});
|
||||
|
||||
it("does not record hover for ordinary controls without popup signals", () => {
|
||||
const capture = startRecordCapture("rec-hover-noise", (step) => steps.push(step));
|
||||
const button = document.querySelector("button")!;
|
||||
|
||||
button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["click"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import {
|
||||
describeEventTarget,
|
||||
describeTarget,
|
||||
resolveClickableElement,
|
||||
resolveHoverElement,
|
||||
type TargetDescriptor,
|
||||
} from "@/lib/describe-target";
|
||||
import {
|
||||
evaluateHoverTrigger,
|
||||
type HoverTriggerDecision,
|
||||
type HoverTriggerRect,
|
||||
} from "@/lib/hover-trigger-policy";
|
||||
import {
|
||||
isRecordCancelMessage,
|
||||
isRecordStartMessage,
|
||||
@@ -20,6 +26,7 @@ import {
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import { shouldRecordPress } from "@/lib/trace-reducer";
|
||||
import { decideHoverSurfaceRelation } from "./record-hover-surface";
|
||||
|
||||
const pendingStepSends = new Map<string, Set<Promise<boolean>>>();
|
||||
const failedStepDeliveries = new Set<string>();
|
||||
@@ -58,11 +65,15 @@ interface PendingHover {
|
||||
element: Element;
|
||||
target: TargetDescriptor;
|
||||
recordedAt: number;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
type FillableElement = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
|
||||
|
||||
const HOVER_BEFORE_CLICK_MAX_MS = 10_000;
|
||||
const HOVER_REPLACE_SCORE_MARGIN = 50;
|
||||
const RECORD_DEBUG_STORAGE_KEY = "bsk_record_debug";
|
||||
|
||||
function eventTarget(event: Event): EventTarget | null {
|
||||
return event.composedPath()[0] ?? event.target;
|
||||
@@ -135,12 +146,160 @@ function fillableValue(el: FillableElement): string {
|
||||
return el.textContent ?? "";
|
||||
}
|
||||
|
||||
function recordDebug(message: string, data?: unknown): void {
|
||||
try {
|
||||
if (localStorage.getItem(RECORD_DEBUG_STORAGE_KEY) !== "1") return;
|
||||
console.info("[bsk record]", message, data === undefined ? "" : JSON.stringify(data));
|
||||
} catch {
|
||||
// Recording must not depend on page storage or console availability.
|
||||
}
|
||||
}
|
||||
|
||||
function elementDebugSummary(el: Element): Record<string, string | undefined> {
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || undefined,
|
||||
class: typeof el.className === "string" ? el.className || undefined : undefined,
|
||||
role: el.getAttribute("role") ?? undefined,
|
||||
ariaLabel: el.getAttribute("aria-label") ?? undefined,
|
||||
text: el.textContent?.replace(/\s+/g, " ").trim().slice(0, 80) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function hoverTriggerAttrs(el: Element): Record<string, string | undefined> {
|
||||
return {
|
||||
id: el.id || undefined,
|
||||
class: typeof el.className === "string" ? el.className || undefined : undefined,
|
||||
"data-testid": el.getAttribute("data-testid") ?? undefined,
|
||||
"data-test": el.getAttribute("data-test") ?? undefined,
|
||||
"data-cy": el.getAttribute("data-cy") ?? undefined,
|
||||
"aria-label": el.getAttribute("aria-label") ?? undefined,
|
||||
"aria-haspopup": el.getAttribute("aria-haspopup") ?? undefined,
|
||||
"aria-controls": el.getAttribute("aria-controls") ?? undefined,
|
||||
"aria-expanded": el.getAttribute("aria-expanded") ?? undefined,
|
||||
"aria-hidden": el.getAttribute("aria-hidden") ?? undefined,
|
||||
"aria-disabled": el.getAttribute("aria-disabled") ?? undefined,
|
||||
contenteditable: el.getAttribute("contenteditable") ?? undefined,
|
||||
disabled: el.hasAttribute("disabled") ? "" : undefined,
|
||||
hidden: el.hasAttribute("hidden") ? "" : undefined,
|
||||
inert: el.hasAttribute("inert") ? "" : undefined,
|
||||
onclick: el.getAttribute("onclick") ?? undefined,
|
||||
onmouseenter: el.getAttribute("onmouseenter") ?? undefined,
|
||||
onmouseover: el.getAttribute("onmouseover") ?? undefined,
|
||||
role: el.getAttribute("role") ?? undefined,
|
||||
tabindex: el.getAttribute("tabindex") ?? undefined,
|
||||
title: el.getAttribute("title") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function hoverTriggerRect(el: Element): HoverTriggerRect {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
|
||||
}
|
||||
|
||||
function hoverTriggerStyle(el: Element): { cursor?: string; pointerEvents?: string } {
|
||||
if (!(el instanceof HTMLElement)) return {};
|
||||
const style = getComputedStyle(el);
|
||||
return { cursor: style.cursor, pointerEvents: style.pointerEvents };
|
||||
}
|
||||
|
||||
function hasHoverGraphicDescendant(el: Element): boolean {
|
||||
return el.querySelector("img,svg,use,path,i") !== null;
|
||||
}
|
||||
|
||||
function isWeakHoverTarget(target: TargetDescriptor): boolean {
|
||||
return !target.role && !target.name && target.tag === "div";
|
||||
}
|
||||
|
||||
function looksLikeAvatarElement(el: Element): boolean {
|
||||
const className = typeof el.className === "string" ? el.className : "";
|
||||
return /\b(avatar|user-avatar)\b/i.test(className);
|
||||
}
|
||||
|
||||
function normalizeHoverTarget(
|
||||
el: Element,
|
||||
desc: TargetDescriptor,
|
||||
decision: HoverTriggerDecision,
|
||||
): TargetDescriptor {
|
||||
if (desc.role === "img" && !desc.name && looksLikeAvatarElement(el)) {
|
||||
return { ...desc, name: "image" };
|
||||
}
|
||||
if (
|
||||
isWeakHoverTarget(desc) &&
|
||||
(looksLikeAvatarElement(el) ||
|
||||
(hasHoverGraphicDescendant(el) && decision.reasons.includes("icon-only")))
|
||||
) {
|
||||
return { tag: "img", role: "img", name: "image" };
|
||||
}
|
||||
return desc;
|
||||
}
|
||||
|
||||
function evaluateHoverTriggerElement(
|
||||
el: Element,
|
||||
desc: TargetDescriptor,
|
||||
): HoverTriggerDecision | null {
|
||||
if (!(el instanceof HTMLElement)) return null;
|
||||
const style = hoverTriggerStyle(el);
|
||||
return evaluateHoverTrigger({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
role: desc.role,
|
||||
label: desc.name,
|
||||
attrs: hoverTriggerAttrs(el),
|
||||
rect: hoverTriggerRect(el),
|
||||
cursor: style.cursor,
|
||||
pointerEvents: style.pointerEvents,
|
||||
hasGraphicDescendant: hasHoverGraphicDescendant(el),
|
||||
});
|
||||
}
|
||||
|
||||
function hoverTargetFromEvent(target: EventTarget | null): PendingHover | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
if (!(target instanceof Element)) {
|
||||
recordDebug("hover ignored: event target is not an Element", { target });
|
||||
return null;
|
||||
}
|
||||
const element = resolveHoverElement(target);
|
||||
if (!element) return null;
|
||||
if (!element) {
|
||||
recordDebug("hover ignored: no resolved hover element", { target });
|
||||
return null;
|
||||
}
|
||||
const desc = describeTarget(element);
|
||||
return { element, target: desc, recordedAt: Date.now() };
|
||||
const decision = evaluateHoverTriggerElement(element, desc);
|
||||
if (!decision?.eligible) {
|
||||
recordDebug("hover ignored: rejected by trigger policy", {
|
||||
desc,
|
||||
score: decision?.score ?? 0,
|
||||
reasons: decision?.reasons ?? [],
|
||||
element: elementDebugSummary(element),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const normalizedTarget = normalizeHoverTarget(element, desc, decision);
|
||||
recordDebug("hover accepted", {
|
||||
desc: normalizedTarget,
|
||||
raw_desc: desc,
|
||||
score: decision.score,
|
||||
reasons: decision.reasons,
|
||||
element: elementDebugSummary(element),
|
||||
});
|
||||
return {
|
||||
element,
|
||||
target: normalizedTarget,
|
||||
recordedAt: Date.now(),
|
||||
score: decision.score,
|
||||
reasons: decision.reasons,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldReplacePendingHover(
|
||||
current: PendingHover,
|
||||
next: PendingHover,
|
||||
now: number,
|
||||
): boolean {
|
||||
if (next.element === current.element) return false;
|
||||
if (now - current.recordedAt > HOVER_BEFORE_CLICK_MAX_MS) return true;
|
||||
if (current.element.contains(next.element)) return false;
|
||||
if (next.element.contains(current.element)) return true;
|
||||
return next.score >= current.score + HOVER_REPLACE_SCORE_MARGIN;
|
||||
}
|
||||
|
||||
/** Clicks that only pick an autocomplete/suggestion value — not a semantic submit action. */
|
||||
@@ -186,6 +345,7 @@ export function startRecordCapture(
|
||||
_requestId: string,
|
||||
sendStep: (step: RecordStepPayload) => void,
|
||||
): RecordCaptureController {
|
||||
recordDebug("record capture started", { page_url: location.href });
|
||||
const emitStep = (step: RecordStepPayload) => {
|
||||
sendStep({ page_url: location.href, ...step });
|
||||
};
|
||||
@@ -277,7 +437,36 @@ export function startRecordCapture(
|
||||
pendingHover = null;
|
||||
return;
|
||||
}
|
||||
if (pendingHover.element.contains(clickTarget)) return;
|
||||
const clickElement = resolveClickableElement(clickTarget);
|
||||
if (clickElement === pendingHover.element) {
|
||||
recordDebug("pending hover skipped: click resolved to same trigger", {
|
||||
target: pendingHover.target,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const relationTarget = clickElement ?? clickTarget;
|
||||
if (!clickElement && pendingHover.element.contains(relationTarget)) {
|
||||
recordDebug("pending hover skipped: anonymous click inside trigger", {
|
||||
target: pendingHover.target,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const relation = decideHoverSurfaceRelation(
|
||||
{
|
||||
triggerElement: pendingHover.element,
|
||||
triggerTarget: pendingHover.target,
|
||||
},
|
||||
relationTarget,
|
||||
);
|
||||
if (!relation.related) {
|
||||
recordDebug("pending hover skipped: click outside hover surface", {
|
||||
target: pendingHover.target,
|
||||
reason: relation.reason,
|
||||
});
|
||||
pendingHover = null;
|
||||
return;
|
||||
}
|
||||
recordDebug("pending hover emitted before click", { target: pendingHover.target });
|
||||
emitStep({
|
||||
op: "hover",
|
||||
target: pendingHover.target,
|
||||
@@ -289,7 +478,37 @@ export function startRecordCapture(
|
||||
const target = eventTarget(event);
|
||||
if (isOverlayTarget(target)) return;
|
||||
const hover = hoverTargetFromEvent(target);
|
||||
if (!hover || hover.element === pendingHover?.element) return;
|
||||
if (!hover) return;
|
||||
const now = Date.now();
|
||||
if (pendingHover && !shouldReplacePendingHover(pendingHover, hover, now)) {
|
||||
recordDebug("pending hover kept over later hover", {
|
||||
current: {
|
||||
target: pendingHover.target,
|
||||
score: pendingHover.score,
|
||||
reasons: pendingHover.reasons,
|
||||
},
|
||||
next: {
|
||||
target: hover.target,
|
||||
score: hover.score,
|
||||
reasons: hover.reasons,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pendingHover) {
|
||||
recordDebug("pending hover replaced", {
|
||||
current: {
|
||||
target: pendingHover.target,
|
||||
score: pendingHover.score,
|
||||
reasons: pendingHover.reasons,
|
||||
},
|
||||
next: {
|
||||
target: hover.target,
|
||||
score: hover.score,
|
||||
reasons: hover.reasons,
|
||||
},
|
||||
});
|
||||
}
|
||||
pendingHover = hover;
|
||||
};
|
||||
|
||||
@@ -482,6 +701,7 @@ export function startRecordCapture(
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
recordDebug("record capture disposed", { page_url: location.href });
|
||||
commitFillSession();
|
||||
document.removeEventListener("click", onClick, true);
|
||||
document.removeEventListener("mouseover", onMouseOver, true);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { TargetDescriptor } from "@/lib/describe-target";
|
||||
|
||||
export interface HoverSurfaceDecision {
|
||||
related: boolean;
|
||||
reason?: string;
|
||||
surface?: Element;
|
||||
}
|
||||
|
||||
export interface HoverSurfaceContext {
|
||||
triggerElement: Element;
|
||||
triggerTarget: TargetDescriptor;
|
||||
}
|
||||
|
||||
const HOVER_SURFACE_SELECTOR = [
|
||||
'[role="menu"]',
|
||||
'[role="menubar"]',
|
||||
'[role="listbox"]',
|
||||
'[role="dialog"]',
|
||||
'[role="tooltip"]',
|
||||
'[aria-modal="true"]',
|
||||
"[data-popper-placement]",
|
||||
"[data-floating-ui-placement]",
|
||||
"[data-headlessui-state]",
|
||||
"[data-radix-popper-content-wrapper]",
|
||||
].join(",");
|
||||
|
||||
const HOVER_SURFACE_WORD_RE =
|
||||
/\b(menu|dropdown|drop-down|popover|popper|popup|tooltip|flyout|submenu|context-menu|account-menu|user-menu|avatar-menu)\b/i;
|
||||
|
||||
function elementTokenText(el: Element): string {
|
||||
return [
|
||||
el.id,
|
||||
typeof el.className === "string" ? el.className : "",
|
||||
el.getAttribute("data-testid"),
|
||||
el.getAttribute("data-test"),
|
||||
el.getAttribute("data-cy"),
|
||||
el.getAttribute("aria-label"),
|
||||
el.getAttribute("role"),
|
||||
]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function isHoverSurfaceElement(el: Element): boolean {
|
||||
if (el.matches(HOVER_SURFACE_SELECTOR)) return true;
|
||||
if (HOVER_SURFACE_WORD_RE.test(elementTokenText(el))) return true;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if ((tag === "ul" || tag === "ol") && el.querySelector("a,button,[role='menuitem']")) {
|
||||
return HOVER_SURFACE_WORD_RE.test(elementTokenText(el.parentElement ?? el));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function closestHoverSurface(clickElement: Element): Element | null {
|
||||
let node: Element | null = clickElement;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
|
||||
if (isHoverSurfaceElement(node)) return node;
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rectFor(el: Element): DOMRect | null {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
return rect;
|
||||
}
|
||||
|
||||
function isNearTrigger(surface: Element, trigger: Element): boolean {
|
||||
const surfaceRect = rectFor(surface);
|
||||
const triggerRect = rectFor(trigger);
|
||||
if (!surfaceRect || !triggerRect) return true;
|
||||
const horizontalGap = Math.max(
|
||||
0,
|
||||
triggerRect.left - surfaceRect.right,
|
||||
surfaceRect.left - triggerRect.right,
|
||||
);
|
||||
const verticalGap = Math.max(
|
||||
0,
|
||||
triggerRect.top - surfaceRect.bottom,
|
||||
surfaceRect.top - triggerRect.bottom,
|
||||
);
|
||||
return horizontalGap <= 320 && verticalGap <= 320;
|
||||
}
|
||||
|
||||
export function decideHoverSurfaceRelation(
|
||||
context: HoverSurfaceContext,
|
||||
clickElement: Element,
|
||||
): HoverSurfaceDecision {
|
||||
const clickSurface = closestHoverSurface(clickElement);
|
||||
if (clickSurface && isNearTrigger(clickSurface, context.triggerElement)) {
|
||||
return { related: true, reason: "click-inside-hover-surface", surface: clickSurface };
|
||||
}
|
||||
|
||||
if (context.triggerElement.contains(clickElement) && clickElement !== context.triggerElement) {
|
||||
return { related: true, reason: "click-inside-trigger-contained-surface" };
|
||||
}
|
||||
|
||||
return { related: false, reason: "click-outside-hover-surface" };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateHoverTrigger } from "../hover-trigger-policy";
|
||||
|
||||
describe("evaluateHoverTrigger", () => {
|
||||
it("accepts explicit popup triggers", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
label: "Open user navigation menu",
|
||||
attrs: { "aria-haspopup": "menu", "aria-expanded": "false" },
|
||||
rect: { x: 900, y: 8, w: 32, h: 32 },
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(true);
|
||||
expect(decision.reasons).toEqual(
|
||||
expect.arrayContaining(["aria-haspopup", "collapsed", "popup-signal"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts compact topbar image buttons without popup attributes", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
label: "image",
|
||||
attrs: {},
|
||||
rect: { x: 900, y: 8, w: 32, h: 32 },
|
||||
pointerEvents: "auto",
|
||||
hasGraphicDescendant: true,
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(true);
|
||||
expect(decision.reasons).toEqual(expect.arrayContaining(["icon-only", "topbar", "compact"]));
|
||||
});
|
||||
|
||||
it("accepts compact topbar image links without popup attributes", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "a",
|
||||
role: "link",
|
||||
label: "image",
|
||||
attrs: {},
|
||||
rect: { x: 900, y: 8, w: 32, h: 32 },
|
||||
pointerEvents: "auto",
|
||||
hasGraphicDescendant: true,
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(true);
|
||||
expect(decision.reasons).toEqual(expect.arrayContaining(["icon-only", "topbar", "compact"]));
|
||||
});
|
||||
|
||||
it("rejects topbar menu links without surface trigger evidence", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "a",
|
||||
role: "link",
|
||||
label: "My profile",
|
||||
attrs: {},
|
||||
rect: { x: 820, y: 72, w: 96, h: 32 },
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects text navigation items even when their attributes contain popup words", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "a",
|
||||
role: "link",
|
||||
label: "My profile",
|
||||
attrs: { class: "dropdown-item profile-link" },
|
||||
rect: { x: 820, y: 72, w: 96, h: 32 },
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects ordinary controls without hover popup signals", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
label: "Search",
|
||||
attrs: {},
|
||||
rect: { x: 100, y: 240, w: 120, h: 36 },
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsafe or non-visible triggers", () => {
|
||||
expect(
|
||||
evaluateHoverTrigger({
|
||||
tag: "input",
|
||||
attrs: {},
|
||||
rect: { x: 10, y: 10, w: 80, h: 24 },
|
||||
pointerEvents: "auto",
|
||||
}).eligible,
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateHoverTrigger({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
attrs: { hidden: "" },
|
||||
rect: { x: 10, y: 10, w: 80, h: 24 },
|
||||
pointerEvents: "auto",
|
||||
}).eligible,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
export interface HoverTriggerRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface HoverTriggerSignals {
|
||||
tag: string;
|
||||
role?: string;
|
||||
label?: string;
|
||||
attrs?: Record<string, string | undefined>;
|
||||
rect?: HoverTriggerRect | null;
|
||||
cursor?: string;
|
||||
pointerEvents?: string;
|
||||
hasGraphicDescendant?: boolean;
|
||||
cssHoverMatch?: boolean;
|
||||
}
|
||||
|
||||
export interface HoverTriggerDecision {
|
||||
eligible: boolean;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
const HOVER_TRIGGER_MIN_SCORE = 45;
|
||||
const HOVER_POPUP_SIGNAL_RE =
|
||||
/(menu|dropdown|popover|popup|avatar|profile|account|user|more|ellipsis|caret)/;
|
||||
|
||||
function attr(signals: HoverTriggerSignals, name: string): string | undefined {
|
||||
return signals.attrs?.[name.toLowerCase()];
|
||||
}
|
||||
|
||||
export function isUnsafeHoverTrigger(signals: HoverTriggerSignals): boolean {
|
||||
const tag = signals.tag.toLowerCase();
|
||||
if (["input", "textarea", "select", "option"].includes(tag)) return true;
|
||||
if ((attr(signals, "contenteditable") ?? "").toLowerCase() === "true") return true;
|
||||
if (attr(signals, "disabled") !== undefined || attr(signals, "inert") !== undefined) return true;
|
||||
return (attr(signals, "aria-disabled") ?? "").toLowerCase() === "true";
|
||||
}
|
||||
|
||||
export function hasHoverPopupSignal(signals: HoverTriggerSignals): boolean {
|
||||
const attrs = signals.attrs ?? {};
|
||||
const haystack = [
|
||||
attrs.id,
|
||||
attrs.class,
|
||||
attrs["data-testid"],
|
||||
attrs["data-test"],
|
||||
attrs["data-cy"],
|
||||
attrs["aria-label"],
|
||||
attrs["aria-haspopup"],
|
||||
attrs["aria-controls"],
|
||||
attrs.title,
|
||||
signals.role === "button" ? signals.label : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return HOVER_POPUP_SIGNAL_RE.test(haystack);
|
||||
}
|
||||
|
||||
export function hasDirectHoverInteractiveSignal(signals: HoverTriggerSignals): boolean {
|
||||
const tag = signals.tag.toLowerCase();
|
||||
const role = (signals.role ?? "").toLowerCase();
|
||||
return (
|
||||
["button", "a", "summary"].includes(tag) ||
|
||||
["button", "link", "menuitem", "tab", "combobox"].includes(role) ||
|
||||
attr(signals, "tabindex") !== undefined ||
|
||||
signals.cursor === "pointer" ||
|
||||
attr(signals, "onclick") !== undefined ||
|
||||
attr(signals, "onmouseenter") !== undefined ||
|
||||
attr(signals, "onmouseover") !== undefined ||
|
||||
attr(signals, "aria-haspopup") !== undefined ||
|
||||
attr(signals, "aria-expanded") === "false"
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluateHoverTrigger(signals: HoverTriggerSignals): HoverTriggerDecision {
|
||||
const rect = signals.rect;
|
||||
if (
|
||||
!rect ||
|
||||
rect.w <= 0 ||
|
||||
rect.h <= 0 ||
|
||||
signals.pointerEvents === "none" ||
|
||||
attr(signals, "hidden") !== undefined ||
|
||||
attr(signals, "inert") !== undefined ||
|
||||
(attr(signals, "aria-hidden") ?? "").toLowerCase() === "true" ||
|
||||
isUnsafeHoverTrigger(signals)
|
||||
) {
|
||||
return { eligible: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const area = rect.w * rect.h;
|
||||
if (area <= 0 || area > 160_000) {
|
||||
return { eligible: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
const tag = signals.tag.toLowerCase();
|
||||
const role = (signals.role ?? "").toLowerCase();
|
||||
const label = signals.label?.trim();
|
||||
const graphic = signals.hasGraphicDescendant === true;
|
||||
const directInteractive = hasDirectHoverInteractiveSignal(signals);
|
||||
const popupSignal = hasHoverPopupSignal(signals);
|
||||
const isTextNavigationItem =
|
||||
(tag === "a" || role === "link" || role.startsWith("menuitem")) &&
|
||||
!graphic &&
|
||||
!!label &&
|
||||
label.toLowerCase() !== "image";
|
||||
const compactTopbarIcon =
|
||||
(role === "button" || role === "link" || tag === "button" || tag === "a") &&
|
||||
graphic &&
|
||||
rect.y < 120 &&
|
||||
rect.w <= 96 &&
|
||||
rect.h <= 96 &&
|
||||
(!label || label.toLowerCase() === "image");
|
||||
const surfaceTriggerEvidence =
|
||||
(popupSignal && !isTextNavigationItem) ||
|
||||
compactTopbarIcon ||
|
||||
signals.cssHoverMatch === true ||
|
||||
attr(signals, "aria-haspopup") !== undefined ||
|
||||
(attr(signals, "aria-expanded") ?? "").toLowerCase() === "false";
|
||||
if (!directInteractive || !surfaceTriggerEvidence) {
|
||||
return { eligible: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
if (attr(signals, "aria-haspopup") !== undefined) {
|
||||
score += 80;
|
||||
reasons.push("aria-haspopup");
|
||||
}
|
||||
if ((attr(signals, "aria-expanded") ?? "").toLowerCase() === "false") {
|
||||
score += 55;
|
||||
reasons.push("collapsed");
|
||||
}
|
||||
if (popupSignal) {
|
||||
score += 45;
|
||||
reasons.push("popup-signal");
|
||||
}
|
||||
if (tag === "button" || role === "button") {
|
||||
score += 30;
|
||||
reasons.push("button");
|
||||
}
|
||||
if (signals.cursor === "pointer") {
|
||||
score += 25;
|
||||
reasons.push("pointer");
|
||||
}
|
||||
if (graphic && (!label || label.toLowerCase() === "image")) {
|
||||
score += 40;
|
||||
reasons.push("icon-only");
|
||||
}
|
||||
if (rect.y < 120) {
|
||||
score += 25;
|
||||
reasons.push("topbar");
|
||||
}
|
||||
if (rect.w <= 80 && rect.h <= 80) {
|
||||
score += 20;
|
||||
reasons.push("compact");
|
||||
}
|
||||
if (signals.cssHoverMatch) {
|
||||
score += 40;
|
||||
reasons.push("css-hover");
|
||||
}
|
||||
|
||||
return { eligible: score >= HOVER_TRIGGER_MIN_SCORE, score, reasons };
|
||||
}
|
||||
@@ -101,7 +101,7 @@ function twoHoverTriggerSnapshotReply() {
|
||||
"button",
|
||||
"class",
|
||||
"user-avatar",
|
||||
"secondary-trigger",
|
||||
"secondary-dropdown-trigger",
|
||||
"position",
|
||||
"static",
|
||||
"pointer-events",
|
||||
@@ -123,7 +123,7 @@ function twoHoverTriggerSnapshotReply() {
|
||||
[],
|
||||
[],
|
||||
[i("class"), i("user-avatar")],
|
||||
[i("class"), i("secondary-trigger")],
|
||||
[i("class"), i("secondary-dropdown-trigger")],
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// `styles` columns are [position, pointer-events, cursor] in that order.
|
||||
|
||||
import type { Rect, Viewport } from "@browser-skill/vom";
|
||||
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
||||
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
@@ -442,27 +443,6 @@ async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function isVisibleProbeNode(node: CapturedNode): boolean {
|
||||
const rect = node.rect;
|
||||
return (
|
||||
!!rect &&
|
||||
rect.w > 0 &&
|
||||
rect.h > 0 &&
|
||||
node.pointerEvents !== "none" &&
|
||||
node.attrs.hidden === undefined &&
|
||||
node.attrs.inert === undefined &&
|
||||
(node.attrs["aria-hidden"] ?? "").toLowerCase() !== "true"
|
||||
);
|
||||
}
|
||||
|
||||
function isUnsafeHoverTrigger(node: CapturedNode): boolean {
|
||||
const tag = node.tag.toLowerCase();
|
||||
if (["input", "textarea", "select", "option"].includes(tag)) return true;
|
||||
if ((node.attrs.contenteditable ?? "").toLowerCase() === "true") return true;
|
||||
if (node.attrs.disabled !== undefined || node.attrs.inert !== undefined) return true;
|
||||
return (node.attrs["aria-disabled"] ?? "").toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function capturedText(node: CapturedNode): string | undefined {
|
||||
const value =
|
||||
node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? "";
|
||||
@@ -484,120 +464,45 @@ function hasGraphicDescendant(
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasHoverPopupSignal(node: CapturedNode): boolean {
|
||||
const attrs = node.attrs;
|
||||
const haystack = [
|
||||
attrs.id,
|
||||
attrs.class,
|
||||
attrs["data-testid"],
|
||||
attrs["data-test"],
|
||||
attrs["data-cy"],
|
||||
attrs["aria-haspopup"],
|
||||
attrs["aria-controls"],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return /(menu|dropdown|popover|popup|avatar|profile|account|user)/.test(haystack);
|
||||
}
|
||||
|
||||
function roleOf(node: CapturedNode): string {
|
||||
return (node.attrs.role ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
function hasDirectInteractiveSignal(node: CapturedNode): boolean {
|
||||
const tag = node.tag.toLowerCase();
|
||||
const role = roleOf(node);
|
||||
return (
|
||||
["button", "a", "summary"].includes(tag) ||
|
||||
["button", "link", "menuitem", "tab", "combobox"].includes(role) ||
|
||||
node.attrs.tabindex !== undefined ||
|
||||
node.cursor === "pointer" ||
|
||||
node.attrs.onclick !== undefined ||
|
||||
node.attrs.onmouseenter !== undefined ||
|
||||
node.attrs.onmouseover !== undefined ||
|
||||
node.attrs["aria-haspopup"] !== undefined ||
|
||||
node.attrs["aria-expanded"] === "false"
|
||||
);
|
||||
}
|
||||
|
||||
function scoreHoverCandidate(
|
||||
node: CapturedNode,
|
||||
childrenByParentId: Map<number, CapturedNode[]>,
|
||||
cssHoverPoints: Array<{ x: number; y: number }>,
|
||||
): HoverCandidate | null {
|
||||
if (!isVisibleProbeNode(node) || isUnsafeHoverTrigger(node)) {
|
||||
return null;
|
||||
}
|
||||
const rect = node.rect;
|
||||
if (!rect) return null;
|
||||
const area = rect.w * rect.h;
|
||||
if (area <= 0 || area > 160_000) return null;
|
||||
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
const tag = node.tag.toLowerCase();
|
||||
const role = roleOf(node);
|
||||
const label = capturedText(node);
|
||||
const graphic = hasGraphicDescendant(node, childrenByParentId);
|
||||
const directInteractive = hasDirectInteractiveSignal(node);
|
||||
const popupSignal = hasHoverPopupSignal(node);
|
||||
const compactTopbarIcon = !label && graphic && rect.y < 120 && rect.w <= 96 && rect.h <= 96;
|
||||
if (!directInteractive && !popupSignal && !compactTopbarIcon) return null;
|
||||
const cssHoverMatch = cssHoverPoints.some(
|
||||
(point) =>
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.w &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.h,
|
||||
);
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: node.tag,
|
||||
role: roleOf(node),
|
||||
label,
|
||||
attrs: node.attrs,
|
||||
rect,
|
||||
cursor: node.cursor,
|
||||
pointerEvents: node.pointerEvents,
|
||||
hasGraphicDescendant: hasGraphicDescendant(node, childrenByParentId),
|
||||
cssHoverMatch,
|
||||
});
|
||||
|
||||
if (node.attrs["aria-haspopup"] !== undefined) {
|
||||
score += 80;
|
||||
reasons.push("aria-haspopup");
|
||||
}
|
||||
if ((node.attrs["aria-expanded"] ?? "").toLowerCase() === "false") {
|
||||
score += 55;
|
||||
reasons.push("collapsed");
|
||||
}
|
||||
if (popupSignal) {
|
||||
score += 45;
|
||||
reasons.push("popup-signal");
|
||||
}
|
||||
if (tag === "button" || role === "button") {
|
||||
score += 30;
|
||||
reasons.push("button");
|
||||
}
|
||||
if (node.cursor === "pointer") {
|
||||
score += 25;
|
||||
reasons.push("pointer");
|
||||
}
|
||||
if (!label && graphic) {
|
||||
score += 40;
|
||||
reasons.push("icon-only");
|
||||
}
|
||||
if (rect.y < 120) {
|
||||
score += 25;
|
||||
reasons.push("topbar");
|
||||
}
|
||||
if (rect.w <= 80 && rect.h <= 80) {
|
||||
score += 20;
|
||||
reasons.push("compact");
|
||||
}
|
||||
if (
|
||||
cssHoverPoints.some(
|
||||
(point) =>
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.w &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.h,
|
||||
)
|
||||
) {
|
||||
score += 40;
|
||||
reasons.push("css-hover");
|
||||
}
|
||||
|
||||
if (score < 45) return null;
|
||||
if (!decision.eligible) return null;
|
||||
return {
|
||||
backendNodeId: node.backendNodeId,
|
||||
label,
|
||||
x: rect.x + rect.w / 2,
|
||||
y: rect.y + rect.h / 2,
|
||||
score,
|
||||
reasons,
|
||||
score: decision.score,
|
||||
reasons: decision.reasons,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user