deepspace 0.26.0 → 0.26.1

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 (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{chunk-LPXWXF6R.js → chunk-YBXIUDZJ.js} +2 -2
  3. package/dist/{chunk-LPXWXF6R.js.map → chunk-YBXIUDZJ.js.map} +1 -1
  4. package/dist/cli.js +80 -45
  5. package/dist/cli.js.map +1 -1
  6. package/dist/documentation-client-core.js +1 -1
  7. package/dist/documentation-default-renderer.cjs +1 -1
  8. package/dist/documentation-react.js +1 -1
  9. package/dist/documentation-runtime.js +1 -1
  10. package/dist/documentation-server-core.js +1 -1
  11. package/dist/documentation.js +1 -1
  12. package/dist/documentation.js.map +1 -1
  13. package/dist/index.d.ts +26 -434
  14. package/dist/index.js +99 -864
  15. package/dist/index.js.map +1 -1
  16. package/dist/schema.d.ts +4 -41
  17. package/dist/schema.js +2 -242
  18. package/dist/schema.js.map +1 -1
  19. package/dist/server.d.ts +6 -162
  20. package/dist/server.js +13 -281
  21. package/dist/server.js.map +1 -1
  22. package/dist/worker.d.ts +4 -160
  23. package/dist/worker.js +15 -281
  24. package/dist/worker.js.map +1 -1
  25. package/features/messaging/feature.json +3 -43
  26. package/features/messaging/src/ChatPage.tsx +12 -16
  27. package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
  28. package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
  29. package/features/messaging/src/components/chat/MessageList.tsx +57 -40
  30. package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
  31. package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
  32. package/features/messaging/src/hooks/useChatChannel.ts +18 -23
  33. package/features/messaging/src/messaging-schema.ts +0 -2
  34. package/features/search-bar/feature.json +9 -20
  35. package/features/search-bar/src/InlineSearch.tsx +101 -0
  36. package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
  37. package/features/search-bar/src/search-model.ts +2 -149
  38. package/package.json +1 -1
  39. package/features/messaging/src/ChatMultiPage.tsx +0 -298
  40. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
  41. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
  42. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
  43. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
  44. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
  45. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
  46. package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
  47. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
  48. package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
  49. package/features/search-bar/src/SearchOverlay.tsx +0 -566
  50. package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
  51. package/features/sidebar/feature.json +0 -19
  52. package/features/sidebar/src/AppSidebar.tsx +0 -558
@@ -1,566 +0,0 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
- import { Button, EmptyState, SearchInput, cn } from '@/components/ui'
3
- import { Loader2 } from 'lucide-react'
4
- import {
5
- createRecentStorageKey,
6
- readRecentSearches,
7
- rememberRecentItem,
8
- rememberRecentQuery,
9
- type RecentSearchEntry,
10
- } from './search-model'
11
-
12
- const recentQueryMarker = Symbol('recent-query')
13
-
14
- export interface SearchItem {
15
- id: string
16
- title: string
17
- subtitle?: string
18
- description?: string
19
- group?: string
20
- keywords?: string[]
21
- meta?: ReactNode
22
- disabled?: boolean
23
- }
24
-
25
- interface RecentQueryItem extends SearchItem {
26
- [recentQueryMarker]: string
27
- }
28
-
29
- export type SearchResultRenderer<T extends SearchItem = SearchItem> = (
30
- item: T,
31
- query: string,
32
- ) => ReactNode
33
-
34
- interface SearchBaseProps<T extends SearchItem = SearchItem> {
35
- query: string
36
- onQueryChange: (query: string) => void
37
- items: T[]
38
- onSelect: (item: T) => void
39
- placeholder?: string
40
- loading?: boolean
41
- error?: string | null
42
- emptyTitle?: string
43
- emptyDescription?: string
44
- className?: string
45
- renderTitle?: SearchResultRenderer<T>
46
- renderSubtitle?: SearchResultRenderer<T>
47
- renderDescription?: SearchResultRenderer<T>
48
- }
49
-
50
- export interface SearchOverlayProps<T extends SearchItem = SearchItem> extends SearchBaseProps<T> {
51
- open: boolean
52
- onOpenChange: (open: boolean) => void
53
- triggerLabel?: string
54
- title?: string
55
- description?: string
56
- onSearchSubmit?: (query: string) => void
57
- searchSubmitLabel?: string
58
- showRecentSearches?: boolean
59
- recentStorageKey?: string
60
- maxRecentItems?: number
61
- }
62
-
63
- export interface InlineSearchProps<T extends SearchItem = SearchItem> extends SearchBaseProps<T> {
64
- label?: string
65
- description?: string
66
- dimContent?: boolean
67
- openOnFocus?: boolean
68
- maxResultsHeightClassName?: string
69
- }
70
-
71
- export function SearchOverlay<T extends SearchItem = SearchItem>({
72
- open,
73
- onOpenChange,
74
- query,
75
- onQueryChange,
76
- items,
77
- onSelect,
78
- triggerLabel = 'Search',
79
- placeholder = 'Search...',
80
- title = 'Search',
81
- description,
82
- onSearchSubmit,
83
- searchSubmitLabel = 'Search all results',
84
- loading = false,
85
- error = null,
86
- emptyTitle = 'No matches',
87
- emptyDescription = 'Try another search term.',
88
- className,
89
- renderTitle,
90
- renderSubtitle,
91
- renderDescription,
92
- showRecentSearches = true,
93
- recentStorageKey,
94
- maxRecentItems = 5,
95
- }: SearchOverlayProps<T>) {
96
- const panelRef = useRef<HTMLDivElement>(null)
97
- const inputRef = useRef<HTMLInputElement>(null)
98
- const [recentSearches, setRecentSearches] = useState<RecentSearchEntry[]>([])
99
- const resolvedRecentStorageKey =
100
- recentStorageKey ?? createRecentStorageKey(title, placeholder, triggerLabel)
101
- const recentItems = useMemo(() => {
102
- const itemsById = new Map(items.map((item) => [item.id, item]))
103
- return recentSearches.flatMap((recent, index): SearchItem[] => {
104
- if (recent.kind === 'query') {
105
- const queryItem: RecentQueryItem = {
106
- id: `deepspace-recent-query:${index}`,
107
- title: recent.query,
108
- subtitle: 'Search query',
109
- group: 'Recent',
110
- keywords: [recent.query],
111
- [recentQueryMarker]: recent.query,
112
- }
113
- return [queryItem]
114
- }
115
-
116
- const item = itemsById.get(recent.itemId)
117
- return item ? [{ ...item, group: 'Recent' }] : []
118
- })
119
- }, [items, recentSearches])
120
- const showRecents = showRecentSearches && query.trim() === '' && recentItems.length > 0
121
- const visibleItems: SearchItem[] = showRecents ? recentItems : items
122
- const canSubmitSearch = Boolean(onSearchSubmit && query.trim())
123
-
124
- function submitSearch(rawQuery: string) {
125
- const trimmed = rawQuery.trim()
126
- if (!trimmed || !onSearchSubmit) return
127
- if (showRecentSearches) {
128
- setRecentSearches(rememberRecentQuery(resolvedRecentStorageKey, trimmed, maxRecentItems))
129
- }
130
- onSearchSubmit(trimmed)
131
- onOpenChange(false)
132
- }
133
-
134
- useEffect(() => {
135
- if (!open) return
136
- const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0)
137
- if (showRecentSearches) {
138
- setRecentSearches(readRecentSearches(resolvedRecentStorageKey, maxRecentItems))
139
- }
140
- return () => window.clearTimeout(focusTimer)
141
- }, [maxRecentItems, open, resolvedRecentStorageKey, showRecentSearches])
142
-
143
- useEffect(() => {
144
- if (!open) return
145
-
146
- function onPointerDown(event: MouseEvent) {
147
- if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
148
- onOpenChange(false)
149
- }
150
- }
151
-
152
- document.addEventListener('mousedown', onPointerDown)
153
- return () => document.removeEventListener('mousedown', onPointerDown)
154
- }, [onOpenChange, open])
155
-
156
- return (
157
- <div className={className}>
158
- <Button type="button" onClick={() => onOpenChange(true)} data-testid="search-trigger">
159
- {triggerLabel}
160
- </Button>
161
-
162
- {open && (
163
- <div
164
- className="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm px-3 py-4 sm:py-16"
165
- data-testid="search-overlay"
166
- >
167
- <div
168
- ref={panelRef}
169
- role="dialog"
170
- aria-modal="true"
171
- aria-label={title}
172
- className="mx-auto flex max-h-[min(720px,calc(100vh-2rem))] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
173
- >
174
- <div className="border-b border-border p-4">
175
- <div className="mb-3 flex items-start justify-between gap-4">
176
- <div>
177
- <h2 className="text-lg font-semibold text-foreground">{title}</h2>
178
- {description && (
179
- <p className="mt-1 text-sm text-muted-foreground">{description}</p>
180
- )}
181
- </div>
182
- <Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
183
- Close
184
- </Button>
185
- </div>
186
- <SearchInput
187
- ref={inputRef}
188
- value={query}
189
- onChange={(event) => onQueryChange(event.target.value)}
190
- onClear={() => onQueryChange('')}
191
- placeholder={placeholder}
192
- aria-label={placeholder}
193
- data-testid="search-input"
194
- />
195
- {canSubmitSearch && (
196
- <Button
197
- type="button"
198
- variant="ghost"
199
- size="sm"
200
- className="mt-2 w-full justify-start text-muted-foreground"
201
- onClick={() => submitSearch(query)}
202
- data-testid="search-submit"
203
- >
204
- {searchSubmitLabel}: "{query.trim()}"
205
- </Button>
206
- )}
207
- </div>
208
-
209
- <SearchPanel
210
- items={visibleItems}
211
- query={showRecents ? '' : query}
212
- onSelect={(item) => {
213
- if (showRecents) {
214
- if (isRecentQueryItem(item)) {
215
- onQueryChange(item[recentQueryMarker])
216
- submitSearch(item[recentQueryMarker])
217
- return
218
- }
219
-
220
- const currentItem = items.find((candidate) => candidate.id === item.id)
221
- if (!currentItem) return
222
- onSelect(currentItem)
223
- onOpenChange(false)
224
- return
225
- }
226
-
227
- if (showRecentSearches) {
228
- setRecentSearches(
229
- rememberRecentItem(resolvedRecentStorageKey, item.id, maxRecentItems),
230
- )
231
- }
232
- onSelect(item as T)
233
- onOpenChange(false)
234
- }}
235
- onClose={() => onOpenChange(false)}
236
- loading={loading}
237
- error={error}
238
- emptyTitle={emptyTitle}
239
- emptyDescription={emptyDescription}
240
- onSubmitQuery={onSearchSubmit ? submitSearch : undefined}
241
- renderTitle={
242
- showRecents
243
- ? undefined
244
- : (renderTitle as SearchResultRenderer<SearchItem> | undefined)
245
- }
246
- renderSubtitle={
247
- showRecents
248
- ? undefined
249
- : (renderSubtitle as SearchResultRenderer<SearchItem> | undefined)
250
- }
251
- renderDescription={
252
- showRecents
253
- ? undefined
254
- : (renderDescription as SearchResultRenderer<SearchItem> | undefined)
255
- }
256
- />
257
- </div>
258
- </div>
259
- )}
260
- </div>
261
- )
262
- }
263
-
264
- export function InlineSearch<T extends SearchItem = SearchItem>({
265
- query,
266
- onQueryChange,
267
- items,
268
- onSelect,
269
- placeholder = 'Search...',
270
- label,
271
- description,
272
- loading = false,
273
- error = null,
274
- emptyTitle = 'No matches',
275
- emptyDescription = 'Try another search term.',
276
- dimContent = false,
277
- openOnFocus = true,
278
- maxResultsHeightClassName = 'max-h-80',
279
- className,
280
- renderTitle,
281
- renderSubtitle,
282
- renderDescription,
283
- }: InlineSearchProps<T>) {
284
- const wrapperRef = useRef<HTMLDivElement>(null)
285
- const [open, setOpen] = useState(false)
286
- const showPanel = open && (openOnFocus || query.trim().length > 0)
287
-
288
- useEffect(() => {
289
- function onPointerDown(event: MouseEvent) {
290
- if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
291
- setOpen(false)
292
- }
293
- }
294
-
295
- document.addEventListener('mousedown', onPointerDown)
296
- return () => document.removeEventListener('mousedown', onPointerDown)
297
- }, [])
298
-
299
- return (
300
- <div ref={wrapperRef} className={cn('relative', className)} data-testid="inline-search">
301
- {dimContent && showPanel && (
302
- <div
303
- className="fixed inset-0 z-30 bg-background/50 backdrop-blur-[2px]"
304
- aria-hidden="true"
305
- data-testid="inline-search-dimmer"
306
- />
307
- )}
308
-
309
- <div className="relative z-40">
310
- {(label || description) && (
311
- <div className="mb-2">
312
- {label && <label className="text-sm font-medium text-foreground">{label}</label>}
313
- {description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
314
- </div>
315
- )}
316
- <SearchInput
317
- value={query}
318
- onChange={(event) => {
319
- onQueryChange(event.target.value)
320
- setOpen(true)
321
- }}
322
- onFocus={() => setOpen(true)}
323
- onClear={() => {
324
- onQueryChange('')
325
- setOpen(true)
326
- }}
327
- placeholder={placeholder}
328
- aria-label={placeholder}
329
- data-testid="inline-search-input"
330
- />
331
-
332
- {showPanel && (
333
- <div
334
- className={cn(
335
- 'absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-xl border border-border bg-card shadow-xl',
336
- maxResultsHeightClassName,
337
- )}
338
- data-testid="inline-search-panel"
339
- >
340
- <SearchPanel
341
- items={items}
342
- query={query}
343
- onSelect={(item) => {
344
- onSelect(item)
345
- setOpen(false)
346
- }}
347
- onClose={() => setOpen(false)}
348
- loading={loading}
349
- error={error}
350
- emptyTitle={emptyTitle}
351
- emptyDescription={emptyDescription}
352
- compact
353
- renderTitle={renderTitle}
354
- renderSubtitle={renderSubtitle}
355
- renderDescription={renderDescription}
356
- />
357
- </div>
358
- )}
359
- </div>
360
- </div>
361
- )
362
- }
363
-
364
- interface SearchPanelProps<T extends SearchItem = SearchItem> {
365
- items: T[]
366
- query: string
367
- onSelect: (item: T) => void
368
- onClose: () => void
369
- loading?: boolean
370
- error?: string | null
371
- emptyTitle: string
372
- emptyDescription: string
373
- compact?: boolean
374
- onSubmitQuery?: (query: string) => void
375
- renderTitle?: SearchResultRenderer<T>
376
- renderSubtitle?: SearchResultRenderer<T>
377
- renderDescription?: SearchResultRenderer<T>
378
- }
379
-
380
- function SearchPanel<T extends SearchItem = SearchItem>({
381
- items,
382
- query,
383
- onSelect,
384
- onClose,
385
- loading = false,
386
- error = null,
387
- emptyTitle,
388
- emptyDescription,
389
- compact = false,
390
- onSubmitQuery,
391
- renderTitle,
392
- renderSubtitle,
393
- renderDescription,
394
- }: SearchPanelProps<T>) {
395
- const [activeIndex, setActiveIndex] = useState(0)
396
- const enabledItems = items.filter((item) => !item.disabled)
397
-
398
- useEffect(() => {
399
- setActiveIndex(0)
400
- }, [items])
401
-
402
- function moveActive(delta: number) {
403
- if (enabledItems.length === 0) return
404
- setActiveIndex((current) => (current + delta + enabledItems.length) % enabledItems.length)
405
- }
406
-
407
- function selectItem(item: T) {
408
- if (item.disabled) return
409
- onSelect(item)
410
- }
411
-
412
- useEffect(() => {
413
- document.addEventListener('keydown', onKeyDown)
414
- return () => document.removeEventListener('keydown', onKeyDown)
415
- })
416
-
417
- function onKeyDown(event: globalThis.KeyboardEvent) {
418
- if (event.key === 'Escape') {
419
- event.preventDefault()
420
- onClose()
421
- } else if (event.key === 'ArrowDown') {
422
- event.preventDefault()
423
- moveActive(1)
424
- } else if (event.key === 'ArrowUp') {
425
- event.preventDefault()
426
- moveActive(-1)
427
- } else if (event.key === 'Enter') {
428
- if (onSubmitQuery && query.trim()) {
429
- event.preventDefault()
430
- onSubmitQuery(query)
431
- return
432
- }
433
- const active = enabledItems[activeIndex]
434
- if (active) {
435
- event.preventDefault()
436
- selectItem(active)
437
- }
438
- }
439
- }
440
-
441
- return (
442
- <div
443
- className={cn('min-h-0 flex-1 overflow-y-auto p-2', compact ? 'max-h-80' : '')}
444
- tabIndex={-1}
445
- >
446
- {loading ? (
447
- <div
448
- className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground"
449
- data-testid="search-loading"
450
- >
451
- <Loader2 className="h-4 w-4 animate-spin" />
452
- Searching...
453
- </div>
454
- ) : error ? (
455
- <div className="px-3 py-10 text-center text-sm text-destructive" data-testid="search-error">
456
- {error}
457
- </div>
458
- ) : items.length === 0 ? (
459
- <div data-testid="search-empty">
460
- <EmptyState
461
- title={emptyTitle}
462
- description={emptyDescription}
463
- className={compact ? 'py-8' : undefined}
464
- />
465
- </div>
466
- ) : (
467
- <SearchResultList
468
- items={items}
469
- query={query}
470
- activeItemId={enabledItems[activeIndex]?.id}
471
- onSelect={selectItem}
472
- renderTitle={renderTitle}
473
- renderSubtitle={renderSubtitle}
474
- renderDescription={renderDescription}
475
- />
476
- )}
477
- </div>
478
- )
479
- }
480
-
481
- export interface SearchResultListProps<T extends SearchItem = SearchItem> {
482
- items: T[]
483
- query?: string
484
- activeItemId?: string
485
- onSelect: (item: T) => void
486
- renderTitle?: SearchResultRenderer<T>
487
- renderSubtitle?: SearchResultRenderer<T>
488
- renderDescription?: SearchResultRenderer<T>
489
- }
490
-
491
- export function SearchResultList<T extends SearchItem = SearchItem>({
492
- items,
493
- query = '',
494
- activeItemId,
495
- onSelect,
496
- renderTitle,
497
- renderSubtitle,
498
- renderDescription,
499
- }: SearchResultListProps<T>) {
500
- const groups = groupItems(items)
501
-
502
- return (
503
- <div className="space-y-3" data-testid="search-results">
504
- {groups.map(([group, groupItems]) => (
505
- <section key={group}>
506
- <div className="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
507
- {group}
508
- </div>
509
- <div className="space-y-1">
510
- {groupItems.map((item) => {
511
- const active = item.id === activeItemId
512
- return (
513
- <button
514
- key={item.id}
515
- type="button"
516
- disabled={item.disabled}
517
- onClick={() => onSelect(item)}
518
- className={cn(
519
- 'flex w-full items-start justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors',
520
- active ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
521
- item.disabled && 'cursor-not-allowed opacity-50',
522
- )}
523
- data-testid="search-result"
524
- data-active={active ? 'true' : 'false'}
525
- >
526
- <span className="min-w-0">
527
- <span className="block truncate text-sm font-medium text-foreground">
528
- {renderTitle ? renderTitle(item, query) : item.title}
529
- </span>
530
- {item.subtitle && (
531
- <span className="mt-0.5 block truncate text-xs text-muted-foreground">
532
- {renderSubtitle ? renderSubtitle(item, query) : item.subtitle}
533
- </span>
534
- )}
535
- {item.description && (
536
- <span className="mt-1 block text-xs text-muted-foreground">
537
- {renderDescription ? renderDescription(item, query) : item.description}
538
- </span>
539
- )}
540
- </span>
541
- {item.meta && (
542
- <span className="shrink-0 text-xs text-muted-foreground">{item.meta}</span>
543
- )}
544
- </button>
545
- )
546
- })}
547
- </div>
548
- </section>
549
- ))}
550
- </div>
551
- )
552
- }
553
-
554
- function groupItems<T extends SearchItem>(items: T[]): Array<[string, T[]]> {
555
- const groups = new Map<string, T[]>()
556
- for (const item of items) {
557
- const group = item.group ?? 'Results'
558
- if (!groups.has(group)) groups.set(group, [])
559
- groups.get(group)!.push(item)
560
- }
561
- return Array.from(groups.entries())
562
- }
563
-
564
- function isRecentQueryItem(item: SearchItem): item is RecentQueryItem {
565
- return recentQueryMarker in item
566
- }
@@ -1,40 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { readRecentSearches, rememberRecentItem, rememberRecentQuery } from '../search-model'
3
-
4
- const storageKey = 'search-history-test'
5
- let values: Map<string, string>
6
-
7
- beforeEach(() => {
8
- values = new Map()
9
- vi.stubGlobal('window', {
10
- localStorage: {
11
- getItem: (key: string) => values.get(key) ?? null,
12
- setItem: (key: string, value: string) => values.set(key, value),
13
- },
14
- })
15
- })
16
-
17
- afterEach(() => vi.unstubAllGlobals())
18
-
19
- describe('recent search history', () => {
20
- it('stores only the stable ID for a selected result', () => {
21
- expect(rememberRecentItem(storageKey, 'post-1', 5)).toEqual([
22
- { kind: 'item', itemId: 'post-1' },
23
- ])
24
- expect(JSON.parse(values.get(storageKey) ?? 'null')).toEqual([
25
- { kind: 'item', itemId: 'post-1' },
26
- ])
27
- })
28
-
29
- it('ignores legacy result snapshots instead of replaying incomplete objects', () => {
30
- values.set(storageKey, JSON.stringify([{ id: 'post-1', title: 'Old title' }]))
31
- expect(readRecentSearches(storageKey, 5)).toEqual([])
32
- })
33
-
34
- it('deduplicates submitted queries without changing their display casing', () => {
35
- rememberRecentQuery(storageKey, 'Project Alpha', 5)
36
- expect(rememberRecentQuery(storageKey, 'project alpha', 5)).toEqual([
37
- { kind: 'query', query: 'project alpha' },
38
- ])
39
- })
40
- })
@@ -1,19 +0,0 @@
1
- {
2
- "id": "sidebar",
3
- "name": "Sidebar",
4
- "category": "nav",
5
- "description": "Collapsible left navigation sidebar with role-based filtering",
6
- "details": "Animated sidebar with collapsed icon-only mode, expanded mode with labels and user chip, mobile overlay drawer, localStorage persistence, role-based nav filtering via useUser, and active state via react-router-dom.",
7
- "files": [
8
- {
9
- "src": "src/AppSidebar.tsx",
10
- "dest": "src/components/sidebar/AppSidebar.tsx"
11
- }
12
- ],
13
- "requires": [],
14
- "instructions": [
15
- "Import AppSidebar and SidebarMobileHeader into your layout",
16
- "Configure nav items array with paths, icons, and optional role restrictions"
17
- ],
18
- "patterns": []
19
- }