docs(27): research phase — top nav restructure and search bar rethink
This commit is contained in:
@@ -0,0 +1,656 @@
|
||||
# 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 `AuthPromptModal` instead 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 `CatalogSearchOverlay` UI 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`.
|
||||
|
||||
```typescript
|
||||
// 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.
|
||||
|
||||
```typescript
|
||||
// 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.
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
|
||||
```typescript
|
||||
// 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.
|
||||
|
||||
```typescript
|
||||
// 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.
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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` (or `briefcase`)
|
||||
- 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>` with `e.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-react` icons directly:** The project pattern uses `<LucideIcon name="..." />` via `src/client/lib/iconData.tsx`. Never import from `lucide-react` directly 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.ts` manually:** It is auto-generated by TanStack Router. Adding `setups/index.tsx` will auto-update it on next `bun 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)
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
1. **`layers` icon availability in the curated LucideIcon set**
|
||||
- What we know: `iconData.tsx` exports a curated subset of 119 Lucide icons. The `EMOJI_TO_ICON_MAP` doesn't include `layers`.
|
||||
- What's unclear: Whether `layers` is in the exported set. The full `icons` object from `lucide-react` is imported, so any icon name should work via `LucideIcon` (it passes the name to the `icons` lookup) — but the comment says "119 curated" icons.
|
||||
- Recommendation: Check `iconData.tsx` for the full export or simply try `layers` — if it fails silently, use `briefcase` or `grid-2x2` as fallback. The planner should note this as a quick verify step.
|
||||
|
||||
2. **Body padding-bottom for bottom tab bar**
|
||||
- What we know: The bottom tab bar is `fixed bottom-0` so 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-0` to the root `<div className="min-h-screen bg-gray-50">` in `__root.tsx` to prevent content being hidden behind the tab bar.
|
||||
|
||||
3. **`openCatalogSearch` mode parameter from TopNav**
|
||||
- What we know: `openCatalogSearch` takes `"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.
|
||||
|
||||
## 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 modal
|
||||
- [ ] `e2e/dashboard.spec.ts` — add: mobile viewport bottom tab bar test (use Playwright `page.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)
|
||||
Reference in New Issue
Block a user