@exxatdesignux/ui 0.2.15 → 0.2.17

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 (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  10. package/package.json +3 -3
  11. package/src/components/ui/banner.tsx +2 -0
  12. package/src/components/ui/chart.tsx +57 -2
  13. package/src/components/ui/sidebar.tsx +1 -0
  14. package/src/globals.css +21 -2
  15. package/src/theme.css +4 -2
  16. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  17. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  18. package/template/AGENTS.md +23 -18
  19. package/template/app/(app)/data-list/page.tsx +2 -2
  20. package/template/app/(app)/question-bank/layout.tsx +27 -7
  21. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  22. package/template/app/globals.css +136 -2
  23. package/template/app/layout.tsx +41 -5
  24. package/template/components/app-sidebar.tsx +141 -59
  25. package/template/components/ask-leo-sidebar.tsx +1 -4
  26. package/template/components/brand-color-picker.tsx +344 -0
  27. package/template/components/compliance-list-view.tsx +33 -51
  28. package/template/components/compliance-table.tsx +24 -0
  29. package/template/components/data-table/index.tsx +68 -24
  30. package/template/components/data-table/pagination.tsx +0 -1
  31. package/template/components/data-table/types.ts +4 -1
  32. package/template/components/data-table/use-table-state.ts +243 -94
  33. package/template/components/data-views/data-row-list.tsx +183 -0
  34. package/template/components/data-views/finder-panel-view.tsx +2 -2
  35. package/template/components/data-views/index.ts +26 -3
  36. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  37. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  38. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  39. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  40. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  41. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  42. package/template/components/export-drawer.tsx +1 -1
  43. package/template/components/exxat-product-logo.tsx +173 -379
  44. package/template/components/folder-details-shell.tsx +1 -1
  45. package/template/components/hub-tree-panel-view.tsx +88 -80
  46. package/template/components/invite-collaborators-drawer.tsx +5 -3
  47. package/template/components/key-metrics.tsx +116 -51
  48. package/template/components/new-placement-form.tsx +4 -2
  49. package/template/components/new-question-composer.tsx +2208 -0
  50. package/template/components/page-breadcrumb-trail.tsx +131 -0
  51. package/template/components/page-header.tsx +21 -11
  52. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  53. package/template/components/placement-detail.tsx +1 -1
  54. package/template/components/placements-board-view.tsx +1 -1
  55. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  56. package/template/components/placements-list-view.tsx +18 -132
  57. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  58. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  59. package/template/components/placements-table-columns.tsx +2 -2
  60. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  61. package/template/components/product-switcher.tsx +26 -11
  62. package/template/components/product-wordmark.tsx +285 -0
  63. package/template/components/question-bank-client.tsx +130 -70
  64. package/template/components/question-bank-hub-client.tsx +108 -115
  65. package/template/components/question-bank-list-view.tsx +30 -54
  66. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  67. package/template/components/question-bank-page-header.tsx +18 -2
  68. package/template/components/question-bank-secondary-nav.tsx +12 -228
  69. package/template/components/question-bank-table.tsx +30 -5
  70. package/template/components/rotations-empty-state.tsx +3 -0
  71. package/template/components/secondary-panel.tsx +24 -4
  72. package/template/components/settings-appearance-card.tsx +584 -141
  73. package/template/components/site-header.tsx +56 -32
  74. package/template/components/sites-list-view.tsx +31 -36
  75. package/template/components/sites-table.tsx +24 -0
  76. package/template/components/table-properties/drawer.tsx +1 -1
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +29 -3
  80. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  81. package/template/components/templates/list-page.tsx +1 -3
  82. package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
  83. package/template/components/ui/dot-pattern.tsx +50 -26
  84. package/template/components/ui/leo-icon.tsx +23 -3
  85. package/template/contexts/product-context.tsx +51 -7
  86. package/template/contexts/system-banner-context.tsx +112 -4
  87. package/template/docs/collaboration-access-pattern.md +2 -0
  88. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  89. package/template/eslint.config.mjs +18 -0
  90. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/data-list-persistence.ts +57 -257
  93. package/template/lib/dev-log.test.ts +6 -5
  94. package/template/lib/exxat-palette.json +1462 -0
  95. package/template/lib/exxat-palette.ts +136 -0
  96. package/template/lib/list-page-table-properties.ts +1 -1
  97. package/template/lib/list-status-badges.ts +1 -1
  98. package/template/lib/mailto.ts +29 -0
  99. package/template/lib/mock/navigation.tsx +30 -1
  100. package/template/lib/placement-board-card-layout.ts +1 -1
  101. package/template/lib/product-brand.ts +268 -0
  102. package/template/lib/question-bank-authoring.ts +308 -0
  103. package/template/lib/question-bank-nav.ts +70 -0
  104. package/template/lib/raf-throttle.ts +45 -0
  105. package/template/lib/table-state-lifecycle.ts +474 -0
  106. package/template/next.config.mjs +156 -0
  107. package/template/package.json +6 -6
  108. package/template/stores/app-store.ts +46 -1
  109. package/template/components/command-menu-01.tsx +0 -133
  110. package/template/components/command-menu-02.tsx +0 -386
@@ -102,7 +102,7 @@ export function FolderDetailsShell({
102
102
 
103
103
  return (
104
104
  <div className="flex h-full min-h-0 flex-col overflow-hidden bg-card">
105
- <header className="shrink-0 border-b border-border/60 bg-muted/10 px-4 pb-4 pt-4">
105
+ <header className="shrink-0 border-b border-border/60 bg-card px-4 pb-4 pt-4">
106
106
  <div className="flex items-start justify-between gap-3">
107
107
  <div className="flex min-w-0 flex-1 items-start gap-3">
108
108
  <OsFolderGlyph
@@ -19,7 +19,7 @@ import {
19
19
  import { LIST_HUB_INSPECTOR_CHIP_SHELL } from "@/components/list-hub-status-badge"
20
20
  import { Badge } from "@/components/ui/badge"
21
21
  import { Button } from "@/components/ui/button"
22
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
22
+ import { Collapsible, CollapsibleTrigger } from "@/components/ui/collapsible"
23
23
  import { Separator } from "@/components/ui/separator"
24
24
  import { Tip } from "@/components/ui/tip"
25
25
  import {
@@ -28,6 +28,14 @@ import {
28
28
  TooltipTrigger,
29
29
  } from "@/components/ui/tooltip"
30
30
  import { cn } from "@/lib/utils"
31
+ import {
32
+ OutlineTreeCollapsibleContentRail,
33
+ OutlineTreeLeafButton,
34
+ OutlineTreeMenu,
35
+ OutlineTreeMenuItem,
36
+ OutlineTreeSub,
37
+ OutlineTreeSubItem,
38
+ } from "@/components/data-views/outline-tree-menu"
31
39
  import { ListPageTreePanelShell } from "@/components/data-views/list-page-tree-panel-shell"
32
40
  import { ListPageSplitDetailsPlaceholder } from "@/components/data-views/list-page-split-details-placeholder"
33
41
  import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
@@ -63,7 +71,6 @@ interface TreeItemProps {
63
71
  folders: QuestionBankFolder[]
64
72
  questions: QuestionBankItem[]
65
73
  selectedItemId: string | null
66
- depth?: number
67
74
  onSelectItem: (itemId: string) => void
68
75
  }
69
76
 
@@ -72,7 +79,6 @@ function TreeItem({
72
79
  folders,
73
80
  questions,
74
81
  selectedItemId,
75
- depth = 0,
76
82
  onSelectItem,
77
83
  }: TreeItemProps) {
78
84
  const childFolders = folders
@@ -83,38 +89,40 @@ function TreeItem({
83
89
 
84
90
  const hasChildren = childFolders.length > 0 || childQuestions.length > 0
85
91
  const isFolderSelected = selectedItemId === folder.id
86
- const indent = depth * 12
87
92
 
88
93
  return (
89
- <Collapsible>
90
- {/* Folder row */}
91
- <div className="group flex items-center hover:bg-muted/50">
92
- <div style={{ width: indent }} className="shrink-0" />
93
-
94
- {/* Expand chevron or spacer */}
94
+ <Collapsible className="group/collapsible">
95
+ {/* Folder row — chevron column + row body (icons align with shadcn tree pattern) */}
96
+ <div
97
+ className={cn(
98
+ "flex min-h-8 items-center rounded-md px-2 hover:bg-muted/50",
99
+ isFolderSelected && "bg-accent text-accent-foreground",
100
+ )}
101
+ >
95
102
  {hasChildren ? (
96
103
  <CollapsibleTrigger asChild>
97
104
  <button
98
105
  type="button"
99
- className="flex h-8 w-5 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none"
106
+ className="flex size-8 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
107
+ aria-label={folder.name ? `${folder.name} — expand or collapse` : "Expand or collapse folder"}
100
108
  >
101
- <ChevronRightIcon className="h-3.5 w-3.5 transition-transform duration-150 group-data-[state=open]:rotate-90 [[data-state=open]_&]:rotate-90" />
109
+ <ChevronRightIcon
110
+ className="h-3.5 w-3.5 shrink-0 transition-transform duration-150 group-data-[state=open]/collapsible:rotate-90"
111
+ aria-hidden
112
+ />
102
113
  </button>
103
114
  </CollapsibleTrigger>
104
115
  ) : (
105
- <div className="w-5 shrink-0" />
116
+ <div className="size-8 shrink-0" aria-hidden />
106
117
  )}
107
118
 
108
- {/* Folder button */}
109
119
  <button
110
120
  type="button"
111
121
  onClick={() => onSelectItem(folder.id)}
112
122
  className={cn(
113
- "flex flex-1 items-center gap-2 py-1.5 pr-3 text-left text-sm transition-colors duration-75",
123
+ "flex min-w-0 flex-1 items-center gap-2 py-1.5 pr-3 text-left text-sm transition-colors duration-75",
114
124
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
115
- isFolderSelected
116
- ? "bg-accent text-accent-foreground"
117
- : "text-foreground",
125
+ !isFolderSelected && "text-foreground",
118
126
  )}
119
127
  aria-selected={isFolderSelected}
120
128
  role="option"
@@ -134,55 +142,51 @@ function TreeItem({
134
142
  </button>
135
143
  </div>
136
144
 
137
- {/* Children */}
138
145
  {hasChildren && (
139
- <CollapsibleContent>
140
- {childFolders.map(child => (
141
- <TreeItem
142
- key={child.id}
143
- folder={child}
144
- folders={folders}
145
- questions={questions}
146
- selectedItemId={selectedItemId}
147
- depth={depth + 1}
148
- onSelectItem={onSelectItem}
149
- />
150
- ))}
151
- {childQuestions.map(question => {
152
- const isSelected = selectedItemId === question.id
153
- return (
154
- <div key={question.id} className="group flex items-center hover:bg-muted/50">
155
- <div style={{ width: indent + 12 + 20 }} className="shrink-0" />
156
- <button
157
- type="button"
158
- onClick={() => onSelectItem(question.id)}
159
- className={cn(
160
- "flex flex-1 items-center gap-2 py-1.5 pr-3 text-left text-sm transition-colors duration-75",
161
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
162
- isSelected
163
- ? "bg-accent text-accent-foreground"
164
- : "text-foreground",
165
- )}
166
- aria-selected={isSelected}
167
- role="option"
168
- >
169
- <FileIcon
170
- className={cn(
171
- "h-3.5 w-3.5 shrink-0",
172
- isSelected ? "fill-current opacity-80" : "text-muted-foreground",
173
- )}
174
- />
175
- <span className="min-w-0 flex-1">
176
- <span className="block truncate leading-tight">{question.stem}</span>
177
- <span className="block truncate font-mono text-[11px] text-muted-foreground">
178
- {question.questionId}
146
+ <OutlineTreeCollapsibleContentRail>
147
+ <OutlineTreeSub surface="panel" guideLayout="chevronRail">
148
+ {childFolders.map(child => (
149
+ <OutlineTreeMenuItem key={child.id}>
150
+ <TreeItem
151
+ folder={child}
152
+ folders={folders}
153
+ questions={questions}
154
+ selectedItemId={selectedItemId}
155
+ onSelectItem={onSelectItem}
156
+ />
157
+ </OutlineTreeMenuItem>
158
+ ))}
159
+ {childQuestions.map(question => {
160
+ const isSelected = selectedItemId === question.id
161
+ return (
162
+ <OutlineTreeSubItem key={question.id}>
163
+ <OutlineTreeLeafButton
164
+ surface="panel"
165
+ isActive={isSelected}
166
+ onClick={() => onSelectItem(question.id)}
167
+ aria-selected={isSelected}
168
+ role="option"
169
+ className="h-auto min-h-8 items-start py-1.5"
170
+ >
171
+ <FileIcon
172
+ className={cn(
173
+ "mt-0.5 shrink-0",
174
+ isSelected ? "fill-current opacity-80" : "text-muted-foreground",
175
+ )}
176
+ aria-hidden
177
+ />
178
+ <span className="min-w-0 flex-1 text-left">
179
+ <span className="block truncate leading-tight">{question.stem}</span>
180
+ <span className="block truncate font-mono text-[11px] text-muted-foreground">
181
+ {question.questionId}
182
+ </span>
179
183
  </span>
180
- </span>
181
- </button>
182
- </div>
183
- )
184
- })}
185
- </CollapsibleContent>
184
+ </OutlineTreeLeafButton>
185
+ </OutlineTreeSubItem>
186
+ )
187
+ })}
188
+ </OutlineTreeSub>
189
+ </OutlineTreeCollapsibleContentRail>
186
190
  )}
187
191
  </Collapsible>
188
192
  )
@@ -262,7 +266,7 @@ function DetailsPanel({ selectedItemId, folders, questions, onClearSelection }:
262
266
 
263
267
  return (
264
268
  <div className="flex h-full min-h-0 flex-col overflow-hidden bg-card">
265
- <header className="shrink-0 border-b border-border/60 bg-muted/10 px-4 pb-4 pt-3">
269
+ <header className="shrink-0 border-b border-border/60 bg-card px-4 pb-4 pt-3">
266
270
  <div className="flex items-start justify-between gap-3">
267
271
  <p className="font-mono text-xs text-muted-foreground">{question.questionId}</p>
268
272
  {onClearSelection ? (
@@ -517,8 +521,8 @@ function DetailsPanel({ selectedItemId, folders, questions, onClearSelection }:
517
521
  }
518
522
 
519
523
  return (
520
- <div className="flex h-full min-h-0 flex-col items-center justify-center bg-gradient-to-b from-muted/25 to-card px-6 py-10 text-center text-muted-foreground">
521
- <div className="mb-3 flex size-12 items-center justify-center rounded-xl border border-border/60 bg-muted/20">
524
+ <div className="flex h-full min-h-0 flex-col items-center justify-center bg-card px-6 py-10 text-center text-muted-foreground">
525
+ <div className="mb-3 flex size-12 items-center justify-center rounded-xl border border-border/60 bg-card">
522
526
  <FileIcon className="size-6 opacity-50" aria-hidden />
523
527
  </div>
524
528
  <p className="text-sm font-medium text-foreground">Item not found</p>
@@ -615,23 +619,27 @@ export function HubTreePanelView({
615
619
  }
616
620
  />
617
621
 
618
- <div className="min-h-0 flex-1 overflow-y-auto py-1" role="listbox" aria-label="Folder tree">
622
+ <OutlineTreeMenu
623
+ className="min-h-0 flex-1 overflow-y-auto py-1"
624
+ role="listbox"
625
+ aria-label="Folder tree"
626
+ >
619
627
  {rootFolders.length === 0 ? (
620
- <p className="px-3 py-4 text-sm text-muted-foreground">No folders</p>
628
+ <li className="list-none px-3 py-4 text-sm text-muted-foreground">No folders</li>
621
629
  ) : (
622
630
  rootFolders.map(folder => (
623
- <TreeItem
624
- key={folder.id}
625
- folder={folder}
626
- folders={folders}
627
- questions={items}
628
- selectedItemId={selectedItemId}
629
- depth={0}
630
- onSelectItem={setSelectedItemId}
631
- />
631
+ <OutlineTreeMenuItem key={folder.id}>
632
+ <TreeItem
633
+ folder={folder}
634
+ folders={folders}
635
+ questions={items}
636
+ selectedItemId={selectedItemId}
637
+ onSelectItem={setSelectedItemId}
638
+ />
639
+ </OutlineTreeMenuItem>
632
640
  ))
633
641
  )}
634
- </div>
642
+ </OutlineTreeMenu>
635
643
  </>
636
644
  }
637
645
  details={
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { useForm } from "react-hook-form"
4
+ import { useForm, useWatch } from "react-hook-form"
5
5
  import { z } from "zod"
6
6
  import { zodResolver } from "@hookform/resolvers/zod"
7
7
 
@@ -191,7 +191,9 @@ export function InviteCollaboratorsDrawer({
191
191
  access: "editor",
192
192
  },
193
193
  })
194
- const inviteAccess = form.watch("access")
194
+ // `useWatch` is memoization-friendly (returns a stable reactive value)
195
+ // unlike `form.watch()`, which the React Compiler can't memoize safely.
196
+ const inviteAccess = useWatch({ control: form.control, name: "access" })
195
197
  const [isSubmitting, setIsSubmitting] = React.useState(false)
196
198
  const [removeTarget, setRemoveTarget] = React.useState<PageHeaderCollaborator | null>(null)
197
199
 
@@ -226,7 +228,7 @@ export function InviteCollaboratorsDrawer({
226
228
  side="right"
227
229
  showCloseButton={false}
228
230
  showOverlay={false}
229
- className="w-80 sm:max-w-80 p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl"
231
+ className="z-[80] w-80 sm:max-w-80 p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl"
230
232
  style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100vh - 1rem)" }}
231
233
  onPointerDownOutside={event => {
232
234
  if (isOverlaySelectorSheetTarget(event.target)) {
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Variants:
7
7
  * "card" (default) — shadcn Card wrapper with brand gradient fill
8
- * "flat" — full-width brand gradient band, no card chrome
8
+ * "flat" — full-width soft tint band (brand-tint background) + bottom glow, no card chrome
9
9
  *
10
10
  * AA checklist:
11
11
  * ✓ Trend text never relies on colour alone — icon + label (WCAG 1.4.1)
@@ -198,23 +198,53 @@ export interface KeyMetricsProps {
198
198
  className?: string
199
199
  }
200
200
 
201
- /** Wrap KPI columns when the strip is narrow (high zoom, 5+ tiles) instead of squeezing cells. */
202
- const METRICS_GRID_TEMPLATE =
203
- "repeat(auto-fit, minmax(min(100%, 11.5rem), 1fr))"
204
-
205
- /** Equal columns in one row up to 4 KPIs beside an insight rail without premature wrap. */
206
- function metricsRowColumns(
207
- rowLength: number,
208
- metricsSingleRow: boolean,
209
- metricsHalfWidthLayout: boolean,
210
- ): string {
211
- if (metricsHalfWidthLayout) {
212
- return `repeat(${rowLength}, minmax(0, 1fr))`
213
- }
214
- if (metricsSingleRow) {
215
- return rowLength > 4 ? METRICS_GRID_TEMPLATE : `repeat(${rowLength}, minmax(0, 1fr))`
201
+ /**
202
+ * KPI grid column step patterns — Tailwind v4 container-query classes.
203
+ *
204
+ * We deliberately AVOID `repeat(auto-fit, minmax(...))` here because it
205
+ * produces awkward "N + leftover" layouts at intermediate widths (e.g. 3
206
+ * tiles in row 1 + 1 lonely tile in row 2 for a 4-KPI strip). Instead we
207
+ * step the column count through values that evenly divide the row size:
208
+ * 1 → 2 → 4 for a 4-KPI strip (3 is skipped on purpose).
209
+ *
210
+ * The breakpoints are container-query based (`@[Xrem]:…`) so they react to
211
+ * the metrics strip's OWN width, not the viewport — that's what makes the
212
+ * 2×2 fallback kick in when the primary sidebar + secondary panel are
213
+ * both open and the strip column is ~360 px wide, even on a 1280 px display.
214
+ *
215
+ * `metricsHalfWidthLayout` = strip shares its row with the insight rail
216
+ * (3fr / 2fr split). Tighter breakpoints because available width is ~60%
217
+ * of the section.
218
+ */
219
+ function metricsRowColumnsClass(rowLength: number, metricsHalfWidthLayout: boolean): string {
220
+ const half = metricsHalfWidthLayout
221
+ switch (rowLength) {
222
+ case 1:
223
+ return "grid-cols-1"
224
+ case 2:
225
+ return half
226
+ ? "grid-cols-1 @[14rem]:grid-cols-2"
227
+ : "grid-cols-1 @[18rem]:grid-cols-2"
228
+ case 3:
229
+ // 3 tiles divide evenly already — step 1 → 3.
230
+ return half
231
+ ? "grid-cols-1 @[18rem]:grid-cols-3"
232
+ : "grid-cols-1 @[24rem]:grid-cols-3"
233
+ case 4:
234
+ // Step 1 → 2 (2×2 grid) → 4. Skip 3 — that's the awkward 3+1 layout.
235
+ // Aggressive 4-col thresholds so the strip fits all four tiles even
236
+ // when the primary sidebar + secondary panel + insight rail are all
237
+ // expanded (typical question-bank layout puts the KPI grid at ~27rem).
238
+ return half
239
+ ? "grid-cols-1 @[14rem]:grid-cols-2 @[26rem]:grid-cols-4"
240
+ : "grid-cols-1 @[18rem]:grid-cols-2 @[30rem]:grid-cols-4"
241
+ default:
242
+ // 5+ KPIs (`exxat-kpi-max-four` caps the strip at 4, but key-metrics
243
+ // is a generic primitive — fall back to a sensible step). 1 → 2 → 3 → 6.
244
+ return half
245
+ ? "grid-cols-1 @[14rem]:grid-cols-2 @[26rem]:grid-cols-3 @[40rem]:grid-cols-6"
246
+ : "grid-cols-1 @[18rem]:grid-cols-2 @[30rem]:grid-cols-3 @[56rem]:grid-cols-6"
216
247
  }
217
- return `repeat(${rowLength}, minmax(0, 1fr))`
218
248
  }
219
249
 
220
250
  /* ── Default data ─────────────────────────────────────────────────────────── */
@@ -484,6 +514,8 @@ interface InnerProps {
484
514
  metricsHalfWidthLayout?: boolean
485
515
  /** Opaque fill behind each KPI cell when using hairline grid gaps (below `lg`). */
486
516
  metricsCellSurfaceClassName?: string
517
+ /** Flat list-page band: softer dividers + tinted cells on a lavender-tinted surface */
518
+ surfaceVariant?: "default" | "flat"
487
519
  }
488
520
 
489
521
  function KeyMetricsInner({
@@ -502,7 +534,12 @@ function KeyMetricsInner({
502
534
  metricsSingleRow = false,
503
535
  metricsHalfWidthLayout = false,
504
536
  metricsCellSurfaceClassName = "bg-background",
537
+ surfaceVariant = "default",
505
538
  }: InnerProps) {
539
+ const isFlatBand = surfaceVariant === "flat"
540
+ const metricsGridClassName = isFlatBand
541
+ ? "gap-0 bg-transparent [&>*:not(:last-child)]:border-r [&>*:not(:last-child)]:border-foreground/[0.055]"
542
+ : "gap-px bg-border"
506
543
  /** Side-by-side KPI + insight rail (md+). Disabled for half-width dashboard cards — insight stacks below. */
507
544
  const insightSideBySide = insight && !insightFullWidth && !metricsHalfWidthLayout
508
545
  const stackedRailInsight = insight && !insightFullWidth && metricsHalfWidthLayout
@@ -566,28 +603,26 @@ function KeyMetricsInner({
566
603
  */}
567
604
  {metricsHalfWidthLayout ? (
568
605
  <div
569
- className="grid grid-cols-2 divide-x divide-border lg:hidden"
570
- style={
606
+ className={cn(
607
+ "@container/metrics-strip grid lg:hidden",
571
608
  metricsSingleRow
572
- ? {
573
- gridTemplateColumns: metricsRowColumns(
574
- metrics.length,
575
- metricsSingleRow,
576
- metricsHalfWidthLayout,
577
- ),
578
- }
579
- : undefined
580
- }
609
+ ? metricsRowColumnsClass(metrics.length, /* half */ true)
610
+ : "grid-cols-2",
611
+ metricsGridClassName,
612
+ )}
581
613
  >
582
614
  {metrics.map((m) => (
583
- <MetricCell key={m.id} {...m} dense />
615
+ <div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
616
+ <MetricCell {...m} dense edgeGutter={false} />
617
+ </div>
584
618
  ))}
585
619
  </div>
586
620
  ) : (
587
621
  <div
588
622
  className={cn(
589
- "grid gap-px bg-border lg:hidden",
590
- "grid-cols-1 md:grid-cols-2",
623
+ "@container/metrics-strip grid lg:hidden",
624
+ metricsRowColumnsClass(metrics.length, /* half */ false),
625
+ metricsGridClassName,
591
626
  )}
592
627
  >
593
628
  {metrics.map((m) => (
@@ -598,25 +633,32 @@ function KeyMetricsInner({
598
633
  </div>
599
634
  )}
600
635
 
601
- {/* lg+: row-by-row 3-col with horizontal separator between rows */}
602
- <div className="hidden lg:block">
636
+ {/*
637
+ lg+: row-by-row container-queried grid. Uses a `gap-px + bg` hairline
638
+ instead of `divide-x` so dividers render correctly when the row wraps
639
+ from 4-across to a 2×2 grid (the awkward 3+1 layout is skipped — see
640
+ `metricsRowColumnsClass`).
641
+ */}
642
+ <div className="@container/metrics-strip hidden lg:block">
603
643
  {rows.map((row, rowIdx) => (
604
644
  <React.Fragment key={rowIdx}>
605
645
  {rowIdx > 0 && (
606
- <Separator aria-hidden="true" className="my-1" />
646
+ <Separator
647
+ aria-hidden="true"
648
+ className={cn("my-1", isFlatBand && "bg-foreground/[0.055]")}
649
+ />
607
650
  )}
608
651
  <div
609
- className="grid divide-x divide-border"
610
- style={{
611
- gridTemplateColumns: metricsRowColumns(
612
- row.length,
613
- metricsSingleRow,
614
- metricsHalfWidthLayout,
615
- ),
616
- }}
652
+ className={cn(
653
+ "grid",
654
+ metricsRowColumnsClass(row.length, metricsHalfWidthLayout),
655
+ metricsGridClassName,
656
+ )}
617
657
  >
618
658
  {row.map((m) => (
619
- <MetricCell key={m.id} {...m} dense={metricsHalfWidthLayout} />
659
+ <div key={m.id} className={cn("min-w-0", metricsCellSurfaceClassName)}>
660
+ <MetricCell {...m} dense={metricsHalfWidthLayout} edgeGutter={false} />
661
+ </div>
620
662
  ))}
621
663
  </div>
622
664
  </React.Fragment>
@@ -628,11 +670,20 @@ function KeyMetricsInner({
628
670
  {insight && (
629
671
  <>
630
672
  {insightFullWidth ? (
631
- <Separator aria-hidden="true" className="my-4 w-full" />
673
+ <Separator
674
+ aria-hidden="true"
675
+ className={cn("my-4 w-full", isFlatBand && "bg-foreground/[0.06]")}
676
+ />
632
677
  ) : stackedRailInsight ? (
633
- <Separator aria-hidden="true" className="my-4 w-full" />
678
+ <Separator
679
+ aria-hidden="true"
680
+ className={cn("my-4 w-full", isFlatBand && "bg-foreground/[0.06]")}
681
+ />
634
682
  ) : (
635
- <Separator aria-hidden="true" className="my-3 lg:hidden" />
683
+ <Separator
684
+ aria-hidden="true"
685
+ className={cn("my-3 lg:hidden", isFlatBand && "bg-foreground/[0.055]")}
686
+ />
636
687
  )}
637
688
 
638
689
  <div
@@ -641,7 +692,10 @@ function KeyMetricsInner({
641
692
  /* Divider + padding replace vertical Separator so grid stays 2 columns */
642
693
  insightSideBySide &&
643
694
  !insightFullWidth &&
644
- "lg:h-full lg:border-l lg:border-border lg:pl-6"
695
+ cn(
696
+ "lg:h-full lg:border-l lg:pl-6",
697
+ isFlatBand ? "lg:border-border/40" : "lg:border-border",
698
+ )
645
699
  )}
646
700
  >
647
701
  {insight && !insightFullWidth ? (
@@ -801,7 +855,8 @@ export function KeyMetrics({
801
855
  return out
802
856
  })()
803
857
 
804
- const metricsCellSurfaceClassName = variant === "flat" ? "bg-background" : "bg-card"
858
+ const metricsCellSurfaceClassName =
859
+ variant === "flat" ? "bg-transparent" : "bg-card"
805
860
 
806
861
  const innerProps: InnerProps = {
807
862
  title,
@@ -817,6 +872,7 @@ export function KeyMetrics({
817
872
  metricsSingleRow,
818
873
  metricsHalfWidthLayout,
819
874
  metricsCellSurfaceClassName,
875
+ surfaceVariant: variant === "flat" ? "flat" : "default",
820
876
  }
821
877
 
822
878
  /*
@@ -849,6 +905,15 @@ export function KeyMetrics({
849
905
  "radial-gradient(ellipse 110% 90% at 50% 100%, oklch(from var(--brand-color) l c h / 0.13) 0%, transparent 65%)",
850
906
  }
851
907
 
908
+ /** List-page KPI band: soft tint → page bg + gentle lift (avoids a hard line into the toolbar). */
909
+ const flatBandStyle: React.CSSProperties = {
910
+ background: [
911
+ "radial-gradient(ellipse 118% 70% at 50% 100%, oklch(from var(--brand-color) l c h / 0.11) 0%, transparent 60%)",
912
+ "linear-gradient(180deg, var(--key-metrics-flat-grad-top) 0%, var(--key-metrics-flat-grad-mid) 48%, var(--background) 100%)",
913
+ ].join(", "),
914
+ boxShadow: "0 18px 42px -26px color-mix(in oklch, var(--brand-color) 10%, transparent)",
915
+ }
916
+
852
917
  /* ── Card variant — ChartCard-style chrome ───────────────────────────── */
853
918
  if (variant === "card") {
854
919
  return (
@@ -917,12 +982,12 @@ export function KeyMetrics({
917
982
  )
918
983
  }
919
984
 
920
- /* ── Flat variant — full-width bottom-glow band ───────────────────────── */
985
+ /* ── Flat variant — soft tint band + bottom glow (no sharp cut to content below) ── */
921
986
  return (
922
987
  <section
923
988
  aria-label={title}
924
- className={cn("w-full py-5", className)}
925
- style={glowStyle}
989
+ className={cn("relative w-full overflow-hidden pt-5 pb-6", className)}
990
+ style={flatBandStyle}
926
991
  >
927
992
  <KeyMetricsInner
928
993
  {...innerProps}
@@ -25,6 +25,7 @@ import { useRouter } from "next/navigation"
25
25
  import {
26
26
  useForm,
27
27
  useFormContext,
28
+ useWatch,
28
29
  type ControllerRenderProps,
29
30
  type Resolver,
30
31
  } from "react-hook-form"
@@ -64,7 +65,6 @@ import {
64
65
  import { RadioGroup, RadioGroupItem, RadioGroupLabel } from "@/components/ui/radio-group"
65
66
  import { Card, CardHeader, CardTitle, CardAction, CardContent } from "@/components/ui/card"
66
67
  import { Kbd, KbdGroup } from "@/components/ui/kbd"
67
- import { Tip } from "@/components/ui/tip"
68
68
  import { Shortcut } from "@/components/ui/dropdown-menu"
69
69
  import { useModKeyLabel, useAltKeyLabel } from "@/hooks/use-mod-key-label"
70
70
 
@@ -967,7 +967,9 @@ export function NewPlacementForm() {
967
967
  router.push("/data-list")
968
968
  }
969
969
 
970
- const formData = form.watch()
970
+ // `useWatch` is memoization-friendly (returns a stable reactive value)
971
+ // unlike `form.watch()`, which the React Compiler can't memoize safely.
972
+ const formData = useWatch({ control: form.control })
971
973
  const mod = useModKeyLabel()
972
974
  const alt = useAltKeyLabel()
973
975