From fa6797e12b38c349fa736288ea581e5674cdcd42 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 9 Oct 2026 13:39:15 +0200 Subject: [PATCH] feat: make the Accounts page readable on phones with container-sized mailbox columns, a labelled warmup, inbox and health line under each address, touch-sized row actions, one-row search and filter, banners that stack their buttons under the copy, and tighter mobile stat tiles --- qa/flows/accounts.flow.ts | 27 ++ web/src/app/app/emails/page.tsx | 319 ++++++++++-------- .../components/app/emails/CloudPathsPanel.tsx | 4 +- .../components/app/emails/CloudPoolBanner.tsx | 64 ++-- .../app/emails/WarmupCoverageNotice.tsx | 3 +- web/src/components/layout/Page.tsx | 10 +- 6 files changed, 249 insertions(+), 178 deletions(-) create mode 100644 qa/flows/accounts.flow.ts diff --git a/qa/flows/accounts.flow.ts b/qa/flows/accounts.flow.ts new file mode 100644 index 000000000..62c05731a --- /dev/null +++ b/qa/flows/accounts.flow.ts @@ -0,0 +1,27 @@ +import { expect, test } from "../lib/proof.ts"; + +test("the accounts list reads on a phone as well as on a desktop", async ({ page, proof }) => { + await page.goto("/app/emails"); + const row = page.getByRole("row", { name: /dev\.outbound@warmbly\.test/ }); + await expect(row).toBeVisible(); + await proof.chapter("Accounts", "The same list on a desktop, then on a phone"); + await proof.shot("accounts-desktop", { caption: "Desktop: every metric has its own column" }); + + // The list's columns follow its own width, so the phone layout is a viewport change away. + await page.setViewportSize({ width: 390, height: 844 }); + await expect(row.getByText("dev.outbound@warmbly.test")).toBeVisible(); + await expect(row.getByText("Health", { exact: true })).toBeVisible(); + await proof.dwell(); + await proof.shot("accounts-phone", { caption: "Phone: address, sender and connection, then labelled warmup, inbox and health readings" }); + + await page.getByRole("textbox", { name: /Search by email/ }).pressSequentially("outbound", { delay: 60 }); + await expect(page.getByRole("row", { name: /dev\.send@warmbly\.test/ })).toBeHidden(); + await expect(row).toBeVisible(); + await proof.shot("accounts-phone-search", { caption: "Search and the tag filter share one row" }); + + // From the keyboard: on a phone-sized page the dev-only query devtools sit over the last row's actions. + await row.getByRole("button", { name: "Mailbox actions" }).focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("menuitem", { name: /Disconnect mailbox/ })).toBeVisible(); + await proof.shot("accounts-phone-actions", { caption: "Row actions stay reachable at touch size" }); +}); diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 93262e8d6..28840be89 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -401,7 +401,7 @@ export default function AddressesPage() { > }> - Sending domains + Sending domains p?.setAddEmail(true)} @@ -423,7 +423,7 @@ export default function AddressesPage() { value={query} onChange={setQuery} placeholder="Search by email…" - className="w-full sm:w-56" + className="flex-1 sm:flex-none sm:w-56" /> @@ -511,58 +511,61 @@ export default function AddressesPage() { ) : ( // table-fixed like the Leads list: every column but Mailbox // carries a width, so one long address never widens the table. - - - -
- { - if (isSelectedAll()) { - setSelected((bef) => - bef.filter((e) => !emailsData.emails.map((em) => em.id).includes(e)), - ); - } else { - setSelected((bef) => [ - ...bef, - ...emailsData.emails - .filter((em) => !selected.includes(em.id)) - .map((em) => em.id), - ]); - } - }} + // Columns key off the list's own width, not the viewport, since the sidebar takes a share of it. +
+ + + + + {MAILBOX_COLUMNS.map((col) => ( + + ))} + + + + + {sortedEmails.map((box) => ( + openMigration(retiringDomain.get(box.id) ?? null)} + checked={selected.includes(box.id)} + onToggleSelect={() => + selected.includes(box.id) + ? setSelected((bef) => bef.filter((i) => i !== box.id)) + : setSelected((bef) => [...bef, box.id]) + } + onOpen={openDetail} /> - - {MAILBOX_COLUMNS.map((col) => ( - ))} - - - - - {sortedEmails.map((box) => ( - openMigration(retiringDomain.get(box.id) ?? null)} - checked={selected.includes(box.id)} - onToggleSelect={() => - selected.includes(box.id) - ? setSelected((bef) => bef.filter((i) => i !== box.id)) - : setSelected((bef) => [...bef, box.id]) - } - onOpen={openDetail} - /> - ))} - -
+ { + if (isSelectedAll()) { + setSelected((bef) => + bef.filter((e) => !emailsData.emails.map((em) => em.id).includes(e)), + ); + } else { + setSelected((bef) => [ + ...bef, + ...emailsData.emails + .filter((em) => !selected.includes(em.id)) + .map((em) => em.id), + ]); + } + }} + /> +
+ +
+ )} {emailsData.isLoadingRest && !emailsData.isPending && (
@@ -866,6 +869,68 @@ function MailboxRow({ const upsell = () => toast("Warmup is available on paid plans", { icon: "✨" }); + const warmupCell = + inCloud && cloud?.cloud ? ( + + {sendFailure ? ( + + ) : cloudPaused ? ( + + ) : ( + + )} + + + /{cloud.cloud.warmup?.target_volume ?? cloud.cloud.settings.base} + + + ) : inCloud ? ( + + + {warmupLabel} + + ) : warming ? ( + + {sendFailure ? : } + + + /{ws?.target_volume ?? box.warmup_base} + + + ) : ( + + {active ? ( + + ) : paused ? ( + + ) : inCampaign ? ( + + ) : ( + + )} + {warmupLabel} + + ); + return ( onOpen(box.id)} @@ -874,7 +939,7 @@ function MailboxRow({ e.stopPropagation()}> - + {/* The flag is a sibling of the open-row button, not a child: it has its own trigger and nesting buttons is invalid. */}
@@ -896,7 +961,7 @@ function MailboxRow({ {inCloud && ( Cloud @@ -904,7 +969,7 @@ function MailboxRow({ {shownTags.map((t) => ( @@ -912,14 +977,14 @@ function MailboxRow({ ))} {rowTags.length > shownTags.length && ( - + +{rowTags.length - shownTags.length} )}
{/* Who it sends as, and how it connects: the vendor or grant chip, else the host. */} -
- {box.name && {box.name}} +
+ {box.name && {box.name}} {box.name && ·} {source.kind !== "host" ? ( @@ -929,26 +994,47 @@ function MailboxRow({ {inCloud && ( · - {cloudPaused ? "Cloud warmup paused" : "Warmed by Warmbly Cloud"} + + {cloudPaused ? "Cloud paused" : "Cloud"} + {cloudPaused ? "Cloud warmup paused" : "Warmed by Warmbly Cloud"} )} {inCampaign && ( - + · In campaign )}
+ {/* Too narrow for the metric columns: the same readings, labelled, under the address. */} +
+ + + Warmup + {warmupCell} + + + Inbox + + + + Health + + + {status?.health?.score ?? "—"} + + +
{retiring && }
- + - + {status?.daily_usage ? ( 0 ? "text-sky-700" : "text-slate-500"}`} @@ -964,80 +1050,21 @@ function MailboxRow({ )} - - {inCloud && cloud?.cloud ? ( - - {sendFailure ? ( - - ) : cloudPaused ? ( - - ) : ( - - )} - - - /{cloud.cloud.warmup?.target_volume ?? cloud.cloud.settings.base} - - - ) : inCloud ? ( - - - {warmupLabel} - - ) : warming ? ( - - {sendFailure ? : } - - - /{ws?.target_volume ?? box.warmup_base} - - - ) : ( - - {active ? ( - - ) : paused ? ( - - ) : inCampaign ? ( - - ) : ( - - )} - {warmupLabel} - - )} + + {warmupCell} - + - + e.stopPropagation()}> @@ -1056,7 +1083,7 @@ function MailboxRow({ type="button" aria-label="Warmup actions" disabled={life.isPending} - className="w-6 h-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-orange-600 transition-colors cursor-pointer disabled:opacity-50" + className="size-7 @2xl:size-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-orange-600 transition-colors cursor-pointer disabled:opacity-50" > {inCloud ? : } @@ -1142,7 +1169,7 @@ function MailboxRow({ + +