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:
Nexus Dev 2026-04-11 13:21:28 +00:00
parent f41690ff30
commit 4623c8aea0
11 changed files with 474 additions and 135 deletions

View file

@ -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 />

View file

@ -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>
);
}

View file

@ -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]");
});
});

View file

@ -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,
)}
>

View file

@ -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]");
});
});

View file

@ -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,
)}
>

View 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");
});
});

View 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>
);
}

View file

@ -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>
);

View file

@ -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");
});
});

View file

@ -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>
);
}