@exxatdesignux/ui 0.0.5 → 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,920 @@
1
+ "use client"
2
+
3
+ /**
4
+ * KeyMetrics — WCAG 2.1 AA reusable KPI panel
5
+ *
6
+ * Variants:
7
+ * "card" (default) — shadcn Card wrapper with brand gradient fill
8
+ * "flat" — full-width brand gradient band, no card chrome
9
+ *
10
+ * AA checklist:
11
+ * ✓ Trend text never relies on colour alone — icon + label (WCAG 1.4.1)
12
+ * ✓ Trend icons have aria-hidden; sr-only label carries meaning (1.1.1)
13
+ * ✓ Select has accessible label via aria-label (4.1.2)
14
+ * ✓ Insight action button has descriptive text (4.1.2)
15
+ * ✓ Decorative dividers are aria-hidden (1.1.1)
16
+ * ✓ Contrast: value text foreground ≥ 17:1, trend colours ≥ 4.5:1 (1.4.3)
17
+ */
18
+
19
+ import * as React from "react"
20
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from "@/components/ui/select"
28
+ import { Separator } from "@/components/ui/separator"
29
+ import { AskLeoShortcutKbds, useAskLeo } from "@/components/ask-leo-sidebar"
30
+ import { Button } from "@/components/ui/button"
31
+ import {
32
+ Tooltip,
33
+ TooltipContent,
34
+ TooltipTrigger,
35
+ } from "@/components/ui/tooltip"
36
+ import { cn } from "@/lib/utils"
37
+
38
+ /** Tooltip + optional ⌘⌥K when the insight CTA is the default Ask Leo action. */
39
+ function InsightAskLeoTooltip({
40
+ actionLabel,
41
+ children,
42
+ }: {
43
+ actionLabel?: string
44
+ children: React.ReactNode
45
+ }) {
46
+ const label = actionLabel ?? "Ask Leo"
47
+ const showShortcut = !actionLabel || actionLabel === "Ask Leo"
48
+ if (!showShortcut) {
49
+ return (
50
+ <Tooltip>
51
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
52
+ <TooltipContent side="top">{label}</TooltipContent>
53
+ </Tooltip>
54
+ )
55
+ }
56
+ return (
57
+ <Tooltip>
58
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
59
+ <TooltipContent side="top" className="flex flex-wrap items-center gap-1.5">
60
+ <span>{label}</span>
61
+ <AskLeoShortcutKbds />
62
+ </TooltipContent>
63
+ </Tooltip>
64
+ )
65
+ }
66
+
67
+ /* ── Types ────────────────────────────────────────────────────────────────── */
68
+
69
+ export interface MetricItem {
70
+ /** Unique identifier for React keying */
71
+ id: string
72
+ /** Short label shown above the value */
73
+ label: string
74
+ /** Displayed value — e.g. "23", "98%", "1,250" */
75
+ value: string | number
76
+ /** Change delta — e.g. "+5", "-3", "+12" */
77
+ delta: string | number
78
+ /** Visual + semantic trend direction */
79
+ trend: "up" | "down" | "neutral"
80
+ /** Makes the cell a link */
81
+ href?: string
82
+ /** Makes the cell a button */
83
+ onClick?: () => void
84
+ /**
85
+ * "hero" — primary KPI (e.g. total count): larger value, same structure as siblings.
86
+ * "default" — standard KPI strip cell.
87
+ */
88
+ metricVariant?: "default" | "hero"
89
+ }
90
+
91
+ export interface MetricInsight {
92
+ /** Optional single line for custom copy; rail prefers `title` + `description` when both are set */
93
+ statement?: string
94
+ /** Card headline */
95
+ title: string
96
+ /** Supporting body copy */
97
+ description?: string
98
+ /** Optional deep-link for the ↗ button */
99
+ href?: string
100
+ /** CTA label — defaults to "Ask Leo" */
101
+ actionLabel?: string
102
+ /** Font Awesome class for the CTA icon — defaults to fa-wand-magic-sparkles */
103
+ actionIcon?: string
104
+ /** Callback for the CTA button */
105
+ onAction?: () => void
106
+ /** Severity determines the badge colour (default: warning) */
107
+ severity?: "warning" | "info" | "error"
108
+ }
109
+
110
+ export interface PeriodOption {
111
+ value: string
112
+ label: string
113
+ }
114
+
115
+ export interface KeyMetricsProps {
116
+ /**
117
+ * "card" — shadcn Card with brand gradient (default)
118
+ * "flat" — full-width gradient band, no card chrome
119
+ */
120
+ variant?: "card" | "flat" | "compact"
121
+ /** Panel title */
122
+ title?: string
123
+ /** Subtitle / description below title */
124
+ description?: string
125
+ /** Array of KPI items — by default split into rows of 3 */
126
+ metrics: MetricItem[]
127
+ /** When true, all metrics share one horizontal row (md+ and compact mobile grid) */
128
+ metricsSingleRow?: boolean
129
+ /**
130
+ * When true with `metricsSingleRow`, use a 2-column KPI grid so half-width dashboard cards
131
+ * fit 1–4 KPIs without horizontal overflow (pair rows on md+; 2-col grid on small screens).
132
+ * The insight rail (if any) stacks below the KPI grid instead of sitting beside it on md+.
133
+ */
134
+ metricsHalfWidthLayout?: boolean
135
+ /** Optional insight card — see `insightFullWidth` */
136
+ insight?: MetricInsight
137
+ /**
138
+ * When true, the insight sits on its own full-width row under the metrics (not a narrow side rail).
139
+ */
140
+ insightFullWidth?: boolean
141
+ /** Comparison-period options for the Select */
142
+ periods?: PeriodOption[]
143
+ /** Initially-selected period value */
144
+ defaultPeriod?: string
145
+ /** Called with the new period value when the Select changes */
146
+ onPeriodChange?: (period: string) => void
147
+ /** When false, hides the title/description/period-selector header row (default: true) */
148
+ showHeader?: boolean
149
+ /**
150
+ * Tighter insight card: one short title + line of body, no vertical filler;
151
+ * aligns visually with a single-row KPI band.
152
+ */
153
+ insightCompact?: boolean
154
+ className?: string
155
+ }
156
+
157
+ /** Wrap KPI columns when the strip is narrow (high zoom, 5+ tiles) instead of squeezing cells. */
158
+ const METRICS_GRID_TEMPLATE =
159
+ "repeat(auto-fit, minmax(min(100%, 11.5rem), 1fr))"
160
+
161
+ /** Equal columns in one row — up to 4 KPIs beside an insight rail without premature wrap. */
162
+ function metricsRowColumns(
163
+ rowLength: number,
164
+ metricsSingleRow: boolean,
165
+ metricsHalfWidthLayout: boolean,
166
+ ): string {
167
+ if (metricsHalfWidthLayout) {
168
+ return `repeat(${rowLength}, minmax(0, 1fr))`
169
+ }
170
+ if (metricsSingleRow) {
171
+ return rowLength > 4 ? METRICS_GRID_TEMPLATE : `repeat(${rowLength}, minmax(0, 1fr))`
172
+ }
173
+ return `repeat(${rowLength}, minmax(0, 1fr))`
174
+ }
175
+
176
+ /* ── Default data ─────────────────────────────────────────────────────────── */
177
+
178
+ const DEFAULT_PERIODS: PeriodOption[] = [
179
+ { value: "week", label: "vs last week" },
180
+ { value: "month", label: "vs last month" },
181
+ { value: "quarter", label: "vs last quarter" },
182
+ { value: "year", label: "vs last year" },
183
+ ]
184
+
185
+ /* ── Sub-components ───────────────────────────────────────────────────────── */
186
+
187
+ /** Single KPI cell inside the metrics grid */
188
+ function MetricCell({
189
+ label,
190
+ value,
191
+ delta,
192
+ trend,
193
+ href,
194
+ onClick,
195
+ metricVariant = "default",
196
+ dense = false,
197
+ edgeGutter = true,
198
+ }: Omit<MetricItem, "id"> & { dense?: boolean; edgeGutter?: boolean }) {
199
+ const isUp = trend === "up"
200
+ const isDown = trend === "down"
201
+ const isInteractive = !!(href || onClick)
202
+ const isHero = metricVariant === "hero"
203
+
204
+ const inner = (
205
+ <>
206
+ {/* Label row — min-height = 2 lines so values align when some titles wrap */}
207
+ <div
208
+ className={cn(
209
+ "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-0.5",
210
+ dense ? "min-h-[2.125rem]" : "min-h-[2.625rem]",
211
+ )}
212
+ >
213
+ <p
214
+ className={cn(
215
+ "min-w-0 text-muted-foreground leading-snug wrap-break-word",
216
+ dense ? "text-xs" : "text-sm",
217
+ isHero && "font-medium",
218
+ )}
219
+ >
220
+ {label}
221
+ </p>
222
+ {isInteractive ? (
223
+ <span className="mt-0.5 inline-flex shrink-0" aria-hidden="true">
224
+ <i className="fa-light fa-arrow-right text-xs text-foreground/70 transition-colors duration-150 group-hover:text-interactive-hover-foreground sm:group-hover:translate-x-0.5" />
225
+ </span>
226
+ ) : null}
227
+ </div>
228
+
229
+ {/* Value + trend badge */}
230
+ <div className="flex items-baseline gap-2 flex-wrap">
231
+ <span
232
+ className={cn(
233
+ "font-bold tabular-nums leading-none text-foreground",
234
+ dense
235
+ ? isHero
236
+ ? "text-lg sm:text-xl"
237
+ : "text-base sm:text-lg"
238
+ : isHero
239
+ ? "text-2xl sm:text-[1.625rem]"
240
+ : "text-xl sm:text-2xl",
241
+ )}
242
+ >
243
+ {value}
244
+ </span>
245
+
246
+ {/* Trend chip — icon + text, never colour-only (WCAG 1.4.1) */}
247
+ <span
248
+ className={cn(
249
+ "inline-flex items-center gap-1 font-medium leading-none",
250
+ dense ? "text-xs sm:text-xs" : "text-xs sm:text-sm",
251
+ isUp && "text-chart-2",
252
+ isDown && "text-destructive",
253
+ !isUp && !isDown && "text-muted-foreground"
254
+ )}
255
+ aria-label={`${isUp ? "up" : isDown ? "down" : "no change"} ${delta}`}
256
+ >
257
+ {isUp && <i className="fa-light fa-arrow-trend-up text-[0.8rem]" aria-hidden="true" />}
258
+ {isDown && <i className="fa-light fa-arrow-trend-down text-[0.8rem]" aria-hidden="true" />}
259
+ {!isUp && !isDown && <i className="fa-light fa-minus text-[0.8rem]" aria-hidden="true" />}
260
+ <span>{delta}</span>
261
+ </span>
262
+ </div>
263
+ </>
264
+ )
265
+
266
+ const sharedClass = cn(
267
+ "group flex min-w-0 flex-col gap-2 text-left outline-none",
268
+ edgeGutter && "first:pl-0 last:pr-0",
269
+ dense ? "gap-1.5 px-2 py-2 sm:px-3 sm:py-3" : "gap-2 px-3 py-3 sm:px-5 sm:py-4",
270
+ isHero && "gap-2.5",
271
+ isInteractive && [
272
+ "cursor-pointer transition-colors duration-150",
273
+ "hover:bg-foreground/5",
274
+ "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
275
+ ]
276
+ )
277
+
278
+ if (href) {
279
+ return (
280
+ <a href={href} className={sharedClass} aria-label={`${label}: ${value}`}>
281
+ {inner}
282
+ </a>
283
+ )
284
+ }
285
+
286
+ if (onClick) {
287
+ return (
288
+ <button type="button" onClick={onClick} className={sharedClass} aria-label={`${label}: ${value}`}>
289
+ {inner}
290
+ </button>
291
+ )
292
+ }
293
+
294
+ return <div className={sharedClass}>{inner}</div>
295
+ }
296
+
297
+ /** Body line for rail: `description`, else optional `statement` */
298
+ function insightRailBody(insight: MetricInsight): string {
299
+ const d = insight.description?.trim()
300
+ if (d) return d
301
+ return insight.statement?.trim() ?? ""
302
+ }
303
+
304
+ /**
305
+ * Rail insight: severity badge + title + description + optional ↗, Ask Leo (no rule between copy and action).
306
+ */
307
+ function InsightRailStatementAction({
308
+ insight,
309
+ compact,
310
+ }: {
311
+ insight: MetricInsight
312
+ compact: boolean
313
+ }) {
314
+ const badgeSize = compact ? "sm" : "default"
315
+ const surface = compact
316
+ ? "border border-border/50 bg-gradient-to-b from-muted/35 to-card"
317
+ : "bg-card"
318
+ const body = insightRailBody(insight)
319
+
320
+ return (
321
+ <Card
322
+ role="region"
323
+ aria-label="Insight"
324
+ className={cn(
325
+ "flex h-full min-h-0 flex-col overflow-hidden rounded-lg border-0 p-0 shadow-none ring-1 ring-foreground/8",
326
+ surface
327
+ )}
328
+ >
329
+ {/* flex-1 + mt-auto on the CTA: copy stays top-aligned when the rail stretches to KPI height */}
330
+ <div className="flex min-h-0 flex-1 flex-col px-3 py-3 sm:px-4 sm:py-4">
331
+ <div className="flex items-start gap-2.5">
332
+ <InsightBadge severity={insight.severity} size={badgeSize} />
333
+ <div className="min-w-0 flex-1">
334
+ <p className="text-sm font-semibold leading-snug text-foreground">{insight.title}</p>
335
+ {body ? (
336
+ <p className="mt-1 text-sm leading-snug text-muted-foreground">{body}</p>
337
+ ) : null}
338
+ </div>
339
+ {insight.href && (
340
+ <a
341
+ href={insight.href}
342
+ className="mt-0.5 shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
343
+ aria-label={`Open ${insight.title} — details`}
344
+ >
345
+ <i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
346
+ </a>
347
+ )}
348
+ </div>
349
+
350
+ <div className="mt-auto flex shrink-0 justify-end pt-3">
351
+ <InsightAskLeoTooltip actionLabel={insight.actionLabel}>
352
+ <Button
353
+ variant={compact ? "outline" : "ghost"}
354
+ size="sm"
355
+ className={cn(
356
+ "h-8 w-full gap-1.5 text-xs sm:w-auto",
357
+ compact
358
+ ? "border-border/60 bg-background px-3 text-foreground hover:bg-background"
359
+ : "px-3 text-muted-foreground hover:text-interactive-hover-foreground"
360
+ )}
361
+ onClick={insight.onAction}
362
+ aria-label={insight.actionLabel ?? "Ask Leo"}
363
+ >
364
+ <i
365
+ className={
366
+ insight.actionIcon
367
+ ? `fa-light ${insight.actionIcon} text-xs`
368
+ : "fa-duotone fa-solid fa-star-christmas text-xs text-brand"
369
+ }
370
+ aria-hidden="true"
371
+ />
372
+ {insight.actionLabel ?? "Ask Leo"}
373
+ </Button>
374
+ </InsightAskLeoTooltip>
375
+ </div>
376
+ </div>
377
+ </Card>
378
+ )
379
+ }
380
+
381
+ /** Severity icon badge for the insight card */
382
+
383
+ function InsightBadge({
384
+ severity = "warning",
385
+ size = "default",
386
+ }: {
387
+ severity?: MetricInsight["severity"]
388
+ size?: "default" | "sm"
389
+ }) {
390
+ const styles = {
391
+ warning: {
392
+ bg: "bg-[var(--insight-severity-warning-bg)]",
393
+ icon: "fa-circle-exclamation",
394
+ color: "text-[var(--insight-severity-warning-fg)]",
395
+ },
396
+ info: {
397
+ bg: "bg-[var(--insight-severity-info-bg)]",
398
+ icon: "fa-circle-info",
399
+ color: "text-[var(--insight-severity-info-fg)]",
400
+ },
401
+ error: { bg: "bg-destructive/15", icon: "fa-circle-xmark", color: "text-destructive" },
402
+ }[severity]
403
+
404
+ return (
405
+ <span
406
+ className={cn(
407
+ "inline-flex shrink-0 items-center justify-center rounded-full",
408
+ size === "sm" ? "h-6 w-6 text-xs" : "h-7 w-7 text-sm",
409
+ styles.bg,
410
+ styles.color
411
+ )}
412
+ aria-hidden="true"
413
+ >
414
+ <i className={`fa-light ${styles.icon}`} />
415
+ </span>
416
+ )
417
+ }
418
+
419
+ /* ── Shared inner content ─────────────────────────────────────────────────── */
420
+
421
+ interface InnerProps {
422
+ title: string
423
+ description: string
424
+ period: string
425
+ periods: PeriodOption[]
426
+ metrics: MetricItem[]
427
+ rows: MetricItem[][]
428
+ insight?: MetricInsight
429
+ onPeriodChange: (v: string) => void
430
+ /** Extra padding class injected by flat variant */
431
+ innerPadding?: string
432
+ /** When false, the header (title/description/period select) is hidden */
433
+ showHeader?: boolean
434
+ insightCompact?: boolean
435
+ insightFullWidth?: boolean
436
+ metricsSingleRow?: boolean
437
+ /** Tighter KPI cells + 2-col mobile grid (half-width dashboard card). */
438
+ metricsHalfWidthLayout?: boolean
439
+ /** Opaque fill behind each KPI cell when using hairline grid gaps (below `lg`). */
440
+ metricsCellSurfaceClassName?: string
441
+ }
442
+
443
+ function KeyMetricsInner({
444
+ title,
445
+ description,
446
+ period,
447
+ periods,
448
+ metrics,
449
+ rows,
450
+ insight,
451
+ onPeriodChange,
452
+ innerPadding = "",
453
+ showHeader = true,
454
+ insightCompact = false,
455
+ insightFullWidth = false,
456
+ metricsSingleRow = false,
457
+ metricsHalfWidthLayout = false,
458
+ metricsCellSurfaceClassName = "bg-background",
459
+ }: InnerProps) {
460
+ /** Side-by-side KPI + insight rail (md+). Disabled for half-width dashboard cards — insight stacks below. */
461
+ const insightSideBySide = insight && !insightFullWidth && !metricsHalfWidthLayout
462
+ const stackedRailInsight = insight && !insightFullWidth && metricsHalfWidthLayout
463
+
464
+ return (
465
+ <div data-slot="key-metrics" className="contents">
466
+ {/* ── Header ──────────────────────────────────────────────────── */}
467
+ {showHeader && (
468
+ <div className={cn(
469
+ "flex flex-col gap-2 pb-3",
470
+ "sm:flex-row sm:items-center sm:justify-between sm:gap-4",
471
+ innerPadding
472
+ )}>
473
+ <div>
474
+ <p className="text-base font-semibold text-foreground leading-tight">{title}</p>
475
+ <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
476
+ </div>
477
+
478
+ {/* Period selector — align="end" keeps dropdown flush-right */}
479
+ <Select value={period} onValueChange={onPeriodChange}>
480
+ <SelectTrigger
481
+ className="h-8 w-full sm:w-auto sm:min-w-[9rem] shrink-0 text-sm"
482
+ aria-label="Select comparison period"
483
+ >
484
+ <SelectValue />
485
+ </SelectTrigger>
486
+ <SelectContent align="end" sideOffset={4}>
487
+ {periods.map((p) => (
488
+ <SelectItem key={p.value} value={p.value}>
489
+ {p.label}
490
+ </SelectItem>
491
+ ))}
492
+ </SelectContent>
493
+ </Select>
494
+ </div>
495
+ )}
496
+
497
+ {/* ── Body: metrics grid + optional insight ───────────────────── */}
498
+ <div
499
+ className={cn(
500
+ "flex flex-col gap-0",
501
+ /* 60% KPIs / 40% insight (3fr:2fr); lg+ only so phones/tablets stack KPIs + insight */
502
+ insightSideBySide &&
503
+ "lg:grid lg:grid-cols-[minmax(0,3fr)_minmax(13rem,2fr)] lg:items-stretch lg:gap-x-6 lg:gap-y-0",
504
+ innerPadding
505
+ )}
506
+ >
507
+
508
+ {/* Metrics section — self-start so KPI cells don’t stretch when the insight column is taller */}
509
+ <div
510
+ className={cn(
511
+ "min-w-0 lg:flex lg:min-h-0 lg:flex-col",
512
+ !insightSideBySide && "w-full",
513
+ insightSideBySide && "lg:self-start"
514
+ )}
515
+ >
516
+ {/*
517
+ Phone (<md): one column. Tablet (md–lg): 2-column grid (e.g. 2×2 for four KPIs).
518
+ Hairline separators use gap-px + opaque cell surfaces (divide-* breaks for 2-col order).
519
+ Half-width dashboard cards keep divide-x + optional template columns.
520
+ */}
521
+ {metricsHalfWidthLayout ? (
522
+ <div
523
+ className="grid grid-cols-2 divide-x divide-border lg:hidden"
524
+ style={
525
+ metricsSingleRow
526
+ ? {
527
+ gridTemplateColumns: metricsRowColumns(
528
+ metrics.length,
529
+ metricsSingleRow,
530
+ metricsHalfWidthLayout,
531
+ ),
532
+ }
533
+ : undefined
534
+ }
535
+ >
536
+ {metrics.map((m) => (
537
+ <MetricCell key={m.id} {...m} dense />
538
+ ))}
539
+ </div>
540
+ ) : (
541
+ <div
542
+ className={cn(
543
+ "grid gap-px bg-border lg:hidden",
544
+ "grid-cols-1 md:grid-cols-2",
545
+ )}
546
+ >
547
+ {metrics.map((m) => (
548
+ <div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
549
+ <MetricCell {...m} dense={false} edgeGutter={false} />
550
+ </div>
551
+ ))}
552
+ </div>
553
+ )}
554
+
555
+ {/* lg+: row-by-row 3-col with horizontal separator between rows */}
556
+ <div className="hidden lg:block">
557
+ {rows.map((row, rowIdx) => (
558
+ <React.Fragment key={rowIdx}>
559
+ {rowIdx > 0 && (
560
+ <Separator aria-hidden="true" className="my-1" />
561
+ )}
562
+ <div
563
+ className="grid divide-x divide-border"
564
+ style={{
565
+ gridTemplateColumns: metricsRowColumns(
566
+ row.length,
567
+ metricsSingleRow,
568
+ metricsHalfWidthLayout,
569
+ ),
570
+ }}
571
+ >
572
+ {row.map((m) => (
573
+ <MetricCell key={m.id} {...m} dense={metricsHalfWidthLayout} />
574
+ ))}
575
+ </div>
576
+ </React.Fragment>
577
+ ))}
578
+ </div>
579
+ </div>
580
+
581
+ {/* Insight card — only rendered when data provided */}
582
+ {insight && (
583
+ <>
584
+ {insightFullWidth ? (
585
+ <Separator aria-hidden="true" className="my-4 w-full" />
586
+ ) : stackedRailInsight ? (
587
+ <Separator aria-hidden="true" className="my-4 w-full" />
588
+ ) : (
589
+ <Separator aria-hidden="true" className="my-3 lg:hidden" />
590
+ )}
591
+
592
+ <div
593
+ className={cn(
594
+ "flex min-h-0 min-w-0 w-full flex-col",
595
+ /* Divider + padding replace vertical Separator so grid stays 2 columns */
596
+ insightSideBySide &&
597
+ !insightFullWidth &&
598
+ "lg:h-full lg:border-l lg:border-border lg:pl-6"
599
+ )}
600
+ >
601
+ {insight && !insightFullWidth ? (
602
+ <InsightRailStatementAction insight={insight} compact={insightCompact} />
603
+ ) : (
604
+ <Card
605
+ role="region"
606
+ aria-label="Insight"
607
+ className={cn(
608
+ "overflow-hidden rounded-lg p-0 ring-1 ring-foreground/8 shadow-none",
609
+ "flex min-h-0 flex-col bg-muted/25"
610
+ )}
611
+ >
612
+ {insightCompact ? (
613
+ <div className="flex min-h-0 flex-1 flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:gap-8 md:p-5">
614
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
615
+ <div className="flex items-start gap-2.5">
616
+ <InsightBadge severity={insight.severity} size="sm" />
617
+ <div className="flex min-w-0 flex-1 items-start justify-between gap-2">
618
+ <p className="text-base font-semibold leading-tight text-foreground">
619
+ {insight.title}
620
+ </p>
621
+ {insight.href && (
622
+ <a
623
+ href={insight.href}
624
+ className="shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
625
+ aria-label={`Open ${insight.title} — details`}
626
+ >
627
+ <i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
628
+ </a>
629
+ )}
630
+ </div>
631
+ </div>
632
+ {insight.description ? (
633
+ <p className="text-sm leading-relaxed text-muted-foreground">
634
+ {insight.description}
635
+ </p>
636
+ ) : null}
637
+ </div>
638
+ <div className="flex w-full shrink-0 md:w-auto">
639
+ <InsightAskLeoTooltip actionLabel={insight.actionLabel}>
640
+ <Button
641
+ variant="ghost"
642
+ size="sm"
643
+ className="h-9 w-full gap-1.5 px-4 text-xs text-muted-foreground hover:text-interactive-hover-foreground md:min-w-[8.5rem]"
644
+ onClick={insight.onAction}
645
+ aria-label={insight.actionLabel ?? "Ask Leo"}
646
+ >
647
+ <i
648
+ className={insight.actionIcon ? `fa-light ${insight.actionIcon} text-xs` : "fa-duotone fa-solid fa-star-christmas text-xs text-brand"}
649
+ aria-hidden="true"
650
+ />
651
+ {insight.actionLabel ?? "Ask Leo"}
652
+ </Button>
653
+ </InsightAskLeoTooltip>
654
+ </div>
655
+ </div>
656
+ ) : (
657
+ <div className="flex min-h-0 flex-1 flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between md:gap-8 md:p-5">
658
+ <div className="flex min-w-0 flex-1 flex-col gap-3">
659
+ <div className="flex items-start gap-3">
660
+ <InsightBadge severity={insight.severity} />
661
+ <div className="flex min-w-0 flex-1 items-start justify-between gap-2">
662
+ <p className="text-base font-semibold leading-snug text-foreground">
663
+ {insight.title}
664
+ </p>
665
+ {insight.href && (
666
+ <a
667
+ href={insight.href}
668
+ className="shrink-0 text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
669
+ aria-label={`Open ${insight.title} — details`}
670
+ >
671
+ <i className="fa-light fa-arrow-up-right text-xs" aria-hidden="true" />
672
+ </a>
673
+ )}
674
+ </div>
675
+ </div>
676
+ {insight.description ? (
677
+ <p className="text-sm leading-relaxed text-muted-foreground">
678
+ {insight.description}
679
+ </p>
680
+ ) : null}
681
+ </div>
682
+ <div className="flex w-full shrink-0 md:w-auto">
683
+ <InsightAskLeoTooltip actionLabel={insight.actionLabel}>
684
+ <Button
685
+ variant="ghost"
686
+ size="sm"
687
+ className="h-9 w-full gap-1.5 px-4 text-xs text-muted-foreground hover:text-interactive-hover-foreground md:min-w-[8.5rem]"
688
+ onClick={insight.onAction}
689
+ aria-label={insight.actionLabel ?? "Ask Leo"}
690
+ >
691
+ <i
692
+ className={insight.actionIcon ? `fa-light ${insight.actionIcon} text-xs` : "fa-duotone fa-solid fa-star-christmas text-xs text-brand"}
693
+ aria-hidden="true"
694
+ />
695
+ {insight.actionLabel ?? "Ask Leo"}
696
+ </Button>
697
+ </InsightAskLeoTooltip>
698
+ </div>
699
+ </div>
700
+ )}
701
+ </Card>
702
+ )}
703
+ </div>
704
+ </>
705
+ )}
706
+ </div>
707
+ </div>
708
+ )
709
+ }
710
+
711
+ function chunkMetricPairs(metrics: MetricItem[]): MetricItem[][] {
712
+ const out: MetricItem[][] = []
713
+ for (let i = 0; i < metrics.length; i += 2) out.push(metrics.slice(i, i + 2))
714
+ return out
715
+ }
716
+
717
+ /* ── Main component ───────────────────────────────────────────────────────── */
718
+
719
+ export function KeyMetrics({
720
+ variant = "card",
721
+ title = "Key Metrics",
722
+ description = "Overview of performance indicators",
723
+ metrics = [],
724
+ insight,
725
+ periods = DEFAULT_PERIODS,
726
+ defaultPeriod = "week",
727
+ onPeriodChange,
728
+ showHeader = true,
729
+ insightCompact = false,
730
+ insightFullWidth = false,
731
+ metricsSingleRow = false,
732
+ metricsHalfWidthLayout = false,
733
+ className,
734
+ }: KeyMetricsProps) {
735
+ const [period, setPeriod] = React.useState(defaultPeriod)
736
+ const { toggle: toggleAskLeo } = useAskLeo()
737
+
738
+ function handlePeriodChange(v: string) {
739
+ setPeriod(v)
740
+ onPeriodChange?.(v)
741
+ }
742
+
743
+ /* Split metrics into rows of 3, or paired rows when half-width + single row, else one row */
744
+ const rows: MetricItem[][] = metricsSingleRow
745
+ ? metrics.length
746
+ ? metricsHalfWidthLayout
747
+ ? chunkMetricPairs(metrics)
748
+ : [metrics]
749
+ : []
750
+ : (() => {
751
+ const out: MetricItem[][] = []
752
+ for (let i = 0; i < metrics.length; i += 3) {
753
+ out.push(metrics.slice(i, i + 3))
754
+ }
755
+ return out
756
+ })()
757
+
758
+ const metricsCellSurfaceClassName = variant === "flat" ? "bg-background" : "bg-card"
759
+
760
+ const innerProps: InnerProps = {
761
+ title,
762
+ description,
763
+ period,
764
+ periods,
765
+ metrics,
766
+ rows,
767
+ insight,
768
+ onPeriodChange: handlePeriodChange,
769
+ insightCompact,
770
+ insightFullWidth,
771
+ metricsSingleRow,
772
+ metricsHalfWidthLayout,
773
+ metricsCellSurfaceClassName,
774
+ }
775
+
776
+ /*
777
+ * ── GLOW GUIDELINE ────────────────────────────────────────────────────────
778
+ * The bottom-glow treatment is a deliberate design signal. Use it only for:
779
+ *
780
+ * 1. AI / intelligence surfaces — e.g. AI Insights, Ask Leo responses,
781
+ * any card that surfaces machine-generated content.
782
+ * Opacity: 0.12–0.16 (subtle; the glow should not dominate)
783
+ *
784
+ * 2. Designer-designated hero sections — e.g. Key Metrics (the primary
785
+ * KPI band), onboarding completion, or any section the product team
786
+ * explicitly wants to "elevate" visually.
787
+ * Opacity: 0.18–0.24 (more pronounced; intentional focal point)
788
+ *
789
+ * Do NOT add glow to:
790
+ * • Standard data/content cards (Tasks, Activity, Learn, Charts…)
791
+ * • Navigation or shell elements
792
+ * • Cards that already use a coloured border or badge for status
793
+ *
794
+ * Implementation:
795
+ * style={{ background: "radial-gradient(ellipse 110% 90% at 50% 100%,
796
+ * oklch(from var(--brand-color) l c h / <opacity>) 0%, transparent 68%)" }}
797
+ * + className="overflow-hidden" ← required to clip the gradient
798
+ * ─────────────────────────────────────────────────────────────────────────
799
+ */
800
+ const glowStyle: React.CSSProperties = {
801
+ /* oklch relative color: inherit brand hue/chroma/lightness, set alpha only */
802
+ background:
803
+ "radial-gradient(ellipse 110% 90% at 50% 100%, oklch(from var(--brand-color) l c h / 0.13) 0%, transparent 65%)",
804
+ }
805
+
806
+ /* ── Card variant — ChartCard-style chrome ───────────────────────────── */
807
+ if (variant === "card") {
808
+ return (
809
+ <Card className={cn("shadow-xs overflow-hidden flex flex-col", className)} style={glowStyle}>
810
+ <CardHeader className={cn("shrink-0 pb-2", metricsHalfWidthLayout && "space-y-2")}>
811
+ <div
812
+ className={cn(
813
+ "flex gap-2",
814
+ metricsHalfWidthLayout
815
+ ? "flex-col min-[400px]:flex-row min-[400px]:items-start min-[400px]:justify-between"
816
+ : "items-start",
817
+ )}
818
+ >
819
+ <div className="flex-1 min-w-0">
820
+ <CardTitle className="text-sm font-semibold leading-tight">{title}</CardTitle>
821
+ <CardDescription className="text-xs mt-0.5">{description}</CardDescription>
822
+ </div>
823
+ <div className="flex flex-wrap items-center gap-1.5 shrink-0">
824
+ <InsightAskLeoTooltip actionLabel="Ask Leo">
825
+ <Button
826
+ size="sm"
827
+ variant="outline"
828
+ className="h-7 shrink-0 text-xs gap-1.5 px-2"
829
+ aria-label="Ask Leo about these metrics"
830
+ onClick={toggleAskLeo}
831
+ type="button"
832
+ >
833
+ <i className="fa-duotone fa-solid fa-star-christmas text-xs text-brand" aria-hidden="true" />
834
+ <span>Ask Leo</span>
835
+ </Button>
836
+ </InsightAskLeoTooltip>
837
+ <Select value={period} onValueChange={handlePeriodChange}>
838
+ <SelectTrigger
839
+ size="sm"
840
+ className="w-auto min-w-[9rem] shrink-0 text-sm"
841
+ aria-label="Select comparison period"
842
+ >
843
+ <SelectValue />
844
+ </SelectTrigger>
845
+ <SelectContent align="end" sideOffset={4}>
846
+ {periods.map((p) => (
847
+ <SelectItem key={p.value} value={p.value}>
848
+ {p.label}
849
+ </SelectItem>
850
+ ))}
851
+ </SelectContent>
852
+ </Select>
853
+ </div>
854
+ </div>
855
+ </CardHeader>
856
+ <CardContent className="flex-1 pb-4">
857
+ <KeyMetricsInner {...innerProps} showHeader={false} />
858
+ </CardContent>
859
+ </Card>
860
+ )
861
+ }
862
+
863
+ /* ── Compact variant — card chrome, no header, metrics only ──────────── */
864
+ if (variant === "compact") {
865
+ return (
866
+ <Card className={cn("shadow-xs overflow-hidden", className)} style={glowStyle}>
867
+ <CardContent className="py-3 px-4">
868
+ <KeyMetricsInner {...innerProps} showHeader={false} />
869
+ </CardContent>
870
+ </Card>
871
+ )
872
+ }
873
+
874
+ /* ── Flat variant — full-width bottom-glow band ───────────────────────── */
875
+ return (
876
+ <section
877
+ aria-label={title}
878
+ className={cn("w-full py-5", className)}
879
+ style={glowStyle}
880
+ >
881
+ <KeyMetricsInner
882
+ {...innerProps}
883
+ innerPadding="px-4 lg:px-6"
884
+ showHeader={showHeader}
885
+ />
886
+ </section>
887
+ )
888
+ }
889
+
890
+ /**
891
+ * KeyMetricsContent — renders just the metrics grid + optional insight panel.
892
+ * No card wrapper, no header, no period selector.
893
+ * Designed for embedding inside a ChartCard with tabOptions period tabs.
894
+ */
895
+ export function KeyMetricsContent({
896
+ metrics = [],
897
+ insight,
898
+ insightCompact = false,
899
+ insightFullWidth = false,
900
+ }: Pick<KeyMetricsProps, "metrics" | "insight" | "insightCompact" | "insightFullWidth">) {
901
+ const rows: MetricItem[][] = []
902
+ for (let i = 0; i < metrics.length; i += 3) rows.push(metrics.slice(i, i + 3))
903
+
904
+ return (
905
+ <KeyMetricsInner
906
+ title=""
907
+ description=""
908
+ period=""
909
+ periods={[]}
910
+ metrics={metrics}
911
+ rows={rows}
912
+ insight={insight}
913
+ onPeriodChange={() => {}}
914
+ showHeader={false}
915
+ insightCompact={insightCompact}
916
+ insightFullWidth={insightFullWidth}
917
+ metricsCellSurfaceClassName="bg-card"
918
+ />
919
+ )
920
+ }