@exxatdesignux/ui 0.2.16 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
  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-mono-ids/SKILL.md +56 -0
  7. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
  8. package/package.json +3 -3
  9. package/src/components/ui/banner.tsx +2 -0
  10. package/src/components/ui/chart.tsx +57 -2
  11. package/src/components/ui/sidebar.tsx +1 -0
  12. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  13. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  14. package/template/AGENTS.md +18 -15
  15. package/template/app/(app)/data-list/page.tsx +2 -2
  16. package/template/app/(app)/question-bank/layout.tsx +18 -5
  17. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  18. package/template/app/globals.css +108 -1
  19. package/template/app/layout.tsx +41 -5
  20. package/template/components/app-sidebar.tsx +68 -34
  21. package/template/components/ask-leo-sidebar.tsx +0 -2
  22. package/template/components/brand-color-picker.tsx +344 -0
  23. package/template/components/compliance-list-view.tsx +33 -51
  24. package/template/components/compliance-table.tsx +24 -0
  25. package/template/components/data-table/index.tsx +68 -24
  26. package/template/components/data-table/pagination.tsx +0 -1
  27. package/template/components/data-table/types.ts +4 -1
  28. package/template/components/data-table/use-table-state.ts +243 -94
  29. package/template/components/data-views/data-row-list.tsx +183 -0
  30. package/template/components/data-views/index.ts +7 -3
  31. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  32. package/template/components/export-drawer.tsx +1 -1
  33. package/template/components/exxat-product-logo.tsx +172 -317
  34. package/template/components/invite-collaborators-drawer.tsx +5 -3
  35. package/template/components/key-metrics.tsx +74 -46
  36. package/template/components/new-placement-form.tsx +4 -2
  37. package/template/components/new-question-composer.tsx +2208 -0
  38. package/template/components/page-breadcrumb-trail.tsx +131 -0
  39. package/template/components/page-header.tsx +2 -1
  40. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  41. package/template/components/placement-detail.tsx +1 -1
  42. package/template/components/placements-board-view.tsx +1 -1
  43. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  44. package/template/components/placements-list-view.tsx +18 -132
  45. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  46. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  47. package/template/components/placements-table-columns.tsx +2 -2
  48. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  49. package/template/components/product-switcher.tsx +26 -8
  50. package/template/components/product-wordmark.tsx +285 -0
  51. package/template/components/question-bank-client.tsx +20 -2
  52. package/template/components/question-bank-hub-client.tsx +108 -115
  53. package/template/components/question-bank-list-view.tsx +30 -54
  54. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  55. package/template/components/question-bank-secondary-nav.tsx +0 -3
  56. package/template/components/question-bank-table.tsx +30 -5
  57. package/template/components/rotations-empty-state.tsx +3 -0
  58. package/template/components/secondary-panel.tsx +23 -3
  59. package/template/components/settings-appearance-card.tsx +584 -141
  60. package/template/components/site-header.tsx +36 -31
  61. package/template/components/sites-list-view.tsx +31 -36
  62. package/template/components/sites-table.tsx +24 -0
  63. package/template/components/table-properties/drawer.tsx +1 -1
  64. package/template/components/team-client.tsx +1 -1
  65. package/template/components/team-list-view.tsx +34 -50
  66. package/template/components/team-table.tsx +29 -3
  67. package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
  68. package/template/components/ui/dot-pattern.tsx +50 -26
  69. package/template/components/ui/leo-icon.tsx +23 -3
  70. package/template/contexts/product-context.tsx +51 -7
  71. package/template/contexts/system-banner-context.tsx +112 -4
  72. package/template/eslint.config.mjs +18 -0
  73. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  74. package/template/lib/data-list-persistence.ts +57 -257
  75. package/template/lib/dev-log.test.ts +6 -5
  76. package/template/lib/exxat-palette.json +1462 -0
  77. package/template/lib/exxat-palette.ts +136 -0
  78. package/template/lib/list-page-table-properties.ts +1 -1
  79. package/template/lib/list-status-badges.ts +1 -1
  80. package/template/lib/mailto.ts +29 -0
  81. package/template/lib/placement-board-card-layout.ts +1 -1
  82. package/template/lib/product-brand.ts +268 -0
  83. package/template/lib/question-bank-authoring.ts +308 -0
  84. package/template/lib/question-bank-nav.ts +44 -0
  85. package/template/lib/raf-throttle.ts +45 -0
  86. package/template/lib/table-state-lifecycle.ts +474 -0
  87. package/template/next.config.mjs +156 -0
  88. package/template/package.json +3 -3
  89. package/template/stores/app-store.ts +46 -1
@@ -18,21 +18,22 @@ 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** 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
37
 
37
38
  **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).
38
39
 
@@ -42,8 +43,8 @@ Cross-cutting Cursor rules also live in the repo root `.cursor/rules/` (data tab
42
43
 
43
44
  1. **User / task instructions** in the current session (highest).
44
45
  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**.
46
+ 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).
47
+ 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
48
 
48
49
  If two documents conflict, prefer the **more specific** rule for the file type, then **newer** team decisions captured in `AGENTS.md`.
49
50
 
@@ -540,6 +541,7 @@ New pages **SHOULD** namespace keys and version JSON (`v: 1`) for future migrati
540
541
  | **§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
542
  | **§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
543
  | **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 |
544
+ | **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
545
 
544
546
  ---
545
547
 
@@ -577,7 +579,8 @@ Copy and complete when implementing or reviewing:
577
579
  - [ ] **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
580
  - [ ] **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
581
  - [ ] **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`**.
582
+ - [ ] **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
583
 
581
584
  ---
582
585
 
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.*
586
+ *Last updated: monospace system IDs rule/skill; 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.*
@@ -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
  }
@@ -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
@@ -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
+ }
@@ -187,7 +187,7 @@ 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) */
190
+ /* KeyMetrics `variant="flat"` — soft KPI band (lavender wash → canvas) */
191
191
  --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-tint-light) 90%, var(--background));
192
192
  --key-metrics-flat-grad-mid: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
193
193
  --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 80%, var(--brand-tint-light));
@@ -533,6 +533,99 @@ html[data-text-size="large"] {
533
533
  --theme-color-chrome: #2a2428;
534
534
  }
535
535
 
536
+ /* ==========================================================================
537
+ Theme: Exxat Assessment · Green · hue 159.88
538
+ Usage: <html class="theme-assessment">
539
+ ========================================================================== */
540
+ .theme-assessment {
541
+ --brand-tint: oklch(0.965 0.018 159.88);
542
+ --brand-tint-light: oklch(0.992 0.008 159.88);
543
+ --brand-tint-subtle: oklch(0.93 0.028 159.88);
544
+ --brand-color: oklch(0.7 0.0913 159.88);
545
+ --brand-color-light: oklch(0.82 0.072 159.88);
546
+ --brand-color-dark: oklch(0.48 0.0913 159.88);
547
+ --brand-color-deep: oklch(0.34 0.075 159.88);
548
+ --ring: var(--brand-color-dark);
549
+ }
550
+
551
+ .theme-assessment:not(.dark) {
552
+ --sidebar: var(--brand-tint);
553
+ --sidebar-accent: oklch(0.945 0.026 159.88);
554
+ --sidebar-border: oklch(0.90 0.026 159.88);
555
+ --secondary: oklch(0.95 0.012 159.88);
556
+ --accent: oklch(0.925 0.016 159.88);
557
+ --muted: oklch(0.945 0.008 159.88);
558
+ --theme-color-chrome: #f1faf5;
559
+ }
560
+
561
+ .theme-assessment.dark,
562
+ .dark.theme-assessment {
563
+ --ring: var(--brand-color);
564
+ --background: oklch(0.20 0.008 159.88);
565
+ --sidebar: oklch(0.245 0.015 159.88);
566
+ --sidebar-accent: oklch(0.30 0.012 159.88);
567
+ --sidebar-border: oklch(0.38 0.010 159.88);
568
+ --secondary: oklch(0.31 0.04 159.88);
569
+ --muted: oklch(0.31 0.04 159.88);
570
+ --accent: oklch(0.33 0.06 159.88);
571
+ --theme-color-chrome: #242a27;
572
+ }
573
+
574
+ /* ==========================================================================
575
+ Theme: Custom product · user-selected hue
576
+ Usage: <html class="theme-custom" style="--custom-product-brand-color: …">
577
+ ========================================================================== */
578
+ /*
579
+ * Tint chroma is **derived from the source's chroma** so neighbouring hues
580
+ * (e.g. Blue h=252 vs Indigo h=280 vs Purple h=286) read as distinct pale
581
+ * tints instead of collapsing to the same near-white. The earlier formula
582
+ * pinned chroma to a fixed `0.018`, which is below the hue-discrimination
583
+ * threshold at L≈0.96 for closely-spaced hues — that's why Blue / Indigo /
584
+ * Purple looked identical in the sidebar.
585
+ *
586
+ * `max(<floor>, calc(c * <fraction>))` rules:
587
+ * - vibrant brand colours (c ≈ 0.10–0.23) get a meaningfully tinted
588
+ * sidebar / accent / muted scale → product chrome visibly changes per
589
+ * pick
590
+ * - low-chroma custom-hex picks (greys) fall back to the floor so they
591
+ * don't render as pure white
592
+ * - lightness + hue stay pinned to the original mock-up values, so the
593
+ * overall "very pale background" feel is unchanged
594
+ */
595
+ .theme-custom {
596
+ --brand-tint: oklch(from var(--custom-product-brand-color) 0.965 max(0.018, calc(c * 0.20)) h);
597
+ --brand-tint-light: oklch(from var(--custom-product-brand-color) 0.992 max(0.008, calc(c * 0.08)) h);
598
+ --brand-tint-subtle: oklch(from var(--custom-product-brand-color) 0.93 max(0.028, calc(c * 0.30)) h);
599
+ --brand-color: var(--custom-product-brand-color);
600
+ --brand-color-light: oklch(from var(--custom-product-brand-color) 0.82 c h);
601
+ --brand-color-dark: oklch(from var(--custom-product-brand-color) 0.48 c h);
602
+ --brand-color-deep: oklch(from var(--custom-product-brand-color) 0.34 c h);
603
+ --ring: var(--brand-color-dark);
604
+ }
605
+
606
+ .theme-custom:not(.dark) {
607
+ --sidebar: var(--brand-tint);
608
+ --sidebar-accent: oklch(from var(--custom-product-brand-color) 0.945 max(0.026, calc(c * 0.28)) h);
609
+ --sidebar-border: oklch(from var(--custom-product-brand-color) 0.90 max(0.026, calc(c * 0.28)) h);
610
+ --secondary: oklch(from var(--custom-product-brand-color) 0.95 max(0.012, calc(c * 0.14)) h);
611
+ --accent: oklch(from var(--custom-product-brand-color) 0.925 max(0.016, calc(c * 0.18)) h);
612
+ --muted: oklch(from var(--custom-product-brand-color) 0.945 max(0.008, calc(c * 0.10)) h);
613
+ --theme-color-chrome: color-mix(in oklch, var(--custom-product-brand-color) 10%, white);
614
+ }
615
+
616
+ .theme-custom.dark,
617
+ .dark.theme-custom {
618
+ --ring: var(--brand-color);
619
+ --background: oklch(from var(--custom-product-brand-color) 0.20 max(0.008, calc(c * 0.10)) h);
620
+ --sidebar: oklch(from var(--custom-product-brand-color) 0.245 max(0.015, calc(c * 0.18)) h);
621
+ --sidebar-accent: oklch(from var(--custom-product-brand-color) 0.30 max(0.012, calc(c * 0.14)) h);
622
+ --sidebar-border: oklch(from var(--custom-product-brand-color) 0.38 max(0.010, calc(c * 0.12)) h);
623
+ --secondary: oklch(from var(--custom-product-brand-color) 0.31 max(0.04, calc(c * 0.40)) h);
624
+ --muted: oklch(from var(--custom-product-brand-color) 0.31 max(0.04, calc(c * 0.40)) h);
625
+ --accent: oklch(from var(--custom-product-brand-color) 0.33 max(0.06, calc(c * 0.50)) h);
626
+ --theme-color-chrome: color-mix(in oklch, var(--custom-product-brand-color) 12%, black);
627
+ }
628
+
536
629
  /* ==========================================================================
537
630
  HIGH CONTRAST MODE
538
631
  ──────────────────────────────────────────────────────────────────────────
@@ -1804,6 +1897,20 @@ html:is([data-contrast="high"], [data-contrast="windows"]) [data-slot="coach-mar
1804
1897
  100% { opacity: 1; transform: scale(1) rotate(0); }
1805
1898
  }
1806
1899
 
1900
+ /* Radix Collapsible / Accordion — slide the children open/closed using the
1901
+ `--radix-collapsible-content-height` CSS var that Radix sets on the
1902
+ content element. Mirrors the shadcn accordion pattern (Tailwind v3
1903
+ "accordion-down" / "accordion-up"); we keep our own name so it can be
1904
+ reused by both Collapsible and Accordion primitives. */
1905
+ @keyframes collapsible-down {
1906
+ from { height: 0; }
1907
+ to { height: var(--radix-collapsible-content-height); }
1908
+ }
1909
+ @keyframes collapsible-up {
1910
+ from { height: var(--radix-collapsible-content-height); }
1911
+ to { height: 0; }
1912
+ }
1913
+
1807
1914
  /* Ask Leo suggestion chips — staggered fade-in + lift on first render. */
1808
1915
  @keyframes leo-chip-in {
1809
1916
  0% { opacity: 0; transform: translateY(8px) scale(0.96); }
@@ -59,14 +59,50 @@ export default function RootLayout({
59
59
  <head>
60
60
  {/* Default until ThemeColorSync hydrates (brand + mode override client-side) */}
61
61
  <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" />
62
+ {/*
63
+ * Adobe Fonts — preconnect + preload Ivy Presto · Kit ID: wuk5wqn.
64
+ *
65
+ * Trust model & Subresource Integrity (SRI):
66
+ * - Adobe Typekit serves *dynamically subsetted* CSS that hashes
67
+ * differently per response, so SRI cannot be applied — the kit URL
68
+ * is locked to a single Adobe-owned origin instead.
69
+ * - `crossOrigin=""` (anonymous CORS) is set on both the preconnect
70
+ * and the stylesheet so the browser uses one CORS-correct
71
+ * connection for preconnect + preload + fetch, and so styles
72
+ * cannot read first-party cookies or credentials.
73
+ * - The same origins are pinned in `style-src` / `font-src` /
74
+ * `connect-src` of the Content-Security-Policy declared in
75
+ * `next.config.mjs`, which is what actually prevents arbitrary
76
+ * third-party CSS/fonts from loading.
77
+ */}
78
+ <link rel="preconnect" href="https://use.typekit.net" crossOrigin="" />
64
79
  <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" />
80
+ <link
81
+ rel="preload"
82
+ href="https://use.typekit.net/wuk5wqn.css"
83
+ as="style"
84
+ crossOrigin=""
85
+ />
86
+ <link
87
+ rel="stylesheet"
88
+ href="https://use.typekit.net/wuk5wqn.css"
89
+ crossOrigin=""
90
+ />
67
91
  </head>
68
92
  <body className="bg-sidebar text-foreground font-sans">
69
- {/* Font Awesome Pro Kit — subset via fontawesome-subset.manifest.json + fontawesome.com/kits (Icon Selection). */}
93
+ {/*
94
+ * Font Awesome Pro Kit — subset via fontawesome-subset.manifest.json +
95
+ * fontawesome.com/kits (Icon Selection).
96
+ *
97
+ * Trust model & SRI: the kit loader URL is content-versioned by
98
+ * fontawesome.com and rotates whenever the subset changes, so SRI
99
+ * cannot be pinned. We instead:
100
+ * - Restrict the script to kit.fontawesome.com (and font/data fetch
101
+ * to ka-f.fontawesome.com) via CSP `script-src` / `font-src` /
102
+ * `connect-src` in `next.config.mjs`.
103
+ * - Load with `crossOrigin="anonymous"` so the script runs with
104
+ * CORS semantics and no credentials.
105
+ */}
70
106
  <Script
71
107
  src="https://kit.fontawesome.com/d9bd5774e0.js"
72
108
  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,8 +844,10 @@ function ProductLogoButton() {
812
844
  suppressHydrationWarning
813
845
  >
814
846
  {iconRail ? (
847
+ // Match the school selector footprint in the icon rail; the
848
+ // inner mark cutout uses the rail surface instead of a white fill.
815
849
  <span className="flex size-8 shrink-0 items-center justify-center">
816
- <ExxatProductMark product={current.id} className="size-7" />
850
+ <ExxatProductMark product={current.id} className="size-8" cutoutColor="var(--sidebar)" />
817
851
  </span>
818
852
  ) : (
819
853
  <span className="flex min-h-0 min-w-0 flex-1 items-stretch gap-2">
@@ -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" />
@@ -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