@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.
- package/CHANGELOG.md +11 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +1 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +18 -15
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +108 -1
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +68 -34
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +172 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +74 -46
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +18 -132
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
- package/template/components/product-switcher.tsx +26 -8
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +108 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
package/template/AGENTS.md
CHANGED
|
@@ -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**
|
|
22
|
-
10. **Before** adding **
|
|
23
|
-
11. **Before** adding **
|
|
24
|
-
12. **Before**
|
|
25
|
-
13. **Before**
|
|
26
|
-
14. **Before**
|
|
27
|
-
15.
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
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
|
-
<
|
|
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`)
|
|
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
|
-
|
|
23
|
-
|
|
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
|
+
}
|
package/template/app/globals.css
CHANGED
|
@@ -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
|
|
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); }
|
package/template/app/layout.tsx
CHANGED
|
@@ -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
|
-
{/*
|
|
63
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
{/*
|
|
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
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
|
211
|
+
const anyChildActive = children.some(c =>
|
|
208
212
|
isCollapsibleChildActive(pathname, item, c, locationHash),
|
|
209
213
|
)
|
|
210
|
-
if (
|
|
211
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
786
|
-
{ id: "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
|
|
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-
|
|
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="
|
|
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
|
-
{
|
|
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="
|
|
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
|