()((set) => ({
+ activeTab: 'calendar',
+ createListSheetOpen: false,
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ setCreateListSheetOpen: (open) => set({ createListSheetOpen: open }),
+}))
+```
+
+Convention from calendarStore.ts: no `persist` middleware used in this project — state is ephemeral (view persistence done manually with localStorage in calendarStore; lists UI state does not need persistence).
+
+---
+
+### `apps/pwa/src/routes/ListsIndex.tsx` (component, CRUD)
+
+**Analog:** `apps/pwa/src/components/CalendarShell.tsx` lines 1–60 (header shown above).
+
+**Data fetching pattern** — useQuery with credentials (from CalendarShell + client.ts):
+```tsx
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { fetchLists, createList, deleteList } from '../api/listsClient.js'
+
+const { data, isLoading, isError } = useQuery({
+ queryKey: ['lists'],
+ queryFn: fetchLists,
+})
+```
+
+**State branches** — mirror CalendarShell: `isLoading` → skeleton/empty, `isError` → error state with retry, `data` → render list. CalendarShell uses `isLoading` / `isError` / success branches explicitly.
+
+**useMutation with optimistic update** (from RESEARCH.md Finding 6):
+```tsx
+const queryClient = useQueryClient()
+
+const deleteMutation = useMutation({
+ mutationFn: (listId: number) => deleteList(listId),
+ onMutate: async (listId) => {
+ await queryClient.cancelQueries({ queryKey: ['lists'] })
+ const previous = queryClient.getQueryData(['lists'])
+ queryClient.setQueryData(['lists'], (old: any) => ({
+ ...old,
+ lists: old.lists.filter((l: any) => l.id !== listId),
+ }))
+ return { previous }
+ },
+ onError: (_err, _vars, context) => {
+ if (context?.previous) queryClient.setQueryData(['lists'], context.previous)
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['lists'] })
+ },
+})
+```
+
+**DeleteConfirmationDialog reuse** — import from `../components/DeleteConfirmationDialog.js` and render conditionally. The existing dialog is tightly coupled to `calendarStore`; create a new list-delete confirmation component (`ListDeleteDialog`) that mirrors its structure but is driven by `listsStore`. Do NOT modify the existing dialog — it is stable (D-06 says reuse, but the implementation is wired to calendarStore).
+
+---
+
+### `apps/pwa/src/routes/ListDetail.tsx` (component, CRUD + event-driven)
+
+**Analog:** `apps/pwa/src/components/CalendarShell.tsx`
+
+**SSE + polling pattern** (from RESEARCH.md Finding 4):
+```tsx
+import { useListSSE } from '../hooks/useListSSE.js'
+
+const { data } = useQuery({
+ queryKey: ['list', listId],
+ queryFn: () => fetchListItems(listId),
+ refetchInterval: 30_000, // D-12: polling fallback always active
+})
+
+useListSSE({ listId, onStateChange: setSyncState })
+```
+
+**Active / completed section split** (D-05):
+```tsx
+const activeItems = items.filter((i) => !i.checked).sort(/* by rank ASC */)
+const completedItems = items.filter((i) => i.checked)
+```
+
+---
+
+### `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (reuse — no modification)
+
+**This file is not modified.** A new `ListDeleteDialog.tsx` mirrors its structure:
+- Same modal layout: fixed backdrop + centered dialog
+- Same CSS tokens: `var(--color-overlay)`, `var(--color-surface-raised)`, `var(--color-destructive)`, `var(--space-*)`, `var(--text-*)`, `var(--font-family-base)`
+- Same `useMutation` + `onSuccess` → close pattern (lines 64–76 of DeleteConfirmationDialog.tsx)
+- Same accessibility: `role="dialog"`, `aria-modal="true"`, Escape key listener, `tabIndex={-1}` + focus on open
+
+Key lines to copy for the modal skeleton (lines 86–208 of DeleteConfirmationDialog.tsx) — swap the heading text to "Delete list?" and the body text to "All items in this list will be permanently deleted."
+
+---
+
+### `apps/pwa/src/components/ItemRow.tsx` (component, event-driven)
+
+**Analog:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (for mutation + CSS token patterns)
+
+**dnd-kit drag handle pattern** (from RESEARCH.md Finding 7):
+```tsx
+import { useSortable } from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { GripVertical } from 'lucide-react'
+
+export function ItemRow({ item, onCheck, onDelete }) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
+ useSortable({ id: item.id })
+
+ return (
+
+ {/* Drag handle — listeners on handle only (not whole row) */}
+
+ {/* ... checkbox, text, delete button */}
+
+ )
+}
+```
+
+---
+
+### `apps/pwa/src/hooks/useListSSE.ts` (hook, event-driven)
+
+**No codebase analog.** Use the pattern from RESEARCH.md Finding 4 verbatim. Key conventions:
+- `useCallback` for `connect` to keep the `useEffect` dependency stable
+- `esRef`, `attemptsRef`, `timerRef` — all `useRef` (not state) to avoid re-render loops
+- Close `es` on error before scheduling retry (prevents browser auto-reconnect stacking with manual reconnect)
+- `withCredentials: true` on `new EventSource(...)` — required for session cookie (Pitfall 7)
+- Return `syncState` so the caller can render `LiveSyncIndicator`
+
+---
+
+## Shared Patterns
+
+### Auth / User Resolution
+**Source:** `apps/api/src/routes/events.ts` lines 59–76
+**Apply to:** `apps/api/src/routes/lists.ts`
+```typescript
+async function resolveUserId(c: any): Promise {
+ const devUser = c.get('user') as { id: number } | undefined
+ if (devUser) return devUser.id
+
+ const auth = await getAuth(c)
+ if (!auth) return null
+
+ const iss = (auth.iss as string | undefined) ?? ''
+ const sub = auth.sub ?? ''
+ const displayName = deriveDisplayName(auth)
+ const user = await upsertUser(iss, sub, displayName)
+ return user?.id ?? null
+}
+```
+Copy verbatim — do not extract to a shared module (existing convention duplicates this per router).
+
+### Error Handling (API routes)
+**Source:** `apps/api/src/routes/events.ts` (every handler's catch block)
+**Apply to:** `apps/api/src/routes/lists.ts`
+```typescript
+try {
+ // ... db operations ...
+ return c.json({ /* result */ })
+} catch (err) {
+ console.error('[lists/] DB operation failed:', err)
+ return c.json({ error: 'Service unavailable' }, 503)
+}
+```
+Pattern: 503 on caught exceptions, not 500. `console.error` with a `[module/endpoint]` prefix tag.
+
+### 401 Guard Pattern
+**Source:** `apps/api/src/routes/events.ts` (every handler, lines 125–126):
+```typescript
+const currentUserId = await resolveUserId(c)
+if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
+```
+First two lines of every protected handler.
+
+### CSS Token Usage (PWA components)
+**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (all inline styles)
+**Apply to:** all new PWA components
+Token set in use:
+- `var(--color-surface)`, `var(--color-surface-raised)`, `var(--color-overlay)`
+- `var(--color-text-primary)`, `var(--color-text-secondary)`
+- `var(--color-destructive)`, `var(--color-border)`
+- `var(--space-1)` through `var(--space-6)`
+- `var(--text-body-size)`, `var(--text-heading-size)`, `var(--text-label-size)`, `var(--font-family-base)`
+- Minimum touch target: `minHeight: '44px'` (buttons/rows)
+
+### Fetch with Credentials (PWA API client)
+**Source:** `apps/pwa/src/api/client.ts` lines 36–39
+**Apply to:** `apps/pwa/src/api/listsClient.ts`
+```typescript
+const res = await fetch('/api/...', {
+ credentials: 'include',
+ redirect: 'manual', // only for /api/me; not required for data endpoints
+})
+```
+All list API calls use `credentials: 'include'`. `redirect: 'manual'` is only needed for the initial session check (`/api/me`) — not for list CRUD endpoints.
+
+### TanStack Query Keys
+**Apply to:** `apps/pwa/src/routes/ListsIndex.tsx`, `apps/pwa/src/routes/ListDetail.tsx`
+```typescript
+// List of lists
+queryKey: ['lists']
+
+// Items for a specific list
+queryKey: ['list', listId] // listId is a number
+```
+Invalidate `['lists']` after create/delete list. Invalidate `['list', listId]` after any item mutation or SSE event for that list.
+
+### Lucide Icons (PWA)
+**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` line 27, CalendarShell.tsx line 43
+**Apply to:** all new PWA components
+```tsx
+import { Trash2 } from 'lucide-react'
+// Usage:
+```
+Always pass `aria-hidden="true"` to decorative icons. Use `size={16}` for inline/dense contexts, `size={22}` for navigation tabs.
+
+### Zustand Store Shape
+**Source:** `apps/pwa/src/store/calendarStore.ts`
+**Apply to:** `apps/pwa/src/store/listsStore.ts`
+- Use `create()((set) => ({ ... }))` — no `persist`, no `immer`
+- Actions are inline setter functions, not separate files
+- UI-only state: no server data, no async in store actions (mutations live in components via `useMutation`)
+
+### Plain-text XSS Guard (PWA)
+**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (comment `/* Plain text — XSS guard */` on every text node, line 133 etc.)
+**Apply to:** all new PWA components that render user-supplied strings (list names, item text)
+Never use `dangerouslySetInnerHTML`. All user content is a JSX text child.
+
+---
+
+## No Analog Found
+
+| File | Role | Data Flow | Reason |
+|------|------|-----------|--------|
+| `apps/api/src/lib/listEmitter.ts` | utility | event-driven | No EventEmitter or pub/sub pattern exists in the codebase. Use RESEARCH.md Finding 1 pattern. |
+| `apps/pwa/src/components/LiveSyncIndicator.tsx` | component | event-driven | No live-sync state indicator exists. Novel UI component — use CSS token conventions and the "disconnected / updates paused" UX from D-11. |
+| `apps/pwa/src/hooks/useListSSE.ts` | hook | event-driven | No custom hooks exist in the codebase. Novel — use RESEARCH.md Finding 4 pattern. |
+
+---
+
+## Metadata
+
+**Analog search scope:** `apps/api/src/routes/`, `apps/api/src/db/`, `apps/api/src/lib/`, `apps/api/src/index.ts`, `apps/pwa/src/`, `apps/pwa/src/components/`, `apps/pwa/src/api/`, `apps/pwa/src/store/`
+**Files scanned:** 12 source files read in full
+**Pattern extraction date:** 2026-06-09