diff --git a/src/app/smoke-tests/SmokeHarness.tsx b/src/app/smoke-tests/SmokeHarness.tsx index 9cb5fdc..af0de5f 100644 --- a/src/app/smoke-tests/SmokeHarness.tsx +++ b/src/app/smoke-tests/SmokeHarness.tsx @@ -33,7 +33,8 @@ export type SmokePanel = | "tab-indicator" | "message-actions" | "mobile-viewport" - | "reader-privacy"; + | "reader-privacy" + | "row-overlays"; const fixtureEmails: Email[] = [ { @@ -80,6 +81,12 @@ const fixtureEmails: Email[] = [ }, ]; +const overlayFixtureEmails = fixtureEmails.map((email) => + email.id === "email-maya" + ? { ...email, keywords: { ...email.keywords, $flagged: true } } + : email, +); + function makePanelData( emails: Email[], emailState: string, @@ -100,7 +107,7 @@ function makePanelData( readTotal: reads.length, }, drafts: { emails: [], total: 0 }, - pinned: [], + pinned: emails.filter((email) => email.keywords.$flagged), sent: { emails: [], total: 0 }, spam: emptySplit, archive: emptySplit, @@ -360,6 +367,25 @@ export default function SmokeHarness({ panel }: { panel: SmokePanel }) { /> )} + {panel === "row-overlays" && ( +
+ +
+ )} + {panel === "mailbox-move" && } {panel === "auto-sync" && ( diff --git a/src/app/smoke-tests/page.tsx b/src/app/smoke-tests/page.tsx index 9ab2d21..bfe356d 100644 --- a/src/app/smoke-tests/page.tsx +++ b/src/app/smoke-tests/page.tsx @@ -18,6 +18,7 @@ const smokePanels = new Set([ "message-actions", "mobile-viewport", "reader-privacy", + "row-overlays", ]); export default async function SmokePage({ searchParams }: Props) { diff --git a/src/components/EmailListPanel.tsx b/src/components/EmailListPanel.tsx index 83580ae..f9fa3b7 100644 --- a/src/components/EmailListPanel.tsx +++ b/src/components/EmailListPanel.tsx @@ -11,6 +11,7 @@ import { useState, useTransition, type CSSProperties, + type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import SenderAvatar from "@/components/SenderAvatar"; @@ -1658,6 +1659,31 @@ export default function EmailListPanel({ : isRouteSelected ? ROW_ACTION_SURFACES.route : ROW_ACTION_SURFACES.default; + const handleThreadPin = async ( + event: ReactMouseEvent, + ) => { + event.preventDefault(); + event.stopPropagation(); + const ids = pinnableEmails.map((email) => email.id); + const next = !threadIsPinned; + ids.forEach((id) => + window.dispatchEvent( + new CustomEvent("email-pin-changed", { + detail: { id, pinned: next }, + }), + ), + ); + try { + await bulkSetPin(ids, next); + showToast({ message: next ? "Pinned" : "Unpinned" }); + router.refresh(); + } catch { + showToast({ + message: "Could not update pinning.", + tone: "error", + }); + } + }; const showPinnedDivider = !isInSearchMode && view === "inbox" && pinnedThreadCount > 0 && idx === 0; @@ -1722,7 +1748,7 @@ export default function EmailListPanel({
{/* Senders + date */}
@@ -1885,150 +1912,132 @@ export default function EmailListPanel({ )} - {/* Quick actions overlay the row so hidden controls do not - reserve space that the message text could use. */} + {/* A visible pin earns its space. Keeping it in the normal + layout avoids cutting the message against a hard strip. */} + {!selectionMode && + view === "inbox" && + pinnableEmails.length > 0 && + threadIsPinned && ( + + )} + + {/* Hidden controls use one opaque tray over the message, + so they never reserve blank room or mix with its text. */} {!selectionMode && ( - <> - + + {view === "inbox" && pinnableEmails.length > 0 && ( - <> -
)}
diff --git a/tests/smoke/mail.spec.ts b/tests/smoke/mail.spec.ts index 680ba65..7b02c04 100644 --- a/tests/smoke/mail.spec.ts +++ b/tests/smoke/mail.spec.ts @@ -128,20 +128,26 @@ test("removes a moved message from the list without a refresh", async ({ await expect(page.getByText("Quarterly plan", { exact: true })).toBeVisible(); }); -test("mail quick actions overlay text instead of reserving row space", async ({ +test("mail row actions form a clean overlay for pinned and selected rows", async ({ page, }) => { - await page.goto("/smoke-tests"); + await page.goto("/smoke-tests?panel=row-overlays"); const conversation = page.locator( 'a[href="/smoke-tests/thread/thread-maya"]', ); const row = conversation.locator("xpath=.."); + const staticPin = row.locator( + '[data-thread-static-pin="thread-maya"]', + ); const quickActions = row.locator( '[data-thread-quick-actions="thread-maya"]', ); + const unpinnedConversation = page.locator( + 'a[href="/smoke-tests/thread/thread-release"]', + ); - const defaultLayout = await conversation.evaluate((element) => { + const unpinnedLayout = await unpinnedConversation.evaluate((element) => { const rowElement = element.parentElement; const linkRect = element.getBoundingClientRect(); const rowRect = rowElement?.getBoundingClientRect(); @@ -151,21 +157,42 @@ test("mail quick actions overlay text instead of reserving row space", async ({ }; }); - expect(defaultLayout.rowRight - defaultLayout.linkRight).toBeLessThan(20); + expect(unpinnedLayout.rowRight - unpinnedLayout.linkRight).toBeLessThan(20); + + const pinnedLayout = await conversation.evaluate((element) => { + const linkRect = element.getBoundingClientRect(); + const pin = element.parentElement?.querySelector( + '[data-thread-static-pin="thread-maya"]', + ); + const pinRect = pin?.getBoundingClientRect(); + return { + linkRight: linkRect.right, + pinLeft: pinRect?.left ?? 0, + }; + }); + expect(pinnedLayout.pinLeft).toBeGreaterThanOrEqual(pinnedLayout.linkRight); + expect(pinnedLayout.pinLeft - pinnedLayout.linkRight).toBeLessThan(16); + await expect(staticPin).toBeVisible(); await row.hover(); await expect( quickActions.getByRole("button", { name: "Archive thread" }), ).toBeVisible(); + await expect( + quickActions.getByRole("button", { name: "Unpin thread" }), + ).toBeVisible(); + await expect(staticPin).toHaveCSS("opacity", "0"); - const overlayColors = await quickActions.evaluate((element) => { + const overlayAppearance = await quickActions.evaluate((element) => { const rowElement = element.parentElement; return { actions: getComputedStyle(element).backgroundColor, row: rowElement ? getComputedStyle(rowElement).backgroundColor : "", + radius: getComputedStyle(element).borderRadius, }; }); - expect(overlayColors.actions).toBe(overlayColors.row); + expect(overlayAppearance.actions).toBe(overlayAppearance.row); + expect(overlayAppearance.radius).not.toBe("0px"); await page.mouse.move(0, 0); await page.keyboard.press("j"); @@ -174,6 +201,15 @@ test("mail quick actions overlay text instead of reserving row space", async ({ quickActions.getByRole("button", { name: "Archive thread" }), ).toBeVisible(); + const focusAppearance = await conversation.evaluate((element) => ({ + linkOutline: getComputedStyle(element).outlineStyle, + rowShadow: element.parentElement + ? getComputedStyle(element.parentElement).boxShadow + : "none", + })); + expect(focusAppearance.linkOutline).toBe("none"); + expect(focusAppearance.rowShadow).not.toBe("none"); + const selectedOverlay = await quickActions.evaluate((element) => { const color = getComputedStyle(element).backgroundColor; const rgba = color.match(/^rgba?\(([^)]+)\)$/);