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 && (
- <>
-
0 ? "right-11" : "right-3",
- ].join(" ")}
- data-thread-quick-actions={thread.threadId}
+
+ {view === "inbox" && archiveMailboxId && (
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ moveThread(thread, archiveMailboxId);
+ }}
+ className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-stone-200 hover:text-stone-700 focus-visible:outline-offset-0 dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-stone-200"
+ >
+
+
+ )}
+ {(view === "archive" || view === "trash") && (
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ void moveMessages(
+ thread.allEmails,
+ inboxId,
+ thread.allEmails.length === 1
+ ? "Restored to Inbox"
+ : `${thread.allEmails.length} messages restored to Inbox`,
+ );
+ }}
+ className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-stone-200 hover:text-stone-700 focus-visible:outline-offset-0 dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-stone-200"
+ >
+
+
+ )}
+ {view === "archive" && trashMailboxId && (
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ moveThread(thread, trashMailboxId);
+ }}
+ className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-stone-200 hover:text-red-600 focus-visible:outline-offset-0 dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-red-400"
+ >
+
+
+ )}
+ {view === "trash" && trashMailboxId && (
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ void deleteMessagesPermanently(
+ thread.allEmails.map((email) => email.id),
+ );
+ }}
+ className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-red-50 hover:text-red-600 focus-visible:outline-offset-0 dark:text-stone-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
+ >
+
+
+ )}
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ void toggleThreadReadState(thread, isUnread);
+ }}
+ className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-stone-200 hover:text-stone-700 focus-visible:outline-offset-0 dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-stone-200"
>
-
- {view === "inbox" && archiveMailboxId && (
- {
- event.preventDefault();
- event.stopPropagation();
- moveThread(thread, archiveMailboxId);
- }}
- className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-stone-200 hover:text-stone-700 dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-stone-200"
- >
-
-
- )}
- {(view === "archive" || view === "trash") && (
- {
- event.preventDefault();
- event.stopPropagation();
- void moveMessages(
- thread.allEmails,
- inboxId,
- thread.allEmails.length === 1
- ? "Restored to Inbox"
- : `${thread.allEmails.length} messages restored to Inbox`,
- );
- }}
- className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-stone-200 hover:text-stone-700 dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-stone-200"
- >
-
-
- )}
- {view === "archive" && trashMailboxId && (
- {
- event.preventDefault();
- event.stopPropagation();
- moveThread(thread, trashMailboxId);
- }}
- className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-stone-200 hover:text-red-600 dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-red-400"
- >
-
-
- )}
- {view === "trash" && trashMailboxId && (
- {
- event.preventDefault();
- event.stopPropagation();
- void deleteMessagesPermanently(
- thread.allEmails.map((email) => email.id),
- );
- }}
- className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-red-50 hover:text-red-600 dark:text-stone-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
- >
-
-
- )}
- {
- event.preventDefault();
- event.stopPropagation();
- void toggleThreadReadState(thread, isUnread);
- }}
- className="flex h-8 w-8 items-center justify-center rounded-md text-stone-400 hover:bg-stone-200 hover:text-stone-700 dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-stone-200"
- >
-
-
-
-
+
+
{view === "inbox" && pinnableEmails.length > 0 && (
- <>
-
-
{
- 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" });
- }
- }}
- className={[
- "absolute right-3 top-1/2 z-20 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md transition-all",
- threadIsPinned
- ? "text-amber-500 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/40"
- : "pointer-events-none text-stone-300 opacity-0 hover:bg-stone-200 hover:text-amber-500 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 dark:text-stone-600 dark:hover:bg-stone-800 dark:hover:text-amber-400",
- ].join(" ")}
- >
-
-
- >
+
+
+
)}
- >
+
)}
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?\(([^)]+)\)$/);