20 KiB
Phase 20: FAB & Full-Screen Catalog Search - Research
Researched: 2026-04-06 Domain: React UI components (FAB menu, full-screen overlay, tag filtering), Hono API endpoint Confidence: HIGH
Summary
Phase 20 is a client-heavy UI phase with a small server addition (GET /api/tags). The work involves: (1) upgrading the existing single-action FAB in __root.tsx to a mini menu with multiple actions, (2) building a full-screen catalog search overlay managed by UIStore, and (3) adding tag chip filtering connected to the existing GET /api/global-items?q=...&tags=... endpoint.
All patterns needed already exist in the codebase. The FAB lives in __root.tsx (lines 257-278), the overlay pattern is established in CreateThreadModal (fixed inset-0 z-50), the search with debounce exists in global-items/index.tsx, the card pattern is in GlobalItemCard.tsx, and Framer Motion is already a dependency for animations. The only net-new server work is a lightweight tags endpoint and registering the existing global-items route (currently unregistered in index.ts).
Primary recommendation: Extend existing patterns -- UIStore state slices, Framer Motion AnimatePresence for FAB menu animation, reuse debounce/search/card patterns from global-items page. No new libraries needed.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: FAB globally visible on all pages (not just collection view). Position: fixed bottom-6 right-6
- D-02: Tapping FAB opens mini menu: 2-3 labeled icon buttons fan out vertically above FAB. Backdrop dims slightly. Tapping backdrop or FAB again closes menu
- D-03: Menu options: "Add to Collection" (package icon) + "Start New Thread" (search icon). On setups page only, third option "New Setup"
- D-04: Both "Add to Collection" and "Start New Thread" open same full-screen catalog search overlay, with mode parameter ("collection" or "thread") stored in UIStore
- D-05: "New Setup" triggers existing setup creation flow (no catalog search)
- D-06: FAB should not appear on login page or public profile/setup pages (only authenticated routes)
- D-07: Full-screen overlay (fixed inset-0 z-50) managed by UIStore state (catalogSearchOpen, catalogSearchMode), not a route change
- D-08: Layout: back arrow (top-left, closes overlay) + large search input + context indicator text
- D-09: Below search bar: horizontal scrollable row of tag chips for quick filtering
- D-10: Results area: grid of compact cards showing brand + model, weight, price, owner count, and "Add" button
- D-11: Search queries existing GET /api/global-items?q=...&tags=... endpoint
- D-12: Empty state: helpful message when no results, with "Add Manually" link (Phase 22 wires this)
- D-13: Loading state: skeleton cards matching result grid pattern
- D-14: Tags fetched from new GET /api/tags endpoint (returns all tags, lightweight)
- D-15: Horizontal scrollable row of rounded-full chips, tapping toggles active state, multiple tags (AND filtering)
- D-16: Active chips: bg-blue-100 text-blue-700; inactive: bg-gray-100 text-gray-500
- D-17: Quick-access chips show common/popular tags first
- D-18: Reuse/adapt GlobalItemCard component pattern
- D-19: Each card has "Add"/"+" button -- stub in Phase 20 (Phase 21 wires action)
- D-20: Cards responsive: 1 column mobile, 2-3 on desktop
- D-21: New UIStore state: catalogSearchOpen, catalogSearchMode ("collection" | "thread" | null)
- D-22: New UIStore actions: openCatalogSearch(mode), closeCatalogSearch()
- D-23: FAB menu state: fabMenuOpen, openFabMenu(), closeFabMenu()
- D-24: New endpoint GET /api/tags returning { id, name }[]. Public, no auth
Claude's Discretion
- Animation style for FAB menu (spring, ease, duration)
- Exact tag chip ordering strategy
- Card grid gap and sizing details
- Whether to debounce search input (recommendation: yes, 300ms)
- Skeleton card count during loading
- Whether "Add Manually" link is visible in Phase 20 or deferred to Phase 22
Deferred Ideas (OUT OF SCOPE)
- "Add Manually" link wiring -- Phase 22
- Actual add-to-collection flow (confirmation step with category picker) -- Phase 21
- Actual add-to-thread flow (instant candidate creation) -- Phase 21
- Search result sorting/ordering options
- Recent searches or search history
- "Popular items" section when search is empty </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CATFLOW-01 | FAB shows mini menu with "Add to Collection" and "Start Thread" globally, plus "New Setup" on setups page | Existing FAB in __root.tsx (lines 257-278), UIStore pattern for state, Framer Motion for animations, useMatchRoute for setups page detection |
| CATFLOW-02 | Full-screen catalog search with tag chip filtering | Existing overlay pattern (CreateThreadModal), debounce pattern (global-items/index.tsx), useGlobalItems hook, tags schema in DB, GlobalItemCard component |
| </phase_requirements> |
Standard Stack
Core (already installed)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| React | 19 | UI framework | Project standard |
| framer-motion | ^12.38.0 | FAB menu animations, overlay transitions | Already used in collection/threads for AnimatePresence/motion |
| zustand | (installed) | UIStore for FAB and overlay state | Project standard for UI state |
| @tanstack/react-query | (installed) | Data fetching for tags and global items | Project standard |
| hono | (installed) | Tags API endpoint | Project standard |
| drizzle-orm | (installed) | Tags query | Project standard |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| tailwind CSS v4 | (installed) | All styling | Project standard |
Alternatives Considered
None -- all required libraries are already in the project.
Architecture Patterns
New Components
src/client/
components/
FabMenu.tsx # FAB + mini menu overlay (extracted from __root.tsx)
CatalogSearchOverlay.tsx # Full-screen search overlay
TagChips.tsx # Horizontal scrollable tag chip row
CatalogItemCard.tsx # Adapted GlobalItemCard with Add button
hooks/
useTags.ts # TanStack Query hook for GET /api/tags
stores/
uiStore.ts # Extended with FAB + catalog search state
src/server/
routes/
tags.ts # New: GET /api/tags
services/
tag.service.ts # New: getAllTags()
index.ts # Register /api/tags AND /api/global-items routes
Pattern 1: UIStore State Extension
What: Add FAB menu and catalog search state slices to existing Zustand store When to use: This is the established project pattern for dialog/panel/overlay state Example:
// Extend UIState interface
fabMenuOpen: boolean;
openFabMenu: () => void;
closeFabMenu: () => void;
catalogSearchOpen: boolean;
catalogSearchMode: "collection" | "thread" | null;
openCatalogSearch: (mode: "collection" | "thread") => void;
closeCatalogSearch: () => void;
Pattern 2: FAB Visibility via useMatchRoute
What: Replace current collection-only FAB visibility with auth-aware global visibility
When to use: The current FAB uses matchRoute to show only on collection gear tab. Phase 20 changes this to show on all authenticated routes except login and public pages.
Example:
// Current logic (to be replaced):
const showFab = isCollection && (!collectionSearch || ...);
// New logic:
const isPublicRoute = location.pathname.startsWith("/users/") || location.pathname === "/login";
const showFab = isAuthenticated && !isPublicRoute;
Pattern 3: Full-Screen Overlay (CreateThreadModal pattern)
What: Fixed inset-0 z-50 overlay managed by UIStore boolean
When to use: Established pattern in CreateThreadModal
Key difference: Catalog search overlay is full-screen white (not a centered modal with backdrop). Uses bg-white not bg-black/50.
Pattern 4: Debounced Search (global-items/index.tsx pattern)
What: useState + useEffect timer for 300ms debounce on search input When to use: Exact pattern from existing catalog page, copy directly Example:
const [searchInput, setSearchInput] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput]);
Pattern 5: Framer Motion FAB Menu Animation
What: AnimatePresence + motion.div for menu items fanning out vertically When to use: Framer Motion already imported in collection/threads Example:
import { AnimatePresence, motion } from "framer-motion";
// Menu items appear one by one with staggered animation
<AnimatePresence>
{fabMenuOpen && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.9 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
{/* menu items */}
</motion.div>
)}
</AnimatePresence>
Anti-Patterns to Avoid
- Route-based overlay: Don't make the catalog search a route. It's managed via UIStore like all other dialogs in the project. Route changes would break the "open from anywhere" requirement.
- Global state for search query: Don't store the search input in UIStore. Keep it local to CatalogSearchOverlay component (matches existing pattern in global-items/index.tsx).
- Custom debounce hook: Don't create a generic
useDebouncehook. The inline useState+useEffect pattern is already established and simple.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Animations | CSS keyframe animations | Framer Motion (AnimatePresence + motion) | Already used, handles enter/exit, spring physics |
| Search debounce | Custom debounce utility | Inline useState+useEffect (300ms) | Established pattern, 5 lines, no abstraction needed |
| Tag data fetching | Manual fetch+cache | TanStack Query useQuery hook |
Project standard, handles caching/stale/refetch |
| Overlay state management | useState in __root | Zustand UIStore slice | Project standard for cross-component dialog state |
Common Pitfalls
Pitfall 1: Global-Items Route Not Registered
What goes wrong: The GET /api/global-items endpoint returns 404 because globalItemRoutes exists in src/server/routes/global-items.ts but is NOT registered in src/server/index.ts.
Why it happens: Phase 19 created the route file but the route registration line app.route("/api/global-items", globalItemRoutes) is missing from index.ts.
How to avoid: First task must register the global-items route in index.ts. Also register the new tags route.
Warning signs: 404 errors when searching in the overlay.
Pitfall 2: Z-Index Layering Conflicts
What goes wrong: FAB menu appears behind existing overlays, or catalog search overlay doesn't cover everything. Why it happens: Project uses layered z-indexes: z-20 (FAB), z-30 (backdrop), z-40 (panels), z-50 (modals). How to avoid: FAB stays at z-20, FAB menu backdrop at z-30, FAB menu items at z-40. Catalog search overlay at z-50 (same as modals -- it replaces the entire screen). When catalog search is open, FAB should be hidden. Warning signs: Visual stacking issues on mobile.
Pitfall 3: FAB Visible on Public Routes
What goes wrong: Unauthenticated users or public profile visitors see the FAB.
Why it happens: Current FAB visibility check is collection-only. New global visibility needs explicit exclusion of public routes and unauthenticated state.
How to avoid: Check both isAuthenticated AND !isPublicRoute. Public routes: /login, /users/*.
Pitfall 4: Scroll Lock When Overlay Is Open
What goes wrong: Background page scrolls while full-screen overlay is visible.
Why it happens: Fixed overlay doesn't prevent body scroll on mobile.
How to avoid: Add overflow-hidden to body when overlay is open, or use overflow-y-auto on the overlay container itself and ensure it captures all scroll events.
Pitfall 5: Tag Query Parameter Format
What goes wrong: Tag filtering sends wrong format to the API.
Why it happens: API expects ?tags=tag1,tag2 (comma-separated names). Easy to accidentally send IDs or wrong separator.
How to avoid: Tags endpoint returns { id, name }[]. When building the query, join selected tag names with commas. The existing searchGlobalItems service already parses this format.
Pitfall 6: Search Fires on Overlay Mount
What goes wrong: Opening the overlay triggers an immediate search for all items with empty query.
Why it happens: useGlobalItems fires on mount with no query, fetching the entire catalog.
How to avoid: Either pass enabled: false until user types something, or accept the initial load as intentional (shows browsable catalog). The CONTEXT.md implies browse-first is acceptable since D-09 shows tag filtering.
Code Examples
Tags Service (new file)
// src/server/services/tag.service.ts
import { db as prodDb } from "../../db/index.ts";
import { tags } from "../../db/schema.ts";
type Db = typeof prodDb;
export async function getAllTags(db: Db = prodDb) {
return db.select({ id: tags.id, name: tags.name }).from(tags);
}
Tags Route (new file)
// src/server/routes/tags.ts
import { Hono } from "hono";
import { getAllTags } from "../services/tag.service.ts";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", async (c) => {
const db = c.get("db");
const allTags = await getAllTags(db);
return c.json(allTags);
});
export { app as tagRoutes };
useTags Hook (new file)
// src/client/hooks/useTags.ts
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../lib/api";
interface Tag {
id: number;
name: string;
}
export function useTags() {
return useQuery({
queryKey: ["tags"],
queryFn: () => apiGet<Tag[]>("/api/tags"),
staleTime: 5 * 60 * 1000, // Tags rarely change, cache 5 min
});
}
Extended useGlobalItems (tag support)
// Update src/client/hooks/useGlobalItems.ts
export function useGlobalItems(query?: string, tags?: string[]) {
const params = new URLSearchParams();
if (query) params.set("q", query);
if (tags && tags.length > 0) params.set("tags", tags.join(","));
const qs = params.toString();
return useQuery({
queryKey: ["global-items", query ?? "", tags ?? []],
queryFn: () =>
apiGet<GlobalItem[]>(`/api/global-items${qs ? `?${qs}` : ""}`),
});
}
Skeleton Card (loading state)
// Reuse existing skeleton pattern from global-items/index.tsx
function SkeletonCard() {
return (
<div className="bg-white rounded-xl border border-gray-100 overflow-hidden animate-pulse">
<div className="aspect-[4/3] bg-gray-100" />
<div className="p-4 space-y-2">
<div className="h-3 bg-gray-100 rounded w-16" />
<div className="h-4 bg-gray-100 rounded w-32" />
<div className="flex gap-1.5">
<div className="h-5 bg-gray-100 rounded-full w-14" />
<div className="h-5 bg-gray-100 rounded-full w-14" />
</div>
</div>
</div>
);
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| FAB only on collection gear tab | FAB globally visible | Phase 20 | Must update visibility logic in __root.tsx |
| Single-action FAB (opens add panel) | Multi-action FAB menu | Phase 20 | FAB click behavior changes from direct action to menu toggle |
| No catalog search overlay | Full-screen overlay with tag filtering | Phase 20 | New UIStore state slices, new component tree |
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | bun:test (service/route) + Playwright (E2E) |
| Config file | bunfig.toml (bun test) / playwright.config.ts (E2E) |
| Quick run command | bun test tests/routes/tags.test.ts |
| Full suite command | bun test |
Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CATFLOW-01 | FAB menu renders with correct options | E2E | bun run test:e2e |
Wave 0 |
| CATFLOW-01 | FAB visible on authenticated routes, hidden on public | E2E | bun run test:e2e |
Wave 0 |
| CATFLOW-02 | GET /api/tags returns all tags | unit | bun test tests/routes/tags.test.ts |
Wave 0 |
| CATFLOW-02 | Tag service returns tags from DB | unit | bun test tests/services/tag.service.test.ts |
Wave 0 |
| CATFLOW-02 | GET /api/global-items?tags=... filters correctly | unit | bun test tests/routes/global-items.test.ts |
Exists (Phase 19) |
| CATFLOW-02 | Search overlay opens/closes via UIStore | manual-only | Visual verification | N/A |
Sampling Rate
- Per task commit:
bun test tests/routes/tags.test.ts && bun test tests/services/tag.service.test.ts - Per wave merge:
bun test - Phase gate: Full suite green before /gsd:verify-work
Wave 0 Gaps
tests/services/tag.service.test.ts-- covers tag retrievaltests/routes/tags.test.ts-- covers GET /api/tags endpoint- Verify
tests/routes/global-items.test.tscovers tag filtering (may already exist from Phase 19)
Open Questions
-
Global-items route registration
- What we know: The route file exists at
src/server/routes/global-items.tsbut is NOT registered insrc/server/index.ts(noapp.route("/api/global-items", ...)line) - What's unclear: Whether this was intentional (staged for Phase 20) or an oversight from Phase 19
- Recommendation: Register it as part of Phase 20 server setup task, alongside the new tags route
- What we know: The route file exists at
-
Owner count in search results
- What we know: D-10 says cards show "owner count". The
searchGlobalItemsservice returns basic fields but NOT owner count. OnlygetGlobalItemWithOwnerCountincludes it (single item fetch). - What's unclear: Whether to add owner count to search results (requires a join/subquery) or show it only on detail pages
- Recommendation: For Phase 20, omit owner count from search cards to keep search fast. The card already shows brand, model, weight, price, category. Owner count can be added in a follow-up if needed, or when clicking through to detail.
- What we know: D-10 says cards show "owner count". The
Project Constraints (from CLAUDE.md)
- Styling: Tailwind CSS v4, tabs for indentation, double quotes (Biome)
- State management: Zustand for UI state only, server data in React Query
- API pattern: Hono routes with Zod validation, delegate to service functions
- Services: Pure functions taking db instance, no HTTP awareness
- Route registration:
app.route("/api/...", routes)insrc/server/index.ts - Path alias:
@/*maps to./src/* - Testing: Bun test runner for unit/integration, Playwright for E2E
- Branching: Feature branch off Develop, merge back when complete
Sources
Primary (HIGH confidence)
src/client/routes/__root.tsx-- Current FAB implementation, route matching patternssrc/client/stores/uiStore.ts-- Zustand store structure, all existing state slicessrc/client/components/CreateThreadModal.tsx-- Full-screen overlay patternsrc/client/components/GlobalItemCard.tsx-- Card component with badge patternsrc/client/routes/global-items/index.tsx-- Debounce pattern, skeleton cards, search UIsrc/server/routes/global-items.ts-- Existing search endpoint with tag supportsrc/server/services/global-item.service.ts-- Search service with ILIKE + tag AND filteringsrc/db/schema.ts-- Tags and globalItemTags table definitionssrc/server/index.ts-- Route registration (global-items NOT registered)package.json-- framer-motion ^12.38.0 confirmed installed
Metadata
Confidence breakdown:
- Standard stack: HIGH -- all libraries already installed, no new dependencies
- Architecture: HIGH -- all patterns exist in codebase, pure extension
- Pitfalls: HIGH -- identified from direct code inspection (missing route registration, z-index layering, public route exclusion)
Research date: 2026-04-06 Valid until: 2026-05-06 (stable -- all internal code patterns)