@fayz-ai/ui 0.6.0 → 0.7.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 (88) hide show
  1. package/dist/chunk-75EFDLST.js +274 -0
  2. package/dist/chunk-75EFDLST.js.map +1 -0
  3. package/dist/{chunk-YB5ZJKNG.cjs → chunk-7PTRAWHO.cjs} +263 -225
  4. package/dist/chunk-7PTRAWHO.cjs.map +1 -0
  5. package/dist/{chunk-CZ6X6PZT.js → chunk-COC5DDFG.js} +313 -44
  6. package/dist/chunk-COC5DDFG.js.map +1 -0
  7. package/dist/{chunk-MYZT2OG7.js → chunk-FY7OZNKU.js} +24 -68
  8. package/dist/chunk-FY7OZNKU.js.map +1 -0
  9. package/dist/chunk-H6OULCHF.cjs +81 -0
  10. package/dist/chunk-H6OULCHF.cjs.map +1 -0
  11. package/dist/{chunk-GE6XUREM.cjs → chunk-HG5RH3B3.cjs} +339 -68
  12. package/dist/chunk-HG5RH3B3.cjs.map +1 -0
  13. package/dist/{chunk-7TBDPNLY.cjs → chunk-NWPMNPGR.cjs} +76 -47
  14. package/dist/chunk-NWPMNPGR.cjs.map +1 -0
  15. package/dist/{chunk-D6SXPF4F.cjs → chunk-ODY3GZ3Y.cjs} +41 -87
  16. package/dist/chunk-ODY3GZ3Y.cjs.map +1 -0
  17. package/dist/{chunk-22J2RTMN.js → chunk-OTUH4K4B.js} +196 -150
  18. package/dist/chunk-OTUH4K4B.js.map +1 -0
  19. package/dist/chunk-OW73RZOU.cjs +313 -0
  20. package/dist/chunk-OW73RZOU.cjs.map +1 -0
  21. package/dist/{chunk-GFH5HOCO.js → chunk-W5O2X277.js} +42 -14
  22. package/dist/chunk-W5O2X277.js.map +1 -0
  23. package/dist/chunk-YRVE4FOD.js +57 -0
  24. package/dist/chunk-YRVE4FOD.js.map +1 -0
  25. package/dist/dashboard/DashboardCanvas.d.ts +5 -1
  26. package/dist/dashboard/DashboardCanvas.d.ts.map +1 -1
  27. package/dist/dashboard/GoldCard.d.ts +19 -0
  28. package/dist/dashboard/GoldCard.d.ts.map +1 -0
  29. package/dist/dashboard/KpiCard.d.ts.map +1 -1
  30. package/dist/dashboard/index.cjs +30 -26
  31. package/dist/dashboard/index.d.ts +1 -0
  32. package/dist/dashboard/index.d.ts.map +1 -1
  33. package/dist/dashboard/index.js +3 -3
  34. package/dist/index.cjs +198 -174
  35. package/dist/index.d.ts +3 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +7 -7
  38. package/dist/layout/AppShell.d.ts +27 -6
  39. package/dist/layout/AppShell.d.ts.map +1 -1
  40. package/dist/layout/ContentSplit.d.ts +32 -0
  41. package/dist/layout/ContentSplit.d.ts.map +1 -0
  42. package/dist/layout/Sidebar.d.ts.map +1 -1
  43. package/dist/layout/Topbar.d.ts.map +1 -1
  44. package/dist/layout/index.cjs +31 -23
  45. package/dist/layout/index.d.ts +2 -1
  46. package/dist/layout/index.d.ts.map +1 -1
  47. package/dist/layout/index.js +3 -3
  48. package/dist/primitives/index.cjs +126 -114
  49. package/dist/primitives/index.d.ts +2 -0
  50. package/dist/primitives/index.d.ts.map +1 -1
  51. package/dist/primitives/index.js +4 -4
  52. package/dist/primitives/markdown-editor.d.ts +23 -0
  53. package/dist/primitives/markdown-editor.d.ts.map +1 -0
  54. package/dist/primitives/markdown.d.ts +9 -0
  55. package/dist/primitives/markdown.d.ts.map +1 -0
  56. package/package.json +3 -3
  57. package/src/dashboard/DashboardCanvas.tsx +13 -2
  58. package/src/dashboard/GoldCard.tsx +38 -0
  59. package/src/dashboard/KpiCard.tsx +4 -2
  60. package/src/dashboard/index.ts +1 -0
  61. package/src/index.ts +15 -0
  62. package/src/layout/AppShell.tsx +240 -20
  63. package/src/layout/ContentSplit.tsx +153 -0
  64. package/src/layout/ModulePage.tsx +2 -2
  65. package/src/layout/Sidebar.tsx +71 -20
  66. package/src/layout/Topbar.tsx +47 -17
  67. package/src/layout/index.ts +15 -1
  68. package/src/primitives/dropdown.tsx +4 -4
  69. package/src/primitives/index.ts +2 -0
  70. package/src/primitives/markdown-editor.tsx +75 -0
  71. package/src/primitives/markdown.tsx +169 -0
  72. package/src/primitives/select.tsx +1 -1
  73. package/dist/chunk-22J2RTMN.js.map +0 -1
  74. package/dist/chunk-7TBDPNLY.cjs.map +0 -1
  75. package/dist/chunk-CZ6X6PZT.js.map +0 -1
  76. package/dist/chunk-D6SXPF4F.cjs.map +0 -1
  77. package/dist/chunk-DNYVEOAX.js +0 -11
  78. package/dist/chunk-DNYVEOAX.js.map +0 -1
  79. package/dist/chunk-E526FHZ4.js +0 -169
  80. package/dist/chunk-E526FHZ4.js.map +0 -1
  81. package/dist/chunk-GE6XUREM.cjs.map +0 -1
  82. package/dist/chunk-GFH5HOCO.js.map +0 -1
  83. package/dist/chunk-MYZT2OG7.js.map +0 -1
  84. package/dist/chunk-VVVZO2LE.cjs +0 -13
  85. package/dist/chunk-VVVZO2LE.cjs.map +0 -1
  86. package/dist/chunk-W4XXXJRW.cjs +0 -196
  87. package/dist/chunk-W4XXXJRW.cjs.map +0 -1
  88. package/dist/chunk-YB5ZJKNG.cjs.map +0 -1
@@ -222,8 +222,10 @@ export function KpiCard(props: KpiCardProps) {
222
222
  }
223
223
 
224
224
  return (
225
- <Card>
226
- <CardContent className="p-4">
225
+ // h-full so cards fill the (stretch) grid cell and every card in a row matches
226
+ // the tallest — labels/subtitles of different lengths no longer misalign them.
227
+ <Card className="h-full">
228
+ <CardContent className="flex h-full flex-col p-4">
227
229
  <div className="mb-2 flex items-center justify-between gap-2">
228
230
  <span className="text-sm text-muted-foreground">{label}</span>
229
231
  <span className="text-muted-foreground">{renderIcon(icon)}</span>
@@ -10,6 +10,7 @@ export { resolveDashboardLayout, type LaidOutWidget } from './layout'
10
10
  export { useDashboardPreferences, type DashboardPreferencesState } from './preferences'
11
11
  export { DashboardCustomizeMenu, type DashboardCustomizeMenuProps } from './DashboardCustomizeMenu'
12
12
  export { DashboardGrid, type DashboardGridItem, type DashboardGridProps } from './DashboardGrid'
13
+ export { GoldCard, type GoldCardProps } from './GoldCard'
13
14
  export {
14
15
  DashboardRangeProvider,
15
16
  DashboardRangeControl,
package/src/index.ts CHANGED
@@ -98,6 +98,11 @@ export {
98
98
  SegmentedControl,
99
99
  type SegmentedControlProps,
100
100
  type SegmentedOption,
101
+ Markdown,
102
+ renderMarkdown,
103
+ type MarkdownProps,
104
+ MarkdownEditor,
105
+ type MarkdownEditorProps,
101
106
  } from './primitives'
102
107
 
103
108
  // Layout
@@ -105,6 +110,10 @@ export {
105
110
  AppShell,
106
111
  type AppShellProps,
107
112
  type AppShellUser,
113
+ type BottomNavItem,
114
+ type BottomNavRouteItem,
115
+ type BottomNavActionItem,
116
+ type MobileHeaderVariant,
108
117
  Sidebar,
109
118
  type SidebarProps,
110
119
  type SidebarUser,
@@ -114,6 +123,8 @@ export {
114
123
  type TopbarProps,
115
124
  ModulePage,
116
125
  SubpageHeader,
126
+ ContentSplit,
127
+ ContentSplitTrigger,
117
128
  ModuleLayoutProvider,
118
129
  useModuleLayout,
119
130
  PageHeaderActions,
@@ -134,6 +145,8 @@ export {
134
145
  type ModuleNavItem,
135
146
  type ModuleNavVariant,
136
147
  type SubpageHeaderProps,
148
+ type ContentSplitProps,
149
+ type ContentSplitTriggerProps,
137
150
  type BackButtonStyle,
138
151
  } from './layout'
139
152
 
@@ -179,6 +192,8 @@ export {
179
192
  DashboardGrid,
180
193
  type DashboardGridItem,
181
194
  type DashboardGridProps,
195
+ GoldCard,
196
+ type GoldCardProps,
182
197
  DashboardRangeProvider,
183
198
  DashboardRangeControl,
184
199
  useDashboardRange,
@@ -1,5 +1,18 @@
1
1
  import * as React from 'react'
2
- import { Menu } from 'lucide-react'
2
+ import {
3
+ Menu,
4
+ Home,
5
+ Receipt,
6
+ ArrowLeftRight,
7
+ Calendar,
8
+ CreditCard,
9
+ Settings,
10
+ MessageCircle,
11
+ Wallet,
12
+ PiggyBank,
13
+ Plus,
14
+ type LucideIcon,
15
+ } from 'lucide-react'
3
16
  import { Sidebar, type NavigationItem, type SidebarUser, type SidebarProps } from './Sidebar'
4
17
  import { Topbar } from './Topbar'
5
18
  import { SaveBar, SaveBarProvider } from './SaveBar'
@@ -35,6 +48,41 @@ export interface AppShellUser {
35
48
  email: string
36
49
  }
37
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Bottom-nav item model
53
+ //
54
+ // Two kinds share the mobile tab bar:
55
+ // - route (default): a normal tab that navigates to `route`.
56
+ // - action: a NON-route item rendered as a raised, elevated circular center
57
+ // button (Mobills style). It fires `onBottomNavAction(id)` instead of
58
+ // navigating — e.g. a global quick-add "+".
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export interface BottomNavRouteItem {
62
+ kind?: 'route'
63
+ label: string
64
+ icon: string
65
+ route: string
66
+ }
67
+
68
+ export interface BottomNavActionItem {
69
+ kind: 'action'
70
+ /** Identifier passed to onBottomNavAction. */
71
+ id: string
72
+ icon: string
73
+ /** Optional accessible label. */
74
+ label?: string
75
+ }
76
+
77
+ export type BottomNavItem = BottomNavRouteItem | BottomNavActionItem
78
+
79
+ /** Mobile header treatment (small screens, <md). Desktop is unaffected.
80
+ * - 'minimal' : a small top header bar (the default / today's behavior).
81
+ * - 'transparent' : NO header bar; content is edge-to-edge with a floating
82
+ * profile avatar pinned top-right (calls onProfile).
83
+ * - 'hidden' : no header bar and no floating avatar. */
84
+ export type MobileHeaderVariant = 'transparent' | 'minimal' | 'hidden'
85
+
38
86
  export interface AppShellProps {
39
87
  /** Layout variant */
40
88
  variant: 'sidebar' | 'topbar' | 'minimal'
@@ -58,7 +106,11 @@ export interface AppShellProps {
58
106
  topbarEnd?: React.ReactNode
59
107
  sidebarTopContent?: React.ReactNode
60
108
  sidebarFooterContent?: React.ReactNode
61
- bottomNav?: Array<{ label: string; icon: string; route: string }>
109
+ bottomNav?: BottomNavItem[]
110
+ /** Fired when a bottom-nav `action` item (e.g. the center "+") is tapped. */
111
+ onBottomNavAction?: (id: string) => void
112
+ /** Mobile header treatment (<md). Default: 'minimal'. Desktop unaffected. */
113
+ mobileHeader?: MobileHeaderVariant
62
114
  /** Rendered in sidebar bottom user slot */
63
115
  userMenuSlot?: React.ReactNode
64
116
  /** Rendered in sidebar bottom notification slot */
@@ -93,6 +145,8 @@ function SidebarLayout({
93
145
  userMenuSlot,
94
146
  notificationSlot,
95
147
  unreadCount = 0,
148
+ hasBottomNav = false,
149
+ mobileHeader = 'minimal',
96
150
  }: {
97
151
  navigation?: NavigationItem[]
98
152
  logo?: React.ReactNode
@@ -115,6 +169,11 @@ function SidebarLayout({
115
169
  userMenuSlot?: React.ReactNode
116
170
  notificationSlot?: React.ReactNode
117
171
  unreadCount?: number
172
+ /** When a mobile bottom-nav bar is present, the mobile hamburger is
173
+ * suppressed so the bottom nav is the primary mobile navigation. */
174
+ hasBottomNav?: boolean
175
+ /** Mobile header treatment (<md). Desktop keeps its top bar untouched. */
176
+ mobileHeader?: MobileHeaderVariant
118
177
  }) {
119
178
  const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed)
120
179
  const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed)
@@ -126,7 +185,13 @@ function SidebarLayout({
126
185
  <SaveBarProvider>
127
186
  <ModuleHeaderSlotContext.Provider value={headerSlot}>
128
187
  <ModuleHeaderActionsSlotContext.Provider value={actionsSlot}>
129
- <div className="flex h-screen overflow-hidden bg-background">
188
+ <div className={cn(
189
+ 'flex h-screen overflow-hidden bg-background',
190
+ // Frame mode: the inset gap around the framed card takes the sidebar (menu)
191
+ // color, so the white card reads as a panel floating on the same tone as the
192
+ // left menu. The card's own md:m-2 margin reveals this behind it.
193
+ frame && 'md:bg-sidebar',
194
+ )}>
130
195
  {/* Desktop Sidebar — always flush / full-height (GoHighLevel style) */}
131
196
  <div className="hidden md:flex">
132
197
  <Sidebar
@@ -158,16 +223,27 @@ function SidebarLayout({
158
223
  'relative flex flex-1 flex-col overflow-hidden',
159
224
  frame && 'md:m-2 md:rounded-xl md:border md:border-border md:bg-card'
160
225
  )}>
161
- {/* Top bar — mobile hamburger + module header slot (title + sub-nav) + actions */}
162
- <div className="flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-card px-4 py-2">
226
+ {/* Top bar — mobile hamburger + module header slot (title + sub-nav) + actions.
227
+ On mobile the bar is suppressed for the 'transparent' / 'hidden' header
228
+ treatments (content goes edge-to-edge); desktop keeps it always. The
229
+ portal targets below stay mounted (display:none on mobile) so module
230
+ pages can still portal their sub-nav on desktop without crashing. */}
231
+ <div className={cn(
232
+ 'flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-card px-4 py-2',
233
+ mobileHeader !== 'minimal' && 'hidden md:flex',
234
+ )}>
163
235
  <div className="flex min-w-0 flex-1 items-center gap-3">
164
- <button
165
- onClick={() => setMobileMenuOpen(true)}
166
- className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
167
- aria-label="Open menu"
168
- >
169
- <Menu className="h-5 w-5" />
170
- </button>
236
+ {/* Mobile hamburger — suppressed when a bottom-nav bar owns mobile
237
+ navigation (mobile-first apps). Desktop sidebar is untouched. */}
238
+ {!hasBottomNav && (
239
+ <button
240
+ onClick={() => setMobileMenuOpen(true)}
241
+ className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
242
+ aria-label="Open menu"
243
+ >
244
+ <Menu className="h-5 w-5" />
245
+ </button>
246
+ )}
171
247
  {pageTitle && <h2 className="font-semibold text-lg">{pageTitle}</h2>}
172
248
  {topbarStart}
173
249
  {/* Module pages (tabs variant) portal their sub-nav here */}
@@ -181,7 +257,7 @@ function SidebarLayout({
181
257
  </div>
182
258
 
183
259
  {/* Page content */}
184
- <main className="flex-1 overflow-y-auto">
260
+ <main className="flex-1 overflow-y-auto pb-16 md:pb-0">
185
261
  {children}
186
262
  </main>
187
263
 
@@ -236,6 +312,7 @@ function TopbarLayout({
236
312
  currentPath,
237
313
  topbarStart,
238
314
  topbarEnd,
315
+ hasBottomNav = false,
239
316
  }: {
240
317
  navigation?: NavigationItem[]
241
318
  logo?: React.ReactNode
@@ -251,6 +328,9 @@ function TopbarLayout({
251
328
  currentPath?: string
252
329
  topbarStart?: React.ReactNode
253
330
  topbarEnd?: React.ReactNode
331
+ /** When a mobile bottom-nav bar is present, the mobile hamburger is
332
+ * suppressed so the bottom nav is the primary mobile navigation. */
333
+ hasBottomNav?: boolean
254
334
  }) {
255
335
  const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
256
336
 
@@ -271,10 +351,12 @@ function TopbarLayout({
271
351
  userMenuExtras={userMenuExtras}
272
352
  leftContent={topbarStart}
273
353
  rightContent={topbarEnd}
274
- onMenuClick={() => setMobileMenuOpen(true)}
354
+ onMenuClick={hasBottomNav ? undefined : () => setMobileMenuOpen(true)}
275
355
  />
276
- {/* Frame inset/border only from md up, so mobile stays edge-to-edge. */}
277
- <main className={cn('flex-1 overflow-y-auto', frame && 'md:p-2')}>
356
+ {/* Frame inset/border only from md up, so mobile stays edge-to-edge. The
357
+ inset gap behind the framed card is the header color (bg-sidebar), so the
358
+ white card reads as a panel floating on the same navy as the topbar. */}
359
+ <main className={cn('flex-1 overflow-y-auto pb-16 md:pb-0', frame && 'md:bg-sidebar md:p-2')}>
278
360
  <div className={cn('min-h-full', frame && 'md:rounded-xl md:border md:border-border md:bg-card')}>
279
361
  {children}
280
362
  </div>
@@ -345,7 +427,7 @@ function MinimalLayout({
345
427
  </div>
346
428
  {headerEnd && <div className="flex items-center gap-2">{headerEnd}</div>}
347
429
  </header>
348
- <main className="flex-1 overflow-y-auto">
430
+ <main className="flex-1 overflow-y-auto pb-16 md:pb-0">
349
431
  {children}
350
432
  </main>
351
433
  </div>
@@ -426,6 +508,111 @@ function MobileOverlay({ onClose, onNavigate, ...sidebarProps }: MobileOverlayPr
426
508
  )
427
509
  }
428
510
 
511
+ // ---------------------------------------------------------------------------
512
+ // Mobile bottom tab bar — rendered for every variant on small screens.
513
+ // ui must NOT import from saas, so the icon lookup is a small local map (a
514
+ // mirror of the saas icon convention). Unknown icon names fall back to Home.
515
+ // ---------------------------------------------------------------------------
516
+
517
+ const BOTTOM_NAV_ICONS: Record<string, LucideIcon> = {
518
+ Home,
519
+ Receipt,
520
+ ArrowLeftRight,
521
+ Calendar,
522
+ CreditCard,
523
+ Settings,
524
+ Menu,
525
+ MessageCircle,
526
+ Wallet,
527
+ PiggyBank,
528
+ Plus,
529
+ }
530
+
531
+ // Floating profile avatar — mobile-only (md:hidden), pinned top-right over the
532
+ // content. Used with the 'transparent' mobile header treatment so there is no
533
+ // header bar but the user still has a one-tap route into their profile.
534
+ function FloatingProfileAvatar({ user, onClick }: { user?: AppShellUser; onClick?: () => void }) {
535
+ if (!user) return null
536
+ const label = user.fullName || user.email
537
+ return (
538
+ <button
539
+ type="button"
540
+ onClick={onClick}
541
+ aria-label="Profile"
542
+ className="fixed right-3 top-3 z-40 md:hidden"
543
+ >
544
+ {user.avatarUrl ? (
545
+ <img
546
+ src={user.avatarUrl}
547
+ alt=""
548
+ className="h-9 w-9 rounded-full object-cover shadow-md ring-2 ring-background"
549
+ />
550
+ ) : (
551
+ <span className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-md ring-2 ring-background">
552
+ {(label || '?').charAt(0).toUpperCase()}
553
+ </span>
554
+ )}
555
+ </button>
556
+ )
557
+ }
558
+
559
+ function MobileBottomNav({
560
+ items,
561
+ currentPath,
562
+ onNavigate,
563
+ onAction,
564
+ }: {
565
+ items: NonNullable<AppShellProps['bottomNav']>
566
+ currentPath?: string
567
+ onNavigate?: (route: string) => void
568
+ onAction?: (id: string) => void
569
+ }) {
570
+ const path = currentPath ?? ''
571
+ return (
572
+ <nav className="fixed inset-x-0 bottom-0 z-50 flex h-16 items-center justify-around border-t border-border bg-background md:hidden">
573
+ {items.map((item) => {
574
+ // Elevated center ACTION button (Mobills style) — raised above the bar,
575
+ // circular, primary-filled; fires onBottomNavAction instead of routing.
576
+ if (item.kind === 'action') {
577
+ const ActionIcon = BOTTOM_NAV_ICONS[item.icon] ?? Plus
578
+ return (
579
+ <div key={item.id} className="flex flex-1 items-start justify-center">
580
+ <button
581
+ type="button"
582
+ onClick={() => onAction?.(item.id)}
583
+ aria-label={item.label ?? 'Add'}
584
+ className="-mt-6 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30 ring-4 ring-background transition-transform active:scale-95"
585
+ >
586
+ <ActionIcon className="h-6 w-6" />
587
+ </button>
588
+ </div>
589
+ )
590
+ }
591
+ const isActive =
592
+ item.route === '/'
593
+ ? path === '/' || path === ''
594
+ : path === item.route || path.startsWith(item.route + '/')
595
+ const Icon = BOTTOM_NAV_ICONS[item.icon] ?? Home
596
+ return (
597
+ <button
598
+ key={item.route}
599
+ type="button"
600
+ onClick={() => onNavigate?.(item.route)}
601
+ className={cn(
602
+ 'flex flex-1 flex-col items-center justify-center gap-0.5 px-1 py-1 transition-colors',
603
+ isActive ? 'text-primary' : 'text-muted-foreground',
604
+ )}
605
+ aria-current={isActive ? 'page' : undefined}
606
+ >
607
+ <Icon className="h-5 w-5" />
608
+ <span className="text-xs">{item.label}</span>
609
+ </button>
610
+ )
611
+ })}
612
+ </nav>
613
+ )
614
+ }
615
+
429
616
  // ---------------------------------------------------------------------------
430
617
  // AppShell — main entry point
431
618
  // ---------------------------------------------------------------------------
@@ -450,13 +637,32 @@ export function AppShell({
450
637
  topbarEnd,
451
638
  sidebarTopContent,
452
639
  sidebarFooterContent,
640
+ bottomNav,
641
+ onBottomNavAction,
642
+ mobileHeader = 'minimal',
453
643
  userMenuSlot,
454
644
  notificationSlot,
455
645
  unreadCount = 0,
456
646
  }: AppShellProps) {
647
+ const hasBottomNav = !!bottomNav?.length
648
+ const bottomBar = hasBottomNav ? (
649
+ <MobileBottomNav
650
+ items={bottomNav!}
651
+ currentPath={currentPath}
652
+ onNavigate={onNavigate}
653
+ onAction={onBottomNavAction}
654
+ />
655
+ ) : null
656
+ // Transparent mobile header → no bar, a floating avatar top-right instead.
657
+ const floatingAvatar =
658
+ mobileHeader === 'transparent' && user ? (
659
+ <FloatingProfileAvatar user={user} onClick={onProfile} />
660
+ ) : null
661
+
662
+ let layoutElement: React.ReactNode
457
663
  switch (variant) {
458
664
  case 'sidebar':
459
- return (
665
+ layoutElement = (
460
666
  <SidebarLayout
461
667
  navigation={navigation}
462
668
  logo={logo}
@@ -478,13 +684,16 @@ export function AppShell({
478
684
  userMenuSlot={userMenuSlot}
479
685
  notificationSlot={notificationSlot}
480
686
  unreadCount={unreadCount}
687
+ hasBottomNav={hasBottomNav}
688
+ mobileHeader={mobileHeader}
481
689
  >
482
690
  {children}
483
691
  </SidebarLayout>
484
692
  )
693
+ break
485
694
 
486
695
  case 'topbar':
487
- return (
696
+ layoutElement = (
488
697
  <TopbarLayout
489
698
  navigation={navigation}
490
699
  logo={logo}
@@ -499,21 +708,32 @@ export function AppShell({
499
708
  currentPath={currentPath}
500
709
  topbarStart={topbarStart}
501
710
  topbarEnd={topbarEnd}
711
+ hasBottomNav={hasBottomNav}
502
712
  >
503
713
  {children}
504
714
  </TopbarLayout>
505
715
  )
716
+ break
506
717
 
507
718
  case 'minimal':
508
- return (
719
+ layoutElement = (
509
720
  <MinimalLayout logo={logo} user={user} headerEnd={topbarEnd}>
510
721
  {children}
511
722
  </MinimalLayout>
512
723
  )
724
+ break
513
725
 
514
726
  default: {
515
727
  const _exhaustive: never = variant
516
728
  return null
517
729
  }
518
730
  }
731
+
732
+ return (
733
+ <>
734
+ {layoutElement}
735
+ {floatingAvatar}
736
+ {bottomBar}
737
+ </>
738
+ )
519
739
  }
@@ -0,0 +1,153 @@
1
+ import * as React from 'react'
2
+ import { PanelRight, PanelRightClose, PanelRightOpen } from 'lucide-react'
3
+ import { cn } from '../utils/cn'
4
+ import { Button } from '../primitives/button'
5
+ import {
6
+ Sheet, SheetContent, SheetHeader, SheetBody, SheetTitle, SheetDescription,
7
+ } from '../primitives/sheet'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // ContentSplit — main content with a right-aligned companion panel, mobile
11
+ // first. Below `lg` the aside lives in a right Sheet (opened by a trigger the
12
+ // host places wherever fits — typically SubpageHeader actions, via
13
+ // <ContentSplitTrigger/>). From `lg` up it docks as a sticky right column.
14
+ //
15
+ // Pairs with SubpageHeader/ModulePage: the page keeps its own back-button
16
+ // header; ContentSplit only owns the columns below it. Reuse for any
17
+ // "document + operational panel" page (script + shooting checklist, form +
18
+ // help, detail + activity feed).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface ContentSplitContextValue {
22
+ open: boolean
23
+ setOpen: (open: boolean) => void
24
+ }
25
+
26
+ const ContentSplitContext = React.createContext<ContentSplitContextValue | null>(null)
27
+
28
+ export interface ContentSplitProps {
29
+ /** Main column (the document). */
30
+ children: React.ReactNode
31
+ /** Companion panel content (the checklist / help / activity). */
32
+ aside: React.ReactNode
33
+ /** Panel title — Sheet header on mobile, card header on desktop. */
34
+ asideTitle: React.ReactNode
35
+ /** Optional subtitle under the title. */
36
+ asideDescription?: React.ReactNode
37
+ /** Desktop column width (defaults to 20rem). */
38
+ asideWidth?: string
39
+ /** Controlled mobile-sheet state; uncontrolled when omitted. */
40
+ asideOpen?: boolean
41
+ onAsideOpenChange?: (open: boolean) => void
42
+ /** Desktop column can collapse into a slim reopen rail. */
43
+ collapsible?: boolean
44
+ /** Initial desktop collapsed state (e.g. expand only on the relevant day). */
45
+ defaultCollapsed?: boolean
46
+ className?: string
47
+ }
48
+
49
+ export function ContentSplit({
50
+ children,
51
+ aside,
52
+ asideTitle,
53
+ asideDescription,
54
+ asideWidth = '20rem',
55
+ asideOpen,
56
+ onAsideOpenChange,
57
+ collapsible = false,
58
+ defaultCollapsed = false,
59
+ className,
60
+ }: ContentSplitProps) {
61
+ const [internalOpen, setInternalOpen] = React.useState(false)
62
+ const open = asideOpen ?? internalOpen
63
+ const setOpen = onAsideOpenChange ?? setInternalOpen
64
+ const [collapsed, setCollapsed] = React.useState(collapsible && defaultCollapsed)
65
+
66
+ return (
67
+ <ContentSplitContext.Provider value={{ open, setOpen }}>
68
+ <div
69
+ className={cn('lg:grid lg:items-start lg:gap-6', className)}
70
+ style={{ gridTemplateColumns: `minmax(0, 1fr) ${collapsed ? 'auto' : asideWidth}` }}
71
+ >
72
+ <div className="min-w-0">{children}</div>
73
+
74
+ {/* Desktop: docked sticky column (or slim reopen rail when collapsed) */}
75
+ <aside className="hidden lg:block lg:sticky lg:top-4">
76
+ {collapsed ? (
77
+ <Button
78
+ variant="outline"
79
+ size="sm"
80
+ onClick={() => setCollapsed(false)}
81
+ className="h-9 w-9 p-0"
82
+ aria-label={typeof asideTitle === 'string' ? asideTitle : undefined}
83
+ title={typeof asideTitle === 'string' ? asideTitle : undefined}
84
+ >
85
+ <PanelRightOpen className="h-4 w-4" />
86
+ </Button>
87
+ ) : (
88
+ <div className="rounded-card border border-border bg-card">
89
+ <div className="flex items-start justify-between gap-2 border-b border-border px-4 py-3">
90
+ <div className="min-w-0">
91
+ <p className="text-sm font-semibold text-foreground">{asideTitle}</p>
92
+ {asideDescription && (
93
+ <p className="mt-0.5 text-xs text-muted-foreground">{asideDescription}</p>
94
+ )}
95
+ </div>
96
+ {collapsible && (
97
+ <button
98
+ type="button"
99
+ onClick={() => setCollapsed(true)}
100
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
101
+ aria-label="collapse"
102
+ >
103
+ <PanelRightClose className="h-4 w-4" />
104
+ </button>
105
+ )}
106
+ </div>
107
+ <div className="p-4">{aside}</div>
108
+ </div>
109
+ )}
110
+ </aside>
111
+ </div>
112
+
113
+ {/* Mobile: right sheet */}
114
+ <div className="lg:hidden">
115
+ <Sheet open={open} onOpenChange={setOpen}>
116
+ <SheetContent width="max-w-sm">
117
+ <SheetHeader>
118
+ <SheetTitle>{asideTitle}</SheetTitle>
119
+ {asideDescription && <SheetDescription>{asideDescription}</SheetDescription>}
120
+ </SheetHeader>
121
+ <SheetBody>{aside}</SheetBody>
122
+ </SheetContent>
123
+ </Sheet>
124
+ </div>
125
+ </ContentSplitContext.Provider>
126
+ )
127
+ }
128
+
129
+ export interface ContentSplitTriggerProps {
130
+ /** Accessible label; also the visible text unless `iconOnly`. */
131
+ label: React.ReactNode
132
+ iconOnly?: boolean
133
+ className?: string
134
+ }
135
+
136
+ /** Opens the mobile aside sheet. Renders nothing from `lg` up (the panel is
137
+ * already docked). Place inside SubpageHeader `actions` or any toolbar. */
138
+ export function ContentSplitTrigger({ label, iconOnly, className }: ContentSplitTriggerProps) {
139
+ const ctx = React.useContext(ContentSplitContext)
140
+ if (!ctx) return null
141
+ return (
142
+ <Button
143
+ variant="outline"
144
+ size="sm"
145
+ onClick={() => ctx.setOpen(true)}
146
+ className={cn('lg:hidden', className)}
147
+ aria-label={typeof label === 'string' ? label : undefined}
148
+ >
149
+ <PanelRight className={cn('h-4 w-4', !iconOnly && 'mr-1.5')} />
150
+ {!iconOnly && label}
151
+ </Button>
152
+ )
153
+ }
@@ -139,7 +139,7 @@ function MobileTabs({ nav }: { nav: ModuleNavItem[] }) {
139
139
 
140
140
  return (
141
141
  <div className="md:hidden -mx-1 mb-4 no-print">
142
- <div ref={scrollRef} className="flex gap-1 px-1 pb-2 overflow-x-auto scrollbar-hide">
142
+ <div ref={scrollRef} className="flex gap-1 px-1 pb-2 overflow-x-auto scrollbar-none">
143
143
  {nav.map((item) => {
144
144
  const Icon = item.icon ? (ICON_MAP[item.icon] ?? null) : null
145
145
  const hasChildren = item.children && item.children.length > 0
@@ -340,7 +340,7 @@ function ModuleHeader({ nav }: { nav: ModuleNavItem[] }) {
340
340
 
341
341
  const content = (
342
342
  <div className="flex w-full min-w-0 items-center gap-4">
343
- <div className="flex min-w-0 items-center gap-0.5 overflow-x-auto scrollbar-hide">
343
+ <div className="flex min-w-0 items-center gap-0.5 overflow-x-auto scrollbar-none">
344
344
  {nav.map((item) => {
345
345
  const Icon = item.icon ? (ICON_MAP[item.icon] ?? null) : null
346
346
  const hasChildren = item.children && item.children.length > 0