fix(ui): dashboard widgets leave a gap below content and hover chrome blocks widget buttons (#114012)

* fix(ui): hug auto widget cards to content and compact board chrome into a top-left pill

* test(gateway): retry temp-dir cleanup in chat abort persistence suite to absorb ENOTEMPTY race

* fix(ui): strip board pill to move and menu on narrow widgets so the action corner stays widget-owned
This commit is contained in:
Peter Steinberger
2026-07-26 01:14:40 -04:00
committed by GitHub
parent 5284408470
commit 36404eaa3b
7 changed files with 248 additions and 51 deletions
@@ -248,7 +248,11 @@ afterEach(async () => {
transcriptFixtures.clear();
const dirs = [...fixtureDirs];
fixtureDirs.clear();
await Promise.all(dirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
// Abort persistence can still be flushing SQLite sidecar files when cleanup
// starts; retries absorb the ENOTEMPTY window instead of failing the test.
await Promise.all(
dirs.map((dir) => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })),
);
});
describe("chat abort transcript persistence", () => {
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { effectiveBoardWidgetRows } from "../../lib/board/grid.ts";
import {
boardChromeRowPx,
effectiveBoardWidgetRows,
exactBoardWidgetHeightPx,
} from "../../lib/board/grid.ts";
// Side-effect import: test-support only type-imports the component, so the
// custom element must be registered here for mount() to render anything.
import "./board-view.ts";
@@ -29,6 +33,46 @@ describe("board widget sizing", () => {
expect(effectiveBoardWidgetRows(card, 150, 0)).toBe(3);
});
it("hugs auto cards to their exact content height inside the quantized cell", () => {
const card = boardWidget({ sizeH: 6 });
// Card inset adds 12px per edge; frameless/full-bleed report bare content.
expect(exactBoardWidgetHeightPx(card, 300)).toBe(324);
expect(exactBoardWidgetHeightPx({ ...card, presentation: "frameless" }, 300)).toBe(300);
// Short content hugs below the 2-row cell minimum: the cell keeps its
// minimum span, only the card shrinks.
expect(exactBoardWidgetHeightPx(card, 60)).toBe(84);
// At the 20-row cap the card fills the cell exactly and the body clips.
expect(exactBoardWidgetHeightPx(card, 10_000)).toBe(20 * 56 + 19 * 12);
// Coarse-pointer layouts keep the 38px bar in flow, joining the height.
expect(exactBoardWidgetHeightPx(card, 300, 38)).toBe(362);
expect(exactBoardWidgetHeightPx({ ...card, heightMode: "fixed" }, 300)).toBeUndefined();
expect(exactBoardWidgetHeightPx(card, undefined)).toBeUndefined();
expect(exactBoardWidgetHeightPx({ ...card, contentKind: "mcp-app" }, 300)).toBeUndefined();
});
it("pins the exact reported height on the card and re-fills while dragging", async () => {
const view = await mount();
const cell = view.querySelector("openclaw-board-widget-cell");
const frame = cell?.querySelector("iframe");
window.dispatchEvent(
new MessageEvent("message", {
source: frame?.contentWindow ?? null,
data: { type: "openclaw:widget-size", height: 300 },
}),
);
const widget = boardWidget();
const expected = exactBoardWidgetHeightPx(widget, 300, boardChromeRowPx());
const section = () => cell?.querySelector<HTMLElement>(".board-widget");
await vi.waitFor(() => {
expect(section()?.getAttribute("style")).toContain(`height: ${expected}px`);
expect(section()?.getAttribute("style")).toContain("align-self: start");
});
// Gestures manipulate the quantized cell, so the card fills it again.
Reflect.set(cell ?? {}, "dragging", true);
await cell?.updateComplete;
expect(section()?.getAttribute("style")).not.toContain("align-self");
});
it("pins preset resizing and toggles height mode from the menu", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount({ callbacks: callbacks({ applyOps }) });
@@ -133,19 +133,80 @@ describe.skipIf(!hasBrowserLayout)("openclaw-board-view browser layout", () => {
widget!.focus();
expect(getComputedStyle(bar!).visibility).toBe("visible");
// The revealed strip must not steal clicks from widget content under it;
// only real controls (drag handle, kebab) stay interactive.
expect(getComputedStyle(bar!).pointerEvents).toBe("none");
const handle = bar!.querySelector<HTMLElement>(".board-widget__drag-handle");
const trigger = bar!.querySelector<HTMLElement>(".board-widget__menu-trigger");
expect(getComputedStyle(handle!).pointerEvents).toBe("auto");
expect(getComputedStyle(trigger!).pointerEvents).toBe("auto");
// Chrome is a compact top-left pill, not a full-width strip: it must not
// stretch across the card, so widget-owned top-right actions stay clear.
const barBounds = bar!.getBoundingClientRect();
const widgetBounds = widget!.getBoundingClientRect();
expect(barBounds.width).toBeLessThan(widgetBounds.width * 0.75);
expect(barBounds.left - widgetBounds.left).toBeLessThan(widgetBounds.right - barBounds.right);
sink.focus();
expect(widget!.matches(":focus-within")).toBe(false);
await vi.waitFor(() => expectChromeHidden(widget!, bar!));
});
it("reserves the top-right action corner even for narrow long-titled widgets", async () => {
const view = await mount();
view.snapshot = {
...structuredClone(source),
widgets: [
{
...source.widgets[0]!,
sizeW: 6,
title: "An extremely long widget title that wants the whole bar",
grantState: "granted",
},
],
};
await view.updateComplete;
const cell = view.querySelector("openclaw-board-widget-cell");
await cell?.updateComplete;
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");
// The interactive pill reserves a widget-owned right-hand region and none
// of its children may overflow the capped box into that corner.
const widgetBounds = widget!.getBoundingClientRect();
expect(widgetBounds.right - bar!.getBoundingClientRect().right).toBeGreaterThanOrEqual(88);
for (const child of bar!.children) {
expect(child.getBoundingClientRect().right).toBeLessThanOrEqual(
bar!.getBoundingClientRect().right + 1,
);
}
});
it("strips the pill to move + menu on widgets too narrow for the reservation", async () => {
const view = await mount();
view.snapshot = {
...structuredClone(source),
widgets: [
{
...source.widgets[0]!,
sizeW: 3,
title: "An extremely long widget title that wants the whole bar",
grantState: "granted",
},
],
};
await view.updateComplete;
const cell = view.querySelector("openclaw-board-widget-cell");
await cell?.updateComplete;
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");
// Below the 184px container threshold the display-only pieces disappear so
// the pill is the irreducible move + menu pair and the rest of the card
// stays widget-owned.
expect(widget!.getBoundingClientRect().width).toBeLessThan(184);
const title = bar!.querySelector<HTMLElement>(".board-widget__title");
const kind = bar!.querySelector<HTMLElement>(".board-widget__kind");
expect(getComputedStyle(title!).display).toBe("none");
expect(getComputedStyle(kind!).display).toBe("none");
expect(bar!.getBoundingClientRect().width).toBeLessThanOrEqual(76);
});
it("keeps widget chrome visible while its menu is open", async () => {
const view = await mount();
const sink = focusSink();
@@ -272,11 +333,9 @@ describe.skipIf(!hasBrowserLayout)("openclaw-board-view browser layout", () => {
data: { type: "openclaw:widget-size", height: 300 },
}),
);
await vi.waitFor(() =>
expect(Math.round(first.getBoundingClientRect().height)).toBe(
BOARD_GRID_ROW_HEIGHT * 5 + BOARD_GRID_GAP * 4,
),
);
// The card hugs its exact content height (300px + 2x12px card inset); the
// ceil-to-row slack stays outside the card as grid background.
await vi.waitFor(() => expect(Math.round(first.getBoundingClientRect().height)).toBe(324));
expect(second.getBoundingClientRect().top).toBeGreaterThan(secondTopBefore);
const cardBody = first.querySelector<HTMLElement>(".board-widget__body");
+5 -8
View File
@@ -259,14 +259,10 @@ class OpenClawBoardView extends OpenClawLightDomElement {
if (!widget || widget.contentKind !== "html") {
return;
}
const chromeRowPx = boardChromeRowPx();
const previousRows = effectiveBoardWidgetRows(
widget,
this.contentHeights.get(name),
chromeRowPx,
);
this.contentHeights.set(name, height);
if (effectiveBoardWidgetRows(widget, height, chromeRowPx) !== previousRows) {
// Any pixel change matters: the cell renders the exact reported height,
// not just the quantized row span.
if (this.contentHeights.get(name) !== height) {
this.contentHeights.set(name, height);
this.requestUpdate();
}
},
@@ -654,6 +650,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
<openclaw-board-widget-cell
.widget=${widget}
.rect=${rect}
.contentHeightPx=${this.contentHeights.get(widget.name)}
.tabs=${tabs}
.sessionKey=${sessionKey}
.widgetFrameUrl=${this.widgetFrameUrl}
+14 -2
View File
@@ -5,7 +5,11 @@ import { applicationContext, type ApplicationContext } from "../../app/context.t
import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts";
import { t } from "../../i18n/index.ts";
import type { BoardGridDirection, BoardGridRect } from "../../lib/board/grid.ts";
import { toCssPlacement } from "../../lib/board/grid.ts";
import {
boardChromeRowPx,
exactBoardWidgetHeightPx,
toCssPlacement,
} from "../../lib/board/grid.ts";
import type { BoardWidgetAppViewState } from "../../lib/board/provider.ts";
import type { BoardTab } from "../../lib/board/types.ts";
import type {
@@ -64,6 +68,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@property({ attribute: false }) widget?: BoardViewWidget;
@property({ attribute: false }) rect?: BoardGridRect;
@property({ attribute: false }) contentHeightPx?: number;
@property({ attribute: false }) tabs: readonly BoardTab[] = [];
@property({ attribute: false }) sessionKey = "";
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
@@ -390,10 +395,17 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
bodyScrollable || widget.contentKind === "mcp-app" || widget.contentKind === "plugin";
const presentation =
widget.contentKind === "html" ? (widget.presentation ?? "card") : undefined;
// While a move/resize gesture runs, the card fills its (preview) cell so
// the user manipulates the quantized rect they will actually commit.
const exactHeightPx = this.dragging
? undefined
: exactBoardWidgetHeightPx(widget, this.contentHeightPx, boardChromeRowPx());
const exactHeightStyle =
exactHeightPx === undefined ? "" : ` height: ${exactHeightPx}px; align-self: start;`;
return html`
<section
class=${`board-widget ${this.dragging ? "board-widget--dragging" : ""} ${presentation ? `board-widget--${presentation}` : ""}`}
style=${toCssPlacement(rect)}
style=${`${toCssPlacement(rect)}${exactHeightStyle}`}
role="listitem"
tabindex=${this.focusTabIndex}
aria-posinset=${this.positionInSet}
+45 -6
View File
@@ -216,11 +216,11 @@ export function boardChromeRowPx(): number {
: 0;
}
export function effectiveBoardWidgetRows(
function autoBoardWidgetHeightPx(
widget: BoardWidgetSizingInput,
contentHeightPx: number | undefined,
chromeRowPx = 0,
): number {
chromeRowPx: number,
): number | undefined {
// Absent heightMode means auto BY DESIGN, including widgets pinned before
// the contract existed: retroactive content-fitting is the product goal, and
// one menu click re-pins. Only an explicit "fixed" preserves stored height.
@@ -231,18 +231,57 @@ export function effectiveBoardWidgetRows(
!Number.isFinite(contentHeightPx) ||
contentHeightPx <= 0
) {
return widget.sizeH;
return undefined;
}
const requiredHeight =
return (
contentHeightPx +
chromeRowPx +
((widget.presentation ?? "card") === "card" ? BOARD_WIDGET_FRAME_INSET * 2 : 0);
((widget.presentation ?? "card") === "card" ? BOARD_WIDGET_FRAME_INSET * 2 : 0)
);
}
function boardRowSpanPx(rows: number): number {
return rows * BOARD_GRID_ROW_HEIGHT + (rows - 1) * BOARD_GRID_GAP;
}
export function effectiveBoardWidgetRows(
widget: BoardWidgetSizingInput,
contentHeightPx: number | undefined,
chromeRowPx = 0,
): number {
const requiredHeight = autoBoardWidgetHeightPx(widget, contentHeightPx, chromeRowPx);
if (requiredHeight === undefined) {
return widget.sizeH;
}
const rows = Math.ceil(
(requiredHeight + BOARD_GRID_GAP) / (BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP),
);
return Math.min(BOARD_WIDGET_AUTO_MAX_ROWS, Math.max(BOARD_WIDGET_AUTO_MIN_ROWS, rows));
}
/**
* Exact card height for auto-sized HTML widgets. The grid cell stays quantized
* to rows (`effectiveBoardWidgetRows`), but the card hugs its content so the
* ceil-to-row slack lands outside the card as background spacing instead of
* dead space inside it. Undefined means "fill the cell" (fixed/non-HTML).
*/
export function exactBoardWidgetHeightPx(
widget: BoardWidgetSizingInput,
contentHeightPx: number | undefined,
chromeRowPx = 0,
): number | undefined {
const requiredHeight = autoBoardWidgetHeightPx(widget, contentHeightPx, chromeRowPx);
if (requiredHeight === undefined) {
return undefined;
}
// Never exceed the quantized cell: at the row cap the content is taller than
// the cell, so the card fills the cell and the body scrolls/clips as before.
return Math.min(
requiredHeight,
boardRowSpanPx(effectiveBoardWidgetRows(widget, contentHeightPx, chromeRowPx)),
);
}
/** Structural sizing inputs so pure grid math stays free of view-type imports. */
type BoardWidgetSizingInput = {
contentKind: string;
+63 -21
View File
@@ -743,49 +743,94 @@ openclaw-workboard-mini-widget {
@media (hover: hover) and (pointer: fine) {
/* Focus keeps keyboard actions reachable; menu-open and dragging keep chrome
pinned when interaction leaves the card bounds. */
pinned when interaction leaves the card bounds. Inline-size containment
lets the pill drop to its minimal form on narrow cards; grid tracks set
the card's width, so containment changes no layout. */
.board-widget {
container-type: inline-size;
grid-template-rows: minmax(0, 1fr);
}
/* Chrome is a compact top-left pill, not a full-width strip: widgets put
their own actions top-right, so that corner must stay visible and
clickable even while host chrome is revealed. The 30px pill height is
overlay-only; coarse pointers keep the base 38px in-flow grid row that
BOARD_WIDGET_TOUCH_BAR_PX mirrors. */
.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;
border: 1px solid var(--board-line);
border-radius: 999px;
box-shadow: 0 4px 14px rgb(0 0 0 / 18%);
height: 30px;
left: 7px;
/* Reserve a widget-owned top-right action region: the interactive pill may
never stretch across the card, or it re-intercepts the exact corner this
chrome exists to keep clickable. The 88px floor keeps the drag handle and
menu usable on very narrow widgets; below 184px the container query strips
the pill to that irreducible pair so the reservation stays maximal. */
max-width: max(88px, calc(100% - 96px));
opacity: 0;
padding: 0 5px;
position: absolute;
right: 0;
top: 0;
top: 7px;
transition:
opacity 120ms ease,
visibility 0s linear 120ms;
opacity 100ms ease,
visibility 0s linear 100ms;
visibility: hidden;
z-index: 5;
/* The overlay strip must not steal clicks from widget content underneath:
only real controls stay interactive, everything else passes through. */
pointer-events: none;
}
/* Capabilities chip stays interactive for its granted-permissions tooltip;
the kind badge and title are display-only and pass clicks through. */
.board-widget__bar > .board-widget__title {
max-width: 200px;
}
/* Every flexible child must shrink inside the capped pill; content that
overflowed the box would carry pointer events into the reserved corner. */
.board-widget__bar > .board-widget__drag-handle,
.board-widget__bar > .board-widget__capabilities,
.board-widget__bar > .board-widget__menu {
pointer-events: auto;
flex-shrink: 0;
}
.board-widget__bar > .board-widget__kind,
.board-widget__bar > .board-widget__capabilities {
min-width: 0;
overflow: hidden;
}
/* Below 184px the 96px reservation and the 88px pill cannot coexist, so the
pill sheds its display-only pieces and keeps just move + menu — the two
controls without which a widget cannot be managed. The card's aria-label
and the drag handle tooltip still carry the title. */
@container (max-width: 184px) {
.board-widget__bar > .board-widget__title,
.board-widget__bar > .board-widget__kind,
.board-widget__bar > .board-widget__capabilities {
display: none;
}
}
.board-widget__resize-handle {
opacity: 0;
transition:
opacity 120ms ease,
visibility 0s linear 120ms;
opacity 100ms ease,
visibility 0s linear 100ms;
visibility: hidden;
}
/* Hover-intent: the pointer must rest on the card briefly before chrome
appears, so quick clicks into widget content never race the overlay.
Delay-only overrides keep the base fade, and reduced-motion's
transition: none stays authoritative because no transition-property is
redeclared here. */
.board-widget:hover .board-widget__bar,
.board-widget:hover .board-widget__resize-handle,
.board-widget:hover .board-widget__resize-handle {
opacity: 1;
transition-delay: 350ms;
visibility: visible;
}
/* Keyboard focus, an open menu, and active drags need chrome immediately. */
.board-widget:focus-within .board-widget__bar,
.board-widget:focus-within .board-widget__resize-handle,
.board-widget--dragging .board-widget__bar,
@@ -793,9 +838,6 @@ openclaw-workboard-mini-widget {
.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;
}