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