@exxatdesignux/ui 0.2.15 → 0.2.16

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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +3 -0
  4. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +3 -1
  5. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  6. package/package.json +1 -1
  7. package/src/globals.css +21 -2
  8. package/src/theme.css +4 -2
  9. package/template/AGENTS.md +6 -4
  10. package/template/app/(app)/question-bank/layout.tsx +11 -4
  11. package/template/app/globals.css +29 -2
  12. package/template/components/app-sidebar.tsx +89 -41
  13. package/template/components/ask-leo-sidebar.tsx +1 -2
  14. package/template/components/data-views/finder-panel-view.tsx +2 -2
  15. package/template/components/data-views/index.ts +19 -0
  16. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  17. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  18. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  19. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  20. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  21. package/template/components/exxat-product-logo.tsx +11 -72
  22. package/template/components/folder-details-shell.tsx +1 -1
  23. package/template/components/hub-tree-panel-view.tsx +88 -80
  24. package/template/components/key-metrics.tsx +50 -13
  25. package/template/components/page-header.tsx +19 -10
  26. package/template/components/product-switcher.tsx +1 -4
  27. package/template/components/question-bank-client.tsx +111 -69
  28. package/template/components/question-bank-page-header.tsx +18 -2
  29. package/template/components/question-bank-secondary-nav.tsx +12 -225
  30. package/template/components/secondary-panel.tsx +1 -1
  31. package/template/components/site-header.tsx +21 -2
  32. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  33. package/template/components/templates/list-page.tsx +1 -3
  34. package/template/components/templates/nested-secondary-panel-shell.tsx +3 -4
  35. package/template/docs/collaboration-access-pattern.md +2 -0
  36. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  37. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  38. package/template/lib/mock/navigation.tsx +30 -1
  39. package/template/lib/question-bank-nav.ts +26 -0
  40. package/template/package.json +3 -3
  41. package/template/components/command-menu-01.tsx +0 -133
  42. package/template/components/command-menu-02.tsx +0 -386
package/CHANGELOG.md CHANGED
@@ -15,6 +15,18 @@ After the user bumps `@exxatdesignux/ui`, do this in order:
15
15
 
16
16
  ---
17
17
 
18
+ ## [0.2.16] - 2026-05-15
19
+
20
+ ### Changed
21
+
22
+ - **Tokens**: `globals.css` / `theme.css` refinements and starter **`template/`** parity with the web app (layout, Question bank hub chrome, navigation).
23
+ - **Consumer extras**: Cursor skills + pattern docs refreshed for collaboration / Question bank hub header.
24
+
25
+ ### Chore (monorepo)
26
+
27
+ - **Dependabot**: version updates for `apps/web`, `packages/ui`, workspace root, and grouped Next.js + GitHub Actions bumps (see repo `.github/dependabot.yml`).
28
+ - **Web app**: Next.js **16.2.6** (security patches), primary shell scroll fixes, `SiteHeader` chrome alignment.
29
+
18
30
  ## [0.2.15] - 2026-05-13
19
31
 
20
32
  ### Fixed
@@ -8,7 +8,8 @@ user-invocable: true
8
8
 
9
9
  **Handbook:** `apps/web/AGENTS.md` §4.7
10
10
  **Narrative:** `apps/web/docs/collaboration-access-pattern.md`
11
- **Cursor rule:** `.cursor/rules/exxat-collaboration-access.mdc`
11
+ **Cursor rule:** `.cursor/rules/exxat-collaboration-access.mdc`
12
+ **Related (Question bank folder scope + ⋯ Customize folder):** `.cursor/rules/exxat-question-bank-hub-header.mdc` · `docs/question-bank-hub-header-pattern.md`
12
13
 
13
14
  ## Wiring checklist
14
15
 
@@ -19,6 +20,7 @@ user-invocable: true
19
20
  5. **Access maps** — `lib/collaborator-access.ts` for Owner / Editor / Commenter / Viewer, invite options, and **`COLLABORATION_HEADER_ADD_LABEL`**.
20
21
  6. **Header** — empty roster → outline **Add collaborator**; non-empty → face rail; both open the invite sheet.
21
22
  7. **Invite sheet** — `InviteCollaboratorsDrawer`: export-style **`Sheet`**, combined email + access menu, grouped roster (name → email → role tags → access badge).
23
+ 8. **Question bank — folder URL scope** — When **`?scope=folder`**, **`QuestionBankPageHeader`** **⋯** also lists **Customize folder**; **`QuestionBankNewFolderSheet`** on **`QuestionBankClient`** — **`.cursor/rules/exxat-question-bank-hub-header.mdc`**, **`docs/question-bank-hub-header-pattern.md`**.
22
24
 
23
25
  ## MUST NOT
24
26
 
@@ -22,6 +22,7 @@ description: >
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
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`**.
25
+ - **Question bank folder-scoped header (rule + doc):** **`.cursor/rules/exxat-question-bank-hub-header.mdc`** and **`docs/question-bank-hub-header-pattern.md`** — pair with **`exxat-primary-nav-secondary-panel`** when URL **`scope=folder`** drives the hub title.
25
26
  - **Consumer repos (npm install of `@exxatdesignux/ui`):** After a version bump, read **`node_modules/@exxatdesignux/ui/CHANGELOG.md`**, run **`npx --package=@exxatdesignux/ui@latest exxat-ui sync-extras`** so **`docs/exxat-ds/consumer-upgrade-checklist.md`** and Cursor skills match the tarball, and diff the host app against **`node_modules/@exxatdesignux/ui/template/`** for anything new to port (routes, re-exports, AGENTS). Use **`exxat-ui changelog`**, **`exxat-ui update`**, and **`exxat-ui doctor`** for CLI guidance.
26
27
 
27
28
  ---
@@ -255,6 +256,8 @@ Use `PageHeader` from `@/components/page-header` for the content-area header (be
255
256
 
256
257
  When a hub is **shared**, use **`PageHeader` `variant="collaboration"`**: **empty roster** → outline **Add collaborator**; **non-empty** → face rail (faces / **`+N`** open the invite sheet). **Invite people** also lives under the entity header **⋯ More** and opens **`InviteCollaboratorsDrawer`** via **`CollaborationAccessFlow`** when possible. Library access (Owner / Editor / Commenter / Viewer) comes from **`lib/collaborator-access.ts`**; directory tags (Faculty, Program coordinator, Director) use **`PageHeaderCollaborator.roles`**.
257
258
 
259
+ **Question bank library — folder URL scope:** When **`?scope=folder&folderId=`** applies, **⋯ More** must also offer **Customize folder** (**`QuestionBankPageHeader`** **`onCustomizeFolder`**) and the **`QuestionBankNewFolderSheet`** must be mounted on **`QuestionBankClient`** so it works on every **`ListPageTemplate`** view tab. **`.cursor/rules/exxat-question-bank-hub-header.mdc`** · **`docs/question-bank-hub-header-pattern.md`** (app: **`apps/web/docs/...`**).
260
+
258
261
  **Handbook:** `apps/web/AGENTS.md` §4.7 · **Doc:** `docs/collaboration-access-pattern.md` · **Skill:** `.cursor/skills/exxat-collaboration-access/SKILL.md` · **Reference:** Question bank header + client.
259
262
 
260
263
  ---
@@ -15,7 +15,8 @@ user-invocable: true
15
15
  2. **`components/secondary-panel.tsx`** — add **`PANELS["<id>"]`** → panel shell (title, optional search) + secondary nav component.
16
16
  3. **Hub client** — mount **`*PanelActivator`** with **`useAutoPanel("<id>")`** (same id) for the lifetime of the route (e.g. `QuestionBankPanelActivator`).
17
17
  4. **Data** — keep **one** **`useTableState`** / **`tableState.rows`**; drive scope from **URL** + small helpers (see **`lib/question-bank-nav.ts`**).
18
- 5. **Collapse control** — the nested rail header uses **`collapseActiveSecondaryPanel()`** (angles-left icon), not “close”, so the panel stays dismissed until **`openPanel`** runs again (nav, scope hook, or hub re-entry). Layout effects that auto-call **`openPanel`** must respect **`secondaryPanelAutoReopenSuppressed`** (see **`app/(app)/question-bank/layout.tsx`** + **`SecondaryPanelProvider`**).
18
+ 5. **Folder-scoped hub header (Question bank library)** When **`scope === "folder"`** in the URL, **`QuestionBankPageHeader`** **⋯ More** includes **Customize folder**; mount **`QuestionBankNewFolderSheet`** on **`QuestionBankClient`** so it works on **all** **`ListPageTemplate`** view tabs **`.cursor/rules/exxat-question-bank-hub-header.mdc`**, **`docs/question-bank-hub-header-pattern.md`**.
19
+ 6. **Collapse control** — the nested rail header uses **`collapseActiveSecondaryPanel()`** (angles-left icon), not “close”, so the panel stays dismissed until **`openPanel`** runs again (nav, scope hook, or hub re-entry). Layout effects that auto-call **`openPanel`** must respect **`secondaryPanelAutoReopenSuppressed`** (see **`app/(app)/question-bank/layout.tsx`** + **`SecondaryPanelProvider`**).
19
20
 
20
21
  ## MUST NOT
21
22
 
@@ -26,3 +27,4 @@ user-invocable: true
26
27
 
27
28
  - `components/app-sidebar.tsx` — `openPanel` on same-route primary click.
28
29
  - `components/question-bank-secondary-nav.tsx` + `lib/question-bank-nav.ts`.
30
+ - **Folder-scoped header customize:** `components/question-bank-page-header.tsx`, `components/question-bank-client.tsx` — **`docs/question-bank-hub-header-pattern.md`**, **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
@@ -2,6 +2,8 @@
2
2
 
3
3
  Shared UI for **who can access a hub** (face stack in the header) and **inviting people** (floating sheet). **Reference:** Question bank — `QuestionBankPageHeader`, `QuestionBankClient`, `InviteCollaboratorsDrawer`.
4
4
 
5
+ **Folder-scoped question bank:** When the library URL selects a folder (`?scope=folder&folderId=`), the same header **⋯ More** menu also exposes **Customize folder** (name / color / icon) via **`QuestionBankNewFolderSheet`** mounted on **`QuestionBankClient`** so it works on every view tab. See **`docs/question-bank-hub-header-pattern.md`** and **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
6
+
5
7
  ## When to use
6
8
 
7
9
  - A list hub or library is **shared** across people (not a private directory).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exxatdesignux/ui",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Exxat shared design system (components, hooks, tokens). Monorepo setup: clone repo then pnpm bootstrap at workspace root — see github.com/ExxatDesign/Exxat-DS-Workspace README.",
5
5
  "type": "module",
6
6
  "engines": {
package/src/globals.css CHANGED
@@ -165,6 +165,19 @@ html[data-text-size="large"] {
165
165
  --brand-preview-one: var(--brand-tint);
166
166
  --brand-preview-prism: oklch(0.57 0.24 342);
167
167
 
168
+ /**
169
+ * Ask Leo panel tints (same mixes as the vertical wash). Use for blobs, cards, etc.
170
+ * `--leo-surface-gradient` is the full linear wash on `AskLeoSidebar`.
171
+ */
172
+ --leo-surface-tint-a: color-mix(in oklch, var(--brand-color) 4%, var(--background));
173
+ --leo-surface-tint-b: color-mix(in oklch, var(--brand-color) 8%, var(--background));
174
+ --leo-surface-gradient: linear-gradient(180deg, var(--leo-surface-tint-a) 0%, var(--leo-surface-tint-b) 100%);
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));
180
+
168
181
  /* ── Surfaces ────────────────────────────────────────────────── */
169
182
  --background: oklch(1 0 0);
170
183
  --foreground: oklch(0.145 0 0); /* ≈ #1A1A1A — 17:1 on white ✓ */
@@ -243,7 +256,8 @@ html[data-text-size="large"] {
243
256
  --sidebar-ring: oklch(0.25 0 0);
244
257
  /* Nav section titles — ≥4.5:1 vs --sidebar (not vs page white) */
245
258
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 58%, var(--sidebar));
246
- --secondary-panel-bg: oklch(0.99 0.008 286.1);
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));
247
261
 
248
262
  /* Browser UI (meta theme-color) — aligned with --brand-tint */
249
263
  --theme-color-chrome: #f3f2f8;
@@ -351,6 +365,10 @@ html[data-text-size="large"] {
351
365
  --destructive: oklch(0.65 0.20 25); /* brighter for dark bg */
352
366
  --destructive-foreground: oklch(0.10 0 0);
353
367
 
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));
371
+
354
372
  /* Borders — visible but not washed out on dark surfaces */
355
373
  --border: oklch(0.38 0.008 270);
356
374
  --border-control: oklch(0.72 0.012 270);
@@ -399,7 +417,8 @@ html[data-text-size="large"] {
399
417
  --sidebar-border: oklch(0.38 0.010 270);
400
418
  --sidebar-ring: oklch(0.85 0 0);
401
419
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 48%, var(--sidebar));
402
- --secondary-panel-bg: oklch(0.23 0.02 270);
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%);
403
422
  --theme-color-chrome: #2f2d36;
404
423
 
405
424
  /* Lifted scrim on dark — white-tinted veil, not heavy black */
package/src/theme.css CHANGED
@@ -219,7 +219,8 @@
219
219
  --sidebar-ring: oklch(0.25 0 0);
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
- --secondary-panel-bg: oklch(0.99 0.008 286.1);
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
224
 
224
225
  /* Browser UI (meta theme-color) — aligned with --brand-tint */
225
226
  --theme-color-chrome: #f3f2f8;
@@ -345,7 +346,8 @@
345
346
  --sidebar-border: oklch(0.38 0.010 270);
346
347
  --sidebar-ring: oklch(0.85 0 0);
347
348
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 48%, var(--sidebar));
348
- --secondary-panel-bg: oklch(0.23 0.02 270);
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
351
  --theme-color-chrome: #2f2d36;
350
352
 
351
353
  /* Lifted scrim on dark — white-tinted veil, not heavy black */
@@ -162,7 +162,9 @@ Thread **`view`** and **`onViewChange`** from the **client** → **table / toolb
162
162
 
163
163
  **MUST NOT:** Set **`secondaryPanel`** without **`PANELS[id]`** and **`useAutoPanel`** — users see **collapsed** main nav with **no** panel body.
164
164
 
165
- **Cursor rule:** **`.cursor/rules/exxat-primary-nav-secondary-panel.mdc`**. **Icons in panel:** **`.cursor/rules/exxat-fontawesome-icons.mdc`**.
165
+ **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
+
167
+ **Cursor rule (panel wiring):** **`.cursor/rules/exxat-primary-nav-secondary-panel.mdc`**. **Icons in panel:** **`.cursor/rules/exxat-fontawesome-icons.mdc`**.
166
168
 
167
169
  ### 4.7 Collaboration & access (shared hubs)
168
170
 
@@ -534,7 +536,7 @@ New pages **SHOULD** namespace keys and version JSON (`v: 1`) for future migrati
534
536
  | Data view charts: **`ChartFigure`** + **`ChartDataTable`**; keyboard highlight via **`chart-keyboard-selection`** (§4.3); layout via **`data-view-dashboard-storage`** | Ad-hoc `localStorage` keys for dashboard layout; opacity-only “selection” without `activeBar`/`activeShape` |
535
537
  | Board cards: **`ListPageBoardCard`** shell; status via **`ListHubStatusBadge`** + **`lib/list-status-badges`**; no **`uppercase`** on status chips (§4.4) | One-off board card markup; status as plain body text; duplicated status maps outside **`list-status-badges`**; **empty placeholder** primary hubs (§4.1) |
536
538
  | **§4.5** — Non-table view bodies use **`ListPageViewFrame`** (+ **`data-views/`** shells); new grids are generic components, not route-only markup | Duplicated `mx-4` / `max-w-*` per hub; wrapping **`DataTable`** so inset **doubles** (**§5**) |
537
- | **§4.6** — **`secondaryPanel`** + **`PANELS`** + **`useAutoPanel`** together for nested scope nav | **`secondaryPanel`** id with no panel component or activator |
539
+ | **§4.6** — **`secondaryPanel`** + **`PANELS`** + **`useAutoPanel`** together for nested scope nav; **folder URL scope** → header **⋯** **Customize folder** + client-mounted **`QuestionBankNewFolderSheet`** (**`exxat-question-bank-hub-header.mdc`**) | **`secondaryPanel`** id with no panel component or activator; folder scope with customize **only** inside a single view tab’s subtree |
538
540
  | **§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 |
539
541
  | **§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 |
540
542
  | **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 |
@@ -570,7 +572,7 @@ Copy and complete when implementing or reviewing:
570
572
  - [ ] **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.
571
573
  - [ ] **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`**.
572
574
  - [ ] **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`**.
573
- - [ ] **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`**.
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`**.
574
576
  - [ ] **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`**.
575
577
  - [ ] **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`**.
576
578
  - [ ] **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`**.
@@ -578,4 +580,4 @@ Copy and complete when implementing or reviewing:
578
580
 
579
581
  ---
580
582
 
581
- *Last updated: 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.*
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.*
@@ -4,11 +4,15 @@ import * as React from "react"
4
4
  import { usePathname } from "next/navigation"
5
5
 
6
6
  import { useSecondaryPanel } from "@/components/secondary-panel"
7
- import { QUESTION_BANK_ENTRY_PATH } from "@/lib/question-bank-nav"
7
+ import {
8
+ QUESTION_BANK_ENTRY_PATH,
9
+ QUESTION_BANK_HUB_FIND_PATH,
10
+ QUESTION_BANK_LIST_PATH,
11
+ } from "@/lib/question-bank-nav"
8
12
 
9
13
  /**
10
- * Keeps the nested secondary panel open across library / list / find navigations.
11
- * The discovery hub (`/question-bank`) is full-width — no secondary bar there.
14
+ * Keeps the nested secondary panel open across library navigations.
15
+ * **Question hub** (`/question-bank`) and **Search** (`/find`, `/list`) stay full-width — no secondary rail.
12
16
  */
13
17
  export default function QuestionBankLayout({ children }: { children: React.ReactNode }) {
14
18
  const pathname = usePathname()
@@ -30,7 +34,10 @@ export default function QuestionBankLayout({ children }: { children: React.React
30
34
  const isDiscoveryHubRoot =
31
35
  pathname === QUESTION_BANK_ENTRY_PATH || pathname === `${QUESTION_BANK_ENTRY_PATH}/`
32
36
 
33
- if (isDiscoveryHubRoot) {
37
+ const isDedicatedSearchSurface =
38
+ pathname === QUESTION_BANK_HUB_FIND_PATH || pathname === QUESTION_BANK_LIST_PATH
39
+
40
+ if (isDiscoveryHubRoot || isDedicatedSearchSurface) {
34
41
  closePanelRef.current({ mainSidebar: "leave" })
35
42
  return undefined
36
43
  }
@@ -151,6 +151,14 @@ html[data-text-size="large"] {
151
151
  via a scoped class, not globally on :root. */
152
152
  /* Minimum readable UI copy: 11px (`text-xs` / --text-xs). Do not use smaller arbitrary sizes. */
153
153
 
154
+ /* ── Layout ─────────────────────────────────────────────────── */
155
+ /* SiteHeader / breadcrumb height. Mirrored on the SidebarProvider for
156
+ descendants; declared here too so JS that reads from documentElement
157
+ (e.g. DataTable sticky-head offset) can resolve it. Plain px so
158
+ `parseFloat` can read it as a number. Keep in sync with `headerHeight`
159
+ in components/sidebar-shell.tsx (currently `calc(var(--spacing) * 12)` = 48px). */
160
+ --header-height: 48px;
161
+
154
162
  /* ── Typography ─────────────────────────────────────────────── */
155
163
  /* Ivy Presto loaded via Adobe Fonts Kit wuk5wqn (use.typekit.net) */
156
164
  --font-heading: "ivypresto-text";
@@ -171,6 +179,19 @@ html[data-text-size="large"] {
171
179
  --brand-preview-one: var(--brand-tint);
172
180
  --brand-preview-prism: oklch(0.57 0.24 342);
173
181
 
182
+ /**
183
+ * Ask Leo panel tints (same mixes as the vertical wash). Use for blobs, cards, etc.
184
+ * `--leo-surface-gradient` is the full linear wash on `AskLeoSidebar`.
185
+ */
186
+ --leo-surface-tint-a: color-mix(in oklch, var(--brand-color) 4%, var(--background));
187
+ --leo-surface-tint-b: color-mix(in oklch, var(--brand-color) 8%, var(--background));
188
+ --leo-surface-gradient: linear-gradient(180deg, var(--leo-surface-tint-a) 0%, var(--leo-surface-tint-b) 100%);
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));
194
+
174
195
  /* ── Surfaces ────────────────────────────────────────────────── */
175
196
  --background: oklch(1 0 0);
176
197
  --foreground: oklch(0.145 0 0); /* ≈ #1A1A1A — 17:1 on white ✓ */
@@ -249,7 +270,8 @@ html[data-text-size="large"] {
249
270
  --sidebar-ring: oklch(0.25 0 0);
250
271
  /* Nav section titles — ≥4.5:1 vs --sidebar (not vs page white) */
251
272
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 58%, var(--sidebar));
252
- --secondary-panel-bg: oklch(0.99 0.008 286.1);
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));
253
275
 
254
276
  /* Browser UI (meta theme-color) — aligned with --brand-tint */
255
277
  --theme-color-chrome: #f3f2f8;
@@ -357,6 +379,10 @@ html[data-text-size="large"] {
357
379
  --destructive: oklch(0.65 0.20 25); /* brighter for dark bg */
358
380
  --destructive-foreground: oklch(0.10 0 0);
359
381
 
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));
385
+
360
386
  /* Borders — visible but not washed out on dark surfaces */
361
387
  --border: oklch(0.38 0.008 270);
362
388
  --border-control: oklch(0.72 0.012 270);
@@ -405,7 +431,8 @@ html[data-text-size="large"] {
405
431
  --sidebar-border: oklch(0.38 0.010 270);
406
432
  --sidebar-ring: oklch(0.85 0 0);
407
433
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 48%, var(--sidebar));
408
- --secondary-panel-bg: oklch(0.23 0.02 270);
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%);
409
436
  --theme-color-chrome: #2f2d36;
410
437
 
411
438
  /* Lifted scrim on dark — white-tinted veil, not heavy black */
@@ -52,6 +52,7 @@ import {
52
52
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
53
53
  import { Badge } from "@/components/ui/badge"
54
54
  import { StatusBadge } from "@/components/ui/status-badge"
55
+ import { Separator } from "@/components/ui/separator"
55
56
  import {
56
57
  Tooltip,
57
58
  TooltipContent,
@@ -108,7 +109,8 @@ function normalizedLocationHash(locationHash: string): string {
108
109
  /**
109
110
  * Whether `pathname` (+ optional `location.hash`) matches a sidebar `href`.
110
111
  * When several links share the same path (e.g. `/settings`), disambiguate with `#fragment`
111
- * and require an empty hash for the “default” row (`/settings` with no `#`).
112
+ * in each `href` those rows use the `frag !== null` branch below.
113
+ * For `href` without `#…`, an in-page hash (e.g. QB view tabs) does not clear the match.
112
114
  */
113
115
  function isNavActive(pathname: string, url: string, locationHash = ""): boolean {
114
116
  const pathOnly = navUrlPath(url)
@@ -129,7 +131,8 @@ function isNavActive(pathname: string, url: string, locationHash = ""): boolean
129
131
  }
130
132
 
131
133
  if (pathOnly === "/") return pathname === "/" && h === ""
132
- if (pathname === pathOnly) return h === ""
134
+ /** Exact path match — ignore `location.hash` when the nav `href` has no `#…` fragment (QB view tabs use hash). */
135
+ if (pathname === pathOnly) return true
133
136
  // Design system library — active on hub and detail routes.
134
137
  if (pathOnly === "/library") {
135
138
  return pathname.startsWith("/library/")
@@ -165,6 +168,16 @@ function isCollapsibleChildActive(
165
168
 
166
169
  if (!isNavActive(pathname, child.url, locationHash)) return false
167
170
 
171
+ /** Hub entry (`/question-bank`) must not stay “active” on `/question-bank/library` etc. */
172
+ if (parent.primaryHubChildKey && child.key === parent.primaryHubChildKey) {
173
+ const hubPath = navUrlPath(parent.url)
174
+ if (hubPath) {
175
+ const normalized =
176
+ pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname
177
+ if (normalized !== hubPath) return false
178
+ }
179
+ }
180
+
168
181
  const urls = children.map(c => c.url)
169
182
  const allSameUrl = urls.length > 1 && urls.every(u => u === urls[0])
170
183
  if (allSameUrl) {
@@ -176,6 +189,32 @@ function isCollapsibleChildActive(
176
189
  return true
177
190
  }
178
191
 
192
+ /**
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).
198
+ */
199
+ function isCollapsibleParentMenuButtonActive(
200
+ pathname: string,
201
+ item: NavLinkItem,
202
+ locationHash: string,
203
+ ): boolean {
204
+ const children = item.children
205
+ if (!children?.length) return isNavActive(pathname, item.url, locationHash)
206
+
207
+ const activeChildren = children.filter(c =>
208
+ isCollapsibleChildActive(pathname, item, c, locationHash),
209
+ )
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)
216
+ }
217
+
179
218
  /** Accessible suffix for sidebar badges (badge is rendered outside the link node). */
180
219
  function badgeAccessibleSuffix(badge: number | string): string {
181
220
  if (typeof badge === "number") return `${badge} items`
@@ -183,30 +222,40 @@ function badgeAccessibleSuffix(badge: number | string): string {
183
222
  }
184
223
 
185
224
  /** Child row for expandable nav items — shared by inline sub-menu and collapsed-rail popover. */
186
- function SidebarNavChildLink({
187
- parent,
188
- child,
189
- pathname,
190
- locationHash,
191
- onNavigate,
192
- linkClassName,
193
- }: {
194
- parent: NavLinkItem
195
- child: NavLinkItem
196
- pathname: string
197
- locationHash: string
198
- onNavigate?: () => void
199
- /** Popover uses surface tokens; inline sub-menu uses `SidebarMenuSubButton`. */
200
- linkClassName?: string
201
- }) {
225
+ const SidebarNavChildLink = React.forwardRef<
226
+ HTMLAnchorElement,
227
+ {
228
+ parent: NavLinkItem
229
+ child: NavLinkItem
230
+ pathname: string
231
+ locationHash: string
232
+ onNavigate?: () => void
233
+ /** Popover uses surface tokens; inline sub-menu uses `SidebarMenuSubButton`. */
234
+ linkClassName?: string
235
+ } & Omit<React.ComponentPropsWithoutRef<typeof Link>, "href">
236
+ >(function SidebarNavChildLink(
237
+ {
238
+ parent,
239
+ child,
240
+ pathname,
241
+ locationHash,
242
+ onNavigate,
243
+ linkClassName,
244
+ className: incomingClassName,
245
+ onClick,
246
+ ...linkRest
247
+ },
248
+ ref,
249
+ ) {
202
250
  const { openPanel } = useSecondaryPanel()
203
251
  const childActive = isCollapsibleChildActive(pathname, parent, child, locationHash)
204
252
  const childPath = navUrlPath(child.url)
205
253
 
206
254
  return (
207
255
  <Link
256
+ ref={ref}
208
257
  href={child.url}
209
- className={cn("flex min-w-0 items-center gap-2", linkClassName)}
258
+ className={cn("flex min-w-0 items-center gap-2", linkClassName, incomingClassName)}
210
259
  aria-current={childActive ? "page" : undefined}
211
260
  onClick={e => {
212
261
  onNavigate?.()
@@ -218,15 +267,18 @@ function SidebarNavChildLink({
218
267
  e.preventDefault()
219
268
  openPanel(parent.secondaryPanel)
220
269
  }
270
+ onClick?.(e)
221
271
  }}
272
+ {...linkRest}
222
273
  >
223
274
  <span className="size-4 shrink-0 inline-flex items-center justify-center" aria-hidden="true">
224
- {child.icon}
275
+ {childActive && child.iconActive ? child.iconActive : child.icon}
225
276
  </span>
226
277
  <span className="min-w-0 flex-1 truncate">{child.title}</span>
227
278
  </Link>
228
279
  )
229
- }
280
+ })
281
+ SidebarNavChildLink.displayName = "SidebarNavChildLink"
230
282
 
231
283
  /**
232
284
  * CollapsibleNavItem — isolated component so each collapsible has its own
@@ -235,19 +287,17 @@ function SidebarNavChildLink({
235
287
  * server (SSR) vs the client (router not yet available).
236
288
  */
237
289
  function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: string }) {
238
- const locationHash = useLocationHash()
239
- const isActive = isNavActive(pathname, item.url, locationHash)
290
+ const locationHash = useLocationHash()
240
291
  const isAnyChildActive =
241
292
  item.children?.some(c => isCollapsibleChildActive(pathname, item, c, locationHash)) ?? false
293
+ const parentMenuButtonActive = isCollapsibleParentMenuButtonActive(pathname, item, locationHash)
242
294
  const { state, isMobile } = useSidebar()
243
- const { openPanel } = useSecondaryPanel()
244
295
  const [open, setOpen] = React.useState(false)
245
296
  const [flyoutOpen, setFlyoutOpen] = React.useState(false)
246
297
  const flyoutTitleId = React.useId()
247
298
  const iconRailCollapsed = state === "collapsed" && !isMobile
248
- const showActiveStyle = isActive || isAnyChildActive
249
299
  const triggerIcon =
250
- showActiveStyle && item.iconActive ? item.iconActive : item.icon
300
+ parentMenuButtonActive && item.iconActive ? item.iconActive : item.icon
251
301
 
252
302
  React.useEffect(() => {
253
303
  setOpen(isAnyChildActive)
@@ -267,23 +317,20 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
267
317
  open={flyoutOpen}
268
318
  onOpenChange={next => {
269
319
  setFlyoutOpen(next)
270
- if (next && item.secondaryPanel) {
271
- openPanel(item.secondaryPanel)
272
- }
273
320
  }}
274
321
  >
275
322
  <Tooltip>
276
323
  <TooltipTrigger asChild>
277
324
  <PopoverTrigger asChild>
278
325
  <SidebarMenuButton
279
- isActive={showActiveStyle}
326
+ isActive={parentMenuButtonActive}
280
327
  aria-haspopup="dialog"
281
328
  aria-label={`${item.title} — open subpages`}
282
329
  >
283
330
  <span
284
331
  className={cn(
285
332
  "size-4 shrink-0 flex items-center justify-center",
286
- showActiveStyle &&
333
+ parentMenuButtonActive &&
287
334
  "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
288
335
  )}
289
336
  aria-hidden="true"
@@ -341,9 +388,6 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
341
388
  open={open}
342
389
  onOpenChange={next => {
343
390
  setOpen(next)
344
- if (next && item.secondaryPanel) {
345
- openPanel(item.secondaryPanel)
346
- }
347
391
  }}
348
392
  asChild
349
393
  >
@@ -351,12 +395,12 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
351
395
  <Tooltip>
352
396
  <TooltipTrigger asChild>
353
397
  <CollapsibleTrigger asChild>
354
- <SidebarMenuButton isActive={showActiveStyle}>
398
+ <SidebarMenuButton isActive={parentMenuButtonActive}>
355
399
  <span
356
- key={showActiveStyle ? "active" : "idle"}
400
+ key={parentMenuButtonActive ? "active" : "idle"}
357
401
  className={cn(
358
402
  "size-4 shrink-0 flex items-center justify-center",
359
- showActiveStyle &&
403
+ parentMenuButtonActive &&
360
404
  "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
361
405
  )}
362
406
  aria-hidden="true"
@@ -769,10 +813,7 @@ function ProductLogoButton() {
769
813
  >
770
814
  {iconRail ? (
771
815
  <span className="flex size-8 shrink-0 items-center justify-center">
772
- <ExxatProductMark
773
- product={current.id}
774
- className="size-7 max-h-none"
775
- />
816
+ <ExxatProductMark product={current.id} className="size-7" />
776
817
  </span>
777
818
  ) : (
778
819
  <span className="flex min-h-0 min-w-0 flex-1 items-stretch gap-2">
@@ -901,6 +942,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
901
942
  <ProductLogoButton />
902
943
  </SidebarMenuItem>
903
944
  </SidebarMenu>
945
+ <div className="flex w-full justify-center px-2">
946
+ <Separator
947
+ orientation="horizontal"
948
+ decorative
949
+ className="my-1.5 h-px w-full max-w-none shrink-0 bg-sidebar-border group-data-[collapsible=icon]:w-8"
950
+ />
951
+ </div>
904
952
  <TeamSwitcher />
905
953
  </SidebarHeaderStack>
906
954
  </SidebarHeader>
@@ -286,8 +286,7 @@ export function AskLeoSidebar() {
286
286
  style={
287
287
  open
288
288
  ? {
289
- background:
290
- "linear-gradient(180deg, color-mix(in oklch, var(--brand-color) 4%, var(--background)) 0%, color-mix(in oklch, var(--brand-color) 8%, var(--background)) 100%)",
289
+ background: "var(--leo-surface-gradient)",
291
290
  }
292
291
  : undefined
293
292
  }
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * FinderPanelView — Miller-style 3-column split for list-page hubs.
5
5
  *
6
- * Visual shell matches Question bank panel (`ListPageTreeColumnHeader`, `bg-muted/15` columns,
6
+ * Visual shell matches Question bank panel (`ListPageTreeColumnHeader`, `LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS`,
7
7
  * shared resizable handles) — see `list-page-split-hub-tokens.ts`.
8
8
  */
9
9
 
@@ -142,7 +142,7 @@ export function FinderGroupStrip({
142
142
  <div
143
143
  role="toolbar"
144
144
  aria-label={ariaLabel}
145
- className="flex min-h-10 flex-wrap items-center gap-1.5 border-b border-border bg-muted/15 px-2 py-2"
145
+ className="flex min-h-10 flex-wrap items-center gap-1.5 border-b border-border bg-card px-2 py-2"
146
146
  >
147
147
  {groups.map(group => {
148
148
  const isSelected = group.id === selectedGroupId
@@ -47,6 +47,25 @@ export {
47
47
  type ListPageTreeColumnHeaderProps,
48
48
  } from "@/components/data-views/list-page-tree-column-header"
49
49
 
50
+ /** VS Code–style outline tree chrome — mirrors shadcn `SidebarMenuSub` (see module doc). */
51
+ export {
52
+ OutlineTreeCollapsibleContentRail,
53
+ OutlineTreeLeafButton,
54
+ OutlineTreeMenu,
55
+ OutlineTreeMenuItem,
56
+ OutlineTreeSub,
57
+ OutlineTreeSubItem,
58
+ OUTLINE_TREE_COLLAPSIBLE_CONTENT_RAIL_CLASS,
59
+ OUTLINE_TREE_CHEVRON_GUIDE_SPACER_CLASS,
60
+ OUTLINE_TREE_SUB_ROW_SHIFT_CLASS,
61
+ type OutlineTreeGuideLayout,
62
+ type OutlineTreeLeafButtonProps,
63
+ type OutlineTreeSurface,
64
+ } from "@/components/data-views/outline-tree-menu"
65
+
66
+ export { QuestionBankFolderTreeBranch } from "@/components/data-views/question-bank-folder-tree-branch"
67
+ export type { QuestionBankFolderTreeBranchProps } from "@/components/data-views/question-bank-folder-tree-branch"
68
+
50
69
  export {
51
70
  LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
52
71
  LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,