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 { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { IconRail } from "./frame/IconRail";
|
||||
import { MobileTabBar } from "./frame/MobileTabBar";
|
||||
import { TopStrip } from "./frame/TopStrip";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
|
|
@ -192,7 +192,7 @@ export function Layout() {
|
|||
tabIndex={-1}
|
||||
className={cn(
|
||||
"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 ? (
|
||||
|
|
@ -221,7 +221,7 @@ export function Layout() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
||||
{isMobile && <MobileTabBar visible={mobileNavVisible} />}
|
||||
|
||||
<CommandPalette />
|
||||
<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";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -24,6 +46,8 @@ describe("<HistorySheet />", () => {
|
|||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
mediaMatches = true;
|
||||
installMatchMedia();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
|
|
@ -107,5 +131,19 @@ describe("<HistorySheet />", () => {
|
|||
// The Tailwind class string should include left-[56px] and w-[320px].
|
||||
expect(panel.className).toContain("left-[56px]");
|
||||
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 { X } from "lucide-react";
|
||||
import { ChatConversationList } from "../ChatConversationList";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface HistorySheetProps {
|
||||
|
|
@ -29,6 +30,11 @@ export function HistorySheet({ open, onClose, companyId, className }: HistoryShe
|
|||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
|
|
@ -47,9 +53,12 @@ export function HistorySheet({ open, onClose, companyId, className }: HistoryShe
|
|||
aria-modal="true"
|
||||
aria-label="Conversation history"
|
||||
data-testid="history-sheet-panel"
|
||||
data-variant={isDesktop ? "desktop" : "mobile"}
|
||||
className={cn(
|
||||
"fixed top-12 bottom-0 left-[56px] z-50 w-[320px] flex flex-col",
|
||||
"border-r border-border bg-background",
|
||||
"fixed z-50 flex flex-col 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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -31,6 +53,8 @@ describe("<MemorySheet />", () => {
|
|||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mediaMatches = true;
|
||||
installMatchMedia();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
|
|
@ -136,5 +160,19 @@ describe("<MemorySheet />", () => {
|
|||
const panel = container.querySelector('[data-testid="memory-sheet-panel"]') as HTMLElement;
|
||||
expect(panel.className).toContain("right-0");
|
||||
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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { assistantMemoryApi, type AssistantMemory } from "../../api/assistantMemory";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MemorySheetProps {
|
||||
|
|
@ -31,6 +32,11 @@ export function MemorySheet({ open, onClose, companyId, className }: MemorySheet
|
|||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [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 enabled = open && !!companyId;
|
||||
|
||||
|
|
@ -93,9 +99,12 @@ export function MemorySheet({ open, onClose, companyId, className }: MemorySheet
|
|||
aria-modal="true"
|
||||
aria-label="Assistant memory"
|
||||
data-testid="memory-sheet-panel"
|
||||
data-variant={isDesktop ? "desktop" : "mobile"}
|
||||
className={cn(
|
||||
"fixed top-12 bottom-0 right-0 z-50 w-[340px] flex flex-col",
|
||||
"border-l border-border bg-background",
|
||||
"fixed z-50 flex flex-col 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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
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 (
|
||||
<header
|
||||
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="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
<ModeBreadcrumb />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 md:gap-4">
|
||||
<CmdKButton />
|
||||
<GlobalMicButton state="idle" />
|
||||
<GlobalMicButton />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -187,4 +187,28 @@ describe("BuilderTabStrip", () => {
|
|||
expect(nav?.className).toContain("gap-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]
|
||||
"relative inline-flex h-10 items-center text-[14px] font-semibold uppercase tracking-[0.1em]",
|
||||
"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",
|
||||
// Active: volt text + 2px volt bottom border
|
||||
active
|
||||
|
|
@ -144,7 +147,22 @@ export function BuilderTabStrip({
|
|||
<nav
|
||||
data-testid="builder-tab-strip"
|
||||
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) => (
|
||||
<BuilderTabLink
|
||||
|
|
@ -154,6 +172,8 @@ export function BuilderTabStrip({
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue