@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
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `/question-bank/new` — full-page authoring composer.
3
+ *
4
+ * Mirrors focused flows: `SiteHeader` back nav (← Question hub) + `PageHeader` title
5
+ * • `SidebarAutoCollapse` mounted in `beforeSiteHeader` so the main sidebar
6
+ * collapses on enter and restores to its prior state on leave
7
+ * • A wider max-width than the placement form (the composer carries a
8
+ * two-column document + metadata-rail layout)
9
+ *
10
+ * The body is `NewQuestionComposer` — see `components/new-question-composer.tsx`.
11
+ *
12
+ * Folder pre-selection: callers may pass `?folderId=<id>` so users dropped into
13
+ * the composer from a folder-scoped library land with that folder pre-selected
14
+ * in the metadata rail.
15
+ */
16
+
17
+ import { NewQuestionComposer } from "@/components/new-question-composer"
18
+ import { SidebarAutoCollapse } from "@/components/sidebar-auto-collapse"
19
+ import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
20
+ import {
21
+ DEFAULT_QUESTION_BANK_FOLDERS,
22
+ type QuestionBankFolder,
23
+ } from "@/lib/mock/question-bank-folders"
24
+ import { generateDraftQuestionId } from "@/lib/question-bank-authoring"
25
+ import { newQuestionBackNav } from "@/lib/question-bank-nav"
26
+
27
+ interface NewQuestionPageProps {
28
+ searchParams: Promise<{ folderId?: string }>
29
+ }
30
+
31
+ export default async function NewQuestionPage({ searchParams }: NewQuestionPageProps) {
32
+ const params = await searchParams
33
+ const folders: QuestionBankFolder[] = DEFAULT_QUESTION_BANK_FOLDERS
34
+
35
+ const requested = typeof params.folderId === "string" ? params.folderId : undefined
36
+ const matched = requested ? folders.find(f => f.id === requested) : undefined
37
+ const defaultFolderId = matched?.id
38
+
39
+ const back = newQuestionBackNav(folders, defaultFolderId)
40
+ const draftQuestionId = generateDraftQuestionId()
41
+
42
+ return (
43
+ <PrimaryPageTemplate
44
+ beforeSiteHeader={<SidebarAutoCollapse />}
45
+ siteHeader={{ back }}
46
+ bodyClassName="min-h-0 flex-1 overflow-y-auto overscroll-y-contain"
47
+ maxWidthClassName="mx-auto w-full max-w-[1100px]"
48
+ contentClassName="px-8 py-4 pb-16"
49
+ >
50
+ <NewQuestionComposer
51
+ draftQuestionId={draftQuestionId}
52
+ defaultFolderId={defaultFolderId}
53
+ backHref={back.href}
54
+ folders={folders}
55
+ />
56
+ </PrimaryPageTemplate>
57
+ )
58
+ }
@@ -0,0 +1,63 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import "./globals.css"
6
+ import { isChunkLoadError } from "@/lib/chunk-load-error"
7
+
8
+ /**
9
+ * Root error boundary — catches failures outside the (app) segment layout.
10
+ */
11
+ export default function GlobalError({
12
+ error,
13
+ reset,
14
+ }: {
15
+ error: Error & { digest?: string }
16
+ reset: () => void
17
+ }) {
18
+ const chunkStale = isChunkLoadError(error)
19
+
20
+ React.useEffect(() => {
21
+ if (process.env.NODE_ENV === "development") {
22
+ console.error(error)
23
+ }
24
+ }, [error])
25
+
26
+ return (
27
+ <html lang="en">
28
+ <body className="bg-background font-sans text-foreground">
29
+ <div
30
+ className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 py-12 text-center"
31
+ role="alert"
32
+ >
33
+ <h1 className="text-lg font-semibold">Something went wrong</h1>
34
+ <p className="max-w-md text-sm text-muted-foreground">
35
+ {chunkStale
36
+ ? "The app loaded an outdated script bundle (common after a dev-server rebuild). Reload the page to fetch the latest chunks."
37
+ : process.env.NODE_ENV === "development"
38
+ ? error.message
39
+ : "Please try again. If the problem continues, contact support."}
40
+ </p>
41
+ <div className="flex flex-wrap items-center justify-center gap-2">
42
+ {chunkStale ? (
43
+ <button
44
+ type="button"
45
+ className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground"
46
+ onClick={() => window.location.reload()}
47
+ >
48
+ Reload page
49
+ </button>
50
+ ) : null}
51
+ <button
52
+ type="button"
53
+ className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium"
54
+ onClick={() => reset()}
55
+ >
56
+ Try again
57
+ </button>
58
+ </div>
59
+ </div>
60
+ </body>
61
+ </html>
62
+ )
63
+ }
@@ -187,10 +187,21 @@ html[data-text-size="large"] {
187
187
  --leo-surface-tint-b: color-mix(in oklch, var(--brand-color) 8%, var(--background));
188
188
  --leo-surface-gradient: linear-gradient(180deg, var(--leo-surface-tint-a) 0%, var(--leo-surface-tint-b) 100%);
189
189
 
190
- /* KeyMetrics `variant="flat"` — soft KPI band (lavender wash canvas; dark: subtle brand lift) */
191
- --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-tint-light) 90%, var(--background));
192
- --key-metrics-flat-grad-mid: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
193
- --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 80%, var(--brand-tint-light));
190
+ /* KeyMetrics `variant="flat"` — no band surface; bottom brand glow only (OKLCH). */
191
+ --key-metrics-flat-cell-bg: transparent;
192
+ --key-metrics-flat-divider: color-mix(in oklch, var(--sidebar-border) 55%, transparent);
193
+ --key-metrics-flat-band-radial: radial-gradient(
194
+ ellipse 120% 68% at 50% 100%,
195
+ color-mix(in oklch, var(--brand-color) 20%, transparent) 0%,
196
+ color-mix(in oklch, var(--brand-color) 8%, transparent) 42%,
197
+ transparent 72%
198
+ );
199
+ --key-metrics-flat-band-shadow: none;
200
+ --key-metrics-card-glow-radial: radial-gradient(
201
+ ellipse 110% 90% at 50% 100%,
202
+ color-mix(in oklch, var(--brand-color) 18%, transparent) 0%,
203
+ transparent 65%
204
+ );
194
205
 
195
206
  /* ── Surfaces ────────────────────────────────────────────────── */
196
207
  --background: oklch(1 0 0);
@@ -270,8 +281,8 @@ html[data-text-size="large"] {
270
281
  --sidebar-ring: oklch(0.25 0 0);
271
282
  /* Nav section titles — ≥4.5:1 vs --sidebar (not vs page white) */
272
283
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 58%, var(--sidebar));
273
- /* Nested secondary rail — soft brand wash on canvas (not pure `--background`, not full `--sidebar`). */
274
- --secondary-panel-bg: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
284
+ /* Nested secondary rail — elevation 1: brand wash, lighter than `--sidebar` / `--brand-tint`. */
285
+ --secondary-panel-bg: color-mix(in oklch, var(--background) 40%, var(--brand-tint-light) 60%);
275
286
 
276
287
  /* Browser UI (meta theme-color) — aligned with --brand-tint */
277
288
  --theme-color-chrome: #f3f2f8;
@@ -379,9 +390,21 @@ html[data-text-size="large"] {
379
390
  --destructive: oklch(0.65 0.20 25); /* brighter for dark bg */
380
391
  --destructive-foreground: oklch(0.10 0 0);
381
392
 
382
- --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-color) 14%, var(--background));
383
- --key-metrics-flat-grad-mid: color-mix(in oklch, var(--muted) 42%, var(--background));
384
- --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 88%, var(--brand-color));
393
+ /* KeyMetrics flat band no surface; bottom brand glow only (OKLCH). */
394
+ --key-metrics-flat-cell-bg: transparent;
395
+ --key-metrics-flat-divider: color-mix(in oklch, var(--sidebar-border) 55%, transparent);
396
+ --key-metrics-flat-band-radial: radial-gradient(
397
+ ellipse 120% 68% at 50% 100%,
398
+ color-mix(in oklch, var(--brand-color) 26%, transparent) 0%,
399
+ color-mix(in oklch, var(--brand-color) 10%, transparent) 42%,
400
+ transparent 72%
401
+ );
402
+ --key-metrics-flat-band-shadow: none;
403
+ --key-metrics-card-glow-radial: radial-gradient(
404
+ ellipse 110% 90% at 50% 100%,
405
+ color-mix(in oklch, var(--brand-color) 22%, transparent) 0%,
406
+ transparent 62%
407
+ );
385
408
 
386
409
  /* Borders — visible but not washed out on dark surfaces */
387
410
  --border: oklch(0.38 0.008 270);
@@ -431,8 +454,8 @@ html[data-text-size="large"] {
431
454
  --sidebar-border: oklch(0.38 0.010 270);
432
455
  --sidebar-ring: oklch(0.85 0 0);
433
456
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 48%, var(--sidebar));
434
- /* Nested secondary rail — dark: subtle brand lift on canvas (not flat `--background` only). */
435
- --secondary-panel-bg: color-mix(in oklch, var(--background) 82%, var(--brand-color) 18%);
457
+ /* Nested secondary rail — elevation 1: brand stack, lifted toward `--card` / page. */
458
+ --secondary-panel-bg: color-mix(in oklch, var(--card) 32%, var(--brand-tint) 68%);
436
459
  --theme-color-chrome: #2f2d36;
437
460
 
438
461
  /* Lifted scrim on dark — white-tinted veil, not heavy black */
@@ -493,6 +516,7 @@ html[data-text-size="large"] {
493
516
  --secondary: oklch(0.31 0.04 286.1);
494
517
  --muted: oklch(0.31 0.04 286.1);
495
518
  --accent: oklch(0.33 0.06 286.1);
519
+ --brand-tint-light: oklch(0.30 0.014 286.1);
496
520
  }
497
521
 
498
522
  /* ==========================================================================
@@ -501,9 +525,14 @@ html[data-text-size="large"] {
501
525
  ========================================================================== */
502
526
  .theme-prism,
503
527
  .theme-rose {
504
- --brand-color: oklch(0.57 0.24 342); /* Prism rose */
505
- --brand-color-dark: oklch(0.42 0.24 342);
506
- --ring: var(--brand-color-dark);
528
+ --brand-tint: oklch(0.97 0.02 343);
529
+ --brand-tint-light: oklch(0.992 0.01 343);
530
+ --brand-tint-subtle: oklch(0.93 0.028 343);
531
+ --brand-color: oklch(0.57 0.24 342); /* Prism rose */
532
+ --brand-color-light: oklch(0.78 0.14 342);
533
+ --brand-color-dark: oklch(0.42 0.24 342);
534
+ --brand-color-deep: oklch(0.32 0.20 342);
535
+ --ring: var(--brand-color-dark);
507
536
  }
508
537
 
509
538
  .theme-prism:not(.dark),
@@ -531,6 +560,100 @@ html[data-text-size="large"] {
531
560
  --muted: oklch(0.31 0.04 342);
532
561
  --accent: oklch(0.33 0.06 342);
533
562
  --theme-color-chrome: #2a2428;
563
+ --brand-tint-light: oklch(0.30 0.014 342);
564
+ }
565
+
566
+ /* ==========================================================================
567
+ Theme: Exxat Assessment · Green · hue 159.88
568
+ Usage: <html class="theme-assessment">
569
+ ========================================================================== */
570
+ .theme-assessment {
571
+ --brand-tint: oklch(0.965 0.018 159.88);
572
+ --brand-tint-light: oklch(0.992 0.008 159.88);
573
+ --brand-tint-subtle: oklch(0.93 0.028 159.88);
574
+ --brand-color: oklch(0.7 0.0913 159.88);
575
+ --brand-color-light: oklch(0.82 0.072 159.88);
576
+ --brand-color-dark: oklch(0.48 0.0913 159.88);
577
+ --brand-color-deep: oklch(0.34 0.075 159.88);
578
+ --ring: var(--brand-color-dark);
579
+ }
580
+
581
+ .theme-assessment:not(.dark) {
582
+ --sidebar: var(--brand-tint);
583
+ --sidebar-accent: oklch(0.945 0.026 159.88);
584
+ --sidebar-border: oklch(0.90 0.026 159.88);
585
+ --secondary: oklch(0.95 0.012 159.88);
586
+ --accent: oklch(0.925 0.016 159.88);
587
+ --muted: oklch(0.945 0.008 159.88);
588
+ --theme-color-chrome: #f1faf5;
589
+ }
590
+
591
+ .theme-assessment.dark,
592
+ .dark.theme-assessment {
593
+ --ring: var(--brand-color);
594
+ --background: oklch(0.20 0.008 159.88);
595
+ --sidebar: oklch(0.245 0.015 159.88);
596
+ --sidebar-accent: oklch(0.30 0.012 159.88);
597
+ --sidebar-border: oklch(0.38 0.010 159.88);
598
+ --secondary: oklch(0.31 0.04 159.88);
599
+ --muted: oklch(0.31 0.04 159.88);
600
+ --accent: oklch(0.33 0.06 159.88);
601
+ --theme-color-chrome: #242a27;
602
+ }
603
+
604
+ /* ==========================================================================
605
+ Theme: Custom product · user-selected hue
606
+ Usage: <html class="theme-custom" style="--custom-product-brand-color: …">
607
+ ========================================================================== */
608
+ /*
609
+ * Tint chroma is **derived from the source's chroma** so neighbouring hues
610
+ * (e.g. Blue h=252 vs Indigo h=280 vs Purple h=286) read as distinct pale
611
+ * tints instead of collapsing to the same near-white. The earlier formula
612
+ * pinned chroma to a fixed `0.018`, which is below the hue-discrimination
613
+ * threshold at L≈0.96 for closely-spaced hues — that's why Blue / Indigo /
614
+ * Purple looked identical in the sidebar.
615
+ *
616
+ * `max(<floor>, calc(c * <fraction>))` rules:
617
+ * - vibrant brand colours (c ≈ 0.10–0.23) get a meaningfully tinted
618
+ * sidebar / accent / muted scale → product chrome visibly changes per
619
+ * pick
620
+ * - low-chroma custom-hex picks (greys) fall back to the floor so they
621
+ * don't render as pure white
622
+ * - lightness + hue stay pinned to the original mock-up values, so the
623
+ * overall "very pale background" feel is unchanged
624
+ */
625
+ .theme-custom {
626
+ --brand-tint: oklch(from var(--custom-product-brand-color) 0.965 max(0.018, calc(c * 0.20)) h);
627
+ --brand-tint-light: oklch(from var(--custom-product-brand-color) 0.992 max(0.008, calc(c * 0.08)) h);
628
+ --brand-tint-subtle: oklch(from var(--custom-product-brand-color) 0.93 max(0.028, calc(c * 0.30)) h);
629
+ --brand-color: var(--custom-product-brand-color);
630
+ --brand-color-light: oklch(from var(--custom-product-brand-color) 0.82 c h);
631
+ --brand-color-dark: oklch(from var(--custom-product-brand-color) 0.48 c h);
632
+ --brand-color-deep: oklch(from var(--custom-product-brand-color) 0.34 c h);
633
+ --ring: var(--brand-color-dark);
634
+ }
635
+
636
+ .theme-custom:not(.dark) {
637
+ --sidebar: var(--brand-tint);
638
+ --sidebar-accent: oklch(from var(--custom-product-brand-color) 0.945 max(0.026, calc(c * 0.28)) h);
639
+ --sidebar-border: oklch(from var(--custom-product-brand-color) 0.90 max(0.026, calc(c * 0.28)) h);
640
+ --secondary: oklch(from var(--custom-product-brand-color) 0.95 max(0.012, calc(c * 0.14)) h);
641
+ --accent: oklch(from var(--custom-product-brand-color) 0.925 max(0.016, calc(c * 0.18)) h);
642
+ --muted: oklch(from var(--custom-product-brand-color) 0.945 max(0.008, calc(c * 0.10)) h);
643
+ --theme-color-chrome: color-mix(in oklch, var(--custom-product-brand-color) 10%, white);
644
+ }
645
+
646
+ .theme-custom.dark,
647
+ .dark.theme-custom {
648
+ --ring: var(--brand-color);
649
+ --background: oklch(from var(--custom-product-brand-color) 0.20 max(0.008, calc(c * 0.10)) h);
650
+ --sidebar: oklch(from var(--custom-product-brand-color) 0.245 max(0.015, calc(c * 0.18)) h);
651
+ --sidebar-accent: oklch(from var(--custom-product-brand-color) 0.30 max(0.012, calc(c * 0.14)) h);
652
+ --sidebar-border: oklch(from var(--custom-product-brand-color) 0.38 max(0.010, calc(c * 0.12)) h);
653
+ --secondary: oklch(from var(--custom-product-brand-color) 0.31 max(0.04, calc(c * 0.40)) h);
654
+ --muted: oklch(from var(--custom-product-brand-color) 0.31 max(0.04, calc(c * 0.40)) h);
655
+ --accent: oklch(from var(--custom-product-brand-color) 0.33 max(0.06, calc(c * 0.50)) h);
656
+ --theme-color-chrome: color-mix(in oklch, var(--custom-product-brand-color) 12%, black);
534
657
  }
535
658
 
536
659
  /* ==========================================================================
@@ -1804,6 +1927,20 @@ html:is([data-contrast="high"], [data-contrast="windows"]) [data-slot="coach-mar
1804
1927
  100% { opacity: 1; transform: scale(1) rotate(0); }
1805
1928
  }
1806
1929
 
1930
+ /* Radix Collapsible / Accordion — slide the children open/closed using the
1931
+ `--radix-collapsible-content-height` CSS var that Radix sets on the
1932
+ content element. Mirrors the shadcn accordion pattern (Tailwind v3
1933
+ "accordion-down" / "accordion-up"); we keep our own name so it can be
1934
+ reused by both Collapsible and Accordion primitives. */
1935
+ @keyframes collapsible-down {
1936
+ from { height: 0; }
1937
+ to { height: var(--radix-collapsible-content-height); }
1938
+ }
1939
+ @keyframes collapsible-up {
1940
+ from { height: var(--radix-collapsible-content-height); }
1941
+ to { height: 0; }
1942
+ }
1943
+
1807
1944
  /* Ask Leo suggestion chips — staggered fade-in + lift on first render. */
1808
1945
  @keyframes leo-chip-in {
1809
1946
  0% { opacity: 0; transform: translateY(8px) scale(0.96); }
@@ -6,6 +6,7 @@ import "./globals.css"
6
6
  import { ThemeProvider } from "@/components/theme-provider"
7
7
  import { TooltipProvider } from "@/components/ui/tooltip"
8
8
  import { ProductProvider } from "@/contexts/product-context"
9
+ import { DevChunkLoadRecovery } from "@/components/dev-chunk-load-recovery"
9
10
  import { ThemeColorSync } from "@/components/theme-color-sync"
10
11
  import { cn } from "@/lib/utils"
11
12
 
@@ -59,14 +60,51 @@ export default function RootLayout({
59
60
  <head>
60
61
  {/* Default until ThemeColorSync hydrates (brand + mode override client-side) */}
61
62
  <meta name="theme-color" content="#f6f3ff" />
62
- {/* Adobe Fonts — preconnect + preload Ivy Presto · Kit ID: wuk5wqn */}
63
- <link rel="preconnect" href="https://use.typekit.net" />
63
+ {/*
64
+ * Adobe Fonts — preconnect + preload Ivy Presto · Kit ID: wuk5wqn.
65
+ *
66
+ * Trust model & Subresource Integrity (SRI):
67
+ * - Adobe Typekit serves *dynamically subsetted* CSS that hashes
68
+ * differently per response, so SRI cannot be applied — the kit URL
69
+ * is locked to a single Adobe-owned origin instead.
70
+ * - `crossOrigin=""` (anonymous CORS) is set on both the preconnect
71
+ * and the stylesheet so the browser uses one CORS-correct
72
+ * connection for preconnect + preload + fetch, and so styles
73
+ * cannot read first-party cookies or credentials.
74
+ * - The same origins are pinned in `style-src` / `font-src` /
75
+ * `connect-src` of the Content-Security-Policy declared in
76
+ * `next.config.mjs`, which is what actually prevents arbitrary
77
+ * third-party CSS/fonts from loading.
78
+ */}
79
+ <link rel="preconnect" href="https://use.typekit.net" crossOrigin="" />
64
80
  <link rel="preconnect" href="https://p.typekit.net" crossOrigin="" />
65
- <link rel="preload" href="https://use.typekit.net/wuk5wqn.css" as="style" />
66
- <link rel="stylesheet" href="https://use.typekit.net/wuk5wqn.css" />
81
+ <link
82
+ rel="preload"
83
+ href="https://use.typekit.net/wuk5wqn.css"
84
+ as="style"
85
+ crossOrigin=""
86
+ />
87
+ <link
88
+ rel="stylesheet"
89
+ href="https://use.typekit.net/wuk5wqn.css"
90
+ crossOrigin=""
91
+ />
67
92
  </head>
68
93
  <body className="bg-sidebar text-foreground font-sans">
69
- {/* Font Awesome Pro Kit — subset via fontawesome-subset.manifest.json + fontawesome.com/kits (Icon Selection). */}
94
+ <DevChunkLoadRecovery />
95
+ {/*
96
+ * Font Awesome Pro Kit — subset via fontawesome-subset.manifest.json +
97
+ * fontawesome.com/kits (Icon Selection).
98
+ *
99
+ * Trust model & SRI: the kit loader URL is content-versioned by
100
+ * fontawesome.com and rotates whenever the subset changes, so SRI
101
+ * cannot be pinned. We instead:
102
+ * - Restrict the script to kit.fontawesome.com (and font/data fetch
103
+ * to ka-f.fontawesome.com) via CSP `script-src` / `font-src` /
104
+ * `connect-src` in `next.config.mjs`.
105
+ * - Load with `crossOrigin="anonymous"` so the script runs with
106
+ * CORS semantics and no credentials.
107
+ */}
70
108
  <Script
71
109
  src="https://kit.fontawesome.com/d9bd5774e0.js"
72
110
  crossOrigin="anonymous"
@@ -71,6 +71,7 @@ import { NavUser } from "@/components/nav-user"
71
71
  import { useSecondaryPanel } from "@/components/secondary-panel"
72
72
  import { ExxatProductLogo, ExxatProductMark } from "@/components/exxat-product-logo"
73
73
  import { motionHeaderEnter } from "@/lib/motion-ui"
74
+ import { customProductBrandConfig, productBrandLabel } from "@/lib/product-brand"
74
75
  import {
75
76
  NAV_DOCUMENTS,
76
77
  NAV_DOCUMENTS_LABEL,
@@ -86,8 +87,6 @@ import {
86
87
  type NavSchool,
87
88
  type NavProgram,
88
89
  } from "@/lib/mock/navigation"
89
- import { QUESTION_BANK_ENTRY_PATH } from "@/lib/question-bank-nav"
90
-
91
90
  /** Path segment of a nav URL (strip `#fragment` for matching). */
92
91
  function navUrlPath(url: string): string {
93
92
  if (!url || url === "#") return ""
@@ -190,11 +189,16 @@ function isCollapsibleChildActive(
190
189
  }
191
190
 
192
191
  /**
193
- * “Selected” styling on a collapsible **parent** row not the same as “a descendant route is open”.
194
- * When a child row is the current destination (e.g. Library on `/question-bank/library`), the parent
195
- * should stay visually neutral while the child carries `data-active`. Only highlight the parent when
196
- * the active child is the hub row whose `href` matches the parent (e.g. Question hub on `/question-bank`),
197
- * or when no child matches but the parent URL still matches (edge routes).
192
+ * “Selected” styling on a collapsible **parent** row in the **expanded** sidebar.
193
+ *
194
+ * Rule: when any descendant child is the current destination, the parent stays
195
+ * visually NEUTRAL the active child carries `data-active` on its own. The
196
+ * parent is only highlighted when no child matches but the parent URL still
197
+ * matches (edge case: route that isn't represented in the sub-list).
198
+ *
199
+ * Note: this is for the expanded view only. The collapsed icon rail uses
200
+ * `iconRailActive = isAnyChildActive` because the parent icon is the only
201
+ * visible affordance there (see `CollapsibleNavItem`).
198
202
  */
199
203
  function isCollapsibleParentMenuButtonActive(
200
204
  pathname: string,
@@ -204,15 +208,11 @@ function isCollapsibleParentMenuButtonActive(
204
208
  const children = item.children
205
209
  if (!children?.length) return isNavActive(pathname, item.url, locationHash)
206
210
 
207
- const activeChildren = children.filter(c =>
211
+ const anyChildActive = children.some(c =>
208
212
  isCollapsibleChildActive(pathname, item, c, locationHash),
209
213
  )
210
- if (activeChildren.length === 0) {
211
- return isNavActive(pathname, item.url, locationHash)
212
- }
213
- if (activeChildren.length !== 1) return false
214
- const [child] = activeChildren
215
- return navUrlPath(child.url) === navUrlPath(item.url)
214
+ if (anyChildActive) return false
215
+ return isNavActive(pathname, item.url, locationHash)
216
216
  }
217
217
 
218
218
  /** Accessible suffix for sidebar badges (badge is rendered outside the link node). */
@@ -296,8 +296,17 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
296
296
  const [flyoutOpen, setFlyoutOpen] = React.useState(false)
297
297
  const flyoutTitleId = React.useId()
298
298
  const iconRailCollapsed = state === "collapsed" && !isMobile
299
+ // In the icon rail the parent icon is the ONLY visible thing for this item
300
+ // (no sub-list, no labels) — so it must reflect "I'm somewhere inside this
301
+ // section" by lighting up on any descendant route (e.g. `/question-bank/library`),
302
+ // not only on the parent URL itself. In the expanded view we keep the
303
+ // parent neutral and let the active child row carry `data-active` (see
304
+ // `isCollapsibleParentMenuButtonActive`).
305
+ const iconRailActive = isAnyChildActive
299
306
  const triggerIcon =
300
- parentMenuButtonActive && item.iconActive ? item.iconActive : item.icon
307
+ (iconRailCollapsed ? iconRailActive : parentMenuButtonActive) && item.iconActive
308
+ ? item.iconActive
309
+ : item.icon
301
310
 
302
311
  React.useEffect(() => {
303
312
  setOpen(isAnyChildActive)
@@ -323,14 +332,16 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
323
332
  <TooltipTrigger asChild>
324
333
  <PopoverTrigger asChild>
325
334
  <SidebarMenuButton
326
- isActive={parentMenuButtonActive}
335
+ isActive={iconRailActive}
336
+ aria-current={iconRailActive ? "page" : undefined}
327
337
  aria-haspopup="dialog"
328
338
  aria-label={`${item.title} — open subpages`}
329
339
  >
330
340
  <span
341
+ key={iconRailActive ? "active" : "idle"}
331
342
  className={cn(
332
343
  "size-4 shrink-0 flex items-center justify-center",
333
- parentMenuButtonActive &&
344
+ iconRailActive &&
334
345
  "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
335
346
  )}
336
347
  aria-hidden="true"
@@ -391,7 +402,10 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
391
402
  }}
392
403
  asChild
393
404
  >
394
- <SidebarMenuItem>
405
+ {/* `group/collapsible` lets descendant utilities react to the
406
+ Radix `data-state` (e.g. chevron rotate, content slide). Radix's
407
+ asChild merges the data-state onto this `<SidebarMenuItem>`. */}
408
+ <SidebarMenuItem className="group/collapsible">
395
409
  <Tooltip>
396
410
  <TooltipTrigger asChild>
397
411
  <CollapsibleTrigger asChild>
@@ -409,7 +423,7 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
409
423
  </span>
410
424
  <span>{item.title}</span>
411
425
  <i
412
- className="fa-light fa-chevron-right ml-auto text-xs text-current transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
426
+ className="fa-light fa-chevron-right ml-auto text-xs text-current transition-transform duration-200 ease-out group-data-[state=open]/collapsible:rotate-90 motion-reduce:transition-none"
413
427
  aria-hidden="true"
414
428
  />
415
429
  </SidebarMenuButton>
@@ -419,7 +433,11 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
419
433
  {item.title}
420
434
  </TooltipContent>
421
435
  </Tooltip>
422
- <CollapsibleContent className="group-data-[collapsible=icon]:hidden">
436
+ {/* Slide the children open/closed using Radix's
437
+ `--radix-collapsible-content-height` CSS variable. `overflow-hidden`
438
+ is required so the height clip is visible during the animation.
439
+ Keyframes defined in `app/globals.css` (`collapsible-down/up`). */}
440
+ <CollapsibleContent className="overflow-hidden group-data-[collapsible=icon]:hidden data-[state=open]:[animation:collapsible-down_200ms_ease-out] data-[state=closed]:[animation:collapsible-up_200ms_ease-out] motion-reduce:animate-none">
423
441
  <SidebarMenuSub>
424
442
  {item.children.map(child => {
425
443
  const childActive = isCollapsibleChildActive(pathname, item, child, locationHash)
@@ -444,7 +462,7 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
444
462
  }
445
463
 
446
464
  function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: string }) {
447
- const { openPanel, closePanel } = useSecondaryPanel()
465
+ const { openPanel } = useSecondaryPanel()
448
466
  const locationHash = useLocationHash()
449
467
  return (
450
468
  <>
@@ -470,6 +488,12 @@ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: str
470
488
  : undefined
471
489
  }
472
490
  onClick={e => {
491
+ // Reopen the panel when the user clicks a panel-driving row
492
+ // while ALREADY on its route — Next.js `<Link>` does not
493
+ // navigate to the same URL, so without this the panel could
494
+ // stay closed (e.g. after the user collapsed it manually).
495
+ // On first click (different route), default navigation runs
496
+ // and the route's `useAutoPanel` opens the panel itself.
473
497
  if (
474
498
  item.secondaryPanel &&
475
499
  itemPath &&
@@ -477,11 +501,7 @@ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: str
477
501
  !item.url.includes("#")
478
502
  ) {
479
503
  e.preventDefault()
480
- if (itemPath === QUESTION_BANK_ENTRY_PATH) {
481
- closePanel({ mainSidebar: "leave" })
482
- } else {
483
- openPanel(item.secondaryPanel)
484
- }
504
+ openPanel(item.secondaryPanel)
485
505
  }
486
506
  }}
487
507
  >
@@ -782,14 +802,26 @@ function TeamSwitcher() {
782
802
  // ─────────────────────────────────────────────────────────────────────────────
783
803
 
784
804
  const PRODUCTS: { id: Product; label: string }[] = [
785
- { id: "exxat-one", label: "Exxat One" },
786
- { id: "exxat-prism", label: "Exxat Prism" },
805
+ { id: "exxat-one", label: "Exxat One" },
806
+ { id: "exxat-prism", label: "Exxat Prism" },
807
+ { id: "exxat-assessment", label: "Exxat Assessment" },
808
+ { id: "exxat-custom", label: "Custom product" },
787
809
  ]
788
810
 
789
811
  function ProductLogoButton() {
790
- const { product, setProduct } = useProduct()
812
+ const { product, setProduct, customProductBrand, hiddenProductIds } = useProduct()
791
813
  const { state, isMobile } = useSidebar()
792
- const current = PRODUCTS.find(p => p.id === product) ?? PRODUCTS[0]
814
+ const products = React.useMemo(
815
+ () => PRODUCTS.flatMap(p => {
816
+ if (hiddenProductIds.includes(p.id)) return []
817
+ if (p.id !== "exxat-custom") return [p]
818
+ return customProductBrand
819
+ ? [{ ...p, label: productBrandLabel(customProductBrandConfig(customProductBrand)) }]
820
+ : []
821
+ }),
822
+ [customProductBrand, hiddenProductIds],
823
+ )
824
+ const current = products.find(p => p.id === product) ?? products[0]
793
825
  const iconRail = state === "collapsed" && !isMobile
794
826
  const expandedOrMobile = state === "expanded" || isMobile
795
827
 
@@ -812,6 +844,8 @@ function ProductLogoButton() {
812
844
  suppressHydrationWarning
813
845
  >
814
846
  {iconRail ? (
847
+ // Match the school selector footprint in the icon rail (32px frame,
848
+ // 28px mark — same visual weight as the avatar with inset padding).
815
849
  <span className="flex size-8 shrink-0 items-center justify-center">
816
850
  <ExxatProductMark product={current.id} className="size-7" />
817
851
  </span>
@@ -824,7 +858,7 @@ function ProductLogoButton() {
824
858
  <ExxatProductLogo
825
859
  product={current.id}
826
860
  variant="mutedSuffix"
827
- className="h-7 w-auto max-w-[min(100%,260px)] object-left object-contain"
861
+ className="w-auto max-w-[min(100%,280px)] object-left object-contain"
828
862
  />
829
863
  </span>
830
864
  <span
@@ -851,7 +885,7 @@ function ProductLogoButton() {
851
885
  Switch product
852
886
  </DropdownMenuLabel>
853
887
  <DropdownMenuSeparator />
854
- {PRODUCTS.map(p => (
888
+ {products.map(p => (
855
889
  <DropdownMenuItem
856
890
  key={p.id}
857
891
  onClick={() => setProduct(p.id)}
@@ -861,7 +895,7 @@ function ProductLogoButton() {
861
895
  <ExxatProductLogo
862
896
  product={p.id}
863
897
  variant="mutedSuffix"
864
- className="h-7 w-auto shrink-0 max-w-[min(100%,200px)]"
898
+ className="w-auto shrink-0 max-w-[min(100%,260px)]"
865
899
  />
866
900
  {p.id === product && (
867
901
  <i className="fa-solid fa-check ml-auto text-brand text-xs" aria-hidden="true" />
@@ -970,6 +1004,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
970
1004
  <SidebarGroupLabel
971
1005
  id="sidebar-documents-heading"
972
1006
  className="text-xs font-medium uppercase tracking-wide px-2 text-sidebar-section-label"
1007
+ suppressHydrationWarning
973
1008
  >
974
1009
  {NAV_DOCUMENTS_LABEL}
975
1010
  </SidebarGroupLabel>
@@ -188,8 +188,6 @@ export function AskLeoSidebar() {
188
188
  const routeContext = React.useMemo(() => getAskLeoRouteContext(pathname), [pathname])
189
189
  const isThinking = threadMessages.some((m) => m.pending)
190
190
 
191
- const pageTitle = pageContext?.title ?? routeContext.title
192
- const pageDescription = pageContext?.description ?? routeContext.description
193
191
  const suggestions =
194
192
  pageContext?.suggestions && pageContext.suggestions.length > 0
195
193
  ? pageContext.suggestions