@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
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* PlacementsTable — placements hub composition on top of the generic
|
|
5
|
+
* `DataTable`. Owns: placement-specific column defs, board column grouping,
|
|
6
|
+
* KPI dashboards, the "open table properties" imperative handle, and the
|
|
7
|
+
* lifecycle persistence wiring (via `useTableStateLifecycle`).
|
|
8
|
+
*
|
|
9
|
+
* NOTE: this is hub composition, NOT a parallel table primitive. Every hub
|
|
10
|
+
* has its own `*-table.tsx` of the same shape (`team-table.tsx`,
|
|
11
|
+
* `compliance-table.tsx`, …); all of them render `<DataTable>` from
|
|
12
|
+
* `@/components/data-table`.
|
|
5
13
|
*
|
|
6
14
|
* View tabs drive `view` (table | list | board | …). `lifecycleTabId` selects which **demo row
|
|
7
15
|
* segment** (columns + filtered rows) to use — keep in sync with each tab's `filterId`, or pass
|
|
@@ -11,6 +19,7 @@
|
|
|
11
19
|
import * as React from "react"
|
|
12
20
|
import dynamic from "next/dynamic"
|
|
13
21
|
import { cn } from "@/lib/utils"
|
|
22
|
+
import { mailtoHref } from "@/lib/mailto"
|
|
14
23
|
import { useRouter } from "next/navigation"
|
|
15
24
|
import { Button } from "@/components/ui/button"
|
|
16
25
|
import { Tip } from "@/components/ui/tip"
|
|
@@ -48,15 +57,14 @@ import {
|
|
|
48
57
|
DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
|
|
49
58
|
type DataListDisplayOptions,
|
|
50
59
|
} from "@/lib/data-list-display-options"
|
|
51
|
-
import {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} from "@/lib/data-list-persistence"
|
|
60
|
+
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
61
|
+
import type { PlacementsLifecycleExtras } from "@/lib/data-list-persistence"
|
|
62
|
+
|
|
63
|
+
/** Storage namespace for the placements hub. Keep `"data-list"` so existing
|
|
64
|
+
* user payloads in localStorage remain readable. */
|
|
65
|
+
const PLACEMENTS_LIFECYCLE_NAMESPACE = "data-list"
|
|
58
66
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
59
|
-
import { StatusBadge } from "@/components/
|
|
67
|
+
import { StatusBadge } from "@/components/placements-table-cells"
|
|
60
68
|
import { columnsToFilterFields } from "@/components/placements-table-columns"
|
|
61
69
|
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
62
70
|
import { CountSyncer, PaginationBar } from "@/components/data-table/pagination"
|
|
@@ -127,9 +135,14 @@ function DataListBoardShell({
|
|
|
127
135
|
displayOptions: DataListDisplayOptions
|
|
128
136
|
onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
|
|
129
137
|
}) {
|
|
138
|
+
// Store the "open properties drawer" callback on a stable ref so the parent
|
|
139
|
+
// imperative handle can invoke it without re-rendering the whole table.
|
|
140
|
+
// `state` is freshly returned each render by useTableState; only the React
|
|
141
|
+
// setter is stable and needed here.
|
|
130
142
|
React.useEffect(() => {
|
|
131
143
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
132
|
-
|
|
144
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
133
146
|
|
|
134
147
|
const boardColumnMenu: PlacementsBoardColumnMenu = React.useMemo(
|
|
135
148
|
() => ({
|
|
@@ -248,9 +261,11 @@ function DataListListShell({
|
|
|
248
261
|
listRows: Placement[]
|
|
249
262
|
emptyTableCopy: string
|
|
250
263
|
}) {
|
|
264
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
251
265
|
React.useEffect(() => {
|
|
252
266
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
253
|
-
|
|
267
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
268
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
254
269
|
|
|
255
270
|
return (
|
|
256
271
|
<>
|
|
@@ -336,9 +351,11 @@ function DataListDashboardShell({
|
|
|
336
351
|
displayOptions: DataListDisplayOptions
|
|
337
352
|
onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
|
|
338
353
|
}) {
|
|
354
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
339
355
|
React.useEffect(() => {
|
|
340
356
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
341
|
-
|
|
357
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
358
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
342
359
|
|
|
343
360
|
const dashboardKpi = React.useMemo(
|
|
344
361
|
() => ({
|
|
@@ -627,9 +644,11 @@ function DataListFolderShell({
|
|
|
627
644
|
}) {
|
|
628
645
|
const router = useRouter()
|
|
629
646
|
|
|
647
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
630
648
|
React.useEffect(() => {
|
|
631
649
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
632
|
-
|
|
650
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
651
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
633
652
|
|
|
634
653
|
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
635
654
|
|
|
@@ -735,9 +754,11 @@ function DataListTreeShell({
|
|
|
735
754
|
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
736
755
|
const [selectedId, setSelectedId] = React.useState<number | null>(() => listRows[0]?.id ?? null)
|
|
737
756
|
|
|
757
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
738
758
|
React.useEffect(() => {
|
|
739
759
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
740
|
-
|
|
760
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
761
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
741
762
|
|
|
742
763
|
React.useEffect(() => {
|
|
743
764
|
if (selectedId == null) {
|
|
@@ -941,7 +962,7 @@ function PlacementFinderDetail({
|
|
|
941
962
|
<i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
|
|
942
963
|
</dt>
|
|
943
964
|
<dd className="text-[13px]">
|
|
944
|
-
<a href={
|
|
965
|
+
<a href={mailtoHref(row.email)} className="text-interactive-foreground hover:underline">{row.email}</a>
|
|
945
966
|
</dd>
|
|
946
967
|
</div>
|
|
947
968
|
)}
|
|
@@ -1099,9 +1120,11 @@ function DataListPanelShell({
|
|
|
1099
1120
|
panelRenderListRow?: (row: Placement, isSelected: boolean) => React.ReactNode
|
|
1100
1121
|
panelRenderDetail?: (row: Placement) => React.ReactNode
|
|
1101
1122
|
}) {
|
|
1123
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
1102
1124
|
React.useEffect(() => {
|
|
1103
1125
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
1104
|
-
|
|
1126
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1127
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
1105
1128
|
|
|
1106
1129
|
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
1107
1130
|
const groups = React.useMemo(
|
|
@@ -1187,7 +1210,7 @@ function DataListPanelShell({
|
|
|
1187
1210
|
// Props
|
|
1188
1211
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1189
1212
|
|
|
1190
|
-
export interface
|
|
1213
|
+
export interface PlacementsTableProps {
|
|
1191
1214
|
view?: DataListViewType
|
|
1192
1215
|
onViewChange?: (view: DataListViewType) => void
|
|
1193
1216
|
/** Demo row segment: drives filtered rows + column set (`all` | `upcoming` | `ongoing` | `completed`). */
|
|
@@ -1210,13 +1233,13 @@ export interface DataListTableProps {
|
|
|
1210
1233
|
}
|
|
1211
1234
|
|
|
1212
1235
|
/** Imperative handle — open Table Properties (table view only). */
|
|
1213
|
-
export type
|
|
1236
|
+
export type PlacementsTableHandle = OpenTablePropertiesHandle
|
|
1214
1237
|
|
|
1215
1238
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1216
1239
|
// Main component
|
|
1217
1240
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1218
1241
|
|
|
1219
|
-
export const
|
|
1242
|
+
export const PlacementsTable = React.forwardRef<PlacementsTableHandle, PlacementsTableProps>(function PlacementsTable({
|
|
1220
1243
|
view = "table",
|
|
1221
1244
|
onViewChange,
|
|
1222
1245
|
lifecycleTabId = "all",
|
|
@@ -1320,50 +1343,36 @@ export const DataListTable = React.forwardRef<DataListTableHandle, DataListTable
|
|
|
1320
1343
|
|
|
1321
1344
|
const columnKeys = React.useMemo(() => new Set(columns.map(c => c.key)), [columns])
|
|
1322
1345
|
|
|
1323
|
-
|
|
1324
|
-
const raw = loadLifecycleFromStorage(lifecycleTabId)
|
|
1325
|
-
if (!raw) return
|
|
1326
|
-
applyLifecyclePersisted(tableState as unknown as TableStatePersistSlice, raw, columnKeys)
|
|
1327
|
-
setConditionalRules(raw.conditionalRules)
|
|
1328
|
-
setPagination(raw.pagination)
|
|
1329
|
-
setPaginationPage(raw.paginationPage)
|
|
1330
|
-
setPaginationPageSize(raw.paginationPageSize)
|
|
1331
|
-
}, [lifecycleTabId, columnKeys])
|
|
1332
|
-
|
|
1346
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
1333
1347
|
React.useEffect(() => {
|
|
1334
1348
|
openDrawerRef.current = () => tableState.setSheetOpen(true)
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1349
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1350
|
+
}, [openDrawerRef, tableState.setSheetOpen])
|
|
1351
|
+
|
|
1352
|
+
// ── Lifecycle persistence ─────────────────────────────────────────────
|
|
1353
|
+
// Centralised in `lib/table-state-lifecycle` — one hook wires both the
|
|
1354
|
+
// load (layout effect) and the debounced save (effect) including all the
|
|
1355
|
+
// table slices plus placements-specific extras. Hubs that don't want
|
|
1356
|
+
// localStorage persistence simply don't call this hook.
|
|
1357
|
+
useTableStateLifecycle<PlacementsLifecycleExtras>({
|
|
1358
|
+
namespace: PLACEMENTS_LIFECYCLE_NAMESPACE,
|
|
1359
|
+
tabId: lifecycleTabId,
|
|
1360
|
+
tableState,
|
|
1361
|
+
columnKeys,
|
|
1362
|
+
extras: {
|
|
1339
1363
|
conditionalRules,
|
|
1340
1364
|
pagination,
|
|
1341
1365
|
paginationPage: safePage,
|
|
1342
1366
|
paginationPageSize,
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
tableState.colOrder,
|
|
1353
|
-
tableState.hiddenCols,
|
|
1354
|
-
tableState.colWidths,
|
|
1355
|
-
tableState.colPins,
|
|
1356
|
-
tableState.colWrap,
|
|
1357
|
-
tableState.colMenuSearch,
|
|
1358
|
-
tableState.rowHeight,
|
|
1359
|
-
tableState.showGridlines,
|
|
1360
|
-
tableState.filterBarVisible,
|
|
1361
|
-
tableState.searchOpen,
|
|
1362
|
-
conditionalRules,
|
|
1363
|
-
pagination,
|
|
1364
|
-
safePage,
|
|
1365
|
-
paginationPageSize,
|
|
1366
|
-
])
|
|
1367
|
+
},
|
|
1368
|
+
onLoadExtras: e => {
|
|
1369
|
+
if (!e) return
|
|
1370
|
+
if (Array.isArray(e.conditionalRules)) setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
1371
|
+
if (typeof e.pagination === "boolean") setPagination(e.pagination)
|
|
1372
|
+
if (typeof e.paginationPage === "number") setPaginationPage(e.paginationPage)
|
|
1373
|
+
if (typeof e.paginationPageSize === "number") setPaginationPageSize(e.paginationPageSize)
|
|
1374
|
+
},
|
|
1375
|
+
})
|
|
1367
1376
|
|
|
1368
1377
|
function buildToolbarSlot(
|
|
1369
1378
|
s: ReturnType<typeof useTableState<Placement>>,
|
|
@@ -1658,7 +1667,7 @@ export const DataListTable = React.forwardRef<DataListTableHandle, DataListTable
|
|
|
1658
1667
|
return <DataTable<Placement> key={lifecycleTabId} {...tableProps} />
|
|
1659
1668
|
})
|
|
1660
1669
|
|
|
1661
|
-
|
|
1670
|
+
PlacementsTable.displayName = "PlacementsTable"
|
|
1662
1671
|
|
|
1663
1672
|
|
|
1664
1673
|
export type { DataListViewType } from "@/lib/data-list-view"
|
|
@@ -18,17 +18,30 @@ import {
|
|
|
18
18
|
} from "@/components/ui/sidebar"
|
|
19
19
|
import { ExxatProductLogo, ExxatProductMark } from "@/components/exxat-product-logo"
|
|
20
20
|
import { useProduct, type Product } from "@/contexts/product-context"
|
|
21
|
+
import { customProductBrandConfig, productBrandLabel } from "@/lib/product-brand"
|
|
21
22
|
|
|
22
23
|
const PRODUCTS: { id: Product; label: string }[] = [
|
|
23
|
-
{ id: "exxat-one",
|
|
24
|
-
{ id: "exxat-prism",
|
|
24
|
+
{ id: "exxat-one", label: "Exxat One" },
|
|
25
|
+
{ id: "exxat-prism", label: "Exxat Prism" },
|
|
26
|
+
{ id: "exxat-assessment", label: "Exxat Assessment" },
|
|
27
|
+
{ id: "exxat-custom", label: "Custom product" },
|
|
25
28
|
]
|
|
26
29
|
|
|
27
30
|
export function ProductSwitcher() {
|
|
28
|
-
const { product, setProduct } = useProduct()
|
|
31
|
+
const { product, setProduct, customProductBrand, hiddenProductIds } = useProduct()
|
|
29
32
|
const { state, isMobile } = useSidebar()
|
|
30
33
|
|
|
31
|
-
const
|
|
34
|
+
const products = React.useMemo(
|
|
35
|
+
() => PRODUCTS.flatMap(p => {
|
|
36
|
+
if (hiddenProductIds.includes(p.id)) return []
|
|
37
|
+
if (p.id !== "exxat-custom") return [p]
|
|
38
|
+
return customProductBrand
|
|
39
|
+
? [{ ...p, label: productBrandLabel(customProductBrandConfig(customProductBrand)) }]
|
|
40
|
+
: []
|
|
41
|
+
}),
|
|
42
|
+
[customProductBrand, hiddenProductIds],
|
|
43
|
+
)
|
|
44
|
+
const current = products.find(p => p.id === product) ?? products[0]
|
|
32
45
|
const iconRail = state === "collapsed" && !isMobile
|
|
33
46
|
const expandedOrMobile = state === "expanded" || isMobile
|
|
34
47
|
|
|
@@ -51,8 +64,10 @@ export function ProductSwitcher() {
|
|
|
51
64
|
suppressHydrationWarning
|
|
52
65
|
>
|
|
53
66
|
{iconRail ? (
|
|
67
|
+
// Collapsed icon-rail product mark must read as a peer of the
|
|
68
|
+
// school selector, without a white cutout patch on the rail.
|
|
54
69
|
<span className="flex size-8 shrink-0 items-center justify-center">
|
|
55
|
-
<ExxatProductMark product={current.id} className="size-
|
|
70
|
+
<ExxatProductMark product={current.id} className="size-8" cutoutColor="var(--sidebar)" />
|
|
56
71
|
</span>
|
|
57
72
|
) : (
|
|
58
73
|
<>
|
|
@@ -63,7 +78,7 @@ export function ProductSwitcher() {
|
|
|
63
78
|
<ExxatProductLogo
|
|
64
79
|
product={current.id}
|
|
65
80
|
variant="mutedSuffix"
|
|
66
|
-
className="
|
|
81
|
+
className="w-auto max-w-[min(100%,260px)]"
|
|
67
82
|
/>
|
|
68
83
|
</span>
|
|
69
84
|
<i
|
|
@@ -84,7 +99,7 @@ export function ProductSwitcher() {
|
|
|
84
99
|
Switch product
|
|
85
100
|
</DropdownMenuLabel>
|
|
86
101
|
<DropdownMenuSeparator />
|
|
87
|
-
{
|
|
102
|
+
{products.map(p => (
|
|
88
103
|
<DropdownMenuItem
|
|
89
104
|
key={p.id}
|
|
90
105
|
onClick={() => setProduct(p.id)}
|
|
@@ -94,7 +109,10 @@ export function ProductSwitcher() {
|
|
|
94
109
|
<ExxatProductLogo
|
|
95
110
|
product={p.id}
|
|
96
111
|
variant="mutedSuffix"
|
|
97
|
-
|
|
112
|
+
// h-9 matches the sidebar trigger so the mark renders at the
|
|
113
|
+
// same 32 px footprint in both contexts. Dropdown rows
|
|
114
|
+
// accommodate the bump via `py-2` on `DropdownMenuItem`.
|
|
115
|
+
className="h-9 w-auto shrink-0 max-w-[min(100%,240px)]"
|
|
98
116
|
/>
|
|
99
117
|
{p.id === product && (
|
|
100
118
|
<i
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProductWordmark + ProductMark — render any product brand as a logo.
|
|
5
|
+
*
|
|
6
|
+
* - `ProductWordmark` renders `${prefix} ${suffix}` as HTML text:
|
|
7
|
+
* • `prefix` (e.g. "Exxat") in `font-sans` extra-bold (Inter 800), neutral.
|
|
8
|
+
* • `suffix` (e.g. "One" / "Prism" / "Pulse") in **Ivy Presto Italic**
|
|
9
|
+
* (`var(--font-heading)`, Adobe Fonts kit `wuk5wqn` preloaded in
|
|
10
|
+
* `app/layout.tsx`) tinted with `brandColor`.
|
|
11
|
+
*
|
|
12
|
+
* We render real font glyphs rather than baked-in SVG paths so a new product
|
|
13
|
+
* only needs `{ prefix, suffix, brandColor }` — no path-tracing required.
|
|
14
|
+
*
|
|
15
|
+
* - `ProductMark` renders the same "E"-style circular mark used by Exxat,
|
|
16
|
+
* recolored with the brand's gradient / fill. The SVG geometry stays
|
|
17
|
+
* constant so existing layouts keep working.
|
|
18
|
+
*
|
|
19
|
+
* `variant="mutedSuffix"` (used by the product switcher / sidebar): in **dark**
|
|
20
|
+
* mode only, the suffix tints to `--muted-foreground` so the wordmark recedes
|
|
21
|
+
* into the rail. Light mode keeps the brand color for recognition.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as React from "react"
|
|
25
|
+
import { cn } from "@/lib/utils"
|
|
26
|
+
import type { ProductBrandConfig } from "@/lib/product-brand"
|
|
27
|
+
|
|
28
|
+
export type ProductWordmarkVariant = "default" | "mutedSuffix"
|
|
29
|
+
|
|
30
|
+
export interface ProductWordmarkProps {
|
|
31
|
+
config: ProductBrandConfig
|
|
32
|
+
variant?: ProductWordmarkVariant
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ── Wordmark ──────────────────────────────────────────────────────────────── */
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Inline product wordmark. Sizing is height-driven — the parent sets the
|
|
40
|
+
* height (e.g. `className="h-7"`) and the text scales via `text-[...]`
|
|
41
|
+
* derived from `--wordmark-size` (set inline from the rendered font-size).
|
|
42
|
+
*
|
|
43
|
+
* Use `aria-hidden` because the wordmark is decorative — pair it with an
|
|
44
|
+
* `aria-label` on the trigger/link (see {@link productBrandLabel}).
|
|
45
|
+
*/
|
|
46
|
+
export function ProductWordmark({
|
|
47
|
+
config,
|
|
48
|
+
variant = "default",
|
|
49
|
+
className,
|
|
50
|
+
}: ProductWordmarkProps) {
|
|
51
|
+
const prefix = config.prefix ?? "Exxat"
|
|
52
|
+
const { suffix, brandColor, wordmarkColor } = config
|
|
53
|
+
const mutedSuffix = variant === "mutedSuffix"
|
|
54
|
+
const suffixColor = wordmarkColor ?? brandColor
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<span
|
|
58
|
+
aria-hidden="true"
|
|
59
|
+
data-product-wordmark
|
|
60
|
+
data-product-id={config.id}
|
|
61
|
+
className={cn(
|
|
62
|
+
// Inline-flex so it sits on a text baseline; whitespace-nowrap so the
|
|
63
|
+
// suffix never wraps under the prefix at narrow widths.
|
|
64
|
+
"inline-flex items-baseline whitespace-nowrap leading-none select-none",
|
|
65
|
+
// Sized **relative to the inherited font-size** so the wordmark always
|
|
66
|
+
// dominates whatever surface hosts it. The parent (`ProductLogo` /
|
|
67
|
+
// `ExxatProductLogo`) pins `text-base` (16 px) → this resolves to
|
|
68
|
+
// ~28 px wordmark text (~20 px cap), matching the cap-to-render-height
|
|
69
|
+
// ratio in the standalone Exxat brand assets (~0.72; image dims 446×124
|
|
70
|
+
// with ~89 px caps). Slight (~1 px) overflow against a 28 px parent
|
|
71
|
+
// height is acceptable — sidebar / switcher slots use `overflow-visible`.
|
|
72
|
+
"text-[1.78em] tracking-tight",
|
|
73
|
+
// Vertically centre the **cap mid-line** on the parent's mid-line.
|
|
74
|
+
// Without this nudge the cap sits ~9 % of font-size above span centre
|
|
75
|
+
// because Inter / Ivy Presto baseline metrics put glyphs in the upper
|
|
76
|
+
// portion of the line box. 0.09 em moves the cap centre down by that
|
|
77
|
+
// exact offset so it shares an axis with the mark centre.
|
|
78
|
+
"translate-y-[0.09em]",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<span
|
|
83
|
+
className={cn(
|
|
84
|
+
"font-sans font-extrabold",
|
|
85
|
+
// Neutral wordmark prefix: deep slate on light, soft cool grey on dark.
|
|
86
|
+
"text-[#273441] dark:text-[#A8B2BA]",
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
{prefix}
|
|
90
|
+
</span>
|
|
91
|
+
<span
|
|
92
|
+
data-product-wordmark-suffix
|
|
93
|
+
className={cn(
|
|
94
|
+
// Per the official Exxat brand spec (Figma):
|
|
95
|
+
// font-family: IvyPresto Text
|
|
96
|
+
// weight: SemiBold (600) — NOT Bold / ExtraBold
|
|
97
|
+
// tracking: -3% — overrides parent `tracking-tight`
|
|
98
|
+
// line-height: auto — inherited (parent sets `leading-none`)
|
|
99
|
+
// IvyPresto's Bodoni-lineage SemiBold already has the thick verticals
|
|
100
|
+
// that read as a logo; pushing to 700/800 makes the letterforms
|
|
101
|
+
// visually heavier than the brand asset.
|
|
102
|
+
"ms-[0.18em] font-semibold tracking-[-0.03em]",
|
|
103
|
+
// mutedSuffix: dark mode recedes to muted; light mode keeps brand.
|
|
104
|
+
mutedSuffix && "dark:!text-[var(--muted-foreground)]",
|
|
105
|
+
)}
|
|
106
|
+
style={{
|
|
107
|
+
// Ivy Presto Text from Adobe Fonts. Upright (NOT italic) — matches
|
|
108
|
+
// the official Exxat wordmark. Fallback chain ends in `serif` so
|
|
109
|
+
// FOUT still renders a serif that reads as a logo rather than Inter.
|
|
110
|
+
fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
|
|
111
|
+
color: suffixColor,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{suffix}
|
|
115
|
+
</span>
|
|
116
|
+
</span>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ── Circular mark ─────────────────────────────────────────────────────────── */
|
|
121
|
+
|
|
122
|
+
export interface ProductMarkProps {
|
|
123
|
+
config: ProductBrandConfig
|
|
124
|
+
className?: string
|
|
125
|
+
cutoutColor?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Generate a stable id suffix for SVG gradient defs so multiple marks on the
|
|
130
|
+
* same page never collide. Strip colons because IDs in HTML/SVG can't legally
|
|
131
|
+
* include them (Radix uses `:`-style IDs by default).
|
|
132
|
+
*/
|
|
133
|
+
function useMarkGradientId(brandId: string) {
|
|
134
|
+
const raw = React.useId().replace(/:/g, "")
|
|
135
|
+
return `pmk-${brandId.replace(/[^a-z0-9-]/gi, "")}-${raw}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Defer SVG `<defs>` (gradient refs) until after mount so server HTML matches
|
|
140
|
+
* the first client paint. `useId()` returns different suffixes in SSR vs CSR
|
|
141
|
+
* trees that conditionally mount the sidebar.
|
|
142
|
+
*/
|
|
143
|
+
function useBrowserPaintReady() {
|
|
144
|
+
const [ready, setReady] = React.useState(false)
|
|
145
|
+
React.useLayoutEffect(() => {
|
|
146
|
+
setReady(true)
|
|
147
|
+
}, [])
|
|
148
|
+
return ready
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Recoloured Exxat "E" mark. Same geometry as the canonical brand mark, so
|
|
153
|
+
* existing pixel-aligned layouts (sidebar header, dropdown rows) don't shift.
|
|
154
|
+
*
|
|
155
|
+
* Fills:
|
|
156
|
+
* - Outer circle: `markGradient` if provided, else flat `brandColor`.
|
|
157
|
+
* - Inner shadow plate: `markShadow` (defaults to `brandColor`).
|
|
158
|
+
* - Cut-out "E" strokes: white by default; callers can override on tinted rails.
|
|
159
|
+
*/
|
|
160
|
+
export function ProductMark({ config, className, cutoutColor = "white" }: ProductMarkProps) {
|
|
161
|
+
const ready = useBrowserPaintReady()
|
|
162
|
+
const gradId = useMarkGradientId(config.id)
|
|
163
|
+
const [from, to] = config.markGradient ?? [config.brandColor, config.brandColor]
|
|
164
|
+
const shadow = config.markShadow ?? config.brandColor
|
|
165
|
+
|
|
166
|
+
// No size default. Callers MUST set explicit dimensions (`size-7`, `h-full
|
|
167
|
+
// w-auto`, etc.). A `size-*` default here loses to a downstream `h-full /
|
|
168
|
+
// w-auto` only when `tailwind-merge` correctly identifies `size-7` as a
|
|
169
|
+
// `w-7 + h-7` shorthand — fragile across versions and causes the mark to
|
|
170
|
+
// render at the default size instead of the parent's height (see
|
|
171
|
+
// `ExxatProductLogo` h-full mark → 32 px in h-8 parent). Aspect-square stays
|
|
172
|
+
// so the mark renders as a circle when only one of width/height is set.
|
|
173
|
+
const sharedClass = cn(
|
|
174
|
+
"box-border block aspect-square shrink-0 flex-none object-contain",
|
|
175
|
+
className,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if (!ready) {
|
|
179
|
+
return (
|
|
180
|
+
<svg
|
|
181
|
+
viewBox="0 8.25 147 147"
|
|
182
|
+
preserveAspectRatio="xMidYMid meet"
|
|
183
|
+
fill="none"
|
|
184
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
185
|
+
data-product-mark
|
|
186
|
+
data-product-logo-mark
|
|
187
|
+
data-product-id={config.id}
|
|
188
|
+
className={sharedClass}
|
|
189
|
+
aria-hidden="true"
|
|
190
|
+
suppressHydrationWarning
|
|
191
|
+
/>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<svg
|
|
197
|
+
viewBox="0 8.25 147 147"
|
|
198
|
+
preserveAspectRatio="xMidYMid meet"
|
|
199
|
+
fill="none"
|
|
200
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
201
|
+
data-product-mark
|
|
202
|
+
data-product-logo-mark
|
|
203
|
+
data-product-id={config.id}
|
|
204
|
+
className={sharedClass}
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
suppressHydrationWarning
|
|
207
|
+
>
|
|
208
|
+
<path
|
|
209
|
+
d="M73.4939 155.238C114.084 155.238 146.988 122.334 146.988 81.7439C146.988 41.1544 114.084 8.25 73.4939 8.25C32.9044 8.25 0 41.1544 0 81.7439C0 122.334 32.9044 155.238 73.4939 155.238Z"
|
|
210
|
+
fill={`url(#${gradId})`}
|
|
211
|
+
/>
|
|
212
|
+
<path
|
|
213
|
+
d="M0.594727 90.9915C4.59951 122.921 29.0894 148.466 60.4966 154.085L102.462 116.355V102.302H86.8312L102.462 88.2489V74.1957H86.8312L102.462 60.1425V46.0894H50.5575L0.594727 90.9915Z"
|
|
214
|
+
fill={shadow}
|
|
215
|
+
/>
|
|
216
|
+
<path d="M102.474 116.355H50.5576L58.6764 102.302H102.474V116.355Z" fill={cutoutColor} />
|
|
217
|
+
<path d="M102.474 60.1303H58.6764L50.5576 46.0771H102.474V60.1303Z" fill={cutoutColor} />
|
|
218
|
+
<path d="M102.474 88.2368H66.7949L70.8483 81.2102L66.7949 74.1836H102.474V88.2368Z" fill={cutoutColor} />
|
|
219
|
+
<path d="M39.2227 74.1835H66.795L58.6762 60.1304H39.2227V74.1835Z" fill={cutoutColor} />
|
|
220
|
+
<path d="M39.2227 102.302H58.6762L66.795 88.2368H39.2227V102.302Z" fill={cutoutColor} />
|
|
221
|
+
<defs>
|
|
222
|
+
<linearGradient
|
|
223
|
+
id={gradId}
|
|
224
|
+
x1="28.3733"
|
|
225
|
+
y1="134.255"
|
|
226
|
+
x2="117.195"
|
|
227
|
+
y2="30.9074"
|
|
228
|
+
gradientUnits="userSpaceOnUse"
|
|
229
|
+
>
|
|
230
|
+
<stop offset="0" stopColor={from} />
|
|
231
|
+
<stop offset="1" stopColor={to} />
|
|
232
|
+
</linearGradient>
|
|
233
|
+
</defs>
|
|
234
|
+
</svg>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ── Mark + wordmark combo ─────────────────────────────────────────────────── */
|
|
239
|
+
|
|
240
|
+
export interface ProductLogoProps {
|
|
241
|
+
config: ProductBrandConfig
|
|
242
|
+
variant?: ProductWordmarkVariant
|
|
243
|
+
/** Render only the mark (omit the wordmark). */
|
|
244
|
+
markOnly?: boolean
|
|
245
|
+
/** Render only the wordmark (omit the mark). */
|
|
246
|
+
wordmarkOnly?: boolean
|
|
247
|
+
className?: string
|
|
248
|
+
/** Class applied to the inner mark — useful for sizing it independently. */
|
|
249
|
+
markClassName?: string
|
|
250
|
+
/** Class applied to the inner wordmark. */
|
|
251
|
+
wordmarkClassName?: string
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Mark + wordmark composed inline. Pass `markOnly` for collapsed sidebar /
|
|
256
|
+
* favicon-like contexts, or `wordmarkOnly` if you've already rendered the
|
|
257
|
+
* mark separately (e.g. switcher dropdown rows).
|
|
258
|
+
*/
|
|
259
|
+
export function ProductLogo({
|
|
260
|
+
config,
|
|
261
|
+
variant = "default",
|
|
262
|
+
markOnly = false,
|
|
263
|
+
wordmarkOnly = false,
|
|
264
|
+
className,
|
|
265
|
+
markClassName,
|
|
266
|
+
wordmarkClassName,
|
|
267
|
+
}: ProductLogoProps) {
|
|
268
|
+
if (markOnly) {
|
|
269
|
+
return <ProductMark config={config} className={cn(className, markClassName)} />
|
|
270
|
+
}
|
|
271
|
+
if (wordmarkOnly) {
|
|
272
|
+
return <ProductWordmark config={config} variant={variant} className={cn(className, wordmarkClassName)} />
|
|
273
|
+
}
|
|
274
|
+
return (
|
|
275
|
+
<span
|
|
276
|
+
aria-hidden="true"
|
|
277
|
+
data-product-logo
|
|
278
|
+
data-product-id={config.id}
|
|
279
|
+
className={cn("inline-flex items-center gap-2", className)}
|
|
280
|
+
>
|
|
281
|
+
<ProductMark config={config} className={cn("size-7", markClassName)} />
|
|
282
|
+
<ProductWordmark config={config} variant={variant} className={wordmarkClassName} />
|
|
283
|
+
</span>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
@@ -21,6 +21,7 @@ import { QuestionBankTable, type QuestionBankTableHandle } from "@/components/qu
|
|
|
21
21
|
import { SecondaryPanelHubTemplate } from "@/components/templates/secondary-panel-hub-template"
|
|
22
22
|
import { QuestionBankAccessBridge, QuestionBankFolderBridge } from "@/components/secondary-panel"
|
|
23
23
|
import { KeyMetrics } from "@/components/key-metrics"
|
|
24
|
+
import { useSidebar } from "@/components/ui/sidebar"
|
|
24
25
|
import { useSecondaryPanelHubNav } from "@/hooks/use-secondary-panel-hub-nav"
|
|
25
26
|
import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
|
|
26
27
|
import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
|
|
@@ -188,6 +189,23 @@ export function QuestionBankClient() {
|
|
|
188
189
|
setHubFolderCustomizeSheetOpen(true)
|
|
189
190
|
}, [folders, navState.folderId, navState.scope])
|
|
190
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Open the full-page authoring composer (`/question-bank/new`).
|
|
194
|
+
* Pre-collapses the main sidebar (Placements pattern) so the user sees one
|
|
195
|
+
* smooth animation into the focused authoring flow. Folder scope, when
|
|
196
|
+
* present, is forwarded as `?folderId=` so the destination dropdown lands
|
|
197
|
+
* pre-selected on the right rail.
|
|
198
|
+
*/
|
|
199
|
+
const { setOpen: setMainSidebarOpen } = useSidebar()
|
|
200
|
+
const handleNewQuestion = React.useCallback(() => {
|
|
201
|
+
const folderQuery =
|
|
202
|
+
navState.scope === "folder" && navState.folderId
|
|
203
|
+
? `?folderId=${encodeURIComponent(navState.folderId)}`
|
|
204
|
+
: ""
|
|
205
|
+
setMainSidebarOpen(false)
|
|
206
|
+
window.setTimeout(() => router.push(`/question-bank/new${folderQuery}`), 260)
|
|
207
|
+
}, [navState.folderId, navState.scope, router, setMainSidebarOpen])
|
|
208
|
+
|
|
191
209
|
const filteredItems = React.useMemo(
|
|
192
210
|
() => applyQuestionBankHubDisplayFilters(items, folders, navState, landingFilters),
|
|
193
211
|
[items, folders, landingFilters, navState],
|
|
@@ -250,7 +268,7 @@ export function QuestionBankClient() {
|
|
|
250
268
|
title={dedicatedSearchTitle}
|
|
251
269
|
questionCount={count}
|
|
252
270
|
hideNewQuestion
|
|
253
|
-
onNewQuestion={
|
|
271
|
+
onNewQuestion={handleNewQuestion}
|
|
254
272
|
onExport={() => setExportOpen(true)}
|
|
255
273
|
/>
|
|
256
274
|
<DedicatedSearchUrlComposer
|
|
@@ -378,7 +396,7 @@ export function QuestionBankClient() {
|
|
|
378
396
|
title={hubHeader.title}
|
|
379
397
|
questionCount={count}
|
|
380
398
|
collaborators={collaborators}
|
|
381
|
-
onNewQuestion={
|
|
399
|
+
onNewQuestion={handleNewQuestion}
|
|
382
400
|
onExport={() => setExportOpen(true)}
|
|
383
401
|
onAddCollaborator={openInvite}
|
|
384
402
|
onCollaboratorsOpen={openInvite}
|