@exxatdesignux/ui 0.2.17 → 0.2.19

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 (162) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/consumer-extras/AGENTS.md +76 -0
  3. package/consumer-extras/README.md +5 -1
  4. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
  5. package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +22 -7
  7. package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
  8. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  9. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -3
  10. package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
  11. package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
  12. package/consumer-extras/patterns/data-views-pattern.md +42 -3
  13. package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
  14. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  15. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +54 -0
  16. package/package.json +2 -1
  17. package/src/components/ui/button-group.tsx +81 -0
  18. package/src/components/ui/button.tsx +4 -4
  19. package/src/components/ui/sidebar.tsx +2 -2
  20. package/src/globals.css +7 -1807
  21. package/src/theme.css +10 -1126
  22. package/src/tokens/README.md +15 -0
  23. package/src/tokens/base.css +337 -0
  24. package/src/tokens/high-contrast.css +1195 -0
  25. package/src/tokens/layers.css +224 -0
  26. package/src/tokens/tailwind-bridge.css +118 -0
  27. package/src/tokens/themes.css +201 -0
  28. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  29. package/template/AGENTS.md +66 -21
  30. package/template/app/(app)/dashboard/loading.tsx +3 -15
  31. package/template/app/(app)/dashboard/page.tsx +2 -14
  32. package/template/app/(app)/data-list/layout.tsx +43 -0
  33. package/template/app/(app)/data-list/page.tsx +2 -2
  34. package/template/app/(app)/error.tsx +22 -6
  35. package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
  36. package/template/app/(app)/examples/page.tsx +1 -0
  37. package/template/app/(app)/layout.tsx +13 -6
  38. package/template/app/(app)/loading.tsx +1 -18
  39. package/template/app/(app)/question-bank/find/page.tsx +2 -1
  40. package/template/app/(app)/question-bank/library/page.tsx +2 -1
  41. package/template/app/(app)/question-bank/list/page.tsx +2 -1
  42. package/template/app/(app)/question-bank/new/page.tsx +15 -23
  43. package/template/app/(app)/question-bank/page.tsx +2 -1
  44. package/template/app/(app)/settings/page.tsx +4 -5
  45. package/template/app/global-error.tsx +63 -0
  46. package/template/app/globals.css +7 -1934
  47. package/template/app/layout.tsx +2 -0
  48. package/template/components/app-route-loading.tsx +14 -0
  49. package/template/components/app-sidebar.tsx +71 -55
  50. package/template/components/data-table/index.tsx +31 -67
  51. package/template/components/data-table/use-table-state.ts +33 -6
  52. package/template/components/data-views/index.ts +37 -9
  53. package/template/components/data-views/list-page-calendar-view.tsx +593 -0
  54. package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
  55. package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
  56. package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
  57. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  58. package/template/components/examples/focused-workflow-showcase.tsx +183 -0
  59. package/template/components/exxat-product-logo.tsx +2 -6
  60. package/template/components/key-metrics.tsx +54 -22
  61. package/template/components/list-hub-board-view.tsx +68 -0
  62. package/template/components/list-hub-client.tsx +186 -0
  63. package/template/components/list-hub-list-view.tsx +36 -0
  64. package/template/components/list-hub-panel-activator.tsx +8 -0
  65. package/template/components/list-hub-secondary-nav.tsx +121 -0
  66. package/template/components/list-hub-table.tsx +336 -0
  67. package/template/components/new-question-composer.tsx +6 -24
  68. package/template/components/product-switcher.tsx +5 -5
  69. package/template/components/product-wordmark.tsx +4 -7
  70. package/template/components/question-bank-client.tsx +4 -1
  71. package/template/components/question-bank-folder-columns-panel.tsx +104 -0
  72. package/template/components/question-bank-hub-client.tsx +2 -5
  73. package/template/components/question-bank-table.tsx +155 -509
  74. package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
  75. package/template/components/secondary-panel.tsx +4 -44
  76. package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
  77. package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
  78. package/template/components/secondary-panels/registry.tsx +15 -0
  79. package/template/components/settings-appearance-card.tsx +3 -2
  80. package/template/components/settings-client.tsx +59 -15
  81. package/template/components/settings-form-row.tsx +9 -4
  82. package/template/components/sidebar-shell.tsx +2 -1
  83. package/template/components/table-properties/drawer-button.tsx +51 -20
  84. package/template/components/table-properties/drawer.tsx +81 -17
  85. package/template/components/templates/focused-workflow-layouts.tsx +448 -0
  86. package/template/components/templates/focused-workflow-page-template.tsx +69 -0
  87. package/template/components/templates/list-page.tsx +40 -13
  88. package/template/components/templates/nested-secondary-panel-shell.tsx +3 -2
  89. package/template/components/templates/page-loading-shell.tsx +262 -0
  90. package/template/components/ui/button-group.tsx +1 -0
  91. package/template/contexts/product-context.tsx +21 -2
  92. package/template/docs/consumer-app-pattern.md +39 -0
  93. package/template/docs/data-views-pattern.md +42 -3
  94. package/template/docs/drawer-vs-dialog-pattern.md +3 -1
  95. package/template/docs/focused-workflow-page-pattern.md +84 -0
  96. package/template/docs/kpi-flat-band-pattern.md +57 -0
  97. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  98. package/template/docs/shell-surface-elevation-pattern.md +54 -0
  99. package/template/lib/chunk-load-error.ts +13 -0
  100. package/template/lib/command-menu-search-data.ts +11 -27
  101. package/template/lib/conditional-rule-match.ts +87 -22
  102. package/template/lib/data-list-display-options.ts +16 -2
  103. package/template/lib/data-list-view-registry.ts +104 -0
  104. package/template/lib/data-list-view-surface.ts +15 -1
  105. package/template/lib/data-list-view.ts +16 -1
  106. package/template/lib/data-view-dashboard-storage.ts +38 -35
  107. package/template/lib/hub-connected-view-renderers.ts +58 -0
  108. package/template/lib/list-hub-nav.ts +121 -0
  109. package/template/lib/list-hub-supported-views.ts +10 -0
  110. package/template/lib/list-page-table-properties.ts +3 -7
  111. package/template/lib/list-status-badges.ts +4 -97
  112. package/template/lib/mock/list-hub-directory.ts +27 -0
  113. package/template/lib/mock/list-hub-kpi.ts +27 -0
  114. package/template/lib/mock/navigation.tsx +1 -0
  115. package/template/lib/page-loading-variant.ts +40 -0
  116. package/template/lib/question-bank-supported-views.ts +13 -0
  117. package/template/lib/sidebar-state-cookie.ts +9 -0
  118. package/template/lib/table-state-lifecycle.ts +60 -13
  119. package/template/app/(app)/data-list/[id]/page.tsx +0 -44
  120. package/template/app/(app)/data-list/new/page.tsx +0 -34
  121. package/template/components/compliance-board-view.tsx +0 -142
  122. package/template/components/compliance-client.tsx +0 -92
  123. package/template/components/compliance-list-view.tsx +0 -54
  124. package/template/components/compliance-page-header.tsx +0 -89
  125. package/template/components/compliance-table.tsx +0 -632
  126. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  127. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  128. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  129. package/template/components/new-placement-back-btn.tsx +0 -28
  130. package/template/components/new-placement-form.tsx +0 -1068
  131. package/template/components/placement-board-card.tsx +0 -262
  132. package/template/components/placement-detail.tsx +0 -438
  133. package/template/components/placements-board-view.tsx +0 -404
  134. package/template/components/placements-client.tsx +0 -252
  135. package/template/components/placements-list-view.tsx +0 -171
  136. package/template/components/placements-page-header.tsx +0 -166
  137. package/template/components/placements-table-cells.test.tsx +0 -22
  138. package/template/components/placements-table-cells.tsx +0 -173
  139. package/template/components/placements-table-columns.tsx +0 -640
  140. package/template/components/placements-table.tsx +0 -1675
  141. package/template/components/rotations-empty-state.tsx +0 -50
  142. package/template/components/rotations-panel-activator.tsx +0 -8
  143. package/template/components/sites-all-client.tsx +0 -154
  144. package/template/components/sites-board-view.tsx +0 -67
  145. package/template/components/sites-list-view.tsx +0 -42
  146. package/template/components/sites-table.tsx +0 -402
  147. package/template/components/team-board-view.tsx +0 -122
  148. package/template/components/team-client.tsx +0 -100
  149. package/template/components/team-list-view.tsx +0 -59
  150. package/template/components/team-page-header.tsx +0 -92
  151. package/template/components/team-table.tsx +0 -714
  152. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  153. package/template/lib/mock/compliance-kpi.ts +0 -61
  154. package/template/lib/mock/compliance.ts +0 -146
  155. package/template/lib/mock/placements-kpi.ts +0 -134
  156. package/template/lib/mock/placements.ts +0 -183
  157. package/template/lib/mock/sites-directory.ts +0 -16
  158. package/template/lib/mock/sites-kpi.ts +0 -25
  159. package/template/lib/mock/team-kpi.ts +0 -60
  160. package/template/lib/mock/team.ts +0 -118
  161. package/template/lib/placement-board-card-layout.ts +0 -79
  162. package/template/lib/placement-lifecycle.ts +0 -5
@@ -0,0 +1,336 @@
1
+ "use client"
2
+
3
+ /**
4
+ * List hub table — `DataTable` + `useTableState`; table | list | board | calendar | dashboard
5
+ * share `tableState.rows` (centralized dataset).
6
+ */
7
+
8
+ import * as React from "react"
9
+ import type { ListHubRecord } from "@/lib/mock/list-hub-directory"
10
+ import { DataTable, DataTableToolbar } from "@/components/data-table"
11
+ import { useTableState } from "@/components/data-table/use-table-state"
12
+ import type { ColumnDef } from "@/components/data-table/types"
13
+ import type { DataListViewType } from "@/lib/data-list-view"
14
+ import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
15
+ import { LIST_HUB_SUPPORTED_VIEWS } from "@/lib/list-hub-supported-views"
16
+ import { TablePropertiesDrawerButton } from "@/components/table-properties"
17
+ import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
18
+ import { Button } from "@/components/ui/button"
19
+ import { Tip } from "@/components/ui/tip"
20
+ import { FinderPanelView, type FinderGroup } from "@/components/data-views/finder-panel-view"
21
+ import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
22
+ import { ListPageConnectedViewBody } from "@/components/data-views/list-page-connected-view-body"
23
+ import { defineHubViewRenderers } from "@/lib/hub-connected-view-renderers"
24
+ import { ListPageCalendarView } from "@/components/data-views/list-page-calendar-view"
25
+ import { ListHubCardGrid } from "@/components/list-hub-board-view"
26
+ import { ListHubListView } from "@/components/list-hub-list-view"
27
+ import {
28
+ DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
29
+ type DataListDisplayOptions,
30
+ } from "@/lib/data-list-display-options"
31
+
32
+ function columnToFilterFieldDef(c: ColumnDef<ListHubRecord>): FilterFieldDef | null {
33
+ if (!c.filter) return null
34
+ const f = c.filter
35
+ const defaultOps: FilterOperator[] =
36
+ f.type === "select" || f.type === "date" ? ["is", "is_not"] : ["contains", "not_contains"]
37
+ return {
38
+ key: c.key,
39
+ label: c.label,
40
+ icon: f.icon ?? "fa-filter",
41
+ type: f.type,
42
+ operators: (f.operators ?? defaultOps) as FilterOperator[],
43
+ options: f.options,
44
+ ...(f.textMask ? { textMask: f.textMask } : {}),
45
+ }
46
+ }
47
+
48
+ function columnsToFilterFields(cols: ColumnDef<ListHubRecord>[]) {
49
+ return cols.map(columnToFilterFieldDef).filter((x): x is FilterFieldDef => x !== null)
50
+ }
51
+
52
+ function buildListHubColumns(): ColumnDef<ListHubRecord>[] {
53
+ return [
54
+ {
55
+ key: "select",
56
+ label: "",
57
+ width: 40,
58
+ minWidth: 40,
59
+ defaultPin: "left",
60
+ lockPin: true,
61
+ },
62
+ {
63
+ key: "title",
64
+ label: "Title",
65
+ width: 280,
66
+ minWidth: 160,
67
+ sortable: true,
68
+ sortKey: "title",
69
+ filter: {
70
+ type: "text",
71
+ icon: "fa-file-lines",
72
+ operators: ["contains", "not_contains"],
73
+ },
74
+ cell: row => <span className="truncate text-sm font-medium text-foreground">{row.title}</span>,
75
+ },
76
+ {
77
+ key: "id",
78
+ label: "ID",
79
+ width: 120,
80
+ minWidth: 100,
81
+ sortable: true,
82
+ sortKey: "id",
83
+ filter: {
84
+ type: "text",
85
+ icon: "fa-hashtag",
86
+ operators: ["contains", "not_contains"],
87
+ },
88
+ cell: row => <span className="font-mono text-sm tabular-nums text-foreground/90">{row.id}</span>,
89
+ },
90
+ {
91
+ key: "eventDate",
92
+ label: "Event date",
93
+ width: 140,
94
+ minWidth: 120,
95
+ sortable: true,
96
+ sortKey: "eventDate",
97
+ filter: {
98
+ type: "date",
99
+ icon: "fa-calendar-days",
100
+ operators: ["is", "is_not"],
101
+ },
102
+ cell: row => <span className="text-sm tabular-nums text-foreground">{row.eventDate}</span>,
103
+ },
104
+ {
105
+ key: "category",
106
+ label: "Category",
107
+ width: 140,
108
+ minWidth: 100,
109
+ sortable: true,
110
+ sortKey: "category",
111
+ filter: {
112
+ type: "text",
113
+ icon: "fa-tag",
114
+ operators: ["contains", "not_contains"],
115
+ },
116
+ cell: row => <span className="text-sm text-muted-foreground">{row.category}</span>,
117
+ },
118
+ {
119
+ key: "actions",
120
+ label: "",
121
+ width: 48,
122
+ minWidth: 48,
123
+ defaultPin: "right",
124
+ lockPin: true,
125
+ cell: () => null,
126
+ },
127
+ ]
128
+ }
129
+
130
+ export type ListHubTableHandle = OpenTablePropertiesHandle
131
+
132
+ export const ListHubTable = React.forwardRef<
133
+ ListHubTableHandle,
134
+ {
135
+ rows: ListHubRecord[]
136
+ view?: DataListViewType
137
+ onViewChange?: (v: DataListViewType) => void
138
+ /** Aligns Properties view tiles with `ListPageTemplate` `supportedViewTypes`. */
139
+ supportedViewTypes?: readonly DataListViewType[]
140
+ }
141
+ >(function ListHubTable(
142
+ { rows, view = "board", onViewChange, supportedViewTypes = LIST_HUB_SUPPORTED_VIEWS },
143
+ ref,
144
+ ) {
145
+ const columns = React.useMemo(() => buildListHubColumns(), [])
146
+ const filterFields = React.useMemo(() => columnsToFilterFields(columns), [columns])
147
+ const fieldDefinitionsForDrawer = React.useMemo(
148
+ () =>
149
+ columns
150
+ .filter(c => c.key !== "select" && c.key !== "actions")
151
+ .map(c => ({ key: c.key, label: c.label, sortable: !!(c.sortable && (c.sortKey ?? c.key)) })),
152
+ [columns],
153
+ )
154
+
155
+ const resolveColumnLabel = React.useCallback(
156
+ (key: string) => columns.find(c => c.key === key)?.label ?? key,
157
+ [columns],
158
+ )
159
+
160
+ const [displayOptions, setDisplayOptions] = React.useState<DataListDisplayOptions>(
161
+ DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
162
+ )
163
+ const patchDisplay = React.useCallback((patch: Partial<DataListDisplayOptions>) => {
164
+ setDisplayOptions(prev => ({ ...prev, ...patch }))
165
+ }, [])
166
+
167
+ const [conditionalRules, setConditionalRules] = React.useState<ConditionalRule[]>([])
168
+
169
+ const addConditionalRule = React.useCallback((rule: Omit<ConditionalRule, "id">) => {
170
+ setConditionalRules(prev => [...prev, { ...rule, id: `cr-${Date.now()}` }])
171
+ }, [])
172
+ const removeConditionalRule = React.useCallback((id: string) => {
173
+ setConditionalRules(prev => prev.filter(r => r.id !== id))
174
+ }, [])
175
+ const updateConditionalRule = React.useCallback((id: string, patch: Partial<ConditionalRule>) => {
176
+ setConditionalRules(prev => prev.map(r => (r.id === id ? { ...r, ...patch } : r)))
177
+ }, [])
178
+
179
+ const tableState = useTableState<ListHubRecord>(rows, columns, { key: "title", dir: "asc" })
180
+
181
+ React.useImperativeHandle(
182
+ ref,
183
+ () => ({
184
+ openPropertiesDrawer: () => {
185
+ tableState.setSheetOpen(true)
186
+ },
187
+ }),
188
+ [tableState.setSheetOpen],
189
+ )
190
+
191
+ const panelGroupsBuilder = (filtered: ListHubRecord[]): FinderGroup[] => [
192
+ { id: "all", label: `All records (${filtered.length})`, count: filtered.length },
193
+ ]
194
+
195
+ const panelRenderListRow = (row: ListHubRecord) => (
196
+ <div className="min-w-0 flex-1">
197
+ <p className="truncate text-sm font-medium text-foreground">{row.title}</p>
198
+ <p className="truncate text-xs text-muted-foreground">{row.category}</p>
199
+ </div>
200
+ )
201
+
202
+ const panelRenderDetail = (row: ListHubRecord) => (
203
+ <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4">
204
+ <div>
205
+ <h3 className="mb-1 text-sm font-semibold text-foreground">{row.title}</h3>
206
+ <p className="text-xs text-muted-foreground">{row.category}</p>
207
+ </div>
208
+ <dl className="grid gap-2 text-sm">
209
+ <div>
210
+ <dt className="text-xs font-medium text-muted-foreground">ID</dt>
211
+ <dd className="font-mono tabular-nums">{row.id}</dd>
212
+ </div>
213
+ <div>
214
+ <dt className="text-xs font-medium text-muted-foreground">Event date</dt>
215
+ <dd className="tabular-nums">{row.eventDate}</dd>
216
+ </div>
217
+ </dl>
218
+ </div>
219
+ )
220
+
221
+ const drawerToolbarProps = {
222
+ state: tableState,
223
+ totalRows: rows.length,
224
+ filterFields,
225
+ fieldDefinitions: fieldDefinitionsForDrawer,
226
+ resolveColumnLabel,
227
+ displayOptions,
228
+ onDisplayOptionsChange: patchDisplay,
229
+ conditionalRules,
230
+ onAddConditionalRule: addConditionalRule,
231
+ onRemoveConditionalRule: removeConditionalRule,
232
+ onUpdateConditionalRule: updateConditionalRule,
233
+ currentView: view,
234
+ onViewChange,
235
+ supportedViewTypes,
236
+ lifecycleTabLabel: "List hub",
237
+ }
238
+
239
+ const tableProps = {
240
+ data: rows,
241
+ columns,
242
+ getRowId: (row: ListHubRecord) => row.id,
243
+ getRowSelectionLabel: (row: ListHubRecord) => row.title,
244
+ selectable: true,
245
+ searchable: displayOptions.showToolbarSearch,
246
+ showColumnHeaders: displayOptions.showColumnLabels,
247
+ groupable: true,
248
+ defaultSort: { key: "title", dir: "asc" as const },
249
+ emptyState: <p className="text-sm text-muted-foreground">No records match your filters.</p>,
250
+ conditionalRules,
251
+ state: tableState,
252
+ toolbarSlot: (s: ReturnType<typeof useTableState<ListHubRecord>>) => (
253
+ <TablePropertiesDrawerButton {...drawerToolbarProps} state={s} />
254
+ ),
255
+ bulkActionsSlot: (selected: Set<string | number>) => {
256
+ const n = selected.size
257
+ if (n === 0) return null
258
+ return (
259
+ <>
260
+ <span className="sr-only">{n} selected</span>
261
+ <Tip label="Export selection (demo)">
262
+ <Button size="sm" variant="outline" type="button">
263
+ <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
264
+ Export
265
+ </Button>
266
+ </Tip>
267
+ </>
268
+ )
269
+ },
270
+ }
271
+
272
+ const sharedToolbar = (
273
+ <DataTableToolbar
274
+ state={tableState}
275
+ columns={columns}
276
+ searchable={displayOptions.showToolbarSearch}
277
+ searchAriaLabel="Search records"
278
+ toolbarSlot={s => <TablePropertiesDrawerButton {...drawerToolbarProps} state={s} />}
279
+ />
280
+ )
281
+
282
+ const toolbarShell = (body: React.ReactNode) => (
283
+ <div className="flex min-h-0 flex-1 flex-col">
284
+ {sharedToolbar}
285
+ {body}
286
+ </div>
287
+ )
288
+
289
+ return (
290
+ <ListPageConnectedViewBody
291
+ view={view}
292
+ hubLabel="List hub"
293
+ renderers={defineHubViewRenderers(LIST_HUB_SUPPORTED_VIEWS, {
294
+ "data-table": (
295
+ <div className="pb-6">
296
+ <DataTable<ListHubRecord> {...tableProps} />
297
+ </div>
298
+ ),
299
+ "list-with-toolbar": toolbarShell(<ListHubListView rows={tableState.rows} />),
300
+ "board-with-toolbar": toolbarShell(<ListHubCardGrid rows={tableState.rows} />),
301
+ "calendar-with-toolbar": toolbarShell(
302
+ <ListPageCalendarView
303
+ rows={tableState.rows}
304
+ getRowId={row => row.id}
305
+ getEventDate={row => row.eventDate}
306
+ getEventLabel={row => row.title}
307
+ getEventMeta={row => row.category}
308
+ emptyMonthLabel="No events on this day."
309
+ ariaLabel="List hub calendar"
310
+ showSummaryPanel={displayOptions.showCalendarSummaryPanel}
311
+ calendarMainView={displayOptions.calendarMainView}
312
+ onCalendarMainViewChange={v => patchDisplay({ calendarMainView: v })}
313
+ />,
314
+ ),
315
+ "panel-with-toolbar": toolbarShell(
316
+ <ListPageSplitHubChrome aria-label="List hub panel view">
317
+ <FinderPanelView<ListHubRecord>
318
+ embedded
319
+ groupsColumnTitle="Records"
320
+ groups={panelGroupsBuilder(tableState.rows)}
321
+ rows={tableState.rows}
322
+ getRowId={row => row.id}
323
+ getRowGroupId={() => "all"}
324
+ autoSaveId="list-hub-panel-view"
325
+ renderListRow={panelRenderListRow}
326
+ renderDetail={panelRenderDetail}
327
+ emptyList={<p className="text-sm text-muted-foreground">No records found.</p>}
328
+ />
329
+ </ListPageSplitHubChrome>
330
+ ),
331
+ })}
332
+ />
333
+ )
334
+ })
335
+
336
+ ListHubTable.displayName = "ListHubTable"
@@ -7,21 +7,10 @@
7
7
  *
8
8
  * ├─ PageHeader (title + actions; parent trail is in `SiteHeader`)
9
9
  * │ · "New question" + "V1 · Last updated …" subtitle
10
- * │ · single primary CTA Save as draft ()
11
- * │ · overflow menu (⌘⌥M) for inspector toggle + discard
12
- * ├─ 2-column layout (lg+): page scrolls as one; inspector card `sticky` on lg+
13
- * │ ┌─ Builder (left, no card chrome)
14
- * │ │ · Question prompt (h1-style Textarea — type-aware)
15
- * │ │ · Answer block — varies by question type
16
- * │ │ · Explanation / rubric / model answer
17
- * │ │ · References (repeatable list)
18
- * │ └─ Inspector (right, bg-card panel)
19
- * │ · Question format (SelectionTileGrid → compact)
20
- * │ · Location (folder SelectionTileGrid)
21
- * │ · Difficulty / Bloom / NBME (chips)
22
- * │ · Tags (Input + Badge list)
23
- * │ Sidebar-style collapse (⌘⌥]) — collapsed rail mimics
24
- * │ `NestedSecondaryPanelShell` icon mode.
10
+ * │ · Save question (⏎) + Save as draft + ⋯ discard (⌘⌥M)
11
+ * └─ Single column (`FocusedWorkflowPageTemplate` see `docs/focused-workflow-page-pattern.md`)
12
+ * · Details format, folder, difficulty, Bloom, NBME, tags
13
+ * · Question prompt, answer block, explanation, references
25
14
  *
26
15
  * Composes existing primitives — `PageHeader`, `Form`/`FormField`,
27
16
  * `Input`, `Textarea`, `Checkbox`, `Badge`, `Button`, `Tip`, `Kbd`,
@@ -776,11 +765,9 @@ export function NewQuestionComposer({
776
765
  const router = useRouter()
777
766
  const [submitting, setSubmitting] = React.useState(false)
778
767
  const [tagDraft, setTagDraft] = React.useState("")
779
- const [inspectorOpen, setInspectorOpen] = React.useState(true)
780
768
  const [moreOpen, setMoreOpen] = React.useState(false)
781
- /** Question-type chooser visibility — collapses into a single
782
- "selected type" tile once the author picks the first time so the
783
- inspector stays compact for the rest of the authoring flow. */
769
+ const [inspectorOpen, setInspectorOpen] = React.useState(true)
770
+ /** Question-type chooser collapses to a compact row after first pick. */
784
771
  const [typeChooserOpen, setTypeChooserOpen] = React.useState(true)
785
772
  /** Local folder list — extended in-place when the author adds one
786
773
  from the location picker so the new entry is selectable without
@@ -1102,11 +1089,6 @@ export function NewQuestionComposer({
1102
1089
  disabled={submitting}
1103
1090
  onInvoke={() => setMoreOpen(o => !o)}
1104
1091
  />
1105
- <Shortcut
1106
- keys="⌘⌥]"
1107
- disabled={submitting}
1108
- onInvoke={() => setInspectorOpen(o => !o)}
1109
- />
1110
1092
 
1111
1093
  <form
1112
1094
  onSubmit={form.handleSubmit(values => persist({ ...values, status: "in_review" }, "publish"))}
@@ -52,6 +52,7 @@ export function ProductSwitcher() {
52
52
  <DropdownMenuTrigger asChild>
53
53
  <SidebarMenuButton
54
54
  size="lg"
55
+ tooltip={iconRail ? current.label : undefined}
55
56
  className={cn(
56
57
  "items-start py-2 text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
57
58
  expandedOrMobile &&
@@ -64,10 +65,9 @@ export function ProductSwitcher() {
64
65
  suppressHydrationWarning
65
66
  >
66
67
  {iconRail ? (
67
- // Collapsed icon-rail product mark must read as a peer of the
68
- // school selector, without a white cutout patch on the rail.
68
+ // Collapsed icon-rail product mark same frame as school avatar.
69
69
  <span className="flex size-8 shrink-0 items-center justify-center">
70
- <ExxatProductMark product={current.id} className="size-8" cutoutColor="var(--sidebar)" />
70
+ <ExxatProductMark product={current.id} className="size-7" />
71
71
  </span>
72
72
  ) : (
73
73
  <>
@@ -92,8 +92,8 @@ export function ProductSwitcher() {
92
92
 
93
93
  <DropdownMenuContent
94
94
  align="start"
95
- side="bottom"
96
- sideOffset={4}
95
+ side={iconRail ? "right" : "bottom"}
96
+ sideOffset={iconRail ? 8 : 4}
97
97
  >
98
98
  <DropdownMenuLabel className="text-xs text-muted-foreground">
99
99
  Switch product
@@ -16,9 +16,8 @@
16
16
  * recolored with the brand's gradient / fill. The SVG geometry stays
17
17
  * constant so existing layouts keep working.
18
18
  *
19
- * `variant="mutedSuffix"` (used by the product switcher / sidebar): in **dark**
20
- * mode only, the suffix tints to `--muted-foreground` so the wordmark recedes
21
- * into the rail. Light mode keeps the brand color for recognition.
19
+ * `variant="mutedSuffix"` (sidebar / switcher): prefix recedes in dark mode;
20
+ * suffix always uses `wordmarkColor` (Exxat pink) for brand recognition.
22
21
  */
23
22
 
24
23
  import * as React from "react"
@@ -50,7 +49,6 @@ export function ProductWordmark({
50
49
  }: ProductWordmarkProps) {
51
50
  const prefix = config.prefix ?? "Exxat"
52
51
  const { suffix, brandColor, wordmarkColor } = config
53
- const mutedSuffix = variant === "mutedSuffix"
54
52
  const suffixColor = wordmarkColor ?? brandColor
55
53
 
56
54
  return (
@@ -100,14 +98,13 @@ export function ProductWordmark({
100
98
  // that read as a logo; pushing to 700/800 makes the letterforms
101
99
  // visually heavier than the brand asset.
102
100
  "ms-[0.18em] font-semibold tracking-[-0.03em]",
103
- // mutedSuffix: dark mode recedes to muted; light mode keeps brand.
104
- mutedSuffix && "dark:!text-[var(--muted-foreground)]",
105
101
  )}
106
102
  style={{
107
103
  // Ivy Presto Text from Adobe Fonts. Upright (NOT italic) — matches
108
104
  // the official Exxat wordmark. Fallback chain ends in `serif` so
109
105
  // FOUT still renders a serif that reads as a logo rather than Inter.
110
106
  fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
107
+ // `wordmarkColor` (Exxat pink) in light and dark — never muted to grey.
111
108
  color: suffixColor,
112
109
  }}
113
110
  >
@@ -155,7 +152,7 @@ function useBrowserPaintReady() {
155
152
  * Fills:
156
153
  * - Outer circle: `markGradient` if provided, else flat `brandColor`.
157
154
  * - Inner shadow plate: `markShadow` (defaults to `brandColor`).
158
- * - Cut-out "E" strokes: white by default; callers can override on tinted rails.
155
+ * - Cut-out "E" strokes: always white in product chrome (sidebar / switcher).
159
156
  */
160
157
  export function ProductMark({ config, className, cutoutColor = "white" }: ProductMarkProps) {
161
158
  const ready = useBrowserPaintReady()
@@ -27,6 +27,7 @@ import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
27
27
  import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
28
28
  import { DEFAULT_QUESTION_BANK_FOLDERS, type QuestionBankFolder } from "@/lib/mock/question-bank-folders"
29
29
  import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
30
+ import { QUESTION_BANK_SUPPORTED_VIEWS } from "@/lib/question-bank-supported-views"
30
31
  import {
31
32
  applyQuestionBankHubDisplayFilters,
32
33
  isQuestionBankDefaultNav,
@@ -122,7 +123,6 @@ export function QuestionBankClient() {
122
123
 
123
124
  // Stable Set of tab ids — defaults are constant so this only updates if tabs change.
124
125
  const tabIds = React.useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
125
-
126
126
  // Keep the latest pathname / searchParamsKey / tabIds available to the (stable) hashchange
127
127
  // listener via refs, so we don't re-subscribe a window listener on every URL change.
128
128
  const navRef = React.useRef({ pathname, searchParamsKey, tabIds, hubBasePath })
@@ -314,6 +314,7 @@ export function QuestionBankClient() {
314
314
  onFoldersChange={setFolders}
315
315
  onItemsChange={setItems}
316
316
  view={tab.viewType}
317
+ supportedViewTypes={QUESTION_BANK_SUPPORTED_VIEWS}
317
318
  onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
318
319
  />
319
320
  )}
@@ -417,6 +418,7 @@ export function QuestionBankClient() {
417
418
  />
418
419
  )}
419
420
  showMetrics={showMetrics}
421
+ supportedViewTypes={QUESTION_BANK_SUPPORTED_VIEWS}
420
422
  exportOpen={exportOpen}
421
423
  onExportOpenChange={setExportOpen}
422
424
  exportTotalRows={count}
@@ -433,6 +435,7 @@ export function QuestionBankClient() {
433
435
  onFoldersChange={setFolders}
434
436
  onItemsChange={setItems}
435
437
  view={tab.viewType}
438
+ supportedViewTypes={QUESTION_BANK_SUPPORTED_VIEWS}
436
439
  onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
437
440
  />
438
441
  )}
@@ -0,0 +1,104 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Button } from "@/components/ui/button"
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuTrigger,
10
+ } from "@/components/ui/dropdown-menu"
11
+ import { cn } from "@/lib/utils"
12
+ import { ListPageFolderColumnsPanel } from "@/components/data-views/list-page-folder-columns-panel"
13
+ import { FolderDetailsShell } from "@/components/folder-details-shell"
14
+ import type { QuestionBankItem } from "@/lib/mock/question-bank"
15
+ import {
16
+ type QuestionBankFolder,
17
+ QUESTION_BANK_FOLDER_COLOR_STYLES,
18
+ QUESTION_BANK_FOLDER_ICON_COLORS,
19
+ } from "@/lib/mock/question-bank-folders"
20
+
21
+ function isQuestionBankFolder(
22
+ item: QuestionBankFolder | QuestionBankItem,
23
+ ): item is QuestionBankFolder {
24
+ return "parentId" in item && !("stem" in item)
25
+ }
26
+
27
+ export interface QuestionBankFolderColumnsPanelProps {
28
+ folders: QuestionBankFolder[]
29
+ rows: QuestionBankItem[]
30
+ panelRenderDetail: (row: QuestionBankItem) => React.ReactNode
31
+ onAddFolder: (parentId: string | null) => void
32
+ onAddQuestion: (parentId: string | null) => void
33
+ onCustomizeFolder?: (folder: QuestionBankFolder) => void
34
+ }
35
+
36
+ /** Question bank **panel** view — Miller columns over folders + questions. */
37
+ export function QuestionBankFolderColumnsPanel({
38
+ folders,
39
+ rows,
40
+ panelRenderDetail,
41
+ onAddFolder,
42
+ onAddQuestion,
43
+ onCustomizeFolder,
44
+ }: QuestionBankFolderColumnsPanelProps) {
45
+ return (
46
+ <ListPageFolderColumnsPanel<QuestionBankFolder, QuestionBankItem>
47
+ folders={folders}
48
+ items={rows}
49
+ isFolder={isQuestionBankFolder}
50
+ getFolderParentId={f => f.parentId}
51
+ getFolderName={f => f.name}
52
+ getItemFolderId={i => i.folderId}
53
+ getItemLabel={i => i.stem}
54
+ renderItemDetail={panelRenderDetail}
55
+ onAddFolder={onAddFolder}
56
+ onAddItem={onAddQuestion}
57
+ addItemAriaLabel="Add question"
58
+ renderFolderDetail={(folder, { folders: allFolders, items }) => (
59
+ <FolderDetailsShell folder={folder} folders={allFolders} questions={items} />
60
+ )}
61
+ renderFolderRowClassName={(folder, { isSelected, depth }) =>
62
+ !isSelected && folder.colorKey && depth > 0
63
+ ? QUESTION_BANK_FOLDER_COLOR_STYLES[folder.colorKey]?.tile
64
+ : undefined
65
+ }
66
+ renderFolderIcon={(folder, { isSelected }) => (
67
+ <i
68
+ className={cn(
69
+ "fa-folder shrink-0 text-sm",
70
+ isSelected ? "fa-solid" : "fa-light",
71
+ folder.colorKey && QUESTION_BANK_FOLDER_ICON_COLORS[folder.colorKey],
72
+ )}
73
+ aria-hidden="true"
74
+ />
75
+ )}
76
+ renderItemMeta={(item, { isSelected }) =>
77
+ item.type === "multiple_choice"
78
+ ? "MCQ"
79
+ : item.difficulty?.charAt(0).toUpperCase() ?? ""
80
+ }
81
+ renderFolderActions={folder => (
82
+ <DropdownMenu>
83
+ <DropdownMenuTrigger asChild>
84
+ <Button
85
+ type="button"
86
+ size="icon-xs"
87
+ variant="ghost"
88
+ aria-label={`Actions for folder ${folder.name}`}
89
+ className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
90
+ >
91
+ <i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
92
+ </Button>
93
+ </DropdownMenuTrigger>
94
+ <DropdownMenuContent align="end">
95
+ <DropdownMenuItem onSelect={() => onCustomizeFolder?.(folder)}>
96
+ <i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
97
+ Customize
98
+ </DropdownMenuItem>
99
+ </DropdownMenuContent>
100
+ </DropdownMenu>
101
+ )}
102
+ />
103
+ )
104
+ }
@@ -254,11 +254,8 @@ export function QuestionBankHubClient() {
254
254
  aria-label="Search and create questions"
255
255
  className="-mx-4 overflow-hidden px-4 py-6 md:-mx-6 md:px-6"
256
256
  style={{
257
- background: [
258
- "radial-gradient(ellipse 118% 70% at 50% 100%, oklch(from var(--brand-color) l c h / 0.11) 0%, transparent 60%)",
259
- "linear-gradient(180deg, var(--key-metrics-flat-grad-top) 0%, var(--key-metrics-flat-grad-mid) 48%, var(--background) 100%)",
260
- ].join(", "),
261
- boxShadow: "0 18px 42px -26px color-mix(in oklch, var(--brand-color) 10%, transparent)",
257
+ background: "var(--key-metrics-flat-band-radial)",
258
+ boxShadow: "var(--key-metrics-flat-band-shadow)",
262
259
  }}
263
260
  >
264
261
  <div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 md:px-6">