feat(dashboard): reveal widget card chrome on hover for fine-pointer devices (#111914)

* feat(dashboard): reveal widget card chrome on hover for fine-pointer devices

* test(dashboard): deterministic focus sink for widget chrome hide assertions

* test(dashboard): finish animations before asserting widget chrome hides
This commit is contained in:
Peter Steinberger
2026-07-20 11:05:13 -07:00
committed by GitHub
parent ea148e728d
commit 8195cf8707
2 changed files with 121 additions and 0 deletions
@@ -88,6 +88,72 @@ describe.skipIf(!hasBrowserLayout)("openclaw-board-view browser layout", () => {
expect(Math.round(first?.height ?? 0)).toBe(BOARD_GRID_ROW_HEIGHT * 3 + BOARD_GRID_GAP * 2);
});
it("hides widget chrome by default on fine-pointer devices", async () => {
const view = await mount();
const bar = view.querySelector<HTMLElement>(".board-widget__bar");
const handle = view.querySelector<HTMLElement>(".board-widget__resize-handle");
expect(getComputedStyle(bar!).visibility).toBe("hidden");
expect(getComputedStyle(handle!).visibility).toBe("hidden");
});
// Chrome must stay revealed while focus is anywhere inside the cell (menu
// close restores focus to its trigger), so hiding is proven by moving focus
// to an outside sink rather than blur(), which leaves focus placement to the
// platform and flakes on Linux.
function focusSink(): HTMLButtonElement {
const sink = document.createElement("button");
sink.type = "button";
document.body.append(sink);
return sink;
}
// Headless CI renderers can stall the animation timeline, leaving the 120ms
// hide transition permanently mid-flight; finishing transitions asserts the
// target visibility state instead of the renderer's clock. A genuinely
// matching reveal selector still fails: its finished end state is visible.
function expectChromeHidden(widget: HTMLElement, bar: HTMLElement): void {
for (const animation of document.getAnimations()) {
animation.finish();
}
const revealState = JSON.stringify({
hover: widget.matches(":hover"),
focusWithin: widget.matches(":focus-within"),
dragging: widget.classList.contains("board-widget--dragging"),
menuOpen: widget.querySelector(".board-widget__menu[open]") !== null,
});
expect(getComputedStyle(bar).visibility, revealState).toBe("hidden");
}
it("reveals widget chrome while the widget has focus", async () => {
const view = await mount();
const sink = focusSink();
const widget = view.querySelector<HTMLElement>('[data-test-id="board-widget"]');
const bar = widget!.querySelector<HTMLElement>(".board-widget__bar");
widget!.focus();
expect(getComputedStyle(bar!).visibility).toBe("visible");
sink.focus();
expect(widget!.matches(":focus-within")).toBe(false);
await vi.waitFor(() => expectChromeHidden(widget!, bar!));
});
it("keeps widget chrome visible while its menu is open", async () => {
const view = await mount();
const sink = focusSink();
const widget = view.querySelector<HTMLElement>('[data-test-id="board-widget"]');
const bar = widget!.querySelector<HTMLElement>(".board-widget__bar");
const menu = widget!.querySelector<HTMLElement & { open: boolean }>(".board-widget__menu");
menu!.open = true;
await vi.waitFor(() => expect(getComputedStyle(bar!).visibility).toBe("visible"));
menu!.open = false;
sink.focus();
expect(widget!.matches(":focus-within")).toBe(false);
await vi.waitFor(() => expectChromeHidden(widget!, bar!));
});
it("snaps pointer resize to columns and rows before committing", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount(applyOps);
+55
View File
@@ -461,6 +461,59 @@ openclaw-board-widget-cell {
width: 6px;
}
@media (hover: hover) and (pointer: fine) {
/* Focus keeps keyboard actions reachable; menu-open and dragging keep chrome
pinned when interaction leaves the card bounds. */
.board-widget {
grid-template-rows: minmax(0, 1fr);
}
.board-widget__bar {
backdrop-filter: blur(6px);
background: color-mix(in srgb, var(--board-surface) 92%, transparent);
border-radius: 11px 11px 0 0;
height: 38px;
left: 0;
opacity: 0;
position: absolute;
right: 0;
top: 0;
transition:
opacity 120ms ease,
visibility 0s linear 120ms;
visibility: hidden;
z-index: 5;
}
.board-widget__resize-handle {
opacity: 0;
transition:
opacity 120ms ease,
visibility 0s linear 120ms;
visibility: hidden;
}
.board-widget:hover .board-widget__bar,
.board-widget:hover .board-widget__resize-handle,
.board-widget:focus-within .board-widget__bar,
.board-widget:focus-within .board-widget__resize-handle,
.board-widget--dragging .board-widget__bar,
.board-widget--dragging .board-widget__resize-handle,
.board-widget:has(.board-widget__menu[open]) .board-widget__bar,
.board-widget:has(.board-widget__menu[open]) .board-widget__resize-handle {
opacity: 1;
/* Delay-only override: reveal flips visibility immediately while the base
shorthand keeps the fade, and reduced-motion's transition: none stays
authoritative because no transition-property is redeclared here. */
transition-delay: 0s;
visibility: visible;
}
.board-widget__body {
border-radius: 11px;
}
}
.board-widget__grant,
.board-widget__error {
align-content: center;
@@ -622,6 +675,8 @@ openclaw-board-widget-cell {
@media (prefers-reduced-motion: reduce) {
.board-widget,
.board-widget__bar,
.board-widget__resize-handle,
.board-tabs__tab,
.swarm-widget__dot--running {
animation: none;