Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c627c6c
test: an ActionBar's ⋯ does not repeat its button (#912)
V3RON Oct 23, 2026
47ca644
fix(ui): a ⋯ beside a button no longer lists that button's action (#912)
V3RON Oct 23, 2026
b71b5a8
test: the page reserves room for a scrollbar (#912)
V3RON Oct 23, 2026
bc1a2da
fix(ui): the page reserves room for a scrollbar (#912)
V3RON Oct 23, 2026
5c1dd3a
fix(ui): the page reserves room for a scrollbar (#912)
V3RON Oct 23, 2026
861caae
test: a worker page has no breadcrumb and a line between the warm poo…
V3RON Oct 23, 2026
cbdea2f
feat(ui): a worker page drops its breadcrumb and the warm pool tab ge…
V3RON Oct 23, 2026
54662d9
test: a fleet row's ⋯ lies inside its cell and an Inbox row's button …
V3RON Oct 23, 2026
a244340
test: a toast's close button sits beside its text (#912)
V3RON Oct 23, 2026
60c3336
fix(ui): a toast's close button sits beside its text, inside the toas…
V3RON Oct 23, 2026
86d28fc
docs(design): a ⋯ does not repeat its button; a toast's close sits be…
V3RON Oct 23, 2026
fee2846
test: the 360px drawer fills the page width the scrollbar gutter leav…
V3RON Oct 23, 2026
6d026d8
test: the toast centring check measures the title's text, not its str…
V3RON Oct 23, 2026
3802268
test: drop the false stretched-box comment in the toast centring chec…
V3RON Oct 23, 2026
be8e8d3
Merge remote-tracking branch 'origin/task/909' into HEAD
V3RON Oct 23, 2026
98b1b94
Merge remote-tracking branch 'origin/task/909' into HEAD
V3RON Oct 23, 2026
c3a9c84
Merge remote-tracking branch 'origin/task/909' into HEAD
V3RON Oct 23, 2026
fca8916
Merge remote-tracking branch 'origin/task/909' into HEAD
V3RON Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/internal/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ put together from them and draws nothing of its own that one of them already dra
a device's shows the device's state as a status, word and square.
- **Menu.** A table row ends in a **⋯** `IconButton` that opens a small list; a right-click on the
row opens the same list, and a drawer or page header shows its item's first safe action as a
secondary button beside its own **⋯**. An entry is 36px tall with `--text`; the highlighted one
secondary button beside its own **⋯**, and that list does not repeat the button's action. An entry is 36px tall with `--text`; the highlighted one
takes `--surface` and the focus ring inside its edge. Dangerous entries come last, after a 1px
`--border` line, in `--status-error`, never `--accent`. A disabled entry is `--text-muted`. A
list with no entry draws no **⋯**. Arrow keys move, Escape closes. It floats above drawers and
Expand All @@ -178,7 +178,8 @@ put together from them and draws nothing of its own that one of them already dra
its progress ("Releasing…"). A dialog that asks for a typed name keeps the action disabled until
the text equals it.
- **Toast.** At the bottom right, newest at the bottom, at most three (older ones wait hidden), each
a title, an optional line and an optional action button, and a **Close** button. A success toast
a title, an optional line and an optional action button, and a **Close** button beside the text,
never over it. A success toast
leaves after 6 seconds, paused while hovered or focused. An error toast has a `--status-error`
edge and title, stays until closed and is announced at once (`role="alert"`); the rest are
announced politely. A toast moves in once, when it appears.
Expand Down
6 changes: 2 additions & 4 deletions e2e/console/actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,15 +478,13 @@ test.describe("the console acts", () => {
await page.getByRole("button", { name: "Drain", exact: true }).click();
await expect(page.getByText("bar-a drained", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Undrain", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Actions for bar-a" }).click();
await expect(page.getByRole("menuitem", { name: "Undrain" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Actions for bar-a" })).toHaveCount(0);

const [stopped] = fleet.workers as [RunningDaemon];
await stopped.stop();
await waitForDisconnected(fleet.gateway);
await page.getByRole("button", { name: "Actions for bar-a" }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Undrain", "Remove worker"]);
await expect(page.getByRole("menuitem")).toHaveText(["Remove worker"]);
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Undrain", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Remove worker" })).toHaveCount(0);
Expand Down
5 changes: 5 additions & 0 deletions e2e/console/inbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,10 @@ test.describe("the Inbox view", () => {
expect((await fleet.gateway.cli(["worker", "drain", a?.id ?? ""])).code).toBe(0);
await openInbox(page, fleet.gateway);

const row = page.getByRole("region", { name: "Needs a look" });
await expect(row.getByRole("button", { name: "Undrain", exact: true })).toBeVisible();
await expect(row.getByRole("button", { name: /^Actions for/ })).toHaveCount(0);

await item(page, "drained", "worker-a").click();

await expect(page).toHaveURL(new RegExp(`/inbox/${encodeURIComponent(`${a?.id}/drained`)}$`));
Expand All @@ -286,6 +290,7 @@ test.describe("the Inbox view", () => {
await expect(drawer.getByRole("region", { name: "What happened" })).toContainText(
"worker-a is drained",
);
await expect(drawer.getByRole("button", { name: "Undrain", exact: true })).toBeVisible();
await expect(facts(page)).toContainText(/Leases still held\s*0/);
await expect(drawer.getByRole("link", { name: "Open worker" })).toHaveAttribute(
"href",
Expand Down
161 changes: 161 additions & 0 deletions e2e/console/layout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { Page } from "@playwright/test";

import { disposeFleet, expect, type RunningDaemon, signIn, startFleet, test } from "./fixtures.js";

const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] };

async function openPage(
page: Page,
daemon: RunningDaemon,
path: string,
title: string,
signedIn: boolean,
) {
await page.goto(new URL(path, daemon.url).toString());
if (!signedIn) await signIn(page, daemon.tokens.operator);
await expect(page.getByRole("heading", { level: 1, name: title })).toBeVisible();
}

test.describe("the console's layout", () => {
test("the page reserves room for a scrollbar on every page", async ({ page }) => {
const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "layout-a" }]);
try {
const listed = await fleet.gateway.cli(["worker", "list", "--json"]);
const id = (listed.json as { workers: { id: string }[] }).workers[0]?.id ?? "";
const pages = [
{ path: "/", title: "Overview" },
{ path: "/fleet", title: "Fleet" },
{ path: `/fleet/${id}`, title: "layout-a" },
];
for (const [index, { path, title }] of pages.entries()) {
await openPage(page, fleet.gateway, path, title, index > 0);
const gutter = await page.evaluate(
() => getComputedStyle(document.documentElement).scrollbarGutter,
);
expect(gutter, path).toBe("stable");
}
} finally {
await disposeFleet(fleet);
}
});

test("a worker page has no breadcrumb, and its Warm pool tab draws a 1px --border line between the facts and the table", async ({
page,
}) => {
const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "layout-a" }]);
try {
const listed = await fleet.gateway.cli(["worker", "list", "--json"]);
const id = (listed.json as { workers: { id: string }[] }).workers[0]?.id ?? "";
await openPage(page, fleet.gateway, `/fleet/${id}?tab=warm-pool`, "layout-a", false);

await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toHaveCount(0);
const panel = page.getByRole("region", { name: "Warm pool", exact: true });
await expect(panel.locator(".facts")).toBeVisible();
const line = await panel.locator(".table-card").evaluate((card) => {
const style = getComputedStyle(card);
const probe = document.createElement("span");
probe.style.color = "var(--border)";
document.body.append(probe);
const border = getComputedStyle(probe).color;
probe.remove();
return { border, color: style.borderTopColor, width: style.borderTopWidth };
});
expect(line.width).toBe("1px");
expect(line.color).toBe(line.border);
} finally {
await disposeFleet(fleet);
}
});

test("a fleet row's ⋯ lies inside its cell with 24px to the table's right edge, and an Inbox row's button has 8px above and below", async ({
page,
}) => {
const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "layout-a" }]);
try {
const listed = await fleet.gateway.cli(["worker", "list", "--json"]);
const id = (listed.json as { workers: { id: string }[] }).workers[0]?.id ?? "";
await openPage(page, fleet.gateway, "/fleet", "Fleet", false);
const menu = page.locator("main tbody tr").getByRole("button", { name: /^Actions for/ });
const box = await menu.evaluate((button) => {
const cell = button.closest("td")?.getBoundingClientRect();
const table = button.closest("table")?.getBoundingClientRect();
const own = button.getBoundingClientRect();
return {
cellLeft: cell?.left ?? 0,
cellRight: cell?.right ?? 0,
left: own.left,
right: own.right,
tableRight: table?.right ?? 0,
};
});
expect(box.left).toBeGreaterThanOrEqual(box.cellLeft);
expect(box.right).toBeLessThanOrEqual(box.cellRight);
expect(box.tableRight - box.right).toBe(24);

expect((await fleet.gateway.cli(["worker", "drain", id])).code).toBe(0);
await openPage(page, fleet.gateway, "/inbox", "Inbox", true);
const undrain = page.getByRole("button", { name: "Undrain", exact: true });
const gaps = await undrain.evaluate((button) => {
const row = button.closest("li")?.getBoundingClientRect();
const own = button.getBoundingClientRect();
return { bottom: (row?.bottom ?? 0) - own.bottom, top: own.top - (row?.top ?? 0) };
});
expect(gaps).toEqual({ bottom: 8, top: 8 });
} finally {
await disposeFleet(fleet);
}
});

test("a toast's close button lies beside its text, inside the toast, and a one-line toast centres both", async ({
page,
}) => {
const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "layout-a" }]);
try {
const listed = await fleet.gateway.cli(["worker", "list", "--json"]);
const id = (listed.json as { workers: { id: string }[] }).workers[0]?.id ?? "";
await openPage(page, fleet.gateway, `/fleet/${id}`, "layout-a", false);

const measure = (title: string) =>
page
.locator(".toast")
.filter({ has: page.getByText(title, { exact: true }) })
.evaluate((toast) => {
const box = toast.getBoundingClientRect();
const close = toast.querySelector(".toast-close")?.getBoundingClientRect();
const title = toast.querySelector(".toast-title");
if (close === undefined || title === null) throw new Error("toast has no parts");
const range = document.createRange();
range.selectNodeContents(title);
const text = range.getBoundingClientRect();
return {
closeInside:
close.left >= box.left &&
close.right <= box.right &&
close.top >= box.top &&
close.bottom <= box.bottom,
centres: Math.abs(close.top + close.height / 2 - (text.top + text.height / 2)),
overlap:
text.right > close.left && text.left < close.right && text.bottom > close.top,
hasAction: toast.querySelector(".toast-action") !== null,
};
});

await page.getByRole("button", { name: "Drain", exact: true }).click();
await expect(page.getByText("layout-a drained", { exact: true })).toBeVisible();
const drained = await measure("layout-a drained");
expect(drained.hasAction).toBe(true);
expect(drained.closeInside).toBe(true);
expect(drained.overlap).toBe(false);

await page.getByRole("button", { name: "Undrain", exact: true }).click();
await expect(page.getByText("layout-a takes leases again", { exact: true })).toBeVisible();
const undrained = await measure("layout-a takes leases again");
expect(undrained.hasAction).toBe(false);
expect(undrained.closeInside).toBe(true);
expect(undrained.overlap).toBe(false);
expect(undrained.centres).toBeLessThanOrEqual(4);
} finally {
await disposeFleet(fleet);
}
});
});
6 changes: 5 additions & 1 deletion e2e/console/leases.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,11 @@ test.describe("the leases page", () => {

const drawer = page.getByRole("dialog");
await expect(drawer).toBeVisible();
expect((await drawer.boundingBox())?.width).toBeCloseTo(360, 0);
// why: the page reserves a scrollbar gutter, so the page is narrower than the 360px viewport.
const available = await page.evaluate(
() => document.documentElement.getBoundingClientRect().width,
);
expect((await drawer.boundingBox())?.width).toBeCloseTo(available, 0);
expect(await hasHorizontalScroll(page)).toBe(false);
} finally {
await host.dispose();
Expand Down
5 changes: 4 additions & 1 deletion e2e/console/long-lists.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,10 @@ test.describe("long lists page", () => {

await page.reload();
await expect(pager(page, "Devices").locator(".pager-range")).toHaveText("51–60 of 60");
await page.getByRole("navigation", { name: "Breadcrumb" }).getByRole("link").click();
await page.goBack({ waitUntil: "commit" });
await page.goBack({ waitUntil: "commit" });
await page.goBack({ waitUntil: "commit" });
await expect(page).toHaveURL(/\/fleet\?page=2$/);
await expect(pager(page, "Workers").locator(".pager-range")).toHaveText("26–30 of 30");
});

Expand Down
9 changes: 3 additions & 6 deletions e2e/console/workers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ test.describe("the workers page", () => {
await expect(figure(page, "Leases").locator(".figure-count")).toHaveText("1", {
timeout: 10_000,
});
await page.getByRole("navigation", { name: "Breadcrumb" }).getByRole("link").click();
await page.goBack();
await expect(running()).toHaveText(/^1 \/ \d+$/, { timeout: 10_000 });
} finally {
await disposeFleet(fleet);
Expand Down Expand Up @@ -1132,7 +1132,7 @@ test.describe("the workers page", () => {
timeout: 10_000,
});

await page.getByRole("navigation", { name: "Breadcrumb" }).getByRole("link").click();
await page.goBack();
await expect(row(page, "worker-b").getByRole("cell").nth(1)).toHaveText("disconnected");
await expect(row(page, "worker-a").getByRole("cell").nth(1)).not.toHaveText("disconnected");
await expect(page.getByText("1 / 2 up")).toBeVisible();
Expand Down Expand Up @@ -1289,10 +1289,7 @@ test.describe("the workers page", () => {
await page.keyboard.press("Enter");
await expect(host_tab).toHaveAttribute("aria-selected", "true");

await follow(
page.getByRole("navigation", { name: "Breadcrumb" }).getByRole("link", { name: "Fleet" }),
"backward",
);
await follow(page.getByRole("link", { name: "Fleet", exact: true }), "backward");
await expect(page).toHaveURL(/\/fleet$/);
});
});
4 changes: 4 additions & 0 deletions ui/src/ds/foundation.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@
box-sizing: border-box;
}

html {
scrollbar-gutter: stable;
}

html,
body {
margin: 0;
Expand Down
24 changes: 1 addition & 23 deletions ui/src/ds/master-detail.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* A list of rows, and the breadcrumb a worker's page carries. */
/* A list of rows. */

.list {
margin: 0;
Expand Down Expand Up @@ -57,25 +57,3 @@
font-size: var(--text-xs);
text-overflow: ellipsis;
}

.crumbs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--text-xs);
}

.crumb + .crumb::before {
content: "/";
margin-right: var(--space-2);
}

.crumb {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
31 changes: 31 additions & 0 deletions ui/src/ds/menu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { ActionBar, type MenuEntry } from "./menu";

const entry = (label: string, danger: boolean): MenuEntry => ({
danger,
disabled: false,
key: label,
label,
onSelect: () => {},
});

describe("the action bar", () => {
it("draws no ⋯ when its only safe entry is the button, and keeps the ⋯ for any other entry", () => {
const draw = (entries: readonly MenuEntry[]) =>
renderToStaticMarkup(<ActionBar label="Actions for bar-a" entries={entries} />);

const onlyButton = draw([entry("Undrain", false)]);
expect(onlyButton).toContain("Undrain");
expect(onlyButton).not.toContain('aria-label="Actions for bar-a"');

const withDanger = draw([entry("Undrain", false), entry("Remove worker", true)]);
expect(withDanger).toContain("Undrain</button>");
expect(withDanger).toContain('aria-label="Actions for bar-a"');

const dangerOnly = draw([entry("Release lease", true)]);
expect(dangerOnly).not.toContain("button-secondary");
expect(dangerOnly).toContain('aria-label="Actions for bar-a"');
});
});
6 changes: 3 additions & 3 deletions ui/src/ds/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ export function RowMenu(props: { readonly label: string; readonly entries: reado
);
}

// why: a drawer or a page shows its item's first safe action as a button and every action under
// its own ⋯, the dangerous ones last.
// why: a drawer or a page shows its item's first safe action as a button and the other actions
// under its own ⋯, the dangerous ones last; the ⋯ does not repeat the button.
export function ActionBar(props: {
readonly label: string;
readonly entries: readonly MenuEntry[];
Expand All @@ -103,7 +103,7 @@ export function ActionBar(props: {
{first.label}
</Button>
)}
<RowMenu label={label} entries={entries} />
<RowMenu label={label} entries={entries.filter((entry) => entry !== first)} />
</span>
);
}
2 changes: 1 addition & 1 deletion ui/src/ds/table.css
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
}

.table .table-menu {
width: 3.5rem;
width: 5rem;
text-align: right;
}

Expand Down
Loading
Loading