diff --git a/.planning/phases/26-discovery-landing-page/26-RESEARCH.md b/.planning/phases/26-discovery-landing-page/26-RESEARCH.md
new file mode 100644
index 0000000..ee5baab
--- /dev/null
+++ b/.planning/phases/26-discovery-landing-page/26-RESEARCH.md
@@ -0,0 +1,591 @@
+# Phase 26: Discovery Landing Page - Research
+
+**Researched:** 2026-04-10
+**Domain:** React SPA landing page, public API feed endpoints, cursor pagination
+**Confidence:** HIGH
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- **D-01:** Full-width hero area with catalog search bar prominently centered. Below the hero, a vertical stack of content sections.
+- **D-02:** Section order: (1) Hero with search bar, (2) Popular Setups, (3) Recently Added Items, (4) Trending Categories. Each section has a heading and optional "View all" link.
+- **D-03:** The current `DashboardPage` component and its `DashboardCard` usage at `/` will be replaced entirely. The dashboard is now the landing page.
+- **D-04:** The hero search bar triggers the existing `CatalogSearchOverlay` on focus or typing. This reuses the full-featured search without duplicating search UI.
+- **D-05:** "Popular setups" ranked by item count descending (proxy for effort/completeness). Only public setups are shown.
+- **D-06:** "Recently added items" shows the most recently created `globalItems`, ordered by `createdAt` descending.
+- **D-07:** "Trending categories" ranked by global item count per distinct `globalItems.category` value.
+- **D-08:** Cursor-based pagination for feed sections per INFR-02. Use `createdAt` cursor for recently added items; item count + ID cursor for popular setups.
+- **D-09:** Same page content for both authenticated and anonymous users. Difference is purely navigational.
+- **D-10:** Authenticated users see a "Go to Collection" CTA in the hero area, next to the search bar. Visible without scrolling.
+- **D-11:** Anonymous users see the search bar and content sections immediately. Sign-in button in top-right per Phase 24.
+
+### Claude's Discretion
+- Exact layout sizing, spacing, and responsive breakpoints
+- Number of items shown per section before "View all" (suggest 6-8 for items/setups, 8-12 for categories)
+- Empty states for sections with no data
+- Loading skeletons for each section
+- Whether "View all" links for setups/items route to existing pages or new dedicated feed pages
+
+### Folded Todos (IN SCOPE)
+- **Add cursor pointer to all clickable links** — Apply broadly while building the new page.
+- **Fix item image not showing on collection overview** — Investigate and fix image display issue since landing page will show `GlobalItemCard` components with images.
+- **Investigate slow image loading** — Profile image loading performance.
+
+### Deferred Ideas (OUT OF SCOPE)
+- Personalized feed based on user's collection categories (PERS-01, PERS-02)
+- SSR/static prerendering for SEO (SEO-01, SEO-02)
+- Engagement metrics (views, likes) for better ranking
+- Setup preview images/thumbnails
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| DISC-01 | Landing page displays an always-visible catalog search bar at the top | Hero section with `CatalogSearchOverlay` trigger; search bar renders unconditionally |
+| DISC-02 | Landing page shows a feed of popular setups below the search | New `GET /api/discovery/setups` endpoint; `useDiscoverySetups` hook; enhanced `PublicSetupCard` |
+| DISC-03 | Landing page shows recently added catalog items | New `GET /api/discovery/items` endpoint; `useDiscoveryItems` hook; `GlobalItemCard` reuse |
+| DISC-04 | Landing page shows trending categories | New `GET /api/discovery/categories` endpoint; `useDiscoveryCategories` hook; category pill/card display |
+| DISC-05 | Authenticated users see a "Go to Collection" entry point | `useAuth` hook; conditional CTA in hero using `auth.user` presence |
+| INFR-02 | Discovery feed endpoint uses cursor pagination | Cursor-based pagination on `createdAt` / `(itemCount, id)` — no offset pagination |
+
+
+---
+
+## Summary
+
+Phase 26 replaces the personal dashboard at `/` with a public discovery landing page. The existing codebase is well-structured for this work: the current `index.tsx` is thin (61 lines), the reusable components (`GlobalItemCard`, `CatalogSearchOverlay`) are ready, the auth pattern (`useAuth`) is already in place, and the Tailwind design language is consistent throughout.
+
+The primary technical work divides into three areas: (1) three new server-side discovery endpoints with cursor pagination, (2) three corresponding React Query hooks on the client, and (3) rewriting `index.tsx` into the landing page layout with hero, sections, and conditional auth CTA. The `PublicSetupCard` needs a minor enhancement to show item count and creator name for the popular setups section.
+
+The folded todos (cursor pointer, image display bug) must be addressed during this phase since they directly affect the landing page experience.
+
+**Primary recommendation:** Build three `GET /api/discovery/*` endpoints, mirror them with three React Query hooks, and rewrite `routes/index.tsx` composing existing `GlobalItemCard` and enhanced `PublicSetupCard`. Add a `discoveryRoutes` file registered at `/api/discovery` in `server/index.ts`.
+
+---
+
+## Standard Stack
+
+### Core (already in project)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| TanStack Router | file-based | Client routing; `routes/index.tsx` stays same file | Project standard |
+| TanStack React Query | in project | Data fetching hooks; each section gets its own hook | Project standard |
+| Tailwind CSS v4 | in project | Styling; `bg-white rounded-xl border border-gray-100` card pattern | Project standard |
+| Hono | in project | Server routes; new `discoveryRoutes` file follows same pattern | Project standard |
+| Drizzle ORM | in project | SQL queries with `desc`, `count`, `groupBy` for feed data | Project standard |
+| Zustand (`uiStore`) | in project | `openCatalogSearch()` trigger from hero search bar | Project standard |
+
+### No New Dependencies
+This phase requires zero new package installations. All patterns and libraries are already present.
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure Changes
+
+```
+src/
+├── client/
+│ ├── routes/
+│ │ └── index.tsx # REWRITE — landing page (replaces dashboard)
+│ ├── hooks/
+│ │ └── useDiscovery.ts # NEW — three hooks: useDiscoverySetups, useDiscoveryItems, useDiscoveryCategories
+│ └── components/
+│ └── PublicSetupCard.tsx # ENHANCE — add itemCount + creatorName props
+└── server/
+ ├── routes/
+ │ └── discovery.ts # NEW — GET /setups, /items, /categories
+ ├── services/
+ │ └── discovery.service.ts # NEW — getPopularSetups, getRecentItems, getTrendingCategories
+ └── index.ts # ADD — discoveryRoutes registration + public allowlist + rate limit
+```
+
+### Pattern 1: Discovery Endpoint with Cursor Pagination (INFR-02)
+
+**What:** GET endpoints accept an optional `cursor` query param and `limit`. Cursor encodes position in result set without OFFSET.
+
+**For recently added items** — cursor is the `createdAt` ISO timestamp of the last seen item:
+```typescript
+// Source: Drizzle ORM docs — cursor pagination
+// GET /api/discovery/items?limit=8&cursor=2026-04-01T00:00:00.000Z
+export async function getRecentGlobalItems(
+ db: Db,
+ limit = 8,
+ cursor?: string, // ISO timestamp of last seen item
+) {
+ const conditions = cursor
+ ? [lt(globalItems.createdAt, new Date(cursor))]
+ : [];
+
+ return db
+ .select()
+ .from(globalItems)
+ .where(conditions.length ? and(...conditions) : undefined)
+ .orderBy(desc(globalItems.createdAt))
+ .limit(limit + 1); // fetch one extra to detect hasMore
+}
+```
+
+**For popular setups** — cursor is `itemCount_id` (composite: item count + setup id for stable ordering):
+```typescript
+// GET /api/discovery/setups?limit=6&cursor=5_42
+// cursor = "{itemCount}_{id}" — both fields for stable pagination
+export async function getPopularSetups(
+ db: Db,
+ limit = 6,
+ cursor?: string,
+) {
+ const itemCountExpr = sql`CAST(COUNT(${setupItems.id}) AS INT)`;
+
+ // Build base query with JOIN to count items per public setup
+ let query = db
+ .select({
+ id: setups.id,
+ name: setups.name,
+ createdAt: setups.createdAt,
+ itemCount: itemCountExpr,
+ })
+ .from(setups)
+ .leftJoin(setupItems, eq(setupItems.setupId, setups.id))
+ .where(eq(setups.isPublic, true))
+ .groupBy(setups.id, setups.name, setups.createdAt)
+ .orderBy(desc(itemCountExpr), desc(setups.id))
+ .limit(limit + 1);
+
+ // Cursor filtering applied post-query (simpler for composite cursor with SQLite)
+ const rows = await query;
+ if (cursor) {
+ const [cursorCount, cursorId] = cursor.split("_").map(Number);
+ // Filter: itemCount < cursorCount, OR (itemCount === cursorCount AND id < cursorId)
+ return rows.filter(r =>
+ r.itemCount < cursorCount ||
+ (r.itemCount === cursorCount && r.id < cursorId)
+ ).slice(0, limit + 1);
+ }
+ return rows;
+}
+```
+
+**Note:** For SQLite (production DB is PostgreSQL per schema imports, but schema uses `pgTable`), the `ilike` operator is already in use in `global-item.service.ts`. The Drizzle operators `lt`, `desc`, `count`, `sql` are all already imported in the codebase.
+
+**hasMore detection pattern:**
+```typescript
+// In route handler — consistent across all three endpoints
+const rows = await getRecentGlobalItems(db, limit + 1, cursor);
+const hasMore = rows.length > limit;
+const items = hasMore ? rows.slice(0, limit) : rows;
+const nextCursor = hasMore ? buildCursor(items[items.length - 1]) : null;
+return c.json({ items, nextCursor, hasMore });
+```
+
+### Pattern 2: Discovery Route Registration
+
+**What:** New `discoveryRoutes` file registered at `/api/discovery` in `server/index.ts`, following the exact same Hono pattern as `globalItemRoutes`.
+
+```typescript
+// src/server/routes/discovery.ts
+import { Hono } from "hono";
+// ...
+const app = new Hono();
+app.get("/setups", async (c) => { ... });
+app.get("/items", async (c) => { ... });
+app.get("/categories", async (c) => { ... });
+export { app as discoveryRoutes };
+```
+
+```typescript
+// src/server/index.ts additions:
+// 1. Import discoveryRoutes
+// 2. Add to public skiplist (GET /api/discovery/*)
+// 3. Add browseTier rate limit for GET /api/discovery/*
+// 4. app.route("/api/discovery", discoveryRoutes)
+```
+
+### Pattern 3: Discovery React Query Hooks
+
+**What:** Single file `useDiscovery.ts` with three named exports, following the exact pattern of `useGlobalItems`.
+
+```typescript
+// src/client/hooks/useDiscovery.ts
+export interface DiscoverySetup {
+ id: number;
+ name: string;
+ createdAt: string;
+ itemCount: number;
+ // creatorName: optional — depends on whether users join is implemented
+}
+
+export interface DiscoveryCategory {
+ name: string;
+ itemCount: number;
+}
+
+export function useDiscoverySetups(limit = 6) {
+ return useQuery({
+ queryKey: ["discovery", "setups", limit],
+ queryFn: () => apiGet<{ items: DiscoverySetup[]; nextCursor: string | null }>(`/api/discovery/setups?limit=${limit}`),
+ staleTime: 2 * 60 * 1000, // 2 min — feed data, okay to be slightly stale
+ });
+}
+
+export function useDiscoveryItems(limit = 8) {
+ return useQuery({
+ queryKey: ["discovery", "items", limit],
+ queryFn: () => apiGet<{ items: GlobalItem[]; nextCursor: string | null }>(`/api/discovery/items?limit=${limit}`),
+ staleTime: 2 * 60 * 1000,
+ });
+}
+
+export function useDiscoveryCategories(limit = 12) {
+ return useQuery({
+ queryKey: ["discovery", "categories", limit],
+ queryFn: () => apiGet(`/api/discovery/categories?limit=${limit}`),
+ staleTime: 5 * 60 * 1000, // 5 min — categories change rarely
+ });
+}
+```
+
+### Pattern 4: Landing Page Component Structure
+
+**What:** `routes/index.tsx` rewritten as `LandingPage` function. `DashboardPage` and `DashboardCard` imports removed. Three sections below the hero, each as a standalone sub-component in the same file.
+
+```typescript
+// src/client/routes/index.tsx
+export const Route = createFileRoute("/")({
+ component: LandingPage,
+});
+
+function LandingPage() {
+ const { data: auth } = useAuth();
+ const isAuthenticated = !!auth?.user;
+ const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
+
+ return (
+
+ );
+}
+```
+
+### Pattern 5: Enhanced `PublicSetupCard`
+
+**What:** Add `itemCount` and optional `creatorName` to `PublicSetupCardProps`. The card currently only shows `name` and formatted date — it needs item count to be useful in a "Popular Setups" feed.
+
+```typescript
+interface PublicSetupCardProps {
+ setup: {
+ id: number;
+ name: string;
+ createdAt: string;
+ itemCount: number; // NEW — required for popular setups feed
+ creatorName?: string; // NEW — optional, shown if present
+ };
+}
+```
+
+**Important:** This is a non-breaking change. Existing usages of `PublicSetupCard` that pass the old shape will need to add `itemCount`. Check all usages before changing the interface.
+
+### Pattern 6: Hero Search Bar
+
+**What:** A styled `` or `
` that calls `openCatalogSearch("collection")` on click/focus. Does NOT perform search itself — just triggers `CatalogSearchOverlay`. This aligns with D-04.
+
+```typescript
+function HeroSection({ isAuthenticated, onSearchFocus }: { isAuthenticated: boolean; onSearchFocus: () => void }) {
+ return (
+
+ );
+}
+```
+
+### Anti-Patterns to Avoid
+- **Do not build inline search results** — The search bar is a trigger only; `CatalogSearchOverlay` handles all search UI (D-04). Duplicating search logic will create state sync bugs.
+- **Do not use OFFSET pagination** — Use cursor-based pagination for all discovery endpoints (INFR-02). OFFSET degrades with large tables and can skip/duplicate rows with concurrent inserts.
+- **Do not use `auth?.authenticated` for conditional CTA** — Use `!!auth?.user` as established by `__root.tsx` pattern (`const isAuthenticated = !!auth?.user`).
+- **Do not import DashboardCard or DashboardPage in new index.tsx** — They are being retired by D-03; remove imports entirely.
+- **Do not fire-and-forget on auth check for CTA** — The `useAuth` query has `staleTime: 5 * 60 * 1000` and `retry: false`. The CTA should appear only after auth resolves (`auth?.user` is truthy), not while `isLoading`.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Search UI | Custom search input with results | `CatalogSearchOverlay` + `openCatalogSearch()` | Full feature set: debounce, tags, grid/list, manual entry; D-04 |
+| Auth state | Manual JWT decode or session check | `useAuth()` hook | Caches auth state, handles race conditions |
+| Weight/price formatting | `item.weightGrams + "g"` | `useFormatters()` → `weight()`, `price()` | Handles unit conversion, null, localization |
+| Card skeleton | Custom loading spinner | `animate-pulse` Tailwind classes — match `CatalogSearchOverlay` skeleton pattern | Consistent with existing `SkeletonGrid` in `CatalogSearchOverlay` |
+| Rate limiting | New rate limit implementation | `createRateLimit(browseTier)` factory | Already handles IP extraction, cleanup, 429 responses |
+
+**Key insight:** Nearly all plumbing already exists. The work is composition, not invention.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: `CatalogSearchOverlay` mounts at `top-[57px]`
+**What goes wrong:** The overlay is positioned `fixed inset-x-0 top-[57px]` (below the `TotalsBar` which is `h-14` = 56px). If the landing page adds any sticky element above the TotalsBar, the overlay will overlap it.
+**Why it happens:** The overlay's top offset is hardcoded to the TotalsBar height.
+**How to avoid:** Do not add sticky elements to the landing page layout outside of TotalsBar. The hero section should be part of the normal document flow.
+**Warning signs:** Overlay appears behind or over an element when opened from landing page.
+
+### Pitfall 2: `isDashboard` detection in `__root.tsx`
+**What goes wrong:** `__root.tsx` has `const isDashboard = !!matchRoute({ to: "/" })`. This controls whether `TotalsBar` shows a `linkTo` prop (back link). If the new landing page keeps the `/` route, `isDashboard` will remain `true` and `TotalsBar` will render its title as a non-link — which is correct behavior (already handled).
+**Why it happens:** No change needed, but worth knowing so it's not "fixed" accidentally.
+**How to avoid:** Leave `isDashboard` logic in `__root.tsx` unchanged.
+
+### Pitfall 3: `PublicSetupCard` interface change breaks existing usages
+**What goes wrong:** If `itemCount` is made required in `PublicSetupCardProps`, any existing usage that doesn't pass it will cause a TypeScript error.
+**Why it happens:** The card is used in at least the public setup profile page.
+**How to avoid:** Check all usages of `PublicSetupCard` before adding `itemCount` as required. Consider adding it as optional (`itemCount?: number`) with a fallback display.
+**Warning signs:** TypeScript errors on `PublicSetupCard` usages in other files.
+
+### Pitfall 4: Discovery endpoint auth allowlist
+**What goes wrong:** New `GET /api/discovery/*` endpoints return 401 for anonymous users because the auth skip list in `server/index.ts` doesn't include them.
+**Why it happens:** The auth middleware at line 151-170 of `server/index.ts` skips specific paths by prefix check. New paths must be explicitly added.
+**How to avoid:** Add this skip condition to `server/index.ts`:
+```typescript
+if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
+ return next();
+```
+**Warning signs:** Anonymous page load shows empty sections or 401 errors in browser network tab.
+
+### Pitfall 5: Trending categories — `globalItems.category` is nullable
+**What goes wrong:** SQL `GROUP BY globalItems.category` will include a `null` group if some items have no category set. This null group may appear in "Trending Categories" and render as an empty/broken category chip.
+**Why it happens:** `category` column is `text` nullable in schema.
+**How to avoid:** Add `WHERE globalItems.category IS NOT NULL` to the trending categories query.
+**Warning signs:** Category section shows an empty/blank chip.
+
+### Pitfall 6: Creator name requires users join (scope risk)
+**What goes wrong:** D-02 says setups show "creator names". But `setups` table only has `userId` — getting `displayName` requires joining `users` table. If `users.displayName` is null (common for new accounts), the join returns null.
+**Why it happens:** User display names are optional in the schema.
+**How to avoid:** Join users in `getPopularSetups` query and return `creatorName: users.displayName ?? null`. In the card, render creator name only when non-null (e.g., "by Jean-Luc" or omit entirely). Mark `creatorName` as optional in the TS interface.
+**Warning signs:** Crashes on `.toLocaleLowerCase()` or similar when `creatorName` is null.
+
+---
+
+## Code Examples
+
+### Drizzle: Trending categories query
+```typescript
+// Source: Drizzle docs + codebase patterns in global-item.service.ts
+import { count, desc, isNotNull, groupBy } from "drizzle-orm";
+
+export async function getTrendingCategories(db: Db, limit = 12) {
+ return db
+ .select({
+ name: globalItems.category,
+ itemCount: count(globalItems.id),
+ })
+ .from(globalItems)
+ .where(isNotNull(globalItems.category))
+ .groupBy(globalItems.category)
+ .orderBy(desc(count(globalItems.id)))
+ .limit(limit);
+}
+// Returns: Array<{ name: string; itemCount: number }>
+```
+
+### Drizzle: Popular setups with item count
+```typescript
+// Source: Drizzle docs — leftJoin + groupBy + count
+import { count, desc, eq } from "drizzle-orm";
+
+export async function getPopularSetups(db: Db, limit = 6) {
+ return db
+ .select({
+ id: setups.id,
+ name: setups.name,
+ createdAt: setups.createdAt,
+ itemCount: count(setupItems.id),
+ creatorName: users.displayName,
+ })
+ .from(setups)
+ .leftJoin(setupItems, eq(setupItems.setupId, setups.id))
+ .leftJoin(users, eq(users.id, setups.userId))
+ .where(eq(setups.isPublic, true))
+ .groupBy(setups.id, setups.name, setups.createdAt, users.displayName)
+ .orderBy(desc(count(setupItems.id)), desc(setups.id))
+ .limit(limit);
+}
+```
+
+### Cursor response shape (consistent across all three endpoints)
+```typescript
+// Applied to /api/discovery/items and /api/discovery/setups
+interface CursorPage {
+ items: T[];
+ nextCursor: string | null;
+ hasMore: boolean;
+}
+
+// Usage in route handler:
+const rows = await getRecentGlobalItems(db, limit + 1, cursor);
+const hasMore = rows.length > limit;
+const sliced = hasMore ? rows.slice(0, limit) : rows;
+const nextCursor = hasMore
+ ? sliced[sliced.length - 1].createdAt.toISOString()
+ : null;
+return c.json({ items: sliced, nextCursor, hasMore });
+```
+
+### Section skeleton pattern (matches existing `CatalogSearchOverlay` style)
+```typescript
+// Reuse the animate-pulse pattern from CatalogSearchOverlay.tsx
+function SectionSkeleton({ count = 6 }: { count?: number }) {
+ return (
+
+ );
+}
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Offset pagination (`LIMIT x OFFSET y`) | Cursor pagination (`WHERE createdAt < cursor`) | INFR-02 decision | Stable results, better performance at scale |
+| Personal dashboard as `/` | Public discovery landing as `/` | D-03 (this phase) | New visitors see content, not a login gate |
+| `PublicSetupCard` shows name + date only | Enhanced card adds item count + creator name | This phase | Sufficient context to judge "popular" setup quality |
+
+**Deprecated/outdated:**
+- `DashboardPage` function in `index.tsx`: retired by D-03; file is rewritten, component removed
+- `DashboardCard` component: no longer rendered from `/`; not deleted (may be useful elsewhere) but imports removed from `index.tsx`
+
+---
+
+## Environment Availability
+
+Step 2.6: SKIPPED — This phase is purely client/server code changes within the existing project stack. No new external tools, services, runtimes, or CLI utilities are required beyond what's already installed (`bun`, `node`, PostgreSQL).
+
+---
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Bun test runner (built-in) |
+| Config file | none — `bun test` auto-discovers `*.test.ts` |
+| Quick run command | `bun test tests/services/discovery.service.test.ts` |
+| Full suite command | `bun test` |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| DISC-01 | Hero search bar renders; clicking triggers CatalogSearchOverlay | smoke | E2E only — overlay integration | ❌ Wave 0 (E2E) |
+| DISC-02 | `getPopularSetups` returns public setups ordered by item count desc | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 |
+| DISC-02 | `GET /api/discovery/setups` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 |
+| DISC-03 | `getRecentGlobalItems` returns items ordered by createdAt desc | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 |
+| DISC-03 | `GET /api/discovery/items` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 |
+| DISC-04 | `getTrendingCategories` excludes null categories, orders by count | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 |
+| DISC-04 | `GET /api/discovery/categories` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 |
+| DISC-05 | "Go to Collection" link absent for anonymous, present for authenticated | smoke | E2E only — requires auth session | ❌ Wave 0 (E2E) |
+| INFR-02 | Cursor pagination: second page excludes items from first page | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** `bun test tests/services/discovery.service.test.ts tests/routes/discovery.test.ts`
+- **Per wave merge:** `bun test`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+- [ ] `tests/services/discovery.service.test.ts` — covers DISC-02, DISC-03, DISC-04, INFR-02 (service layer)
+- [ ] `tests/routes/discovery.test.ts` — covers DISC-02, DISC-03, DISC-04 route layer; anonymous access
+- [ ] `src/server/routes/discovery.ts` — new route file (Wave 0 stub before tests)
+- [ ] `src/server/services/discovery.service.ts` — new service file (Wave 0 stub before tests)
+- [ ] `src/client/hooks/useDiscovery.ts` — new hooks file
+
+---
+
+## Open Questions
+
+1. **Creator name display in popular setups feed**
+ - What we know: `setups.userId` exists; `users.displayName` is nullable text
+ - What's unclear: How many users have null `displayName` (could be most in early data)
+ - Recommendation: Render "by {name}" only when `creatorName` is non-null; show nothing otherwise. Do not fall back to email (privacy concern).
+
+2. **"View all" link destinations for setups and items**
+ - What we know: Claude's Discretion says this is unresolved
+ - What's unclear: No dedicated `/catalog` browse page or `/setups` public listing page exists yet
+ - Recommendation: "View all" for items links to `/global-items` (existing catalog page). "View all" for setups can be omitted for v1 of this page if no public setups listing page exists. Verify that `/global-items` route exists as a valid destination.
+
+3. **Cursor pagination for `categories` endpoint**
+ - What we know: INFR-02 requires cursor pagination for discovery feed; categories are ranked by count
+ - What's unclear: Categories list will be small (10-50 items max in early data); cursor pagination may be over-engineering for categories
+ - Recommendation: Use `limit` param for categories without cursor (no pagination). Categories don't grow unboundedly and the full list is small. Use cursor only for `setups` and `items` as decision D-08 specifies.
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- Codebase direct read — `src/server/routes/global-items.ts`, `src/server/services/global-item.service.ts`, `src/db/schema.ts`, `src/server/index.ts`, `src/client/routes/__root.tsx`, `src/client/stores/uiStore.ts`, `src/client/components/CatalogSearchOverlay.tsx`, `src/client/components/GlobalItemCard.tsx`, `src/client/components/PublicSetupCard.tsx`
+- CONTEXT.md — Locked decisions D-01 through D-11
+- CLAUDE.md — Project stack, patterns, reusable component guidelines
+
+### Secondary (MEDIUM confidence)
+- Drizzle ORM standard patterns for `leftJoin`, `groupBy`, `count`, `desc` — consistent with existing codebase usage
+
+### Tertiary (LOW confidence)
+- None
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — all in project, no new deps
+- Architecture patterns: HIGH — based on direct codebase reads, locked decisions
+- Pitfalls: HIGH — derived from actual code inspection (nullable category, auth allowlist gaps, interface changes)
+- Validation approach: HIGH — matches existing test patterns in `tests/services/` and `tests/routes/`
+
+**Research date:** 2026-04-10
+**Valid until:** 2026-05-10 (stable stack, no fast-moving dependencies)