@exxatdesignux/ui 0.2.9 → 0.2.11

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 (126) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +4 -1
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/.nvmrc +1 -1
  28. package/template/AGENTS.md +82 -27
  29. package/template/app/(app)/examples/page.tsx +2 -1
  30. package/template/app/(app)/help/page.tsx +6 -0
  31. package/template/app/(app)/layout.tsx +7 -4
  32. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  33. package/template/app/(app)/question-bank/layout.tsx +46 -0
  34. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  35. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  36. package/template/app/(app)/question-bank/page.tsx +4 -3
  37. package/template/app/globals.css +1 -2
  38. package/template/components/app-sidebar.tsx +51 -13
  39. package/template/components/ask-leo-composer.tsx +173 -45
  40. package/template/components/ask-leo-sidebar.tsx +9 -1
  41. package/template/components/chart-area-interactive.tsx +3 -13
  42. package/template/components/charts-overview.tsx +33 -6
  43. package/template/components/collaboration-access-flow.tsx +144 -0
  44. package/template/components/compliance-page-header.tsx +1 -1
  45. package/template/components/compliance-table.tsx +2 -2
  46. package/template/components/dashboard-tabs.tsx +4 -3
  47. package/template/components/data-list-table-cells.tsx +1 -1
  48. package/template/components/data-list-table.tsx +1 -1
  49. package/template/components/data-table/index.tsx +5 -5
  50. package/template/components/data-table/use-table-state.ts +18 -2
  51. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  53. package/template/components/data-view-dashboard-charts.tsx +62 -227
  54. package/template/components/dedicated-search-recents.tsx +96 -0
  55. package/template/components/dedicated-search-url-composer.tsx +112 -0
  56. package/template/components/getting-started.tsx +1 -1
  57. package/template/components/hub-tree-panel-view.tsx +10 -26
  58. package/template/components/invite-collaborators-drawer.tsx +453 -0
  59. package/template/components/key-metrics.tsx +54 -8
  60. package/template/components/nav-documents.tsx +1 -1
  61. package/template/components/new-placement-form.tsx +3 -3
  62. package/template/components/page-header.tsx +76 -59
  63. package/template/components/placements-board-view.tsx +3 -3
  64. package/template/components/placements-page-header.tsx +1 -1
  65. package/template/components/placements-table-columns.tsx +3 -2
  66. package/template/components/product-switcher.tsx +0 -1
  67. package/template/components/question-bank-board-view.tsx +35 -47
  68. package/template/components/question-bank-client.tsx +293 -81
  69. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  70. package/template/components/question-bank-favorite-button.tsx +46 -0
  71. package/template/components/question-bank-hub-client.tsx +436 -0
  72. package/template/components/question-bank-list-view.tsx +26 -19
  73. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  74. package/template/components/question-bank-os-folder-view.tsx +3 -14
  75. package/template/components/question-bank-page-header.tsx +85 -53
  76. package/template/components/question-bank-panel-activator.tsx +3 -4
  77. package/template/components/question-bank-secondary-nav.tsx +523 -65
  78. package/template/components/question-bank-table.tsx +125 -343
  79. package/template/components/secondary-panel.tsx +130 -63
  80. package/template/components/settings-client.tsx +3 -1
  81. package/template/components/sidebar-shell.tsx +2 -0
  82. package/template/components/sites-all-client.tsx +1 -1
  83. package/template/components/sites-table.tsx +1 -1
  84. package/template/components/system-banner-slot.tsx +2 -1
  85. package/template/components/table-properties/drawer.tsx +3 -3
  86. package/template/components/table-properties/sort-card.tsx +1 -1
  87. package/template/components/team-page-header.tsx +1 -1
  88. package/template/components/team-table.tsx +8 -4
  89. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  90. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  91. package/template/components/templates/discovery-hub-template.tsx +273 -0
  92. package/template/components/templates/list-page.tsx +11 -4
  93. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  94. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  95. package/template/docs/card-vs-rows-pattern.md +36 -0
  96. package/template/docs/collaboration-access-pattern.md +114 -0
  97. package/template/docs/data-views-pattern.md +12 -4
  98. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  99. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  100. package/template/docs/kpi-trend-pattern.md +43 -0
  101. package/template/fontawesome-subset.manifest.json +2 -2
  102. package/template/hooks/use-location-hash.ts +14 -8
  103. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  104. package/template/lib/ask-leo-route-context.ts +24 -0
  105. package/template/lib/collaborator-access.ts +92 -0
  106. package/template/lib/command-menu-config.ts +8 -1
  107. package/template/lib/command-menu-search-data.ts +11 -8
  108. package/template/lib/data-list-display-options.ts +1 -1
  109. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  110. package/template/lib/date-filter.ts +1 -0
  111. package/template/lib/dedicated-search-recents.ts +76 -0
  112. package/template/lib/dedicated-search-url.ts +23 -0
  113. package/template/lib/discovery-hub.ts +15 -0
  114. package/template/lib/list-status-badges.ts +1 -21
  115. package/template/lib/mock/navigation.tsx +4 -2
  116. package/template/lib/mock/placements.ts +9 -9
  117. package/template/lib/mock/question-bank-folders.ts +7 -0
  118. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  119. package/template/lib/mock/question-bank-inspector.ts +1 -2
  120. package/template/lib/mock/question-bank-kpi.ts +38 -26
  121. package/template/lib/mock/question-bank.ts +43 -16
  122. package/template/lib/question-bank-dedicated-search.ts +19 -0
  123. package/template/lib/question-bank-hub-search.ts +90 -0
  124. package/template/lib/question-bank-nav.ts +322 -6
  125. package/template/lib/question-bank-recent-searches.ts +22 -0
  126. package/template/package.json +1 -2
@@ -10,7 +10,7 @@ import { SystemBannerSlot } from "@/components/system-banner-slot"
10
10
  import { CommandMenu } from "@/components/command-menu"
11
11
  import { CommandMenuProvider } from "@/contexts/command-menu-context"
12
12
  import { buildCommandMenuConfig } from "@/lib/command-menu-config"
13
- import { getCommandMenuSearchDataGroups } from "@/lib/command-menu-search-data"
13
+ import { COMMAND_MENU_SEARCH_DATA_GROUPS } from "@/lib/command-menu-search-data"
14
14
 
15
15
  /**
16
16
  * Shared app layout:
@@ -21,14 +21,17 @@ import { getCommandMenuSearchDataGroups } from "@/lib/command-menu-search-data"
21
21
  * via SystemBannerProvider) — no hardcoded copy here.
22
22
  */
23
23
  export default function AppLayout({ children }: { children: React.ReactNode }) {
24
+ const commandMenuConfig = React.useMemo(
25
+ () => buildCommandMenuConfig({ dataGroups: COMMAND_MENU_SEARCH_DATA_GROUPS }),
26
+ [],
27
+ )
28
+
24
29
  return (
25
30
  <DashboardViewProvider>
26
31
  <ChartVariantProvider>
27
32
  <AskLeoProvider>
28
33
  <SystemBannerProvider>
29
- <CommandMenuProvider
30
- value={buildCommandMenuConfig({ dataGroups: getCommandMenuSearchDataGroups() })}
31
- >
34
+ <CommandMenuProvider value={commandMenuConfig}>
32
35
 
33
36
  <SidebarShell wrapperClassName="flex min-h-svh flex-col">
34
37
  {/* ⌘K command palette */}
@@ -0,0 +1,12 @@
1
+ import { Suspense } from "react"
2
+
3
+ import { QuestionBankClient } from "@/components/question-bank-client"
4
+
5
+ /** Discovery hub composer results — same hub chrome as the library, distinct from `/question-bank/list`. */
6
+ export default function QuestionBankHubFindPage() {
7
+ return (
8
+ <Suspense fallback={null}>
9
+ <QuestionBankClient />
10
+ </Suspense>
11
+ )
12
+ }
@@ -0,0 +1,46 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { usePathname } from "next/navigation"
5
+
6
+ import { useSecondaryPanel } from "@/components/secondary-panel"
7
+ import { QUESTION_BANK_ENTRY_PATH } from "@/lib/question-bank-nav"
8
+
9
+ /**
10
+ * Keeps the nested secondary panel open across library / list / find navigations.
11
+ * The discovery hub (`/question-bank`) is full-width — no secondary bar there.
12
+ */
13
+ export default function QuestionBankLayout({ children }: { children: React.ReactNode }) {
14
+ const pathname = usePathname()
15
+ const { openPanel, closePanel, activePanel } = useSecondaryPanel()
16
+ const closePanelRef = React.useRef(closePanel)
17
+ const openPanelRef = React.useRef(openPanel)
18
+ closePanelRef.current = closePanel
19
+ openPanelRef.current = openPanel
20
+
21
+ /** Leaving `/question-bank/*` entirely — close nested panel without forcing primary sidebar open (⌘B / cookie). */
22
+ React.useEffect(() => {
23
+ return () => {
24
+ closePanelRef.current({ mainSidebar: "leave" })
25
+ }
26
+ }, [])
27
+
28
+ /** Only react to route changes — refs carry latest open/close. */
29
+ React.useEffect(() => {
30
+ const isDiscoveryHubRoot =
31
+ pathname === QUESTION_BANK_ENTRY_PATH || pathname === `${QUESTION_BANK_ENTRY_PATH}/`
32
+
33
+ if (isDiscoveryHubRoot) {
34
+ closePanelRef.current({ mainSidebar: "leave" })
35
+ return undefined
36
+ }
37
+
38
+ if (activePanel !== "question-bank") {
39
+ openPanelRef.current("question-bank")
40
+ }
41
+ return undefined
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- pathname + activePanel drive when to ensure panel id
43
+ }, [pathname, activePanel])
44
+
45
+ return children
46
+ }
@@ -0,0 +1,11 @@
1
+ import { Suspense } from "react"
2
+
3
+ import { QuestionBankClient } from "@/components/question-bank-client"
4
+
5
+ export default function QuestionBankLibraryPage() {
6
+ return (
7
+ <Suspense fallback={null}>
8
+ <QuestionBankClient />
9
+ </Suspense>
10
+ )
11
+ }
@@ -0,0 +1,12 @@
1
+ import { Suspense } from "react"
2
+
3
+ import { QuestionBankClient } from "@/components/question-bank-client"
4
+
5
+ /** Question bank list surface — same hub as `/question-bank/library`, optimized for `?q=` search landings. */
6
+ export default function QuestionBankListPage() {
7
+ return (
8
+ <Suspense fallback={null}>
9
+ <QuestionBankClient />
10
+ </Suspense>
11
+ )
12
+ }
@@ -1,10 +1,11 @@
1
1
  import { Suspense } from "react"
2
- import { QuestionBankClient } from "@/components/question-bank-client"
3
2
 
4
- export default function QuestionBankPage() {
3
+ import { QuestionBankHubClient } from "@/components/question-bank-hub-client"
4
+
5
+ export default function QuestionBankHubPage() {
5
6
  return (
6
7
  <Suspense fallback={null}>
7
- <QuestionBankClient />
8
+ <QuestionBankHubClient />
8
9
  </Suspense>
9
10
  )
10
11
  }
@@ -10,9 +10,8 @@
10
10
 
11
11
  @import "tailwindcss";
12
12
  @import "tw-animate-css";
13
- @import "shadcn/tailwind.css";
14
13
 
15
- /* Ensure Tailwind scans the shared UI package for utility classes */
14
+ /* Ensure Tailwind scans the shared UI package for utility classes (repo-relative — stable with pnpm + Turbopack). */
16
15
  @source "../node_modules/@exxatdesignux/ui/src";
17
16
 
18
17
  /* RTL layout direction support */
@@ -85,6 +85,7 @@ import {
85
85
  type NavSchool,
86
86
  type NavProgram,
87
87
  } from "@/lib/mock/navigation"
88
+ import { QUESTION_BANK_ENTRY_PATH } from "@/lib/question-bank-nav"
88
89
 
89
90
  /** Path segment of a nav URL (strip `#fragment` for matching). */
90
91
  function navUrlPath(url: string): string {
@@ -93,11 +94,42 @@ function navUrlPath(url: string): string {
93
94
  return i === -1 ? url : url.slice(0, i)
94
95
  }
95
96
 
96
- function isNavActive(pathname: string, url: string): boolean {
97
+ /** Hash segment from a nav `href` (no `#`). `null` when the URL has no `#`. */
98
+ function navUrlFragment(url: string): string | null {
99
+ if (!url.includes("#")) return null
100
+ return url.slice(url.indexOf("#") + 1)
101
+ }
102
+
103
+ function normalizedLocationHash(locationHash: string): string {
104
+ if (!locationHash) return ""
105
+ return locationHash.startsWith("#") ? locationHash.slice(1) : locationHash
106
+ }
107
+
108
+ /**
109
+ * Whether `pathname` (+ optional `location.hash`) matches a sidebar `href`.
110
+ * When several links share the same path (e.g. `/settings`), disambiguate with `#fragment`
111
+ * and require an empty hash for the “default” row (`/settings` with no `#`).
112
+ */
113
+ function isNavActive(pathname: string, url: string, locationHash = ""): boolean {
97
114
  const pathOnly = navUrlPath(url)
115
+ const frag = navUrlFragment(url)
116
+ const h = normalizedLocationHash(locationHash)
117
+
98
118
  if (!pathOnly || pathOnly === "#") return false
99
- if (pathOnly === "/") return pathname === "/"
100
- if (pathname === pathOnly) return true
119
+
120
+ if (frag !== null) {
121
+ if (pathOnly === "/") return pathname === "/" && h === frag
122
+ if (pathOnly === "/library") {
123
+ return pathname.startsWith("/library/") && h === frag
124
+ }
125
+ if (pathOnly.startsWith("/library/")) {
126
+ return pathname === pathOnly && h === frag
127
+ }
128
+ return pathname === pathOnly && h === frag
129
+ }
130
+
131
+ if (pathOnly === "/") return pathname === "/" && h === ""
132
+ if (pathname === pathOnly) return h === ""
101
133
  // Design system library — active on hub and detail routes.
102
134
  if (pathOnly === "/library") {
103
135
  return pathname.startsWith("/library/")
@@ -116,11 +148,11 @@ function isCollapsibleChildActive(
116
148
  locationHash: string
117
149
  ): boolean {
118
150
  const children = parent.children
119
- if (!children?.length) return isNavActive(pathname, child.url)
151
+ if (!children?.length) return isNavActive(pathname, child.url, locationHash)
120
152
 
121
153
  const hasHashChild = children.some(c => c.url.includes("#"))
122
154
  if (hasHashChild) {
123
- const h = locationHash.startsWith("#") ? locationHash.slice(1) : locationHash
155
+ const h = normalizedLocationHash(locationHash)
124
156
  const childHash = child.url.includes("#") ? child.url.split("#")[1] : ""
125
157
  if (parent.primaryHubChildKey && child.key === parent.primaryHubChildKey) {
126
158
  return h === ""
@@ -131,7 +163,7 @@ function isCollapsibleChildActive(
131
163
  return false
132
164
  }
133
165
 
134
- if (!isNavActive(pathname, child.url)) return false
166
+ if (!isNavActive(pathname, child.url, locationHash)) return false
135
167
 
136
168
  const urls = children.map(c => c.url)
137
169
  const allSameUrl = urls.length > 1 && urls.every(u => u === urls[0])
@@ -204,12 +236,12 @@ function SidebarNavChildLink({
204
236
  */
205
237
  function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: string }) {
206
238
  const locationHash = useLocationHash()
207
- const isActive = isNavActive(pathname, item.url)
239
+ const isActive = isNavActive(pathname, item.url, locationHash)
208
240
  const isAnyChildActive =
209
241
  item.children?.some(c => isCollapsibleChildActive(pathname, item, c, locationHash)) ?? false
210
242
  const { state, isMobile } = useSidebar()
211
243
  const { openPanel } = useSecondaryPanel()
212
- const [open, setOpen] = React.useState(isAnyChildActive)
244
+ const [open, setOpen] = React.useState(false)
213
245
  const [flyoutOpen, setFlyoutOpen] = React.useState(false)
214
246
  const flyoutTitleId = React.useId()
215
247
  const iconRailCollapsed = state === "collapsed" && !isMobile
@@ -368,7 +400,8 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
368
400
  }
369
401
 
370
402
  function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: string }) {
371
- const { openPanel } = useSecondaryPanel()
403
+ const { openPanel, closePanel } = useSecondaryPanel()
404
+ const locationHash = useLocationHash()
372
405
  return (
373
406
  <>
374
407
  {items.map(item => {
@@ -379,7 +412,7 @@ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: str
379
412
  return <CollapsibleNavItem key={item.key} item={item} pathname={pathname} />
380
413
  }
381
414
 
382
- const isActive = isNavActive(pathname, item.url)
415
+ const isActive = isNavActive(pathname, item.url, locationHash)
383
416
  const itemPath = navUrlPath(item.url)
384
417
  return (
385
418
  <SidebarMenuItem key={item.key}>
@@ -400,7 +433,11 @@ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: str
400
433
  !item.url.includes("#")
401
434
  ) {
402
435
  e.preventDefault()
403
- openPanel(item.secondaryPanel)
436
+ if (itemPath === QUESTION_BANK_ENTRY_PATH) {
437
+ closePanel({ mainSidebar: "leave" })
438
+ } else {
439
+ openPanel(item.secondaryPanel)
440
+ }
404
441
  }
405
442
  }}
406
443
  >
@@ -465,6 +502,7 @@ function SidebarNavSecondaryItems({
465
502
  pathname: string
466
503
  }) {
467
504
  const mod = useModKeyLabel()
505
+ const locationHash = useLocationHash()
468
506
  return (
469
507
  <>
470
508
  {items.map((item) => {
@@ -473,7 +511,7 @@ function SidebarNavSecondaryItems({
473
511
  !item.opensCommandMenu &&
474
512
  Boolean(pathOnly) &&
475
513
  pathOnly !== "#" &&
476
- isNavActive(pathname, item.url)
514
+ isNavActive(pathname, item.url, locationHash)
477
515
 
478
516
  return (
479
517
  <SidebarMenuItem key={item.key}>
@@ -767,7 +805,7 @@ function ProductLogoButton() {
767
805
  </TooltipContent>
768
806
  </Tooltip>
769
807
 
770
- <DropdownMenuContent className="w-52" align="start" side="right" sideOffset={8}>
808
+ <DropdownMenuContent align="start" side="right" sideOffset={8}>
771
809
  <DropdownMenuLabel className="text-xs text-muted-foreground">
772
810
  Switch product
773
811
  </DropdownMenuLabel>
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import * as React from "react"
9
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react"
9
10
 
10
11
  import { Button } from "@/components/ui/button"
11
12
  import {
@@ -29,20 +30,80 @@ export interface AskLeoComposerProps {
29
30
  /** Called with trimmed message after send (composer clears afterward). */
30
31
  onSubmit?: (message: string) => void
31
32
  placeholder?: string
32
- className?: string
33
+ /**
34
+ * When non-empty and the field is empty (single-line / collapsed), cycles these as an overlaid hint
35
+ * with a soft crossfade. Native `placeholder` is suppressed while the overlay shows.
36
+ */
37
+ animatedPlaceholders?: string[]
38
+ /** Milliseconds between animated placeholder phrases. Default 4200. */
39
+ animatedPlaceholderIntervalMs?: number
40
+ /**
41
+ * When `2`, animated hints can wrap to two lines instead of a single truncated line (e.g. example hub queries).
42
+ * Default `1` matches the original pill composer behavior.
43
+ */
44
+ animatedPlaceholderMaxLines?: 1 | 2
45
+ /**
46
+ * `attachments` — plus menu + file picker (default). `ai-mark` — Leo-style icon only (e.g. question bank hub).
47
+ */
48
+ leadingSlot?: "attachments" | "ai-mark"
49
+ /** Accessible name for the textarea (paired with `htmlFor`). */
50
+ inputLabel?: string
51
+ /** `aria-label` on the submit control when the field has text. */
52
+ submitButtonAriaLabel?: string
53
+ /**
54
+ * `send` — paper plane (chat / Ask Leo). `search` — magnifying glass (question bank hub + dedicated search).
55
+ */
56
+ submitAppearance?: "send" | "search"
33
57
  /** Lets the parent swap pill vs card chrome when the field grows (multiline / long text). */
34
58
  onExpandedChange?: (expanded: boolean) => void
59
+ className?: string
35
60
  }
36
61
 
37
62
  export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoComposerProps>(
38
63
  function AskLeoComposer(
39
- { value, onChange, onSubmit, placeholder = "Ask Leo anything…", className, onExpandedChange },
64
+ {
65
+ value,
66
+ onChange,
67
+ onSubmit,
68
+ placeholder = "Ask Leo anything…",
69
+ className,
70
+ onExpandedChange,
71
+ animatedPlaceholders,
72
+ animatedPlaceholderIntervalMs = 4200,
73
+ animatedPlaceholderMaxLines = 1,
74
+ leadingSlot = "attachments",
75
+ inputLabel = "Message to Leo",
76
+ submitButtonAriaLabel = "Send message",
77
+ submitAppearance = "send",
78
+ },
40
79
  forwardedRef,
41
80
  ) {
42
81
  const [isExpanded, setIsExpanded] = React.useState(false)
82
+ const reduceMotion = useReducedMotion()
43
83
  const fieldId = React.useId()
84
+ const phrases = React.useMemo(
85
+ () => (animatedPlaceholders ?? []).map(s => s.trim()).filter(Boolean),
86
+ [animatedPlaceholders],
87
+ )
88
+ const [phraseIndex, setPhraseIndex] = React.useState(0)
89
+ const showAnimatedPlaceholder = phrases.length > 0 && !value.trim() && !isExpanded
90
+
91
+ React.useEffect(() => {
92
+ if (!showAnimatedPlaceholder) return
93
+ const id = window.setInterval(() => {
94
+ setPhraseIndex(i => (i + 1) % phrases.length)
95
+ }, animatedPlaceholderIntervalMs)
96
+ return () => window.clearInterval(id)
97
+ }, [showAnimatedPlaceholder, phrases.length, animatedPlaceholderIntervalMs])
98
+
99
+ React.useEffect(() => {
100
+ if (!showAnimatedPlaceholder) setPhraseIndex(0)
101
+ }, [showAnimatedPlaceholder])
44
102
 
103
+ const reportedExpandedRef = React.useRef<boolean | undefined>(undefined)
45
104
  React.useEffect(() => {
105
+ if (reportedExpandedRef.current === isExpanded) return
106
+ reportedExpandedRef.current = isExpanded
46
107
  onExpandedChange?.(isExpanded)
47
108
  }, [isExpanded, onExpandedChange])
48
109
  const innerRef = React.useRef<HTMLTextAreaElement>(null)
@@ -92,13 +153,15 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
92
153
  <div className={cn("min-w-0 w-full", className)}>
93
154
  <form onSubmit={handleSubmit} className="group/composer min-w-0 w-full" noValidate>
94
155
  <label htmlFor={fieldId} className="sr-only">
95
- Message to Leo
156
+ {inputLabel}
96
157
  </label>
97
- <input ref={fileInputRef} type="file" multiple className="sr-only" onChange={() => {}} />
158
+ {leadingSlot === "attachments" ? (
159
+ <input ref={fileInputRef} type="file" multiple className="sr-only" onChange={() => {}} />
160
+ ) : null}
98
161
 
99
162
  <div
100
163
  className={cn(
101
- "min-w-0 w-full cursor-text overflow-hidden border border-border/80 bg-card transition-[border-radius,padding] duration-200 ease-out",
164
+ "min-w-0 w-full cursor-text overflow-hidden border border-[color:var(--control-border)] bg-card transition-[border-radius,padding] duration-200 ease-out",
102
165
  isExpanded
103
166
  ? "rounded-2xl px-2 py-2 shadow-none grid [grid-template-columns:minmax(0,1fr)] [grid-template-rows:auto_1fr_auto] [grid-template-areas:'header'_'primary'_'footer']"
104
167
  : "rounded-full px-1 py-0.5 shadow-none grid [grid-template-columns:auto_minmax(0,1fr)_auto] [grid-template-rows:minmax(0,auto)] [grid-template-areas:'header_header_header'_'leading_primary_trailing'_'._footer_.']",
@@ -111,65 +174,119 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
111
174
  })}
112
175
  style={{ gridArea: "primary" }}
113
176
  >
114
- <div className="max-h-52 min-h-0 min-w-0 flex-1 overflow-y-auto">
177
+ <div className="relative max-h-52 min-h-0 min-w-0 flex-1 overflow-y-auto">
115
178
  <Textarea
116
179
  id={fieldId}
117
180
  ref={setTextareaRef}
118
181
  value={value}
119
182
  onChange={handleTextareaChange}
120
183
  onKeyDown={handleKeyDown}
121
- placeholder={placeholder}
184
+ placeholder={showAnimatedPlaceholder ? " " : placeholder}
122
185
  autoComplete="off"
123
186
  className={cn(
124
- "min-h-0 min-w-0 w-full max-w-full resize-none rounded-none border-0 bg-transparent p-0 text-sm leading-5 text-foreground shadow-none placeholder:text-foreground/55 focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 md:text-sm dark:placeholder:text-foreground/50",
187
+ "min-h-0 min-w-0 w-full max-w-full resize-none rounded-none border-0 bg-transparent p-0 text-sm leading-5 text-foreground shadow-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 md:text-sm dark:bg-transparent",
125
188
  !isExpanded && "min-h-[1.25rem] py-0",
189
+ showAnimatedPlaceholder && "placeholder:text-transparent",
126
190
  )}
127
191
  rows={1}
128
192
  />
193
+ {showAnimatedPlaceholder ? (
194
+ <div
195
+ className={cn(
196
+ "pointer-events-none absolute inset-x-0 top-0 flex overflow-hidden",
197
+ animatedPlaceholderMaxLines === 2
198
+ ? "min-h-[2.5rem] items-start"
199
+ : "min-h-[1.25rem] items-center",
200
+ )}
201
+ aria-hidden="true"
202
+ >
203
+ <AnimatePresence mode="wait" initial={false}>
204
+ <motion.span
205
+ key={phraseIndex}
206
+ initial={{ opacity: 0, y: reduceMotion ? 0 : 3 }}
207
+ animate={{ opacity: 1, y: 0 }}
208
+ exit={{ opacity: 0, y: reduceMotion ? 0 : -3 }}
209
+ transition={{ duration: reduceMotion ? 0 : 0.32, ease: [0.22, 1, 0.36, 1] }}
210
+ className={cn(
211
+ "block w-full text-start text-sm leading-5 text-muted-foreground",
212
+ animatedPlaceholderMaxLines === 2
213
+ ? "line-clamp-2 whitespace-normal break-words"
214
+ : "truncate",
215
+ )}
216
+ >
217
+ {phrases[phraseIndex]}
218
+ </motion.span>
219
+ </AnimatePresence>
220
+ </div>
221
+ ) : null}
129
222
  </div>
130
223
  </div>
131
224
 
132
225
  <div className={cn("flex shrink-0 items-center", { hidden: isExpanded })} style={{ gridArea: "leading" }}>
133
- <DropdownMenu>
226
+ {leadingSlot === "ai-mark" ? (
134
227
  <Tooltip>
135
228
  <TooltipTrigger asChild>
136
- <DropdownMenuTrigger asChild>
137
- <Button
138
- type="button"
139
- variant="ghost"
140
- size="icon"
141
- className="size-8 shrink-0 rounded-full hover:bg-accent"
142
- aria-label="Add attachments"
143
- >
144
- <i className="fa-light fa-plus text-base text-muted-foreground" aria-hidden="true" />
145
- </Button>
146
- </DropdownMenuTrigger>
229
+ <span
230
+ tabIndex={0}
231
+ role="img"
232
+ aria-label="AI search"
233
+ className="flex size-8 shrink-0 items-center justify-center rounded-full text-brand outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
234
+ >
235
+ <i
236
+ className="fa-light fa-star-christmas text-base text-[color:var(--brand-color-dark)] dark:text-[color:var(--brand-color-light)]"
237
+ aria-hidden="true"
238
+ />
239
+ </span>
147
240
  </TooltipTrigger>
148
- <TooltipContent side="top" sideOffset={6} className="max-w-xs text-xs">
149
- Add photos, files, and more
241
+ <TooltipContent side="top" sideOffset={6} className="text-xs">
242
+ AI search
150
243
  </TooltipContent>
151
244
  </Tooltip>
245
+ ) : (
246
+ <DropdownMenu>
247
+ <Tooltip>
248
+ <TooltipTrigger asChild>
249
+ <DropdownMenuTrigger asChild>
250
+ <Button
251
+ type="button"
252
+ variant="ghost"
253
+ size="icon"
254
+ className="size-8 shrink-0 rounded-full hover:bg-accent"
255
+ aria-label="Add attachments"
256
+ >
257
+ <i className="fa-light fa-plus text-base text-muted-foreground" aria-hidden="true" />
258
+ </Button>
259
+ </DropdownMenuTrigger>
260
+ </TooltipTrigger>
261
+ <TooltipContent side="top" sideOffset={6} className="max-w-xs text-xs">
262
+ Add photos, files, and more
263
+ </TooltipContent>
264
+ </Tooltip>
152
265
 
153
- <DropdownMenuContent align="start" className="max-w-xs rounded-2xl p-1.5">
154
- <DropdownMenuGroup className="space-y-1">
155
- <DropdownMenuItem
156
- className="flex items-center gap-2 rounded-md"
157
- onClick={() => fileInputRef.current?.click()}
158
- >
159
- <i className="fa-light fa-paperclip w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
160
- Add photos &amp; files
161
- </DropdownMenuItem>
162
- <DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
163
- <i className="fa-light fa-robot w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
164
- Agent mode
165
- </DropdownMenuItem>
166
- <DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
167
- <i className="fa-light fa-magnifying-glass w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
168
- Deep Research
169
- </DropdownMenuItem>
170
- </DropdownMenuGroup>
171
- </DropdownMenuContent>
172
- </DropdownMenu>
266
+ <DropdownMenuContent align="start" className="max-w-xs rounded-2xl p-1.5">
267
+ <DropdownMenuGroup className="space-y-1">
268
+ <DropdownMenuItem
269
+ className="flex items-center gap-2 rounded-md"
270
+ onClick={() => fileInputRef.current?.click()}
271
+ >
272
+ <i className="fa-light fa-paperclip w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
273
+ Add photos &amp; files
274
+ </DropdownMenuItem>
275
+ <DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
276
+ <i className="fa-light fa-robot w-4 shrink-0 text-center opacity-60" aria-hidden="true" />
277
+ Agent mode
278
+ </DropdownMenuItem>
279
+ <DropdownMenuItem className="flex items-center gap-2 rounded-md" onClick={() => {}}>
280
+ <i
281
+ className="fa-light fa-magnifying-glass w-4 shrink-0 text-center opacity-60"
282
+ aria-hidden="true"
283
+ />
284
+ Deep Research
285
+ </DropdownMenuItem>
286
+ </DropdownMenuGroup>
287
+ </DropdownMenuContent>
288
+ </DropdownMenu>
289
+ )}
173
290
  </div>
174
291
 
175
292
  <div
@@ -197,12 +314,23 @@ export const AskLeoComposer = React.forwardRef<HTMLTextAreaElement, AskLeoCompos
197
314
  {value.trim() ? (
198
315
  <Tooltip>
199
316
  <TooltipTrigger asChild>
200
- <Button type="submit" size="icon" className="size-8 shrink-0 rounded-full" aria-label="Send message">
201
- <i className="fa-light fa-paper-plane-top text-base" aria-hidden="true" />
317
+ <Button
318
+ type="submit"
319
+ size="icon"
320
+ className="size-8 shrink-0 rounded-full"
321
+ aria-label={submitButtonAriaLabel}
322
+ >
323
+ <i
324
+ className={cn(
325
+ "text-base",
326
+ submitAppearance === "search" ? "fa-light fa-magnifying-glass" : "fa-light fa-paper-plane-top",
327
+ )}
328
+ aria-hidden="true"
329
+ />
202
330
  </Button>
203
331
  </TooltipTrigger>
204
332
  <TooltipContent side="top" sideOffset={6} className="text-xs">
205
- Send message
333
+ {submitButtonAriaLabel}
206
334
  </TooltipContent>
207
335
  </Tooltip>
208
336
  ) : null}
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import * as React from "react"
10
+ import dynamic from "next/dynamic"
10
11
  import { usePathname } from "next/navigation"
11
12
  import { AnimatePresence, motion } from "motion/react"
12
13
  import { cn } from "@/lib/utils"
@@ -24,7 +25,14 @@ import { useSidebar } from "@/components/ui/sidebar"
24
25
  import { StatusBadge } from "@/components/ui/status-badge"
25
26
  import { AiThinkingOverlay } from "@/components/ui/ai-thinking-surface"
26
27
  import { LeoTypingDots } from "@/components/leo-typing-dots"
27
- import { LeoIcon } from "@/components/ui/leo-icon"
28
+
29
+ const LeoIcon = dynamic(
30
+ () => import("@/components/ui/leo-icon").then(m => m.LeoIcon),
31
+ {
32
+ ssr: false,
33
+ loading: () => <div className="size-20" aria-hidden="true" />,
34
+ },
35
+ )
28
36
  import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
29
37
  import { ASK_LEO_GENERIC_SUGGESTIONS, getAskLeoRouteContext } from "@/lib/ask-leo-route-context"
30
38
  import { isEditableTarget } from "@/lib/editable-target"
@@ -4,6 +4,7 @@ import * as React from "react"
4
4
  import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
5
5
 
6
6
  import { useIsMobile } from "@/hooks/use-mobile"
7
+ import { formatDateUS } from "@/lib/date-filter"
7
8
  import {
8
9
  Card,
9
10
  CardAction,
@@ -247,24 +248,13 @@ export function ChartAreaInteractive() {
247
248
  axisLine={false}
248
249
  tickMargin={8}
249
250
  minTickGap={32}
250
- tickFormatter={(value) => {
251
- const date = new Date(value)
252
- return date.toLocaleDateString("en-US", {
253
- month: "short",
254
- day: "numeric",
255
- })
256
- }}
251
+ tickFormatter={(value) => formatDateUS(String(value))}
257
252
  />
258
253
  <ChartTooltip
259
254
  cursor={false}
260
255
  content={
261
256
  <ChartTooltipContent
262
- labelFormatter={(value) => {
263
- return new Date(value).toLocaleDateString("en-US", {
264
- month: "short",
265
- day: "numeric",
266
- })
267
- }}
257
+ labelFormatter={(value) => formatDateUS(String(value))}
268
258
  indicator="dot"
269
259
  />
270
260
  }