FN-8238: tighten mobile mailbox header layout

Keep full-page mailbox actions and detail controls compact on mobile devices.

- Gate compact mailbox styles on the runtime mobile viewport mode
- Preserve header actions, tabs, and message controls in single mobile rows
- Add regression coverage for mobile layout gating and actions

Files changed:
 packages/dashboard/app/components/MailboxModal.css | 113 ++++++++++++++++++++-
 packages/dashboard/app/components/MailboxView.tsx  |  12 ++-
 .../app/components/__tests__/MailboxView.test.tsx  |  73 +++++++++++++
 3 files changed, 193 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-8238

Fusion-Task-Lineage: fce35577-4368-4cf6-a059-445550c98463

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 13:58:34 -07:00
parent 595c27ceae
commit eae8d9dabe
3 changed files with 193 additions and 5 deletions

View File

@@ -870,6 +870,104 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d
margin: 0;
}
/*
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.
*/
.mailbox-view--mobile .view-header {
flex-wrap: wrap;
gap: var(--space-sm);
}
.mailbox-view--mobile .view-header__title {
flex: 0 0 100%;
}
.mailbox-view--mobile .view-header__actions {
flex: 1 1 100%;
min-width: 0;
flex-wrap: nowrap;
justify-content: flex-start;
gap: var(--space-xs);
margin-left: 0;
}
.mailbox-view--mobile .view-header__actions .btn {
min-width: 0;
flex-shrink: 1;
padding: var(--space-xs);
white-space: nowrap;
}
.mailbox-view--mobile .view-header__actions .btn-icon,
.mailbox-view--mobile .mailbox-unread-badge {
flex-shrink: 0;
}
/*
FNXC:MailboxMobile 2026-07-17-13:43:
FN-8238 requires Inbox, Outbox, Agents, and Approvals to remain on one compact mobile row.
Tabs may shrink while retaining their badge and use horizontal scrolling only as the narrowest
viewport fallback; this applies only when the runtime mobile class is present.
*/
.mailbox-view--mobile .mailbox-tabs {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.mailbox-view--mobile .mailbox-tab {
min-width: 0;
flex: 1 1 0;
flex-shrink: 1;
padding: var(--space-xs);
font-size: var(--font-size-xs, 0.8rem);
}
/*
FNXC:MailboxMobile 2026-07-17-13:43:
FN-8238 keeps mobile message-detail Back, message meta, and Delete (plus Reply for agent
messages) on one row. Meta may truncate before controls wrap, and this does not affect the
separate approvals Back button outside this header or non-mobile mailbox layouts.
*/
.mailbox-view--mobile .mailbox-message-detail-header {
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-xs);
}
.mailbox-view--mobile .mailbox-message-detail-header > .btn,
.mailbox-view--mobile .mailbox-message-detail-actions {
flex-shrink: 0;
}
.mailbox-view--mobile .mailbox-message-detail-meta {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
}
.mailbox-view--mobile .mailbox-message-detail-meta > * {
overflow: hidden;
text-overflow: ellipsis;
}
.mailbox-view--mobile .mailbox-message-detail-actions {
flex-wrap: nowrap;
gap: var(--space-xs);
}
.mailbox-view--mobile .mailbox-message-detail-actions .btn {
min-width: 0;
padding: var(--space-xs);
white-space: nowrap;
}
/* ── Mailbox — Mobile (≤ 768px) ───────────────────────────────────── */
@media (max-width: 768px) {
@@ -994,7 +1092,14 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d
padding: var(--space-sm) var(--space-md);
}
/* Mailbox View (full-page) mobile overrides */
/*
FNXC:MailboxMobile 2026-07-17-14:10:
FN-8238's runtime mobile class owns the compact full-page tabs and message-detail row.
Keep these legacy width-query fallbacks off that class: this media block follows the
compact rules in source order and would otherwise restore tab overflow and a wrapped
detail header on every actual phone viewport.
*/
/* Mailbox View (full-page) width-query fallbacks outside runtime mobile mode */
.mailbox-view .mailbox-header {
flex-wrap: wrap;
gap: var(--space-sm);
@@ -1014,7 +1119,7 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d
display: none;
}
.mailbox-view .mailbox-tab {
.mailbox-view:not(.mailbox-view--mobile) .mailbox-tab {
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-xs, 0.8rem);
@@ -1053,12 +1158,12 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d
overflow: visible;
}
.mailbox-view .mailbox-message-detail-header {
.mailbox-view:not(.mailbox-view--mobile) .mailbox-message-detail-header {
flex-direction: column;
align-items: flex-start;
}
.mailbox-view .mailbox-message-detail-actions {
.mailbox-view:not(.mailbox-view--mobile) .mailbox-message-detail-actions {
flex-wrap: wrap;
}

View File

@@ -1348,8 +1348,18 @@ export function MailboxView({
);
};
/*
FNXC:MailboxMobile 2026-07-17-13:43:
FN-8238 gates the full-page mailbox's compact mobile layout on this class, mirroring
isMobileViewport() exactly. CSS media or pointer queries cannot read the runtime
physical-screen and visualViewport signals that determine the mobile classification.
*/
return (
<div className="mailbox-view" style={containerKeyboardStyle} data-testid="mailbox-view">
<div
className={`mailbox-view${isMobile ? " mailbox-view--mobile" : ""}`}
style={containerKeyboardStyle}
data-testid="mailbox-view"
>
{/*
FNXC:Navigation 2026-06-22-01:10:
Mailbox adopts the shared ViewHeader (Command Center-modeled) for a consistent main-content title row. The unread count badge stays beside the title (preserving the mailbox-unread-badge test id), and Compose / Mark-all-read / Refresh controls move into the header actions cluster so they keep working. Tabs remain below the header as their own row.

View File

@@ -2283,6 +2283,79 @@ describe("MailboxView", () => {
});
describe("mobile layout CSS regressions", () => {
it("adds the runtime mobile layout gate only in mobile mode", async () => {
mockFetchInbox.mockResolvedValue(makeInboxResponse([], 0));
mockUseViewportMode.mockReturnValue("mobile");
const { unmount } = render(<MailboxView {...defaultProps} />);
expect(await screen.findByTestId("mailbox-view")).toHaveClass("mailbox-view--mobile");
unmount();
mockUseViewportMode.mockReturnValue("desktop");
render(<MailboxView {...defaultProps} />);
expect(await screen.findByTestId("mailbox-view")).not.toHaveClass("mailbox-view--mobile");
});
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+\.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;[^}]*\}/);
expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-message-detail-actions\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*\}/);
// The later width-query block must not override the runtime compact tab/detail rules on phones.
expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-tab\s*\{[^}]*flex-shrink:\s*0;[^}]*\}/);
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.
expect(css).not.toMatch(/@media\s*\([^)]*(?:max-height:\s*480px|pointer:\s*coarse)[^)]*\)\s*\{[\s\S]*?\.mailbox-view--mobile/);
});
it("keeps system and agent message actions on the mobile detail row", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const systemMessage: Message = {
...mockMessage,
id: "msg-system",
fromId: "system",
fromType: "system",
type: "system",
};
mockFetchInbox.mockResolvedValue(makeInboxResponse([systemMessage], 0));
mockFetchConversation.mockResolvedValue([systemMessage]);
const { unmount } = render(<MailboxView {...defaultProps} />);
await screen.findByTestId("mailbox-item-msg-system");
fireEvent.click(screen.getByTestId("mailbox-item-msg-system"));
await screen.findByTestId("mailbox-message-detail");
expect(screen.getByTestId("mailbox-delete")).toBeInTheDocument();
expect(screen.queryByTestId("mailbox-reply")).toBeNull();
unmount();
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 0));
mockFetchConversation.mockResolvedValue([mockMessage]);
render(<MailboxView {...defaultProps} />);
await screen.findByTestId("mailbox-item-msg-001");
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
await screen.findByTestId("mailbox-message-detail");
expect(screen.getByTestId("mailbox-reply")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-delete")).toBeInTheDocument();
});
it("renders every unread Inbox header action together in mobile mode", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1));
render(<MailboxView {...defaultProps} />);
expect(await screen.findByTestId("mailbox-unread-badge")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-header-compose")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-mark-all-read")).toBeInTheDocument();
expect(screen.getByTestId("mailbox-refresh")).toBeInTheDocument();
});
it("defines .mailbox-view base flex layout with min-height: 0", async () => {
const fs = await import("fs");
const path = await import("path");