feat(FN-1303): redesign mobile nav with 4-tab structure

- Split board and list into separate bottom nav tab buttons for direct access
- Move activity from bottom nav into a more/action sheet for cleaner layout
- Add usage and search icons to the mobile header
- Remove unused mobile nav CSS for old view-toggle and badge patterns
- Update mobile nav tests to cover the new 4-tab structure
This commit is contained in:
gsxdsm
2026-04-08 21:42:31 -07:00
parent 50a1b2cba3
commit 75ec1874a5
4 changed files with 73 additions and 151 deletions

View File

@@ -307,8 +307,8 @@ export function Header({
</div>
)}
{/* Mobile Search Trigger - only show in board view on mobile */}
{onSearchChange && view === "board" && isMobile && (
{/* Mobile Search Trigger - show in board and list views when mobile nav is hidden (hideFullNav) or in board view when mobile nav is visible */}
{onSearchChange && isMobile && (hideFullNav || view === "board") && (
<>
{!shouldShowMobileSearch ? (
<button
@@ -317,6 +317,7 @@ export function Header({
title="Open search"
aria-label="Open search"
aria-expanded={false}
data-testid="mobile-header-search-btn"
>
<Search size={16} />
</button>
@@ -347,6 +348,18 @@ export function Header({
</>
)}
{/* Usage button on mobile when mobile bottom nav is active */}
{isMobile && hideFullNav && onOpenUsage && (
<button
className="btn-icon"
onClick={onOpenUsage}
title="View usage"
data-testid="mobile-header-usage-btn"
>
<Activity size={16} />
</button>
)}
{/* View Toggle - always inline, even on mobile */}
{!hideFullNav && onChangeView && (
<div className="view-toggle">

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
Activity,
Bot,
@@ -92,11 +92,6 @@ export function MobileNavBar({
const mode = useViewportMode();
const [isMoreOpen, setIsMoreOpen] = useState(false);
const combinedCount = useMemo(
() => activePlanningSessionCount + mailboxUnreadCount,
[activePlanningSessionCount, mailboxUnreadCount],
);
const closeMore = useCallback(() => setIsMoreOpen(false), []);
const handleMoreAction = useCallback(
@@ -133,30 +128,28 @@ export function MobileNavBar({
role="tablist"
aria-label="Primary navigation"
>
<div className="mobile-nav-view-toggle">
<button
type="button"
className={`mobile-nav-view-toggle-btn${view === "board" ? " mobile-nav-view-toggle-btn--active" : ""}`}
data-testid="mobile-nav-tab-board"
role="tab"
aria-selected={view === "board"}
onClick={() => onChangeView("board")}
>
<LayoutGrid />
<span>Board</span>
</button>
<button
type="button"
className={`mobile-nav-view-toggle-btn${view === "list" ? " mobile-nav-view-toggle-btn--active" : ""}`}
data-testid="mobile-nav-tab-list"
role="tab"
aria-selected={view === "list"}
onClick={() => onChangeView("list")}
>
<List />
<span>List</span>
</button>
</div>
<button
type="button"
className={`mobile-nav-tab${view === "board" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-board"
role="tab"
aria-selected={view === "board"}
onClick={() => onChangeView("board")}
>
<LayoutGrid />
<span className="mobile-nav-tab-label">Board</span>
</button>
<button
type="button"
className={`mobile-nav-tab${view === "list" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-list"
role="tab"
aria-selected={view === "list"}
onClick={() => onChangeView("list")}
>
<List />
<span className="mobile-nav-tab-label">List</span>
</button>
<button
type="button"
@@ -170,19 +163,6 @@ export function MobileNavBar({
<span className="mobile-nav-tab-label">Agents</span>
</button>
<button
type="button"
className="mobile-nav-tab"
data-testid="mobile-nav-tab-activity"
role="tab"
aria-selected={false}
onClick={() => onOpenActivityLog?.()}
>
<Activity />
<span className="mobile-nav-tab-label">Activity</span>
{combinedCount > 0 && <span className="mobile-nav-badge">{formatCount(combinedCount)}</span>}
</button>
<button
type="button"
className="mobile-nav-tab"
@@ -219,6 +199,16 @@ export function MobileNavBar({
)}
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-activity"
onClick={() => handleMoreAction(onOpenActivityLog)}
>
<Activity />
<span>Activity Log</span>
</button>
<button
type="button"
className="mobile-more-item"

View File

@@ -50,13 +50,12 @@ describe("MobileNavBar", () => {
mockViewport("mobile");
});
it("renders five tab buttons (board + list + agents + activity + more)", () => {
it("renders four tab buttons (board + list + agents + more)", () => {
render(<MobileNavBar {...createDefaultProps()} />);
expect(screen.getByTestId("mobile-nav-tab-board")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-list")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-agents")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-activity")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
@@ -66,7 +65,7 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-agents").className).toContain("mobile-nav-tab--active");
});
it("board sub-button calls onChangeView with 'board'", () => {
it("board tab calls onChangeView with 'board'", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} view="list" />);
@@ -74,7 +73,7 @@ describe("MobileNavBar", () => {
expect(props.onChangeView).toHaveBeenCalledWith("board");
});
it("list sub-button calls onChangeView with 'list'", () => {
it("list tab calls onChangeView with 'list'", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} view="board" />);
@@ -82,48 +81,22 @@ describe("MobileNavBar", () => {
expect(props.onChangeView).toHaveBeenCalledWith("list");
});
it("board sub-button is active when view is 'board'", () => {
it("board tab is active when view is 'board'", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" />);
expect(screen.getByTestId("mobile-nav-tab-board").className).toContain("mobile-nav-view-toggle-btn--active");
expect(screen.getByTestId("mobile-nav-tab-list").className).not.toContain("mobile-nav-view-toggle-btn--active");
expect(screen.getByTestId("mobile-nav-tab-board").className).toContain("mobile-nav-tab--active");
expect(screen.getByTestId("mobile-nav-tab-list").className).not.toContain("mobile-nav-tab--active");
});
it("list sub-button is active when view is 'list'", () => {
it("list tab is active when view is 'list'", () => {
render(<MobileNavBar {...createDefaultProps()} view="list" />);
expect(screen.getByTestId("mobile-nav-tab-list").className).toContain("mobile-nav-view-toggle-btn--active");
expect(screen.getByTestId("mobile-nav-tab-board").className).not.toContain("mobile-nav-view-toggle-btn--active");
expect(screen.getByTestId("mobile-nav-tab-list").className).toContain("mobile-nav-tab--active");
expect(screen.getByTestId("mobile-nav-tab-board").className).not.toContain("mobile-nav-tab--active");
});
it("board sub-button is not active when view is 'agents'", () => {
it("board and list tabs are not active when view is 'agents'", () => {
render(<MobileNavBar {...createDefaultProps()} view="agents" />);
expect(screen.getByTestId("mobile-nav-tab-board").className).not.toContain("mobile-nav-view-toggle-btn--active");
expect(screen.getByTestId("mobile-nav-tab-list").className).not.toContain("mobile-nav-view-toggle-btn--active");
});
it("shows activity badge with combined planning + mailbox count", () => {
const { container } = render(
<MobileNavBar
{...createDefaultProps()}
activePlanningSessionCount={2}
mailboxUnreadCount={3}
/>,
);
const activityTab = screen.getByTestId("mobile-nav-tab-activity");
const badge = activityTab.querySelector(".mobile-nav-badge");
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe("5");
expect(container.querySelectorAll(".mobile-nav-badge")).toHaveLength(1);
});
it("hides badge when combined count is zero", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} mailboxUnreadCount={0} activePlanningSessionCount={0} />);
expect(container.querySelector(".mobile-nav-badge")).toBeNull();
});
it("caps badge at 99+", () => {
render(<MobileNavBar {...createDefaultProps()} mailboxUnreadCount={75} activePlanningSessionCount={75} />);
expect(screen.getByText("99+")).toBeDefined();
expect(screen.getByTestId("mobile-nav-tab-board").className).not.toContain("mobile-nav-tab--active");
expect(screen.getByTestId("mobile-nav-tab-list").className).not.toContain("mobile-nav-tab--active");
});
it("opens and toggles the more sheet", () => {
@@ -137,11 +110,12 @@ describe("MobileNavBar", () => {
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("sheet contains expected navigation items", () => {
it("sheet contains expected navigation items including activity log", () => {
render(<MobileNavBar {...createDefaultProps()} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-missions")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-git")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
@@ -155,6 +129,17 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("activity log item in more sheet calls onOpenActivityLog", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-activity"));
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
expect(props.onOpenActivityLog).toHaveBeenCalledOnce();
});
it("closes sheet and calls handler when item is clicked", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);

View File

@@ -25918,52 +25918,6 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
-webkit-tap-highlight-color: transparent;
}
/* Board/List view toggle — two sub-buttons sharing first tab slot */
.mobile-nav-view-toggle {
flex: 1;
display: flex;
align-items: stretch;
justify-content: center;
min-height: 56px;
}
.mobile-nav-view-toggle-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 0;
background: none;
border: none;
color: var(--text-muted);
font-size: 10px;
line-height: 1.2;
cursor: pointer;
transition: color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.mobile-nav-view-toggle-btn:active {
color: var(--text-primary);
}
.mobile-nav-view-toggle-btn--active {
color: var(--accent);
}
.mobile-nav-view-toggle-btn svg {
width: 18px;
height: 18px;
}
.mobile-nav-view-toggle-btn span {
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
}
.mobile-nav-tab:active {
color: var(--text-primary);
}
@@ -25983,26 +25937,6 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
white-space: nowrap;
}
/* Badge on tab */
.mobile-nav-badge {
position: absolute;
top: 2px;
right: 50%;
transform: translateX(calc(50% + 10px));
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--accent);
color: var(--surface);
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
line-height: 1;
}
/* === Mobile More Sheet (Bottom Drawer) === */
.mobile-more-sheet-backdrop {