refactor(ui): use native menus for lobster dismissal (#170568)

Replace the lobster dismissal dropdown with the owned Solid Menu while preserving its pointer anchor, dismissal actions, selectors, and lazy loading. Restore composer focus after keyboard dismissal and selection; retain outside interaction focus.

Exact-head full CI, including Chromium/WebKit overlay checks and all UI/real-Gateway shards, passed. Paired startup is 342,335 to 342,328 bytes JS gzip with one CSS request; six inspected synthetic before/after frames are embedded in the PR.
This commit is contained in:
Peter Steinberger 2026-10-12 23:19:03 -07:00 • committed by GitHub
parent 95b3b97a4c
commit 8b1315ac0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 227 additions and 142 deletions

View file

@ -684,7 +684,9 @@ if (uiE2eSelection) {
// Keep its browser-only contracts scoped to their interaction and style owners.
const uiWebkitOwners = [
"ui/vitest.config.ts",
"ui/src/components/{web-awesome*,modal-dialog*,tooltip*,menu-*,overlay*,composer-menu*,dropdown-menu*,anchored-overlay*,textarea-token-anchor*,panel-tab-strip*,select-picker*,multi-select*,agent-select*}.{ts,tsx}",
"ui/src/components/{web-awesome*,modal-dialog*,tooltip*,menu-*,overlay*,composer-menu*,dropdown-menu*,anchored-overlay*,textarea-token-anchor*,panel-tab-strip*,select-picker*,multi-select*,agent-select*,lobster-pet*,session-group-defaults-dialog*}.{ts,tsx}",
"ui/src/app/app-shell-chrome.ts",
"ui/src/pages/{cron/view-jobs-filter,plugins/settings-editor,plugins/settings-view}.{ts,tsx,css}",
"ui/src/pages/chat/chat-composer*.{ts,tsx}",
"ui/src/pages/chat/components/chat-{composer*,picker-overlay*,model-picker*,effort-picker*}.{ts,tsx}",
"ui/src/pages/chat/{chat-pane-header,components/chat-pane-header*,components/chat-pane-versions-menu*}.{ts,tsx}",

View file

@ -1,53 +1,83 @@
import { Show } from "solid-js";
import { createUniqueId, onCleanup } from "solid-js";
import { t } from "../i18n/index.ts";
import type { JSX } from "../types/solid-elements.d.ts";
import "./web-awesome.ts";
import { Menu, type MenuHandle } from "./solid/menu.tsx";
export type LobsterPetDismissMenuPosition = { x: number; y: number };
export function LobsterPetDismissMenu(params: {
position: LobsterPetDismissMenuPosition | null;
position: LobsterPetDismissMenuPosition;
onDismiss: (permanently: boolean) => void;
onClose: () => void;
}): JSX.Element {
// Auto-size clamps the menu to whatever placement flip picks rather than
// relocating it, so top-start is forced directly since the footer always
// has clear room above — leaving it to flip risks a shrunk-in-place menu.
const id = createUniqueId();
let menu!: MenuHandle;
let returnTarget: HTMLElement | undefined;
let removeListeners: (() => void) | undefined;
onCleanup(() => removeListeners?.());
const restoreCapturedFocus = () => {
const trigger = menu.overlay.trigger;
if (!trigger || !returnTarget?.isConnected) {
return;
}
const root = trigger.getRootNode();
const active =
root instanceof ShadowRoot ? root.activeElement : trigger.ownerDocument.activeElement;
// Translate only the owner's trigger focus; outside interactions keep their target.
if (active === trigger) {
returnTarget.focus({ preventScroll: true });
}
};
return (
<Show when={params.position}>
{(position) => (
<wa-dropdown
class="session-menu lobster-pet-dismiss-menu"
prop:open={true}
placement="top-start"
prop:distance={0}
aria-label={t("quickSettings.appearance.lobsterVisits")}
onWa-select={(event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
if (event.detail.item.value === "dismiss") {
params.onDismiss(false);
} else if (event.detail.item.value === "dismiss-permanently") {
params.onDismiss(true);
}
}}
onWa-after-hide={params.onClose}
>
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label={t("quickSettings.appearance.lobsterVisits")}
style={`position: fixed; left: ${position().x}px; top: ${position().y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;`}
/>
<wa-dropdown-item class="session-menu__item" value="dismiss">
{t("common.dismiss")}
</wa-dropdown-item>
<wa-dropdown-item class="session-menu__item" value="dismiss-permanently">
{t("common.dismissAndDontShowAgain")}
</wa-dropdown-item>
</wa-dropdown>
)}
</Show>
<div
class="lobster-pet-dismiss-menu__position"
style={`--lobster-pet-menu-x: ${params.position.x}px; --lobster-pet-menu-y: ${params.position.y}px;`}
>
<Menu
id={id}
class="session-menu lobster-pet-dismiss-menu"
panelClass="lobster-pet-dismiss-menu__panel"
triggerClass="lobster-pet-dismiss-menu__trigger"
ref={(handle) => {
menu = handle;
// Menu binds its trigger before ref; it is only a hidden point anchor here.
const trigger = handle.overlay.trigger!;
trigger.tabIndex = -1;
trigger.setAttribute("aria-hidden", "true");
const root = trigger.getRootNode();
const active =
root instanceof ShadowRoot ? root.activeElement : trigger.ownerDocument.activeElement;
returnTarget = active instanceof HTMLElement ? active : undefined;
const surface = handle.overlay.surface;
const close = () => params.onClose();
surface.addEventListener("overlay-after-hide", close);
trigger.addEventListener("focus", restoreCapturedFocus);
removeListeners = () => {
surface.removeEventListener("overlay-after-hide", close);
trigger.removeEventListener("focus", restoreCapturedFocus);
};
if (!handle.open()) {
params.onClose();
}
}}
placement="top-start"
label={t("quickSettings.appearance.lobsterVisits")}
items={[
{ id: "dismiss", label: t("common.dismiss"), class: "session-menu__item" },
{
id: "dismiss-permanently",
label: t("common.dismissAndDontShowAgain"),
class: "session-menu__item",
},
]}
onSelect={(item, event) => {
event.preventDefault();
// Restore focus through the owner before dismissal removes this menu.
if (menu.close()) {
params.onDismiss(item.id === "dismiss-permanently");
}
}}
/>
</div>
);
}

View file

@ -195,7 +195,13 @@ function Sprite(props: LobsterPetSceneProps & { twin: boolean }) {
style={visual().style}
aria-hidden="true"
title={visual().title}
onPointerDown={(event) => props.onPointerDown(event)}
onPointerDown={(event) => {
// Keep the composer focused before the context menu captures its return target.
if (event.button === 2) {
event.preventDefault();
}
props.onPointerDown(event);
}}
onPointerUp={(event) => props.onPointerUp(event)}
onPointerCancel={() => props.onPointerCancel()}
onPointerLeave={() => props.onPointerCancel()}

View file

@ -2,6 +2,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitForSolid } from "../test-helpers/solid-settle.ts";
import { getLobsterdex, getLobsterdexEntries } from "./lobster-dex.ts";
import { resolveLobsterPetMode, resolveLobsterRunOutcome } from "./lobster-pet-contract.ts";
import {
@ -30,11 +31,18 @@ async function advancePetFrame(element: LobsterPetElement): Promise<void> {
}
async function settleDismissMenu(element: LobsterPetElement) {
for (const child of element.querySelectorAll<HTMLElement & { updateComplete: Promise<boolean> }>(
"wa-dropdown, wa-dropdown-item",
)) {
await child.updateComplete;
}
await waitForSolid(() => {
const firstItem = element.querySelector('.lobster-pet-dismiss-menu [role="menuitem"]');
expect(firstItem).not.toBeNull();
expect(document.activeElement).toBe(firstItem);
});
}
function selectDismissal(element: LobsterPetElement, value: "dismiss" | "dismiss-permanently") {
expectDefined(
element.querySelector<HTMLButtonElement>(`[role="menuitem"][data-value="${value}"]`),
`${value} menu item`,
).click();
}
function poke(element: LobsterPetElement): void {
@ -78,10 +86,11 @@ async function startVigilOnlyRun(
return element;
}
afterEach(() => {
afterEach(async () => {
document.body.replaceChildren();
await Promise.resolve();
vi.useRealTimers();
vi.unstubAllGlobals();
document.body.innerHTML = "";
localStorage.clear();
});
@ -326,10 +335,12 @@ describe("lobster pet element", () => {
expect(getLobsterdexEntries().get(look.palette.id)?.name).toBeTruthy();
});
it("anchors the dismiss menu at the raw pointer position with a top-start placement", async () => {
it("anchors the dismiss menu at the raw pointer position and preserves outside focus", async () => {
vi.useFakeTimers();
const element = createPet(42);
await arrive(element);
const composer = element.parentElement!.querySelector<HTMLTextAreaElement>("textarea")!;
composer.focus();
// Coordinates deliberately exceed a guessed 264x80 pre-clamp: a
// reintroduced clamp would move the trigger away from these exact values.
@ -342,17 +353,29 @@ describe("lobster pet element", () => {
);
await element.updateComplete;
const dropdown = element.querySelector(".lobster-pet-dismiss-menu");
expect(dropdown?.getAttribute("placement")).toBe("top-start");
const trigger = element.querySelector<HTMLElement>('[slot="trigger"]');
expect(trigger?.style.left).toBe(`${clientX}px`);
expect(trigger?.style.top).toBe(`${clientY}px`);
await settleDismissMenu(element);
const menu = element.querySelector(".lobster-pet-dismiss-menu__panel");
expect(menu?.getAttribute("data-placement")).toBe("top-start");
const trigger = element.querySelector<HTMLElement>(
".lobster-pet-dismiss-menu .oc-menu-trigger",
);
const position = trigger?.closest<HTMLElement>(".lobster-pet-dismiss-menu__position");
expect(position?.style.getPropertyValue("--lobster-pet-menu-x")).toBe(`${clientX}px`);
expect(position?.style.getPropertyValue("--lobster-pet-menu-y")).toBe(`${clientY}px`);
expect(trigger?.tabIndex).toBe(-1);
expect(trigger?.getAttribute("aria-hidden")).toBe("true");
composer.focus();
await Promise.resolve();
expect(menu?.matches(":popover-open")).toBe(false);
expect(document.activeElement).toBe(composer);
});
it("offers a temporary dismissal from the right-click menu", async () => {
vi.useFakeTimers();
const element = createPet(42);
await arrive(element);
const composer = element.parentElement!.querySelector<HTMLTextAreaElement>("textarea")!;
composer.focus();
const sprite = element.querySelector(".lobster-pet");
sprite?.dispatchEvent(new MouseEvent("pointerdown", { button: 2 }));
@ -363,15 +386,29 @@ describe("lobster pet element", () => {
expect(shoo.defaultPrevented).toBe(true);
expect(spritePresent(element)).toBe(true);
expect(spriteClasses(element)).not.toContain("lobster-pet--act-startle");
await settleDismissMenu(element);
expect(
[...element.querySelectorAll(".lobster-pet-dismiss-menu wa-dropdown-item")].map((item) =>
[...element.querySelectorAll('.lobster-pet-dismiss-menu [role="menuitem"]')].map((item) =>
item.textContent?.trim(),
),
).toEqual(["Dismiss", "Dismiss and don't show again"]);
await settleDismissMenu(element);
element.querySelector<HTMLElement>('wa-dropdown-item[value="dismiss"]')?.click();
const veto = (event: Event) => event.preventDefault();
document.addEventListener("overlay-hide", veto);
try {
selectDismissal(element, "dismiss");
await element.updateComplete;
expect(spriteClasses(element)).not.toContain("lobster-pet--away");
expect(element.querySelector('[role="menu"]')?.matches(":popover-open")).toBe(true);
expect(document.activeElement).toBe(
element.querySelector('.lobster-pet-dismiss-menu [role="menuitem"]'),
);
} finally {
document.removeEventListener("overlay-hide", veto);
}
selectDismissal(element, "dismiss");
await element.updateComplete;
expect(document.activeElement).toBe(composer);
const gone = await advanceUntil(element, () => !spritePresent(element), 5_000);
expect(gone).toBe(true);
@ -395,7 +432,7 @@ describe("lobster pet element", () => {
?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await element.updateComplete;
await settleDismissMenu(element);
element.querySelector<HTMLElement>('wa-dropdown-item[value="dismiss-permanently"]')?.click();
selectDismissal(element, "dismiss-permanently");
await element.updateComplete;
const persistedSettings = Array.from({ length: localStorage.length }, (_, index) => {
@ -494,7 +531,7 @@ describe("lobster pet element", () => {
?.dispatchEvent(new MouseEvent("contextmenu", { cancelable: true, bubbles: true }));
await element.updateComplete;
await settleDismissMenu(element);
element.querySelector<HTMLElement>('wa-dropdown-item[value="dismiss"]')?.click();
selectDismissal(element, "dismiss");
await element.updateComplete;
const raw = JSON.parse(
localStorage.getItem("openclaw.control.lobsterpet.familiarity.v1") ?? "{}",

View file

@ -1,10 +1,13 @@
import { createEffect, createSignal, onCleanup, onSettled } from "solid-js";
import { createEffect, createSignal, lazy, Loading, onCleanup, onSettled, Show } from "solid-js";
import { defineSolidBridge } from "../lit/solid-bridge.ts";
import type { LobsterPetProps } from "./lobster-pet-contract.ts";
import { LobsterPetController } from "./lobster-pet-controller.ts";
import { LobsterPetDismissMenu } from "./lobster-pet-dismiss-menu.tsx";
import { LobsterPetScene } from "./lobster-pet-scene-view.tsx";
const LobsterPetDismissMenu = lazy(() => import("./lobster-pet-dismiss-menu.tsx"), {
export: "LobsterPetDismissMenu",
});
// A bridge remount retires the view, not the host's once-per-load visitor history.
const pets = new WeakMap<HTMLElement, LobsterPetController>();
@ -40,11 +43,17 @@ export const LobsterPet = defineSolidBridge<LobsterPetProps>(
return (
<>
<LobsterPetScene scene={scene()} />
<LobsterPetDismissMenu
position={position()}
onDismiss={(permanently) => pet.dismiss(permanently)}
onClose={() => pet.closeMenu()}
/>
<Show when={position()}>
{(menuPosition) => (
<Loading>
<LobsterPetDismissMenu
position={menuPosition()}
onDismiss={(permanently) => pet.dismiss(permanently)}
onClose={() => pet.closeMenu()}
/>
</Loading>
)}
</Show>
</>
);
},

View file

@ -67,67 +67,34 @@ async function configureRealComposerPet(currentPage: Page, seed: number) {
* the viewport. */
async function measureDismissMenu(currentPage: Page) {
return await currentPage.evaluate(() => {
const dropdown = document.querySelector("wa-dropdown.lobster-pet-dismiss-menu");
if (!dropdown?.shadowRoot) {
throw new Error("dismiss menu dropdown not found");
}
const menu = dropdown.shadowRoot.querySelector<HTMLElement>("#menu");
const popup = dropdown.shadowRoot.querySelector("wa-popup");
const popupBox = popup?.shadowRoot?.querySelector<HTMLElement>(".popup") ?? null;
const menu = document.querySelector<HTMLElement>(".lobster-pet-dismiss-menu__panel");
if (!menu) {
throw new Error("dismiss menu #menu part not found");
throw new Error("dismiss menu not found");
}
const menuStyle = getComputedStyle(menu);
const menuRect = menu.getBoundingClientRect();
const host = document.querySelector<HTMLElement>("openclaw-lobster-pet");
const hostStyle = host ? getComputedStyle(host) : null;
const hostRect = host?.getBoundingClientRect() ?? null;
const firstItem = dropdown.querySelector("wa-dropdown-item");
const firstItemLabel = firstItem?.shadowRoot?.querySelector<HTMLElement>("#label") ?? null;
const firstItem = menu.querySelector<HTMLElement>('[role="menuitem"]');
const firstItemLabel = firstItem?.querySelector<HTMLElement>(".oc-menu-item__label");
const anchorRect = document
.querySelector(".lobster-pet-dismiss-menu__trigger")
?.getBoundingClientRect();
return {
// The item's rendered label size comes from the app's own
// .session-menu__item light-DOM class (ui/src/styles/layout.css),
// which sets an explicit font-size from --control-ui-text-scale —
// not from wa-dropdown-item's shadow styles. Captured here so tests
// can prove the text-scale token actually reached the label instead
// of trusting an unrelated Web Awesome token override.
firstItemFontSizePx: firstItem
? Number.parseFloat(getComputedStyle(firstItem).fontSize)
: null,
// The label's own unclamped box height: .session-menu__item's
// min-height design floor (28px, ui/src/styles/base.css) keeps a
// single short line and a single modestly-scaled line at the same
// 28px row height, so the row height alone can't tell a real
// font-size increase from a no-op. The label's own height isn't
// floor-clamped and grows with font-size regardless.
firstItemLabelHeightPx: firstItemLabel?.getBoundingClientRect().height ?? null,
scrollHeight: menu.scrollHeight,
clientHeight: menu.clientHeight,
offsetHeight: menu.offsetHeight,
overflowPx: menu.scrollHeight - menu.clientHeight,
scrollbarWidthPx: menu.offsetWidth - menu.clientWidth,
computedMaxHeight: menuStyle.maxHeight,
computedOverflowY: menuStyle.overflowY,
autoSizeAvailableHeight: popup
? getComputedStyle(popup).getPropertyValue("--auto-size-available-height").trim()
: "(no wa-popup)",
resolvedPlacement: popup?.getAttribute("data-current-placement") ?? "(none)",
anchorTop: dropdown.querySelector<HTMLElement>('[slot="trigger"]')?.style.top ?? "(none)",
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth,
menuLeft: menuRect.left,
menuRight: menuRect.right,
menuTop: menuRect.top,
menuBottom: menuRect.bottom,
popupIsTopLayer: popupBox ? popupBox.matches(":popover-open") : null,
hasOuterMenuSurface: dropdown.closest("openclaw-menu-surface") !== null,
hostHeight: hostStyle?.height ?? null,
hostOverflow: hostStyle?.overflow ?? null,
anchorLeft: anchorRect?.left,
anchorTop: anchorRect?.top,
popupIsTopLayer: menu.matches(":popover-open"),
hasOuterMenuSurface: menu.closest("openclaw-menu-surface") !== null,
hostParentClass: host?.parentElement?.className ?? null,
hostBottom: hostRect?.bottom ?? null,
itemHeights: [...dropdown.querySelectorAll("wa-dropdown-item")].map(
(item) => (item as HTMLElement).offsetHeight,
),
};
});
}
@ -137,18 +104,8 @@ async function measureDismissMenu(currentPage: Page) {
// way ui/src/app/bootstrap.ts:79 applies it at runtime.
const ENLARGED_TEXT_SCALE = "1.4";
/** Rewrites the two dismissal items to long, wide labels and applies the
* real "enlarged type scale" setting. The dismiss menu's items render
* through the app's own `.session-menu__item` light-DOM class
* (ui/src/styles/layout.css), which sets an explicit
* `font-size: calc(13px * var(--control-ui-text-scale))` — that wins over
* wa-dropdown-item's inherited shadow-DOM styles, which never set
* font-size themselves (confirmed in the installed Web Awesome 3.10.0
* dropdown-item styles). So --control-ui-text-scale, not any
* --wa-font-size-* token, is the lever that actually changes what the
* operator sees. Stands in for a locale whose strings and type-scale are
* both larger than the pinned English default, without depending on the
* app's network-loaded locale chunks. */
/** Long labels and the real Appearance text-scale setting exercise the
* owned menu's wrapping without depending on network-loaded locale chunks. */
async function useOversizedDismissLabels(currentPage: Page) {
await currentPage.evaluate((scale) => {
document.documentElement.style.setProperty("--control-ui-text-scale", scale);
@ -157,7 +114,7 @@ async function useOversizedDismissLabels(currentPage: Page) {
"Permanently suppress every future lobster visit notice across all workspaces",
];
document
.querySelectorAll("wa-dropdown.lobster-pet-dismiss-menu wa-dropdown-item")
.querySelectorAll(".lobster-pet-dismiss-menu__panel .oc-menu-item__label")
.forEach((item, index) => {
item.textContent = labels[index] ?? item.textContent;
});
@ -170,8 +127,19 @@ suite.define(() => {
await configureRealComposerPet(page, 42);
const sprite = page.locator(".lobster-pet");
await sprite.waitFor();
// Native dismissal completion needs live animation frames.
await page.clock.resume();
const composer = page.locator(".new-session-page__message");
await composer.focus();
await sprite.click({ button: "right" });
await page.getByText("Dismiss and don't show again", { exact: true }).waitFor();
await expect
.poll(() =>
page
.getByRole("menuitem", { name: "Dismiss", exact: true })
.evaluate((item) => item === document.activeElement),
)
.toBe(true);
const measurement = await measureDismissMenu(page);
expect(measurement).toMatchObject({
overflowPx: 0,
@ -182,8 +150,23 @@ suite.define(() => {
expect(measurement.menuTop).toBeGreaterThanOrEqual(0);
expect(measurement.menuBottom).toBeLessThanOrEqual(measurement.viewportHeight);
expect(await page.locator("openclaw-app-sidebar openclaw-lobster-pet").count()).toBe(0);
await page.getByText("Dismiss", { exact: true }).click();
await page.clock.runFor(350);
const panel = page.locator(".lobster-pet-dismiss-menu__panel");
await page.keyboard.press("Escape");
expect(await composer.evaluate((input) => input === document.activeElement)).toBe(true);
await panel.waitFor({ state: "detached" });
expect(await sprite.count()).toBe(1);
await sprite.click({ button: "right" });
await panel.waitFor();
await composer.click();
await panel.waitFor({ state: "detached" });
expect(await composer.evaluate((input) => input === document.activeElement)).toBe(true);
expect(await sprite.count()).toBe(1);
await sprite.click({ button: "right" });
await panel.waitFor();
await page.keyboard.press("Enter");
expect(await composer.evaluate((input) => input === document.activeElement)).toBe(true);
await expect.poll(() => sprite.count()).toBe(0);
}));
@ -194,16 +177,13 @@ suite.define(() => {
await sprite.waitFor();
await sprite.click({ button: "right" });
await page.locator("wa-dropdown.lobster-pet-dismiss-menu").waitFor();
await page.locator(".lobster-pet-dismiss-menu__panel").waitFor();
await page.getByText("Dismiss and don't show again", { exact: true }).waitFor();
const baseline = await measureDismissMenu(page);
await useOversizedDismissLabels(page);
// Web Awesome's popup tracks anchor/floating element size with a
// ResizeObserver, so the label swap above reflows the popup
// asynchronously; poll for settlement instead of asserting immediately
// after the DOM write.
// Allow CSS anchor positioning to settle after the labels reflow.
await expect
.poll(async () => (await measureDismissMenu(page)).overflowPx, {
message: "menu did not settle",
@ -231,15 +211,13 @@ suite.define(() => {
await sprite.waitFor();
await sprite.click({ button: "right" });
await shortPage.locator("wa-dropdown.lobster-pet-dismiss-menu").waitFor();
await shortPage.locator(".lobster-pet-dismiss-menu__panel").waitFor();
await shortPage.getByText("Dismiss and don't show again", { exact: true }).waitFor();
const measurement = await measureDismissMenu(shortPage);
// At this height even the "top" placement may run short, so Web
// Awesome's own vertical auto-size may still shrink the menu in place
// (an accepted last-resort fallback). What must hold regardless is
// that the popup itself never renders outside the viewport.
// The native anchor can flip or constrain the menu at short heights;
// either placement must stay within the viewport.
expect(measurement.menuTop).toBeGreaterThanOrEqual(0);
expect(measurement.menuBottom).toBeLessThanOrEqual(measurement.viewportHeight);
});
@ -256,17 +234,24 @@ suite.define(() => {
const point = await sprite.evaluate(
(element, direction) => ({
clientX: direction === "left" ? 12 : innerWidth - 12,
clientY: element.getBoundingClientRect().top + 12,
clientY: Math.trunc(element.getBoundingClientRect().top + 12),
}),
edge,
);
await sprite.dispatchEvent("contextmenu", point);
await page.locator("wa-dropdown.lobster-pet-dismiss-menu").waitFor();
await sprite.evaluate((element, position) => {
element.dispatchEvent(
new MouseEvent("contextmenu", { ...position, bubbles: true, cancelable: true }),
);
}, point);
await page.locator(".lobster-pet-dismiss-menu__panel").waitFor();
await page.clock.runFor(150);
const measurement = await measureDismissMenu(page);
expect(measurement.menuLeft).toBeGreaterThanOrEqual(0);
expect(measurement.menuRight).toBeLessThanOrEqual(measurement.viewportWidth);
expect(measurement.anchorLeft).toBeCloseTo(point.clientX);
expect(measurement.anchorTop).toBeCloseTo(point.clientY);
}));
}
@ -279,7 +264,7 @@ suite.define(() => {
await sprite.waitFor();
await sprite.click({ button: "right" });
await darkPage.locator("wa-dropdown.lobster-pet-dismiss-menu").waitFor();
await darkPage.locator(".lobster-pet-dismiss-menu__panel").waitFor();
await darkPage.getByText("Dismiss and don't show again", { exact: true }).waitFor();
const measurement = await measureDismissMenu(darkPage);

View file

@ -121,10 +121,26 @@ openclaw-lobster-pet .lobster-bottle {
}
/* Restore hit testing beneath the pointer-inert decorative pet host. */
wa-dropdown.lobster-pet-dismiss-menu {
.lobster-pet-dismiss-menu {
pointer-events: auto;
}
.lobster-pet-dismiss-menu__panel {
--oc-overlay-distance: 0px;
}
.lobster-pet-dismiss-menu__trigger {
position: fixed;
left: var(--lobster-pet-menu-x);
top: var(--lobster-pet-menu-y);
width: 1px;
height: 1px;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
.lobster-pet--elder {
transition: left 2.6s ease-in-out;
}