FN-8407: keep mailbox header actions inline on mobile

Keep Mailbox title and header actions on one compact mobile row.

- Align the mobile title, unread badge, and action controls inline with safe text truncation.
- Cover the mobile layout rules and 320px browser geometry across mailbox states.

Files changed:
 packages/dashboard/app/components/MailboxModal.css | 30 ++++++----
 .../app/components/__tests__/MailboxView.test.tsx  | 10 +++-
 .../dashboard/scripts/browser-layout-smoke.mjs     | 67 ++++++++++++++++++++++
 3 files changed, 94 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-8407

Fusion-Task-Lineage: 742d2043-6093-4fe9-a27e-029572218e42

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 17:31:37 -07:00
parent b07f207b00
commit bfdd16b90a
3 changed files with 94 additions and 13 deletions

View File

@@ -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;

View File

@@ -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/);
});

View File

@@ -182,6 +182,25 @@ export function createSmokeHtml() {
</section>
`).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", '<span class="mailbox-unread-badge">9</span><button class="btn btn-sm btn-primary" data-testid="mailbox-header-compose" type="button"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2h10v10H2z"/></svg><span>Compose</span></button><button class="btn btn-sm btn-secondary" data-testid="mailbox-mark-all-read" type="button"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7l3 3 7-7"/></svg><span>Mark all read</span></button><button class="btn-icon" data-testid="mailbox-refresh" type="button" aria-label="Refresh"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7a5 5 0 1 0 2-4"/></svg></button>'],
["read-inbox", '<button class="btn btn-sm btn-primary" data-testid="mailbox-header-compose" type="button"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2h10v10H2z"/></svg><span>Compose</span></button><button class="btn-icon" data-testid="mailbox-refresh" type="button" aria-label="Refresh"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7a5 5 0 1 0 2-4"/></svg></button>'],
["non-inbox", '<button class="btn btn-sm btn-primary" data-testid="mailbox-header-compose" type="button"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2h10v10H2z"/></svg><span>Compose</span></button><button class="btn-icon" data-testid="mailbox-refresh" type="button" aria-label="Refresh"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7a5 5 0 1 0 2-4"/></svg></button>'],
].map(([state, actions]) => `
<section class="mailbox-view mailbox-view--mobile" data-smoke="mailbox-mobile-header-${state}" style="width: 100%; max-width: 20rem;">
<header class="view-header">
<h2 class="view-header__title"><svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M2 3h16v14H2z"/></svg><span>Mailbox</span></h2>
<div class="view-header__actions">${actions}</div>
</header>
</section>
`).join("");
return `<!doctype html>
<html lang="en">
<head>
@@ -265,6 +284,10 @@ export function createSmokeHtml() {
${taskDetailInlineRowFixtures}
</section>
<section data-smoke="mailbox-mobile-header-fixtures" aria-label="Mailbox mobile header layout fixtures">
${mailboxMobileHeaderFixtures}
</section>
<footer class="executor-status-bar">
<div class="executor-status-bar__segment">
<span class="executor-status-bar__indicator executor-status-bar__indicator--running"></span>
@@ -1200,6 +1223,50 @@ async function runSmokeChecks(page, pageUrl) {
);
}
await page.send("Emulation.setDeviceMetricsOverride", {
width: 320,
height: 844,
deviceScaleFactor: 2,
mobile: true,
});
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const mailboxMobileHeaderLayout = await evaluate(page, `(() => {
return [...document.querySelectorAll('[data-smoke^="mailbox-mobile-header-"]:not([data-smoke="mailbox-mobile-header-fixtures"])')].map((fixture) => {
const header = fixture.querySelector('.view-header').getBoundingClientRect();
const title = fixture.querySelector('.view-header__title').getBoundingClientRect();
const titleLabel = fixture.querySelector('.view-header__title span').getBoundingClientRect();
const actions = fixture.querySelector('.view-header__actions').getBoundingClientRect();
return {
state: fixture.dataset.smoke,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
headerLeft: header.left,
headerRight: header.right,
headerOverflow: fixture.scrollWidth - fixture.clientWidth,
titleLeft: title.left,
titleTop: title.top,
titleBottom: title.bottom,
titleLabelWidth: titleLabel.width,
actionsLeft: actions.left,
actionsRight: actions.right,
actionsTop: actions.top,
actionsBottom: actions.bottom,
actionsHeight: actions.height,
};
});
})()`);
assertSmokeResult(
"Mailbox mobile headers keep title and actions inline at 320px",
mailboxMobileHeaderLayout.length === 3
&& mailboxMobileHeaderLayout.every((layout) => layout.documentOverflow <= 1
&& layout.headerOverflow <= 1
&& layout.actionsLeft > layout.titleLeft
&& layout.actionsRight <= layout.headerRight + 1
&& Math.abs(layout.titleTop - layout.actionsTop) <= layout.actionsHeight
&& layout.actionsTop < layout.titleBottom)
&& mailboxMobileHeaderLayout.find((layout) => layout.state === "mailbox-mobile-header-unread-inbox")?.titleLabelWidth > 0,
JSON.stringify(mailboxMobileHeaderLayout),
);
await page.send("Emulation.setDeviceMetricsOverride", {
width: 412,
height: 915,