diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css
index 61cccc1522..c6f9c2d1a5 100644
--- a/packages/dashboard/app/components/MailboxModal.css
+++ b/packages/dashboard/app/components/MailboxModal.css
@@ -871,37 +871,47 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d
}
/*
-FNXC:MailboxMobile 2026-07-17-13:43:
-FN-8238 keeps the full-page mobile title above one compact action row: unread badge,
-Compose, Mark all read, and Refresh remain visible together. This runtime-mode class
-mirrors isMobileViewport(); CSS media and pointer queries cannot observe its physical-screen
-or visualViewport classification, so desktop and tablet headers retain their shared chrome.
+FNXC:MailboxMobile 2026-07-19-17:00:
+FN-8407 supersedes FN-8238's title-then-actions mobile stack: Mailbox title and its
+unread badge, Compose, Mark all read, and Refresh share one right-aligned row at the
+320px boundary. Keep this override runtime-class-gated because CSS media and pointer
+queries cannot observe isMobileViewport()'s physical-screen or visualViewport classification;
+FN-8349 attempted this same change but finalized without landing it.
*/
.mailbox-view--mobile .view-header {
- flex-wrap: wrap;
+ flex-wrap: nowrap;
+ align-items: center;
gap: var(--space-sm);
}
.mailbox-view--mobile .view-header__title {
- flex: 0 0 100%;
+ flex: 1 1 auto;
+ min-width: 0;
}
.mailbox-view--mobile .view-header__actions {
- flex: 1 1 100%;
+ flex: 0 1 auto;
min-width: 0;
flex-wrap: nowrap;
- justify-content: flex-start;
+ justify-content: flex-end;
gap: var(--space-xs);
- margin-left: 0;
+ margin-left: auto;
}
.mailbox-view--mobile .view-header__actions .btn {
min-width: 0;
flex-shrink: 1;
padding: var(--space-xs);
+}
+
+.mailbox-view--mobile .view-header__actions .btn span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
white-space: nowrap;
}
+.mailbox-view--mobile .view-header__actions .btn svg,
.mailbox-view--mobile .view-header__actions .btn-icon,
.mailbox-view--mobile .mailbox-unread-badge {
flex-shrink: 0;
diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
index e203843f46..9e20d9217d 100644
--- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx
@@ -2310,8 +2310,12 @@ describe("MailboxView", () => {
it("defines class-gated, compact single-row mailbox mobile layout rules", () => {
const css = loadAllAppCss();
- expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header\s*\{[^}]*flex-wrap:\s*wrap;[^}]*\}/);
- expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header__actions\s*\{[^}]*flex:\s*1\s+1\s+100%;[^}]*min-width:\s*0;[^}]*flex-wrap:\s*nowrap;[^}]*margin-left:\s*0;[^}]*\}/);
+ expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*align-items:\s*center;[^}]*\}/);
+ expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header__title\s*\{[^}]*flex:\s*1\s+1\s+auto;[^}]*min-width:\s*0;[^}]*\}/);
+ expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header__actions\s*\{[^}]*flex:\s*0\s+1\s+auto;[^}]*min-width:\s*0;[^}]*flex-wrap:\s*nowrap;[^}]*justify-content:\s*flex-end;[^}]*margin-left:\s*auto;[^}]*\}/);
+ expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header__actions\s+\.btn\s+span\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;[^}]*\}/);
+ expect(css).not.toMatch(/\.mailbox-view--mobile\s+\.view-header__title\s*\{[^}]*flex:\s*0\s+0\s+100%;[^}]*\}/);
+ expect(css).not.toMatch(/\.mailbox-view--mobile\s+\.view-header__actions\s*\{[^}]*flex:\s*1\s+1\s+100%;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-tabs\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*overflow-x:\s*auto;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-tab\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*1\s+1\s+0;[^}]*flex-shrink:\s*1;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-message-detail-header\s*\{[^}]*flex-direction:\s*row;[^}]*flex-wrap:\s*nowrap;[^}]*\}/);
@@ -2321,7 +2325,7 @@ describe("MailboxView", () => {
expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-message-detail-header\s*\{[^}]*flex-direction:\s*column;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-message-detail-actions\s*\{[^}]*flex-wrap:\s*wrap;[^}]*\}/);
- // The runtime class, not a height or pointer media proxy, is the only FN-8238 gate.
+ // The runtime class, not a height or pointer media proxy, is the only FN-8407 gate.
expect(css).not.toMatch(/@media\s*\([^)]*(?:max-height:\s*480px|pointer:\s*coarse)[^)]*\)\s*\{[\s\S]*?\.mailbox-view--mobile/);
});
diff --git a/packages/dashboard/scripts/browser-layout-smoke.mjs b/packages/dashboard/scripts/browser-layout-smoke.mjs
index 301475507e..9b150d2ebe 100644
--- a/packages/dashboard/scripts/browser-layout-smoke.mjs
+++ b/packages/dashboard/scripts/browser-layout-smoke.mjs
@@ -182,6 +182,25 @@ export function createSmokeHtml() {
`).join("");
+ /*
+ FNXC:MailboxMobile 2026-07-19-17:00:
+ FN-8407 requires a real-browser 320px regression surface because jsdom cannot
+ measure the shared ViewHeader flex geometry. Exercise the unread Inbox's tightest
+ badge + Compose + Mark all read + Refresh row and both Compose + Refresh-only states.
+ */
+ const mailboxMobileHeaderFixtures = [
+ ["unread-inbox", '9'],
+ ["read-inbox", ''],
+ ["non-inbox", ''],
+ ].map(([state, actions]) => `
+ Mailbox
+