From c627c6cea19f64a4492a27893c9b98bb37a39083 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:18:55 +0200 Subject: [PATCH 01/14] =?UTF-8?q?test:=20an=20ActionBar's=20=E2=8B=AF=20do?= =?UTF-8?q?es=20not=20repeat=20its=20button=20(#912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/ds/menu.test.tsx | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 ui/src/ds/menu.test.tsx diff --git a/ui/src/ds/menu.test.tsx b/ui/src/ds/menu.test.tsx new file mode 100644 index 00000000..7ae94ad4 --- /dev/null +++ b/ui/src/ds/menu.test.tsx @@ -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(); + + 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"); + 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"'); + }); +}); From 47ca644840e39f47b1e98c4f93feb9c7a52399cd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:27:08 +0200 Subject: [PATCH 02/14] =?UTF-8?q?fix(ui):=20a=20=E2=8B=AF=20beside=20a=20b?= =?UTF-8?q?utton=20no=20longer=20lists=20that=20button's=20action=20(#912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/console/actions.spec.ts | 6 ++---- e2e/console/inbox.spec.ts | 5 +++++ ui/src/ds/menu.tsx | 6 +++--- ui/src/screens/inbox/index.tsx | 5 ++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/e2e/console/actions.spec.ts b/e2e/console/actions.spec.ts index 9cfa7acc..982272c3 100644 --- a/e2e/console/actions.spec.ts +++ b/e2e/console/actions.spec.ts @@ -474,15 +474,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); diff --git a/e2e/console/inbox.spec.ts b/e2e/console/inbox.spec.ts index 2a22ec66..4452a9b9 100644 --- a/e2e/console/inbox.spec.ts +++ b/e2e/console/inbox.spec.ts @@ -344,6 +344,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`)}$`)); @@ -352,6 +356,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", diff --git a/ui/src/ds/menu.tsx b/ui/src/ds/menu.tsx index cf942830..fbcf4519 100644 --- a/ui/src/ds/menu.tsx +++ b/ui/src/ds/menu.tsx @@ -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[]; @@ -103,7 +103,7 @@ export function ActionBar(props: { {first.label} )} - + entry !== first)} /> ); } diff --git a/ui/src/screens/inbox/index.tsx b/ui/src/screens/inbox/index.tsx index 89a05b9a..b7d6e780 100644 --- a/ui/src/screens/inbox/index.tsx +++ b/ui/src/screens/inbox/index.tsx @@ -142,6 +142,7 @@ export function InboxBody(props: { function InboxActions(props: { readonly row: InboxRow; readonly actions: InboxRowActions }) { const { actions, row } = props; + const rest = actions.menu.filter((entry) => entry.key !== actions.fix?.key); return ( {actions.fix === undefined ? null : ( @@ -149,9 +150,7 @@ function InboxActions(props: { readonly row: InboxRow; readonly actions: InboxRo {actions.fix.label} )} - {actions.menu.length === 0 ? null : ( - - )} + ); } From b71b5a8a20e33f97c4d1055e115fd614885561c1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:28:46 +0200 Subject: [PATCH 03/14] test: the page reserves room for a scrollbar (#912) --- e2e/console/layout.spec.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 e2e/console/layout.spec.ts diff --git a/e2e/console/layout.spec.ts b/e2e/console/layout.spec.ts new file mode 100644 index 00000000..aad02d65 --- /dev/null +++ b/e2e/console/layout.spec.ts @@ -0,0 +1,35 @@ +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) { + await page.goto(new URL(path, daemon.url).toString()); + 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 { path, title } of pages) { + await openPage(page, fleet.gateway, path, title); + const gutter = await page.evaluate( + () => getComputedStyle(document.documentElement).scrollbarGutter, + ); + expect(gutter, path).toBe("stable"); + } + } finally { + await disposeFleet(fleet); + } + }); +}); From bc1a2daf013f6e1fcc2c8a44cf4998b9c1de5171 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:30:25 +0200 Subject: [PATCH 04/14] fix(ui): the page reserves room for a scrollbar (#912) --- ui/src/ds/foundation.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/src/ds/foundation.css b/ui/src/ds/foundation.css index f1d34bde..f912bbef 100644 --- a/ui/src/ds/foundation.css +++ b/ui/src/ds/foundation.css @@ -78,6 +78,10 @@ box-sizing: border-box; } +html { + scrollbar-gutter: stable; +} + html, body { margin: 0; From 5c1dd3a5de10608a0b277e5dd8c08dd54704c9fb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:35:01 +0200 Subject: [PATCH 05/14] fix(ui): the page reserves room for a scrollbar (#912) --- e2e/console/layout.spec.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/e2e/console/layout.spec.ts b/e2e/console/layout.spec.ts index aad02d65..66901870 100644 --- a/e2e/console/layout.spec.ts +++ b/e2e/console/layout.spec.ts @@ -4,9 +4,15 @@ import { disposeFleet, expect, type RunningDaemon, signIn, startFleet, test } fr const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; -async function openPage(page: Page, daemon: RunningDaemon, path: string, title: string) { +async function openPage( + page: Page, + daemon: RunningDaemon, + path: string, + title: string, + signedIn: boolean, +) { await page.goto(new URL(path, daemon.url).toString()); - await signIn(page, daemon.tokens.operator); + if (!signedIn) await signIn(page, daemon.tokens.operator); await expect(page.getByRole("heading", { level: 1, name: title })).toBeVisible(); } @@ -21,8 +27,8 @@ test.describe("the console's layout", () => { { path: "/fleet", title: "Fleet" }, { path: `/fleet/${id}`, title: "layout-a" }, ]; - for (const { path, title } of pages) { - await openPage(page, fleet.gateway, path, title); + 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, ); From 861caae4b3bb538c8fc7a5b7a859d3eab1d12997 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:37:02 +0200 Subject: [PATCH 06/14] test: a worker page has no breadcrumb and a line between the warm pool's facts and table (#912) --- e2e/console/layout.spec.ts | 28 ++++++++++++++++++++++++++++ e2e/console/long-lists.spec.ts | 2 +- e2e/console/workers.spec.ts | 9 +++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/e2e/console/layout.spec.ts b/e2e/console/layout.spec.ts index 66901870..8e0f1d3b 100644 --- a/e2e/console/layout.spec.ts +++ b/e2e/console/layout.spec.ts @@ -38,4 +38,32 @@ test.describe("the console's layout", () => { 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); + } + }); }); diff --git a/e2e/console/long-lists.spec.ts b/e2e/console/long-lists.spec.ts index 56f00abe..bc2dea06 100644 --- a/e2e/console/long-lists.spec.ts +++ b/e2e/console/long-lists.spec.ts @@ -418,7 +418,7 @@ 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(); await expect(pager(page, "Workers").locator(".pager-range")).toHaveText("26–30 of 30"); }); diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index d6e1dc02..ea9d55fd 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -930,7 +930,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); @@ -1130,7 +1130,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(); @@ -1287,10 +1287,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$/); }); }); From cbdea2fc90e0acf02a389a56b77946754d6fc300 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:40:52 +0200 Subject: [PATCH 07/14] feat(ui): a worker page drops its breadcrumb and the warm pool tab gets a line between facts and table (#912) --- e2e/console/long-lists.spec.ts | 5 ++++- ui/src/ds/master-detail.css | 22 ---------------------- ui/src/screens/fleet/fleet.css | 5 +++++ ui/src/screens/fleet/worker-page.tsx | 5 ----- 4 files changed, 9 insertions(+), 28 deletions(-) diff --git a/e2e/console/long-lists.spec.ts b/e2e/console/long-lists.spec.ts index bc2dea06..dbd4c998 100644 --- a/e2e/console/long-lists.spec.ts +++ b/e2e/console/long-lists.spec.ts @@ -418,7 +418,10 @@ test.describe("long lists page", () => { await page.reload(); await expect(pager(page, "Devices").locator(".pager-range")).toHaveText("51–60 of 60"); - await page.goBack(); + 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"); }); diff --git a/ui/src/ds/master-detail.css b/ui/src/ds/master-detail.css index 0a2bbba1..8a7a32d6 100644 --- a/ui/src/ds/master-detail.css +++ b/ui/src/ds/master-detail.css @@ -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; -} diff --git a/ui/src/screens/fleet/fleet.css b/ui/src/screens/fleet/fleet.css index 4a8fffd2..2db02ceb 100644 --- a/ui/src/screens/fleet/fleet.css +++ b/ui/src/screens/fleet/fleet.css @@ -165,3 +165,8 @@ .worker-events li > :last-child { grid-column: 2; } + +.panel .facts + .table-card { + margin-top: var(--space-4); + border-top: 1px solid var(--border); +} diff --git a/ui/src/screens/fleet/worker-page.tsx b/ui/src/screens/fleet/worker-page.tsx index 702cbc9d..d9db8ad2 100644 --- a/ui/src/screens/fleet/worker-page.tsx +++ b/ui/src/screens/fleet/worker-page.tsx @@ -70,11 +70,6 @@ export function WorkerPageBody({ page }: { readonly page: WorkerPageView }) { {...(page.version === undefined ? {} : { note: page.version })} actions={} > -

{page.health.word} {page.system === undefined ? null : <> · {page.system}} From 54662d91f249089a33dc3a371cb5d62dc858bc76 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:42:23 +0200 Subject: [PATCH 08/14] =?UTF-8?q?test:=20a=20fleet=20row's=20=E2=8B=AF=20l?= =?UTF-8?q?ies=20inside=20its=20cell=20and=20an=20Inbox=20row's=20button?= =?UTF-8?q?=20has=20room=20above=20and=20below=20(#912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/console/layout.spec.ts | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/e2e/console/layout.spec.ts b/e2e/console/layout.spec.ts index 8e0f1d3b..43ae47ac 100644 --- a/e2e/console/layout.spec.ts +++ b/e2e/console/layout.spec.ts @@ -66,4 +66,43 @@ test.describe("the console's layout", () => { 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); + } + }); }); From a244340a095f5866b19c6ccb579ab5805808443d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:43:52 +0200 Subject: [PATCH 09/14] test: a toast's close button sits beside its text (#912) --- e2e/console/layout.spec.ts | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/e2e/console/layout.spec.ts b/e2e/console/layout.spec.ts index 43ae47ac..c5d492d5 100644 --- a/e2e/console/layout.spec.ts +++ b/e2e/console/layout.spec.ts @@ -105,4 +105,54 @@ test.describe("the console's layout", () => { 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 text = toast.querySelector(".toast-title")?.getBoundingClientRect(); + if (close === undefined || text === undefined) throw new Error("toast has no parts"); + 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 undrained", { exact: true })).toBeVisible(); + const undrained = await measure("layout-a undrained"); + expect(undrained.hasAction).toBe(false); + expect(undrained.closeInside).toBe(true); + expect(undrained.overlap).toBe(false); + expect(undrained.centres).toBeLessThanOrEqual(4); + } finally { + await disposeFleet(fleet); + } + }); }); From 60c3336b39ab0ebbe6678c41ddcc2c486c732db3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:46:01 +0200 Subject: [PATCH 10/14] fix(ui): a toast's close button sits beside its text, inside the toast (#912) --- e2e/console/layout.spec.ts | 4 ++-- ui/src/ds/master-detail.css | 2 +- ui/src/ds/table.css | 2 +- ui/src/ds/toast.css | 19 +++++++++++++------ ui/src/screens/inbox/inbox.css | 2 +- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/e2e/console/layout.spec.ts b/e2e/console/layout.spec.ts index c5d492d5..ba28bd2a 100644 --- a/e2e/console/layout.spec.ts +++ b/e2e/console/layout.spec.ts @@ -145,8 +145,8 @@ test.describe("the console's layout", () => { expect(drained.overlap).toBe(false); await page.getByRole("button", { name: "Undrain", exact: true }).click(); - await expect(page.getByText("layout-a undrained", { exact: true })).toBeVisible(); - const undrained = await measure("layout-a undrained"); + 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); diff --git a/ui/src/ds/master-detail.css b/ui/src/ds/master-detail.css index 8a7a32d6..550745b4 100644 --- a/ui/src/ds/master-detail.css +++ b/ui/src/ds/master-detail.css @@ -1,4 +1,4 @@ -/* A list of rows, and the breadcrumb a worker's page carries. */ +/* A list of rows. */ .list { margin: 0; diff --git a/ui/src/ds/table.css b/ui/src/ds/table.css index 4f529408..84b8db1a 100644 --- a/ui/src/ds/table.css +++ b/ui/src/ds/table.css @@ -212,7 +212,7 @@ } .table .table-menu { - width: 3.5rem; + width: 5rem; text-align: right; } diff --git a/ui/src/ds/toast.css b/ui/src/ds/toast.css index 443281f7..0a964d7f 100644 --- a/ui/src/ds/toast.css +++ b/ui/src/ds/toast.css @@ -34,11 +34,17 @@ .toast-content { display: grid; - gap: var(--space-1); - padding: var(--space-3) var(--space-4); + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-1) var(--space-2); + padding: var(--space-1) var(--space-1) var(--space-3) var(--space-4); +} + +.toast-content > :not(.toast-close) { + grid-column: 1; } .toast-title { + align-self: center; margin: 0; font-size: var(--text-sm); font-weight: 600; @@ -69,8 +75,9 @@ display: none; } -.toast-close { - position: absolute; - top: var(--space-2); - right: var(--space-2); +/* why: the close keeps its 40px hit area but not its border, so it reads as part of the toast. */ +.toast .toast-close { + grid-column: 2; + grid-row: 1; + border-color: transparent; } diff --git a/ui/src/screens/inbox/inbox.css b/ui/src/screens/inbox/inbox.css index 67ff3279..56a2f2e3 100644 --- a/ui/src/screens/inbox/inbox.css +++ b/ui/src/screens/inbox/inbox.css @@ -97,7 +97,7 @@ display: flex; align-items: center; gap: var(--space-2); - padding-right: var(--space-4); + padding: var(--space-2) var(--space-4) var(--space-2) 0; } .inbox-row { From 86d28fcf05d905a64d6826a94068d1be7163c64a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:46:12 +0200 Subject: [PATCH 11/14] =?UTF-8?q?docs(design):=20a=20=E2=8B=AF=20does=20no?= =?UTF-8?q?t=20repeat=20its=20button;=20a=20toast's=20close=20sits=20besid?= =?UTF-8?q?e=20the=20text=20(#912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/DESIGN.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/internal/DESIGN.md b/docs/internal/DESIGN.md index 1ca6ddf3..2f7b0d8c 100644 --- a/docs/internal/DESIGN.md +++ b/docs/internal/DESIGN.md @@ -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 @@ -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. From fee284623409d648dbc2583fd19896408f896ae1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:13:58 +0200 Subject: [PATCH 12/14] test: the 360px drawer fills the page width the scrollbar gutter leaves (#912) --- e2e/console/leases.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/console/leases.spec.ts b/e2e/console/leases.spec.ts index caf8e192..781a5799 100644 --- a/e2e/console/leases.spec.ts +++ b/e2e/console/leases.spec.ts @@ -359,7 +359,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(); From 6d026d8eabfffcc00c6a456f61fb7a9253e0f147 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:27:15 +0200 Subject: [PATCH 13/14] test: the toast centring check measures the title's text, not its stretched box (#912) --- e2e/console/layout.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/e2e/console/layout.spec.ts b/e2e/console/layout.spec.ts index ba28bd2a..a7528757 100644 --- a/e2e/console/layout.spec.ts +++ b/e2e/console/layout.spec.ts @@ -122,8 +122,12 @@ test.describe("the console's layout", () => { .evaluate((toast) => { const box = toast.getBoundingClientRect(); const close = toast.querySelector(".toast-close")?.getBoundingClientRect(); - const text = toast.querySelector(".toast-title")?.getBoundingClientRect(); - if (close === undefined || text === undefined) throw new Error("toast has no parts"); + const title = toast.querySelector(".toast-title"); + if (close === undefined || title === null) throw new Error("toast has no parts"); + // why: the title's box is stretched to the close button's row, so only a range over its text shows where the text sits. + const range = document.createRange(); + range.selectNodeContents(title); + const text = range.getBoundingClientRect(); return { closeInside: close.left >= box.left && From 380226861131b8a701ab81ee3ed88dfb0f23075e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:33:00 +0200 Subject: [PATCH 14/14] test: drop the false stretched-box comment in the toast centring check (#912) --- e2e/console/layout.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e/console/layout.spec.ts b/e2e/console/layout.spec.ts index a7528757..72051d42 100644 --- a/e2e/console/layout.spec.ts +++ b/e2e/console/layout.spec.ts @@ -124,7 +124,6 @@ test.describe("the console's layout", () => { 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"); - // why: the title's box is stretched to the close button's row, so only a range over its text shows where the text sits. const range = document.createRange(); range.selectNodeContents(title); const text = range.getBoundingClientRect();