@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,69 @@
1
+ import * as React from "react"
2
+
3
+ import { SiteHeader, type SiteHeaderProps } from "@/components/site-header"
4
+ import { SidebarInset } from "@/components/ui/sidebar"
5
+ import { cn } from "@/lib/utils"
6
+
7
+ /** Default horizontal padding for focused workflow routes (forms, wizards, authoring). */
8
+ export const FOCUSED_WORKFLOW_CONTENT_PADDING_CLASS =
9
+ "px-4 pt-6 pb-32 sm:px-6 lg:px-8"
10
+
11
+ /** Max-width presets — narrower than primary list hubs (`max-w-[1440px]`). */
12
+ export const FOCUSED_WORKFLOW_MAX_WIDTH = {
13
+ md: "max-w-3xl",
14
+ lg: "max-w-4xl",
15
+ xl: "max-w-5xl",
16
+ } as const
17
+
18
+ export type FocusedWorkflowMaxWidth = keyof typeof FOCUSED_WORKFLOW_MAX_WIDTH
19
+
20
+ export interface FocusedWorkflowPageTemplateProps {
21
+ /** e.g. `SidebarAutoCollapse` on long-form routes. */
22
+ beforeSiteHeader?: React.ReactNode
23
+ /** Breadcrumb back link + title; parent context stays in `SiteHeader`. */
24
+ siteHeader: SiteHeaderProps
25
+ children: React.ReactNode
26
+ maxWidth?: FocusedWorkflowMaxWidth
27
+ /** Merged with default content padding. */
28
+ contentClassName?: string
29
+ bodyClassName?: string
30
+ }
31
+
32
+ /**
33
+ * Dedicated-route shell for **large or multi-step work** — create/edit flows, wizards,
34
+ * and sectioned settings. **Not** for list hubs (use `PrimaryPageTemplate`) and **not**
35
+ * for Miller-column / split-panel explorers (use `ListPageSplitHubChrome`).
36
+ *
37
+ * Pair body layouts with `FocusedWorkflowSingleColumn`, `FocusedWorkflowStepForm`,
38
+ * `FocusedWorkflowSidebarSections`, or `FocusedWorkflowEmptyState`.
39
+ *
40
+ * @see `docs/focused-workflow-page-pattern.md`
41
+ */
42
+ export function FocusedWorkflowPageTemplate({
43
+ beforeSiteHeader,
44
+ siteHeader,
45
+ children,
46
+ maxWidth = "md",
47
+ contentClassName,
48
+ bodyClassName,
49
+ }: FocusedWorkflowPageTemplateProps) {
50
+ return (
51
+ <SidebarInset id="main-content" tabIndex={-1}>
52
+ {beforeSiteHeader}
53
+ <SiteHeader {...siteHeader} />
54
+ <div className={cn("flex min-h-0 flex-1 flex-col outline-none", bodyClassName)}>
55
+ <div
56
+ className={cn(
57
+ "@container/main mx-auto flex min-h-0 w-full min-w-0 flex-1 flex-col",
58
+ FOCUSED_WORKFLOW_MAX_WIDTH[maxWidth],
59
+ FOCUSED_WORKFLOW_CONTENT_PADDING_CLASS,
60
+ contentClassName,
61
+ )}
62
+ >
63
+ {children}
64
+ </div>
65
+ </div>
66
+ </SidebarInset>
67
+ )
68
+ }
69
+
@@ -47,7 +47,11 @@ import {
47
47
  Shortcut,
48
48
  } from "@/components/ui/dropdown-menu"
49
49
  import type { DataListViewType } from "@/lib/data-list-view"
50
- import { DATA_LIST_VIEW_TILES } from "@/lib/data-list-view"
50
+ import {
51
+ dataListViewTilesForHub,
52
+ showsListPageHubMetricsStrip,
53
+ } from "@/lib/data-list-view-registry"
54
+ import { DATA_LIST_VIEW_TILES, dataListViewAddShortcut } from "@/lib/data-list-view"
51
55
  import {
52
56
  createListPageEditViewHandler,
53
57
  type OpenTablePropertiesHandle,
@@ -126,6 +130,11 @@ export interface ListPageTemplateProps {
126
130
  tablePropertiesRef?: React.RefObject<OpenTablePropertiesHandle | null>
127
131
  /** When true, hide the views tab strip (tabs + add view) — e.g. search landing with a single table surface. */
128
132
  hideViewsToolbar?: boolean
133
+ /**
134
+ * View types this hub can render. Limits **Add view** and documents intent; table components
135
+ * should still implement each kind via `ListPageConnectedViewBody`. Defaults to all registry views.
136
+ */
137
+ supportedViewTypes?: readonly DataListViewType[]
129
138
  }
130
139
 
131
140
  /** Collision-proof id for a dynamically-added tab. Module-level counters reset
@@ -180,6 +189,7 @@ export function ListPageTemplate({
180
189
  onEditView,
181
190
  tablePropertiesRef,
182
191
  hideViewsToolbar = false,
192
+ supportedViewTypes,
183
193
  }: ListPageTemplateProps) {
184
194
  const controlled =
185
195
  tabsProp !== undefined &&
@@ -212,6 +222,20 @@ export function ListPageTemplate({
212
222
 
213
223
  const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0]
214
224
 
225
+ const addableViewTypes = React.useMemo(
226
+ () =>
227
+ supportedViewTypes != null
228
+ ? dataListViewTilesForHub(supportedViewTypes)
229
+ : VIEW_TYPES,
230
+ [supportedViewTypes],
231
+ )
232
+
233
+ const metricsVisible =
234
+ showMetrics
235
+ && metrics != null
236
+ && activeTab != null
237
+ && showsListPageHubMetricsStrip(activeTab.viewType)
238
+
215
239
  const editViewFromRef = React.useMemo(
216
240
  () => (tablePropertiesRef ? createListPageEditViewHandler(tablePropertiesRef) : undefined),
217
241
  [tablePropertiesRef]
@@ -272,14 +296,17 @@ export function ListPageTemplate({
272
296
  }
273
297
 
274
298
  return (
275
- <>
276
- {!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) => (
277
- <Shortcut
278
- key={v.type}
279
- keys={`⌘⇧${i + 1}`}
280
- onInvoke={() => addView(v.type)}
281
- />
282
- ))}
299
+ <div className="flex min-h-0 w-full min-w-0 flex-1 flex-col">
300
+ {!hideViewsToolbar && addableViewTypes.slice(0, 9).map((v, i) => {
301
+ const keys = dataListViewAddShortcut(i)
302
+ return keys ? (
303
+ <Shortcut
304
+ key={v.type}
305
+ keys={keys}
306
+ onInvoke={() => addView(v.type)}
307
+ />
308
+ ) : null
309
+ })}
283
310
  {activeTab && !hideViewsToolbar && (
284
311
  <>
285
312
  <Shortcut keys="F2" onInvoke={() => openRename(activeTab)} />
@@ -299,7 +326,7 @@ export function ListPageTemplate({
299
326
  )}
300
327
  {header}
301
328
 
302
- {showMetrics && metrics}
329
+ {metricsVisible ? metrics : null}
303
330
 
304
331
  {/* ── Views toolbar (not tablist: settings/close are not tabs — WCAG 1.3.1 / ARIA) ── */}
305
332
  {!hideViewsToolbar && (
@@ -477,10 +504,10 @@ export function ListPageTemplate({
477
504
  <DropdownMenuContent align="start">
478
505
  <DropdownMenuLabel className="text-xs">Add a view</DropdownMenuLabel>
479
506
  <DropdownMenuSeparator />
480
- {VIEW_TYPES.map((v, i) => (
507
+ {addableViewTypes.map((v, i) => (
481
508
  <DropdownMenuItem
482
509
  key={v.type}
483
- shortcut={i < 9 ? `⌘⇧${i + 1}` : undefined}
510
+ shortcut={dataListViewAddShortcut(i)}
484
511
  onSelect={() => addView(v.type)}
485
512
  >
486
513
  <i className={`fa-light ${v.icon}`} aria-hidden="true" />
@@ -576,6 +603,6 @@ export function ListPageTemplate({
576
603
  </DialogFooter>
577
604
  </DialogContent>
578
605
  </Dialog>
579
- </>
606
+ </div>
580
607
  )
581
608
  }
@@ -15,7 +15,7 @@ export interface NestedSecondaryPanelShellProps {
15
15
 
16
16
  /**
17
17
  * Shared chrome for a nested hub rail — full width vs icon rail.
18
- * Fill uses `--secondary-panel-bg` (soft brand wash on `--background`).
18
+ * Fill uses `--secondary-panel-bg` one step lighter than `--sidebar` (elevation 1).
19
19
  */
20
20
  export function NestedSecondaryPanelShell({
21
21
  open,
@@ -27,6 +27,7 @@ export function NestedSecondaryPanelShell({
27
27
  return (
28
28
  <nav
29
29
  aria-label={ariaLabel}
30
+ data-slot="secondary-panel"
30
31
  data-state={open ? "open" : "closed"}
31
32
  data-layout={open ? (compact ? "icon" : "expanded") : "closed"}
32
33
  className={cn(
@@ -40,7 +41,7 @@ export function NestedSecondaryPanelShell({
40
41
  // 2rem on mobile where the panel scrolls inline and we leave
41
42
  // a little more breathing room). No upper cap so tall screens
42
43
  // get a fully-extended rail.
43
- "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
44
+ "shrink-0 m-2 mx-2 rounded-xl border border-sidebar-border bg-secondary-panel-bg shadow-sm relative md:sticky md:top-2",
44
45
  compact
45
46
  ? "w-12 min-w-12 max-w-12 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]"
46
47
  : "w-64 min-w-64 max-w-64 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]",
@@ -0,0 +1,262 @@
1
+ import * as React from "react"
2
+
3
+ import { Skeleton } from "@/components/ui/skeleton"
4
+ import { SidebarInset } from "@/components/ui/sidebar"
5
+ import {
6
+ FOCUSED_WORKFLOW_CONTENT_PADDING_CLASS,
7
+ FOCUSED_WORKFLOW_MAX_WIDTH,
8
+ } from "@/components/templates/focused-workflow-page-template"
9
+ import { FOCUSED_WORKFLOW_SIDEBAR_SECTIONS_GRID_CLASS } from "@/components/templates/focused-workflow-layouts"
10
+ import { PRIMARY_PAGE_MAX_WIDTH_CLASS } from "@/components/templates/primary-page-template"
11
+ import type { PageLoadingVariant } from "@/lib/page-loading-variant"
12
+ import { cn } from "@/lib/utils"
13
+
14
+ /** Breadcrumb bar placeholder — matches `SiteHeader` footprint without client hooks. */
15
+ function SiteHeaderSkeleton() {
16
+ return (
17
+ <div className="sticky top-0 z-30 bg-transparent">
18
+ <header
19
+ role="presentation"
20
+ className="flex h-(--header-height) shrink-0 items-center gap-2 rounded-t-xl bg-background ps-4 pe-2 lg:gap-2 lg:ps-6 lg:pe-2"
21
+ >
22
+ <Skeleton className="size-8 shrink-0 rounded-md" />
23
+ <Skeleton className="h-4 w-36 max-w-[50vw] rounded-md" />
24
+ <div className="ms-auto flex items-center gap-2">
25
+ <Skeleton className="h-8 w-16 rounded-md" />
26
+ <Skeleton className="size-8 rounded-md" />
27
+ </div>
28
+ </header>
29
+ </div>
30
+ )
31
+ }
32
+
33
+ interface PageLoadingChromeProps {
34
+ children: React.ReactNode
35
+ maxWidthClassName?: string
36
+ contentClassName?: string
37
+ paddingClassName?: string
38
+ }
39
+
40
+ function PageLoadingChrome({
41
+ children,
42
+ maxWidthClassName = PRIMARY_PAGE_MAX_WIDTH_CLASS,
43
+ contentClassName,
44
+ paddingClassName = "px-4 pt-2 pb-32 sm:px-6 lg:px-8",
45
+ }: PageLoadingChromeProps) {
46
+ return (
47
+ <SidebarInset id="main-content" tabIndex={-1} aria-busy="true" aria-label="Loading page">
48
+ <SiteHeaderSkeleton />
49
+ <div className="flex min-h-0 flex-1 flex-col outline-none">
50
+ <div
51
+ className={cn(
52
+ "@container/main mx-auto flex min-h-0 w-full min-w-0 flex-1 flex-col",
53
+ maxWidthClassName,
54
+ paddingClassName,
55
+ contentClassName,
56
+ )}
57
+ >
58
+ {children}
59
+ </div>
60
+ </div>
61
+ </SidebarInset>
62
+ )
63
+ }
64
+
65
+ /** List hub — header, KPI band, toolbar, table rows. */
66
+ export function PrimaryListHubLoadingBody() {
67
+ return (
68
+ <div className="flex flex-col gap-6">
69
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
70
+ <div className="space-y-2">
71
+ <Skeleton className="h-9 w-56 max-w-full rounded-md" />
72
+ <Skeleton className="h-4 w-72 max-w-full rounded-md" />
73
+ </div>
74
+ <div className="flex shrink-0 gap-2">
75
+ <Skeleton className="h-9 w-28 rounded-md" />
76
+ <Skeleton className="h-9 w-9 rounded-md" />
77
+ </div>
78
+ </div>
79
+ <div className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-4">
80
+ {Array.from({ length: 4 }).map((_, i) => (
81
+ <Skeleton key={i} className="h-[4.75rem] rounded-none bg-card" />
82
+ ))}
83
+ </div>
84
+ <div className="flex flex-wrap items-center gap-2">
85
+ <Skeleton className="h-9 min-w-[12rem] flex-1 rounded-md sm:max-w-xs" />
86
+ <Skeleton className="h-9 w-24 rounded-md" />
87
+ <Skeleton className="h-9 w-9 rounded-md" />
88
+ </div>
89
+ <div className="space-y-2 rounded-xl border border-border bg-card p-2">
90
+ <Skeleton className="h-10 w-full rounded-md" />
91
+ {Array.from({ length: 8 }).map((_, i) => (
92
+ <Skeleton key={i} className="h-11 w-full rounded-md" />
93
+ ))}
94
+ </div>
95
+ </div>
96
+ )
97
+ }
98
+
99
+ export function PrimaryListHubLoadingFallback() {
100
+ return (
101
+ <PageLoadingChrome>
102
+ <PrimaryListHubLoadingBody />
103
+ </PageLoadingChrome>
104
+ )
105
+ }
106
+
107
+ /** Question bank discovery hub — composer + folder grid. */
108
+ export function QuestionBankHubLoadingBody() {
109
+ return (
110
+ <div className="flex flex-col gap-10">
111
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 py-6">
112
+ <Skeleton className="mx-auto h-10 w-64 max-w-full rounded-md" />
113
+ <Skeleton className="h-12 w-full rounded-xl" />
114
+ <Skeleton className="h-4 w-48 rounded-md" />
115
+ </div>
116
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
117
+ {Array.from({ length: 6 }).map((_, i) => (
118
+ <Skeleton key={i} className="h-28 rounded-xl" />
119
+ ))}
120
+ </div>
121
+ </div>
122
+ )
123
+ }
124
+
125
+ export function QuestionBankHubLoadingFallback() {
126
+ return (
127
+ <PageLoadingChrome>
128
+ <QuestionBankHubLoadingBody />
129
+ </PageLoadingChrome>
130
+ )
131
+ }
132
+
133
+ /** Dedicated search landing / results (`?q=`). */
134
+ export function DedicatedSearchLoadingBody() {
135
+ return (
136
+ <div className="mx-auto flex min-h-[min(52vh,28rem)] w-full max-w-3xl flex-col justify-center gap-6 py-10">
137
+ <Skeleton className="h-10 w-72 max-w-full rounded-md" />
138
+ <Skeleton className="h-12 w-full rounded-xl" />
139
+ <div className="flex flex-wrap gap-2">
140
+ <Skeleton className="h-8 w-24 rounded-full" />
141
+ <Skeleton className="h-8 w-32 rounded-full" />
142
+ <Skeleton className="h-8 w-20 rounded-full" />
143
+ </div>
144
+ </div>
145
+ )
146
+ }
147
+
148
+ export function DedicatedSearchLoadingFallback() {
149
+ return (
150
+ <PageLoadingChrome maxWidthClassName="max-w-5xl">
151
+ <DedicatedSearchLoadingBody />
152
+ </PageLoadingChrome>
153
+ )
154
+ }
155
+
156
+ /** Dashboard tabs — KPI row + chart block. */
157
+ export function DashboardLoadingBody() {
158
+ return (
159
+ <div className="flex flex-col gap-6">
160
+ <div className="space-y-2">
161
+ <Skeleton className="h-9 w-56 max-w-full rounded-md" />
162
+ <Skeleton className="h-4 w-80 max-w-full rounded-md" />
163
+ </div>
164
+ <Skeleton className="h-11 w-full max-w-xl rounded-md" />
165
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
166
+ {Array.from({ length: 4 }).map((_, i) => (
167
+ <Skeleton key={i} className="h-24 rounded-xl" />
168
+ ))}
169
+ </div>
170
+ <Skeleton className="min-h-[320px] w-full rounded-xl" />
171
+ </div>
172
+ )
173
+ }
174
+
175
+ export function DashboardLoadingFallback() {
176
+ return (
177
+ <PageLoadingChrome maxWidthClassName={PRIMARY_PAGE_MAX_WIDTH_CLASS}>
178
+ <DashboardLoadingBody />
179
+ </PageLoadingChrome>
180
+ )
181
+ }
182
+
183
+ /** Focused workflow — single column form/settings sections. */
184
+ export function FocusedWorkflowLoadingBody({ withSidebar = false }: { withSidebar?: boolean }) {
185
+ if (withSidebar) {
186
+ return (
187
+ <div className={cn("flex flex-col gap-8", FOCUSED_WORKFLOW_SIDEBAR_SECTIONS_GRID_CLASS)}>
188
+ <div className="space-y-2 lg:col-span-2">
189
+ <Skeleton className="h-9 w-40 max-w-full rounded-md" />
190
+ <Skeleton className="h-4 w-full max-w-xl rounded-md" />
191
+ </div>
192
+ <div className="flex flex-col gap-1">
193
+ {Array.from({ length: 5 }).map((_, i) => (
194
+ <Skeleton key={i} className="h-9 w-full rounded-md" />
195
+ ))}
196
+ </div>
197
+ <div className="flex flex-col gap-8">
198
+ <Skeleton className="h-24 w-full rounded-xl" />
199
+ <Skeleton className="h-48 w-full rounded-xl" />
200
+ <Skeleton className="h-32 w-full rounded-xl" />
201
+ </div>
202
+ </div>
203
+ )
204
+ }
205
+
206
+ return (
207
+ <div className="flex flex-col gap-8">
208
+ <div className="space-y-2">
209
+ <Skeleton className="h-9 w-48 max-w-full rounded-md" />
210
+ <Skeleton className="h-4 w-full max-w-lg rounded-md" />
211
+ </div>
212
+ <Skeleton className="h-40 w-full rounded-xl" />
213
+ <Skeleton className="h-56 w-full rounded-xl" />
214
+ </div>
215
+ )
216
+ }
217
+
218
+ export function FocusedWorkflowLoadingFallback({ withSidebar = false }: { withSidebar?: boolean }) {
219
+ return (
220
+ <PageLoadingChrome
221
+ maxWidthClassName={FOCUSED_WORKFLOW_MAX_WIDTH.lg}
222
+ paddingClassName={FOCUSED_WORKFLOW_CONTENT_PADDING_CLASS}
223
+ >
224
+ <FocusedWorkflowLoadingBody withSidebar={withSidebar} />
225
+ </PageLoadingChrome>
226
+ )
227
+ }
228
+
229
+ export function SimplePageLoadingBody() {
230
+ return (
231
+ <div className="flex max-w-xl flex-col gap-4 py-4">
232
+ <Skeleton className="h-4 w-full rounded-md" />
233
+ <Skeleton className="h-4 w-5/6 rounded-md" />
234
+ <div className="flex gap-3 pt-2">
235
+ <Skeleton className="h-9 w-36 rounded-md" />
236
+ <Skeleton className="h-9 w-28 rounded-md" />
237
+ </div>
238
+ </div>
239
+ )
240
+ }
241
+
242
+ export function SimplePageLoadingFallback() {
243
+ return (
244
+ <PageLoadingChrome maxWidthClassName="max-w-3xl">
245
+ <SimplePageLoadingBody />
246
+ </PageLoadingChrome>
247
+ )
248
+ }
249
+
250
+ const FALLBACK_BY_VARIANT: Record<PageLoadingVariant, React.ReactNode> = {
251
+ dashboard: <DashboardLoadingFallback />,
252
+ "primary-list-hub": <PrimaryListHubLoadingFallback />,
253
+ "question-bank-hub": <QuestionBankHubLoadingFallback />,
254
+ "dedicated-search": <DedicatedSearchLoadingFallback />,
255
+ "focused-workflow": <FocusedWorkflowLoadingFallback />,
256
+ "focused-workflow-sidebar": <FocusedWorkflowLoadingFallback withSidebar />,
257
+ simple: <SimplePageLoadingFallback />,
258
+ }
259
+
260
+ export function PageLoadingByVariant({ variant }: { variant: PageLoadingVariant }) {
261
+ return FALLBACK_BY_VARIANT[variant]
262
+ }
@@ -0,0 +1 @@
1
+ export * from "@exxatdesignux/ui/components/button-group"
@@ -10,7 +10,26 @@
10
10
 
11
11
  import * as React from "react"
12
12
  import { useAppStore, type Product } from "@/stores/app-store"
13
- import { brandForProduct } from "@/lib/product-brand"
13
+ import {
14
+ brandForProduct,
15
+ EXXAT_ASSESSMENT_BRAND,
16
+ EXXAT_ONE_BRAND,
17
+ EXXAT_PRISM_BRAND,
18
+ } from "@/lib/product-brand"
19
+
20
+ const DEFAULT_PRODUCT_ACCENT: Record<Product, string> = {
21
+ "exxat-one": EXXAT_ONE_BRAND.brandColor,
22
+ "exxat-prism": EXXAT_PRISM_BRAND.brandColor,
23
+ "exxat-assessment": EXXAT_ASSESSMENT_BRAND.brandColor,
24
+ "exxat-custom": "",
25
+ }
26
+
27
+ function accentOverrideActive(product: Product, override: string | undefined): boolean {
28
+ if (!override?.trim()) return false
29
+ const defaultAccent = DEFAULT_PRODUCT_ACCENT[product]?.trim()
30
+ if (!defaultAccent) return true
31
+ return override.trim().toLowerCase() !== defaultAccent.toLowerCase()
32
+ }
14
33
 
15
34
  export type { Product }
16
35
 
@@ -62,7 +81,7 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
62
81
  // / theme-assessment` classes (with bespoke hue formulas in
63
82
  // `globals.css`) are still used for the **default** look of each
64
83
  // built-in.
65
- const hasAccentOverride = Boolean(productBrandColors[product])
84
+ const hasAccentOverride = accentOverrideActive(product, productBrandColors[product])
66
85
  let themeClass: "theme-one" | "theme-prism" | "theme-assessment" | "theme-custom"
67
86
  if (hasAccentOverride) {
68
87
  themeClass = "theme-custom"
@@ -0,0 +1,39 @@
1
+ # Consumer app pattern (`@exxatdesignux/ui`)
2
+
3
+ > **Audience:** Product repos that **install** the design system from npm — not the `apps/web` monorepo reference app.
4
+ > **Handbook:** `packages/ui/consumer-extras/AGENTS.md` · **Upgrade:** `consumer-upgrade-checklist.md` · **Skill:** `.cursor/skills/exxat-consumer-app/SKILL.md`
5
+
6
+ ## Intent
7
+
8
+ A **consumer app** composes **`@exxatdesignux/ui`** primitives and copies **patterns** from the published **`template/`** tree. It does **not** fork parallel table stacks, view routers, or shell tokens.
9
+
10
+ ## MUST
11
+
12
+ 1. **Install** — `@exxatdesignux/ui` + peers; import DS CSS once (`@exxatdesignux/ui/globals.css` or documented entry).
13
+ 2. **Sync extras** — After version bumps: `npx --package=@exxatdesignux/ui@latest exxat-ui sync-extras` → `.cursor/skills/exxat-*` + `docs/exxat-ds/*.md`.
14
+ 3. **Diff template** — Compare `node_modules/@exxatdesignux/ui/template/` for new files (layouts, `ListPageConnectedViewBody`, `FocusedWorkflowPageTemplate`, mocks).
15
+ 4. **List hubs** — One **`useTableState`** row bag; **`ListPageConnectedViewBody`** + **`defineHubViewRenderers`**; same **`supportedViewTypes`** on **`ListPageTemplate`** and **`TablePropertiesDrawer`**.
16
+ 5. **Shell tokens** — Sidebar / secondary panel / page elevation via **`--sidebar`**, **`--secondary-panel-bg`**, **`--background`** — see **`shell-surface-elevation-pattern.md`**.
17
+ 6. **Focused workflows** — Dedicated create/edit/settings routes use **`FocusedWorkflowPageTemplate`** — **`focused-workflow-page-pattern.md`**.
18
+
19
+ ## MUST NOT
20
+
21
+ - Copy only components without porting **AGENTS**, **rules**, and **pattern docs** when behavior changes.
22
+ - Ship hub view tabs with long **`if (view === "table")`** chains instead of **`ListPageConnectedViewBody`**.
23
+ - Keep a second mock array per view while the grid uses **`useTableState`**.
24
+ - Set secondary panel to **`bg-sidebar`** or light **`--brand-tint`** mixes in dark mode.
25
+
26
+ ## Checklist (new consumer feature)
27
+
28
+ - [ ] Read **`consumer-upgrade-checklist.md`** for the installed UI version.
29
+ - [ ] Run **`exxat-ui sync-extras`** if skills/patterns are stale.
30
+ - [ ] Find the closest **`template/`** hub or page; port file names and imports to your app paths.
31
+ - [ ] **List hub:** `lib/<hub>-supported-views.ts`, `lib/data-list-view-registry.ts`, `ListPageConnectedViewBody`, centralized **`tableState.rows`**.
32
+ - [ ] **Form route:** `FocusedWorkflowPageTemplate` + one body layout from **`focused-workflow-layouts.tsx`**.
33
+ - [ ] **Nav + secondary panel:** `secondaryPanel` on nav item, `PANELS` registry, `useAutoPanel` — **`exxat-primary-nav-secondary-panel`** skill.
34
+ - [ ] Re-run **`fa:subset-audit`** when adding Font Awesome glyphs.
35
+
36
+ ## See also
37
+
38
+ - Monorepo reference: `apps/web/` (full product demo)
39
+ - **`.cursor/rules/exxat-consumer-app.mdc`** (when working inside a consumer repo that synced rules)
@@ -18,6 +18,8 @@ This document describes how list pages combine **views**, **toolbar** behavior,
18
18
  | **Team page (primary template)** | `TeamClient` = `ListPageTemplate` + `KeyMetrics` + `TeamPageHeader` + `TeamTable` (same composition as `DataListClient`) | `components/team-client.tsx`, `lib/mock/team-kpi.ts` |
19
19
  | **Team roster** | `TeamTable` — `DataTable` + `useTableState` + `TablePropertiesDrawer`; list/board/dashboard read **`tableState.rows`** | `components/team-table.tsx` |
20
20
  | **Dashboard view (list tab)** | **`KeyMetrics`** (`variant="flat"` or `"card"`) — same KPI system as the template metrics strip; **do not** add ad-hoc `Card` grids for entity summaries | `TeamTable` dashboard branch, `lib/mock/team-kpi.ts` |
21
+ | **List hub metrics strip** | **`KeyMetrics variant="flat"`** — transparent cells, OKLCH brand glow only, border hairlines (**no** grey panel) | **`docs/kpi-flat-band-pattern.md`**, Placements / Team / Question bank clients |
22
+ | **Secondary panel chrome** | **`--secondary-panel-bg`** on **`NestedSecondaryPanelShell`** (lighter than sidebar, follows active product) | **`docs/shell-surface-elevation-pattern.md`**, Question bank |
21
23
  | **Export** | `ExportDrawer` | `ListPageTemplate` export props; `DataListClient` |
22
24
  | **View body layout** (gutter + centered max-width for folder / icon / panel-style content) | **`ListPageViewFrame`** (`components/data-views/list-page-view-frame.tsx`, re-exported from `components/data-views`) | **`FolderGridView`** (uses the frame); **`QuestionBankOsFolderView`** — see **`AGENTS.md` §4.5** |
23
25
 
@@ -36,6 +38,41 @@ Non-table view branches (e.g. **folder** icon grid, **panel** finder, OS-style f
36
38
 
37
39
  **Handbook:** **`AGENTS.md` §4.5**. **Cursor:** **`.cursor/rules/exxat-list-page-view-shells.mdc`**. **Skill:** **`.cursor/skills/exxat-list-page-view-shells/SKILL.md`**. **Do not** wrap **`DataTable`** in the frame if that stacks padding with the table toolbar (**`AGENTS.md` §5**).
38
40
 
41
+ ## View registry and connected bodies (extensibility)
42
+
43
+ To add a **new view type** without breaking existing hubs:
44
+
45
+ 1. **Register once** — Add a tile in `lib/data-list-view.ts` (`DATA_LIST_VIEW_TILES`). Capabilities (hub KPI strip, render kind) derive automatically in `lib/data-list-view-registry.ts`.
46
+ 2. **Build the body once** — Add or extend a generic surface under `components/data-views/` (e.g. `ListPageCalendarView`). Entity-specific wiring stays in props (`getEventDate`, board `renderCard`), not a second calendar implementation per hub.
47
+ 3. **Declare what each hub supports** — `lib/<hub>-supported-views.ts` exports a `const` array; pass the same array to **`ListPageTemplate`** (`supportedViewTypes`), the hub table (`supportedViewTypes` → `TablePropertiesDrawerButton`), and rely on defaults if omitted. **Add view**, **⌘1–9** shortcuts, and **Properties → View type** tiles all use `dataListViewTilesForHub` / `dataListViewSelectionTilesForHub` so users cannot pick a view the hub never implemented.
48
+ 4. **Route in the hub table with `ListPageConnectedViewBody`** — Switch on **`getDataListViewRenderKind(view)`**, not raw `view === "…"` chains. Pass one renderer per kind the hub supports via **`defineHubViewRenderers(MY_HUB_SUPPORTED_VIEWS, { … })`** (`lib/hub-connected-view-renderers.ts`) so dev builds warn when a supported view has no body. **Do not** use a default branch that renders dashboard/KPIs; missing renderers show `ListPageViewNotConfigured`.
49
+ 5. **Let the template own hub chrome** — `ListPageTemplate` hides the metrics strip on calendar/dashboard via `showsListPageHubMetricsStrip(activeTab.viewType)`. Hub clients pass `showMetrics` only; they do not reimplement per-tab KPI visibility.
50
+ 6. **Panel / Miller columns** — Reuse **`ListPageFolderColumnsPanel`** (`components/data-views/list-page-folder-columns-panel.tsx`) for folder + record columns; hub-specific chrome (folder colors, actions) lives in a thin wrapper (e.g. `QuestionBankFolderColumnsPanel`). **Do not** export question-bank-only tree/folder nav from the generic `data-views` barrel.
51
+
52
+ ```tsx
53
+ // lib/my-hub-supported-views.ts
54
+ export const MY_HUB_SUPPORTED_VIEWS = ["table", "list", "calendar"] as const satisfies readonly DataListViewType[]
55
+
56
+ // my-hub-client.tsx
57
+ <ListPageTemplate supportedViewTypes={MY_HUB_SUPPORTED_VIEWS} showMetrics={showMetrics} … />
58
+
59
+ // my-hub-table.tsx
60
+ import { defineHubViewRenderers } from "@/lib/hub-connected-view-renderers"
61
+
62
+ return (
63
+ <ListPageConnectedViewBody
64
+ view={view}
65
+ hubLabel="My hub"
66
+ renderers={defineHubViewRenderers(MY_HUB_SUPPORTED_VIEWS, {
67
+ "data-table": <DataTable … />,
68
+ "calendar-with-toolbar": toolbarShell(<ListPageCalendarView rows={tableState.rows} getEventDate={…} />),
69
+ })}
70
+ />
71
+ )
72
+ ```
73
+
74
+ **Checklist for a new view type:** registry tile → `data-views/` component → update each `*-supported-views.ts` that should expose it → add renderer in each `*-table.tsx` (via `defineHubViewRenderers`) → Properties drawer copy in `table-properties/drawer.tsx` if needed → `table-state-lifecycle` picks up types via `DATA_LIST_VIEW_TILES` (no separate allowlist).
75
+
39
76
  ## Architecture
40
77
 
41
78
  - **Page shell** — `ListPageTemplate` owns the views toolbar (tabs), optional metrics, and export drawer. Content for the active tab is rendered via `renderContent`.
@@ -144,11 +181,13 @@ Reference: `components/placements-page-header.tsx`, `components/team-page-header
144
181
 
145
182
  **When to use a new page (route):** The flow is **primary**, **long-form**, **multi-step**, or should have its **own URL** / bookmark / history **without** the parent page behind it — e.g. full create/edit, wizards, or detail that *is* the task.
146
183
 
147
- **Rule of thumb:** **Context + quick** **drawer**; **blocking short choice** **dialog**; **otherwise** **new page**.
184
+ **Focused workflow shell:** For dedicated create/edit, wizards, and sectioned settings, use **`FocusedWorkflowPageTemplate`** + layouts in **`focused-workflow-layouts.tsx`** see **`docs/focused-workflow-page-pattern.md`** and **`AGENTS.md` §14**. **Not** for list hubs (**`ListPageTemplate`**) or Miller-column explorers.
185
+
186
+ **Rule of thumb:** **Context + quick** → **drawer**; **blocking short choice** → **dialog**; **primary / long / wizard / settings** → **focused workflow route** (or other dedicated page).
148
187
 
149
188
  **Modal vs side panel (same route):** When the overlay stays on the same URL, prefer **`docs/drawer-vs-dialog-pattern.md`** and **`.cursor/rules/exxat-drawer-vs-dialog.mdc`** — drawers keep the hub visible; dialogs trap focus for confirms.
150
189
 
151
- Canonical rules: **`AGENTS.md` §6.4**, root **`.cursor/rules/exxat-page-vs-drawer.mdc`**, **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**.
190
+ Canonical rules: **`AGENTS.md` §6.4**, **§14**, root **`.cursor/rules/exxat-page-vs-drawer.mdc`**, **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**, **`.cursor/rules/exxat-focused-workflow-page.mdc`**.
152
191
 
153
192
  ---
154
193
 
@@ -168,7 +207,7 @@ When a route is a **primary** destination in nav (main hub for an entity) **and*
168
207
  - [ ] **>10 items** → search, filter, sort, properties (per surface type above).
169
208
  - [ ] **Has data to export** → **More** menu with **Export** + shared `ExportDrawer` pattern.
170
209
  - [ ] **Primary + large / main hub** → `ListPageTemplate`-style shell where applicable.
171
- - [ ] **Page vs drawer vs dialog (§6.4)** — Quick with parent **context** → drawer/sheet; **blocking** confirm → **dialog**; primary or long flows → **new route** (`docs/drawer-vs-dialog-pattern.md`).
210
+ - [ ] **Page vs drawer vs dialog (§6.4)** — Quick with parent **context** → drawer/sheet; **blocking** confirm → **dialog**; primary or long flows → **focused workflow route** (`docs/focused-workflow-page-pattern.md`, **§14**) or other dedicated page (`docs/drawer-vs-dialog-pattern.md`).
172
211
  - [ ] **Primary button** → `Button` default variant (`size="lg"` for parity with Placements), not outline.
173
212
  - [ ] **Dashboard view tab** → `KeyMetrics` + shared KPI helpers from **`tableState.rows`**; no duplicate one-off metric cards.
174
213
  - [ ] **Data view charts** → `ChartFigure` + `chart-keyboard-selection`; layout persistence via **`data-view-dashboard-storage`** (see `AGENTS.md` §4.3).
@@ -30,13 +30,15 @@
30
30
 
31
31
  Use when the work is **primary**, **long**, **multi-step**, or deserves its **own URL** — see **`exxat-page-vs-drawer.mdc`** and **`AGENTS.md` §6.4**.
32
32
 
33
+ For **large forms, wizards, and sectioned settings**, use **`FocusedWorkflowPageTemplate`** and body layouts — **`docs/focused-workflow-page-pattern.md`**, **`AGENTS.md` §14**, **`.cursor/skills/exxat-focused-workflow-page/SKILL.md`**.
34
+
33
35
  ## Quick matrix
34
36
 
35
37
  | Need | Drawer | Dialog | Route |
36
38
  | --- | --- | --- | --- |
37
39
  | Keep hub visible | Yes | No (blocks) | No |
38
40
  | Short confirm / alert | Rare | Yes | Overkill |
39
- | Long form / wizard | Cramped | No | Yes |
41
+ | Long form / wizard | Cramped | No | Yes — **`FocusedWorkflowPageTemplate`** |
40
42
  | Properties tied to a table | Yes | Too small | Optional |
41
43
 
42
44
  ## Accessibility