@prmichaelsen/acp-visualizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +68 -0
  2. package/agent/commands/acp.clarification-address.md +417 -0
  3. package/agent/commands/acp.clarification-capture.md +386 -0
  4. package/agent/commands/acp.clarification-create.md +437 -0
  5. package/agent/commands/acp.clarifications-research.md +326 -0
  6. package/agent/commands/acp.command-create.md +432 -0
  7. package/agent/commands/acp.design-create.md +286 -0
  8. package/agent/commands/acp.design-reference.md +355 -0
  9. package/agent/commands/acp.handoff.md +270 -0
  10. package/agent/commands/acp.index.md +423 -0
  11. package/agent/commands/acp.init.md +546 -0
  12. package/agent/commands/acp.package-create.md +895 -0
  13. package/agent/commands/acp.package-info.md +212 -0
  14. package/agent/commands/acp.package-install.md +539 -0
  15. package/agent/commands/acp.package-list.md +280 -0
  16. package/agent/commands/acp.package-publish.md +541 -0
  17. package/agent/commands/acp.package-remove.md +293 -0
  18. package/agent/commands/acp.package-search.md +307 -0
  19. package/agent/commands/acp.package-update.md +361 -0
  20. package/agent/commands/acp.package-validate.md +540 -0
  21. package/agent/commands/acp.pattern-create.md +386 -0
  22. package/agent/commands/acp.plan.md +587 -0
  23. package/agent/commands/acp.proceed.md +882 -0
  24. package/agent/commands/acp.project-create.md +675 -0
  25. package/agent/commands/acp.project-info.md +312 -0
  26. package/agent/commands/acp.project-list.md +226 -0
  27. package/agent/commands/acp.project-remove.md +379 -0
  28. package/agent/commands/acp.project-set.md +227 -0
  29. package/agent/commands/acp.project-update.md +307 -0
  30. package/agent/commands/acp.projects-restore.md +228 -0
  31. package/agent/commands/acp.projects-sync.md +347 -0
  32. package/agent/commands/acp.report.md +407 -0
  33. package/agent/commands/acp.resume.md +239 -0
  34. package/agent/commands/acp.sessions.md +301 -0
  35. package/agent/commands/acp.status.md +293 -0
  36. package/agent/commands/acp.sync.md +364 -0
  37. package/agent/commands/acp.task-create.md +500 -0
  38. package/agent/commands/acp.update.md +302 -0
  39. package/agent/commands/acp.validate.md +466 -0
  40. package/agent/commands/acp.version-check-for-updates.md +276 -0
  41. package/agent/commands/acp.version-check.md +191 -0
  42. package/agent/commands/acp.version-update.md +289 -0
  43. package/agent/commands/command.template.md +339 -0
  44. package/agent/commands/git.commit.md +526 -0
  45. package/agent/commands/git.init.md +514 -0
  46. package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
  47. package/agent/commands/tanstack-cloudflare.tail.md +275 -0
  48. package/agent/design/.gitkeep +0 -0
  49. package/agent/design/design.template.md +154 -0
  50. package/agent/design/local.dashboard-layout-routing.md +288 -0
  51. package/agent/design/local.data-model-yaml-parsing.md +310 -0
  52. package/agent/design/local.search-filtering.md +331 -0
  53. package/agent/design/local.server-api-auto-refresh.md +235 -0
  54. package/agent/design/local.table-tree-views.md +299 -0
  55. package/agent/design/local.visualizer-requirements.md +349 -0
  56. package/agent/design/requirements.template.md +387 -0
  57. package/agent/index/.gitkeep +0 -0
  58. package/agent/index/acp.core.yaml +137 -0
  59. package/agent/index/local.main.template.yaml +37 -0
  60. package/agent/manifest.template.yaml +13 -0
  61. package/agent/manifest.yaml +302 -0
  62. package/agent/milestones/.gitkeep +0 -0
  63. package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
  64. package/agent/milestones/milestone-1-{title}.template.md +206 -0
  65. package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
  66. package/agent/package.template.yaml +86 -0
  67. package/agent/patterns/.gitkeep +0 -0
  68. package/agent/patterns/bootstrap.template.md +1237 -0
  69. package/agent/patterns/pattern.template.md +382 -0
  70. package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
  71. package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
  72. package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
  73. package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
  74. package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
  75. package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
  76. package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
  77. package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
  78. package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
  79. package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
  80. package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
  81. package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
  82. package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
  83. package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
  84. package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
  85. package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
  86. package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
  87. package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
  88. package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
  89. package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
  90. package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
  91. package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
  92. package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
  93. package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
  94. package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
  95. package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
  96. package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
  97. package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
  98. package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
  99. package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
  100. package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
  101. package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
  102. package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
  103. package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
  104. package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
  105. package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
  106. package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
  107. package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
  108. package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
  109. package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
  110. package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
  111. package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
  112. package/agent/progress.template.yaml +161 -0
  113. package/agent/progress.yaml +145 -0
  114. package/agent/schemas/package.schema.yaml +276 -0
  115. package/agent/scripts/acp.common.sh +1781 -0
  116. package/agent/scripts/acp.install.sh +333 -0
  117. package/agent/scripts/acp.package-create.sh +924 -0
  118. package/agent/scripts/acp.package-info.sh +288 -0
  119. package/agent/scripts/acp.package-install.sh +893 -0
  120. package/agent/scripts/acp.package-list.sh +311 -0
  121. package/agent/scripts/acp.package-publish.sh +420 -0
  122. package/agent/scripts/acp.package-remove.sh +348 -0
  123. package/agent/scripts/acp.package-search.sh +156 -0
  124. package/agent/scripts/acp.package-update.sh +517 -0
  125. package/agent/scripts/acp.package-validate.sh +1018 -0
  126. package/agent/scripts/acp.uninstall.sh +85 -0
  127. package/agent/scripts/acp.version-check-for-updates.sh +98 -0
  128. package/agent/scripts/acp.version-check.sh +47 -0
  129. package/agent/scripts/acp.version-update.sh +176 -0
  130. package/agent/scripts/acp.yaml-parser.sh +985 -0
  131. package/agent/scripts/acp.yaml-validate.sh +205 -0
  132. package/agent/tasks/.gitkeep +0 -0
  133. package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
  134. package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
  135. package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
  136. package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
  137. package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
  138. package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
  139. package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
  140. package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
  141. package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
  142. package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
  143. package/agent/tasks/task-1-{title}.template.md +244 -0
  144. package/bin/visualize.mjs +84 -0
  145. package/package.json +48 -0
  146. package/src/components/ExtraFieldsBadge.tsx +15 -0
  147. package/src/components/FilterBar.tsx +33 -0
  148. package/src/components/Header.tsx +23 -0
  149. package/src/components/MilestoneTable.tsx +167 -0
  150. package/src/components/MilestoneTree.tsx +84 -0
  151. package/src/components/ProgressBar.tsx +20 -0
  152. package/src/components/SearchInput.tsx +22 -0
  153. package/src/components/Sidebar.tsx +54 -0
  154. package/src/components/StatusBadge.tsx +23 -0
  155. package/src/components/StatusDot.tsx +12 -0
  156. package/src/components/TaskList.tsx +36 -0
  157. package/src/components/ViewToggle.tsx +31 -0
  158. package/src/lib/config.ts +8 -0
  159. package/src/lib/file-watcher.ts +43 -0
  160. package/src/lib/search.ts +48 -0
  161. package/src/lib/types.ts +73 -0
  162. package/src/lib/useAutoRefresh.ts +31 -0
  163. package/src/lib/useCollapse.ts +31 -0
  164. package/src/lib/useFilteredData.ts +55 -0
  165. package/src/lib/yaml-loader-real.spec.ts +47 -0
  166. package/src/lib/yaml-loader.spec.ts +201 -0
  167. package/src/lib/yaml-loader.ts +265 -0
  168. package/src/routeTree.gen.ts +140 -0
  169. package/src/router.tsx +10 -0
  170. package/src/routes/__root.tsx +75 -0
  171. package/src/routes/api/watch.ts +29 -0
  172. package/src/routes/index.tsx +115 -0
  173. package/src/routes/milestones.tsx +50 -0
  174. package/src/routes/search.tsx +84 -0
  175. package/src/routes/tasks.tsx +63 -0
  176. package/src/services/progress-database.service.ts +46 -0
  177. package/src/styles.css +25 -0
  178. package/tsconfig.json +24 -0
  179. package/vite.config.ts +16 -0
  180. package/vitest.config.ts +27 -0
@@ -0,0 +1,442 @@
1
+ # Pagination Suite
2
+
3
+ **Category**: Design
4
+ **Applicable To**: All paginated data display — explicit page controls, infinite scroll, virtualized lists, offset/limit APIs, and view mode toggles
5
+ **Status**: Stable
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ The pagination suite provides four complementary strategies for navigating large data sets: Paginator (explicit page controls with editable current page), PaginationToggle (paginated vs infinite mode switch with page-size slider), InfiniteScrollSentinel (auto-load on scroll via IntersectionObserver), and react-virtuoso / @tanstack/react-virtual for virtualized rendering. All feed APIs use a consistent offset/limit pattern with `{ items, total, hasMore }` responses.
12
+
13
+ ---
14
+
15
+ ## When to Use This Pattern
16
+
17
+ | Strategy | When to Use |
18
+ |---|---|
19
+ | **Paginator** | Discrete page navigation with known total pages (e.g., reorder grid) |
20
+ | **PaginationToggle** | User should choose between paginated and infinite modes |
21
+ | **Virtuoso `useWindowScroll`** | Page-level feed with infinite scroll (memories, spaces, profiles) |
22
+ | **Virtuoso container-scroll** | Fixed-height container with prepend (chat messages) |
23
+ | **@tanstack/react-virtual** | Contained scrollable list within a panel (comments) |
24
+ | **InfiniteScrollSentinel** | Simple auto-load trigger without full virtualization |
25
+ | **FeedList (non-virtualized)** | Small static lists (< ~50 items) with loading/empty/error states |
26
+
27
+ ---
28
+
29
+ ## Core Principles
30
+
31
+ 1. **Offset/Limit API Convention**: All paginated endpoints accept `limit` + `offset` and return `{ items, total, hasMore }`
32
+ 2. **Clamped Limits**: Server clamps `limit` to `[1, 50]` — client defaults to `PAGE_SIZE = 20`
33
+ 3. **Offset Ref**: Client tracks current offset in a `useRef` (not state) to avoid stale closures in callbacks
34
+ 4. **Cache First Pages**: First-page results are cached for instant tab/filter switching; load-more appends are not cached
35
+ 5. **URL State for Pagination**: Page number, page size, and view mode sync to URL search params via `replaceState`
36
+
37
+ ---
38
+
39
+ ## Implementation
40
+
41
+ ### Paginator (Explicit Page Controls)
42
+
43
+ **File**: `src/components/Paginator.tsx`
44
+
45
+ ```typescript
46
+ interface PaginatorProps {
47
+ currentPage: number
48
+ totalPages: number
49
+ onPageChange: (page: number) => void
50
+ /** Number of sibling page numbers on each side of current (default: 2) */
51
+ siblings?: number
52
+ }
53
+ ```
54
+
55
+ **Layout**: `|< < 3 4 [X] 5 6 > >|`
56
+
57
+ - **First/Last**: `ChevronsLeft` / `ChevronsRight` — jump to page 1 or totalPages
58
+ - **Prev/Next**: `ChevronLeft` / `ChevronRight` — single page step
59
+ - **Siblings**: Clickable page numbers ±`siblings` from current
60
+ - **Editable current page**: Gradient input (`from-purple-600 to-blue-600`)
61
+ - Auto-selects on focus
62
+ - Enter commits, Escape reverts, blur commits
63
+ - `inputMode="numeric"` for mobile keyboards
64
+ - Value clamped to `[1, totalPages]`
65
+ - **Hidden**: Returns `null` if `totalPages <= 1`
66
+ - **Accessibility**: `aria-label` on all buttons and input
67
+
68
+ ---
69
+
70
+ ### PaginationToggle (Mode Switch + Page Size)
71
+
72
+ **File**: `src/components/reorder/PaginationToggle.tsx`
73
+
74
+ ```typescript
75
+ interface PaginationToggleProps {
76
+ viewMode: 'infinite' | 'paginated'
77
+ onViewModeChange: (mode: 'infinite' | 'paginated') => void
78
+ currentPage?: number
79
+ totalPages?: number
80
+ onPageChange?: (page: number) => void
81
+ pageSize?: number
82
+ onPageSizeChange?: (size: number) => void
83
+ }
84
+ ```
85
+
86
+ **Features**:
87
+ - **View mode pills**: "Pages" | "Infinite" toggle buttons
88
+ - Active: gradient `from-purple-600 to-blue-600` with shadow
89
+ - Inactive: gray with hover
90
+ - **Paginated mode shows**:
91
+ - Page size slider (discrete options: `[5, 50, 70, 100]`) with "Per page: N" label
92
+ - Paginator component for page navigation
93
+ - **Page size change resets to page 1**
94
+
95
+ **Usage**:
96
+
97
+ ```typescript
98
+ <PaginationToggle
99
+ viewMode={viewMode}
100
+ onViewModeChange={setViewMode}
101
+ currentPage={currentPage}
102
+ totalPages={totalPages}
103
+ onPageChange={setCurrentPage}
104
+ pageSize={pageSize}
105
+ onPageSizeChange={(size) => {
106
+ setPageSize(size)
107
+ setCurrentPage(1) // Reset on size change
108
+ }}
109
+ />
110
+ ```
111
+
112
+ ---
113
+
114
+ ### InfiniteScrollSentinel (Auto-Load Trigger)
115
+
116
+ **File**: `src/components/feed/InfiniteScrollSentinel.tsx`
117
+
118
+ ```typescript
119
+ interface InfiniteScrollSentinelProps {
120
+ hasMore: boolean
121
+ loading: boolean
122
+ onLoadMore: () => void
123
+ }
124
+ ```
125
+
126
+ **Implementation**:
127
+ - Renders a sentinel `<div>` (h-4) watched by IntersectionObserver (threshold: 0.1)
128
+ - Triggers `onLoadMore` when sentinel becomes 10% visible AND `hasMore && !loading`
129
+ - Shows `<Loader2 animate-spin>` during loading
130
+ - Place at the bottom of a scrollable list
131
+
132
+ **Usage**:
133
+
134
+ ```typescript
135
+ {memories.map(m => <MemoryCard key={m.id} memory={m} />)}
136
+ <InfiniteScrollSentinel hasMore={hasMore} loading={loadingMore} onLoadMore={() => loadFeed(false)} />
137
+ ```
138
+
139
+ ---
140
+
141
+ ### Virtuoso (react-virtuoso) — Feed & Chat Patterns
142
+
143
+ #### Pattern A: Window-Scroll Feed
144
+
145
+ Used by: memories, SpacesFeed, ProfileMemoriesFeed, GroupMemories
146
+
147
+ ```typescript
148
+ import { Virtuoso } from 'react-virtuoso'
149
+
150
+ <Virtuoso
151
+ useWindowScroll
152
+ data={memories}
153
+ endReached={() => {
154
+ if (hasMore && !loading && !loadingMore) {
155
+ loadFeed(false)
156
+ }
157
+ }}
158
+ itemContent={(index, memory) => (
159
+ <div className="pb-2">
160
+ <MemoryCard memory={memory} />
161
+ </div>
162
+ )}
163
+ components={{
164
+ Footer: () => loadingMore ? (
165
+ <div className="flex justify-center py-4">
166
+ <Loader2 className="w-5 h-5 animate-spin text-gray-500" />
167
+ </div>
168
+ ) : null,
169
+ }}
170
+ />
171
+ ```
172
+
173
+ #### Pattern B: Container-Scroll Chat (Prepend)
174
+
175
+ Used by: MessageList
176
+
177
+ ```typescript
178
+ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
179
+
180
+ const INITIAL_INDEX = 100_000
181
+ const virtuosoRef = useRef<VirtuosoHandle>(null)
182
+ const [firstItemIndex, setFirstItemIndex] = useState(INITIAL_INDEX - items.length)
183
+
184
+ // Update firstItemIndex when items prepend
185
+ useEffect(() => {
186
+ setFirstItemIndex(INITIAL_INDEX - items.length)
187
+ }, [items.length])
188
+
189
+ <Virtuoso
190
+ ref={virtuosoRef}
191
+ className="flex-grow h-0"
192
+ firstItemIndex={firstItemIndex}
193
+ initialTopMostItemIndex={items.length - 1}
194
+ data={items}
195
+ startReached={() => {
196
+ if (!isLoadingMore && hasMore && onLoadMore) {
197
+ setIsLoadingMore(true)
198
+ onLoadMore()
199
+ }
200
+ }}
201
+ itemContent={(index, item) => <Message ... />}
202
+ />
203
+
204
+ // Programmatic scroll
205
+ virtuosoRef.current?.scrollToIndex({ index: 'LAST', behavior: 'smooth' })
206
+ ```
207
+
208
+ #### Pattern C: @tanstack/react-virtual (Container Panel)
209
+
210
+ Used by: CommentSection
211
+
212
+ ```typescript
213
+ import { useVirtualizer } from '@tanstack/react-virtual'
214
+
215
+ const virtualizer = useVirtualizer({
216
+ count: comments.length,
217
+ getScrollElement: () => scrollContainerRef.current,
218
+ estimateSize: () => 160,
219
+ overscan: 10,
220
+ })
221
+
222
+ // Manual scroll detection for load-more
223
+ const handleScroll = () => {
224
+ const el = scrollContainerRef.current
225
+ const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
226
+ if (distFromBottom < 100 && hasMore && !loadingMore) loadMore()
227
+ }
228
+
229
+ // Programmatic scroll
230
+ virtualizer.scrollToIndex(index, { align: 'start' })
231
+ ```
232
+
233
+ ---
234
+
235
+ ### Offset/Limit API Convention
236
+
237
+ All paginated endpoints follow this pattern:
238
+
239
+ **Request**:
240
+ ```
241
+ GET /api/spaces/feed?limit=20&offset=0&algorithm=smart&query=AI
242
+ ```
243
+
244
+ **Response**:
245
+ ```typescript
246
+ {
247
+ memories: MemoryItem[] // The page of results
248
+ total: number // Total matching items
249
+ hasMore: boolean // Whether more pages exist
250
+ limit: number // Echoed back
251
+ offset: number // Echoed back
252
+ maps?: {
253
+ profiles: Record<string, UserProfile> // Profile enrichment
254
+ }
255
+ }
256
+ ```
257
+
258
+ **Server-side clamping**:
259
+ ```typescript
260
+ const limit = Math.min(Math.max(parsedLimit || 20, 1), 50) // Clamp to [1, 50]
261
+ const offset = Math.max(parsedOffset || 0, 0)
262
+ const hasMore = offset + results.length < total
263
+ ```
264
+
265
+ ---
266
+
267
+ ### Client-Side Data Fetching Pattern
268
+
269
+ ```typescript
270
+ const PAGE_SIZE = 20
271
+ const offsetRef = useRef(0)
272
+ const [memories, setMemories] = useState<MemoryItem[]>([])
273
+ const [loading, setLoading] = useState(false)
274
+ const [loadingMore, setLoadingMore] = useState(false)
275
+ const [hasMore, setHasMore] = useState(false)
276
+
277
+ const loadFeed = useCallback(async (reset: boolean) => {
278
+ const currentOffset = reset ? 0 : offsetRef.current
279
+
280
+ if (reset) {
281
+ setLoading(true)
282
+ setMemories([])
283
+ offsetRef.current = 0
284
+ } else {
285
+ setLoadingMore(true)
286
+ }
287
+
288
+ try {
289
+ const result = await FeedService.getFeed({
290
+ limit: PAGE_SIZE,
291
+ offset: currentOffset,
292
+ // ... filters
293
+ })
294
+
295
+ const newItems = result.memories ?? []
296
+ setMemories(prev => reset ? newItems : [...prev, ...newItems])
297
+ setHasMore(result.hasMore ?? false)
298
+ offsetRef.current = currentOffset + newItems.length
299
+
300
+ // Cache first page for instant tab switching
301
+ if (reset) {
302
+ cache.set(cacheKey, { memories: newItems, total: result.total, hasMore: result.hasMore })
303
+ }
304
+ } finally {
305
+ setLoading(false)
306
+ setLoadingMore(false)
307
+ }
308
+ }, [filters])
309
+ ```
310
+
311
+ **Key details**:
312
+ - `offsetRef` (not state) avoids stale closure issues in `endReached` / `loadFeed` callbacks
313
+ - Reset clears items and resets offset to 0
314
+ - Append keeps existing items and advances offset
315
+ - Only first-page (reset) results are cached
316
+
317
+ ---
318
+
319
+ ### URL State Sync
320
+
321
+ ```typescript
322
+ useEffect(() => {
323
+ const url = new URL(window.location.href)
324
+ if (viewMode === 'paginated') {
325
+ url.searchParams.set('mode', 'pages')
326
+ url.searchParams.set('page', String(currentPage))
327
+ if (pageSize !== DEFAULT_PAGE_SIZE) {
328
+ url.searchParams.set('pageSize', String(pageSize))
329
+ } else {
330
+ url.searchParams.delete('pageSize')
331
+ }
332
+ } else {
333
+ url.searchParams.delete('mode')
334
+ url.searchParams.delete('page')
335
+ url.searchParams.delete('pageSize')
336
+ }
337
+ window.history.replaceState(null, '', url.toString())
338
+ }, [currentPage, viewMode, pageSize])
339
+ ```
340
+
341
+ ---
342
+
343
+ ## Anti-Patterns
344
+
345
+ ### Using State Instead of Ref for Offset
346
+
347
+ ```typescript
348
+ // Bad: Stale closure — endReached captures old offset value
349
+ const [offset, setOffset] = useState(0)
350
+ endReached={() => loadFeed(offset)} // offset is stale!
351
+
352
+ // Good: Ref always has current value
353
+ const offsetRef = useRef(0)
354
+ endReached={() => loadFeed(offsetRef.current)}
355
+ ```
356
+
357
+ ### Unbounded API Queries
358
+
359
+ ```typescript
360
+ // Bad: No limit — could return thousands of documents
361
+ const result = await fetch('/api/feed')
362
+
363
+ // Good: Always pass limit, server clamps to [1, 50]
364
+ const result = await fetch('/api/feed?limit=20&offset=0')
365
+ ```
366
+
367
+ ### Not Resetting Page on Filter Change
368
+
369
+ ```typescript
370
+ // Bad: Page 5 of old filter shows empty results
371
+ setAlgorithm('recent')
372
+ // currentPage still 5, but 'recent' might only have 3 pages
373
+
374
+ // Good: Reset to page 1 on any filter change
375
+ setAlgorithm('recent')
376
+ setCurrentPage(1)
377
+ ```
378
+
379
+ ### Caching Load-More Results
380
+
381
+ ```typescript
382
+ // Bad: Cache includes appended pages — stale on next visit
383
+ cache.set(key, { memories: allLoadedMemories })
384
+
385
+ // Good: Only cache first-page results
386
+ if (reset) {
387
+ cache.set(key, { memories: firstPageMemories, total, hasMore })
388
+ }
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Key Design Decisions
394
+
395
+ ### Architecture
396
+
397
+ | Decision | Choice | Rationale |
398
+ |---|---|---|
399
+ | API pagination style | Offset/limit (not cursor) | Simpler; works with Firestore's `startAfter` |
400
+ | Server limit clamp | [1, 50] | Prevents abuse; 50 is enough for any page |
401
+ | Client default page size | 20 | Good balance between load time and content density |
402
+ | Offset tracking | `useRef` (not state) | Avoids stale closures in scroll callbacks |
403
+ | First-page caching | Cache only reset fetches | Prevents stale data from cached load-more appends |
404
+
405
+ ### Component Selection
406
+
407
+ | Decision | Choice | Rationale |
408
+ |---|---|---|
409
+ | Feed virtualization | react-virtuoso | Best window-scroll support, prepend support for chat |
410
+ | Panel virtualization | @tanstack/react-virtual | Lighter weight for contained panels |
411
+ | Simple lists | FeedList (no virtualization) | < 50 items don't need virtual DOM overhead |
412
+ | Auto-load trigger | IntersectionObserver sentinel | No scroll listener needed; clean observer API |
413
+
414
+ ---
415
+
416
+ ## Checklist
417
+
418
+ - [ ] API endpoints clamp limit to `[1, 50]` and return `{ items, total, hasMore }`
419
+ - [ ] Client tracks offset in `useRef`, not `useState`
420
+ - [ ] Only first-page (reset) results are cached; load-more appends are not
421
+ - [ ] Page/filter changes reset offset to 0 and clear existing items
422
+ - [ ] Paginator returns `null` when `totalPages <= 1`
423
+ - [ ] PaginationToggle resets to page 1 when page size changes
424
+ - [ ] Virtuoso feeds use `useWindowScroll` for page-level scroll
425
+ - [ ] Virtuoso chat uses `firstItemIndex` pattern for stable prepend
426
+ - [ ] InfiniteScrollSentinel placed at list bottom with `hasMore` + `loading` guards
427
+ - [ ] Pagination state synced to URL via `replaceState` for deep-linkability
428
+
429
+ ---
430
+
431
+ ## Related Patterns
432
+
433
+ - **[Card & List](./tanstack-cloudflare.card-and-list.md)**: FeedList for non-virtualized lists, Virtuoso usage details
434
+ - **[Form Controls](./tanstack-cloudflare.form-controls.md)**: Slider component used in PaginationToggle for page size
435
+ - **[SSR Preload](./ssr-preload.md)**: Server-side first-page preload seeds the Virtuoso data array
436
+ - **[Feed State Preservation](./local.feed-state-preservation.md)**: FeedCacheContext preserves feed state across navigation
437
+
438
+ ---
439
+
440
+ **Status**: Stable
441
+ **Last Updated**: 2026-03-14
442
+ **Contributors**: Community
@@ -0,0 +1,220 @@
1
+ # PillInput & Multi-Typeahead Selector
2
+
3
+ **Category**: Design
4
+ **Applicable To**: Multi-value inputs with typeahead suggestions, tag selection, and filterable option lists
5
+ **Status**: Stable
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ PillInput is a multi-value text input with Fuse.js fuzzy typeahead, removable pill badges, custom entry support, and keyboard navigation. It supports both selecting from a predefined options list and entering custom free-text values. Used for tag filters, space selectors, and multi-value search inputs.
12
+
13
+ ---
14
+
15
+ ## Implementation
16
+
17
+ ### PillInput
18
+
19
+ **File**: `src/components/feed/PillInput.tsx`
20
+
21
+ ```typescript
22
+ interface PillInputProps {
23
+ options: string[] // Available options for typeahead
24
+ selected: string[] // Currently selected values
25
+ onChange: (selected: string[]) => void
26
+ placeholder?: string
27
+ allowCustom?: boolean // Allow free-text entries (default: true)
28
+ maxResults?: number // Max suggestions shown (default: 8)
29
+ }
30
+ ```
31
+
32
+ **Features**:
33
+
34
+ 1. **Typeahead with Fuse.js** (threshold: 0.4):
35
+ - Filters options list as user types
36
+ - Removes already-selected values from suggestions
37
+ - Shows up to `maxResults` matches
38
+
39
+ 2. **Custom Entry** (when `allowCustom: true`):
40
+ - Enter or comma adds current text as custom pill
41
+ - Trims whitespace, prevents duplicates
42
+
43
+ 3. **Keyboard Navigation**:
44
+ - ArrowUp/ArrowDown: navigate dropdown suggestions
45
+ - Enter: select highlighted suggestion (or add custom)
46
+ - Escape: close dropdown
47
+ - Backspace on empty input: remove last pill
48
+
49
+ 4. **Pill Display**:
50
+ - Gradient background: `from-purple-500 to-blue-500`
51
+ - X button to remove individual pills
52
+ - Flex wrap layout for multiple pills
53
+
54
+ 5. **Dropdown**:
55
+ - Positioned below input
56
+ - Max 8 results
57
+ - Highlighted item via keyboard or hover
58
+ - Click outside closes dropdown
59
+
60
+ **Usage**:
61
+
62
+ ```typescript
63
+ const [tags, setTags] = useState<string[]>([])
64
+
65
+ <PillInput
66
+ options={['javascript', 'typescript', 'python', 'rust', 'go']}
67
+ selected={tags}
68
+ onChange={setTags}
69
+ placeholder="Add tags..."
70
+ allowCustom={true}
71
+ />
72
+ ```
73
+
74
+ **Rendered Output**:
75
+
76
+ ```
77
+ ┌─────────────────────────────────────────────┐
78
+ │ [javascript ×] [rust ×] type here... │
79
+ ├─────────────────────────────────────────────┤
80
+ │ ▸ typescript │
81
+ │ python │
82
+ │ go │
83
+ └─────────────────────────────────────────────┘
84
+ ```
85
+
86
+ ---
87
+
88
+ ### Multi-Typeahead Pattern (Generalized)
89
+
90
+ For more complex multi-select scenarios beyond string arrays, use this generalized approach:
91
+
92
+ ```typescript
93
+ interface TypeaheadOption<T> {
94
+ id: string
95
+ label: string
96
+ data: T
97
+ }
98
+
99
+ function useMultiTypeahead<T>(options: TypeaheadOption<T>[], selected: string[]) {
100
+ const [query, setQuery] = useState('')
101
+ const [highlightIndex, setHighlightIndex] = useState(-1)
102
+
103
+ const fuse = useMemo(() => new Fuse(options, {
104
+ keys: ['label'],
105
+ threshold: 0.4,
106
+ }), [options])
107
+
108
+ const suggestions = useMemo(() => {
109
+ const base = query
110
+ ? fuse.search(query).map(r => r.item)
111
+ : options
112
+ return base
113
+ .filter(opt => !selected.includes(opt.id))
114
+ .slice(0, 8)
115
+ }, [query, selected, fuse, options])
116
+
117
+ const handleKeyDown = (e: KeyboardEvent) => {
118
+ switch (e.key) {
119
+ case 'ArrowDown':
120
+ e.preventDefault()
121
+ setHighlightIndex(i => Math.min(suggestions.length - 1, i + 1))
122
+ break
123
+ case 'ArrowUp':
124
+ e.preventDefault()
125
+ setHighlightIndex(i => Math.max(0, i - 1))
126
+ break
127
+ case 'Enter':
128
+ e.preventDefault()
129
+ if (highlightIndex >= 0) selectOption(suggestions[highlightIndex])
130
+ break
131
+ case 'Escape':
132
+ setQuery('')
133
+ break
134
+ case 'Backspace':
135
+ if (!query && selected.length) removeLastSelected()
136
+ break
137
+ }
138
+ }
139
+
140
+ return { query, setQuery, suggestions, highlightIndex, handleKeyDown }
141
+ }
142
+ ```
143
+
144
+ **Usage with complex objects**:
145
+
146
+ ```typescript
147
+ const { query, setQuery, suggestions, highlightIndex, handleKeyDown } = useMultiTypeahead(
148
+ users.map(u => ({ id: u.uid, label: u.displayName, data: u })),
149
+ selectedUserIds
150
+ )
151
+
152
+ <input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={handleKeyDown} />
153
+ {suggestions.map((opt, i) => (
154
+ <div key={opt.id}
155
+ className={i === highlightIndex ? 'bg-gray-700' : ''}
156
+ onClick={() => addSelection(opt.id)}>
157
+ <Avatar user={opt.data} />
158
+ <span>{opt.label}</span>
159
+ </div>
160
+ ))}
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Anti-Patterns
166
+
167
+ ### Using Select/Multiselect Instead of PillInput
168
+
169
+ ```typescript
170
+ // Bad: Native multi-select is ugly and hard to use on mobile
171
+ <select multiple>{options.map(o => <option>{o}</option>)}</select>
172
+
173
+ // Good: PillInput with typeahead for better UX
174
+ <PillInput options={options} selected={selected} onChange={setSelected} />
175
+ ```
176
+
177
+ ### Not Filtering Already-Selected Options
178
+
179
+ ```typescript
180
+ // Bad: User sees already-selected items in dropdown (confusing)
181
+ const suggestions = fuse.search(query)
182
+
183
+ // Good: Remove selected from suggestions
184
+ const suggestions = fuse.search(query).filter(r => !selected.includes(r.item))
185
+ ```
186
+
187
+ ### Forgetting Backspace-to-Remove
188
+
189
+ ```typescript
190
+ // Bad: No way to remove last pill from keyboard
191
+ onKeyDown={(e) => { if (e.key === 'Enter') addPill() }}
192
+
193
+ // Good: Backspace on empty input removes last pill
194
+ onKeyDown={(e) => {
195
+ if (e.key === 'Backspace' && !query && selected.length) {
196
+ removeLastPill()
197
+ }
198
+ }
199
+ ```
200
+
201
+ ---
202
+
203
+ ## Checklist
204
+
205
+ - [ ] Use Fuse.js for fuzzy matching (threshold: 0.4)
206
+ - [ ] Filter already-selected values from suggestions
207
+ - [ ] Support ArrowUp/Down for dropdown navigation
208
+ - [ ] Support Enter to select highlighted or add custom
209
+ - [ ] Support Backspace on empty to remove last pill
210
+ - [ ] Support Escape to close dropdown
211
+ - [ ] Click outside closes dropdown
212
+ - [ ] Max 8 results in dropdown to prevent overwhelming
213
+ - [ ] Pills show gradient background with X remove button
214
+ - [ ] Trim whitespace and prevent duplicate entries
215
+
216
+ ---
217
+
218
+ **Status**: Stable
219
+ **Last Updated**: 2026-03-14
220
+ **Contributors**: Community