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:
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user