From 4623c8aea050e592dd24b0a726d7ee7918db475c Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 13:21:28 +0000 Subject: [PATCH] feat(nexus): add mobile tab bar and sheet variants (phase 15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/src/components/Layout.tsx | 6 +- ui/src/components/MobileBottomNav.tsx | 123 -------------- .../assistant/HistorySheet.test.tsx | 38 +++++ ui/src/components/assistant/HistorySheet.tsx | 13 +- .../components/assistant/MemorySheet.test.tsx | 38 +++++ ui/src/components/assistant/MemorySheet.tsx | 13 +- ui/src/components/frame/MobileTabBar.test.tsx | 159 +++++++++++++++++ ui/src/components/frame/MobileTabBar.tsx | 160 ++++++++++++++++++ ui/src/components/frame/TopStrip.tsx | 13 +- .../projects/BuilderTabStrip.test.tsx | 24 +++ .../components/projects/BuilderTabStrip.tsx | 22 ++- 11 files changed, 474 insertions(+), 135 deletions(-) delete mode 100644 ui/src/components/MobileBottomNav.tsx create mode 100644 ui/src/components/frame/MobileTabBar.test.tsx create mode 100644 ui/src/components/frame/MobileTabBar.tsx diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index dbd69a5e..67faee65 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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() { - {isMobile && } + {isMobile && } diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx deleted file mode 100644 index daa17318..00000000 --- a/ui/src/components/MobileBottomNav.tsx +++ /dev/null @@ -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( - () => [ - { 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 ( - - ); -} diff --git a/ui/src/components/assistant/HistorySheet.test.tsx b/ui/src/components/assistant/HistorySheet.test.tsx index 26f6d098..184b8906 100644 --- a/ui/src/components/assistant/HistorySheet.test.tsx +++ b/ui/src/components/assistant/HistorySheet.test.tsx @@ -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("", () => { let root: ReturnType | null = null; beforeEach(() => { + mediaMatches = true; + installMatchMedia(); container = document.createElement("div"); document.body.appendChild(container); root = null; @@ -107,5 +131,19 @@ describe("", () => { // 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( {}} 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]"); }); }); diff --git a/ui/src/components/assistant/HistorySheet.tsx b/ui/src/components/assistant/HistorySheet.tsx index bf61f67a..93699cea 100644 --- a/ui/src/components/assistant/HistorySheet.tsx +++ b/ui/src/components/assistant/HistorySheet.tsx @@ -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, )} > diff --git a/ui/src/components/assistant/MemorySheet.test.tsx b/ui/src/components/assistant/MemorySheet.test.tsx index 9f0071d9..6434595c 100644 --- a/ui/src/components/assistant/MemorySheet.test.tsx +++ b/ui/src/components/assistant/MemorySheet.test.tsx @@ -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("", () => { let queryClient: QueryClient; beforeEach(() => { + mediaMatches = true; + installMatchMedia(); container = document.createElement("div"); document.body.appendChild(container); root = null; @@ -136,5 +160,19 @@ describe("", () => { 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( {}} 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]"); }); }); diff --git a/ui/src/components/assistant/MemorySheet.tsx b/ui/src/components/assistant/MemorySheet.tsx index 49f349f9..b92fde1e 100644 --- a/ui/src/components/assistant/MemorySheet.tsx +++ b/ui/src/components/assistant/MemorySheet.tsx @@ -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, )} > diff --git a/ui/src/components/frame/MobileTabBar.test.tsx b/ui/src/components/frame/MobileTabBar.test.tsx new file mode 100644 index 00000000..2c91bbc5 --- /dev/null +++ b/ui/src/components/frame/MobileTabBar.test.tsx @@ -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 | 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( + + + , + ); + }); + 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( + "[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"); + }); +}); diff --git a/ui/src/components/frame/MobileTabBar.tsx b/ui/src/components/frame/MobileTabBar.tsx new file mode 100644 index 00000000..0fb073ee --- /dev/null +++ b/ui/src/components/frame/MobileTabBar.tsx @@ -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 ( + + ); +} + +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 ( +
  • + + {/* 2px volt bar above the icon when active — spec §9.1 */} + {active && ( +
  • + ); +} diff --git a/ui/src/components/frame/TopStrip.tsx b/ui/src/components/frame/TopStrip.tsx index 3ebe5193..245f8806 100644 --- a/ui/src/components/frame/TopStrip.tsx +++ b/ui/src/components/frame/TopStrip.tsx @@ -14,12 +14,17 @@ export function TopStrip() { return (
    - -
    +
    + +
    +
    - +
    ); diff --git a/ui/src/components/projects/BuilderTabStrip.test.tsx b/ui/src/components/projects/BuilderTabStrip.test.tsx index ea6c9dbf..691e5ea0 100644 --- a/ui/src/components/projects/BuilderTabStrip.test.tsx +++ b/ui/src/components/projects/BuilderTabStrip.test.tsx @@ -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('[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( + "[data-testid='builder-tab-overview']", + ); + expect(firstTab?.className).toContain("shrink-0"); + expect(firstTab?.className).toContain("snap-start"); + }); }); diff --git a/ui/src/components/projects/BuilderTabStrip.tsx b/ui/src/components/projects/BuilderTabStrip.tsx index 0be5a015..deb99fd6 100644 --- a/ui/src/components/projects/BuilderTabStrip.tsx +++ b/ui/src/components/projects/BuilderTabStrip.tsx @@ -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({ ); }