32 KiB
Phase 27: Top Nav Restructure & Search Bar Rethink - Research
Researched: 2026-04-10 Domain: React navigation restructure, TanStack Router file-based routing, Tailwind CSS v4 responsive layout, Framer Motion animations Confidence: HIGH
Summary
This phase replaces the minimal TotalsBar (54 lines, logo + user menu only) with a full persistent navigation bar, adds a mobile bottom tab bar, removes the landing page hero section, and elevates Setups to a top-level route. All the required building blocks already exist in the codebase: UserMenu, AuthPromptModal, CatalogSearchOverlay, FabMenu, and LucideIcon. No new libraries are needed.
The core technical challenge is the conditional routing behavior: nav links are visible to anonymous users, but clicking Collection or Setups while anonymous must intercept navigation and fire openAuthPrompt() from uiStore instead of calling navigate(). TanStack Router's <Link> component does not support onClick preventDefault-style interception in a clean way — the pattern is to render a <button> styled as a link that calls openAuthPrompt() for anon users, or use <Link> with an onClick that short-circuits navigation.
The setups route currently has no index page — src/client/routes/setups/ contains only $setupId.tsx. A new src/client/routes/setups/index.tsx must be created, which simply renders <SetupsView> (the component already exists at src/client/components/SetupsView.tsx). The collection route must drop the "setups" tab from its TAB_ORDER and TAB_LABELS constants and update the Zod search schema to remove "setups" as a valid enum value.
Primary recommendation: Build a single TopNav.tsx component to replace TotalsBar in __root.tsx, and a BottomTabBar.tsx for mobile. Both live in src/client/components/. Use Tailwind md: breakpoint to switch between them. Add a setups/index.tsx route. Surgical edits to collection/index.tsx and routes/index.tsx.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: Persistent top nav bar replaces the current
TotalsBar. Contains: logo ("GearBox" with package icon), section links (Home, Collection, Setups), a catalog search bar, and user avatar (authenticated) or "Sign in" link (anonymous). - D-02: All nav links are visible to both authenticated and anonymous users. Clicking Collection or Setups while anonymous triggers the existing
AuthPromptModalinstead of navigating. - D-03: Active section is visually indicated in the nav (current page highlighting).
- D-04: Setups is elevated to a top-level nav section with its own route. It is no longer a tab inside Collection.
- D-05: Collection page keeps pill tab navigation but drops to two tabs: Gear and Planning. The Setups tab is removed from Collection.
- D-06: Threads (Planning) remain nested inside Collection — not elevated to top-level.
- D-07: The nav bar includes a persistent search input/button that always triggers global catalog search via the existing
CatalogSearchOverlay, regardless of which page the user is on. - D-08: Collection and Setups pages retain their existing inline search/filter inputs for local filtering. The nav search bar is always catalog-global.
- D-09: The landing page hero section (heading, subtitle, search bar, "Go to Collection" link) is removed entirely. The nav search bar replaces it as the catalog search entry point.
- D-10: With the hero removed, the landing page starts directly with content sections: Popular Setups, Recently Added Items, Trending Categories. No introductory text or hero area.
- D-11: The "Go to Collection" link from the hero is no longer needed — Collection is now a persistent nav link.
- D-12: On mobile (narrow screens), the top bar shows only the logo and user avatar/sign-in.
- D-13: Navigation moves to a fixed bottom tab bar with 4 items: Home, Collection, Setups, Search. Each uses a Lucide icon with a short label below.
- D-14: Tapping the Search tab icon opens the
CatalogSearchOverlay. - D-15: The bottom tab bar replaces the FAB on mobile — the FAB is hidden when the bottom tab bar is visible (search and add-to-collection flows are now accessible via the tab bar and overlay).
- D-16: No changes to the
CatalogSearchOverlayUI or behavior. Same full-page takeover below the nav bar. Same tag filtering, grid/list toggle, weight/price range filters, manual entry fallback. - D-17: The overlay is now triggered from the nav search bar (desktop) or bottom tab bar search icon (mobile) instead of from the landing page hero or FAB menu.
Claude's Discretion
- Exact responsive breakpoint for switching between top nav and bottom tab bar
- Nav link styling (text links, pill buttons, underline indicators)
- Search bar appearance in nav (full input field vs compact icon that expands)
- Bottom tab bar icon choices (specific Lucide icons for each section)
- Animation for bottom tab bar / overlay transitions
- Whether the "GearBox" logo text is hidden on mobile top bar to save space
- FAB behavior on desktop (keep as-is or consolidate into nav)
Deferred Ideas (OUT OF SCOPE)
- Blended local+global search — When searching from Collection, show local gear first then global catalog results. Future phase.
- Setup page redesign — Revisit the Setups page layout. Backlog item.
- Add manufacturer entity with brand details
- Fix item image not showing on collection overview
- Add cursor pointer to all clickable links
- Investigate slow image loading
- Fix storage service tests </user_constraints>
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| @tanstack/react-router | ^1.167.0 | File-based routing, <Link>, useLocation, useMatchRoute |
Already in use; active route detection built-in |
| framer-motion | ^12.38.0 | Bottom tab bar entry animation, tab transitions | Already in use throughout app |
| tailwindcss | ^4.2.1 | Responsive layout (md:hidden, md:flex) |
Already in use; v4 in project |
| zustand | ^5.0.11 | openCatalogSearch(), openAuthPrompt() from uiStore |
Already controls all overlay state |
| lucide-react | ^0.577.0 | Bottom tab bar icons via LucideIcon wrapper |
Already the app icon system |
Supporting
No new packages required. All needed tools are already installed.
Installation: No new packages needed.
Architecture Patterns
Recommended Project Structure Changes
src/client/
├── components/
│ ├── TopNav.tsx # NEW — replaces TotalsBar (desktop nav bar)
│ ├── BottomTabBar.tsx # NEW — mobile fixed bottom tab bar
│ ├── TotalsBar.tsx # DELETED — replaced by TopNav
│ ├── FabMenu.tsx # MODIFIED — hidden on mobile (md:block only)
│ └── ...existing...
├── routes/
│ ├── __root.tsx # MODIFIED — swap TotalsBar for TopNav, add BottomTabBar
│ ├── index.tsx # MODIFIED — remove HeroSection
│ ├── collection/
│ │ └── index.tsx # MODIFIED — drop "setups" tab
│ └── setups/
│ ├── index.tsx # NEW — renders SetupsView
│ └── $setupId.tsx # unchanged
Pattern 1: Active Route Detection with useMatchRoute
TanStack Router's useMatchRoute hook returns a truthy match object when the current route matches. Use it to drive active link styling in TopNav.
// In TopNav.tsx
const matchRoute = useMatchRoute();
const isHome = !!matchRoute({ to: "/" });
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
const isSetups = !!matchRoute({ to: "/setups", fuzzy: true });
This pattern is already used in __root.tsx (see isDashboard, isSetupsPage). Confidence: HIGH (source: existing codebase).
Pattern 2: Anonymous Nav Link with AuthPrompt Interception
For Collection and Setups nav links, render them differently based on auth state. For anonymous users, an onClick prevents navigation and fires the modal instead.
// In TopNav.tsx
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
// For each protected nav link:
{isAuthenticated ? (
<Link to="/collection" className={linkClass(isCollection)}>Collection</Link>
) : (
<button
type="button"
onClick={openAuthPrompt}
className={linkClass(false)}
>
Collection
</button>
)}
AuthPromptModal already subscribes to showAuthPrompt from uiStore and renders itself. No props needed. Confidence: HIGH (source: existing AuthPromptModal.tsx, uiStore.ts).
Pattern 3: Desktop/Mobile Layout Split with Tailwind
The breakpoint choice (Claude's discretion) should be md (768px) based on the existing app pattern (sm:px-6 lg:px-8 used throughout). Desktop: top nav links + search visible. Mobile: only logo + avatar in top bar, nav in bottom tab bar.
// TopNav: hide nav links and search on mobile
<nav className="hidden md:flex items-center gap-6">
{/* nav links */}
</nav>
<div className="hidden md:flex ...">
{/* search bar */}
</div>
// BottomTabBar: only show on mobile
<div className="fixed bottom-0 left-0 right-0 md:hidden z-20 ...">
{/* tab items */}
</div>
Confidence: HIGH (source: existing Tailwind patterns in codebase).
Pattern 4: FAB Hidden on Mobile
In __root.tsx, the showFab condition already gates FAB rendering. Add a CSS class to limit it to md: and above:
// FabMenu: add className prop or wrap in __root.tsx
{showFab && (
<div className="hidden md:block">
<FabMenu isSetupsPage={isSetupsPage} />
</div>
)}
Alternatively, add className support to FabMenu to accept hidden md:block. Confidence: HIGH.
Pattern 5: New setups/index.tsx Route
The setups/ directory has only $setupId.tsx. Create setups/index.tsx to make /setups a valid TanStack Router route. Render <SetupsView> directly (the component already exists). The route is currently public (no auth wall at the route level — SetupsView handles auth for mutations). The isPublicRoute check in __root.tsx does NOT include /setups (only /setups/), so adding /setups as a nav destination for anonymous users will require adding it to isPublicRoute OR relying on the AuthPromptModal pattern (D-02) where anonymous users never reach the route.
// src/client/routes/setups/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { SetupsView } from "../../components/SetupsView";
export const Route = createFileRoute("/setups/")({
component: SetupsPage,
});
function SetupsPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<SetupsView />
</div>
);
}
Confidence: HIGH (source: existing route pattern in collection/index.tsx).
Pattern 6: Collection Tab Simplification
Remove "setups" from TAB_ORDER and TAB_LABELS in collection/index.tsx. Update the Zod search schema catch default. Any existing bookmarked URLs with ?tab=setups will gracefully fall through to the catch("gear") default.
const TAB_ORDER = ["gear", "planning"] as const;
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
gear: "Gear",
planning: "Planning",
};
const searchSchema = z.object({
tab: z.enum(["gear", "planning"]).catch("gear"),
});
Confidence: HIGH.
Pattern 7: Search Bar in TopNav (Desktop)
The search bar in the nav should be a clickable element that calls openCatalogSearch("collection"). Use the same clickable div pattern from the existing HeroSection:
// In TopNav.tsx
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
<div
onClick={() => openCatalogSearch("collection")}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && openCatalogSearch("collection")}
className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg cursor-pointer hover:border-gray-300 transition-all"
>
<LucideIcon name="search" size={16} className="text-gray-400" />
<span className="text-sm text-gray-400 hidden lg:block">Search catalog...</span>
</div>
Confidence: HIGH (source: existing index.tsx HeroSection pattern and uiStore.ts).
Pattern 8: Bottom Tab Bar with Framer Motion
// BottomTabBar.tsx
import { AnimatePresence, motion } from "framer-motion";
// Simple entry animation on mount
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed bottom-0 left-0 right-0 md:hidden z-20 bg-white border-t border-gray-100 pb-safe"
>
Icons (Claude's discretion — recommendations):
- Home:
home - Collection:
package - Setups:
layers(orbriefcase) - Search:
search
Each tab has icon + label, active state highlighted with text-gray-900 vs text-gray-400. Confidence: HIGH (source: existing framer-motion usage, LucideIcon wrapper).
Anti-Patterns to Avoid
- Using
<Link>withe.preventDefault()for auth interception: TanStack Router links fire navigation before React event handlers can intercept reliably. Use conditional render (Link vs button) instead. - Putting auth logic in the setups route loader: D-02 says anon users see the nav link but get the auth modal when clicking. The route itself should be reachable (for future public setups browsing). Gate creation/edit actions in the page, not the route guard.
- Importing
lucide-reacticons directly: The project pattern uses<LucideIcon name="..." />viasrc/client/lib/iconData.tsx. Never import fromlucide-reactdirectly in components. - Duplicating search trigger logic: There is one
openCatalogSearch()function in uiStore. Both the desktop nav search and mobile bottom tab bar Search icon call the same function. Don't create a second overlay or a second state. - Editing
routeTree.gen.tsmanually: It is auto-generated by TanStack Router. Addingsetups/index.tsxwill auto-update it on nextbun run dev.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Active route detection | Custom location.pathname.startsWith() checks |
useMatchRoute from @tanstack/react-router |
Already used in __root.tsx; handles nested routes correctly |
| Auth-gated nav | Custom auth middleware or route guards | Conditional render (Link vs button) + openAuthPrompt() |
AuthPromptModal already exists and is wired to uiStore |
| Search overlay trigger | New search overlay or new state | openCatalogSearch("collection") from uiStore |
Overlay already exists at CatalogSearchOverlay.tsx (849 lines — no changes needed) |
| Icon rendering | Direct SVG or lucide-react imports |
LucideIcon from lib/iconData.tsx |
Project convention; ensures curated icon set consistency |
| Mobile nav animations | CSS transitions | Framer Motion (already installed) | Consistent with existing animation patterns in FabMenu |
Key insight: Every primitive needed (auth state, overlay state, icon system, animation library, modal components) already exists. This phase is pure composition and restructuring — zero new dependencies.
Common Pitfalls
Pitfall 1: setups/index.tsx Route Not Recognized
What goes wrong: Creating setups/index.tsx but the route tree auto-generation hasn't run, leaving /setups as a 404 during development.
Why it happens: TanStack Router generates routeTree.gen.ts at build/dev startup. File creation during a running dev server may not immediately trigger regeneration depending on Vite config.
How to avoid: Restart the dev server after creating setups/index.tsx. Verify the new route appears in routeTree.gen.ts.
Warning signs: Console error "Route not found: /setups" or blank page at /setups.
Pitfall 2: isPublicRoute Check Missing /setups
What goes wrong: Anonymous users trigger the AuthPromptModal when clicking Setups nav, but if they somehow navigate directly to /setups (e.g., back button after login), __root.tsx redirects them to /login because /setups is not in isPublicRoute.
Why it happens: The current isPublicRoute check only includes /setups/ (with trailing slash, for setup detail pages). The new /setups index is not covered.
How to avoid: Add location.pathname === "/setups" to the isPublicRoute check in __root.tsx, OR restrict the Setups nav link to authenticated users only (contradicts D-02). Given D-02 says all links are visible to anon users (but trigger AuthPromptModal), the safest approach is to keep the auth interception at the nav level AND make /setups a public route so direct navigation doesn't hard-redirect.
Warning signs: Anon user clicks Setups, modal appears, logs in, back-navigates, gets sent to /login again.
Pitfall 3: FAB Bottom Position Conflicts with Bottom Tab Bar
What goes wrong: On mobile, the FAB (bottom-6 right-6) overlaps with the bottom tab bar if both are visible simultaneously.
Why it happens: D-15 says FAB is hidden when bottom tab bar is visible. If the hidden md:block wrapper is applied incorrectly or forgotten, both render.
How to avoid: In __root.tsx, wrap <FabMenu> with <div className="hidden md:block">. Verify on mobile viewport that FAB is gone.
Warning signs: FAB overlapping tab bar on narrow screens.
Pitfall 4: CatalogSearchOverlay z-index Fighting Bottom Tab Bar
What goes wrong: The CatalogSearchOverlay renders below the bottom tab bar, making the tab bar visible on top of the search overlay.
Why it happens: CatalogSearchOverlay and BottomTabBar both use high z-index. Current overlay z-index needs checking.
How to avoid: Ensure BottomTabBar uses z-20 and CatalogSearchOverlay uses z-30 or higher. The overlay already uses fixed inset-0 — verify its z-index is above the tab bar.
Warning signs: Bottom tab bar visible when search overlay is open.
Pitfall 5: Collection URL with ?tab=setups Breaks After Tab Removal
What goes wrong: Existing links, bookmarks, or tests that reference /collection?tab=setups stop working after the tab is removed.
Why it happens: The Zod catch("gear") will handle it gracefully in the router (redirects to gear tab), but E2E tests may assert on the old three-tab structure.
How to avoid: Update e2e/dashboard.spec.ts and e2e/collection.spec.ts — the existing tests assert on "Collection, Planning, and Setups card headings" and the old tab structure. Update or remove those assertions.
Warning signs: E2E test failures asserting Setups tab inside Collection.
Pitfall 6: useAuth() During SSR / Hydration Flash
What goes wrong: Nav renders "Sign in" on first paint even for authenticated users, causing a flash.
Why it happens: useAuth() is async (React Query). auth.isLoading is true on first render. The existing TotalsBar has this same behavior — it's acceptable in this app.
How to avoid: Match existing TotalsBar behavior. Don't add special hydration handling unless this becomes a visible problem. The flash is consistent with current UX.
Warning signs: Nav flickers from "Sign in" to avatar on page load. Acceptable if it matches current behavior.
Code Examples
TopNav.tsx skeleton (desktop + mobile top bar)
// src/client/components/TopNav.tsx
// Source: derived from TotalsBar.tsx pattern + __root.tsx matchRoute pattern
import { Link, useMatchRoute } from "@tanstack/react-router";
import { useAuth } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { UserMenu } from "./UserMenu";
export function TopNav() {
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
const matchRoute = useMatchRoute();
const isHome = !!matchRoute({ to: "/" });
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
const isSetups = !!matchRoute({ to: "/setups", fuzzy: true });
function navLinkClass(active: boolean) {
return `text-sm font-medium transition-colors ${
active ? "text-gray-900" : "text-gray-500 hover:text-gray-700"
}`;
}
function NavLinkOrButton({
label,
to,
active,
isProtected,
}: {
label: string;
to: string;
active: boolean;
isProtected: boolean;
}) {
if (isProtected && !isAuthenticated) {
return (
<button
type="button"
onClick={openAuthPrompt}
className={navLinkClass(false)}
>
{label}
</button>
);
}
return (
<Link to={to} className={navLinkClass(active)}>
{label}
</Link>
);
}
return (
<div className="sticky top-0 z-10 bg-white border-b border-gray-100">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
{/* Logo */}
<Link
to="/"
className="flex items-center gap-2 text-lg font-semibold text-gray-900 hover:text-gray-600 transition-colors"
>
<LucideIcon name="package" size={20} className="text-gray-500" />
<span>GearBox</span>
</Link>
{/* Desktop nav links (hidden on mobile) */}
<nav className="hidden md:flex items-center gap-6">
<Link to="/" className={navLinkClass(isHome)}>Home</Link>
<NavLinkOrButton label="Collection" to="/collection" active={isCollection} isProtected />
<NavLinkOrButton label="Setups" to="/setups" active={isSetups} isProtected />
</nav>
{/* Desktop search + user (hidden on mobile, user avatar shown on mobile) */}
<div className="flex items-center gap-3">
{/* Search bar — desktop only */}
<div
onClick={() => openCatalogSearch("collection")}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && openCatalogSearch("collection")}
className="hidden md:flex items-center gap-2 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg cursor-pointer hover:border-gray-300 transition-all"
>
<LucideIcon name="search" size={16} className="text-gray-400" />
<span className="text-sm text-gray-400 hidden lg:inline">Search catalog...</span>
</div>
{/* User menu / sign-in */}
{isAuthenticated ? (
<UserMenu />
) : (
<Link to="/login" className="text-xs text-gray-500 hover:text-gray-700 transition-colors">
Sign in
</Link>
)}
</div>
</div>
</div>
</div>
);
}
BottomTabBar.tsx skeleton
// src/client/components/BottomTabBar.tsx
// Source: FabMenu.tsx framer-motion pattern + uiStore
import { Link, useMatchRoute } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { useAuth } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
export function BottomTabBar() {
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
const matchRoute = useMatchRoute();
const isHome = !!matchRoute({ to: "/" });
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
const isSetups = !!matchRoute({ to: "/setups", fuzzy: true });
const tabClass = (active: boolean) =>
`flex flex-col items-center gap-0.5 py-2 px-3 text-xs font-medium transition-colors ${
active ? "text-gray-900" : "text-gray-400"
}`;
return (
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed bottom-0 left-0 right-0 md:hidden z-20 bg-white border-t border-gray-100"
>
<div className="flex justify-around items-center">
<Link to="/" className={tabClass(isHome)}>
<LucideIcon name="home" size={20} />
<span>Home</span>
</Link>
{isAuthenticated ? (
<Link to="/collection" className={tabClass(isCollection)}>
<LucideIcon name="package" size={20} />
<span>Collection</span>
</Link>
) : (
<button type="button" onClick={openAuthPrompt} className={tabClass(false)}>
<LucideIcon name="package" size={20} />
<span>Collection</span>
</button>
)}
{isAuthenticated ? (
<Link to="/setups" className={tabClass(isSetups)}>
<LucideIcon name="layers" size={20} />
<span>Setups</span>
</Link>
) : (
<button type="button" onClick={openAuthPrompt} className={tabClass(false)}>
<LucideIcon name="layers" size={20} />
<span>Setups</span>
</button>
)}
<button
type="button"
onClick={() => openCatalogSearch("collection")}
className={tabClass(false)}
>
<LucideIcon name="search" size={20} />
<span>Search</span>
</button>
</div>
</motion.div>
);
}
__root.tsx changes
// Replace:
import { TotalsBar } from "../components/TotalsBar";
// With:
import { TopNav } from "../components/TopNav";
import { BottomTabBar } from "../components/BottomTabBar";
// In RootLayout return:
// Replace: <TotalsBar {...totalsBarProps} />
// With: <TopNav />
// Wrap FabMenu to hide on mobile:
{showFab && (
<div className="hidden md:block">
<FabMenu isSetupsPage={isSetupsPage} />
</div>
)}
// Add after FabMenu:
<BottomTabBar />
collection/index.tsx tab removal
// Remove "setups" from TAB_ORDER, TAB_LABELS, and Zod schema
// Remove SetupsView import
// Remove tab === "setups" conditional render
const TAB_ORDER = ["gear", "planning"] as const;
const TAB_LABELS: Record<(typeof TAB_ORDER)[number], string> = {
gear: "Gear",
planning: "Planning",
};
const searchSchema = z.object({
tab: z.enum(["gear", "planning"]).catch("gear"),
});
routes/index.tsx hero removal
// Remove HeroSection function entirely
// Remove HeroSection from LandingPage render
// Remove Search import from lucide-react
function LandingPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<PopularSetupsSection />
<RecentItemsSection />
<TrendingCategoriesSection />
</div>
);
}
// Remove: openCatalogSearch from useUIStore (no longer needed in this file)
// Remove: useAuth import (no longer needed in this file)
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Hero-based catalog entry | Nav-persistent search bar | This phase | Catalog accessible from any page, not just landing page |
| Setups as Collection tab | Setups as top-level route | This phase | Setups gets own URL, bookmarkable, mobile tab bar includes it |
| FAB for all mobile actions | Bottom tab bar for nav, FAB only on desktop | This phase | Standard mobile pattern — iOS/Android tab bar convention |
| TotalsBar (logo + user) | TopNav (logo + links + search + user) | This phase | Full navigation affordance for multi-section app |
No deprecated patterns: The transition follows standard React + TanStack Router conventions throughout.
Open Questions
-
layersicon availability in the curated LucideIcon set- What we know:
iconData.tsxexports a curated subset of 119 Lucide icons. TheEMOJI_TO_ICON_MAPdoesn't includelayers. - What's unclear: Whether
layersis in the exported set. The fulliconsobject fromlucide-reactis imported, so any icon name should work viaLucideIcon(it passes the name to theiconslookup) — but the comment says "119 curated" icons. - Recommendation: Check
iconData.tsxfor the full export or simply trylayers— if it fails silently, usebriefcaseorgrid-2x2as fallback. The planner should note this as a quick verify step.
- What we know:
-
Body padding-bottom for bottom tab bar
- What we know: The bottom tab bar is
fixed bottom-0so it overlays page content. On mobile, the last content may be obscured by the tab bar. - What's unclear: The exact height of the tab bar (approximately 60-64px with icons + labels + padding).
- Recommendation: Add
pb-20 md:pb-0to the root<div className="min-h-screen bg-gray-50">in__root.tsxto prevent content being hidden behind the tab bar.
- What we know: The bottom tab bar is
-
openCatalogSearchmode parameter from TopNav- What we know:
openCatalogSearchtakes"collection" | "thread". From the nav, it should always be"collection". - What's unclear: Whether calling it in "collection" mode when on a thread detail page is correct behavior (D-07 says it's always catalog-global).
- Recommendation: Always pass
"collection"from the nav. The mode only affects what happens after the user selects a catalog item (add to collection vs add to thread). A user on a thread page who opens search from the nav would get the "add to collection" flow, not "add to thread" — this is a reasonable simplification per D-07 and D-08.
- What we know:
Environment Availability
Step 2.6: SKIPPED (no external dependencies — purely client-side React component restructuring, no new CLI tools, services, or runtimes required).
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Playwright (E2E) + Bun test runner (unit) |
| Config file | playwright.config.ts (E2E), built-in Bun test runner |
| Quick run command | bun test tests/ |
| Full suite command | bun run test:e2e |
Phase Requirements → Test Map
| Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|
| Top nav renders logo, Home/Collection/Setups links, search | E2E smoke | bunx playwright test e2e/dashboard.spec.ts |
Partial — needs update |
| Clicking Collection while anon triggers AuthPromptModal | E2E | bunx playwright test e2e/dashboard.spec.ts |
❌ Wave 0 |
| Mobile bottom tab bar shows 4 items | E2E (mobile viewport) | bunx playwright test e2e/dashboard.spec.ts |
❌ Wave 0 |
| Landing page has no hero section | E2E | bunx playwright test e2e/dashboard.spec.ts |
Partial — existing test checks for heading, needs update |
| /setups route renders SetupsView | E2E | bunx playwright test e2e/collection.spec.ts |
❌ Wave 0 |
| Collection page has only Gear and Planning tabs | E2E | bunx playwright test e2e/collection.spec.ts |
❌ needs update |
Sampling Rate
- Per task commit:
bun test tests/(unit only — fast) - Per wave merge:
bun run test:e2e(full E2E suite) - Phase gate: Full E2E suite green before
/gsd:verify-work
Wave 0 Gaps
e2e/dashboard.spec.ts— update existing tests: remove assertions about hero heading "Discover Gear", add assertion for top nav presence, update "GearBox heading" to check nav bar (not h1)e2e/dashboard.spec.ts— add: anon user clicking Collection nav triggers auth modale2e/dashboard.spec.ts— add: mobile viewport bottom tab bar test (use Playwrightpage.setViewportSize)e2e/collection.spec.ts— update: remove Setups tab assertions, add /setups route navigation test
(Note: tests/ unit tests cover service-level logic — no unit tests needed for this pure UI restructuring phase. E2E tests are the validation layer.)
Sources
Primary (HIGH confidence)
- Existing codebase:
TotalsBar.tsx,__root.tsx,FabMenu.tsx,uiStore.ts,AuthPromptModal.tsx,UserMenu.tsx,collection/index.tsx,routes/index.tsx— direct source inspection - Existing codebase:
SetupsView.tsx,setups/$setupId.tsx— route structure verified
Secondary (MEDIUM confidence)
- TanStack Router file-based routing conventions — inferred from existing route structure (collection/index.tsx, setups/$setupId.tsx)
- Framer Motion v12 entry animation pattern — inferred from FabMenu.tsx usage
Tertiary (LOW confidence)
- None — all findings backed by direct codebase inspection
Metadata
Confidence breakdown:
- Standard stack: HIGH — all libraries already in use, versions from package.json
- Architecture: HIGH — patterns derived directly from existing code
- Pitfalls: HIGH — derived from direct code analysis (isPublicRoute, z-index, tab removal implications)
Research date: 2026-04-10 Valid until: 2026-05-10 (stable codebase; no external API dependencies)