@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
@@ -67,12 +67,16 @@ import {
67
67
  KEY_METRICS_KPI_COUNT_DEFAULT,
68
68
  KEY_METRICS_KPI_COUNT_MAX,
69
69
  KEY_METRICS_KPI_COUNT_MIN,
70
- mergeDashboardLayoutGeneric,
71
70
  } from "@/lib/dashboard-layout-merge"
72
71
  import {
73
- loadDataViewLayout as loadStoredDataViewLayout,
74
- saveDataViewLayout as saveStoredDataViewLayout,
75
- } from "@/lib/data-view-dashboard-storage"
72
+ type ChartType,
73
+ type DashboardCardDef,
74
+ KEY_METRICS_CARD_ID,
75
+ ALL_DASHBOARD_CARDS,
76
+ DEFAULT_SPANS,
77
+ DEFAULT_CHART_TYPES,
78
+ applyVisibleReorder,
79
+ } from "@/lib/data-view-dashboard-placements-layout"
76
80
  import { cn } from "@/lib/utils"
77
81
  import {
78
82
  ChartContainer,
@@ -87,6 +91,19 @@ import {
87
91
  CHART_KBD_ACTIVE_PIE_SHAPE,
88
92
  } from "@/lib/chart-keyboard-selection"
89
93
 
94
+ export type { ChartType, ChartTypeOption, DashboardCardDef, DashboardLayout } from "@/lib/data-view-dashboard-placements-layout"
95
+ export {
96
+ KEY_METRICS_CARD_ID,
97
+ ALL_DASHBOARD_CARDS,
98
+ DEFAULT_VISIBLE_CARDS,
99
+ DEFAULT_SPANS,
100
+ DEFAULT_CHART_TYPES,
101
+ loadDashboardLayout,
102
+ mergeDashboardLayout,
103
+ saveDashboardLayout,
104
+ applyVisibleReorder,
105
+ } from "@/lib/data-view-dashboard-placements-layout"
106
+
90
107
  /* ── Chart colour tokens ───────────────────────────────────────────────── */
91
108
 
92
109
  const STATUS_COLORS: Record<string, string> = {
@@ -117,32 +134,16 @@ const READINESS_COLORS: Record<string, string> = {
117
134
  const BAR_CFG: ChartConfig = { value: { label: "Placements", color: "var(--primary)" } }
118
135
  const AREA_CFG: ChartConfig = { count: { label: "Starting", color: "var(--color-chart-1)" } }
119
136
 
120
- /* ── Chart types available per card ───────────────────────────────────── */
121
-
122
- export type ChartType = "bar" | "horizontal-bar" | "pie" | "area" | "line" | "radial" | "stacked-bar"
123
-
124
- export interface ChartTypeOption {
125
- type: ChartType
126
- label: string
127
- icon: string
128
- }
129
-
130
- /* ── Card definitions ──────────────────────────────────────────────────── */
131
-
132
- export interface DashboardCardDef {
133
- id: string
134
- title: string
135
- description: string
136
- /** Default grid column span: 1 = half width, 2 = full width */
137
- defaultSpan: 1 | 2
138
- /** Default chart type (unused when chartTypes is empty) */
139
- defaultChartType: ChartType
140
- /** Available chart types; empty = KPI / non-chart block (no type switcher) */
141
- chartTypes: ChartTypeOption[]
142
- }
137
+ const CHART_MARGIN = { top: 8, right: 8, left: 0, bottom: 0 } as const
138
+ const CHART_MARGIN_HORIZONTAL = { top: 8, right: 8, left: 4, bottom: 0 } as const
143
139
 
144
- /** Virtual “card” for the KPI strip — reorderable with charts in edit mode */
145
- export const KEY_METRICS_CARD_ID = "key-metrics"
140
+ const PALETTE_COLORS = [
141
+ "var(--color-chart-1)",
142
+ "var(--color-chart-2)",
143
+ "var(--color-chart-3)",
144
+ "var(--color-chart-4)",
145
+ "var(--color-chart-5)",
146
+ ] as const
146
147
 
147
148
  /** Demo Leo “smart scan” copy per chart (swap for model output when wired). */
148
149
  const PLACEMENTS_CHART_LEO_INSIGHTS: Partial<Record<string, ChartLeoInsight>> = {
@@ -173,181 +174,6 @@ const PLACEMENTS_CHART_LEO_INSIGHTS: Partial<Record<string, ChartLeoInsight>> =
173
174
  },
174
175
  }
175
176
 
176
- export const ALL_DASHBOARD_CARDS: DashboardCardDef[] = [
177
- {
178
- id: KEY_METRICS_CARD_ID,
179
- title: "Key metrics",
180
- description: "Summary KPIs for filtered placements",
181
- defaultSpan: 1,
182
- defaultChartType: "bar",
183
- chartTypes: [],
184
- },
185
- {
186
- id: "status-pipeline",
187
- title: "Status Pipeline",
188
- description: "Where placements are in the workflow",
189
- defaultSpan: 1,
190
- defaultChartType: "bar",
191
- chartTypes: [
192
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
193
- { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
194
- { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
195
- ],
196
- },
197
- {
198
- id: "program-mix",
199
- title: "Placements by Program",
200
- description: "Distribution across active programs",
201
- defaultSpan: 1,
202
- defaultChartType: "pie",
203
- chartTypes: [
204
- { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
205
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
206
- { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
207
- ],
208
- },
209
- {
210
- id: "compliance-status",
211
- title: "Compliance Status",
212
- description: "Document readiness for upcoming placements",
213
- defaultSpan: 1,
214
- defaultChartType: "horizontal-bar",
215
- chartTypes: [
216
- { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
217
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
218
- { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
219
- ],
220
- },
221
- {
222
- id: "readiness-overview",
223
- title: "Student Readiness",
224
- description: "How prepared students are for their placements",
225
- defaultSpan: 1,
226
- defaultChartType: "bar",
227
- chartTypes: [
228
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
229
- { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
230
- { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
231
- ],
232
- },
233
- {
234
- id: "progress-tracker",
235
- title: "Ongoing Progress",
236
- description: "How far along each ongoing placement is",
237
- defaultSpan: 2,
238
- defaultChartType: "stacked-bar",
239
- chartTypes: [
240
- { type: "stacked-bar", label: "Stacked Bar", icon: "fa-light fa-layer-group" },
241
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
242
- ],
243
- },
244
- {
245
- id: "site-utilisation",
246
- title: "Site Utilisation",
247
- description: "Which clinical sites have the most placements",
248
- defaultSpan: 1,
249
- defaultChartType: "horizontal-bar",
250
- chartTypes: [
251
- { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
252
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
253
- { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
254
- ],
255
- },
256
- {
257
- id: "completion-outcomes",
258
- title: "Completion Outcomes",
259
- description: "Pass rate and average ratings for completed placements",
260
- defaultSpan: 1,
261
- defaultChartType: "radial",
262
- chartTypes: [
263
- { type: "radial", label: "Radial", icon: "fa-light fa-circle-notch" },
264
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
265
- ],
266
- },
267
- {
268
- id: "upcoming-timeline",
269
- title: "Upcoming Start Dates",
270
- description: "New placements starting in the next 8 weeks",
271
- defaultSpan: 2,
272
- defaultChartType: "area",
273
- chartTypes: [
274
- { type: "area", label: "Area", icon: "fa-light fa-chart-area" },
275
- { type: "line", label: "Line", icon: "fa-light fa-chart-line" },
276
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
277
- ],
278
- },
279
- ]
280
-
281
- export const DEFAULT_VISIBLE_CARDS = ALL_DASHBOARD_CARDS.map(c => c.id)
282
- export const DEFAULT_SPANS: Record<string, 1 | 2> = Object.fromEntries(ALL_DASHBOARD_CARDS.map(c => [c.id, c.defaultSpan]))
283
- export const DEFAULT_CHART_TYPES: Record<string, ChartType> = Object.fromEntries(ALL_DASHBOARD_CARDS.map(c => [c.id, c.defaultChartType]))
284
-
285
- /* ── Persistence (centralized bundle — see `lib/data-view-dashboard-storage`) ─ */
286
-
287
- export interface DashboardLayout {
288
- visible: string[]
289
- order: string[]
290
- spans?: Record<string, 1 | 2>
291
- chartTypes?: Record<string, ChartType>
292
- /** Key metrics card: show first N KPIs (1–4). */
293
- keyMetricsKpiCount?: number
294
- }
295
-
296
- export function loadDashboardLayout(): DashboardLayout | null {
297
- const v = loadStoredDataViewLayout("placements")
298
- if (!v) return null
299
- return {
300
- visible: v.visible,
301
- order: v.order,
302
- spans: v.spans,
303
- chartTypes: v.chartTypes as Record<string, ChartType> | undefined,
304
- keyMetricsKpiCount: v.keyMetricsKpiCount,
305
- }
306
- }
307
-
308
- /** Merge saved layout with defaults so every card id has span + chart type. */
309
- export function mergeDashboardLayout(saved: DashboardLayout | null): DashboardLayout {
310
- const defaults = {
311
- visible: [...DEFAULT_VISIBLE_CARDS],
312
- order: ALL_DASHBOARD_CARDS.map(c => c.id),
313
- spans: { ...DEFAULT_SPANS },
314
- chartTypes: { ...DEFAULT_CHART_TYPES } as Record<string, string>,
315
- keyMetricsKpiCount: KEY_METRICS_KPI_COUNT_DEFAULT,
316
- }
317
- const ids = ALL_DASHBOARD_CARDS.map(c => c.id)
318
- const m = mergeDashboardLayoutGeneric(saved, defaults, ids)
319
- return {
320
- visible: m.visible,
321
- order: m.order,
322
- spans: m.spans as Record<string, 1 | 2>,
323
- chartTypes: m.chartTypes as Record<string, ChartType>,
324
- keyMetricsKpiCount: m.keyMetricsKpiCount,
325
- }
326
- }
327
-
328
- export function saveDashboardLayout(layout: DashboardLayout) {
329
- saveStoredDataViewLayout("placements", {
330
- visible: layout.visible,
331
- order: layout.order,
332
- spans: layout.spans,
333
- chartTypes: layout.chartTypes as Record<string, string> | undefined,
334
- keyMetricsKpiCount: layout.keyMetricsKpiCount,
335
- })
336
- }
337
-
338
- /** Rebuild full `cardOrder` after reordering only the visible subset (order of hidden ids is preserved). */
339
- export function applyVisibleReorder(
340
- fullOrder: string[],
341
- visible: Set<string>,
342
- newVisibleOrder: string[],
343
- ): string[] {
344
- let vi = 0
345
- return fullOrder.map(id => {
346
- if (!visible.has(id)) return id
347
- return newVisibleOrder[vi++]!
348
- })
349
- }
350
-
351
177
  /* ── Individual chart renderers (ChartFigure + ChartDataTable from charts-overview) ─ */
352
178
  /* Keyboard highlight: `CHART_KBD_*` — same ring-on-active pattern as `charts-overview`. */
353
179
 
@@ -378,7 +204,7 @@ function StatusPipelineChart({ rows, chartType }: { rows: Placement[]; chartType
378
204
  <HBarChartRenderer data={data} colored activeIndex={activeIndex} />
379
205
  ) : (
380
206
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
381
- <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
207
+ <BarChart data={data} margin={CHART_MARGIN}>
382
208
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
383
209
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
384
210
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -417,7 +243,11 @@ function ProgramMixChart({ rows, chartType }: { rows: Placement[]; chartType: Ch
417
243
  .sort((a, b) => b.value - a.value)
418
244
  }, [rows])
419
245
 
420
- const colors = ["var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)"]
246
+ const colors = PALETTE_COLORS
247
+ const coloredData = React.useMemo(
248
+ () => data.map((d, i) => ({ ...d, fill: colors[i % colors.length] })),
249
+ [data, colors],
250
+ )
421
251
 
422
252
  if (data.length === 0) return <EmptyChart />
423
253
 
@@ -429,7 +259,7 @@ function ProgramMixChart({ rows, chartType }: { rows: Placement[]; chartType: Ch
429
259
  <>
430
260
  {chartType === "bar" ? (
431
261
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
432
- <BarChart data={data.map((d, i) => ({ ...d, fill: colors[i % colors.length] }))} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
262
+ <BarChart data={coloredData} margin={CHART_MARGIN}>
433
263
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
434
264
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
435
265
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -441,15 +271,15 @@ function ProgramMixChart({ rows, chartType }: { rows: Placement[]; chartType: Ch
441
271
  activeBar={CHART_KBD_ACTIVE_BAR}
442
272
  activeIndex={activeIndex ?? undefined}
443
273
  >
444
- {data.map((_, i) => (
445
- <Cell key={i} fill={colors[i % colors.length]} />
274
+ {coloredData.map((d, i) => (
275
+ <Cell key={i} fill={d.fill} />
446
276
  ))}
447
277
  </Bar>
448
278
  </BarChart>
449
279
  </ChartContainer>
450
280
  ) : chartType === "horizontal-bar" ? (
451
281
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
452
- <BarChart data={data.map((d, i) => ({ ...d, fill: colors[i % colors.length] }))} layout="vertical" margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
282
+ <BarChart data={coloredData} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
453
283
  <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
454
284
  <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
455
285
  <YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -461,14 +291,14 @@ function ProgramMixChart({ rows, chartType }: { rows: Placement[]; chartType: Ch
461
291
  activeBar={CHART_KBD_ACTIVE_BAR}
462
292
  activeIndex={activeIndex ?? undefined}
463
293
  >
464
- {data.map((_, i) => (
465
- <Cell key={i} fill={colors[i % colors.length]} />
294
+ {coloredData.map((d, i) => (
295
+ <Cell key={i} fill={d.fill} />
466
296
  ))}
467
297
  </Bar>
468
298
  </BarChart>
469
299
  </ChartContainer>
470
300
  ) : (
471
- <PieChartRenderer data={data.map((d, i) => ({ ...d, fill: colors[i % colors.length] }))} activeIndex={activeIndex} />
301
+ <PieChartRenderer data={coloredData} activeIndex={activeIndex} />
472
302
  )}
473
303
  <ChartDataTable
474
304
  caption="Placements by Program data"
@@ -505,7 +335,7 @@ function ComplianceChart({ rows, chartType }: { rows: Placement[]; chartType: Ch
505
335
  <>
506
336
  {chartType === "bar" ? (
507
337
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
508
- <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
338
+ <BarChart data={data} margin={CHART_MARGIN}>
509
339
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
510
340
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
511
341
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -567,7 +397,7 @@ function ReadinessChart({ rows, chartType }: { rows: Placement[]; chartType: Cha
567
397
  <HBarChartRenderer data={data} colored activeIndex={activeIndex} />
568
398
  ) : (
569
399
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
570
- <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
400
+ <BarChart data={data} margin={CHART_MARGIN}>
571
401
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
572
402
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
573
403
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -624,7 +454,7 @@ function ProgressTrackerChart({ rows, chartType }: { rows: Placement[]; chartTyp
624
454
  <>
625
455
  {chartType === "bar" ? (
626
456
  <ChartContainer config={cfg} className="h-[220px] w-full">
627
- <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
457
+ <BarChart data={data} margin={CHART_MARGIN}>
628
458
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
629
459
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
630
460
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -651,7 +481,7 @@ function ProgressTrackerChart({ rows, chartType }: { rows: Placement[]; chartTyp
651
481
  </ChartContainer>
652
482
  ) : (
653
483
  <ChartContainer config={cfg} className="h-[220px] w-full">
654
- <BarChart data={data} layout="vertical" margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
484
+ <BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
655
485
  <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
656
486
  <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
657
487
  <YAxis type="category" dataKey="name" width={72} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -701,10 +531,15 @@ function SiteUtilisationChart({ rows, chartType }: { rows: Placement[]; chartTyp
701
531
  .slice(0, 8)
702
532
  }, [rows])
703
533
 
534
+ const colors = PALETTE_COLORS
535
+ const coloredData = React.useMemo(
536
+ () => data.map((d, i) => ({ ...d, fill: colors[i % colors.length] })),
537
+ [data, colors],
538
+ )
539
+
704
540
  if (data.length === 0) return <EmptyChart />
705
541
 
706
542
  const summary = `Top ${data.length} sites. Busiest: ${data[0].name} with ${data[0].value} placements.`
707
- const colors = ["var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)"]
708
543
 
709
544
  return (
710
545
  <ChartFigure label="Site Utilisation" summary={summary} dataLength={data.length}>
@@ -712,7 +547,7 @@ function SiteUtilisationChart({ rows, chartType }: { rows: Placement[]; chartTyp
712
547
  <>
713
548
  {chartType === "bar" ? (
714
549
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
715
- <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
550
+ <BarChart data={data} margin={CHART_MARGIN}>
716
551
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
717
552
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
718
553
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -732,10 +567,10 @@ function SiteUtilisationChart({ rows, chartType }: { rows: Placement[]; chartTyp
732
567
  </BarChart>
733
568
  </ChartContainer>
734
569
  ) : chartType === "pie" ? (
735
- <PieChartRenderer data={data.map((d, i) => ({ ...d, fill: colors[i % colors.length] }))} activeIndex={activeIndex} />
570
+ <PieChartRenderer data={coloredData} activeIndex={activeIndex} />
736
571
  ) : (
737
572
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
738
- <BarChart data={data} layout="vertical" margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
573
+ <BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
739
574
  <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
740
575
  <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
741
576
  <YAxis type="category" dataKey="name" width={130} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -792,7 +627,7 @@ function CompletionOutcomesChart({ rows, chartType }: { rows: Placement[]; chart
792
627
  {(activeIndex) => (
793
628
  <>
794
629
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
795
- <BarChart data={barData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
630
+ <BarChart data={barData} margin={CHART_MARGIN}>
796
631
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
797
632
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
798
633
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} domain={[0, 100]} />
@@ -923,7 +758,7 @@ function UpcomingTimelineChart({ rows, chartType }: { rows: Placement[]; chartTy
923
758
  {chartType === "line" ? (
924
759
  <div className="relative w-full">
925
760
  <ChartContainer config={AREA_CFG} className="h-[200px] w-full">
926
- <LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
761
+ <LineChart data={data} margin={CHART_MARGIN}>
927
762
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
928
763
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
929
764
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -961,7 +796,7 @@ function UpcomingTimelineChart({ rows, chartType }: { rows: Placement[]; chartTy
961
796
  ) : chartType === "bar" ? (
962
797
  <div className="relative w-full">
963
798
  <ChartContainer config={AREA_CFG} className="h-[200px] w-full">
964
- <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
799
+ <BarChart data={data} margin={CHART_MARGIN}>
965
800
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
966
801
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
967
802
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -989,7 +824,7 @@ function UpcomingTimelineChart({ rows, chartType }: { rows: Placement[]; chartTy
989
824
  ) : (
990
825
  <div className="relative w-full">
991
826
  <ChartContainer config={AREA_CFG} className="h-[200px] w-full">
992
- <AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
827
+ <AreaChart data={data} margin={CHART_MARGIN}>
993
828
  <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
994
829
  <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
995
830
  <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -1052,7 +887,7 @@ function PieChartRenderer({
1052
887
  data: { name: string; value: number; fill?: string }[]
1053
888
  activeIndex?: number | null
1054
889
  }) {
1055
- const colors = ["var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)"]
890
+ const colors = PALETTE_COLORS
1056
891
  return (
1057
892
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
1058
893
  <PieChart>
@@ -1091,7 +926,7 @@ function HBarChartRenderer({
1091
926
  }) {
1092
927
  return (
1093
928
  <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
1094
- <BarChart data={data} layout="vertical" margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
929
+ <BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
1095
930
  <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
1096
931
  <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
1097
932
  <YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
@@ -0,0 +1,96 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useRouter } from "next/navigation"
5
+
6
+ import { Button } from "@/components/ui/button"
7
+ import { Tip } from "@/components/ui/tip"
8
+ import type { DedicatedSearchRecentsController } from "@/lib/dedicated-search-recents"
9
+
10
+ export interface DedicatedSearchRecentsProps {
11
+ recents: Pick<DedicatedSearchRecentsController, "read" | "record" | "clear" | "eventName">
12
+ searchParamsKey: string
13
+ replacePath: string
14
+ patchSearchParams: (current: URLSearchParams, submittedText: string) => URLSearchParams
15
+ sectionTitle?: string
16
+ clearLabel?: string
17
+ }
18
+
19
+ /**
20
+ * Recent query rows — reads storage only after mount so SSR and first client paint match (no hydration drift).
21
+ */
22
+ export function DedicatedSearchRecents({
23
+ recents,
24
+ searchParamsKey,
25
+ replacePath,
26
+ patchSearchParams,
27
+ sectionTitle = "Recently searched",
28
+ clearLabel = "Clear",
29
+ }: DedicatedSearchRecentsProps) {
30
+ const router = useRouter()
31
+ const [items, setItems] = React.useState<string[]>([])
32
+
33
+ React.useEffect(() => {
34
+ const sync = () => setItems(recents.read())
35
+ sync()
36
+ window.addEventListener(recents.eventName, sync)
37
+ window.addEventListener("storage", sync)
38
+ return () => {
39
+ window.removeEventListener(recents.eventName, sync)
40
+ window.removeEventListener("storage", sync)
41
+ }
42
+ }, [recents])
43
+
44
+ const runQuery = React.useCallback(
45
+ (q: string) => {
46
+ recents.record(q)
47
+ const next = patchSearchParams(new URLSearchParams(searchParamsKey), q)
48
+ const qs = next.toString()
49
+ router.replace(qs ? `${replacePath}?${qs}` : replacePath, { scroll: false })
50
+ },
51
+ [patchSearchParams, recents, replacePath, router, searchParamsKey],
52
+ )
53
+
54
+ if (items.length === 0) {
55
+ return null
56
+ }
57
+
58
+ const headingId = "dedicated-search-recents-heading"
59
+
60
+ return (
61
+ <section className="min-w-0" aria-labelledby={headingId}>
62
+ <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
63
+ <h2 id={headingId} className="text-base font-semibold tracking-tight text-foreground">
64
+ {sectionTitle}
65
+ </h2>
66
+ <Button
67
+ type="button"
68
+ variant="ghost"
69
+ size="sm"
70
+ className="h-auto min-h-6 shrink-0 px-2 py-1 text-sm text-muted-foreground"
71
+ onClick={() => recents.clear()}
72
+ >
73
+ {clearLabel}
74
+ </Button>
75
+ </div>
76
+ <div className="mt-2 divide-y divide-border overflow-hidden rounded-xl border border-border bg-card sm:mt-2.5">
77
+ {items.map((q, i) => (
78
+ <div key={`${q}-${i}`} className="min-w-0">
79
+ <Tip side="bottom" label={`Run this search again — ${q}`}>
80
+ <button
81
+ type="button"
82
+ aria-label={`Run this search again — ${q}`}
83
+ className="flex w-full min-h-11 items-center gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset sm:px-5 sm:py-3.5"
84
+ onClick={() => runQuery(q)}
85
+ >
86
+ <i className="fa-light fa-clock-rotate-left size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
87
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground">{q}</span>
88
+ <i className="fa-light fa-chevron-right shrink-0 text-xs text-muted-foreground" aria-hidden="true" />
89
+ </button>
90
+ </Tip>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ </section>
95
+ )
96
+ }
@@ -0,0 +1,112 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useRouter } from "next/navigation"
5
+
6
+ import { AskLeoComposer } from "@/components/ask-leo-composer"
7
+ import { cn } from "@/lib/utils"
8
+
9
+ export interface DedicatedSearchUrlComposerProps {
10
+ /** Serialized `URLSearchParams` for the active route (stable string from parent). */
11
+ searchParamsKey: string
12
+ /** Base path for `router.replace` (no query). */
13
+ replacePath: string
14
+ /**
15
+ * Merge submitted text into the next query string. Hub supplies domain rules
16
+ * (e.g. preserve `scope=` / toggles while updating `q=`).
17
+ */
18
+ patchSearchParams: (current: URLSearchParams, submittedText: string) => URLSearchParams
19
+ /** Optional — record a successful non-empty submission (e.g. recents). */
20
+ onRecordSubmission?: (trimmed: string) => void
21
+ /** `hero` — centered landing; `default` — inset under a list header. */
22
+ layout?: "default" | "hero"
23
+ animatedPlaceholders: readonly string[] | string[]
24
+ animatedPlaceholderIntervalMs?: number
25
+ animatedPlaceholderMaxLines?: 1 | 2
26
+ placeholder?: string
27
+ inputLabel?: string
28
+ submitAppearance?: "search" | "send"
29
+ submitButtonAriaLabel?: string
30
+ /** Screen-reader-only instructions for the field (not the sole format hint). */
31
+ srOnlyDescription: React.ReactNode
32
+ composerClassName?: string
33
+ }
34
+
35
+ /**
36
+ * AI-styled composer that updates the URL via `router.replace` — does not open Ask Leo.
37
+ */
38
+ export function DedicatedSearchUrlComposer({
39
+ searchParamsKey,
40
+ replacePath,
41
+ patchSearchParams,
42
+ onRecordSubmission,
43
+ layout = "default",
44
+ animatedPlaceholders,
45
+ animatedPlaceholderIntervalMs = 4800,
46
+ animatedPlaceholderMaxLines = 2,
47
+ placeholder = "Search…",
48
+ inputLabel = "Search",
49
+ submitAppearance = "search",
50
+ submitButtonAriaLabel = "Run search",
51
+ srOnlyDescription,
52
+ composerClassName,
53
+ }: DedicatedSearchUrlComposerProps) {
54
+ const router = useRouter()
55
+ const sp = React.useMemo(() => new URLSearchParams(searchParamsKey), [searchParamsKey])
56
+ const qFromUrl = sp.get("q") ?? ""
57
+ const [value, setValue] = React.useState(qFromUrl)
58
+ const [expanded, setExpanded] = React.useState(false)
59
+
60
+ React.useEffect(() => {
61
+ setValue(qFromUrl)
62
+ }, [qFromUrl])
63
+
64
+ const onSubmit = React.useCallback(
65
+ (message: string) => {
66
+ const trimmed = message.trim()
67
+ if (trimmed) onRecordSubmission?.(trimmed)
68
+ const next = patchSearchParams(new URLSearchParams(searchParamsKey), trimmed)
69
+ const qs = next.toString()
70
+ router.replace(qs ? `${replacePath}?${qs}` : replacePath, { scroll: false })
71
+ },
72
+ [onRecordSubmission, patchSearchParams, replacePath, router, searchParamsKey],
73
+ )
74
+
75
+ return (
76
+ <div className={cn(layout === "hero" ? "min-w-0" : "px-4 pb-3 lg:px-6")}>
77
+ <p className="sr-only">{srOnlyDescription}</p>
78
+ <div
79
+ className={cn(
80
+ "min-w-0 max-w-full border border-[color:var(--control-border)] bg-card shadow-sm transition-[border-radius,padding,box-shadow] duration-200 ease-out",
81
+ layout === "hero" && "shadow-md",
82
+ expanded
83
+ ? layout === "hero"
84
+ ? "rounded-2xl p-1.5 shadow-md"
85
+ : "rounded-2xl p-1.5 shadow-md"
86
+ : layout === "hero"
87
+ ? "rounded-2xl px-1.5 py-1.5 sm:px-2 sm:py-2"
88
+ : "rounded-full px-1 py-1",
89
+ )}
90
+ >
91
+ <AskLeoComposer
92
+ value={value}
93
+ onChange={setValue}
94
+ onSubmit={onSubmit}
95
+ onExpandedChange={setExpanded}
96
+ animatedPlaceholders={[...animatedPlaceholders]}
97
+ animatedPlaceholderIntervalMs={animatedPlaceholderIntervalMs}
98
+ animatedPlaceholderMaxLines={animatedPlaceholderMaxLines}
99
+ leadingSlot="ai-mark"
100
+ inputLabel={inputLabel}
101
+ submitAppearance={submitAppearance}
102
+ submitButtonAriaLabel={submitButtonAriaLabel}
103
+ placeholder={placeholder}
104
+ className={cn(
105
+ "[&_form>div]:rounded-none [&_form>div]:border-0 [&_form>div]:bg-transparent [&_form>div]:shadow-none",
106
+ composerClassName,
107
+ )}
108
+ />
109
+ </div>
110
+ </div>
111
+ )
112
+ }
@@ -237,7 +237,7 @@ function ChecklistVariant() {
237
237
  <i className="fa-light fa-ellipsis-vertical" aria-hidden="true" />
238
238
  </Button>
239
239
  </DropdownMenuTrigger>
240
- <DropdownMenuContent align="end" className="w-44">
240
+ <DropdownMenuContent align="end">
241
241
  <DropdownMenuItem onSelect={() => setDismissed(true)}>
242
242
  <i className="fa-light fa-box-archive me-2 text-xs" aria-hidden="true" />
243
243
  Dismiss