@vadimcomanescu/nadicode-design-system 4.0.1 → 4.0.2

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 (35) hide show
  1. package/.agents/skills/seed/SKILL.md +34 -163
  2. package/.agents/skills/seed/references/animation.md +2 -2
  3. package/.agents/skills/seed/references/responsive.md +1 -1
  4. package/README.md +2 -2
  5. package/eslint-rules/nadicode/rules/no-has-svg-selector.js +1 -1
  6. package/package.json +1 -2
  7. package/scripts/ds-check.mjs +0 -10
  8. package/scripts/sync-seed-skill.mjs +0 -3
  9. package/.agents/skills/seed/contract.md +0 -110
  10. package/.agents/skills/seed/intent-map.md +0 -320
  11. package/.agents/skills/seed/recipes/agency-home.md +0 -311
  12. package/.agents/skills/seed/recipes/agents-chat.md +0 -305
  13. package/.agents/skills/seed/recipes/analytics.md +0 -253
  14. package/.agents/skills/seed/recipes/auth.md +0 -254
  15. package/.agents/skills/seed/recipes/blog-content.md +0 -307
  16. package/.agents/skills/seed/recipes/checkout.md +0 -311
  17. package/.agents/skills/seed/recipes/company-about.md +0 -276
  18. package/.agents/skills/seed/recipes/company-contact.md +0 -234
  19. package/.agents/skills/seed/recipes/crud-form.md +0 -233
  20. package/.agents/skills/seed/recipes/crud-list-detail.md +0 -230
  21. package/.agents/skills/seed/recipes/dashboard.md +0 -354
  22. package/.agents/skills/seed/recipes/digital-workers.md +0 -314
  23. package/.agents/skills/seed/recipes/error-pages.md +0 -199
  24. package/.agents/skills/seed/recipes/marketing-landing.md +0 -293
  25. package/.agents/skills/seed/recipes/marketing-shell.md +0 -156
  26. package/.agents/skills/seed/recipes/navigation-shell.md +0 -787
  27. package/.agents/skills/seed/recipes/onboarding.md +0 -258
  28. package/.agents/skills/seed/recipes/pricing.md +0 -271
  29. package/.agents/skills/seed/recipes/service-detail.md +0 -302
  30. package/.agents/skills/seed/recipes/settings.md +0 -252
  31. package/.agents/skills/seed/references/blocks.md +0 -128
  32. package/.agents/skills/seed/references/components.md +0 -287
  33. package/.agents/skills/seed/references/icons.md +0 -169
  34. package/.agents/skills/seed/references/nextjs.md +0 -49
  35. package/.agents/skills/seed/references/tokens.md +0 -88
@@ -1,787 +0,0 @@
1
- # Recipe: Navigation Shell
2
-
3
- Shared app navigation frame with sidebar, top bar, Cmd+K search, breadcrumbs, and user menu.
4
-
5
- ## Purpose
6
-
7
- The navigation shell wraps all app-shell pages. It is not a page itself but the persistent frame around page content. This recipe defines the complete app shell layout with working wiring for every navigation subsystem.
8
-
9
- ## Shell
10
-
11
- `app-shell` (this IS the shell definition)
12
-
13
- ---
14
-
15
- ## Layout Blueprint (Desktop)
16
-
17
- ```
18
- +--+----------------------------------------------+
19
- | | [=] Home > Settings [Cmd+K] [🔔] [User] |
20
- |S | |
21
- |I | +------------------------------------------+ |
22
- |D | | | |
23
- |E | | Page Content | |
24
- |B | | (filled by child route) | |
25
- |A | | | |
26
- |R | | | |
27
- | | +------------------------------------------+ |
28
- +--+----------------------------------------------+
29
-
30
- Sidebar (expanded):
31
- +------------------+
32
- | [v] Acme Inc | <-- Workspace switcher (DropdownMenu)
33
- +------------------+
34
- | Group: Main |
35
- | Dashboard |
36
- | Analytics |
37
- | Agents |
38
- +------------------+
39
- | Group: Manage |
40
- | Team |
41
- | Settings |
42
- +------------------+
43
- | [Avatar] Jane | <-- NavUser (DropdownMenu)
44
- +------------------+
45
-
46
- Mobile: Sidebar hidden, hamburger in top bar opens Sheet overlay
47
- ```
48
-
49
- ---
50
-
51
- ## Section Sequence
52
-
53
- ### 1. App Shell Layout (the file)
54
-
55
- This is your app route group layout (e.g. src/app/(app)/layout.tsx). It composes every subsystem.
56
-
57
- ```tsx
58
- 'use client'
59
-
60
- import { usePathname } from 'next/navigation'
61
- import {
62
- Sidebar,
63
- SidebarProvider,
64
- SidebarTrigger,
65
- SidebarInset,
66
- SidebarHeader,
67
- SidebarContent,
68
- SidebarFooter,
69
- SidebarGroup,
70
- SidebarGroupLabel,
71
- SidebarMenu,
72
- SidebarMenuItem,
73
- SidebarMenuButton,
74
- SidebarMenuSub,
75
- SidebarMenuSubItem,
76
- SidebarMenuSubButton,
77
- } from '@vadimcomanescu/nadicode-design-system/sidebar'
78
- import { seedComponents } from '@vadimcomanescu/nadicode-design-system/catalog/components'
79
- const { NavUser } = seedComponents
80
- import { AppBreadcrumb } from '@/components/blocks/AppBreadcrumb'
81
- import { AppSearch } from '@/components/blocks/AppSearch'
82
- import { WorkspaceSwitcher } from '@/components/blocks/WorkspaceSwitcher'
83
- import { ThemeToggle } from '@vadimcomanescu/nadicode-design-system/theme-toggle'
84
- import { Button } from '@vadimcomanescu/nadicode-design-system/button'
85
- import { BellIcon } from '@vadimcomanescu/nadicode-design-system/icons/bell'
86
- import { LayoutDashboardIcon } from '@vadimcomanescu/nadicode-design-system/icons/layout-dashboard'
87
- import { ChartBarIcon } from '@vadimcomanescu/nadicode-design-system/icons/chart-bar'
88
- import { BotIcon } from '@vadimcomanescu/nadicode-design-system/icons/bot'
89
- import { UsersIcon } from '@vadimcomanescu/nadicode-design-system/icons/users'
90
- import { SettingsIcon } from '@vadimcomanescu/nadicode-design-system/icons/settings'
91
-
92
- const NAV_ITEMS = [
93
- {
94
- group: 'Main',
95
- items: [
96
- { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboardIcon },
97
- { label: 'Analytics', href: '/analytics', icon: ChartBarIcon },
98
- {
99
- label: 'Agents',
100
- href: '/agents',
101
- icon: BotIcon,
102
- children: [
103
- { label: 'Active', href: '/agents/active' },
104
- { label: 'History', href: '/agents/history' },
105
- ],
106
- },
107
- ],
108
- },
109
- {
110
- group: 'Manage',
111
- items: [
112
- { label: 'Team', href: '/team', icon: UsersIcon },
113
- { label: 'Settings', href: '/settings', icon: SettingsIcon },
114
- ],
115
- },
116
- ]
117
-
118
- export default function AppShellLayout({
119
- children,
120
- }: {
121
- children: React.ReactNode
122
- }) {
123
- const pathname = usePathname()
124
-
125
- return (
126
- <SidebarProvider>
127
- <Sidebar>
128
- <SidebarHeader>
129
- <WorkspaceSwitcher />
130
- </SidebarHeader>
131
-
132
- <SidebarContent>
133
- {NAV_ITEMS.map((group) => (
134
- <SidebarGroup key={group.group}>
135
- <SidebarGroupLabel>{group.group}</SidebarGroupLabel>
136
- <SidebarMenu>
137
- {group.items.map((item) => (
138
- <SidebarMenuItem key={item.href}>
139
- <SidebarMenuButton
140
- asChild
141
- isActive={pathname === item.href || pathname.startsWith(item.href + '/')}
142
- tooltip={item.label}
143
- >
144
- <a href={item.href}>
145
- <item.icon size={16} />
146
- {item.label}
147
- </a>
148
- </SidebarMenuButton>
149
-
150
- {item.children && (
151
- <SidebarMenuSub>
152
- {item.children.map((child) => (
153
- <SidebarMenuSubItem key={child.href}>
154
- <SidebarMenuSubButton
155
- asChild
156
- isActive={pathname === child.href}
157
- >
158
- <a href={child.href}>{child.label}</a>
159
- </SidebarMenuSubButton>
160
- </SidebarMenuSubItem>
161
- ))}
162
- </SidebarMenuSub>
163
- )}
164
- </SidebarMenuItem>
165
- ))}
166
- </SidebarMenu>
167
- </SidebarGroup>
168
- ))}
169
- </SidebarContent>
170
-
171
- <SidebarFooter>
172
- <NavUser
173
- user={{ name: 'Jane Smith', email: 'jane@acme.com', avatar: '/avatars/jane.jpg' }}
174
- items={[
175
- { label: 'Profile', href: '/settings/profile' },
176
- { label: 'Billing', href: '/settings/billing' },
177
- { label: 'Sign out', onClick: handleSignOut },
178
- ]}
179
- />
180
- </SidebarFooter>
181
- </Sidebar>
182
-
183
- <SidebarInset>
184
- <header className="flex items-center gap-3 px-4 py-3 border-b border-border">
185
- <SidebarTrigger />
186
- <AppBreadcrumb />
187
- <div className="ml-auto flex items-center gap-2">
188
- <AppSearch />
189
- <Button variant="ghost" size="icon" className="relative">
190
- <BellIcon size={16} />
191
- <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-destructive" />
192
- </Button>
193
- <ThemeToggle />
194
- </div>
195
- </header>
196
-
197
- <div className="flex-1 p-4 lg:p-6">
198
- {children}
199
- </div>
200
- </SidebarInset>
201
- </SidebarProvider>
202
- )
203
- }
204
- ```
205
-
206
- ---
207
-
208
- ### 2. Breadcrumb Generation
209
-
210
- The breadcrumb builds itself from the current pathname and a route config map. This is a reusable block, not inline logic.
211
-
212
- **Consumer file: src/components/blocks/AppBreadcrumb.tsx**
213
-
214
- ```tsx
215
- 'use client'
216
-
217
- import { usePathname } from 'next/navigation'
218
- import {
219
- Breadcrumb,
220
- BreadcrumbList,
221
- BreadcrumbItem,
222
- BreadcrumbLink,
223
- BreadcrumbPage,
224
- BreadcrumbSeparator,
225
- BreadcrumbEllipsis,
226
- } from '@vadimcomanescu/nadicode-design-system/breadcrumb'
227
- import { Fragment } from 'react'
228
-
229
- // Map route segments to display labels.
230
- // Dynamic segments use a placeholder; the caller can resolve them.
231
- const ROUTE_LABELS: Record<string, string> = {
232
- dashboard: 'Dashboard',
233
- analytics: 'Analytics',
234
- agents: 'Agents',
235
- active: 'Active',
236
- history: 'History',
237
- team: 'Team',
238
- settings: 'Settings',
239
- profile: 'Profile',
240
- billing: 'Billing',
241
- security: 'Security',
242
- }
243
-
244
- // Max visible crumbs before collapsing middle segments.
245
- const MAX_VISIBLE = 4
246
-
247
- export function AppBreadcrumb() {
248
- const pathname = usePathname()
249
- const segments = pathname.split('/').filter(Boolean)
250
-
251
- if (segments.length === 0) return null
252
-
253
- // Build crumb list: [{label, href}]
254
- const crumbs = segments.map((segment, i) => ({
255
- label: ROUTE_LABELS[segment] ?? decodeURIComponent(segment),
256
- href: '/' + segments.slice(0, i + 1).join('/'),
257
- }))
258
-
259
- // Collapse if too many crumbs
260
- const shouldCollapse = crumbs.length > MAX_VISIBLE
261
- const visible = shouldCollapse
262
- ? [crumbs[0], ...crumbs.slice(-2)]
263
- : crumbs
264
-
265
- return (
266
- <Breadcrumb>
267
- <BreadcrumbList>
268
- {visible.map((crumb, i) => (
269
- <Fragment key={crumb.href}>
270
- {i > 0 && <BreadcrumbSeparator />}
271
- {shouldCollapse && i === 1 && (
272
- <>
273
- <BreadcrumbItem>
274
- <BreadcrumbEllipsis />
275
- </BreadcrumbItem>
276
- <BreadcrumbSeparator />
277
- </>
278
- )}
279
- <BreadcrumbItem>
280
- {i === visible.length - 1 ? (
281
- <BreadcrumbPage>{crumb.label}</BreadcrumbPage>
282
- ) : (
283
- <BreadcrumbLink href={crumb.href}>{crumb.label}</BreadcrumbLink>
284
- )}
285
- </BreadcrumbItem>
286
- </Fragment>
287
- ))}
288
- </BreadcrumbList>
289
- </Breadcrumb>
290
- )
291
- }
292
- ```
293
-
294
- **Dynamic segments** (e.g., `/agents/[id]`): extend `ROUTE_LABELS` with a resolver function, or pass a `resolveLabel` prop that fetches the entity name from a cache.
295
-
296
- ---
297
-
298
- ### 3. SearchCommand (Cmd+K) Wiring
299
-
300
- The search palette needs three things: route items, action items, and a router integration. Wrap it in a Dialog triggered by Cmd+K.
301
-
302
- **Consumer file: src/components/blocks/AppSearch.tsx**
303
-
304
- ```tsx
305
- 'use client'
306
-
307
- import { useCallback, useMemo, useState } from 'react'
308
- import { useRouter } from 'next/navigation'
309
- import { Dialog, DialogContent } from '@vadimcomanescu/nadicode-design-system/dialog'
310
- import { SearchCommand, SearchResult } from '@vadimcomanescu/nadicode-design-system/search-command'
311
- import { Button } from '@vadimcomanescu/nadicode-design-system/button'
312
- import { SearchIcon } from '@vadimcomanescu/nadicode-design-system/icons/search'
313
- import { useHotkey } from '@/hooks/useHotkey'
314
-
315
- // Route items: all navigable pages in the app.
316
- const PAGE_ITEMS: SearchResult[] = [
317
- { id: 'dashboard', title: 'Dashboard', category: 'Pages', description: 'Overview and KPIs' },
318
- { id: 'analytics', title: 'Analytics', category: 'Pages', description: 'Charts and trends' },
319
- { id: 'agents', title: 'Agents', category: 'Pages', description: 'AI agent management' },
320
- { id: 'team', title: 'Team', category: 'Pages', description: 'Team members' },
321
- { id: 'settings', title: 'Settings', category: 'Pages', description: 'App configuration' },
322
- ]
323
-
324
- // Action items: commands that do something (not navigation).
325
- const ACTION_ITEMS: SearchResult[] = [
326
- { id: 'new-agent', title: 'Create Agent', category: 'Actions' },
327
- { id: 'invite-user', title: 'Invite Team Member', category: 'Actions' },
328
- { id: 'toggle-theme', title: 'Toggle Theme', category: 'Actions' },
329
- ]
330
-
331
- // Combine all items into a single searchable list.
332
- const ALL_ITEMS = [...PAGE_ITEMS, ...ACTION_ITEMS]
333
-
334
- // Map item IDs to routes (pages) or callbacks (actions).
335
- const ROUTE_MAP: Record<string, string> = {
336
- dashboard: '/dashboard',
337
- analytics: '/analytics',
338
- agents: '/agents',
339
- team: '/team',
340
- settings: '/settings',
341
- }
342
-
343
- export function AppSearch() {
344
- const [open, setOpen] = useState(false)
345
- const [query, setQuery] = useState('')
346
- const router = useRouter()
347
-
348
- // Cmd+K to open
349
- useHotkey('mod+k', (e) => {
350
- e.preventDefault()
351
- setOpen(true)
352
- })
353
-
354
- // Filter results by query
355
- const results = useMemo(() => {
356
- if (!query) return ALL_ITEMS
357
- const q = query.toLowerCase()
358
- return ALL_ITEMS.filter(
359
- (item) =>
360
- item.title.toLowerCase().includes(q) ||
361
- item.description?.toLowerCase().includes(q),
362
- )
363
- }, [query])
364
-
365
- const handleSelect = useCallback(
366
- (result: SearchResult) => {
367
- setOpen(false)
368
- setQuery('')
369
-
370
- const route = ROUTE_MAP[result.id]
371
- if (route) {
372
- router.push(route)
373
- return
374
- }
375
-
376
- // Handle action items
377
- switch (result.id) {
378
- case 'toggle-theme':
379
- document.documentElement.classList.toggle('dark')
380
- break
381
- case 'new-agent':
382
- router.push('/agents/new')
383
- break
384
- case 'invite-user':
385
- router.push('/team?invite=true')
386
- break
387
- }
388
- },
389
- [router],
390
- )
391
-
392
- return (
393
- <>
394
- <Button
395
- variant="ghost"
396
- size="sm"
397
- className="gap-2 text-text-secondary"
398
- onClick={() => setOpen(true)}
399
- >
400
- <SearchIcon size={16} />
401
- <span className="hidden sm:inline">Search...</span>
402
- <kbd className="hidden sm:inline text-xs bg-surface-raised px-1.5 py-0.5 rounded border border-border">
403
- ⌘K
404
- </kbd>
405
- </Button>
406
-
407
- <Dialog open={open} onOpenChange={setOpen}>
408
- <DialogContent className="p-0 max-w-lg">
409
- <SearchCommand
410
- value={query}
411
- onChange={setQuery}
412
- results={results}
413
- onSelect={handleSelect}
414
- placeholder="Search pages and actions..."
415
- />
416
- </DialogContent>
417
- </Dialog>
418
- </>
419
- )
420
- }
421
- ```
422
-
423
- **Extending with recent items**: store last 5 selected IDs in `localStorage`, prepend them as a "Recent" category on mount.
424
-
425
- ---
426
-
427
- ### 4. NavUser Wiring
428
-
429
- NavUser sits in the sidebar footer. It needs auth data and menu items.
430
-
431
- ```tsx
432
- // In the layout, wire NavUser to your auth context:
433
-
434
- import { useAuth } from '@/hooks/useAuth' // your auth hook
435
-
436
- function AppShellSidebarFooter() {
437
- const { user, signOut } = useAuth()
438
-
439
- if (!user) return null
440
-
441
- return (
442
- <SidebarFooter>
443
- <NavUser
444
- user={{
445
- name: user.name,
446
- email: user.email,
447
- avatar: user.avatarUrl,
448
- }}
449
- items={[
450
- { label: 'Profile', href: '/settings/profile' },
451
- { label: 'Billing', href: '/settings/billing' },
452
- ]}
453
- footer={
454
- <button
455
- onClick={signOut}
456
- className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:text-text-primary"
457
- >
458
- Sign out
459
- </button>
460
- }
461
- />
462
- </SidebarFooter>
463
- )
464
- }
465
- ```
466
-
467
- NavUser renders a `DropdownMenu` with the user's avatar and name as trigger. On mobile, the dropdown opens upward (`side="top"`). On desktop, it opens right (`side="right"`).
468
-
469
- ---
470
-
471
- ### 5. Mobile Sidebar (Sheet Overlay)
472
-
473
- The Sidebar component handles mobile automatically. No extra code needed, but you need to understand the behavior:
474
-
475
- ```
476
- MOBILE BEHAVIOR
477
- ===============
478
-
479
- Trigger: SidebarTrigger (hamburger icon in top bar)
480
- Container: Sheet overlay (slides from left, 80% viewport width)
481
- Backdrop: Semi-transparent overlay, tap to close
482
- Close: Tap backdrop, tap X button, or navigate to a new route
483
- Focus trap: Yes (keyboard focus stays inside sheet)
484
- Scroll: SidebarContent scrolls independently
485
- Width: var(--sidebar-width), max 320px
486
-
487
- DESKTOP BEHAVIOR
488
- ================
489
-
490
- < lg: Sidebar hidden (mobile mode, Sheet overlay)
491
- lg: Sidebar visible, collapsible to icon-only (var(--sidebar-width-icon) = 3rem)
492
- xl: Sidebar expanded by default (var(--sidebar-width) = 16rem)
493
- Collapse: SidebarTrigger or Cmd+B toggles between expanded and icon-only
494
- Rail: SidebarRail provides invisible drag handle on sidebar edge
495
- Cookie: Expanded/collapsed state persists via cookie
496
- ```
497
-
498
- To close the mobile sidebar on navigation:
499
-
500
- ```tsx
501
- // In nav item click handlers or via useEffect:
502
- const { setOpenMobile } = useSidebar()
503
-
504
- // Close mobile sidebar after navigating
505
- function handleNavClick(href: string) {
506
- setOpenMobile(false)
507
- router.push(href)
508
- }
509
- ```
510
-
511
- ---
512
-
513
- ### 6. Top Bar Composition
514
-
515
- The top bar is a `<header>` inside `SidebarInset`. It contains four zones:
516
-
517
- ```
518
- +---+------------------+-----------------------------------+
519
- | = | Breadcrumb | [Search] [Notifications] [Theme] |
520
- +---+------------------+-----------------------------------+
521
- ^ ^ ^ ^ ^
522
- | | | | |
523
- Trigger AppBreadcrumb AppSearch NotifButton ThemeToggle
524
- ```
525
-
526
- **Notification button pattern** (badge + dropdown):
527
-
528
- ```tsx
529
- import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@vadimcomanescu/nadicode-design-system/dropdown-menu'
530
- import { BellIcon } from '@vadimcomanescu/nadicode-design-system/icons/bell'
531
- import { Button } from '@vadimcomanescu/nadicode-design-system/button'
532
- import { Badge } from '@vadimcomanescu/nadicode-design-system/badge'
533
- import { ScrollArea } from '@vadimcomanescu/nadicode-design-system/scroll-area'
534
-
535
- function NotificationButton({ notifications }: { notifications: Notification[] }) {
536
- const unreadCount = notifications.filter((n) => !n.read).length
537
-
538
- return (
539
- <DropdownMenu>
540
- <DropdownMenuTrigger asChild>
541
- <Button variant="ghost" size="icon" className="relative">
542
- <BellIcon size={16} />
543
- {unreadCount > 0 && (
544
- <span className="absolute -top-0.5 -right-0.5 size-4 rounded-full bg-destructive text-destructive-foreground text-[10px] flex items-center justify-center">
545
- {unreadCount}
546
- </span>
547
- )}
548
- </Button>
549
- </DropdownMenuTrigger>
550
- <DropdownMenuContent align="end" className="w-80">
551
- <div className="px-3 py-2 border-b border-border">
552
- <span className="text-sm font-medium">Notifications</span>
553
- </div>
554
- <ScrollArea className="max-h-64">
555
- {notifications.length === 0 ? (
556
- <div className="px-3 py-6 text-center text-sm text-text-secondary">
557
- No notifications
558
- </div>
559
- ) : (
560
- notifications.map((n) => (
561
- <DropdownMenuItem key={n.id} className="flex flex-col items-start gap-1 py-2">
562
- <span className="text-sm">{n.title}</span>
563
- <span className="text-xs text-text-secondary">{n.timeAgo}</span>
564
- </DropdownMenuItem>
565
- ))
566
- )}
567
- </ScrollArea>
568
- </DropdownMenuContent>
569
- </DropdownMenu>
570
- )
571
- }
572
- ```
573
-
574
- ---
575
-
576
- ### 7. Workspace Switcher
577
-
578
- Logo area in sidebar header doubles as a workspace/org switcher.
579
-
580
- **Consumer file: src/components/blocks/WorkspaceSwitcher.tsx**
581
-
582
- ```tsx
583
- 'use client'
584
-
585
- import {
586
- DropdownMenu,
587
- DropdownMenuTrigger,
588
- DropdownMenuContent,
589
- DropdownMenuItem,
590
- DropdownMenuSeparator,
591
- } from '@vadimcomanescu/nadicode-design-system/dropdown-menu'
592
- import { SidebarMenuButton } from '@vadimcomanescu/nadicode-design-system/sidebar'
593
- import { ChevronsUpDownIcon } from '@vadimcomanescu/nadicode-design-system/icons/chevrons-up-down'
594
- import { PlusIcon } from '@vadimcomanescu/nadicode-design-system/icons/plus'
595
- import { Avatar, AvatarFallback, AvatarImage } from '@vadimcomanescu/nadicode-design-system/avatar'
596
-
597
- interface Workspace {
598
- id: string
599
- name: string
600
- logo?: string
601
- plan: string
602
- }
603
-
604
- export function WorkspaceSwitcher({
605
- workspaces,
606
- activeId,
607
- onSwitch,
608
- }: {
609
- workspaces: Workspace[]
610
- activeId: string
611
- onSwitch: (id: string) => void
612
- }) {
613
- const active = workspaces.find((w) => w.id === activeId)
614
-
615
- return (
616
- <DropdownMenu>
617
- <DropdownMenuTrigger asChild>
618
- <SidebarMenuButton size="lg" className="gap-2">
619
- <Avatar className="size-6 rounded-md">
620
- <AvatarImage src={active?.logo} />
621
- <AvatarFallback className="rounded-md text-xs">
622
- {active?.name.slice(0, 2).toUpperCase()}
623
- </AvatarFallback>
624
- </Avatar>
625
- <div className="flex-1 text-left">
626
- <span className="text-sm font-medium truncate">{active?.name}</span>
627
- <span className="text-xs text-text-secondary block">{active?.plan}</span>
628
- </div>
629
- <ChevronsUpDownIcon size={14} className="text-text-secondary" />
630
- </SidebarMenuButton>
631
- </DropdownMenuTrigger>
632
- <DropdownMenuContent align="start" className="w-56">
633
- {workspaces.map((ws) => (
634
- <DropdownMenuItem
635
- key={ws.id}
636
- onClick={() => onSwitch(ws.id)}
637
- className={ws.id === activeId ? 'bg-sidebar-accent' : ''}
638
- >
639
- <Avatar className="size-5 rounded-md mr-2">
640
- <AvatarImage src={ws.logo} />
641
- <AvatarFallback className="rounded-md text-[10px]">
642
- {ws.name.slice(0, 2).toUpperCase()}
643
- </AvatarFallback>
644
- </Avatar>
645
- {ws.name}
646
- </DropdownMenuItem>
647
- ))}
648
- <DropdownMenuSeparator />
649
- <DropdownMenuItem>
650
- <PlusIcon size={14} className="mr-2" />
651
- Create workspace
652
- </DropdownMenuItem>
653
- </DropdownMenuContent>
654
- </DropdownMenu>
655
- )
656
- }
657
- ```
658
-
659
- ---
660
-
661
- ## Navigation Rules
662
-
663
- 1. **Max 3 levels**: Group > Item > Sub-item. No deeper nesting.
664
- 2. **Group labels required**: Every `SidebarGroup` must have `SidebarGroupLabel` (ESLint enforced).
665
- 3. **Active state**: Use `isActive` prop on `SidebarMenuButton`. Match with `pathname === href || pathname.startsWith(href + '/')`.
666
- 4. **Icons required**: Every top-level nav item has an icon from `@vadimcomanescu/nadicode-design-system/icons/`.
667
- 5. **Max 7 top-level items**: Keep primary nav focused. Use groups to organize.
668
- 6. **Labels from config**: Nav labels come from the `NAV_ITEMS` config array, not hardcoded in JSX.
669
- 7. **Close on navigate (mobile)**: Call `setOpenMobile(false)` before `router.push()`.
670
- 8. **Breadcrumb on every page**: Every app-shell page must show breadcrumb context in the top bar.
671
-
672
- ---
673
-
674
- ## Animation Storyboard
675
-
676
- ```
677
- ANIMATION STORYBOARD
678
- ====================
679
- BUDGET: 200ms | SPRING: snappy | REDUCED: none (shell is instant)
680
-
681
- T+0ms [sidebar] Sidebar visible (instant)
682
- T+0ms [topbar] Top bar visible (instant)
683
- T+0ms [content] Page content area visible (instant)
684
-
685
- SIDEBAR COLLAPSE/EXPAND:
686
- T+0ms [sidebar] Width transitions {CSS ease-in-out-cubic, 200ms}
687
- T+0ms [labels] Text fades out (collapse) {opacity, 100ms}
688
- T+100ms [icons] Icons center-align {CSS ease-out, 100ms}
689
-
690
- MOBILE SIDEBAR OPEN:
691
- T+0ms [backdrop] Backdrop fades in {opacity 0->1, 150ms}
692
- T+0ms [sheet] Sidebar slides from left {translateX(-100% -> 0), snappy spring}
693
-
694
- CMD+K OPEN:
695
- T+0ms [overlay] Backdrop fades in {fadeIn, 150ms}
696
- T+0ms [dialog] Command palette scales in {scaleIn 0.95->1, snappy}
697
-
698
- REDUCED MOTION: All transitions instant
699
- ```
700
-
701
- ---
702
-
703
- ## Required Components
704
-
705
- | Component | Import Path | Purpose |
706
- |-----------|-------------|---------|
707
- | `Sidebar` (full system) | `@vadimcomanescu/nadicode-design-system/sidebar` | App sidebar with all subcomponents |
708
- | `NavUser` | `@vadimcomanescu/nadicode-design-system/catalog/components` via `seedComponents` | User menu in sidebar footer |
709
- | `AppBreadcrumb` | Consumer block (see Section 2) | Route-aware breadcrumb |
710
- | `AppSearch` | Consumer block (see Section 3) | Cmd+K search palette |
711
- | `WorkspaceSwitcher` | Consumer block (see Section 7) | Org/workspace picker |
712
- | `SearchCommand` | `@vadimcomanescu/nadicode-design-system/search-command` | Search UI primitive |
713
- | `Breadcrumb` (full system) | `@vadimcomanescu/nadicode-design-system/breadcrumb` | Breadcrumb primitives |
714
- | `Dialog` | `@vadimcomanescu/nadicode-design-system/dialog` | Cmd+K dialog wrapper |
715
- | `DropdownMenu` | `@vadimcomanescu/nadicode-design-system/dropdown-menu` | Notifications, workspace switcher |
716
- | `ThemeToggle` | `@vadimcomanescu/nadicode-design-system/theme-toggle` | Light/dark switch |
717
- | `Button` | `@vadimcomanescu/nadicode-design-system/button` | Top bar actions |
718
- | `ScrollArea` | `@vadimcomanescu/nadicode-design-system/scroll-area` | Notification dropdown scroll |
719
- | `Avatar` | `@vadimcomanescu/nadicode-design-system/avatar` | User and workspace avatars |
720
-
721
- ### Allowed (optional)
722
-
723
- `NotificationCenter`, `Badge` (notification count), `StyleToggle`, `Tooltip`, `ContextMenu`, `Menubar`
724
-
725
- ### Forbidden
726
-
727
- Marketing blocks (`HeroBlock`, `FooterBlock`), chart components, form components, agentic components in the shell itself
728
-
729
- ---
730
-
731
- ## Responsive Contract
732
-
733
- | Breakpoint | Sidebar | Top Bar | Breadcrumb |
734
- |------------|---------|---------|------------|
735
- | Mobile | Hidden, Sheet overlay via SidebarTrigger | Always visible, hamburger left | Hidden (not enough space) |
736
- | `sm:` | Same as mobile | Same | Visible, max 2 crumbs |
737
- | `lg:` | Collapsible (icon-only or full, Cmd+B) | Visible with all actions | Full breadcrumb trail |
738
- | `xl:` | Full sidebar (expanded by default) | Same | Same |
739
-
740
- ---
741
-
742
- ## Styling Rules
743
-
744
- - Sidebar: `bg-sidebar` token (not `bg-background`)
745
- - Sidebar border: `border-sidebar-border`
746
- - Active item: `bg-sidebar-accent text-sidebar-accent-foreground`
747
- - Top bar: `border-b border-border`, same `bg-background` as content
748
- - Content area: `bg-background`
749
- - No raw palette colors
750
- - Sidebar width: `var(--sidebar-width)` (16rem) and `var(--sidebar-width-icon)` (3rem)
751
- - Workspace switcher: `SidebarMenuButton size="lg"` for consistent sizing
752
-
753
- ---
754
-
755
- ## Accessibility
756
-
757
- - Sidebar: `<nav aria-label="Main navigation">`
758
- - Groups: `role="group"` with `aria-labelledby` pointing to group label
759
- - Active item: `aria-current="page"` (handled by `isActive` prop)
760
- - SidebarTrigger: `aria-label="Toggle sidebar"`, `aria-expanded`
761
- - Cmd+K: keyboard-triggered (Cmd/Ctrl+K), focus trapped in dialog
762
- - Skip-nav link: `<SkipNav />` before sidebar targets `#main-content`
763
- - Mobile sheet: focus trap, Escape to close, backdrop click to close
764
- - Notification button: `aria-label="Notifications"`, badge announces count
765
- - Breadcrumb: `<nav aria-label="breadcrumb">` with `aria-current="page"` on last item
766
-
767
- ---
768
-
769
- ## Reference Implementations
770
-
771
- - `src/app/(showcase)/layout.tsx` (showcase shell, uses AnimatedTabs instead of Sidebar)
772
- - `src/components/ui/Sidebar.tsx` (sidebar system)
773
- - `src/components/blocks/NavUser.tsx` (user menu)
774
- - `src/components/ui/SearchCommand.tsx` (search primitive)
775
- - `src/components/ui/Breadcrumb.tsx` (breadcrumb primitives)
776
- - `src/components/ui/Command.tsx` (cmdk wrapper)
777
-
778
- ---
779
-
780
- ## Verification
781
-
782
- ```bash
783
- npx tsc --noEmit
784
- npm run lint
785
- npm run test
786
- npx vitest run src/test/opinion-navigation.test.ts
787
- ```