@exxatdesignux/ui 0.2.16 → 0.2.18

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 (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
@@ -153,6 +153,7 @@ export function SystemBanner({
153
153
  <a
154
154
  href={action.href}
155
155
  className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold underline underline-offset-2 hover:no-underline"
156
+ suppressHydrationWarning
156
157
  >
157
158
  {action.label}
158
159
  <i className="fa-light fa-arrow-right text-xs" aria-hidden="true" />
@@ -185,6 +186,7 @@ export function SystemBanner({
185
186
  ...(variant === "promo" ? { boxShadow: promoOuterShadow } : null),
186
187
  ...style,
187
188
  }}
189
+ suppressHydrationWarning
188
190
  {...props}
189
191
  >
190
192
  {decorativeOverlay ? (
@@ -69,6 +69,50 @@ function ChartContainer({
69
69
  )
70
70
  }
71
71
 
72
+ /**
73
+ * Conservative validators for the two values interpolated into a `<style>`
74
+ * block via `dangerouslySetInnerHTML`.
75
+ *
76
+ * `ChartConfig.color` is typed as a free `string` and may be authored by
77
+ * downstream consumers of `@exxatdesignux/ui` who could pass user-controlled
78
+ * data. To prevent CSS injection (escaping the property value, closing the
79
+ * block, or injecting `</style>`) we accept only a documented allowlist of
80
+ * CSS color syntaxes and reject anything that contains rule-terminating or
81
+ * markup-sensitive characters.
82
+ *
83
+ * Keys come from `ChartConfig` and become CSS custom-property names, so they
84
+ * are restricted to a safe identifier alphabet.
85
+ */
86
+ const CSS_KEY_PATTERN = /^[A-Za-z0-9_-]+$/
87
+
88
+ const SAFE_COLOR_PATTERN = new RegExp(
89
+ [
90
+ /^#[0-9a-fA-F]{3,8}$/, // #rgb / #rrggbb / #rrggbbaa
91
+ /^rgba?\([^;{}<>"'\\]*\)$/, // rgb()/rgba()
92
+ /^hsla?\([^;{}<>"'\\]*\)$/, // hsl()/hsla()
93
+ /^hwb\([^;{}<>"'\\]*\)$/, // hwb()
94
+ /^lab\([^;{}<>"'\\]*\)$/, // lab()
95
+ /^lch\([^;{}<>"'\\]*\)$/, // lch()
96
+ /^oklab\([^;{}<>"'\\]*\)$/, // oklab()
97
+ /^oklch\([^;{}<>"'\\]*\)$/, // oklch()
98
+ /^color\([^;{}<>"'\\]*\)$/, // color()
99
+ /^color-mix\([^;{}<>"'\\]*\)$/, // color-mix()
100
+ /^var\(--[A-Za-z0-9_-]+(?:\s*,[^;{}<>"'\\]+)?\)$/, // var(--token[, fallback])
101
+ /^[a-zA-Z]+$/, // named colors + currentColor/transparent
102
+ ]
103
+ .map((re) => re.source)
104
+ .join("|"),
105
+ )
106
+
107
+ function sanitizeChartColor(color: string): string | null {
108
+ const trimmed = color.trim()
109
+ if (!trimmed) return null
110
+ // Defence-in-depth: any of these characters could break out of the value
111
+ // and turn the inline `<style>` block into an injection sink.
112
+ if (/[;{}<>"'\\]/.test(trimmed)) return null
113
+ return SAFE_COLOR_PATTERN.test(trimmed) ? trimmed : null
114
+ }
115
+
72
116
  const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
73
117
  const colorConfig = Object.entries(config).filter(
74
118
  ([, config]) => config.theme || config.color
@@ -78,6 +122,13 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
78
122
  return null
79
123
  }
80
124
 
125
+ // `id` is generated from `React.useId()` in `ChartContainer`, but consumers
126
+ // can override it via the `id` prop, so we still verify the shape before
127
+ // interpolating it into a CSS selector.
128
+ if (!CSS_KEY_PATTERN.test(id)) {
129
+ return null
130
+ }
131
+
81
132
  return (
82
133
  <style
83
134
  dangerouslySetInnerHTML={{
@@ -87,11 +138,15 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
87
138
  ${prefix} [data-chart=${id}] {
88
139
  ${colorConfig
89
140
  .map(([key, itemConfig]) => {
90
- const color =
141
+ if (!CSS_KEY_PATTERN.test(key)) return null
142
+ const rawColor =
91
143
  itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
92
144
  itemConfig.color
93
- return color ? ` --color-${key}: ${color};` : null
145
+ if (!rawColor) return null
146
+ const safeColor = sanitizeChartColor(rawColor)
147
+ return safeColor ? ` --color-${key}: ${safeColor};` : null
94
148
  })
149
+ .filter(Boolean)
95
150
  .join("\n")}
96
151
  }
97
152
  `
@@ -80,9 +80,9 @@ function SidebarProvider({
80
80
  if (typeof window === "undefined") return
81
81
  if (window.matchMedia(SIDEBAR_COOKIE_VIEWPORT_MQ).matches) return
82
82
  const fromCookie = readSidebarStateCookie()
83
- if (fromCookie === undefined) return
83
+ if (fromCookie === undefined || fromCookie === open) return
84
84
  _setOpen(fromCookie)
85
- }, [openProp])
85
+ }, [openProp, open])
86
86
 
87
87
  const setOpen = React.useCallback(
88
88
  (value: boolean | ((value: boolean) => boolean)) => {
@@ -490,6 +490,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
490
490
  <li
491
491
  data-slot="sidebar-menu-item"
492
492
  data-sidebar="menu-item"
493
+ suppressHydrationWarning
493
494
  className={cn(
494
495
  "group/menu-item relative",
495
496
  /* Icon rail: center the square menu control in the column (footer + primary). */
package/src/globals.css CHANGED
@@ -173,10 +173,21 @@ html[data-text-size="large"] {
173
173
  --leo-surface-tint-b: color-mix(in oklch, var(--brand-color) 8%, var(--background));
174
174
  --leo-surface-gradient: linear-gradient(180deg, var(--leo-surface-tint-a) 0%, var(--leo-surface-tint-b) 100%);
175
175
 
176
- /* KeyMetrics `variant="flat"` — soft KPI band (lavender wash canvas; dark: subtle brand lift) */
177
- --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-tint-light) 90%, var(--background));
178
- --key-metrics-flat-grad-mid: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
179
- --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 80%, var(--brand-tint-light));
176
+ /* KeyMetrics `variant="flat"` — no band surface; bottom brand glow only (OKLCH). */
177
+ --key-metrics-flat-cell-bg: transparent;
178
+ --key-metrics-flat-divider: color-mix(in oklch, var(--sidebar-border) 55%, transparent);
179
+ --key-metrics-flat-band-radial: radial-gradient(
180
+ ellipse 120% 68% at 50% 100%,
181
+ color-mix(in oklch, var(--brand-color) 20%, transparent) 0%,
182
+ color-mix(in oklch, var(--brand-color) 8%, transparent) 42%,
183
+ transparent 72%
184
+ );
185
+ --key-metrics-flat-band-shadow: none;
186
+ --key-metrics-card-glow-radial: radial-gradient(
187
+ ellipse 110% 90% at 50% 100%,
188
+ color-mix(in oklch, var(--brand-color) 18%, transparent) 0%,
189
+ transparent 65%
190
+ );
180
191
 
181
192
  /* ── Surfaces ────────────────────────────────────────────────── */
182
193
  --background: oklch(1 0 0);
@@ -256,8 +267,8 @@ html[data-text-size="large"] {
256
267
  --sidebar-ring: oklch(0.25 0 0);
257
268
  /* Nav section titles — ≥4.5:1 vs --sidebar (not vs page white) */
258
269
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 58%, var(--sidebar));
259
- /* Nested secondary rail — soft brand wash on canvas (not pure `--background`, not full `--sidebar`). */
260
- --secondary-panel-bg: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
270
+ /* Nested secondary rail — elevation 1: brand wash, lighter than `--brand-tint` / sidebar. */
271
+ --secondary-panel-bg: color-mix(in oklch, var(--background) 40%, var(--brand-tint-light) 60%);
261
272
 
262
273
  /* Browser UI (meta theme-color) — aligned with --brand-tint */
263
274
  --theme-color-chrome: #f3f2f8;
@@ -365,9 +376,20 @@ html[data-text-size="large"] {
365
376
  --destructive: oklch(0.65 0.20 25); /* brighter for dark bg */
366
377
  --destructive-foreground: oklch(0.10 0 0);
367
378
 
368
- --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-color) 14%, var(--background));
369
- --key-metrics-flat-grad-mid: color-mix(in oklch, var(--muted) 42%, var(--background));
370
- --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 88%, var(--brand-color));
379
+ /* KeyMetrics flat band no surface; bottom brand glow only (OKLCH). */
380
+ --key-metrics-flat-cell-bg: transparent;
381
+ --key-metrics-flat-band-radial: radial-gradient(
382
+ ellipse 120% 68% at 50% 100%,
383
+ color-mix(in oklch, var(--brand-color) 26%, transparent) 0%,
384
+ color-mix(in oklch, var(--brand-color) 10%, transparent) 42%,
385
+ transparent 72%
386
+ );
387
+ --key-metrics-flat-band-shadow: none;
388
+ --key-metrics-card-glow-radial: radial-gradient(
389
+ ellipse 110% 90% at 50% 100%,
390
+ color-mix(in oklch, var(--brand-color) 22%, transparent) 0%,
391
+ transparent 62%
392
+ );
371
393
 
372
394
  /* Borders — visible but not washed out on dark surfaces */
373
395
  --border: oklch(0.38 0.008 270);
@@ -417,8 +439,8 @@ html[data-text-size="large"] {
417
439
  --sidebar-border: oklch(0.38 0.010 270);
418
440
  --sidebar-ring: oklch(0.85 0 0);
419
441
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 48%, var(--sidebar));
420
- /* Nested secondary rail — dark: subtle brand lift on canvas (not flat `--background` only). */
421
- --secondary-panel-bg: color-mix(in oklch, var(--background) 82%, var(--brand-color) 18%);
442
+ /* Nested secondary rail — elevation 1: brand stack, lifted toward `--card` / page. */
443
+ --secondary-panel-bg: color-mix(in oklch, var(--card) 32%, var(--brand-tint) 68%);
422
444
  --theme-color-chrome: #2f2d36;
423
445
 
424
446
  /* Lifted scrim on dark — white-tinted veil, not heavy black */
@@ -464,6 +486,12 @@ html[data-text-size="large"] {
464
486
  --secondary: oklch(0.95 0.012 286.1);
465
487
  --accent: oklch(0.925 0.015 286.1);
466
488
  --muted: oklch(0.945 0.008 286.1);
489
+ --key-metrics-flat-band-radial: radial-gradient(
490
+ ellipse 120% 68% at 50% 100%,
491
+ oklch(0.50 0.14 286.1 / 0.22) 0%,
492
+ oklch(0.50 0.14 286.1 / 0.09) 42%,
493
+ transparent 72%
494
+ );
467
495
  }
468
496
 
469
497
  .theme-one.dark,
@@ -479,6 +507,12 @@ html[data-text-size="large"] {
479
507
  --secondary: oklch(0.31 0.04 286.1);
480
508
  --muted: oklch(0.31 0.04 286.1);
481
509
  --accent: oklch(0.33 0.06 286.1);
510
+ --key-metrics-flat-band-radial: radial-gradient(
511
+ ellipse 120% 68% at 50% 100%,
512
+ oklch(0.50 0.14 286.1 / 0.28) 0%,
513
+ oklch(0.50 0.14 286.1 / 0.1) 42%,
514
+ transparent 72%
515
+ );
482
516
  }
483
517
 
484
518
  /* ==========================================================================
@@ -487,9 +521,14 @@ html[data-text-size="large"] {
487
521
  ========================================================================== */
488
522
  .theme-prism,
489
523
  .theme-rose {
490
- --brand-color: oklch(0.57 0.24 342); /* Prism rose */
491
- --brand-color-dark: oklch(0.42 0.24 342);
492
- --ring: var(--brand-color-dark);
524
+ --brand-tint: oklch(0.97 0.02 343);
525
+ --brand-tint-light: oklch(0.992 0.01 343);
526
+ --brand-tint-subtle: oklch(0.93 0.028 343);
527
+ --brand-color: oklch(0.57 0.24 342); /* Prism rose */
528
+ --brand-color-light: oklch(0.78 0.14 342);
529
+ --brand-color-dark: oklch(0.42 0.24 342);
530
+ --brand-color-deep: oklch(0.32 0.20 342);
531
+ --ring: var(--brand-color-dark);
493
532
  }
494
533
 
495
534
  .theme-prism:not(.dark),
@@ -502,6 +541,12 @@ html[data-text-size="large"] {
502
541
  --muted: oklch(0.945 0.008 343);
503
542
  --banner-prism-bg: oklch(0.97 0.02 343);
504
543
  --theme-color-chrome: #fff5f9;
544
+ --key-metrics-flat-band-radial: radial-gradient(
545
+ ellipse 120% 68% at 50% 100%,
546
+ oklch(0.57 0.24 342 / 0.22) 0%,
547
+ oklch(0.57 0.24 342 / 0.09) 42%,
548
+ transparent 72%
549
+ );
505
550
  }
506
551
 
507
552
  .theme-prism.dark,
@@ -517,6 +562,12 @@ html[data-text-size="large"] {
517
562
  --muted: oklch(0.31 0.04 342);
518
563
  --accent: oklch(0.33 0.06 342);
519
564
  --theme-color-chrome: #2a2428;
565
+ --key-metrics-flat-band-radial: radial-gradient(
566
+ ellipse 120% 68% at 50% 100%,
567
+ oklch(0.57 0.24 342 / 0.28) 0%,
568
+ oklch(0.57 0.24 342 / 0.1) 42%,
569
+ transparent 72%
570
+ );
520
571
  }
521
572
 
522
573
  /* ==========================================================================
package/src/theme.css CHANGED
@@ -220,7 +220,7 @@
220
220
  /* Nav section titles — ≥4.5:1 vs --sidebar (not vs page white) */
221
221
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 58%, var(--sidebar));
222
222
  /* Nested secondary rail — soft brand wash on canvas (not pure `--background`, not full `--sidebar`). */
223
- --secondary-panel-bg: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
223
+ --secondary-panel-bg: color-mix(in oklch, var(--background) 50%, var(--sidebar) 50%);
224
224
 
225
225
  /* Browser UI (meta theme-color) — aligned with --brand-tint */
226
226
  --theme-color-chrome: #f3f2f8;
@@ -346,8 +346,8 @@
346
346
  --sidebar-border: oklch(0.38 0.010 270);
347
347
  --sidebar-ring: oklch(0.85 0 0);
348
348
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 48%, var(--sidebar));
349
- /* Nested secondary rail — dark: subtle brand lift on canvas (not flat `--background` only). */
350
- --secondary-panel-bg: color-mix(in oklch, var(--background) 82%, var(--brand-color) 18%);
349
+ /* Nested secondary rail — dark: neutral step between page canvas and sidebar (no per-product brand wash). */
350
+ --secondary-panel-bg: color-mix(in oklch, var(--background) 58%, var(--sidebar) 42%);
351
351
  --theme-color-chrome: #2f2d36;
352
352
 
353
353
  /* Lifted scrim on dark — white-tinted veil, not heavy black */
@@ -21,7 +21,7 @@ description: >
21
21
  - **Stack:** Next.js 16 (App Router), React, TypeScript, Tailwind CSS, shadcn/ui primitives, Font Awesome icons
22
22
  - **App root:** `exxat-ds/app/(app)/` — route group that wraps all authenticated pages
23
23
  - **Single source of truth:** `exxat-ds/AGENTS.md` for full prose explanations; this skill is the actionable summary
24
- - **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-dedicated-search-surfaces`, `exxat-accessibility`, `exxat-board-cards`, `exxat-collaboration-access` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
24
+ - **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-mono-ids`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-dedicated-search-surfaces`, `exxat-accessibility`, `exxat-board-cards`, `exxat-collaboration-access` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
25
25
 
26
26
  ---
27
27
 
@@ -66,7 +66,7 @@ Use `@/components/ui/kbd` (`Kbd` + `KbdGroup`) anywhere users discover actions b
66
66
  | Duplicate | ⌘/Ctrl + **D** |
67
67
  | Review / Info | ⌘/Ctrl + **I** |
68
68
  | Remove / Delete | ⌘/Ctrl + **⌫** |
69
- | Add view (1..n) | ⌘/Ctrl + **⇧/Shift** + **1..9** |
69
+ | Add view (1..n) | **1..9** (plain digit; skipped in inputs / open dialogs) |
70
70
  | **Submit a workflow** (Create, Save, Export, Apply) | **Enter** (⏎) — scoped to the form/drawer/dialog |
71
71
  | **Cancel / dismiss** a workflow | **Esc** (Radix handles for Dialog/Sheet) |
72
72
  | **Advance a multi-step wizard** | ⌘/Ctrl + **Enter** (plain Enter stays in the input) |
@@ -0,0 +1,30 @@
1
+ ---
2
+ description: Exxat DS — monospace typography for record IDs, question IDs, and other system identifiers
3
+ globs: apps/web/**/*.tsx
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Exxat DS — monospace IDs
8
+
9
+ Use this when rendering **system identifiers** — values a user copies, searches, or matches in APIs and tables (not human-readable names or prose).
10
+
11
+ ## MUST
12
+
13
+ 1. **Class** — Wrap identifier text in **`font-mono tabular-nums`**. Add size/color from context: typically **`text-xs text-muted-foreground`** (secondary line, table meta) or **`text-sm`** when the ID is the primary label in a narrow cell.
14
+ 2. **What counts as an ID** — Question IDs (`questionId`, `Q-YYMM-XXXX`), record/entity keys shown in UI, folder/surface technical keys when displayed as identifiers, hex tokens in pickers, audit/log principals, site/row **`id`** columns meant for lookup.
15
+ 3. **Mixed lines** — When an ID sits beside prose (e.g. page subtitle), only the ID segment is mono; keep separators and labels in the default sans stack.
16
+
17
+ ## SHOULD
18
+
19
+ - Match existing hubs: **`question-bank-table.tsx`**, **`question-bank-list-view.tsx`**, **`new-question-composer.tsx`** (header subtitle), **`sites-table.tsx`** (`row.id`).
20
+ - Prefer **`truncate`** / **`min-w-0`** on mono IDs in tight layouts so long tokens do not blow out columns.
21
+
22
+ ## MUST NOT
23
+
24
+ - Apply **`font-mono`** to **person names**, **folder display names**, **status labels**, **dates**, **counts**, **currency**, or **body copy** — only the identifier token.
25
+ - Use mono for **option letters** (A/B/C) or **step numbers** unless they are literal system IDs.
26
+
27
+ ## See also
28
+
29
+ - **`.cursor/skills/exxat-mono-ids/SKILL.md`**
30
+ - **`apps/web/AGENTS.md`** — §1 item on IDs, §13 checklist
@@ -18,23 +18,26 @@ Cross-cutting Cursor rules also live in the repo root `.cursor/rules/` (data tab
18
18
  6. **Before** adding or changing **board (kanban) cards** on list hubs, read **§4.4** and the **`exxat-board-cards`** skill (**`.cursor/skills/`** or **`.claude/skills/`** at repo root — same content).
19
19
  7. **Before** adding **folder, panel, or other non-table view bodies** (centered grids, reusable shells), read **§4.5** and **`.cursor/rules/exxat-list-page-view-shells.mdc`** / **`.cursor/skills/exxat-list-page-view-shells/SKILL.md`**.
20
20
  8. **Before** adding or changing **Font Awesome** icons in app UI, read **`.cursor/rules/exxat-fontawesome-icons.mdc`** (Kit subsetting, weights, **`aria-hidden`** on **`<i>`**).
21
- 9. **Before** adding a **primary nav row** that opens a **nested secondary nav panel** (Question bank style), read **§4.6** and **`.cursor/rules/exxat-primary-nav-secondary-panel.mdc`**.
22
- 10. **Before** adding **shared access / invite collaborators** on a hub (face stack + invite sheet), read **§4.7** and **`.cursor/rules/exxat-collaboration-access.mdc`** / **`.cursor/skills/exxat-collaboration-access/SKILL.md`**.
23
- 11. **Before** adding **onboarding tours, feature walkthroughs, or coach marks**, read **§11** and `references/coach-marks.md`.
24
- 12. **Before** changing the **global command palette (⌘K)** or search/AI entry UX, read **§7.1** and **`docs/command-menu-pattern.md`**.
25
- 13. **Before** choosing **drawer vs dialog vs new page** for a task flow, read **§6.4**, **`docs/data-views-pattern.md`** (Page vs drawer), and **`docs/drawer-vs-dialog-pattern.md`** (modal vs side panel on the same route).
26
- 14. **Before** adding **success/error/confirmation feedback**, read **§6.5** and **`.cursor/rules/exxat-no-toast.mdc`** (no toast or snackbars).
27
- 15. Prefer **composing existing components** over new one-off UI. If something is missing, **extend** shared components under `components/`, not a single page file.
21
+ 9. **Before** rendering **record IDs, question IDs, or other system identifiers**, read **`.cursor/rules/exxat-mono-ids.mdc`** and **`.cursor/skills/exxat-mono-ids/SKILL.md`** (**`font-mono tabular-nums`**).
22
+ 10. **Before** adding a **primary nav row** that opens a **nested secondary nav panel** (Question bank style), read **§4.6** and **`.cursor/rules/exxat-primary-nav-secondary-panel.mdc`**.
23
+ 11. **Before** adding **shared access / invite collaborators** on a hub (face stack + invite sheet), read **§4.7** and **`.cursor/rules/exxat-collaboration-access.mdc`** / **`.cursor/skills/exxat-collaboration-access/SKILL.md`**.
24
+ 12. **Before** adding **onboarding tours, feature walkthroughs, or coach marks**, read **§11** and `references/coach-marks.md`.
25
+ 13. **Before** changing the **global command palette (⌘K)** or search/AI entry UX, read **§7.1** and **`docs/command-menu-pattern.md`**.
26
+ 14. **Before** choosing **drawer vs dialog vs new page** for a task flow, read **§6.4**, **`docs/data-views-pattern.md`** (Page vs drawer), and **`docs/drawer-vs-dialog-pattern.md`** (modal vs side panel on the same route).
27
+ 15. **Before** adding **success/error/confirmation feedback**, read **§6.5** and **`.cursor/rules/exxat-no-toast.mdc`** (no toast or snackbars).
28
+ 16. Prefer **composing existing components** over new one-off UI. If something is missing, **extend** shared components under `components/`, not a single page file.
28
29
  - **MUST** scan `components/` (especially `components/ui/`, `components/data-views/`, `components/templates/`, `components/key-metrics.tsx`, `components/page-header.tsx`, and the charts/banner/dot-pattern surfaces) **before** writing any new UI. If a primitive or composition already exists, **use it** — don't build a parallel one.
29
30
  - **Examples of existing surfaces to reuse:** card grid → `ListPageBoardCard` + `BoardCardIconRow` / `BoardCardTwoLineBlock`; AI / dot animation → `AiThinkingOverlay` + `DotPattern`; search input → `InputGroup` + `InputGroupAddon` + `InputGroupInput`; page title → `PageHeader` (serif via `font-heading`); list hub shell → `ListPageTemplate` (`metrics`, `defaultTabs`, `renderContent`); metrics strip → `KeyMetrics`; **view body gutter + centered max-width** → **`ListPageViewFrame`** (**§4.5**); **shared access / invite** → **`PageHeader` `variant="collaboration"`** + **`InviteCollaboratorsDrawer`** (**§4.7**).
30
31
  - **If** nothing fits and you would add a **new shared primitive or large bespoke widget**: **ask the user** for direction first — **`.cursor/rules/exxat-reuse-before-custom.mdc`** (unless the task already explicitly approved a greenfield build).
31
- 16. **Match** naming, imports, and patterns of the nearest reference implementation (usually Placements).
32
- 17. **Before** adding entity **mock data**, a **new view tab**, or **detail/inspector** panels on a list hub, read **`.cursor/rules/exxat-centralized-list-dataset.mdc`** and **`.cursor/skills/exxat-centralized-list-dataset/SKILL.md`** (single **`useTableState`** row bag for every view; **no** parallel mock arrays per view).
33
- 18. **Before** choosing **cards vs table rows vs simple lists** for a hub, read **`docs/card-vs-rows-pattern.md`** and **`.cursor/rules/exxat-card-vs-list-rows.mdc`**.
34
- 19. **Before** adding **`KeyMetrics`** strips on list hubs or dashboard key-metrics cards, read **`docs/kpi-strip-max-four-pattern.md`** and **`.cursor/rules/exxat-kpi-max-four.mdc`** (at most **four** tiles).
35
- 20. **Before** adding **new shared UI primitives** or bespoke widgets when nothing in **`components/`** fits after scanning, follow **`.cursor/rules/exxat-reuse-before-custom.mdc`** **ask the user** for direction unless the task already approved a greenfield build.
32
+ 17. **Match** naming, imports, and patterns of the nearest reference implementation (usually Placements).
33
+ 18. **Before** adding entity **mock data**, a **new view tab**, or **detail/inspector** panels on a list hub, read **`.cursor/rules/exxat-centralized-list-dataset.mdc`** and **`.cursor/skills/exxat-centralized-list-dataset/SKILL.md`** (single **`useTableState`** row bag for every view; **no** parallel mock arrays per view).
34
+ 19. **Before** choosing **cards vs table rows vs simple lists** for a hub, read **`docs/card-vs-rows-pattern.md`** and **`.cursor/rules/exxat-card-vs-list-rows.mdc`**.
35
+ 20. **Before** adding **`KeyMetrics`** strips on list hubs or dashboard key-metrics cards, read **`docs/kpi-strip-max-four-pattern.md`** and **`.cursor/rules/exxat-kpi-max-four.mdc`** (at most **four** tiles).
36
+ 21. **Before** styling **`KeyMetrics variant="flat"`** (list hub metrics strip, dashboard mix KPI band), read **`docs/kpi-flat-band-pattern.md`** and **`.cursor/rules/exxat-kpi-flat-band.mdc`** / **`.cursor/skills/exxat-kpi-flat-band/SKILL.md`** (transparent band, OKLCH glow, border hairlines only).
37
+ 22. **Before** changing **secondary panel** or **sidebar** brand chrome, read **`docs/shell-surface-elevation-pattern.md`** and **§4.6** ( **`--secondary-panel-bg`**, active product theme).
38
+ 23. **Before** adding **new shared UI primitives** or bespoke widgets when nothing in **`components/`** fits after scanning, follow **`.cursor/rules/exxat-reuse-before-custom.mdc`** — **ask the user** for direction unless the task already approved a greenfield build.
36
39
 
37
- **Longer narrative and architecture:** `docs/data-views-pattern.md`, `docs/drawer-vs-dialog-pattern.md`, `docs/card-vs-rows-pattern.md`, `docs/kpi-strip-max-four-pattern.md`, `docs/command-menu-pattern.md`, `docs/collaboration-access-pattern.md` (keep in sync with this handbook for big refactors).
40
+ **Longer narrative and architecture:** `docs/data-views-pattern.md`, `docs/drawer-vs-dialog-pattern.md`, `docs/card-vs-rows-pattern.md`, `docs/kpi-strip-max-four-pattern.md`, **`docs/kpi-flat-band-pattern.md`**, **`docs/shell-surface-elevation-pattern.md`**, `docs/command-menu-pattern.md`, `docs/collaboration-access-pattern.md` (keep in sync with this handbook for big refactors).
38
41
 
39
42
  ---
40
43
 
@@ -42,8 +45,8 @@ Cross-cutting Cursor rules also live in the repo root `.cursor/rules/` (data tab
42
45
 
43
46
  1. **User / task instructions** in the current session (highest).
44
47
  2. This **`AGENTS.md`** for Exxat DS product patterns.
45
- 3. **`.cursor/rules/*.mdc`** at repo root (`exxat-data-tables`, `exxat-list-page-connected-views`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-table-properties-drawer`, `exxat-board-cards`, `exxat-page-vs-drawer`, `exxat-drawer-vs-dialog`, `exxat-card-vs-list-rows`, `exxat-kpi-max-four`, `exxat-reuse-before-custom`, `exxat-no-toast`, `exxat-kbd-shortcuts`, `exxat-accessibility`, `exxat-fontawesome-icons`, `exxat-primary-nav-secondary-panel`, `exxat-collaboration-access`, `exxat-ds-agents`) and any rules under **`exxat-ds/.cursor/rules/`** (including **`exxat-dashboard-view-charts`** for Data view charts).
46
- 4. Project **skills** under `.cursor/skills/` when relevant — e.g. **shadcn**, **exxat-accessibility** (WCAG / ARIA / touch / contrast), **exxat-board-cards** (kanban card shell, status badges, primitives), **exxat-list-page-view-shells** (centered view bodies, **`ListPageViewFrame`**), **exxat-centralized-list-dataset** (one **`useTableState`** row bag + shared maps across all list-hub views and **`TablePropertiesDrawer`**), **exxat-collaboration-access** (face rail + invite sheet + library access), **exxat-dedicated-search-surfaces** (landing vs results split, **`DedicatedSearch*`** templates + recents without hydration drift), **exxat-drawer-vs-dialog**, **exxat-card-vs-list-rows**, **exxat-kpi-max-four**.
48
+ 3. **`.cursor/rules/*.mdc`** at repo root (`exxat-data-tables`, `exxat-list-page-connected-views`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-table-properties-drawer`, `exxat-board-cards`, `exxat-page-vs-drawer`, `exxat-drawer-vs-dialog`, `exxat-card-vs-list-rows`, `exxat-kpi-max-four`, `exxat-reuse-before-custom`, `exxat-no-toast`, `exxat-kbd-shortcuts`, `exxat-accessibility`, `exxat-fontawesome-icons`, **`exxat-mono-ids`**, `exxat-primary-nav-secondary-panel`, `exxat-collaboration-access`, `exxat-ds-agents`) and any rules under **`exxat-ds/.cursor/rules/`** (including **`exxat-dashboard-view-charts`** for Data view charts).
49
+ 4. Project **skills** under `.cursor/skills/` when relevant — e.g. **shadcn**, **exxat-accessibility** (WCAG / ARIA / touch / contrast), **exxat-board-cards** (kanban card shell, status badges, primitives), **exxat-list-page-view-shells** (centered view bodies, **`ListPageViewFrame`**), **exxat-centralized-list-dataset** (one **`useTableState`** row bag + shared maps across all list-hub views and **`TablePropertiesDrawer`**), **exxat-collaboration-access** (face rail + invite sheet + library access), **exxat-dedicated-search-surfaces** (landing vs results split, **`DedicatedSearch*`** templates + recents without hydration drift), **exxat-drawer-vs-dialog**, **exxat-card-vs-list-rows**, **exxat-kpi-max-four**, **`exxat-mono-ids`** (monospace system identifiers).
47
50
 
48
51
  If two documents conflict, prefer the **more specific** rule for the file type, then **newer** team decisions captured in `AGENTS.md`.
49
52
 
@@ -164,6 +167,8 @@ Thread **`view`** and **`onViewChange`** from the **client** → **table / toolb
164
167
 
165
168
  **Folder-scoped library (Question bank):** When the URL is scoped to a folder (**`scope === "folder"`** + **`folderId`** via **`lib/question-bank-nav.ts`**), the hub **`QuestionBankPageHeader`** **⋯ More** menu **MUST** include **Customize folder** and open **`QuestionBankNewFolderSheet`** from the **hub client** so the action works on **every** **`ListPageTemplate`** view tab — not only inside **`QuestionBankTable`** branches that mount their own sheet. **Pattern:** **`docs/question-bank-hub-header-pattern.md`**. **Cursor rule:** **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
166
169
 
170
+ **Surface elevation:** Secondary panel = **level 1** between primary sidebar (**`--sidebar`**, level 0) and page canvas (**`--background`**, level 2). **`NestedSecondaryPanelShell`** uses **`bg-[var(--secondary-panel-bg)]`** — OKLCH mix from **`--brand-tint*`** per active product (**One** indigo, **Prism** rose, **`theme-custom`** when accent differs from default). **MUST NOT** set panel to **`bg-sidebar`** or a fixed rose fill for all products. **`docs/shell-surface-elevation-pattern.md`**.
171
+
167
172
  **Cursor rule (panel wiring):** **`.cursor/rules/exxat-primary-nav-secondary-panel.mdc`**. **Icons in panel:** **`.cursor/rules/exxat-fontawesome-icons.mdc`**.
168
173
 
169
174
  ### 4.7 Collaboration & access (shared hubs)
@@ -509,6 +514,8 @@ New pages **SHOULD** namespace keys and version JSON (`v: 1`) for future migrati
509
514
  - **Drawer vs dialog (same route):** `docs/drawer-vs-dialog-pattern.md` — **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**
510
515
  - **Cards vs table rows:** `docs/card-vs-rows-pattern.md` — **`.cursor/rules/exxat-card-vs-list-rows.mdc`**
511
516
  - **KPI strip (max four tiles):** `docs/kpi-strip-max-four-pattern.md` — **`.cursor/rules/exxat-kpi-max-four.mdc`**
517
+ - **KPI flat band (list hubs):** `docs/kpi-flat-band-pattern.md` — **`.cursor/rules/exxat-kpi-flat-band.mdc`**
518
+ - **Shell surfaces (sidebar · secondary panel · page):** `docs/shell-surface-elevation-pattern.md`
512
519
  - **KPI deltas & trend arrows:** `docs/kpi-trend-pattern.md` (`MetricItem.trendPolarity`, `KeyMetrics`, chart mini-metrics)
513
520
  - **Global command palette (⌘K):** `docs/command-menu-pattern.md`
514
521
  - **No toast / snackbars:** **§6.5**, root **`.cursor/rules/exxat-no-toast.mdc`**
@@ -540,6 +547,7 @@ New pages **SHOULD** namespace keys and version JSON (`v: 1`) for future migrati
540
547
  | **§4.7** — **`PageHeader` `variant="collaboration"`** + **`CollaborationAccessFlow`** / **`InviteCollaboratorsDrawer`**; empty **Add collaborator** + non-empty face rail; roster + invite from **`collaborator-access.ts`** | Extra invite beside a populated face rail; per-person roster cards; forked access enums; toast on invite |
541
548
  | **§4.8** — **`DedicatedSearch*`** templates + composer + recents; **no** `localStorage` in **`useState`** initial paint; hub-specific **`patchSearchParams`** only | Forked `*QuestionBank*SearchLanding*` shells for another entity; hydration mismatch on recents |
542
549
  | **Font Awesome** — Kit in **`app/layout.tsx`**; **`fa-light` / `fa-solid`** conventions; **`aria-hidden`** on decorative **`<i>`**; run **`fa:subset-audit`** when adding glyphs (**`exxat-fontawesome-icons.mdc`**) | Parallel icon libraries for the same product chrome |
550
+ | **System IDs** — **`font-mono tabular-nums`** on question/record keys; mono **only** the ID token in mixed subtitles (**`exxat-mono-ids.mdc`**) | Mono on names, statuses, dates, or whole subtitle lines |
543
551
 
544
552
  ---
545
553
 
@@ -572,12 +580,14 @@ Copy and complete when implementing or reviewing:
572
580
  - [ ] **Accessibility:** §8 — tablist/toolbar patterns, **≥24px** targets for icon-only controls, contrast on tinted surfaces, dialog/sheet/drawer **titles**; **every icon that communicates info has a text alternative** — adjacent label (preferred) OR `aria-label` + `Tooltip` (§8.6 Case A/B/C, covers informational icons like calendar-for-date, status dots, AND icon-only buttons); **kbd inside a button uses `<Kbd variant="bare">`** (§8.7); re-run **axe** on Placements when changing views toolbar.
573
581
  - [ ] **Coach marks (§11):** `CoachMark` + `useCoachMark`; register in **`coach-mark-registry`**; use **`enabled`** / **`dependsOnDismissedFlowId`** when a tour must wait for another flow or a specific view (e.g. **dashboard**); customize-dashboard flows use **`lib/dashboard-customize-coach-mark.ts`**.
574
582
  - [ ] **Application sidebar (§9.1):** **`ExxatProductLogo`** for product; **`logoDevUrl`** for schools; team switcher **`DropdownMenuContent`** keeps the explicit wide school/program surface (**`!w-max`** + min/max width); expanded switcher **`h-auto min-h-12`**; no **`CollapsibleTrigger` → `SidebarMenuButton` with `tooltip` prop**; child links **popover** on icon rail; profile **`stockPortraitUrl`** + **`referrerPolicy="no-referrer"`** on **`AvatarImage`**.
575
- - [ ] **Secondary panel (§4.6):** If **`NavLinkItem.secondaryPanel`** is set — **`PANELS[id]`** in **`secondary-panel.tsx`**, hub mounts **`useAutoPanel(id)`**, scope syncs to URL + **`tableState.rows`** — **`.cursor/rules/exxat-primary-nav-secondary-panel.mdc`**. **Question bank folder scope:** header **⋯** → **Customize folder** + **`QuestionBankNewFolderSheet`** on **`QuestionBankClient`** — **`docs/question-bank-hub-header-pattern.md`**, **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
583
+ - [ ] **Secondary panel (§4.6):** If **`NavLinkItem.secondaryPanel`** is set — **`PANELS[id]`** in **`secondary-panel.tsx`**, hub mounts **`useAutoPanel(id)`**, scope syncs to URL + **`tableState.rows`** — **`.cursor/rules/exxat-primary-nav-secondary-panel.mdc`**. Panel shell uses **`--secondary-panel-bg`** (brand OKLCH, not **`bg-sidebar`**) — **`docs/shell-surface-elevation-pattern.md`**. **Question bank folder scope:** header **⋯** → **Customize folder** + **`QuestionBankNewFolderSheet`** on **`QuestionBankClient`** — **`docs/question-bank-hub-header-pattern.md`**, **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
584
+ - [ ] **Flat KPI strip:** **`KeyMetrics variant="flat"`** — transparent cells, radial glow only, **`flatMetricsHairlineClass`** borders — **`docs/kpi-flat-band-pattern.md`**, **`.cursor/rules/exxat-kpi-flat-band.mdc`**.
576
585
  - [ ] **Collaboration & access (§4.7):** Shared hubs use **`variant="collaboration"`**, empty **Add collaborator** / non-empty face rail, **⋯ → Invite people**, **`CollaborationAccessFlow`** or **`InviteCollaboratorsDrawer`**, **`lib/collaborator-access.ts`**, roster **name → email → role tags** — **`.cursor/rules/exxat-collaboration-access.mdc`**.
577
586
  - [ ] **Dedicated search (§4.8):** Landing uses **`DedicatedSearchLandingTemplate`**; results use **`DedicatedSearchResultsHeaderChrome`** + outer **`DEDICATED_SEARCH_RESULTS_OUTER_CONTENT_CLASSNAME`**; **`DedicatedSearchUrlComposer`** + **`DedicatedSearchRecents`** with **`createDedicatedSearchRecentsController`** — **`.cursor/rules/exxat-dedicated-search-surfaces.mdc`**.
578
587
  - [ ] **KPI trends:** **`MetricItem.trend`** matches the delta direction; **`trendPolarity`** set for “more is worse” metrics (flags, defects, overdue) — **`docs/kpi-trend-pattern.md`**, **`.cursor/rules/exxat-kpi-trends.mdc`**.
579
588
  - [ ] **Font Awesome:** New glyphs covered by **`fa:subset-audit`** / Kit subset; decorative **`<i>`** has **`aria-hidden`**; icon-only controls follow **§8.6** — **`.cursor/rules/exxat-fontawesome-icons.mdc`**.
589
+ - [ ] **System IDs:** Visible **`questionId`**, record keys, and copy-pasteable identifiers use **`font-mono tabular-nums`**; mixed lines mono-wrap **only** the ID — **`.cursor/rules/exxat-mono-ids.mdc`**, **`.cursor/skills/exxat-mono-ids/SKILL.md`**.
580
590
 
581
591
  ---
582
592
 
583
- *Last updated: question bank folder-scoped header Customize + rule/skill; drawer vs dialog / card vs rows / KPI max-four pattern docs + rules + skills; §6.4 table; §4.8 dedicated search templates; §4.7 collaboration & access; §4.1 centralized dataset + presentation; §4.5–§4.6 view shells + secondary panel; Font Awesome rule; §9.1 application sidebar shell; §4.4 board cards; §6.5 no toast; §7.1 command palette; §13 checklist.*
593
+ *Last updated: KPI flat band + shell surface elevation pattern docs/rules/skills; §4.6 secondary panel OKLCH; monospace system IDs; question bank folder header; drawer vs dialog / card vs rows / KPI max-four; §4.8 dedicated search; §4.7 collaboration; §4.1 centralized dataset; §4.5 view shells; Font Awesome; §9.1 sidebar; §4.4 board cards; §6.5 no toast; §7.1 command palette; §13 checklist.*
@@ -1,10 +1,10 @@
1
- import { DataListClient } from "@/components/data-list-client"
1
+ import { PlacementsClient } from "@/components/placements-client"
2
2
  import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
3
3
 
4
4
  export default function DataListPage() {
5
5
  return (
6
6
  <PrimaryPageTemplate siteHeader={{ title: "List hub" }}>
7
- <DataListClient />
7
+ <PlacementsClient />
8
8
  </PrimaryPageTemplate>
9
9
  )
10
10
  }
@@ -4,6 +4,7 @@ import * as React from "react"
4
4
  import { AlertCircle } from "lucide-react"
5
5
 
6
6
  import { Button } from "@/components/ui/button"
7
+ import { isChunkLoadError } from "@/lib/chunk-load-error"
7
8
 
8
9
  /**
9
10
  * Route error boundary for the signed-in app shell. Lets users retry without a full reload.
@@ -15,6 +16,8 @@ export default function AppRouteError({
15
16
  error: Error & { digest?: string }
16
17
  reset: () => void
17
18
  }) {
19
+ const chunkStale = isChunkLoadError(error)
20
+
18
21
  React.useEffect(() => {
19
22
  if (process.env.NODE_ENV === "development") {
20
23
  console.error(error)
@@ -30,14 +33,27 @@ export default function AppRouteError({
30
33
  <div className="space-y-2">
31
34
  <h1 className="text-lg font-semibold text-foreground">Something went wrong</h1>
32
35
  <p className="max-w-md text-sm text-muted-foreground">
33
- {process.env.NODE_ENV === "development"
34
- ? error.message
35
- : "Please try again. If the problem continues, contact support."}
36
+ {chunkStale
37
+ ? "The app loaded an outdated script bundle (common after a dev-server rebuild). Reload the page to fetch the latest chunks."
38
+ : process.env.NODE_ENV === "development"
39
+ ? error.message
40
+ : "Please try again. If the problem continues, contact support."}
36
41
  </p>
37
42
  </div>
38
- <Button type="button" onClick={() => reset()}>
39
- Try again
40
- </Button>
43
+ <div className="flex flex-wrap items-center justify-center gap-2">
44
+ {chunkStale ? (
45
+ <Button type="button" onClick={() => window.location.reload()}>
46
+ Reload page
47
+ </Button>
48
+ ) : null}
49
+ <Button
50
+ type="button"
51
+ variant={chunkStale ? "outline" : "default"}
52
+ onClick={() => reset()}
53
+ >
54
+ Try again
55
+ </Button>
56
+ </div>
41
57
  </div>
42
58
  )
43
59
  }
@@ -1,6 +1,10 @@
1
- import * as React from "react"
1
+ import { cookies } from "next/headers"
2
2
  import { AppSidebar } from "@/components/app-sidebar"
3
3
  import { SidebarShell } from "@/components/sidebar-shell"
4
+ import {
5
+ SIDEBAR_STATE_COOKIE_NAME,
6
+ sidebarDefaultOpenFromCookie,
7
+ } from "@/lib/sidebar-state-cookie"
4
8
  import { DashboardViewProvider } from "@/contexts/dashboard-view-context"
5
9
  import { ChartVariantProvider } from "@/contexts/chart-variant-context"
6
10
  import { AskLeoProvider, AskLeoSidebar } from "@/components/ask-leo-sidebar"
@@ -20,11 +24,14 @@ import { COMMAND_MENU_SEARCH_DATA_GROUPS } from "@/lib/command-menu-search-data"
20
24
  * The SystemBanner is configured from Settings (persisted to localStorage
21
25
  * via SystemBannerProvider) — no hardcoded copy here.
22
26
  */
23
- export default function AppLayout({ children }: { children: React.ReactNode }) {
24
- const commandMenuConfig = React.useMemo(
25
- () => buildCommandMenuConfig({ dataGroups: COMMAND_MENU_SEARCH_DATA_GROUPS }),
26
- [],
27
+ export default async function AppLayout({ children }: { children: React.ReactNode }) {
28
+ const cookieStore = await cookies()
29
+ const sidebarDefaultOpen = sidebarDefaultOpenFromCookie(
30
+ cookieStore.get(SIDEBAR_STATE_COOKIE_NAME)?.value,
27
31
  )
32
+ const commandMenuConfig = buildCommandMenuConfig({
33
+ dataGroups: COMMAND_MENU_SEARCH_DATA_GROUPS,
34
+ })
28
35
 
29
36
  return (
30
37
  <DashboardViewProvider>
@@ -33,7 +40,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
33
40
  <SystemBannerProvider>
34
41
  <CommandMenuProvider value={commandMenuConfig}>
35
42
 
36
- <SidebarShell wrapperClassName="flex min-h-svh flex-col">
43
+ <SidebarShell defaultOpen={sidebarDefaultOpen} wrapperClassName="flex min-h-svh flex-col">
37
44
  {/* ⌘K command palette */}
38
45
  <CommandMenu />
39
46
  <SystemBannerSlot />
@@ -10,17 +10,31 @@ import {
10
10
  QUESTION_BANK_LIST_PATH,
11
11
  } from "@/lib/question-bank-nav"
12
12
 
13
+ /** Full-page focused flows under `/question-bank/*` that suppress the secondary panel. */
14
+ const QUESTION_BANK_FOCUSED_FLOW_PATHS: readonly string[] = ["/question-bank/new"]
15
+
16
+ function isQuestionBankFocusedFlow(pathname: string): boolean {
17
+ return QUESTION_BANK_FOCUSED_FLOW_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))
18
+ }
19
+
13
20
  /**
14
21
  * Keeps the nested secondary panel open across library navigations.
15
- * **Question hub** (`/question-bank`) and **Search** (`/find`, `/list`) stay full-width — no secondary rail.
22
+ * **Question hub** (`/question-bank`), **Search** (`/find`, `/list`), and
23
+ * focused authoring routes (`/new`) stay full-width — no secondary rail.
16
24
  */
17
25
  export default function QuestionBankLayout({ children }: { children: React.ReactNode }) {
18
26
  const pathname = usePathname()
19
27
  const { openPanel, closePanel, activePanel } = useSecondaryPanel()
20
28
  const closePanelRef = React.useRef(closePanel)
21
29
  const openPanelRef = React.useRef(openPanel)
22
- closePanelRef.current = closePanel
23
- openPanelRef.current = openPanel
30
+ // "Latest ref" pattern — keep callbacks current without re-running the
31
+ // route effect below on every render. `useEffect` (no deps) updates the
32
+ // refs after each render; React's `refs` rule disallows direct ref writes
33
+ // during render so the assignment lives here instead.
34
+ React.useEffect(() => {
35
+ closePanelRef.current = closePanel
36
+ openPanelRef.current = openPanel
37
+ })
24
38
 
25
39
  /** Leaving `/question-bank/*` entirely — close nested panel without forcing primary sidebar open (⌘B / cookie). */
26
40
  React.useEffect(() => {
@@ -37,7 +51,7 @@ export default function QuestionBankLayout({ children }: { children: React.React
37
51
  const isDedicatedSearchSurface =
38
52
  pathname === QUESTION_BANK_HUB_FIND_PATH || pathname === QUESTION_BANK_LIST_PATH
39
53
 
40
- if (isDiscoveryHubRoot || isDedicatedSearchSurface) {
54
+ if (isDiscoveryHubRoot || isDedicatedSearchSurface || isQuestionBankFocusedFlow(pathname)) {
41
55
  closePanelRef.current({ mainSidebar: "leave" })
42
56
  return undefined
43
57
  }
@@ -46,7 +60,6 @@ export default function QuestionBankLayout({ children }: { children: React.React
46
60
  openPanelRef.current("question-bank")
47
61
  }
48
62
  return undefined
49
- // eslint-disable-next-line react-hooks/exhaustive-deps -- pathname + activePanel drive when to ensure panel id
50
63
  }, [pathname, activePanel])
51
64
 
52
65
  return children