feat(nexus): add mobile tab bar and sheet variants (phase 15)
Replace legacy MobileBottomNav with the new 4-destination MobileTabBar that mirrors the desktop IconRail (Assistant, Studio, Projects, Settings). Adds volt active states with a 2px bar above the icon, safe-area bottom padding, and scroll-hide wiring via the existing mobileNavVisible handler in Layout. Adapts Phase 9 HistorySheet and MemorySheet to a full-screen variant below 768px via useMediaQuery, and makes Phase 11 BuilderTabStrip horizontally scrollable with scroll-snap and edge fade on mobile. Light TopStrip polish (tighter padding, truncating breadcrumb) completes the mobile frame. PromoteTransition mobile variant is deferred — Phase 12 had not yet landed when Phase 15 started; the controller can add the mobile branch when merging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f41690ff30
commit
4623c8aea0
11 changed files with 474 additions and 135 deletions
|
|
@ -9,10 +9,10 @@ import { NewProjectDialog } from "./NewProjectDialog";
|
||||||
import { NewGoalDialog } from "./NewGoalDialog";
|
import { NewGoalDialog } from "./NewGoalDialog";
|
||||||
import { NewAgentDialog } from "./NewAgentDialog";
|
import { NewAgentDialog } from "./NewAgentDialog";
|
||||||
import { ToastViewport } from "./ToastViewport";
|
import { ToastViewport } from "./ToastViewport";
|
||||||
import { MobileBottomNav } from "./MobileBottomNav";
|
|
||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { DevRestartBanner } from "./DevRestartBanner";
|
import { DevRestartBanner } from "./DevRestartBanner";
|
||||||
import { IconRail } from "./frame/IconRail";
|
import { IconRail } from "./frame/IconRail";
|
||||||
|
import { MobileTabBar } from "./frame/MobileTabBar";
|
||||||
import { TopStrip } from "./frame/TopStrip";
|
import { TopStrip } from "./frame/TopStrip";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||||
|
|
@ -192,7 +192,7 @@ export function Layout() {
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1",
|
"flex-1",
|
||||||
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto p-4 md:p-6",
|
isMobile ? "overflow-visible pb-[calc(3.5rem+env(safe-area-inset-bottom))]" : "overflow-auto p-4 md:p-6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasUnknownCompanyPrefix ? (
|
{hasUnknownCompanyPrefix ? (
|
||||||
|
|
@ -221,7 +221,7 @@ export function Layout() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
{isMobile && <MobileTabBar visible={mobileNavVisible} />}
|
||||||
|
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<NewIssueDialog />
|
<NewIssueDialog />
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { useMemo } from "react";
|
|
||||||
import { NavLink, useLocation } from "@/lib/router";
|
|
||||||
import {
|
|
||||||
House,
|
|
||||||
CircleDot,
|
|
||||||
SquarePen,
|
|
||||||
Users,
|
|
||||||
Inbox,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCompany } from "../context/CompanyContext";
|
|
||||||
import { useDialog } from "../context/DialogContext";
|
|
||||||
import { cn } from "../lib/utils";
|
|
||||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
|
||||||
|
|
||||||
interface MobileBottomNavProps {
|
|
||||||
visible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MobileNavLinkItem {
|
|
||||||
type: "link";
|
|
||||||
to: string;
|
|
||||||
label: string;
|
|
||||||
icon: typeof House;
|
|
||||||
badge?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MobileNavActionItem {
|
|
||||||
type: "action";
|
|
||||||
label: string;
|
|
||||||
icon: typeof SquarePen;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MobileNavItem = MobileNavLinkItem | MobileNavActionItem;
|
|
||||||
|
|
||||||
export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const { selectedCompanyId } = useCompany();
|
|
||||||
const { openNewIssue } = useDialog();
|
|
||||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
|
||||||
|
|
||||||
const items = useMemo<MobileNavItem[]>(
|
|
||||||
() => [
|
|
||||||
{ type: "link", to: "/dashboard", label: "Home", icon: House },
|
|
||||||
{ type: "link", to: "/issues", label: "Issues", icon: CircleDot },
|
|
||||||
{ type: "action", label: "Create", icon: SquarePen, onClick: () => openNewIssue() },
|
|
||||||
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
|
|
||||||
{
|
|
||||||
type: "link",
|
|
||||||
to: "/inbox",
|
|
||||||
label: "Inbox",
|
|
||||||
icon: Inbox,
|
|
||||||
badge: inboxBadge.inbox,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[openNewIssue, inboxBadge.inbox],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
className={cn(
|
|
||||||
"fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-transform duration-200 ease-out md:hidden pb-[env(safe-area-inset-bottom)]",
|
|
||||||
visible ? "translate-y-0" : "translate-y-full",
|
|
||||||
)}
|
|
||||||
aria-label="Mobile navigation"
|
|
||||||
>
|
|
||||||
<div className="grid h-16 grid-cols-5 px-1">
|
|
||||||
{items.map((item) => {
|
|
||||||
if (item.type === "action") {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = /\/issues\/new(?:\/|$)/.test(location.pathname);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.label}
|
|
||||||
type="button"
|
|
||||||
onClick={item.onClick}
|
|
||||||
className={cn(
|
|
||||||
"relative flex min-w-0 flex-col items-center justify-center gap-1 rounded-md text-[10px] font-medium transition-colors",
|
|
||||||
active
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-[18px] w-[18px]" />
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.label}
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
"relative flex min-w-0 flex-col items-center justify-center gap-1 rounded-md text-[10px] font-medium transition-colors",
|
|
||||||
isActive
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<span className="relative">
|
|
||||||
<Icon className={cn("h-[18px] w-[18px]", isActive && "stroke-[2.3]")} />
|
|
||||||
{item.badge != null && item.badge > 0 && (
|
|
||||||
<span className="absolute -right-2 -top-2 rounded-full bg-primary px-1.5 py-0.5 text-[10px] leading-none text-primary-foreground">
|
|
||||||
{item.badge > 99 ? "99+" : item.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -14,6 +14,28 @@ vi.mock("../ChatConversationList", () => ({
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// jsdom does not ship window.matchMedia, so the Phase 15 `useMediaQuery`
|
||||||
|
// hook inside HistorySheet would throw. Stub it with a controllable fake
|
||||||
|
// so individual tests can toggle between the desktop slide-over and the
|
||||||
|
// mobile full-screen variant.
|
||||||
|
let mediaMatches = true;
|
||||||
|
function installMatchMedia() {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: mediaMatches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
import { HistorySheet } from "./HistorySheet";
|
import { HistorySheet } from "./HistorySheet";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
@ -24,6 +46,8 @@ describe("<HistorySheet />", () => {
|
||||||
let root: ReturnType<typeof createRoot> | null = null;
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mediaMatches = true;
|
||||||
|
installMatchMedia();
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = null;
|
root = null;
|
||||||
|
|
@ -107,5 +131,19 @@ describe("<HistorySheet />", () => {
|
||||||
// The Tailwind class string should include left-[56px] and w-[320px].
|
// The Tailwind class string should include left-[56px] and w-[320px].
|
||||||
expect(panel.className).toContain("left-[56px]");
|
expect(panel.className).toContain("left-[56px]");
|
||||||
expect(panel.className).toContain("w-[320px]");
|
expect(panel.className).toContain("w-[320px]");
|
||||||
|
expect(panel.getAttribute("data-variant")).toBe("desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a full-screen variant on mobile (< 768px)", () => {
|
||||||
|
mediaMatches = false;
|
||||||
|
installMatchMedia();
|
||||||
|
render(<HistorySheet open onClose={() => {}} companyId="co-1" />);
|
||||||
|
const panel = container.querySelector('[data-testid="history-sheet-panel"]') as HTMLElement;
|
||||||
|
expect(panel.getAttribute("data-variant")).toBe("mobile");
|
||||||
|
expect(panel.className).toContain("w-full");
|
||||||
|
expect(panel.className).toContain("left-0");
|
||||||
|
expect(panel.className).toContain("right-0");
|
||||||
|
expect(panel.className).not.toContain("left-[56px]");
|
||||||
|
expect(panel.className).not.toContain("w-[320px]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { ChatConversationList } from "../ChatConversationList";
|
import { ChatConversationList } from "../ChatConversationList";
|
||||||
|
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface HistorySheetProps {
|
export interface HistorySheetProps {
|
||||||
|
|
@ -29,6 +30,11 @@ export function HistorySheet({ open, onClose, companyId, className }: HistoryShe
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Phase 15 — on mobile (`< 768px`), the sheet takes the full viewport
|
||||||
|
// width below the 48px top strip, rather than the 320px desktop
|
||||||
|
// slide-over that butts against the icon rail.
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -47,9 +53,12 @@ export function HistorySheet({ open, onClose, companyId, className }: HistoryShe
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Conversation history"
|
aria-label="Conversation history"
|
||||||
data-testid="history-sheet-panel"
|
data-testid="history-sheet-panel"
|
||||||
|
data-variant={isDesktop ? "desktop" : "mobile"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-12 bottom-0 left-[56px] z-50 w-[320px] flex flex-col",
|
"fixed z-50 flex flex-col bg-background",
|
||||||
"border-r border-border bg-background",
|
isDesktop
|
||||||
|
? "top-12 bottom-0 left-[56px] w-[320px] border-r border-border"
|
||||||
|
: "top-12 bottom-0 left-0 right-0 w-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,28 @@ vi.mock("../../api/assistantMemory", () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// jsdom does not ship window.matchMedia, so the Phase 15 `useMediaQuery`
|
||||||
|
// hook inside MemorySheet would throw. Stub it so individual tests can
|
||||||
|
// toggle between the desktop right-side slide-over and the mobile
|
||||||
|
// full-screen variant.
|
||||||
|
let mediaMatches = true;
|
||||||
|
function installMatchMedia() {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: mediaMatches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
import { MemorySheet } from "./MemorySheet";
|
import { MemorySheet } from "./MemorySheet";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
@ -31,6 +53,8 @@ describe("<MemorySheet />", () => {
|
||||||
let queryClient: QueryClient;
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mediaMatches = true;
|
||||||
|
installMatchMedia();
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = null;
|
root = null;
|
||||||
|
|
@ -136,5 +160,19 @@ describe("<MemorySheet />", () => {
|
||||||
const panel = container.querySelector('[data-testid="memory-sheet-panel"]') as HTMLElement;
|
const panel = container.querySelector('[data-testid="memory-sheet-panel"]') as HTMLElement;
|
||||||
expect(panel.className).toContain("right-0");
|
expect(panel.className).toContain("right-0");
|
||||||
expect(panel.className).toContain("w-[340px]");
|
expect(panel.className).toContain("w-[340px]");
|
||||||
|
expect(panel.getAttribute("data-variant")).toBe("desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a full-screen variant on mobile (< 768px)", () => {
|
||||||
|
mediaMatches = false;
|
||||||
|
installMatchMedia();
|
||||||
|
getMemory.mockResolvedValue({ companyId: "co-1", facts: [], updatedAt: null });
|
||||||
|
render(<MemorySheet open onClose={() => {}} companyId="co-1" />);
|
||||||
|
const panel = container.querySelector('[data-testid="memory-sheet-panel"]') as HTMLElement;
|
||||||
|
expect(panel.getAttribute("data-variant")).toBe("mobile");
|
||||||
|
expect(panel.className).toContain("w-full");
|
||||||
|
expect(panel.className).toContain("left-0");
|
||||||
|
expect(panel.className).toContain("right-0");
|
||||||
|
expect(panel.className).not.toContain("w-[340px]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useEffect, useState } from "react";
|
||||||
import { X, Plus, Trash2, Loader2 } from "lucide-react";
|
import { X, Plus, Trash2, Loader2 } from "lucide-react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { assistantMemoryApi, type AssistantMemory } from "../../api/assistantMemory";
|
import { assistantMemoryApi, type AssistantMemory } from "../../api/assistantMemory";
|
||||||
|
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface MemorySheetProps {
|
export interface MemorySheetProps {
|
||||||
|
|
@ -31,6 +32,11 @@ export function MemorySheet({ open, onClose, companyId, className }: MemorySheet
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Phase 15 — on mobile (`< 768px`), the sheet takes the full viewport
|
||||||
|
// width below the 48px top strip, rather than the 340px desktop
|
||||||
|
// right-side slide-over.
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const enabled = open && !!companyId;
|
const enabled = open && !!companyId;
|
||||||
|
|
||||||
|
|
@ -93,9 +99,12 @@ export function MemorySheet({ open, onClose, companyId, className }: MemorySheet
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Assistant memory"
|
aria-label="Assistant memory"
|
||||||
data-testid="memory-sheet-panel"
|
data-testid="memory-sheet-panel"
|
||||||
|
data-variant={isDesktop ? "desktop" : "mobile"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-12 bottom-0 right-0 z-50 w-[340px] flex flex-col",
|
"fixed z-50 flex flex-col bg-background",
|
||||||
"border-l border-border bg-background",
|
isDesktop
|
||||||
|
? "top-12 bottom-0 right-0 w-[340px] border-l border-border"
|
||||||
|
: "top-12 bottom-0 left-0 right-0 w-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
159
ui/src/components/frame/MobileTabBar.test.tsx
Normal file
159
ui/src/components/frame/MobileTabBar.test.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MemoryRouter } from "@/lib/router";
|
||||||
|
import { MobileTabBar } from "./MobileTabBar";
|
||||||
|
|
||||||
|
// The real @/lib/router Link wrapper calls useCompany(), which throws
|
||||||
|
// outside a CompanyProvider. The MobileTabBar also calls useCompany()
|
||||||
|
// directly to resolve the company prefix. Stub both with the same mock
|
||||||
|
// used by IconRail.test.tsx.
|
||||||
|
vi.mock("@/context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
companies: [],
|
||||||
|
selectedCompanyId: null,
|
||||||
|
selectedCompany: { id: "co-nex", issuePrefix: "NEX" },
|
||||||
|
selectionSource: "bootstrap" as const,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
setSelectedCompanyId: () => {},
|
||||||
|
reloadCompanies: async () => {},
|
||||||
|
createCompany: async () => {
|
||||||
|
throw new Error("not implemented in test stub");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("MobileTabBar", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root!.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
if (container.parentNode) {
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderBar(initialPath: string, visible = true) {
|
||||||
|
root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root!.render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<MobileTabBar visible={visible} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
getNav: () => container.querySelector("[data-testid='mobile-tab-bar']") as HTMLElement,
|
||||||
|
getLinkByLabel: (label: string) =>
|
||||||
|
container.querySelector(
|
||||||
|
`[data-testid='mobile-tab-bar'] a[aria-label='${label}']`,
|
||||||
|
) as HTMLAnchorElement | null,
|
||||||
|
getTabs: () =>
|
||||||
|
Array.from(
|
||||||
|
container.querySelectorAll<HTMLAnchorElement>(
|
||||||
|
"[data-testid='mobile-tab-bar'] a",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders a primary nav with four destination tabs", () => {
|
||||||
|
const { getNav, getTabs } = renderBar("/NEX/assistant");
|
||||||
|
const nav = getNav();
|
||||||
|
expect(nav).not.toBeNull();
|
||||||
|
expect(nav.getAttribute("aria-label")).toBe("Primary");
|
||||||
|
|
||||||
|
const labels = getTabs().map((a) => a.getAttribute("aria-label"));
|
||||||
|
expect(labels).toEqual(["Assistant", "Studio", "Projects", "Settings"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds company-prefixed URLs for the first three destinations", () => {
|
||||||
|
const { getLinkByLabel } = renderBar("/NEX/assistant");
|
||||||
|
expect(getLinkByLabel("Assistant")?.getAttribute("href")).toBe("/NEX/assistant");
|
||||||
|
expect(getLinkByLabel("Studio")?.getAttribute("href")).toBe("/NEX/content-studio");
|
||||||
|
expect(getLinkByLabel("Projects")?.getAttribute("href")).toBe("/NEX/projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points Settings at the global /instance/settings/general route", () => {
|
||||||
|
const { getLinkByLabel } = renderBar("/NEX/assistant");
|
||||||
|
expect(getLinkByLabel("Settings")?.getAttribute("href")).toBe("/instance/settings/general");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the Assistant tab as current on /NEX/assistant", () => {
|
||||||
|
const { getLinkByLabel } = renderBar("/NEX/assistant");
|
||||||
|
expect(getLinkByLabel("Assistant")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
expect(getLinkByLabel("Projects")?.getAttribute("aria-current")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the Projects tab as current on /NEX/issues", () => {
|
||||||
|
const { getLinkByLabel } = renderBar("/NEX/issues");
|
||||||
|
expect(getLinkByLabel("Projects")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
expect(getLinkByLabel("Assistant")?.getAttribute("aria-current")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the Studio tab as current on /NEX/convert", () => {
|
||||||
|
const { getLinkByLabel } = renderBar("/NEX/convert");
|
||||||
|
expect(getLinkByLabel("Studio")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the Settings tab as current on /instance/settings/general", () => {
|
||||||
|
const { getLinkByLabel } = renderBar("/instance/settings/general");
|
||||||
|
expect(getLinkByLabel("Settings")?.getAttribute("aria-current")).toBe("page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a 2px volt bar above the active tab icon", () => {
|
||||||
|
renderBar("/NEX/assistant");
|
||||||
|
const bar = container.querySelector(
|
||||||
|
"[data-testid='mobile-tab-assistant-bar']",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
expect(bar).not.toBeNull();
|
||||||
|
// Only the active destination should show the bar.
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='mobile-tab-studio-bar']"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(bar?.className).toContain("bg-primary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 56px height, top border, and safe-area bottom padding", () => {
|
||||||
|
const { getNav } = renderBar("/NEX/assistant");
|
||||||
|
const nav = getNav();
|
||||||
|
expect(nav.className).toContain("h-14");
|
||||||
|
expect(nav.className).toContain("border-t");
|
||||||
|
expect(nav.className).toContain("border-border");
|
||||||
|
expect(nav.className).toContain("bg-background");
|
||||||
|
expect(nav.className).toContain("pb-[env(safe-area-inset-bottom)]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is hidden on desktop via the md:hidden class", () => {
|
||||||
|
const { getNav } = renderBar("/NEX/assistant");
|
||||||
|
expect(getNav().className).toContain("md:hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slides off-screen when visible=false", () => {
|
||||||
|
const { getNav } = renderBar("/NEX/assistant", false);
|
||||||
|
expect(getNav().className).toContain("translate-y-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays in place when visible=true", () => {
|
||||||
|
const { getNav } = renderBar("/NEX/assistant", true);
|
||||||
|
expect(getNav().className).toContain("translate-y-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
160
ui/src/components/frame/MobileTabBar.tsx
Normal file
160
ui/src/components/frame/MobileTabBar.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// [nexus] Phase 15 — MobileTabBar.
|
||||||
|
//
|
||||||
|
// Replacement for the legacy `MobileBottomNav`. Renders 4 destination tabs
|
||||||
|
// at the bottom of the viewport on `< 768px` viewports, matching the four
|
||||||
|
// destinations surfaced by the desktop IconRail: Assistant, Studio,
|
||||||
|
// Projects, Settings. Silver default, volt active, with a 2px volt bar
|
||||||
|
// above the active icon and safe-area aware bottom padding.
|
||||||
|
//
|
||||||
|
// The destination-matching regexes are intentionally duplicated from
|
||||||
|
// IconRail rather than shared via a module: Phase 8 owns IconRail, and
|
||||||
|
// touching it inside Phase 15 is out of scope. Phase 16 cleanup can DRY
|
||||||
|
// the helpers into a shared `frame/destinations.ts`.
|
||||||
|
//
|
||||||
|
// Spec: docs/specs/2026-04-11-nexus-layout-overhaul.md §9 (Mobile).
|
||||||
|
import { MessageCircle, Sparkles, FolderKanban, Settings } from "lucide-react";
|
||||||
|
import { Link, useLocation } from "@/lib/router";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useCompany } from "../../context/CompanyContext";
|
||||||
|
|
||||||
|
interface MobileTabBarProps {
|
||||||
|
/**
|
||||||
|
* When false, the bar slides off-screen (translate-y-full). This mirrors
|
||||||
|
* the legacy MobileBottomNav scroll-hide behavior so Layout.tsx can keep
|
||||||
|
* its existing "hide on scroll down, show on scroll up" handler.
|
||||||
|
*/
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DestinationKey = "assistant" | "studio" | "projects" | "settings";
|
||||||
|
|
||||||
|
interface Destination {
|
||||||
|
key: DestinationKey;
|
||||||
|
label: string;
|
||||||
|
href: (prefix: string | null) => string;
|
||||||
|
isActive: (pathname: string) => boolean;
|
||||||
|
icon: typeof MessageCircle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regexes duplicated from IconRail intentionally — see file header.
|
||||||
|
const DESTINATIONS: Destination[] = [
|
||||||
|
{
|
||||||
|
key: "assistant",
|
||||||
|
label: "Assistant",
|
||||||
|
href: (prefix) => (prefix ? `/${prefix}/assistant` : "/"),
|
||||||
|
isActive: (pathname) => /\/assistant(\/|$)/.test(pathname),
|
||||||
|
icon: MessageCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "studio",
|
||||||
|
label: "Studio",
|
||||||
|
href: (prefix) => (prefix ? `/${prefix}/content-studio` : "/"),
|
||||||
|
isActive: (pathname) =>
|
||||||
|
/\/content-studio(\/|$)/.test(pathname) ||
|
||||||
|
/\/studio(\/|$)/.test(pathname) ||
|
||||||
|
/\/convert(\/|$)/.test(pathname),
|
||||||
|
icon: Sparkles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "projects",
|
||||||
|
label: "Projects",
|
||||||
|
href: (prefix) => (prefix ? `/${prefix}/projects` : "/"),
|
||||||
|
isActive: (pathname) =>
|
||||||
|
/\/projects(\/|$)/.test(pathname) ||
|
||||||
|
/\/issues(\/|$)/.test(pathname) ||
|
||||||
|
/\/agents(\/|$)/.test(pathname) ||
|
||||||
|
/\/routines(\/|$)/.test(pathname) ||
|
||||||
|
/\/goals(\/|$)/.test(pathname) ||
|
||||||
|
/\/approvals(\/|$)/.test(pathname) ||
|
||||||
|
/\/costs(\/|$)/.test(pathname) ||
|
||||||
|
/\/activity(\/|$)/.test(pathname) ||
|
||||||
|
/\/inbox(\/|$)/.test(pathname) ||
|
||||||
|
/\/execution-workspaces(\/|$)/.test(pathname),
|
||||||
|
icon: FolderKanban,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "settings",
|
||||||
|
label: "Settings",
|
||||||
|
href: () => "/instance/settings/general",
|
||||||
|
isActive: (pathname) => pathname.startsWith("/instance/settings"),
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MobileTabBar({ visible = true }: MobileTabBarProps) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
// Company-prefix scoping mirrors IconRail: the first three destinations
|
||||||
|
// route under the currently-selected workspace prefix, Settings is global.
|
||||||
|
const { selectedCompany } = useCompany();
|
||||||
|
const companyPrefix = selectedCompany?.issuePrefix ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Primary"
|
||||||
|
data-testid="mobile-tab-bar"
|
||||||
|
className={cn(
|
||||||
|
"fixed bottom-0 left-0 right-0 z-30 md:hidden",
|
||||||
|
"h-14 border-t border-border bg-background",
|
||||||
|
"pb-[env(safe-area-inset-bottom)]",
|
||||||
|
"transition-transform duration-200 ease-out",
|
||||||
|
visible ? "translate-y-0" : "translate-y-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ul className="grid h-full grid-cols-4">
|
||||||
|
{DESTINATIONS.map((dest) => (
|
||||||
|
<MobileTabLink
|
||||||
|
key={dest.key}
|
||||||
|
destination={dest}
|
||||||
|
companyPrefix={companyPrefix}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileTabLink({
|
||||||
|
destination,
|
||||||
|
companyPrefix,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
destination: Destination;
|
||||||
|
companyPrefix: string | null;
|
||||||
|
pathname: string;
|
||||||
|
}) {
|
||||||
|
const Icon = destination.icon;
|
||||||
|
const active = destination.isActive(pathname);
|
||||||
|
const href = destination.href(companyPrefix);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="relative flex items-stretch">
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
aria-label={destination.label}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
data-testid={`mobile-tab-${destination.key}`}
|
||||||
|
data-active={active ? "true" : "false"}
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-w-0 flex-1 flex-col items-center justify-center gap-1",
|
||||||
|
"transition-colors duration-100 ease-out",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset",
|
||||||
|
active ? "text-primary" : "text-muted-foreground hover:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 2px volt bar above the icon when active — spec §9.1 */}
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid={`mobile-tab-${destination.key}-bar`}
|
||||||
|
className="absolute left-1/2 top-0 h-[2px] w-6 -translate-x-1/2 bg-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] leading-none">
|
||||||
|
{destination.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,12 +14,17 @@ export function TopStrip() {
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
aria-label="Top bar"
|
aria-label="Top bar"
|
||||||
className="sticky top-0 z-30 flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-6"
|
// Phase 15 mobile: tighter horizontal padding and cluster gap so
|
||||||
|
// the breadcrumb + actions still fit on a 360px viewport. Breadcrumb
|
||||||
|
// is allowed to truncate rather than hide.
|
||||||
|
className="sticky top-0 z-30 flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border bg-background px-3 md:px-6"
|
||||||
>
|
>
|
||||||
<ModeBreadcrumb />
|
<div className="min-w-0 flex-1 truncate">
|
||||||
<div className="flex items-center gap-4">
|
<ModeBreadcrumb />
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 md:gap-4">
|
||||||
<CmdKButton />
|
<CmdKButton />
|
||||||
<GlobalMicButton state="idle" />
|
<GlobalMicButton />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -187,4 +187,28 @@ describe("BuilderTabStrip", () => {
|
||||||
expect(nav?.className).toContain("gap-6");
|
expect(nav?.className).toContain("gap-6");
|
||||||
expect(nav?.className).toContain("pl-6");
|
expect(nav?.className).toContain("pl-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports horizontal scroll with scroll-snap on mobile (Phase 15)", () => {
|
||||||
|
// The mobile treatment is expressed as Tailwind classes on the strip
|
||||||
|
// itself; we verify the classes are present rather than measuring
|
||||||
|
// scrollWidth (jsdom does not lay out flex children).
|
||||||
|
renderStrip({
|
||||||
|
projectRef: "abc",
|
||||||
|
activeTab: "overview",
|
||||||
|
hasMultipleAgents: true,
|
||||||
|
});
|
||||||
|
const nav = container.querySelector<HTMLElement>('[data-testid="builder-tab-strip"]');
|
||||||
|
// Horizontal scroll enabled on mobile, reset on md+.
|
||||||
|
expect(nav?.className).toContain("overflow-x-auto");
|
||||||
|
expect(nav?.className).toContain("md:overflow-visible");
|
||||||
|
// Scroll snap per tab on mobile.
|
||||||
|
expect(nav?.className).toContain("snap-x");
|
||||||
|
expect(nav?.className).toContain("snap-mandatory");
|
||||||
|
// Tabs do not shrink so they stay fixed width while the container scrolls.
|
||||||
|
const firstTab = container.querySelector<HTMLElement>(
|
||||||
|
"[data-testid='builder-tab-overview']",
|
||||||
|
);
|
||||||
|
expect(firstTab?.className).toContain("shrink-0");
|
||||||
|
expect(firstTab?.className).toContain("snap-start");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,9 @@ function BuilderTabLink({
|
||||||
// Base type: uppercase 14px Inter 600 tracking-[0.1em]
|
// Base type: uppercase 14px Inter 600 tracking-[0.1em]
|
||||||
"relative inline-flex h-10 items-center text-[14px] font-semibold uppercase tracking-[0.1em]",
|
"relative inline-flex h-10 items-center text-[14px] font-semibold uppercase tracking-[0.1em]",
|
||||||
"no-underline transition-colors",
|
"no-underline transition-colors",
|
||||||
|
// Mobile (Phase 15): do not shrink tabs and snap each tab to the
|
||||||
|
// scroll container's left edge for a satisfying swipe feel.
|
||||||
|
"shrink-0 snap-start",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
// Active: volt text + 2px volt bottom border
|
// Active: volt text + 2px volt bottom border
|
||||||
active
|
active
|
||||||
|
|
@ -144,7 +147,22 @@ export function BuilderTabStrip({
|
||||||
<nav
|
<nav
|
||||||
data-testid="builder-tab-strip"
|
data-testid="builder-tab-strip"
|
||||||
aria-label="Project builder tabs"
|
aria-label="Project builder tabs"
|
||||||
className="flex h-10 items-center gap-6 border-b border-border pl-6"
|
// Phase 11 desktop: flex row with 24px gap and 24px left padding.
|
||||||
|
// Phase 15 mobile (< 768px): horizontally scrollable strip with
|
||||||
|
// scroll-snap so users can see all 7 tabs without shrinking them.
|
||||||
|
// No shrinking — `shrink-0` on the children keeps tab widths stable.
|
||||||
|
// Edge fade is applied via a mask-image so the scroll affordance is
|
||||||
|
// visible without an extra overlay element.
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 items-center gap-6 border-b border-border pl-6",
|
||||||
|
"overflow-x-auto md:overflow-visible",
|
||||||
|
"snap-x snap-mandatory md:snap-none",
|
||||||
|
"scrollbar-none",
|
||||||
|
// Edge fade — visible on mobile where horizontal scroll applies.
|
||||||
|
// On desktop the mask is disabled via md:[mask-image:none].
|
||||||
|
"[mask-image:linear-gradient(to_right,transparent,black_16px,black_calc(100%-24px),transparent)]",
|
||||||
|
"md:[mask-image:none]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<BuilderTabLink
|
<BuilderTabLink
|
||||||
|
|
@ -154,6 +172,8 @@ export function BuilderTabStrip({
|
||||||
active={tab === activeTab}
|
active={tab === activeTab}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Trailing spacer so the last tab fully clears the edge fade on mobile. */}
|
||||||
|
<span aria-hidden="true" className="shrink-0 pr-6 md:hidden" />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue