@exxatdesignux/ui 0.0.6 → 0.0.7

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 (264) hide show
  1. package/bin/init.mjs +29 -0
  2. package/package.json +7 -2
  3. package/template/.nvmrc +1 -0
  4. package/template/.prettierignore +7 -0
  5. package/template/.prettierrc +11 -0
  6. package/template/AGENTS.md +485 -0
  7. package/template/Logo/Exxat_Prism.svg +39 -0
  8. package/template/Logo/Exxat_one.svg +36 -0
  9. package/template/README.md +58 -0
  10. package/template/app/(app)/compliance/page.tsx +10 -0
  11. package/template/app/(app)/dashboard/loading.tsx +18 -0
  12. package/template/app/(app)/dashboard/page.tsx +36 -0
  13. package/template/app/(app)/data-list/[id]/page.tsx +28 -0
  14. package/template/app/(app)/data-list/new/page.tsx +31 -0
  15. package/template/app/(app)/data-list/page.tsx +10 -0
  16. package/template/app/(app)/error.tsx +43 -0
  17. package/template/app/(app)/help/page.tsx +34 -0
  18. package/template/app/(app)/layout.tsx +54 -0
  19. package/template/app/(app)/loading.tsx +18 -0
  20. package/template/app/(app)/question-bank/page.tsx +10 -0
  21. package/template/app/(app)/rotations/page.tsx +15 -0
  22. package/template/app/(app)/settings/page.tsx +17 -0
  23. package/template/app/(app)/sites/all/page.tsx +13 -0
  24. package/template/app/(app)/team/page.tsx +10 -0
  25. package/template/app/favicon.ico +0 -0
  26. package/template/app/globals.css +1811 -0
  27. package/template/app/layout.tsx +95 -0
  28. package/template/app/page.tsx +9 -0
  29. package/template/components/.gitkeep +0 -0
  30. package/template/components/app-sidebar-dynamic.tsx +15 -0
  31. package/template/components/app-sidebar.tsx +901 -0
  32. package/template/components/ask-leo-composer.tsx +216 -0
  33. package/template/components/ask-leo-sidebar.tsx +509 -0
  34. package/template/components/chart-area-interactive.tsx +293 -0
  35. package/template/components/charts-overview.tsx +2321 -0
  36. package/template/components/command-menu-01.tsx +133 -0
  37. package/template/components/command-menu-02.tsx +386 -0
  38. package/template/components/command-menu.tsx +182 -0
  39. package/template/components/compliance-board-view.tsx +134 -0
  40. package/template/components/compliance-client.tsx +92 -0
  41. package/template/components/compliance-list-view.tsx +59 -0
  42. package/template/components/compliance-page-header.tsx +89 -0
  43. package/template/components/compliance-table.tsx +525 -0
  44. package/template/components/dashboard-onboarding-gallery.tsx +13 -0
  45. package/template/components/dashboard-onboarding.tsx +21 -0
  46. package/template/components/dashboard-promo-banner.tsx +67 -0
  47. package/template/components/dashboard-quota-progress-card.tsx +369 -0
  48. package/template/components/dashboard-report-charts.tsx +69 -0
  49. package/template/components/dashboard-section-heading.tsx +68 -0
  50. package/template/components/dashboard-tabs.tsx +598 -0
  51. package/template/components/data-list-client.tsx +239 -0
  52. package/template/components/data-list-table-cells.test.tsx +22 -0
  53. package/template/components/data-list-table-cells.tsx +173 -0
  54. package/template/components/data-list-table.tsx +879 -0
  55. package/template/components/data-table/filter-date-calendar.tsx +38 -0
  56. package/template/components/data-table/filter-text-value-input.tsx +77 -0
  57. package/template/components/data-table/index.tsx +1612 -0
  58. package/template/components/data-table/pagination.tsx +256 -0
  59. package/template/components/data-table/types.ts +91 -0
  60. package/template/components/data-table/use-table-state.ts +566 -0
  61. package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
  62. package/template/components/data-view-dashboard-charts-team.tsx +968 -0
  63. package/template/components/data-view-dashboard-charts.tsx +1668 -0
  64. package/template/components/data-views/board-card-primitives.tsx +93 -0
  65. package/template/components/data-views/index.ts +41 -0
  66. package/template/components/data-views/list-page-board-card.tsx +192 -0
  67. package/template/components/data-views/list-page-board-template.tsx +122 -0
  68. package/template/components/data-views/placement-board-card.tsx +262 -0
  69. package/template/components/export-drawer.tsx +375 -0
  70. package/template/components/exxat-product-logo.tsx +453 -0
  71. package/template/components/form-layout-01.tsx +131 -0
  72. package/template/components/getting-started.tsx +625 -0
  73. package/template/components/key-metrics.tsx +920 -0
  74. package/template/components/leo-insight-indicator.tsx +364 -0
  75. package/template/components/leo-typing-dots.tsx +121 -0
  76. package/template/components/list-hub-status-badge.tsx +51 -0
  77. package/template/components/list-page-dashboard-charts.tsx +18 -0
  78. package/template/components/nav-documents.tsx +89 -0
  79. package/template/components/nav-main.tsx +58 -0
  80. package/template/components/nav-secondary.tsx +64 -0
  81. package/template/components/nav-user.tsx +190 -0
  82. package/template/components/new-placement-back-btn.tsx +28 -0
  83. package/template/components/new-placement-form.tsx +1066 -0
  84. package/template/components/onboarding/index.ts +4 -0
  85. package/template/components/onboarding/onboarding-01.tsx +7 -0
  86. package/template/components/onboarding/onboarding-02.tsx +7 -0
  87. package/template/components/onboarding/onboarding-03.tsx +7 -0
  88. package/template/components/onboarding/onboarding-04.tsx +7 -0
  89. package/template/components/page-header.tsx +57 -0
  90. package/template/components/placement-detail.tsx +438 -0
  91. package/template/components/placements-board-view.tsx +404 -0
  92. package/template/components/placements-list-view.tsx +285 -0
  93. package/template/components/placements-page-header.tsx +160 -0
  94. package/template/components/placements-table-columns.tsx +639 -0
  95. package/template/components/product-switcher.tsx +116 -0
  96. package/template/components/question-bank-board-view.tsx +205 -0
  97. package/template/components/question-bank-client.tsx +77 -0
  98. package/template/components/question-bank-list-view.tsx +59 -0
  99. package/template/components/question-bank-page-header.tsx +89 -0
  100. package/template/components/question-bank-table.tsx +586 -0
  101. package/template/components/rotations-empty-state.tsx +47 -0
  102. package/template/components/rotations-panel-activator.tsx +8 -0
  103. package/template/components/secondary-nav.tsx +394 -0
  104. package/template/components/secondary-panel.tsx +239 -0
  105. package/template/components/section-cards.tsx +106 -0
  106. package/template/components/settings-appearance-card.tsx +424 -0
  107. package/template/components/settings-client.tsx +537 -0
  108. package/template/components/settings-form-row.tsx +42 -0
  109. package/template/components/sidebar-auto-collapse.tsx +23 -0
  110. package/template/components/sidebar-auto-open.tsx +18 -0
  111. package/template/components/sidebar-shell.tsx +37 -0
  112. package/template/components/site-header.tsx +93 -0
  113. package/template/components/sites-all-client.tsx +154 -0
  114. package/template/components/sites-board-view.tsx +67 -0
  115. package/template/components/sites-list-view.tsx +47 -0
  116. package/template/components/sites-table.tsx +312 -0
  117. package/template/components/system-banner-slot.tsx +66 -0
  118. package/template/components/table-properties/column-row.tsx +90 -0
  119. package/template/components/table-properties/draggable-list.ts +49 -0
  120. package/template/components/table-properties/drawer-button.tsx +231 -0
  121. package/template/components/table-properties/drawer.tsx +1102 -0
  122. package/template/components/table-properties/filter-card.tsx +251 -0
  123. package/template/components/table-properties/index.ts +22 -0
  124. package/template/components/table-properties/sort-card.tsx +59 -0
  125. package/template/components/table-properties/types.ts +124 -0
  126. package/template/components/task-list-panel.tsx +98 -0
  127. package/template/components/task-priority-badge.tsx +28 -0
  128. package/template/components/team-board-view.tsx +114 -0
  129. package/template/components/team-client.tsx +93 -0
  130. package/template/components/team-list-view.tsx +62 -0
  131. package/template/components/team-page-header.tsx +92 -0
  132. package/template/components/team-table.tsx +525 -0
  133. package/template/components/templates/list-page.tsx +576 -0
  134. package/template/components/templates/primary-page-template.tsx +56 -0
  135. package/template/components/theme-color-sync.tsx +32 -0
  136. package/template/components/theme-provider.tsx +71 -0
  137. package/template/components/tinted-icon-disc.tsx +53 -0
  138. package/template/components/ui/ai-thinking-surface.tsx +121 -0
  139. package/template/components/ui/avatar.tsx +1 -0
  140. package/template/components/ui/badge.tsx +1 -0
  141. package/template/components/ui/banner.tsx +1 -0
  142. package/template/components/ui/breadcrumb.tsx +1 -0
  143. package/template/components/ui/button.tsx +1 -0
  144. package/template/components/ui/calendar.tsx +1 -0
  145. package/template/components/ui/card.tsx +1 -0
  146. package/template/components/ui/chart.tsx +1 -0
  147. package/template/components/ui/checkbox.tsx +1 -0
  148. package/template/components/ui/coach-mark.tsx +1 -0
  149. package/template/components/ui/collapsible.tsx +1 -0
  150. package/template/components/ui/command.tsx +1 -0
  151. package/template/components/ui/date-picker-field.tsx +1 -0
  152. package/template/components/ui/dialog.tsx +1 -0
  153. package/template/components/ui/dot-pattern.tsx +159 -0
  154. package/template/components/ui/drag-handle-grip.tsx +1 -0
  155. package/template/components/ui/drawer.tsx +1 -0
  156. package/template/components/ui/dropdown-menu.tsx +1 -0
  157. package/template/components/ui/field.tsx +1 -0
  158. package/template/components/ui/form.tsx +1 -0
  159. package/template/components/ui/input-group.tsx +1 -0
  160. package/template/components/ui/input-mask.tsx +1 -0
  161. package/template/components/ui/input.tsx +1 -0
  162. package/template/components/ui/kbd.tsx +1 -0
  163. package/template/components/ui/label.tsx +1 -0
  164. package/template/components/ui/leo-icon.tsx +726 -0
  165. package/template/components/ui/payment-card-fields.tsx +1 -0
  166. package/template/components/ui/popover.tsx +1 -0
  167. package/template/components/ui/radio-group.tsx +1 -0
  168. package/template/components/ui/select.tsx +1 -0
  169. package/template/components/ui/selection-tile-grid.tsx +1 -0
  170. package/template/components/ui/separator.tsx +1 -0
  171. package/template/components/ui/sheet.tsx +1 -0
  172. package/template/components/ui/sidebar.tsx +1 -0
  173. package/template/components/ui/skeleton.tsx +1 -0
  174. package/template/components/ui/sonner.tsx +1 -0
  175. package/template/components/ui/status-badge.tsx +1 -0
  176. package/template/components/ui/table.tsx +1 -0
  177. package/template/components/ui/tabs.tsx +1 -0
  178. package/template/components/ui/textarea.tsx +1 -0
  179. package/template/components/ui/tip.tsx +1 -0
  180. package/template/components/ui/toggle-group.tsx +1 -0
  181. package/template/components/ui/toggle-switch.tsx +1 -0
  182. package/template/components/ui/toggle.tsx +1 -0
  183. package/template/components/ui/tooltip.tsx +1 -0
  184. package/template/components/ui/view-segmented-control.tsx +1 -0
  185. package/template/components.json +27 -0
  186. package/template/contexts/chart-variant-context.tsx +35 -0
  187. package/template/contexts/command-menu-context.tsx +28 -0
  188. package/template/contexts/dashboard-view-context.tsx +35 -0
  189. package/template/contexts/product-context.tsx +38 -0
  190. package/template/contexts/system-banner-context.tsx +127 -0
  191. package/template/docs/command-menu-pattern.md +45 -0
  192. package/template/docs/data-views-pattern.md +160 -0
  193. package/template/ecosystem.config.cjs +20 -0
  194. package/template/eslint.config.mjs +18 -0
  195. package/template/fontawesome-subset.manifest.json +190 -0
  196. package/template/hooks/.gitkeep +0 -0
  197. package/template/hooks/use-app-theme.ts +1 -0
  198. package/template/hooks/use-coach-mark.ts +1 -0
  199. package/template/hooks/use-mobile.ts +1 -0
  200. package/template/hooks/use-mod-key-label.ts +1 -0
  201. package/template/lib/.gitkeep +0 -0
  202. package/template/lib/ask-leo-route-context.ts +133 -0
  203. package/template/lib/chart-keyboard-selection.test.ts +20 -0
  204. package/template/lib/chart-keyboard-selection.ts +17 -0
  205. package/template/lib/chart-line-dash.ts +16 -0
  206. package/template/lib/coach-mark-registry.ts +68 -0
  207. package/template/lib/command-menu-config.ts +127 -0
  208. package/template/lib/command-menu-search-data.ts +44 -0
  209. package/template/lib/conditional-rule-match.ts +32 -0
  210. package/template/lib/dashboard-customize-coach-mark.ts +18 -0
  211. package/template/lib/dashboard-layout-merge.ts +63 -0
  212. package/template/lib/data-list-display-options.ts +35 -0
  213. package/template/lib/data-list-persistence.ts +280 -0
  214. package/template/lib/data-list-view-surface.ts +58 -0
  215. package/template/lib/data-list-view.ts +29 -0
  216. package/template/lib/data-view-dashboard-storage.ts +101 -0
  217. package/template/lib/date-filter.ts +8 -0
  218. package/template/lib/dev-log.test.ts +28 -0
  219. package/template/lib/dev-log.ts +8 -0
  220. package/template/lib/editable-target.ts +10 -0
  221. package/template/lib/floating-sheet-panel.ts +72 -0
  222. package/template/lib/initials-from-name.ts +7 -0
  223. package/template/lib/list-page-table-properties.ts +52 -0
  224. package/template/lib/list-status-badges.ts +168 -0
  225. package/template/lib/logo-dev.ts +12 -0
  226. package/template/lib/mock/compliance-kpi.ts +61 -0
  227. package/template/lib/mock/compliance.ts +146 -0
  228. package/template/lib/mock/dashboard.ts +105 -0
  229. package/template/lib/mock/navigation.tsx +231 -0
  230. package/template/lib/mock/placements-kpi.ts +134 -0
  231. package/template/lib/mock/placements.ts +183 -0
  232. package/template/lib/mock/question-bank-kpi.ts +61 -0
  233. package/template/lib/mock/question-bank.ts +142 -0
  234. package/template/lib/mock/sites-directory.ts +16 -0
  235. package/template/lib/mock/sites-kpi.ts +25 -0
  236. package/template/lib/mock/team-kpi.ts +60 -0
  237. package/template/lib/mock/team.ts +118 -0
  238. package/template/lib/motion-ui.ts +17 -0
  239. package/template/lib/placement-board-card-layout.ts +79 -0
  240. package/template/lib/placement-lifecycle.ts +5 -0
  241. package/template/lib/row-height.ts +10 -0
  242. package/template/lib/stock-portrait.ts +11 -0
  243. package/template/lib/utils.test.ts +13 -0
  244. package/template/lib/utils.ts +1 -0
  245. package/template/next.config.mjs +15 -0
  246. package/template/package.json +83 -0
  247. package/template/postcss.config.mjs +8 -0
  248. package/template/public/.gitkeep +0 -0
  249. package/template/public/Illustration/Rotation.svg +74 -0
  250. package/template/public/avatars/user.svg +11 -0
  251. package/template/public/favicon/favicon.ico +0 -0
  252. package/template/public/favicon.ico +0 -0
  253. package/template/public/logos/exxat-one.svg +36 -0
  254. package/template/public/logos/exxat-prism.svg +39 -0
  255. package/template/public/mock-schools/emory.svg +4 -0
  256. package/template/public/mock-schools/rush.svg +4 -0
  257. package/template/scripts/fontawesome-subset-audit.mjs +190 -0
  258. package/template/scripts/pm2-startup-macos.sh +13 -0
  259. package/template/skills-lock.json +10 -0
  260. package/template/stores/app-store.ts +33 -0
  261. package/template/tests/setup.ts +1 -0
  262. package/template/tsconfig.json +35 -0
  263. package/template/types/react-payment-inputs.d.ts +19 -0
  264. package/template/vitest.config.ts +18 -0
@@ -0,0 +1,509 @@
1
+ "use client"
2
+
3
+ /**
4
+ * AskLeoSidebar — app-wide right sidebar for the AI assistant.
5
+ * Mirrors the left sidebar behavior: slides in/out with a toggle button.
6
+ * Lives in the (app) layout so it persists across all pages.
7
+ */
8
+
9
+ import * as React from "react"
10
+ import { usePathname } from "next/navigation"
11
+ import { AnimatePresence, motion } from "motion/react"
12
+ import { cn } from "@/lib/utils"
13
+ import { Avatar, AvatarFallback, AvatarImage, AvatarLeoAssistant } from "@/components/ui/avatar"
14
+ import { Badge } from "@/components/ui/badge"
15
+ import { Button } from "@/components/ui/button"
16
+ import { AskLeoComposer } from "@/components/ask-leo-composer"
17
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
18
+ import {
19
+ Tooltip,
20
+ TooltipContent,
21
+ TooltipTrigger,
22
+ } from "@/components/ui/tooltip"
23
+ import { useSidebar } from "@/components/ui/sidebar"
24
+ import { StatusBadge } from "@/components/ui/status-badge"
25
+ import { AiThinkingOverlay } from "@/components/ui/ai-thinking-surface"
26
+ import { LeoTypingDots } from "@/components/leo-typing-dots"
27
+ import { LeoIcon } from "@/components/ui/leo-icon"
28
+ import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
29
+ import { ASK_LEO_GENERIC_SUGGESTIONS, getAskLeoRouteContext } from "@/lib/ask-leo-route-context"
30
+ import { isEditableTarget } from "@/lib/editable-target"
31
+ import { NAV_USER } from "@/lib/mock/navigation"
32
+
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ // Context — share open state with any page (e.g. "Ask Leo" buttons on cards)
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Page context that pages register with `useAskLeoPageContext` so Leo knows
39
+ * what the user is currently looking at. Title is shown in the welcome
40
+ * bubble; `suggestions` replace the generic prompt list when present; and
41
+ * `data` is an opaque payload the downstream API call can echo back.
42
+ */
43
+ export interface AskLeoPageContext {
44
+ /** Human-readable page name, e.g. "Placements" or "Compliance dashboard". */
45
+ title: string
46
+ /** Optional one-line description ("42 active placements, 3 pending review"). */
47
+ description?: string
48
+ /** Page-specific starter prompts — replace the generic 4 when provided. */
49
+ suggestions?: string[]
50
+ /** Arbitrary payload handed to the assistant API at send time. */
51
+ data?: Record<string, unknown>
52
+ }
53
+
54
+ interface AskLeoContextValue {
55
+ open: boolean
56
+ setOpen: (open: boolean) => void
57
+ toggle: () => void
58
+ /** Open the sidebar and prefill the composer (e.g. command palette AI suggestions). */
59
+ openWithPrompt: (prompt: string) => void
60
+ /** Internal — AskLeoSidebar consumes pending text when opening. */
61
+ consumePendingComposerPrompt: () => string | null
62
+ /** Current page context (or null if no page has registered one). */
63
+ pageContext: AskLeoPageContext | null
64
+ /** Register/replace the current page's context. */
65
+ setPageContext: (ctx: AskLeoPageContext | null) => void
66
+ }
67
+
68
+ const AskLeoContext = React.createContext<AskLeoContextValue>({
69
+ open: false,
70
+ setOpen: () => {},
71
+ toggle: () => {},
72
+ openWithPrompt: () => {},
73
+ consumePendingComposerPrompt: () => null,
74
+ pageContext: null,
75
+ setPageContext: () => {},
76
+ })
77
+
78
+ export function useAskLeo() {
79
+ return React.useContext(AskLeoContext)
80
+ }
81
+
82
+ /**
83
+ * Pages call this at the top of their client component to tell Leo what
84
+ * surface the user is on. Unregisters on unmount (so route changes clear
85
+ * stale context). Memoize `ctx` to avoid update loops — use `React.useMemo`.
86
+ *
87
+ * @example
88
+ * useAskLeoPageContext(React.useMemo(() => ({
89
+ * title: "Placements",
90
+ * description: `${rows.length} rows, ${filters.active} filters active`,
91
+ * suggestions: [
92
+ * "Summarize placements ending this month",
93
+ * "Which sites are at capacity?",
94
+ * ],
95
+ * }), [rows.length, filters.active]))
96
+ */
97
+ export function useAskLeoPageContext(ctx: AskLeoPageContext | null) {
98
+ const { setPageContext } = React.useContext(AskLeoContext)
99
+ React.useEffect(() => {
100
+ setPageContext(ctx)
101
+ return () => setPageContext(null)
102
+ }, [ctx, setPageContext])
103
+ }
104
+
105
+ export function AskLeoProvider({ children }: { children: React.ReactNode }) {
106
+ const [open, setOpen] = React.useState(false)
107
+ const [pageContext, setPageContext] = React.useState<AskLeoPageContext | null>(null)
108
+ const toggle = React.useCallback(() => setOpen(v => !v), [])
109
+ const pendingComposerPromptRef = React.useRef<string | null>(null)
110
+
111
+ const openWithPrompt = React.useCallback((prompt: string) => {
112
+ pendingComposerPromptRef.current = prompt
113
+ setOpen(true)
114
+ }, [])
115
+
116
+ const consumePendingComposerPrompt = React.useCallback(() => {
117
+ const p = pendingComposerPromptRef.current
118
+ pendingComposerPromptRef.current = null
119
+ return p
120
+ }, [])
121
+
122
+ const value = React.useMemo(
123
+ () => ({ open, setOpen, toggle, openWithPrompt, consumePendingComposerPrompt, pageContext, setPageContext }),
124
+ [open, toggle, openWithPrompt, consumePendingComposerPrompt, pageContext],
125
+ )
126
+
127
+ /** ⌘⌥K / Ctrl+Alt+K — avoids browser ⌘⇧N (incognito), ⌘⇧O (bookmarks), and Ctrl+Alt+L (lock on some Linux). */
128
+ React.useEffect(() => {
129
+ function onGlobalKeyDown(e: KeyboardEvent) {
130
+ if (!e.altKey || (!e.metaKey && !e.ctrlKey)) return
131
+ if (e.key.toLowerCase() !== "k") return
132
+ if (isEditableTarget(e.target)) return
133
+ e.preventDefault()
134
+ toggle()
135
+ }
136
+ document.addEventListener("keydown", onGlobalKeyDown)
137
+ return () => document.removeEventListener("keydown", onGlobalKeyDown)
138
+ }, [toggle])
139
+
140
+ return (
141
+ <AskLeoContext.Provider value={value}>
142
+ {children}
143
+ </AskLeoContext.Provider>
144
+ )
145
+ }
146
+
147
+ type LeoThreadMessage = {
148
+ id: string
149
+ role: "user" | "assistant"
150
+ content: string
151
+ /** Assistant-only: show thinking animation until the reply is applied. */
152
+ pending?: boolean
153
+ }
154
+
155
+ function mockAssistantReply(userText: string): string {
156
+ return `Thanks — I received: “${userText.slice(0, 120)}${userText.length > 120 ? "…" : ""}”. Wire your assistant API here to return a real answer.`
157
+ }
158
+
159
+ const LEO_REPLY_DELAY_MS = 3500
160
+
161
+ // ─────────────────────────────────────────────────────────────────────────────
162
+ // Sidebar component
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+
165
+ export function AskLeoSidebar() {
166
+ const { open, setOpen, consumePendingComposerPrompt, pageContext } = useAskLeo()
167
+ const { setOpen: setSidebarOpen, isMobile } = useSidebar()
168
+ const [composerValue, setComposerValue] = React.useState("")
169
+ const [composerExpanded, setComposerExpanded] = React.useState(false)
170
+ const [threadMessages, setThreadMessages] = React.useState<LeoThreadMessage[]>([])
171
+ const composerTextareaRef = React.useRef<HTMLTextAreaElement>(null)
172
+ const conversationScrollRef = React.useRef<HTMLDivElement>(null)
173
+ const pendingReplyTimeoutsRef = React.useRef<number[]>([])
174
+
175
+ const clearPendingReplyTimeouts = React.useCallback(() => {
176
+ pendingReplyTimeoutsRef.current.forEach(clearTimeout)
177
+ pendingReplyTimeoutsRef.current = []
178
+ }, [])
179
+ const pathname = usePathname()
180
+ const routeContext = React.useMemo(() => getAskLeoRouteContext(pathname), [pathname])
181
+ const isThinking = threadMessages.some((m) => m.pending)
182
+
183
+ const pageTitle = pageContext?.title ?? routeContext.title
184
+ const pageDescription = pageContext?.description ?? routeContext.description
185
+ const suggestions =
186
+ pageContext?.suggestions && pageContext.suggestions.length > 0
187
+ ? pageContext.suggestions
188
+ : routeContext.suggestions ?? []
189
+
190
+ const suggestionChips =
191
+ suggestions.length > 0 ? suggestions : ASK_LEO_GENERIC_SUGGESTIONS
192
+
193
+ const appendUserTurn = React.useCallback((text: string) => {
194
+ const trimmed = text.trim()
195
+ if (!trimmed) return
196
+ const userId = crypto.randomUUID()
197
+ const asstId = crypto.randomUUID()
198
+ setThreadMessages((prev) => [
199
+ ...prev,
200
+ { id: userId, role: "user", content: trimmed },
201
+ { id: asstId, role: "assistant", content: "", pending: true },
202
+ ])
203
+ const tid = window.setTimeout(() => {
204
+ setThreadMessages((prev) =>
205
+ prev.map((m) =>
206
+ m.id === asstId && m.role === "assistant"
207
+ ? { ...m, content: mockAssistantReply(trimmed), pending: false }
208
+ : m,
209
+ ),
210
+ )
211
+ pendingReplyTimeoutsRef.current = pendingReplyTimeoutsRef.current.filter((t) => t !== tid)
212
+ }, LEO_REPLY_DELAY_MS)
213
+ pendingReplyTimeoutsRef.current.push(tid)
214
+ }, [])
215
+
216
+ React.useEffect(() => {
217
+ if (!open) return
218
+ const el = conversationScrollRef.current
219
+ if (!el) return
220
+ requestAnimationFrame(() => {
221
+ el.scrollTop = el.scrollHeight
222
+ })
223
+ }, [threadMessages, open])
224
+
225
+ React.useEffect(() => {
226
+ if (!open) {
227
+ clearPendingReplyTimeouts()
228
+ setThreadMessages([])
229
+ setComposerValue("")
230
+ return
231
+ }
232
+ const pending = consumePendingComposerPrompt()
233
+ if (pending !== null) {
234
+ setComposerValue(pending)
235
+ queueMicrotask(() => composerTextareaRef.current?.focus())
236
+ } else {
237
+ setComposerValue("")
238
+ }
239
+ }, [open, consumePendingComposerPrompt, clearPendingReplyTimeouts])
240
+
241
+ React.useEffect(() => () => clearPendingReplyTimeouts(), [clearPendingReplyTimeouts])
242
+
243
+ // Collapse main sidebar when Ask Leo opens, expand when it closes
244
+ const prevOpen = React.useRef(open)
245
+ React.useEffect(() => {
246
+ if (open && !prevOpen.current) setSidebarOpen(false)
247
+ if (!open && prevOpen.current) setSidebarOpen(true)
248
+ prevOpen.current = open
249
+ }, [open, setSidebarOpen])
250
+
251
+ return (
252
+ <>
253
+ {/* Mobile/zoomed-in: tap-outside scrim — WCAG aria-hidden, closes on click */}
254
+ {isMobile && open && (
255
+ <div
256
+ aria-hidden="true"
257
+ className="fixed inset-0 z-40"
258
+ onClick={() => setOpen(false)}
259
+ />
260
+ )}
261
+ <aside
262
+ aria-label="Ask Leo — AI assistant"
263
+ data-state={open ? "open" : "closed"}
264
+ className={cn(
265
+ "flex flex-col overflow-hidden",
266
+ isMobile
267
+ ? open
268
+ // Mobile/zoomed: fixed floating panel on the right, same inset style as left sidebar
269
+ ? "fixed z-50 right-2 top-2 h-[calc(100dvh-1rem)] w-[min(20rem,calc(100vw-1rem))] rounded-2xl border border-border/60 shadow-2xl ring-1 ring-border/20"
270
+ : "hidden"
271
+ : cn(
272
+ "transition-[width,margin,opacity] duration-200 ease-linear",
273
+ open
274
+ ? "relative w-64 md:w-80 shrink-0 self-start m-2 mx-2 min-h-0 h-[min(calc(100dvh-2rem),800px)] overflow-hidden rounded-xl border border-sidebar-border/80 shadow-[0_18px_48px_-16px_rgba(15,23,42,0.2),0_8px_20px_-10px_rgba(15,23,42,0.12)] ring-1 ring-sidebar-border dark:shadow-[0_22px_56px_-12px_rgba(0,0,0,0.5),0_10px_28px_-12px_rgba(0,0,0,0.35)] md:sticky md:top-2 md:ml-0 md:h-[calc(100dvh-1.25rem)]"
275
+ : "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none",
276
+ )
277
+ )}
278
+ style={
279
+ open
280
+ ? {
281
+ background:
282
+ "linear-gradient(180deg, color-mix(in oklch, var(--brand-color) 4%, var(--background)) 0%, color-mix(in oklch, var(--brand-color) 8%, var(--background)) 100%)",
283
+ }
284
+ : undefined
285
+ }
286
+ >
287
+ <AiThinkingOverlay active={open && isThinking} />
288
+ {/* min-w only when open — avoids flex min-width:auto stealing width / hit-testing when closed */}
289
+ <div
290
+ className={cn(
291
+ "relative z-[1] flex min-h-0 min-w-0 flex-1 flex-col",
292
+ open ? "min-w-0" : "hidden min-w-0 w-0"
293
+ )}
294
+ >
295
+ {/* Header */}
296
+ <div className="flex items-start justify-between gap-2 px-4 py-3 shrink-0">
297
+ <div className="flex min-w-0 flex-col gap-0.5">
298
+ <div className="flex items-center gap-2 min-w-0">
299
+ <i className="fa-duotone fa-solid fa-star-christmas text-brand shrink-0" aria-hidden="true" />
300
+ <h1
301
+ className="m-0 text-lg font-semibold tracking-tight leading-tight text-sidebar-foreground truncate"
302
+ style={{ fontFamily: "var(--font-heading)" }}
303
+ >
304
+ Ask Leo
305
+ </h1>
306
+ <StatusBadge status="beta" size="xs" className="shrink-0" />
307
+ </div>
308
+ <p className="text-[11px] leading-snug text-sidebar-foreground/60">
309
+ Powered by AI · responses may vary
310
+ </p>
311
+ </div>
312
+ <Tooltip>
313
+ <TooltipTrigger asChild>
314
+ <button
315
+ type="button"
316
+ onClick={() => setOpen(false)}
317
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
318
+ aria-label="Close Ask Leo"
319
+ >
320
+ <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
321
+ </button>
322
+ </TooltipTrigger>
323
+ <TooltipContent side="bottom" className="flex max-w-xs flex-wrap items-center gap-1.5 text-xs">
324
+ <span>Close Ask Leo</span>
325
+ <AskLeoShortcutKbds />
326
+ </TooltipContent>
327
+ </Tooltip>
328
+ </div>
329
+
330
+ {/* Conversation scrolls behind composer; composer is absolutely pinned to the bottom. */}
331
+ <div className="relative min-h-0 flex-1">
332
+ <div
333
+ ref={conversationScrollRef}
334
+ className={cn(
335
+ "absolute inset-0 scroll-smooth overflow-y-auto overflow-x-hidden overscroll-y-contain px-4 pt-4 pb-28 [-webkit-overflow-scrolling:touch]",
336
+ threadMessages.length === 0 && "flex items-center justify-center",
337
+ )}
338
+ role="log"
339
+ aria-label="Conversation with Leo"
340
+ aria-live="polite"
341
+ >
342
+ <div
343
+ className={cn(
344
+ "flex w-full min-w-0 flex-col gap-4",
345
+ threadMessages.length === 0 && "items-center",
346
+ )}
347
+ >
348
+ {threadMessages.length === 0 ? (
349
+ <>
350
+ <LeoIcon
351
+ variant="interactive"
352
+ size="xl"
353
+ className="[animation:leo-chip-in_520ms_cubic-bezier(0.22,1,0.36,1)_both]"
354
+ />
355
+ <ul className="m-0 flex list-none flex-wrap justify-center gap-2 p-0" aria-label="Suggested prompts">
356
+ {suggestionChips.map((q, i) => (
357
+ <li
358
+ key={`${i}-${q.slice(0, 24)}`}
359
+ className="max-w-full list-none [animation:leo-chip-in_420ms_cubic-bezier(0.22,1,0.36,1)_both]"
360
+ style={{ animationDelay: `${i * 70}ms` }}
361
+ >
362
+ <Badge
363
+ asChild
364
+ variant="outline"
365
+ className="h-auto min-h-8 max-w-full items-stretch whitespace-normal rounded-4xl border-border/90 bg-card px-0 py-0 font-normal text-card-foreground shadow-sm transition-transform duration-150 hover:-translate-y-0.5 dark:border-border dark:bg-card"
366
+ >
367
+ <button
368
+ type="button"
369
+ onClick={() => appendUserTurn(q)}
370
+ className="inline-flex min-h-8 w-full max-w-full cursor-pointer text-start text-xs leading-snug transition-colors hover:bg-sidebar-accent/70 hover:text-sidebar-foreground dark:hover:bg-sidebar-accent/40"
371
+ >
372
+ <span className="line-clamp-4 px-3 py-2">{q}</span>
373
+ </button>
374
+ </Badge>
375
+ </li>
376
+ ))}
377
+ </ul>
378
+ </>
379
+ ) : (
380
+ threadMessages.map((m) =>
381
+ m.role === "user" ? (
382
+ <div key={m.id} className="flex w-full min-w-0 flex-row-reverse gap-3">
383
+ <Avatar size="sm" className="mt-0.5 shrink-0">
384
+ <AvatarImage src={NAV_USER.avatar} alt="" />
385
+ <AvatarFallback className="bg-secondary text-xs font-medium text-secondary-foreground">
386
+ {NAV_USER.name.slice(0, 2).toUpperCase()}
387
+ </AvatarFallback>
388
+ </Avatar>
389
+ {/* Reserve avatar (~2rem) + gap-3 so max-w 100% does not include sibling width (overflow). */}
390
+ <div className="min-w-0 max-w-[min(18rem,calc(100%-3rem))] break-words rounded-lg rounded-tr-sm bg-primary px-3 py-2.5 text-start text-sm leading-relaxed text-primary-foreground shadow-sm">
391
+ {m.content}
392
+ </div>
393
+ </div>
394
+ ) : (
395
+ <div key={m.id} className="flex w-full min-w-0 gap-3">
396
+ <AvatarLeoAssistant className="mt-0.5 shrink-0" />
397
+ <div className="min-w-0 flex-1 pt-0.5 text-start text-sm leading-relaxed text-sidebar-foreground">
398
+ <AnimatePresence mode="wait" initial={false}>
399
+ {m.pending ? (
400
+ <LeoTypingDots key="thinking" />
401
+ ) : (
402
+ <motion.p
403
+ key="content"
404
+ className="m-0 break-words"
405
+ initial={{ opacity: 0, y: 4 }}
406
+ animate={{ opacity: 1, y: 0 }}
407
+ transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
408
+ >
409
+ {m.content}
410
+ </motion.p>
411
+ )}
412
+ </AnimatePresence>
413
+ </div>
414
+ </div>
415
+ ),
416
+ )
417
+ )}
418
+ </div>
419
+ </div>
420
+
421
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-b from-transparent to-sidebar/90 px-3 pb-4 pt-10 sm:pb-5">
422
+ <div className="pointer-events-auto">
423
+ <div
424
+ className={cn(
425
+ "mx-1 min-w-0 max-w-full border border-border/80 bg-card/95 shadow-[0_22px_56px_-14px_rgba(15,23,42,0.28),0_10px_28px_-10px_rgba(15,23,42,0.18),0_2px_8px_-2px_rgba(15,23,42,0.08)] backdrop-blur-md supports-[backdrop-filter]:bg-card/92 dark:border-border/55 dark:shadow-[0_24px_64px_-12px_rgba(0,0,0,0.62),0_12px_32px_-12px_rgba(0,0,0,0.42),0_4px_12px_-4px_rgba(0,0,0,0.35)]",
426
+ composerExpanded ? "rounded-2xl p-1.5" : "rounded-full px-1 py-1",
427
+ )}
428
+ >
429
+ <AskLeoComposer
430
+ ref={composerTextareaRef}
431
+ value={composerValue}
432
+ onChange={setComposerValue}
433
+ onSubmit={appendUserTurn}
434
+ onExpandedChange={setComposerExpanded}
435
+ placeholder="Ask Leo anything…"
436
+ className="[&_form>div]:rounded-none [&_form>div]:border-0 [&_form>div]:bg-transparent [&_form>div]:shadow-none"
437
+ />
438
+ </div>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ </div>
443
+ </aside>
444
+ </>
445
+ )
446
+ }
447
+
448
+ // ─────────────────────────────────────────────────────────────────────────────
449
+ // Toggle button — can be placed anywhere (e.g. in SiteHeader or floating)
450
+ // ─────────────────────────────────────────────────────────────────────────────
451
+
452
+ /**
453
+ * Keyboard shortcut hint for Ask Leo (⌘⌥K / Ctrl+Alt+K).
454
+ *
455
+ * • `variant="tile"` (default) — three separate tile kbds, for use in tooltip
456
+ * content / standalone surfaces where the tile chrome is welcome.
457
+ * • `variant="bare"` — single inline kbd with no background/border that
458
+ * inherits the parent's currentColor (see Kbd "bare" variant). Use this
459
+ * whenever the kbds are rendered INSIDE a button (e.g. primary Ask Leo
460
+ * button in a popover footer). Matches the `<Kbd variant="bare">` pattern
461
+ * used by the Next / Back buttons in the new placement flow.
462
+ */
463
+ export function AskLeoShortcutKbds({
464
+ className,
465
+ variant = "tile",
466
+ }: {
467
+ className?: string
468
+ variant?: "tile" | "bare"
469
+ }) {
470
+ const mod = useModKeyLabel()
471
+ const alt = useAltKeyLabel()
472
+ if (variant === "bare") {
473
+ return (
474
+ <KbdGroup className={className}>
475
+ <Kbd variant="bare">{mod}{alt}K</Kbd>
476
+ </KbdGroup>
477
+ )
478
+ }
479
+ return (
480
+ <KbdGroup className={className}>
481
+ <Kbd>{mod}</Kbd>
482
+ <Kbd>{alt}</Kbd>
483
+ <Kbd>K</Kbd>
484
+ </KbdGroup>
485
+ )
486
+ }
487
+
488
+ export function AskLeoToggle({ className }: { className?: string }) {
489
+ const { toggle, open } = useAskLeo()
490
+ return (
491
+ <Tooltip>
492
+ <TooltipTrigger asChild>
493
+ <Button
494
+ variant="outline"
495
+ size="sm"
496
+ onClick={toggle}
497
+ className={cn("gap-1.5 md:aspect-auto aspect-square", open && "bg-brand/10 border-brand/30 text-brand", className)}
498
+ >
499
+ <i className="fa-duotone fa-solid fa-star-christmas text-xs text-brand" aria-hidden="true" />
500
+ <span className="hidden md:inline">Ask Leo</span>
501
+ </Button>
502
+ </TooltipTrigger>
503
+ <TooltipContent side="bottom" className="flex flex-wrap items-center gap-1.5">
504
+ <span>{open ? "Close Ask Leo" : "Ask Leo"}</span>
505
+ <AskLeoShortcutKbds />
506
+ </TooltipContent>
507
+ </Tooltip>
508
+ )
509
+ }