@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.
- package/CHANGELOG.md +23 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
- package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +1 -0
- package/src/globals.css +21 -2
- package/src/theme.css +4 -2
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +23 -18
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +27 -7
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +136 -2
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +141 -59
- package/template/components/ask-leo-sidebar.tsx +1 -4
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/finder-panel-view.tsx +2 -2
- package/template/components/data-views/index.ts +26 -3
- package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
- package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
- package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/data-views/outline-tree-menu.tsx +157 -0
- package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +173 -379
- package/template/components/folder-details-shell.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +88 -80
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +116 -51
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +21 -11
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +18 -132
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
- package/template/components/product-switcher.tsx +26 -11
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +130 -70
- package/template/components/question-bank-hub-client.tsx +108 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-page-header.tsx +18 -2
- package/template/components/question-bank-secondary-nav.tsx +12 -228
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +24 -4
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +56 -32
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
- package/template/components/templates/list-page.tsx +1 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/collaboration-access-pattern.md +2 -0
- package/template/docs/question-bank-hub-header-pattern.md +25 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/mock/navigation.tsx +30 -1
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +70 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +6 -6
- package/template/stores/app-store.ts +46 -1
- package/template/components/command-menu-01.tsx +0 -133
- 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-
|
|
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,
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
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="
|
|
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
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
key={child.id}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
</
|
|
181
|
-
</
|
|
182
|
-
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
</
|
|
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-
|
|
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-
|
|
521
|
-
<div className="mb-3 flex size-12 items-center justify-center rounded-xl border border-border/60 bg-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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=
|
|
570
|
-
|
|
606
|
+
className={cn(
|
|
607
|
+
"@container/metrics-strip grid lg:hidden",
|
|
571
608
|
metricsSingleRow
|
|
572
|
-
?
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
<
|
|
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
|
-
"
|
|
590
|
-
|
|
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
|
-
{/*
|
|
602
|
-
|
|
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
|
|
646
|
+
<Separator
|
|
647
|
+
aria-hidden="true"
|
|
648
|
+
className={cn("my-1", isFlatBand && "bg-foreground/[0.055]")}
|
|
649
|
+
/>
|
|
607
650
|
)}
|
|
608
651
|
<div
|
|
609
|
-
className=
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
<
|
|
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
|
|
673
|
+
<Separator
|
|
674
|
+
aria-hidden="true"
|
|
675
|
+
className={cn("my-4 w-full", isFlatBand && "bg-foreground/[0.06]")}
|
|
676
|
+
/>
|
|
632
677
|
) : stackedRailInsight ? (
|
|
633
|
-
<Separator
|
|
678
|
+
<Separator
|
|
679
|
+
aria-hidden="true"
|
|
680
|
+
className={cn("my-4 w-full", isFlatBand && "bg-foreground/[0.06]")}
|
|
681
|
+
/>
|
|
634
682
|
) : (
|
|
635
|
-
<Separator
|
|
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
|
-
|
|
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 =
|
|
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 —
|
|
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
|
|
925
|
-
style={
|
|
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
|
-
|
|
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
|
|